mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-21 21:09:49 +08:00
## Stack Auth → Hexclave rename — PR 5 (internal symbols, paths,
packages, brand strings)
PR 5 finishes the **internal / non-wire** half of the Stack→Hexclave
rename. It only touches things where nothing outside the repo depends on
the exact name: internal symbols, file/dir names, the
`@stackframe/template` package, and residual brand strings. Plan +
progress are in `HEXCLAVE-RENAME-PR5-PLAN.md`.
Every step was verified green (`pnpm typecheck` + `pnpm lint`, 28/28)
and committed as its own checkpoint, then a fan-out of review agents
audited all commits and the findings were fixed.
### What changed
- **Internal symbols** (`@hexclave/shared`, `packages/template`, apps):
`stack*`/`Stack*` → `hexclave*`/`Hexclave*` — incl.
`stackGlobalsSymbol`, the `_Stack*AppImpl` classes,
`stackAppInternalsSymbol`, `StackContext`, `getStackStripe`, etc. The
`stack*App` local-variable convention
(`stackServerApp`/`stackClientApp`/…) was renamed across 175
source/example/doc files.
- **File renames**: `hexclave-handler/provider/context.tsx`,
`backend/hexclave.tsx`, `internal-tool/hexclave.ts`,
`hexclave-app-internals.ts`.
- **Directory renames**: `lib/hexclave-app`, `hexclave-companion`,
`[...hexclave]` route segment, `skills/hexclave`,
`dashboard/src/hexclave`, and the package dirs
**`packages/{next,shared,ui,sc,cli}`** (dropping the `stack-` prefix to
match the `@hexclave/*` npm names).
- **Packages**: `@stackframe/template` → `@hexclave/template`; **deleted
`packages/init-stack`** (onboarding lives in `@hexclave/cli init`; the
published npm package is untouched).
- **Brand strings**: reworded `Stack Auth`/`Stack dashboard` prose in
code + docs-mintlify, renamed `hexclave-app.mdx`/`use-hexclave-app.mdx`
with redirects, regenerated OpenAPI, updated coupled e2e assertions;
`doctor`/`init` now prefer `hexclave.config.ts`.
### Intentionally kept (verified, not oversights)
Wire/compat identifiers (`x-stack-*` headers, `stack-*` cookies,
`STACK_*` env names, `*.stack-auth.com`, `stackauth_`, `ask_stack_auth`,
query params), public `Stack*` SDK aliases, crypto/JWT/vault
domain-separation tags, `*-brand-sentinel`s, the
`Symbol.for("StackAuth--…")` string, `_stack_sync_metadata`, Postgres
`stackframe` / docker image names, the `stack-auth-logo*.svg` (used by
the rebrand modal), and `migration.mdx` / "formerly known as Stack Auth"
notes. False positives (Phosphor `StackIcon`/`StackSimple`, `TanStack`,
`OrbStack`, `stackable`/`Stacked` charts) left alone.
### Review pass
Six review agents audited all commits. Found + fixed one real bug — a
build script (`bundle-type-definitions.ts`) hardcoded the old
`lib/stack-app` glob path (not an import, so typecheck/lint were blind),
silently emptying the dashboard AI type bundle — plus stale comments, a
dead CI env var, and stale `.gitignore`/`.dockerignore` entries.
Cross-cutting audit confirmed **zero wire-compat identifiers were
accidentally renamed**.
### ⚠️ Verification note
`typecheck` + `lint` are fully green locally. The **e2e suite was not
run** (needs a live backend+DB), so the brand-string assertion +
OpenAPI-regen changes are verified by grep/codegen only — please let CI
exercise e2e to confirm.
### Base-branch note
This branch was forked from the local-only `cl/friendly-lewin-72293f`
(not on origin, no separate PR), so this PR against `dev` also carries
that branch's ~11 preceding Hexclave-rename commits (config-file rename,
env-var dual-read, AI setup-prompt rebrand). If those should land
separately, re-parent before merge.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Finishes the internal Stack Auth → Hexclave rename and cleans up
remaining stragglers, including dev-tool and prompt copy. All changes
are internal-only; public/wire APIs remain unchanged. Re-merged `dev`
and resolved the payments create-purchase-url conflict.
- **Refactors**
- Internal symbols: stack*/Stack* → hexclave*/Hexclave* (e.g.,
`getHexclaveServerApp` via `@/hexclave`, `getHexclaveStripe`,
`hexclaveAppInternalsSymbol`, `hexclaveSchemaInfo`, Prisma
`__hexclave_*`, `data-hexclave-handler-page`, Stripe mock
`hexclavePortPrefix`).
- Files/dirs: moved to `lib/hexclave-app`; handler route
`[...hexclave]`; backend entry `src/hexclave.tsx`; dashboard internals
`hexclave-app-internals`; companion `hexclave-companion`; dropped
`stack-` prefix across package dirs
(`packages/{shared,ui,sc,cli,next}`); workflows/emulator paths now
`packages/cli`; Quetzal codegen env at `packages/next/.env.local`.
- Packages/docs: `@stackframe/template` → `@hexclave/template`; removed
`packages/init-stack`; regenerated OpenAPI and updated docs
slugs/redirects for hexclave-app/use-hexclave-app.
- Brand strings/prompts: reworded remaining “Stack” dashboard strings to
Hexclave; updated dev-tool copy and prompts; `doctor/init` now prefer
`hexclave.config.ts`. Kept all wire-compat identifiers and public
aliases (`x-stack-*`, `stack-*` cookies, `STACK_*` env,
`*.stack-auth.com`, `Stack*` SDK names).
- Rebased/merged onto latest `dev`: retained `@hexclave/template`, kept
`src` in published files, refreshed setup-prompt imports and docs JSON,
adopted 1.0.5 version bumps, and re-merged `dev` again (resolved
`create-purchase-url` with `getHexclaveStripe`).
- **Bug Fixes**
- Restored dashboard AI type bundle by pointing the glob to
`packages/template/src/lib/hexclave-app`.
- Addressed rename leftovers: updated lingering `@/stack` imports and
CSS selector, fixed schema/meta and port-prefix expansions, and aligned
emulator commands to `packages/cli`.
- CI/build: removed a dead env var and stale ignore entries; fixed
Docker by renaming `STACK_SKIP_TEMPLATE_GENERATION` →
`HEXCLAVE_SKIP_TEMPLATE_GENERATION`.
<sup>Written for commit 3c1af3bff3.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1547?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
1147 lines
33 KiB
TypeScript
1147 lines
33 KiB
TypeScript
import * as yup from "yup";
|
|
import { decryptValue, encryptValue, hashKey } from "../helpers/vault/client-side";
|
|
import { KnownErrors } from "../known-errors";
|
|
import { inlineProductSchema } from "../schema-fields";
|
|
import { AccessToken, InternalSession, RefreshToken } from "../sessions";
|
|
import { HexclaveAssertionError } from "../utils/errors";
|
|
import { filterUndefined } from "../utils/objects";
|
|
import { Result } from "../utils/results";
|
|
import { urlString } from "../utils/urls";
|
|
import {
|
|
ClientInterfaceOptions,
|
|
HexclaveClientInterface
|
|
} from "./client-interface";
|
|
import { ConnectedAccountAccessTokenCrud, ConnectedAccountCrud } from "./crud/connected-accounts";
|
|
import { ContactChannelsCrud } from "./crud/contact-channels";
|
|
import { CurrentUserCrud } from "./crud/current-user";
|
|
import { ItemCrud } from "./crud/items";
|
|
import { NotificationPreferenceCrud } from "./crud/notification-preferences";
|
|
import { OAuthProviderCrud } from "./crud/oauth-providers";
|
|
import { ProjectPermissionsCrud } from "./crud/project-permissions";
|
|
import { SessionsCrud } from "./crud/sessions";
|
|
import { TeamInvitationCrud } from "./crud/team-invitation";
|
|
import { TeamMemberProfilesCrud } from "./crud/team-member-profiles";
|
|
import { TeamMembershipsCrud } from "./crud/team-memberships";
|
|
import { TeamPermissionsCrud } from "./crud/team-permissions";
|
|
import { TeamsCrud } from "./crud/teams";
|
|
import { UsersCrud } from "./crud/users";
|
|
|
|
export type ServerAuthApplicationOptions = (
|
|
& ClientInterfaceOptions
|
|
& (
|
|
| {
|
|
readonly secretServerKey: string,
|
|
}
|
|
| {
|
|
readonly projectOwnerSession: InternalSession | (() => Promise<string | null>),
|
|
}
|
|
)
|
|
);
|
|
|
|
export class HexclaveServerInterface extends HexclaveClientInterface {
|
|
constructor(public override options: ServerAuthApplicationOptions) {
|
|
super(options);
|
|
}
|
|
|
|
protected async sendServerRequest(path: string, options: RequestInit, session: InternalSession | null, requestType: "server" | "admin" = "server") {
|
|
return await this.sendClientRequest(
|
|
path,
|
|
{
|
|
...options,
|
|
headers: {
|
|
// Hexclave rebrand: emit x-hexclave-* request header; the backend proxy dual-accepts both names.
|
|
"x-hexclave-secret-server-key": "secretServerKey" in this.options ? this.options.secretServerKey : "",
|
|
...options.headers,
|
|
},
|
|
},
|
|
session,
|
|
requestType,
|
|
);
|
|
}
|
|
|
|
override async getCustomerBilling(
|
|
customerType: "user" | "team",
|
|
customerId: string,
|
|
session: InternalSession | null,
|
|
): Promise<{
|
|
has_customer: boolean,
|
|
default_payment_method: {
|
|
id: string,
|
|
brand: string | null,
|
|
last4: string | null,
|
|
exp_month: number | null,
|
|
exp_year: number | null,
|
|
} | null,
|
|
}> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/payments/billing/${customerType}/${customerId}`,
|
|
{},
|
|
session,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
override async createCustomerPaymentMethodSetupIntent(
|
|
customerType: "user" | "team",
|
|
customerId: string,
|
|
session: InternalSession | null,
|
|
): Promise<{
|
|
client_secret: string,
|
|
stripe_account_id: string,
|
|
}> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/payments/payment-method/${customerType}/${customerId}/setup-intent`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
session,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
override async setDefaultCustomerPaymentMethodFromSetupIntent(
|
|
customerType: "user" | "team",
|
|
customerId: string,
|
|
setupIntentId: string,
|
|
session: InternalSession | null,
|
|
): Promise<{
|
|
default_payment_method: {
|
|
id: string,
|
|
brand: string | null,
|
|
last4: string | null,
|
|
exp_month: number | null,
|
|
exp_year: number | null,
|
|
},
|
|
}> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/payments/payment-method/${customerType}/${customerId}/set-default`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
setup_intent_id: setupIntentId,
|
|
}),
|
|
},
|
|
session,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
|
|
protected async sendServerRequestAndCatchKnownError<E extends typeof KnownErrors[keyof KnownErrors]>(
|
|
path: string,
|
|
requestOptions: RequestInit,
|
|
tokenStoreOrNull: InternalSession | null,
|
|
errorsToCatch: readonly E[],
|
|
): Promise<Result<
|
|
Response & {
|
|
usedTokens: {
|
|
accessToken: AccessToken,
|
|
refreshToken: RefreshToken | null,
|
|
} | null,
|
|
},
|
|
InstanceType<E>
|
|
>> {
|
|
try {
|
|
return Result.ok(await this.sendServerRequest(path, requestOptions, tokenStoreOrNull));
|
|
} catch (e) {
|
|
for (const errorType of errorsToCatch) {
|
|
if (errorType.isInstance(e)) {
|
|
return Result.error(e as InstanceType<E>);
|
|
}
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async createServerUser(data: UsersCrud['Server']['Create']): Promise<UsersCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
"/users",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(data),
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async getServerUserByToken(session: InternalSession): Promise<CurrentUserCrud['Server']['Read'] | null> {
|
|
const responseOrError = await this.sendServerRequestAndCatchKnownError(
|
|
"/users/me",
|
|
{},
|
|
session,
|
|
[KnownErrors.CannotGetOwnUserWithoutUser],
|
|
);
|
|
if (responseOrError.status === "error") {
|
|
if (KnownErrors.CannotGetOwnUserWithoutUser.isInstance(responseOrError.error)) {
|
|
return null;
|
|
} else {
|
|
throw new HexclaveAssertionError("Unexpected uncaught error", { cause: responseOrError.error });
|
|
}
|
|
}
|
|
const response = responseOrError.data;
|
|
const user: CurrentUserCrud['Server']['Read'] = await response.json();
|
|
if (!(user as any)) throw new HexclaveAssertionError("User endpoint returned null; this should never happen");
|
|
return user;
|
|
}
|
|
|
|
async getServerUserById(userId: string): Promise<Result<UsersCrud['Server']['Read']>> {
|
|
const responseOrError = await this.sendServerRequestAndCatchKnownError(
|
|
urlString`/users/${userId}`,
|
|
{},
|
|
null,
|
|
[KnownErrors.UserNotFound],
|
|
);
|
|
if (responseOrError.status === "error") {
|
|
return Result.error(responseOrError.error);
|
|
}
|
|
const user: UsersCrud['Server']['Read'] = await responseOrError.data.json();
|
|
return Result.ok(user);
|
|
}
|
|
|
|
async listServerTeamInvitations(options: {
|
|
teamId: string,
|
|
}): Promise<TeamInvitationCrud['Server']['Read'][]> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/team-invitations?team_id=${options.teamId}`,
|
|
{},
|
|
null,
|
|
);
|
|
const result = await response.json() as TeamInvitationCrud['Server']['List'];
|
|
return result.items;
|
|
}
|
|
|
|
async revokeServerTeamInvitation(invitationId: string, teamId: string) {
|
|
await this.sendServerRequest(
|
|
urlString`/team-invitations/${invitationId}?team_id=${teamId}`,
|
|
{ method: "DELETE" },
|
|
null,
|
|
);
|
|
}
|
|
|
|
async listServerTeamMemberProfiles(
|
|
options: {
|
|
teamId: string,
|
|
},
|
|
): Promise<TeamMemberProfilesCrud['Server']['Read'][]> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/team-member-profiles?team_id=${options.teamId}`,
|
|
{},
|
|
null,
|
|
);
|
|
const result = await response.json() as TeamMemberProfilesCrud['Server']['List'];
|
|
return result.items;
|
|
}
|
|
|
|
async getServerTeamMemberProfile(
|
|
options: {
|
|
teamId: string,
|
|
userId: string,
|
|
},
|
|
): Promise<TeamMemberProfilesCrud['Client']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/team-member-profiles/${options.teamId}/${options.userId}`,
|
|
{},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async listServerTeamPermissions(
|
|
options: {
|
|
userId?: string,
|
|
teamId?: string,
|
|
recursive: boolean,
|
|
},
|
|
session: InternalSession | null,
|
|
): Promise<TeamPermissionsCrud['Server']['Read'][]> {
|
|
const response = await this.sendServerRequest(
|
|
`/team-permissions?${new URLSearchParams(filterUndefined({
|
|
user_id: options.userId,
|
|
team_id: options.teamId,
|
|
recursive: options.recursive.toString(),
|
|
}))}`,
|
|
{},
|
|
session,
|
|
);
|
|
const result = await response.json() as TeamPermissionsCrud['Server']['List'];
|
|
return result.items;
|
|
}
|
|
|
|
async listServerProjectPermissions(
|
|
options: {
|
|
userId?: string,
|
|
recursive: boolean,
|
|
},
|
|
session: InternalSession | null,
|
|
): Promise<ProjectPermissionsCrud['Server']['Read'][]> {
|
|
const response = await this.sendServerRequest(
|
|
`/project-permissions?${new URLSearchParams(filterUndefined({
|
|
user_id: options.userId,
|
|
recursive: options.recursive.toString(),
|
|
}))}`,
|
|
{},
|
|
session,
|
|
);
|
|
const result = await response.json() as ProjectPermissionsCrud['Server']['List'];
|
|
return result.items;
|
|
}
|
|
|
|
async listServerUsers(options: (
|
|
& {
|
|
cursor?: string,
|
|
limit?: number,
|
|
orderBy?: 'signedUpAt' | 'lastActiveAt',
|
|
desc?: boolean,
|
|
query?: string,
|
|
includeRestricted?: boolean,
|
|
teamId?: string,
|
|
}
|
|
& (
|
|
{
|
|
includeAnonymous?: boolean,
|
|
onlyAnonymous?: false,
|
|
}
|
|
| {
|
|
includeAnonymous: true,
|
|
onlyAnonymous: true,
|
|
}
|
|
)
|
|
)): Promise<UsersCrud['Server']['List']> {
|
|
const searchParams = new URLSearchParams(filterUndefined({
|
|
cursor: options.cursor,
|
|
limit: options.limit?.toString(),
|
|
desc: options.desc?.toString(),
|
|
team_id: options.teamId,
|
|
...options.orderBy ? {
|
|
order_by: {
|
|
signedUpAt: "signed_up_at",
|
|
lastActiveAt: "last_active_at",
|
|
}[options.orderBy],
|
|
} : {},
|
|
...options.query ? {
|
|
query: options.query,
|
|
} : {},
|
|
...options.includeRestricted ? {
|
|
include_restricted: 'true',
|
|
} : {},
|
|
...options.includeAnonymous ? {
|
|
include_anonymous: 'true',
|
|
} : {},
|
|
...options.onlyAnonymous ? {
|
|
only_anonymous: 'true',
|
|
} : {},
|
|
}));
|
|
const response = await this.sendServerRequest("/users?" + searchParams.toString(), {}, null);
|
|
return await response.json();
|
|
}
|
|
|
|
async listServerTeams(options?: {
|
|
userId?: string,
|
|
}): Promise<TeamsCrud['Server']['Read'][]> {
|
|
const result = await this.listServerTeamsPaginated(options);
|
|
return result.items;
|
|
}
|
|
|
|
async listServerTeamsPaginated(options?: {
|
|
userId?: string,
|
|
orderBy?: 'createdAt',
|
|
desc?: boolean,
|
|
cursor?: string,
|
|
limit?: number,
|
|
query?: string,
|
|
}): Promise<TeamsCrud['Server']['List']> {
|
|
const response = await this.sendServerRequest(
|
|
`/teams?${new URLSearchParams(filterUndefined({
|
|
user_id: options?.userId,
|
|
// SDK option uses camelCase `createdAt`; backend uses snake_case.
|
|
order_by: options?.orderBy === 'createdAt' ? 'created_at' : options?.orderBy,
|
|
desc: options?.desc !== undefined ? String(options.desc) : undefined,
|
|
cursor: options?.cursor,
|
|
limit: options?.limit?.toString(),
|
|
query: options?.query,
|
|
}))}`,
|
|
{},
|
|
null
|
|
);
|
|
return await response.json() as TeamsCrud['Server']['List'];
|
|
}
|
|
|
|
async getServerTeam(teamId: string): Promise<TeamsCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
`/teams/${teamId}`,
|
|
{},
|
|
null
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async listServerTeamUsers(teamId: string): Promise<UsersCrud['Server']['Read'][]> {
|
|
const response = await this.sendServerRequest(`/users?team_id=${teamId}`, {}, null);
|
|
const result = await response.json() as UsersCrud['Server']['List'];
|
|
return result.items;
|
|
}
|
|
|
|
/* when passing a session, the user will be added to the team */
|
|
async createServerTeam(data: TeamsCrud['Server']['Create']): Promise<TeamsCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
"/teams",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(data),
|
|
},
|
|
null
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async updateServerTeam(teamId: string, data: TeamsCrud['Server']['Update']): Promise<TeamsCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/teams/${teamId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(data),
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async deleteServerTeam(teamId: string): Promise<void> {
|
|
await this.sendServerRequest(
|
|
urlString`/teams/${teamId}`,
|
|
{ method: "DELETE" },
|
|
null,
|
|
);
|
|
}
|
|
|
|
async addServerUserToTeam(options: {
|
|
userId: string,
|
|
teamId: string,
|
|
}): Promise<TeamMembershipsCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/team-memberships/${options.teamId}/${options.userId}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async removeServerUserFromTeam(options: {
|
|
userId: string,
|
|
teamId: string,
|
|
}) {
|
|
await this.sendServerRequest(
|
|
urlString`/team-memberships/${options.teamId}/${options.userId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async listServerUserTeamInvitations(userId: string): Promise<TeamInvitationCrud['Server']['Read'][]> {
|
|
const response = await this.sendServerRequest(
|
|
"/team-invitations?" + new URLSearchParams({ user_id: userId }),
|
|
{},
|
|
null,
|
|
);
|
|
const result = await response.json() as TeamInvitationCrud['Server']['List'];
|
|
return result.items;
|
|
}
|
|
|
|
async acceptServerTeamInvitationById(
|
|
invitationId: string,
|
|
userId: string,
|
|
) {
|
|
await this.sendServerRequest(
|
|
urlString`/team-invitations/${invitationId}/accept` + "?" + new URLSearchParams({ user_id: userId }),
|
|
{ method: "POST" },
|
|
null,
|
|
);
|
|
}
|
|
|
|
async updateServerUser(userId: string, update: UsersCrud['Server']['Update']): Promise<UsersCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/users/${userId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(update),
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async createServerProviderAccessToken(
|
|
userId: string,
|
|
provider: string,
|
|
scope: string,
|
|
): Promise<ConnectedAccountAccessTokenCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/connected-accounts/${userId}/${provider}/access-token`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ scope }),
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Get access token for a specific connected account by provider ID and provider account ID.
|
|
* This is the preferred method when dealing with multiple accounts of the same provider.
|
|
*/
|
|
async createServerProviderAccessTokenByAccount(
|
|
userId: string,
|
|
providerId: string,
|
|
providerAccountId: string,
|
|
scope: string,
|
|
): Promise<ConnectedAccountAccessTokenCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/connected-accounts/${userId}/${providerId}/${providerAccountId}/access-token`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ scope }),
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* List all connected accounts for a user.
|
|
*/
|
|
async listServerConnectedAccounts(
|
|
userId: string,
|
|
): Promise<ConnectedAccountCrud['Server']['List']> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/connected-accounts/${userId}`,
|
|
{ method: "GET" },
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async createServerUserSession(userId: string, expiresInMillis: number, isImpersonation: boolean): Promise<{ accessToken: string, refreshToken: string }> {
|
|
const response = await this.sendServerRequest(
|
|
"/auth/sessions",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
user_id: userId,
|
|
expires_in_millis: expiresInMillis,
|
|
is_impersonation: isImpersonation,
|
|
}),
|
|
},
|
|
null,
|
|
);
|
|
const result = await response.json();
|
|
return {
|
|
accessToken: result.access_token,
|
|
refreshToken: result.refresh_token,
|
|
};
|
|
}
|
|
|
|
async leaveServerTeam(
|
|
options: {
|
|
teamId: string,
|
|
userId: string,
|
|
},
|
|
) {
|
|
await this.sendClientRequest(
|
|
urlString`/team-memberships/${options.teamId}/${options.userId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async updateServerTeamMemberProfile(options: {
|
|
teamId: string,
|
|
userId: string,
|
|
profile: TeamMemberProfilesCrud['Server']['Update'],
|
|
}) {
|
|
await this.sendServerRequest(
|
|
urlString`/team-member-profiles/${options.teamId}/${options.userId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(options.profile),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async grantServerTeamUserPermission(teamId: string, userId: string, permissionId: string) {
|
|
await this.sendServerRequest(
|
|
urlString`/team-permissions/${teamId}/${userId}/${permissionId}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async grantServerProjectPermission(userId: string, permissionId: string) {
|
|
await this.sendServerRequest(
|
|
urlString`/project-permissions/${userId}/${permissionId}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async revokeServerTeamUserPermission(teamId: string, userId: string, permissionId: string) {
|
|
await this.sendServerRequest(
|
|
urlString`/team-permissions/${teamId}/${userId}/${permissionId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async revokeServerProjectPermission(userId: string, permissionId: string) {
|
|
await this.sendServerRequest(
|
|
urlString`/project-permissions/${userId}/${permissionId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async deleteServerUser(userId: string) {
|
|
await this.sendServerRequest(
|
|
urlString`/users/${userId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async createServerContactChannel(
|
|
data: ContactChannelsCrud['Server']['Create'],
|
|
): Promise<ContactChannelsCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
"/contact-channels",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(data),
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async updateServerContactChannel(
|
|
userId: string,
|
|
contactChannelId: string,
|
|
data: ContactChannelsCrud['Server']['Update'],
|
|
): Promise<ContactChannelsCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/contact-channels/${userId}/${contactChannelId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(data),
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async deleteServerContactChannel(
|
|
userId: string,
|
|
contactChannelId: string,
|
|
): Promise<void> {
|
|
await this.sendServerRequest(
|
|
urlString`/contact-channels/${userId}/${contactChannelId}`,
|
|
{
|
|
method: "DELETE",
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async listServerContactChannels(
|
|
userId: string,
|
|
): Promise<ContactChannelsCrud['Server']['Read'][]> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/contact-channels?user_id=${userId}`,
|
|
{
|
|
method: "GET",
|
|
},
|
|
null,
|
|
);
|
|
const json = await response.json() as ContactChannelsCrud['Server']['List'];
|
|
return json.items;
|
|
}
|
|
|
|
async listServerNotificationCategories(
|
|
userId: string,
|
|
): Promise<NotificationPreferenceCrud['Server']['Read'][]> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/emails/notification-preference/${userId}`,
|
|
{
|
|
method: "GET",
|
|
},
|
|
null,
|
|
);
|
|
const json = await response.json() as NotificationPreferenceCrud['Server']['List'];
|
|
return json.items;
|
|
}
|
|
|
|
async setServerNotificationsEnabled(
|
|
userId: string,
|
|
notificationCategoryId: string,
|
|
enabled: boolean,
|
|
): Promise<void> {
|
|
await this.sendServerRequest(
|
|
urlString`/emails/notification-preference/${userId}/${notificationCategoryId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
enabled,
|
|
}),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async sendServerContactChannelVerificationEmail(
|
|
userId: string,
|
|
contactChannelId: string,
|
|
callbackUrl: string,
|
|
): Promise<void> {
|
|
await this.sendServerRequest(
|
|
urlString`/contact-channels/${userId}/${contactChannelId}/send-verification-code`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ callback_url: callbackUrl }),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
|
|
async listServerSessions(userId: string): Promise<SessionsCrud['Server']['List']> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/auth/sessions?user_id=${userId}`,
|
|
{
|
|
method: "GET",
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async deleteServerSession(sessionId: string) {
|
|
await this.sendServerRequest(
|
|
urlString`/auth/sessions/${sessionId}`,
|
|
{
|
|
method: "DELETE",
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
|
|
async sendServerTeamInvitation(options: {
|
|
email: string,
|
|
teamId: string,
|
|
callbackUrl: string,
|
|
}): Promise<void> {
|
|
await this.sendServerRequest(
|
|
"/team-invitations/send-code",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
email: options.email,
|
|
team_id: options.teamId,
|
|
callback_url: options.callbackUrl,
|
|
}),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async updatePassword(
|
|
options: { oldPassword: string, newPassword: string },
|
|
): Promise<KnownErrors["PasswordConfirmationMismatch"] | KnownErrors["PasswordRequirementsNotMet"] | undefined> {
|
|
const res = await this.sendServerRequestAndCatchKnownError(
|
|
"/auth/password/update",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
old_password: options.oldPassword,
|
|
new_password: options.newPassword,
|
|
}),
|
|
},
|
|
null,
|
|
[KnownErrors.PasswordConfirmationMismatch, KnownErrors.PasswordRequirementsNotMet]
|
|
);
|
|
|
|
if (res.status === "error") {
|
|
return res.error;
|
|
}
|
|
}
|
|
|
|
// OAuth Providers CRUD operations
|
|
async createServerOAuthProvider(
|
|
data: OAuthProviderCrud['Server']['Create'],
|
|
): Promise<OAuthProviderCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
"/oauth-providers",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(data),
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
|
|
async listServerOAuthProviders(
|
|
options: {
|
|
user_id?: string,
|
|
} = {},
|
|
): Promise<OAuthProviderCrud['Server']['Read'][]> {
|
|
const queryParams = new URLSearchParams(filterUndefined(options));
|
|
const response = await this.sendServerRequest(
|
|
`/oauth-providers${queryParams.toString() ? `?${queryParams.toString()}` : ''}`,
|
|
{
|
|
method: "GET",
|
|
},
|
|
null,
|
|
);
|
|
const result = await response.json();
|
|
return result.items;
|
|
}
|
|
|
|
async updateServerOAuthProvider(
|
|
userId: string,
|
|
providerId: string,
|
|
data: OAuthProviderCrud['Server']['Update'],
|
|
): Promise<OAuthProviderCrud['Server']['Read']> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/oauth-providers/${userId}/${providerId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(data),
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async deleteServerOAuthProvider(
|
|
userId: string,
|
|
providerId: string,
|
|
): Promise<void> {
|
|
const response = await this.sendServerRequest(
|
|
urlString`/oauth-providers/${userId}/${providerId}`,
|
|
{
|
|
method: "DELETE",
|
|
},
|
|
null,
|
|
);
|
|
return await response.json();
|
|
}
|
|
|
|
async sendEmail(options: {
|
|
userIds?: string[],
|
|
allUsers?: true,
|
|
themeId?: string | null | false,
|
|
html?: string,
|
|
subject?: string,
|
|
notificationCategoryName?: string,
|
|
templateId?: string,
|
|
variables?: Record<string, any>,
|
|
draftId?: string,
|
|
scheduledAt?: Date,
|
|
}): Promise<Result<void, KnownErrors["RequiresCustomEmailServer"] | KnownErrors["SchemaError"] | KnownErrors["UserIdDoesNotExist"]>> {
|
|
const res = await this.sendServerRequest(
|
|
"/emails/send-email",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
user_ids: options.userIds,
|
|
all_users: options.allUsers,
|
|
theme_id: options.themeId,
|
|
html: options.html,
|
|
subject: options.subject,
|
|
notification_category_name: options.notificationCategoryName,
|
|
template_id: options.templateId,
|
|
variables: options.variables,
|
|
draft_id: options.draftId,
|
|
scheduled_at_millis: options.scheduledAt?.getTime(),
|
|
}),
|
|
},
|
|
null,
|
|
);
|
|
return Result.ok(undefined);
|
|
}
|
|
|
|
async getEmailDeliveryInfo(): Promise<{
|
|
stats: {
|
|
hour: { sent: number, bounced: number, marked_as_spam: number },
|
|
day: { sent: number, bounced: number, marked_as_spam: number },
|
|
week: { sent: number, bounced: number, marked_as_spam: number },
|
|
month: { sent: number, bounced: number, marked_as_spam: number },
|
|
},
|
|
capacity: {
|
|
rate_per_second: number,
|
|
boost_multiplier: number,
|
|
penalty_factor: number,
|
|
is_boost_active: boolean,
|
|
boost_expires_at: string | null,
|
|
},
|
|
}> {
|
|
const res = await this.sendServerRequest(
|
|
"/emails/delivery-info",
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
},
|
|
null,
|
|
);
|
|
return await res.json();
|
|
}
|
|
|
|
async activateEmailCapacityBoost(): Promise<{ expires_at: string }> {
|
|
const res = await this.sendServerRequest(
|
|
"/emails/capacity-boost",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
null,
|
|
);
|
|
return await res.json();
|
|
}
|
|
|
|
async updateItemQuantity(
|
|
options: (
|
|
{ itemId: string, userId: string } |
|
|
{ itemId: string, teamId: string } |
|
|
{ itemId: string, customCustomerId: string }
|
|
),
|
|
data: ItemCrud['Server']['Update'],
|
|
): Promise<void> {
|
|
let customerType: "user" | "team" | "custom";
|
|
let customerId: string;
|
|
const itemId: string = options.itemId;
|
|
|
|
if ("userId" in options) {
|
|
customerType = "user";
|
|
customerId = options.userId;
|
|
} else if ("teamId" in options) {
|
|
customerType = "team";
|
|
customerId = options.teamId;
|
|
} else if ("customCustomerId" in options) {
|
|
customerType = "custom";
|
|
customerId = options.customCustomerId;
|
|
} else {
|
|
throw new HexclaveAssertionError("updateItemQuantity requires one of userId, teamId, or customCustomerId");
|
|
}
|
|
|
|
const queryParams = new URLSearchParams({ allow_negative: (data.allow_negative ?? false).toString() });
|
|
await this.sendServerRequest(
|
|
`/payments/items/${customerType}/${customerId}/${itemId}/update-quantity?${queryParams.toString()}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ delta: data.delta, expires_at: data.expires_at, description: data.description }),
|
|
},
|
|
null
|
|
);
|
|
}
|
|
|
|
async grantProduct(
|
|
options: {
|
|
customerType: "user" | "team" | "custom",
|
|
customerId: string,
|
|
productId?: string,
|
|
product?: yup.InferType<typeof inlineProductSchema>,
|
|
quantity?: number,
|
|
},
|
|
): Promise<void> {
|
|
if (!options.productId && !options.product) {
|
|
throw new HexclaveAssertionError("grantProduct requires either productId or product");
|
|
}
|
|
if (options.productId && options.product) {
|
|
throw new HexclaveAssertionError("grantProduct should not receive both productId and product");
|
|
}
|
|
const body = filterUndefined({
|
|
product_id: options.productId,
|
|
product_inline: options.product,
|
|
quantity: options.quantity,
|
|
});
|
|
await this.sendServerRequest(
|
|
urlString`/payments/products/${options.customerType}/${options.customerId}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async getDataVaultStoreValue(secret: string, storeId: string, key: string) {
|
|
const hashedKey = await hashKey(secret, key);
|
|
const response = await this.sendServerRequestAndCatchKnownError(
|
|
`/data-vault/stores/${storeId}/get`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ hashed_key: hashedKey }),
|
|
},
|
|
null,
|
|
[KnownErrors.DataVaultStoreHashedKeyDoesNotExist] as const,
|
|
);
|
|
if (response.status === "error") {
|
|
if (KnownErrors.DataVaultStoreHashedKeyDoesNotExist.isInstance(response.error)) {
|
|
return null;
|
|
} else {
|
|
throw new HexclaveAssertionError("Unexpected uncaught error", { cause: response.error });
|
|
}
|
|
}
|
|
const json = await response.data.json();
|
|
const encryptedValue = json.encrypted_value;
|
|
if (typeof encryptedValue !== "string") throw new HexclaveAssertionError("encrypted_value is not a string", { type: typeof encryptedValue });
|
|
return await decryptValue(secret, key, encryptedValue);
|
|
}
|
|
|
|
async setDataVaultStoreValue(secret: string, storeId: string, key: string, value: string) {
|
|
const hashedKey = await hashKey(secret, key);
|
|
const encryptedValue = await encryptValue(secret, key, value);
|
|
await this.sendServerRequest(
|
|
`/data-vault/stores/${storeId}/set`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ hashed_key: hashedKey, encrypted_value: encryptedValue }),
|
|
},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async initiateServerPasskeyRegistration(userId: string): Promise<Result<{ options_json: any, code: string }, KnownErrors[]>> {
|
|
// Create a temporary session for this user to use for passkey registration
|
|
// TODO instead of creating a new session, this should just call the endpoint in a way in which it doesn't require a session
|
|
// (currently this shows up on session history etc... not ideal)
|
|
const { accessToken, refreshToken } = await this.createServerUserSession(userId, 60000 * 2, false); // 2 minute session
|
|
const tempSession = new InternalSession({
|
|
accessToken,
|
|
refreshToken,
|
|
refreshAccessTokenCallback: async () => null, // No refresh for temporary sessions
|
|
});
|
|
|
|
// Use the existing initiatePasskeyRegistration method with the temporary session
|
|
return await this.initiatePasskeyRegistration({}, tempSession);
|
|
}
|
|
}
|