stack/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
2026-05-25 17:50:09 -07:00

1686 lines
80 KiB
TypeScript

import { WebAuthnError, startRegistration } from "@simplewebauthn/browser";
import { KnownErrors, HexclaveServerInterface } from "@stackframe/stack-shared";
import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels";
import { ItemCrud } from "@stackframe/stack-shared/dist/interface/crud/items";
import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences";
import { OAuthProviderCrud } from "@stackframe/stack-shared/dist/interface/crud/oauth-providers";
import type { CustomerProductsListResponse } from "@stackframe/stack-shared/dist/interface/crud/products";
import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateOutputSchema, userApiKeysCreateOutputSchema } from "@stackframe/stack-shared/dist/interface/crud/project-api-keys";
import { ProjectPermissionDefinitionsCrud, ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions";
import { TeamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation";
import { TeamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/crud/team-member-profiles";
import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions";
import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
import type { AsyncCache } from "@stackframe/stack-shared/dist/utils/caches";
import { HexclaveAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { ProviderType } from "@stackframe/stack-shared/dist/utils/oauth";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { suspend } from "@stackframe/stack-shared/dist/utils/react";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like
import * as yup from "yup";
import { constructRedirectUrl } from "../../../../utils/url";
import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud, apiKeyUpdateOptionsToCrud } from "../../api-keys";
import { ConvexCtx, GetCurrentUserOptions } from "../../common";
import { DeprecatedOAuthConnection, OAuthConnection } from "../../connected-accounts";
import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactChannelUpdateOptions, serverContactChannelCreateOptionsToCrud, serverContactChannelUpdateOptionsToCrud } from "../../contact-channels";
import { Customer, CustomerProductsList, CustomerProductsRequestOptions, InlineProduct, ServerItem } from "../../customers";
import { DataVaultStore } from "../../data-vault";
import { EmailDeliveryInfo, SendEmailOptions } from "../../email";
import { NotificationCategory } from "../../notification-categories";
import { AdminProjectPermissionDefinition, AdminTeamPermission, AdminTeamPermissionDefinition } from "../../permissions";
import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, ServerListTeamsOptions, ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions, ServerTeamUpdateOptions, ServerTeamUser, Team, serverTeamCreateOptionsToCrud, serverTeamUpdateOptionsToCrud } from "../../teams";
import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCreateOptions, ServerUserUpdateOptions, serverUserCreateOptionsToCrud, serverUserUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
import { StackServerAppConstructorOptions } from "../interfaces/server-app";
import { _StackClientAppImplIncomplete } from "./client-app-impl";
import { clientVersion, createCache, createCacheBySession, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, resolveApiUrls, resolveConstructorOptions } from "./common";
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, ProjectId extends string> extends _StackClientAppImplIncomplete<HasTokenStore, ProjectId> {
declare protected _interface: HexclaveServerInterface;
// TODO override the client user cache to use the server user cache, so we save some requests
private readonly _currentServerUserCache = createCacheBySession(async (session) => {
if (session.isKnownToBeInvalid()) {
// see comment in _currentUserCache for more details on why we do this
return null;
}
return await this._interface.getServerUserByToken(session);
});
private readonly _serverUsersCache = createCache<[
cursor?: string,
limit?: number,
orderBy?: 'signedUpAt' | 'lastActiveAt',
desc?: boolean,
query?: string,
includeRestricted?: boolean,
includeAnonymous?: boolean,
onlyAnonymous?: boolean,
teamId?: string,
], UsersCrud['Server']['List']>(async ([cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous, onlyAnonymous, teamId]) => {
if (onlyAnonymous && !includeAnonymous) {
throw new HexclaveAssertionError("onlyAnonymous=true requires includeAnonymous=true");
}
if (onlyAnonymous) {
return await this._interface.listServerUsers({ cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous: true, onlyAnonymous: true, teamId });
}
return await this._interface.listServerUsers({ cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous, teamId });
});
private readonly _serverUserCache = createCache<string[], UsersCrud['Server']['Read'] | null>(async ([userId]) => {
const user = await this._interface.getServerUserById(userId);
return Result.or(user, null);
});
private readonly _serverTeamsCache = createCache<[
userId?: string,
orderBy?: 'createdAt',
desc?: boolean,
cursor?: string,
limit?: number,
query?: string,
], TeamsCrud['Server']['List']>(async ([userId, orderBy, desc, cursor, limit, query]) => {
return await this._interface.listServerTeamsPaginated({ userId, orderBy, desc, cursor, limit, query });
});
protected async _refreshTeamMembership(teamId: string, userId: string) {
await Promise.all([
this._serverTeamMemberProfilesCache.refresh([teamId]),
this._serverTeamsCache.refreshWhere(([u]) => u === userId || u === undefined),
this._serverUsersCache.refreshWhere((key) => key[8] === teamId),
]);
}
private readonly _serverUserTeamInvitationsCache = createCache<string[], TeamInvitationCrud['Client']['Read'][]>(async ([userId]) => {
return await this._interface.listServerUserTeamInvitations(userId);
});
private readonly _serverTeamUserPermissionsCache = createCache<
[string, string, boolean],
TeamPermissionsCrud['Server']['Read'][]
>(async ([teamId, userId, recursive]) => {
return await this._interface.listServerTeamPermissions({ teamId, userId, recursive }, null);
});
// Bulk variant: one request returning permissions for every member of a
// team. Used by the dashboard's team-member table to avoid N per-row
// calls. Keyed without userId so it's a distinct cache entry from the
// per-user lookup above.
private readonly _serverAllTeamMemberPermissionsCache = createCache<
[string, boolean],
TeamPermissionsCrud['Server']['Read'][]
>(async ([teamId, recursive]) => {
return await this._interface.listServerTeamPermissions({ teamId, recursive }, null);
});
private readonly _serverUserProjectPermissionsCache = createCache<
[string, boolean],
ProjectPermissionsCrud['Server']['Read'][]
>(async ([userId, recursive]) => {
return await this._interface.listServerProjectPermissions({ userId, recursive }, null);
});
/** @deprecated Used by legacy getConnectedAccount(providerId) — uses old per-provider access token endpoint */
private readonly _serverUserOAuthConnectionAccessTokensCache = createCache<[string, string, string], { accessToken: string } | null>(
async ([userId, providerId, scope]) => {
try {
const result = await this._interface.createServerProviderAccessToken(userId, providerId, scope || "");
return { accessToken: result.access_token };
} catch (err) {
if (!(KnownErrors.OAuthAccessTokenNotAvailable.isInstance(err) || KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
throw err;
}
}
return null;
}
);
/** @deprecated Used by legacy getConnectedAccount(providerId) — combines token check + redirect */
private readonly _serverUserOAuthConnectionCache = createCache<[string, ProviderType, string, boolean], DeprecatedOAuthConnection | null>(
async ([userId, providerId, scope, redirect]) => {
return await this._getUserOAuthConnectionCacheFn({
getUser: async () => Result.orThrow(await this._serverUserCache.getOrWait([userId], "write-only")),
getOrWaitOAuthToken: async () => Result.orThrow(await this._serverUserOAuthConnectionAccessTokensCache.getOrWait([userId, providerId, scope || ""] as const, "write-only")),
// IF_PLATFORM react-like
useOAuthToken: () => useAsyncCache(this._serverUserOAuthConnectionAccessTokensCache, [userId, providerId, scope || ""] as const, "user.useConnectedAccount()"),
// END_PLATFORM
providerId,
scope,
redirect,
session: null,
});
}
);
private readonly _serverUserConnectedAccountsCache = createCache<[string], OAuthConnection[]>(
async ([userId]) => {
const result = await this._interface.listServerConnectedAccounts(userId);
return result.items.map((item) => this._createServerOAuthConnectionFromCrudItem(userId, item));
}
);
private readonly _serverUserOAuthConnectionAccessTokensByAccountCache = createCache<[string, string, string, string], { accessToken: string } | null>(
async ([userId, providerId, providerAccountId, scope]) => {
try {
const result = await this._interface.createServerProviderAccessTokenByAccount(userId, providerId, providerAccountId, scope || "");
return { accessToken: result.access_token };
} catch (err) {
if (!(KnownErrors.OAuthAccessTokenNotAvailable.isInstance(err) || KnownErrors.OAuthConnectionDoesNotHaveRequiredScope.isInstance(err) || KnownErrors.OAuthConnectionNotConnectedToUser.isInstance(err))) {
throw err;
}
}
return null;
}
);
private readonly _serverTeamMemberProfilesCache = createCache<[string], TeamMemberProfilesCrud['Server']['Read'][]>(
async ([teamId]) => {
return await this._interface.listServerTeamMemberProfiles({ teamId });
}
);
private readonly _serverTeamInvitationsCache = createCache<[string], TeamInvitationCrud['Server']['Read'][]>(
async ([teamId]) => {
return await this._interface.listServerTeamInvitations({ teamId });
}
);
private readonly _serverUserTeamProfileCache = createCache<[string, string], TeamMemberProfilesCrud['Client']['Read']>(
async ([teamId, userId]) => {
return await this._interface.getServerTeamMemberProfile({ teamId, userId });
}
);
private readonly _serverContactChannelsCache = createCache<[string], ContactChannelsCrud['Server']['Read'][]>(
async ([userId]) => {
return await this._interface.listServerContactChannels(userId);
}
);
private readonly _serverNotificationCategoriesCache = createCache<[string], NotificationPreferenceCrud['Server']['Read'][]>(
async ([userId]) => {
return await this._interface.listServerNotificationCategories(userId);
}
);
private readonly _serverDataVaultStoreValueCache = createCache<[string, string, string], string | null>(async ([storeId, key, secret]) => {
return await this._interface.getDataVaultStoreValue(secret, storeId, key);
});
private readonly _emailDeliveryInfoCache = createCache(async () => {
return await this._interface.getEmailDeliveryInfo();
});
private readonly _serverUserApiKeysCache = createCache<[string], UserApiKeysCrud['Server']['Read'][]>(
async ([userId]) => {
const result = await this._interface.listProjectApiKeys({
user_id: userId,
}, null, "server");
return result as UserApiKeysCrud['Server']['Read'][];
}
);
private readonly _serverTeamApiKeysCache = createCache<[string], TeamApiKeysCrud['Server']['Read'][]>(
async ([teamId]) => {
const result = await this._interface.listProjectApiKeys({
team_id: teamId,
}, null, "server");
return result as TeamApiKeysCrud['Server']['Read'][];
}
);
private readonly _convexIdentitySubjectCache = createCache<[ConvexCtx], string | null>(
async ([ctx]) => {
const identity = await ctx.auth.getUserIdentity();
return identity ? identity.subject : null;
}
);
private readonly _serverCheckApiKeyCache = createCache<["user" | "team", string], UserApiKeysCrud['Server']['Read'] | TeamApiKeysCrud['Server']['Read'] | null>(async ([type, apiKey]) => {
const result = await this._interface.checkProjectApiKey(
type,
apiKey,
null,
"server",
);
return result;
});
private readonly _serverOAuthProvidersCache = createCache<[string], OAuthProviderCrud['Server']['Read'][]>(
async ([userId]) => {
return await this._interface.listServerOAuthProviders({ user_id: userId });
}
);
private readonly _serverTeamItemsCache = createCache<[string, string], ItemCrud['Client']['Read']>(
async ([teamId, itemId]) => {
return await this._interface.getItem({ teamId, itemId }, null, "server");
}
);
private readonly _serverUserItemsCache = createCache<[string, string], ItemCrud['Client']['Read']>(
async ([userId, itemId]) => {
return await this._interface.getItem({ userId, itemId }, null, "server");
}
);
private readonly _serverCustomItemsCache = createCache<[string, string], ItemCrud['Client']['Read']>(
async ([customCustomerId, itemId]) => {
return await this._interface.getItem({ customCustomerId, itemId }, null, "server");
}
);
private readonly _serverUserProductsCache = createCache<[string, string | null, number | null], CustomerProductsListResponse>(
async ([userId, cursor, limit]) => {
return await this._interface.listProducts({
customer_type: "user",
customer_id: userId,
cursor: cursor ?? undefined,
limit: limit ?? undefined,
}, null, "server");
}
);
private readonly _serverTeamProductsCache = createCache<[string, string | null, number | null], CustomerProductsListResponse>(
async ([teamId, cursor, limit]) => {
return await this._interface.listProducts({
customer_type: "team",
customer_id: teamId,
cursor: cursor ?? undefined,
limit: limit ?? undefined,
}, null, "server");
}
);
private readonly _serverCustomProductsCache = createCache<[string, string | null, number | null], CustomerProductsListResponse>(
async ([customCustomerId, cursor, limit]) => {
return await this._interface.listProducts({
customer_type: "custom",
customer_id: customCustomerId,
cursor: cursor ?? undefined,
limit: limit ?? undefined,
}, null, "server");
}
);
protected _createServerCustomer(userIdOrTeamId: string, type: "user" | "team"): Omit<Customer<true>, "id"> {
const app = this;
const productsCache = type === "user" ? app._serverUserProductsCache : app._serverTeamProductsCache;
const customerOptions = type === "user" ? { userId: userIdOrTeamId } : { teamId: userIdOrTeamId };
return {
...this._createCustomer(userIdOrTeamId, type, null),
async getItem(itemId: string) {
return await app.getItem({ itemId, ...customerOptions });
},
// IF_PLATFORM react-like
useItem(itemId: string) {
return app.useItem({ itemId, ...customerOptions });
},
// END_PLATFORM
async grantProduct(productOptions: { productId: string, quantity?: number } | { product: InlineProduct, quantity?: number }) {
if (type === "user") {
if ("productId" in productOptions) {
await app.grantProduct({ userId: userIdOrTeamId, productId: productOptions.productId, quantity: productOptions.quantity });
} else {
await app.grantProduct({ userId: userIdOrTeamId, product: productOptions.product, quantity: productOptions.quantity });
}
} else {
if ("productId" in productOptions) {
await app.grantProduct({ teamId: userIdOrTeamId, productId: productOptions.productId, quantity: productOptions.quantity });
} else {
await app.grantProduct({ teamId: userIdOrTeamId, product: productOptions.product, quantity: productOptions.quantity });
}
}
await productsCache.refresh([userIdOrTeamId, null, null]);
},
async createCheckoutUrl(options: { productId: string, returnUrl?: string } | { product: InlineProduct, returnUrl?: string }) {
const productIdOrInline = "productId" in options ? options.productId : options.product;
return await app._interface.createCheckoutUrl(type, userIdOrTeamId, productIdOrInline, null, options.returnUrl, "server");
},
};
}
private async _updateServerUser(userId: string, update: ServerUserUpdateOptions): Promise<UsersCrud['Server']['Read']> {
const result = await this._interface.updateServerUser(userId, serverUserUpdateOptionsToCrud(update));
await this._refreshUsers();
return result;
}
protected _serverEditableTeamProfileFromCrud(crud: TeamMemberProfilesCrud['Client']['Read']): EditableTeamMemberProfile {
const app = this;
return {
displayName: crud.display_name,
profileImageUrl: crud.profile_image_url,
async update(update: { displayName?: string, profileImageUrl?: string }) {
await app._interface.updateServerTeamMemberProfile({
teamId: crud.team_id,
userId: crud.user_id,
profile: {
display_name: update.displayName,
profile_image_url: update.profileImageUrl,
},
});
await app._serverUserTeamProfileCache.refresh([crud.team_id, crud.user_id]);
}
};
}
protected _serverContactChannelFromCrud(userId: string, crud: ContactChannelsCrud['Server']['Read']): ServerContactChannel {
const app = this;
return {
id: crud.id,
value: crud.value,
type: crud.type,
isVerified: crud.is_verified,
isPrimary: crud.is_primary,
usedForAuth: crud.used_for_auth,
async sendVerificationEmail(options?: { callbackUrl?: string }) {
await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, options?.callbackUrl ?? constructRedirectUrl(app.urls.emailVerification, "callbackUrl"));
},
async update(data: ServerContactChannelUpdateOptions) {
await app._interface.updateServerContactChannel(userId, crud.id, serverContactChannelUpdateOptionsToCrud(data));
await Promise.all([
app._serverContactChannelsCache.refresh([userId]),
app._serverUserCache.refresh([userId])
]);
},
async delete() {
await app._interface.deleteServerContactChannel(userId, crud.id);
await Promise.all([
app._serverContactChannelsCache.refresh([userId]),
app._serverUserCache.refresh([userId])
]);
},
};
}
protected _serverNotificationCategoryFromCrud(userId: string, crud: NotificationPreferenceCrud['Server']['Read']): NotificationCategory {
const app = this;
return {
id: crud.notification_category_id,
name: crud.notification_category_name,
enabled: crud.enabled,
canDisable: crud.can_disable,
async setEnabled(enabled: boolean) {
await app._interface.setServerNotificationsEnabled(userId, crud.notification_category_id, enabled);
await app._serverNotificationCategoriesCache.refresh([userId]);
},
};
}
protected _serverOAuthProviderFromCrud(crud: OAuthProviderCrud['Server']['Read']) {
const app = this;
return {
id: crud.id,
type: crud.type,
userId: crud.user_id,
accountId: crud.account_id,
email: crud.email,
allowSignIn: crud.allow_sign_in,
allowConnectedAccounts: crud.allow_connected_accounts,
async update(data: { accountId?: string, email?: string, allowSignIn?: boolean, allowConnectedAccounts?: boolean }): Promise<Result<void,
InstanceType<typeof KnownErrors.OAuthProviderAccountIdAlreadyUsedForSignIn>
>> {
try {
await app._interface.updateServerOAuthProvider(crud.user_id, crud.id, {
account_id: data.accountId,
email: data.email,
allow_sign_in: data.allowSignIn,
allow_connected_accounts: data.allowConnectedAccounts,
});
await Promise.all([
app._serverOAuthProvidersCache.refresh([crud.user_id]),
app._serverUserConnectedAccountsCache.refresh([crud.user_id]),
]);
return Result.ok(undefined);
} catch (error) {
if (KnownErrors.OAuthProviderAccountIdAlreadyUsedForSignIn.isInstance(error)) {
return Result.error(error);
}
throw error;
}
},
async delete() {
await app._interface.deleteServerOAuthProvider(crud.user_id, crud.id);
await Promise.all([
app._serverOAuthProvidersCache.refresh([crud.user_id]),
app._serverUserConnectedAccountsCache.refresh([crud.user_id]),
]);
},
};
}
constructor(options: StackServerAppConstructorOptions<HasTokenStore, ProjectId>, extraOptions?: { uniqueIdentifier?: string, checkString?: string, interface?: HexclaveServerInterface }) {
const resolvedOptions = resolveConstructorOptions(options);
const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey();
super(resolvedOptions, {
...extraOptions,
interface: extraOptions?.interface ?? (() => {
const apiUrls = resolveApiUrls(resolvedOptions.baseUrl);
return new HexclaveServerInterface({
getBaseUrl: () => apiUrls()[0],
getApiUrls: apiUrls,
projectId: resolvedOptions.projectId ?? getDefaultProjectId(),
extraRequestHeaders: resolvedOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(),
clientVersion,
...(publishableClientKey != null ? { publishableClientKey } : {}),
secretServerKey: resolvedOptions.secretServerKey ?? getDefaultSecretServerKey(),
});
})(),
});
}
protected _serverApiKeyFromCrud(crud: TeamApiKeysCrud['Client']['Read']): ApiKey<"team">;
protected _serverApiKeyFromCrud(crud: UserApiKeysCrud['Client']['Read']): ApiKey<"user">;
protected _serverApiKeyFromCrud(crud: yup.InferType<typeof teamApiKeysCreateOutputSchema>): ApiKey<"team", true>;
protected _serverApiKeyFromCrud(crud: yup.InferType<typeof userApiKeysCreateOutputSchema>): ApiKey<"user", true>;
protected _serverApiKeyFromCrud(crud: TeamApiKeysCrud['Client']['Read'] | UserApiKeysCrud['Client']['Read'] | yup.InferType<typeof teamApiKeysCreateOutputSchema> | yup.InferType<typeof userApiKeysCreateOutputSchema>): ApiKey<"user" | "team", boolean> {
return {
...this._baseApiKeyFromCrud(crud),
async revoke() {
await this.update({ revoked: true });
},
update: async (options: ApiKeyUpdateOptions) => {
await this._interface.updateProjectApiKey(
crud.type === "team" ? { team_id: crud.team_id } : { user_id: crud.user_id },
crud.id,
await apiKeyUpdateOptionsToCrud(crud.type, options),
null,
"server");
if (crud.type === "team") {
await this._serverTeamApiKeysCache.refresh([crud.team_id]);
} else {
await this._serverUserApiKeysCache.refresh([crud.user_id]);
}
},
};
}
protected _createServerOAuthConnectionFromCrudItem(
userId: string,
item: { provider: string, provider_account_id: string },
): OAuthConnection {
const app = this;
const providerId = item.provider;
const providerAccountId = item.provider_account_id;
return {
id: providerId, // deprecated, for backward compat
provider: providerId,
providerAccountId,
async getAccessToken(options?: { scopes?: string[] }) {
const scopeString = options?.scopes?.join(" ") ?? "";
const result = Result.orThrow(await app._serverUserOAuthConnectionAccessTokensByAccountCache.getOrWait([userId, providerId, providerAccountId, scopeString], "write-only"));
if (!result) {
const scopeDetail = scopeString ? `The requested scopes [${scopeString}] are not available on the existing token.` : "The OAuth refresh token has likely been revoked or expired.";
return Result.error(new KnownErrors.OAuthAccessTokenNotAvailable(providerId, `${scopeDetail} The user needs to re-authorize by calling \`linkConnectedAccount\` or using \`getOrLinkConnectedAccount\`.`));
}
return Result.ok(result);
},
// IF_PLATFORM react-like
useAccessToken(options?: { scopes?: string[] }) {
const scopeString = options?.scopes?.join(" ") ?? "";
const result = useAsyncCache(app._serverUserOAuthConnectionAccessTokensByAccountCache, [userId, providerId, providerAccountId, scopeString] as const, "connection.useAccessToken()");
if (!result) {
const scopeDetail = scopeString ? `The requested scopes [${scopeString}] are not available on the existing token.` : "The OAuth refresh token has likely been revoked or expired.";
return Result.error(new KnownErrors.OAuthAccessTokenNotAvailable(providerId, `${scopeDetail} The user needs to re-authorize by calling \`linkConnectedAccount\` or using \`getOrLinkConnectedAccount\`.`));
}
return Result.ok(result);
},
// END_PLATFORM
};
}
protected _serverUserFromCrud(crud: UsersCrud['Server']['Read']): ServerUser {
const app = this;
/** @deprecated The string-based overloads are deprecated. Use `getConnectedAccount({ provider, providerAccountId })` for existence check. */
async function getConnectedAccount(id: ProviderType, options?: { scopes?: string[] }): Promise<DeprecatedOAuthConnection | null>;
async function getConnectedAccount(id: ProviderType, options: { or: 'redirect', scopes?: string[] }): Promise<DeprecatedOAuthConnection>;
async function getConnectedAccount(account: { provider: string, providerAccountId: string }): Promise<OAuthConnection | null>;
async function getConnectedAccount(
idOrAccount: ProviderType | { provider: string, providerAccountId: string },
options?: { or?: 'redirect', scopes?: string[] }
): Promise<DeprecatedOAuthConnection | OAuthConnection | null> {
const scopeString = options?.scopes?.join(" ") ?? "";
// Check if it's the new object-based API
if (typeof idOrAccount === 'object' && 'provider' in idOrAccount && 'providerAccountId' in idOrAccount) {
const { provider, providerAccountId } = idOrAccount;
// Check if the account exists in the connected accounts list
const connectedAccounts = Result.orThrow(await app._serverUserConnectedAccountsCache.getOrWait([crud.id], "write-only"));
const found = connectedAccounts.find(
a => a.provider === provider && a.providerAccountId === providerAccountId
);
if (!found) {
return null;
}
return found;
}
// Original behavior: by provider ID (returns first match)
return Result.orThrow(await app._serverUserOAuthConnectionCache.getOrWait([crud.id, idOrAccount, scopeString, options?.or === 'redirect'], "write-only"));
}
// IF_PLATFORM react-like
/** @deprecated The string-based overloads are deprecated. Use `useConnectedAccount({ provider, providerAccountId })` for existence check. */
function useConnectedAccount(id: ProviderType, options?: { scopes?: string[] }): DeprecatedOAuthConnection | null;
function useConnectedAccount(id: ProviderType, options: { or: 'redirect', scopes?: string[] }): DeprecatedOAuthConnection;
function useConnectedAccount(account: { provider: string, providerAccountId: string }): OAuthConnection | null;
function useConnectedAccount(
idOrAccount: ProviderType | { provider: string, providerAccountId: string },
options?: { or?: 'redirect', scopes?: string[] }
): DeprecatedOAuthConnection | OAuthConnection | null {
const scopeString = options?.scopes?.join(" ") ?? "";
// Check if it's the new object-based API
if (typeof idOrAccount === 'object' && 'provider' in idOrAccount && 'providerAccountId' in idOrAccount) {
const { provider, providerAccountId } = idOrAccount;
// Check if the account exists in the connected accounts list
const connectedAccounts = useAsyncCache(
app._serverUserConnectedAccountsCache,
[crud.id] as const,
"user.useConnectedAccount()"
);
const found = connectedAccounts.find(
a => a.provider === provider && a.providerAccountId === providerAccountId
);
return found ?? null;
}
// Original behavior: by provider ID (returns first match)
return useAsyncCache(app._serverUserOAuthConnectionCache, [crud.id, idOrAccount, scopeString, options?.or === 'redirect'] as const, "user.useConnectedAccount()");
}
// END_PLATFORM
// Type assertion needed because the new restricted_by_admin fields may not be reflected in TypeScript types yet
// after schema changes - the runtime values are present after the schema is updated
const crudWithAdminRestriction = crud as typeof crud & {
restricted_by_admin: boolean,
restricted_by_admin_reason: string | null,
restricted_by_admin_private_details: string | null,
};
const serverUser = withUserDestructureGuard({
...super._createBaseUser(crud),
lastActiveAt: new Date(crud.last_active_at_millis),
serverMetadata: crud.server_metadata,
restrictedByAdmin: crudWithAdminRestriction.restricted_by_admin,
restrictedByAdminReason: crudWithAdminRestriction.restricted_by_admin_reason,
restrictedByAdminPrivateDetails: crudWithAdminRestriction.restricted_by_admin_private_details,
countryCode: crud.country_code,
riskScores: {
signUp: {
bot: crud.risk_scores.sign_up.bot,
freeTrialAbuse: crud.risk_scores.sign_up.free_trial_abuse,
},
},
async setPrimaryEmail(email: string | null, options?: { verified?: boolean }) {
await app._updateServerUser(crud.id, { primaryEmail: email, primaryEmailVerified: options?.verified });
},
async grantPermission(scopeOrPermissionId: Team | string, permissionId?: string): Promise<void> {
if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string' && permissionId) {
const scope = scopeOrPermissionId;
await app._interface.grantServerTeamUserPermission(scope.id, crud.id, permissionId);
for (const recursive of [true, false]) {
await app._serverTeamUserPermissionsCache.refresh([scope.id, crud.id, recursive]);
await app._serverAllTeamMemberPermissionsCache.refresh([scope.id, recursive]);
}
} else {
const pId = scopeOrPermissionId as string;
await app._interface.grantServerProjectPermission(crud.id, pId);
for (const recursive of [true, false]) {
await app._serverUserProjectPermissionsCache.refresh([crud.id, recursive]);
}
}
},
async revokePermission(scopeOrPermissionId: Team | string, permissionId?: string): Promise<void> {
if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string' && permissionId) {
const scope = scopeOrPermissionId;
await app._interface.revokeServerTeamUserPermission(scope.id, crud.id, permissionId);
for (const recursive of [true, false]) {
await app._serverTeamUserPermissionsCache.refresh([scope.id, crud.id, recursive]);
await app._serverAllTeamMemberPermissionsCache.refresh([scope.id, recursive]);
}
} else {
const pId = scopeOrPermissionId as string;
await app._interface.revokeServerProjectPermission(crud.id, pId);
for (const recursive of [true, false]) {
await app._serverUserProjectPermissionsCache.refresh([crud.id, recursive]);
}
}
},
async delete() {
const res = await app._interface.deleteServerUser(crud.id);
await app._refreshUsers();
return res;
},
async createSession(options: { expiresInMillis?: number, isImpersonation?: boolean }) {
// TODO this should also refresh the access token when it expires (like InternalSession)
const tokens = await app._interface.createServerUserSession(crud.id, options.expiresInMillis ?? 1000 * 60 * 60 * 24 * 365, options.isImpersonation ?? false);
return {
async getTokens() {
return tokens;
},
};
},
async getActiveSessions() {
const sessions = await app._interface.listServerSessions(crud.id);
return sessions.items.map((session) => app._clientSessionFromCrud(session));
},
async revokeSession(sessionId: string) {
await app._interface.deleteServerSession(sessionId);
},
async setDisplayName(displayName: string | null) {
return await this.update({ displayName });
},
async setClientMetadata(metadata: Record<string, any>) {
return await this.update({ clientMetadata: metadata });
},
async setClientReadOnlyMetadata(metadata: Record<string, any>) {
return await this.update({ clientReadOnlyMetadata: metadata });
},
async setServerMetadata(metadata: Record<string, any>) {
return await this.update({ serverMetadata: metadata });
},
async setSelectedTeam(team: Team | string | null) {
return await this.update({ selectedTeamId: typeof team === 'string' ? team : team?.id ?? null });
},
getConnectedAccount,
useConnectedAccount, // THIS_LINE_PLATFORM react-like
async listConnectedAccounts() {
return Result.orThrow(await app._serverUserConnectedAccountsCache.getOrWait([crud.id], "write-only"));
},
// IF_PLATFORM react-like
useConnectedAccounts() {
return useAsyncCache(app._serverUserConnectedAccountsCache, [crud.id] as const, "user.useConnectedAccounts()");
},
// END_PLATFORM
async linkConnectedAccount(): Promise<void> {
throw new HexclaveAssertionError("linkConnectedAccount is not available for server users. OAuth flows must be initiated on the client side.");
},
async getOrLinkConnectedAccount(): Promise<OAuthConnection> {
throw new HexclaveAssertionError("getOrLinkConnectedAccount is not available for server users. OAuth flows must be initiated on the client side.");
},
// IF_PLATFORM react-like
useOrLinkConnectedAccount(): OAuthConnection {
throw new HexclaveAssertionError("useOrLinkConnectedAccount is not available for server users. OAuth flows must be initiated on the client side.");
},
// END_PLATFORM
selectedTeam: crud.selected_team ? app._serverTeamFromCrud(crud.selected_team) : null,
async getTeam(teamId: string) {
const teams = await this.listTeams();
return teams.find((t) => t.id === teamId) ?? null;
},
// IF_PLATFORM react-like
useTeam(teamId: string) {
const teams = this.useTeams();
return useMemo(() => {
return teams.find((t) => t.id === teamId) ?? null;
}, [teams, teamId]);
},
// END_PLATFORM
async listTeams(options?: ServerListTeamsOptions): Promise<ServerTeam[] & { nextCursor: string | null }> {
const result = Result.orThrow(await app._serverTeamsCache.getOrWait([crud.id, options?.orderBy, options?.desc, options?.cursor, options?.limit, options?.query] as const, "write-only"));
const teams: any = result.items.map((t) => app._serverTeamFromCrud(t));
teams.nextCursor = result.pagination?.next_cursor ?? null;
return teams as any;
},
// IF_PLATFORM react-like
useTeams(options?: ServerListTeamsOptions): ServerTeam[] & { nextCursor: string | null } {
const result = useAsyncCache(app._serverTeamsCache, [crud.id, options?.orderBy, options?.desc, options?.cursor, options?.limit, options?.query] as const, "user.useTeams()");
return useMemo(() => {
const teams: any = result.items.map((t) => app._serverTeamFromCrud(t));
teams.nextCursor = result.pagination?.next_cursor ?? null;
return teams as any;
}, [result]);
},
// END_PLATFORM
createTeam: async (data: Omit<ServerTeamCreateOptions, "creatorUserId">) => {
const team = await app._interface.createServerTeam(serverTeamCreateOptionsToCrud({
creatorUserId: crud.id,
...data,
}));
await app._serverTeamsCache.refreshWhere(() => true);
await app._updateServerUser(crud.id, { selectedTeamId: team.id });
return app._serverTeamFromCrud(team);
},
leaveTeam: async (team: Team) => {
await app._interface.leaveServerTeam({ teamId: team.id, userId: crud.id });
await app._refreshTeamMembership(team.id, crud.id);
},
async listTeamInvitations() {
const invitations = Result.orThrow(await app._serverUserTeamInvitationsCache.getOrWait([crud.id], "write-only"));
return invitations.map((inv) => app._serverReceivedTeamInvitationFromCrud(crud.id, inv));
},
// IF_PLATFORM react-like
useTeamInvitations() {
const invitations = useAsyncCache(app._serverUserTeamInvitationsCache, [crud.id], "user.useTeamInvitations()");
return useMemo(() => invitations.map((inv) => app._serverReceivedTeamInvitationFromCrud(crud.id, inv)), [invitations]);
},
// END_PLATFORM
async listPermissions(scopeOrOptions?: Team | { recursive?: boolean }, options?: { recursive?: boolean }): Promise<AdminTeamPermission[]> {
if (scopeOrOptions && 'id' in scopeOrOptions) {
const scope = scopeOrOptions;
const recursive = options?.recursive ?? true;
const permissions = Result.orThrow(await app._serverTeamUserPermissionsCache.getOrWait([scope.id, crud.id, recursive], "write-only"));
return permissions.map((crud) => app._serverPermissionFromCrud(crud));
} else {
const opts = scopeOrOptions;
const recursive = opts?.recursive ?? true;
const permissions = Result.orThrow(await app._serverUserProjectPermissionsCache.getOrWait([crud.id, recursive], "write-only"));
return permissions.map((crud) => app._serverPermissionFromCrud(crud));
}
},
// IF_PLATFORM react-like
usePermissions(scopeOrOptions?: Team | { recursive?: boolean }, options?: { recursive?: boolean }): AdminTeamPermission[] {
if (scopeOrOptions && 'id' in scopeOrOptions) {
const scope = scopeOrOptions;
const recursive = options?.recursive ?? true;
const permissions = useAsyncCache(app._serverTeamUserPermissionsCache, [scope.id, crud.id, recursive] as const, "user.usePermissions()");
return useMemo(() => permissions.map((crud) => app._serverPermissionFromCrud(crud)), [permissions]);
} else {
const opts = scopeOrOptions;
const recursive = opts?.recursive ?? true;
const permissions = useAsyncCache(app._serverUserProjectPermissionsCache, [crud.id, recursive] as const, "user.usePermissions()");
return useMemo(() => permissions.map((crud) => app._serverPermissionFromCrud(crud)), [permissions]);
}
},
// END_PLATFORM
async getPermission(scopeOrPermissionId: Team | string, permissionId?: string): Promise<AdminTeamPermission | null> {
if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string') {
const scope = scopeOrPermissionId;
const permissions = await this.listPermissions(scope);
return permissions.find((p) => p.id === permissionId) ?? null;
} else {
const pid = scopeOrPermissionId;
const permissions = await this.listPermissions();
return permissions.find((p) => p.id === pid) ?? null;
}
},
// IF_PLATFORM react-like
usePermission(scopeOrPermissionId: Team | string, permissionId?: string): AdminTeamPermission | null {
if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string') {
const scope = scopeOrPermissionId;
const permissions = this.usePermissions(scope);
return useMemo(() => permissions.find((p) => p.id === permissionId) ?? null, [permissions, permissionId]);
} else {
const pid = scopeOrPermissionId;
const permissions = this.usePermissions();
return useMemo(() => permissions.find((p) => p.id === pid) ?? null, [permissions, pid]);
}
},
// END_PLATFORM
async hasPermission(scopeOrPermissionId: Team | string, permissionId?: string): Promise<boolean> {
if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string') {
const scope = scopeOrPermissionId;
return (await this.getPermission(scope, permissionId as string)) !== null;
} else {
const pid = scopeOrPermissionId;
return (await this.getPermission(pid)) !== null;
}
},
async update(update: ServerUserUpdateOptions) {
await app._updateServerUser(crud.id, update);
},
async sendVerificationEmail() {
return await app._checkFeatureSupport("sendVerificationEmail() on ServerUser", {});
},
async updatePassword(options: { oldPassword: string, newPassword: string }) {
const result = await app._interface.updatePassword(options);
await app._serverUserCache.refresh([crud.id]);
return result;
},
async setPassword(options: { password: string }) {
const result = await this.update(options);
await app._serverUserCache.refresh([crud.id]);
return result;
},
async getTeamProfile(team: Team) {
const result = Result.orThrow(await app._serverUserTeamProfileCache.getOrWait([team.id, crud.id], "write-only"));
return app._serverEditableTeamProfileFromCrud(result);
},
// IF_PLATFORM react-like
useTeamProfile(team: Team) {
const result = useAsyncCache(app._serverUserTeamProfileCache, [team.id, crud.id] as const, "user.useTeamProfile()");
return useMemo(() => app._serverEditableTeamProfileFromCrud(result), [result]);
},
// END_PLATFORM
async listContactChannels() {
const result = Result.orThrow(await app._serverContactChannelsCache.getOrWait([crud.id], "write-only"));
return result.map((data) => app._serverContactChannelFromCrud(crud.id, data));
},
// IF_PLATFORM react-like
useContactChannels() {
const result = useAsyncCache(app._serverContactChannelsCache, [crud.id] as const, "user.useContactChannels()");
return useMemo(() => result.map((data) => app._serverContactChannelFromCrud(crud.id, data)), [result]);
},
// END_PLATFORM
createContactChannel: async (data: ServerContactChannelCreateOptions) => {
const contactChannel = await app._interface.createServerContactChannel(serverContactChannelCreateOptionsToCrud(crud.id, data));
await Promise.all([
app._serverContactChannelsCache.refresh([crud.id]),
app._serverUserCache.refresh([crud.id])
]);
return app._serverContactChannelFromCrud(crud.id, contactChannel);
},
// IF_PLATFORM react-like
useNotificationCategories() {
const results = useAsyncCache(app._serverNotificationCategoriesCache, [crud.id] as const, "user.useNotificationCategories()");
return results.map((category) => app._serverNotificationCategoryFromCrud(crud.id, category));
},
// END_PLATFORM
async listNotificationCategories() {
const results = Result.orThrow(await app._serverNotificationCategoriesCache.getOrWait([crud.id], "write-only"));
return results.map((category) => app._serverNotificationCategoryFromCrud(crud.id, category));
},
// IF_PLATFORM react-like
useApiKeys() {
const result = useAsyncCache(app._serverUserApiKeysCache, [crud.id] as const, "user.useApiKeys()");
return result.map((apiKey) => app._serverApiKeyFromCrud(apiKey));
},
// END_PLATFORM
async listApiKeys() {
const result = Result.orThrow(await app._serverUserApiKeysCache.getOrWait([crud.id], "write-only"));
return result.map((apiKey) => app._serverApiKeyFromCrud(apiKey));
},
async createApiKey(options: ApiKeyCreationOptions<"user">) {
const result = await app._interface.createProjectApiKey(
await apiKeyCreationOptionsToCrud("user", crud.id, options),
null,
"server",
);
await app._serverUserApiKeysCache.refresh([crud.id]);
return app._serverApiKeyFromCrud(result);
},
// IF_PLATFORM react-like
useOAuthProviders() {
const results = useAsyncCache(app._serverOAuthProvidersCache, [crud.id] as const, "user.useOAuthProviders()");
return useMemo(() => results.map((oauthCrud) => app._serverOAuthProviderFromCrud(oauthCrud)), [results]);
},
// END_PLATFORM
async listOAuthProviders() {
const results = Result.orThrow(await app._serverOAuthProvidersCache.getOrWait([crud.id], "write-only"));
return results.map((oauthCrud) => app._serverOAuthProviderFromCrud(oauthCrud));
},
// IF_PLATFORM react-like
useOAuthProvider(id: string) {
const providers = this.useOAuthProviders();
return useMemo(() => providers.find((p) => p.id === id) ?? null, [providers, id]);
},
// END_PLATFORM
async getOAuthProvider(id: string) {
const providers = await this.listOAuthProviders();
return providers.find((p) => p.id === id) ?? null;
},
async registerPasskey(options?: { hostname?: string }): Promise<Result<undefined, KnownErrors["PasskeyRegistrationFailed"] | KnownErrors["PasskeyWebAuthnError"]>> {
// TODO remove duplicated code between this and the function in client-app-impl.ts
const hostname = options?.hostname || (await app._getCurrentUrl())?.hostname;
if (!hostname) {
throw new HexclaveAssertionError("hostname must be provided if the Stack App does not have a redirect method");
}
// Use server interface to initiate passkey registration for this specific user
const initiationResult = await app._interface.initiateServerPasskeyRegistration(crud.id);
if (initiationResult.status !== "ok") {
return Result.error(new KnownErrors.PasskeyRegistrationFailed("Failed to get initiation options for passkey registration"));
}
const { options_json, code } = initiationResult.data;
// HACK: Override the rpID to be the actual domain
if (options_json.rp.id !== "THIS_VALUE_WILL_BE_REPLACED.example.com") {
throw new HexclaveAssertionError(`Expected returned RP ID from server to equal sentinel, but found ${options_json.rp.id}`);
}
options_json.rp.id = hostname;
let attResp;
try {
attResp = await startRegistration({ optionsJSON: options_json });
} catch (error: any) {
if (error instanceof WebAuthnError) {
return Result.error(new KnownErrors.PasskeyWebAuthnError(error.message, error.name));
} else {
// This should never happen
captureError("passkey-registration-failed", error);
return Result.error(new KnownErrors.PasskeyRegistrationFailed("Failed to start passkey registration due to unknown error"));
}
}
// Create a temporary session to complete the 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 app._interface.createServerUserSession(crud.id, 60000 * 2, false);
const tempSession = new InternalSession({
accessToken,
refreshToken,
refreshAccessTokenCallback: async () => null,
});
const registrationResult = await app._interface.registerPasskey({ credential: attResp, code }, tempSession);
await app._serverUserCache.refresh([crud.id]);
return registrationResult;
},
...app._createServerCustomer(crud.id, "user"),
} satisfies ServerUser);
return serverUser;
}
protected _serverTeamUserFromCrud(crud: TeamMemberProfilesCrud["Server"]["Read"]): ServerTeamUser {
const teamUser = withUserDestructureGuard({
...this._serverUserFromCrud(crud.user),
teamProfile: {
displayName: crud.display_name,
profileImageUrl: crud.profile_image_url,
},
} satisfies ServerTeamUser);
return teamUser;
}
protected _serverSentTeamInvitationFromCrud(crud: TeamInvitationCrud['Server']['Read']): SentTeamInvitation {
return {
id: crud.id,
recipientEmail: crud.recipient_email,
expiresAt: new Date(crud.expires_at_millis),
revoke: async () => {
await this._interface.revokeServerTeamInvitation(crud.id, crud.team_id);
await this._serverTeamInvitationsCache.refresh([crud.team_id]);
},
};
}
protected _serverReceivedTeamInvitationFromCrud(userId: string, crud: TeamInvitationCrud['Client']['Read']): ReceivedTeamInvitation {
const app = this;
return {
id: crud.id,
teamId: crud.team_id,
teamDisplayName: crud.team_display_name,
recipientEmail: crud.recipient_email,
expiresAt: new Date(crud.expires_at_millis),
accept: async () => {
await app._interface.acceptServerTeamInvitationById(crud.id, userId);
await Promise.all([
app._serverUserTeamInvitationsCache.refresh([userId]),
app._serverTeamInvitationsCache.refresh([crud.team_id]),
app._refreshTeamMembership(crud.team_id, userId),
]);
},
};
}
protected override _currentUserFromCrud(crud: UsersCrud['Server']['Read'], session: InternalSession): ProjectCurrentServerUser<ProjectId> {
const currentUser = withUserDestructureGuard({
...this._serverUserFromCrud(crud),
...this._createAuth(session),
...this._isInternalProject() ? this._createInternalUserExtra(session) : {},
} satisfies ServerUser);
return currentUser as ProjectCurrentServerUser<ProjectId>;
}
protected _serverTeamFromCrud(crud: TeamsCrud['Server']['Read']): ServerTeam {
const app = this;
return {
id: crud.id,
displayName: crud.display_name,
profileImageUrl: crud.profile_image_url,
createdAt: new Date(crud.created_at_millis),
clientMetadata: crud.client_metadata,
clientReadOnlyMetadata: crud.client_read_only_metadata,
serverMetadata: crud.server_metadata,
async update(update: Partial<ServerTeamUpdateOptions>) {
await app._interface.updateServerTeam(crud.id, serverTeamUpdateOptionsToCrud(update));
await Promise.all([
app._serverTeamsCache.refreshWhere(() => true),
app._serverUsersCache.refreshWhere(() => true),
]);
},
async delete() {
await app._interface.deleteServerTeam(crud.id);
await Promise.all([
app._serverTeamsCache.refreshWhere(() => true),
app._serverUsersCache.refreshWhere(() => true),
]);
},
async listUsers() {
const result = Result.orThrow(await app._serverTeamMemberProfilesCache.getOrWait([crud.id], "write-only"));
return result.map(u => app._serverTeamUserFromCrud(u));
},
// IF_PLATFORM react-like
useUsers() {
const result = useAsyncCache(app._serverTeamMemberProfilesCache, [crud.id] as const, "team.useUsers()");
return useMemo(() => result.map(u => app._serverTeamUserFromCrud(u)), [result]);
},
// END_PLATFORM
async addUser(userId) {
await app._interface.addServerUserToTeam({
teamId: crud.id,
userId,
});
await app._refreshTeamMembership(crud.id, userId);
},
async removeUser(userId) {
await app._interface.removeServerUserFromTeam({
teamId: crud.id,
userId,
});
await app._refreshTeamMembership(crud.id, userId);
},
async inviteUser(options: { email: string, callbackUrl?: string }) {
await app._interface.sendServerTeamInvitation({
teamId: crud.id,
email: options.email,
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.urls.teamInvitation, "callbackUrl"),
});
await app._serverTeamInvitationsCache.refresh([crud.id]);
},
async listInvitations() {
const result = Result.orThrow(await app._serverTeamInvitationsCache.getOrWait([crud.id], "write-only"));
return result.map((crud) => app._serverSentTeamInvitationFromCrud(crud));
},
// IF_PLATFORM react-like
useInvitations() {
const result = useAsyncCache(app._serverTeamInvitationsCache, [crud.id] as const, "team.useInvitations()");
return useMemo(() => result.map((crud) => app._serverSentTeamInvitationFromCrud(crud)), [result]);
},
// END_PLATFORM
// IF_PLATFORM react-like
useApiKeys() {
const result = useAsyncCache(app._serverTeamApiKeysCache, [crud.id] as const, "team.useApiKeys()");
return result.map((apiKey) => app._serverApiKeyFromCrud(apiKey));
},
// END_PLATFORM
async listApiKeys() {
const result = Result.orThrow(await app._serverTeamApiKeysCache.getOrWait([crud.id], "write-only"));
return result.map((apiKey) => app._serverApiKeyFromCrud(apiKey));
},
async createApiKey(options: ApiKeyCreationOptions<"team">) {
const result = await app._interface.createProjectApiKey(
await apiKeyCreationOptionsToCrud("team", crud.id, options),
null,
"server",
);
await app._serverTeamApiKeysCache.refresh([crud.id]);
return app._serverApiKeyFromCrud(result);
},
...app._createServerCustomer(crud.id, "team"),
};
}
protected _serverItemFromCrud(customer: { type: "user" | "team" | "custom", id: string }, crud: ItemCrud['Client']['Read']): ServerItem {
const app = this;
return {
displayName: crud.display_name,
quantity: crud.quantity,
nonNegativeQuantity: Math.max(0, crud.quantity),
increaseQuantity: async (delta: number) => {
const updateOptions = customer.type === "user"
? { itemId: crud.id, userId: customer.id }
: customer.type === "team"
? { itemId: crud.id, teamId: customer.id }
: { itemId: crud.id, customCustomerId: customer.id };
await app._interface.updateItemQuantity(updateOptions, { delta });
if (customer.type === "user") await app._serverUserItemsCache.refresh([customer.id, crud.id]);
else if (customer.type === "team") await app._serverTeamItemsCache.refresh([customer.id, crud.id]);
else await app._serverCustomItemsCache.refresh([customer.id, crud.id]);
},
decreaseQuantity: async (delta: number) => {
const updateOptions = customer.type === "user"
? { itemId: crud.id, userId: customer.id }
: customer.type === "team"
? { itemId: crud.id, teamId: customer.id }
: { itemId: crud.id, customCustomerId: customer.id };
await app._interface.updateItemQuantity(updateOptions, { delta: -delta, allow_negative: true });
if (customer.type === "user") await app._serverUserItemsCache.refresh([customer.id, crud.id]);
else if (customer.type === "team") await app._serverTeamItemsCache.refresh([customer.id, crud.id]);
else await app._serverCustomItemsCache.refresh([customer.id, crud.id]);
},
tryDecreaseQuantity: async (delta: number) => {
try {
const updateOptions = customer.type === "user"
? { itemId: crud.id, userId: customer.id }
: customer.type === "team"
? { itemId: crud.id, teamId: customer.id }
: { itemId: crud.id, customCustomerId: customer.id };
await app._interface.updateItemQuantity(updateOptions, { delta: -delta });
if (customer.type === "user") await app._serverUserItemsCache.refresh([customer.id, crud.id]);
else if (customer.type === "team") await app._serverTeamItemsCache.refresh([customer.id, crud.id]);
else await app._serverCustomItemsCache.refresh([customer.id, crud.id]);
return true;
} catch (error) {
if (error instanceof KnownErrors.ItemQuantityInsufficientAmount) {
return false;
}
throw error;
}
},
};
}
protected async _getUserApiKey(options: { apiKey: string }): Promise<ApiKey<"user"> | null> {
const crud = Result.orThrow(await this._serverCheckApiKeyCache.getOrWait(["user", options.apiKey], "write-only")) as UserApiKeysCrud['Server']['Read'] | null;
return crud ? this._serverApiKeyFromCrud(crud) : null;
}
protected async _getTeamApiKey(options: { apiKey: string }): Promise<ApiKey<"team"> | null> {
const crud = Result.orThrow(await this._serverCheckApiKeyCache.getOrWait(["team", options.apiKey], "write-only")) as TeamApiKeysCrud['Server']['Read'] | null;
return crud ? this._serverApiKeyFromCrud(crud) : null;
}
// IF_PLATFORM react-like
protected _useUserApiKey(options: { apiKey: string }): ApiKey<"user"> | null {
const crud = useAsyncCache(this._serverCheckApiKeyCache, ["user", options.apiKey] as const, "serverApp.useUserApiKey()") as UserApiKeysCrud['Server']['Read'] | null;
return useMemo(() => crud ? this._serverApiKeyFromCrud(crud) : null, [crud]);
}
// END_PLATFORM
// IF_PLATFORM react-like
protected _useTeamApiKey(options: { apiKey: string }): ApiKey<"team"> | null {
const crud = useAsyncCache(this._serverCheckApiKeyCache, ["team", options.apiKey] as const, "serverApp.useTeamApiKey()") as TeamApiKeysCrud['Server']['Read'] | null;
return useMemo(() => crud ? this._serverApiKeyFromCrud(crud) : null, [crud]);
}
// END_PLATFORM
protected async _getUserByApiKey(apiKey: string): Promise<ServerUser | null> {
const apiKeyObject = await this._getUserApiKey({ apiKey });
if (apiKeyObject === null) {
return null;
}
return await this.getServerUserById(apiKeyObject.userId);
}
protected async _getUserByConvex(ctx: ConvexCtx, includeAnonymous: boolean): Promise<ServerUser | null> {
const identity = await ctx.auth.getUserIdentity();
if (identity === null) {
return null;
}
const user = await this.getServerUserById(identity.subject);
if (user?.isAnonymous && !includeAnonymous) {
return null;
}
return user;
}
// IF_PLATFORM react-like
protected _useUserByConvex(ctx: ConvexCtx, includeAnonymous: boolean): ServerUser | null {
const subject = useAsyncCache(this._convexIdentitySubjectCache, [ctx] as const, "serverApp.useUserByConvex()");
if (subject === null) {
return null;
}
const user = this.useUserById(subject);
if (user?.isAnonymous && !includeAnonymous) {
return null;
}
return user;
}
// END_PLATFORM
// IF_PLATFORM react-like
protected _useUserByApiKey(apiKey: string): ServerUser | null {
const apiKeyObject = this._useUserApiKey({ apiKey });
if (apiKeyObject === null) {
return null;
}
return this.useUserById(apiKeyObject.userId);
}
// END_PLATFORM
protected async _getTeamByApiKey(apiKey: string): Promise<ServerTeam | null> {
const apiKeyObject = await this._getTeamApiKey({ apiKey });
if (apiKeyObject === null) {
return null;
}
return await this.getTeam(apiKeyObject.teamId);
}
// IF_PLATFORM react-like
protected _useTeamByApiKey(apiKey: string): ServerTeam | null {
const apiKeyObject = this._useTeamApiKey({ apiKey });
if (apiKeyObject === null) {
return null;
}
return this.useTeam(apiKeyObject.teamId);
}
// END_PLATFORM
async createUser(options: ServerUserCreateOptions): Promise<ServerUser> {
const crud = await this._interface.createServerUser(serverUserCreateOptionsToCrud(options));
await this._refreshUsers();
return this._serverUserFromCrud(crud);
}
async getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): Promise<ProjectCurrentServerUser<ProjectId>>;
async getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): Promise<ProjectCurrentServerUser<ProjectId>>;
async getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'anonymous' }): Promise<ProjectCurrentServerUser<ProjectId>>;
async getUser(options?: GetCurrentUserOptions<HasTokenStore>): Promise<ProjectCurrentServerUser<ProjectId> | null>;
async getUser(id: string): Promise<ServerUser | null>;
async getUser(options: { apiKey: string }): Promise<ServerUser | null>;
async getUser(options: { from: "convex", ctx: ConvexCtx, or?: "return-null" | "anonymous" }): Promise<ServerUser | null>;
async getUser(options?: string | GetCurrentUserOptions<HasTokenStore> | { apiKey: string } | { from: "convex", ctx: ConvexCtx }): Promise<ProjectCurrentServerUser<ProjectId> | ServerUser | null> {
if (typeof options === "string") {
return await this.getServerUserById(options);
} else if (typeof options === "object" && "apiKey" in options) {
return await this._getUserByApiKey(options.apiKey);
} else if (typeof options === "object" && "from" in options && options.from as string === "convex") {
return await this._getUserByConvex(options.ctx, "or" in options && options.or === "anonymous");
} else {
options = options as GetCurrentUserOptions<HasTokenStore> | undefined;
// Validate that includeRestricted: false and or: 'anonymous' are mutually exclusive
if (options?.or === 'anonymous' && options.includeRestricted === false) {
throw new Error("Cannot use { or: 'anonymous' } with { includeRestricted: false }. Anonymous users implicitly include restricted users.");
}
// TODO this code is duplicated from the client app; fix that
this._ensurePersistentTokenStore(options?.tokenStore);
const session = await this._getSession(options?.tokenStore);
let crud = Result.orThrow(await this._currentServerUserCache.getOrWait([session], "write-only"));
const includeAnonymous = options?.or === "anonymous" || options?.or === "anonymous-if-exists[deprecated]";
const includeRestricted = options?.includeRestricted === true || includeAnonymous;
if (crud === null || (crud.is_anonymous && !includeAnonymous) || (crud.is_restricted && !includeRestricted)) {
switch (options?.or) {
case 'redirect': {
if (!crud?.is_anonymous && crud?.is_restricted) {
await this.redirectToOnboarding({ replace: true });
} else {
await this.redirectToSignIn({ replace: true });
}
// TODO: see client-app-impl. We probably want to `await neverResolve()` here instead of returning null
break;
}
case 'throw': {
throw new Error("User is not signed in but getUser was called with { or: 'throw' }");
}
case 'anonymous': {
const tokens = await this._signUpAnonymously();
return await this.getUser({ tokenStore: tokens, or: "anonymous-if-exists[deprecated]", includeRestricted: true }) ?? throwErr("Something went wrong while signing up anonymously");
}
case undefined:
case "anonymous-if-exists[deprecated]":
case "return-null": {
return null;
}
}
}
return crud && this._currentUserFromCrud(crud, session);
}
}
async getServerUser(): Promise<ProjectCurrentServerUser<ProjectId> | null> {
console.warn("stackServerApp.getServerUser is deprecated; use stackServerApp.getUser instead");
return await this.getUser();
}
async getServerUserById(userId: string): Promise<ServerUser | null> {
const crud = Result.orThrow(await this._serverUserCache.getOrWait([userId], "write-only"));
return crud && this._serverUserFromCrud(crud);
}
// IF_PLATFORM react-like
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentServerUser<ProjectId>;
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentServerUser<ProjectId>;
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'anonymous' }): ProjectCurrentServerUser<ProjectId>;
useUser(options?: GetCurrentUserOptions<HasTokenStore>): ProjectCurrentServerUser<ProjectId> | null;
useUser(id: string): ServerUser | null;
useUser(options: { apiKey: string }): ServerUser | null;
useUser(options: { from: "convex", ctx: ConvexCtx, or?: "return-null" | "anonymous" }): ServerUser | null;
useUser(options?: GetCurrentUserOptions<HasTokenStore> | string | { apiKey: string } | { from: "convex", ctx: ConvexCtx }): ProjectCurrentServerUser<ProjectId> | ServerUser | null {
if (typeof options === "string") {
return this.useUserById(options);
} else if (typeof options === "object" && "apiKey" in options) {
return this._useUserByApiKey(options.apiKey);
} else if (typeof options === "object" && "from" in options && options.from as string === "convex") {
return this._useUserByConvex(options.ctx, "or" in options && options.or === "anonymous");
} else {
options = options as GetCurrentUserOptions<HasTokenStore> | undefined;
// TODO this code is duplicated from the client app; fix that
// Validate that includeRestricted: false and or: 'anonymous' are mutually exclusive
if (options?.or === 'anonymous' && options.includeRestricted === false) {
throw new Error("Cannot use { or: 'anonymous' } with { includeRestricted: false }. Anonymous users implicitly include restricted users.");
}
this._ensurePersistentTokenStore(options?.tokenStore);
const session = this._useSession(options?.tokenStore);
let crud = useAsyncCache(this._currentServerUserCache, [session] as const, "serverApp.useUser()");
const includeAnonymous = options?.or === "anonymous" || options?.or === "anonymous-if-exists[deprecated]";
const includeRestricted = options?.includeRestricted === true || includeAnonymous;
if (crud === null) {
switch (options?.or) {
case 'redirect': {
runAsynchronously(this.redirectToSignIn({ replace: true }));
suspend();
throw new HexclaveAssertionError("suspend should never return");
}
case 'throw': {
throw new Error("User is not signed in but useUser was called with { or: 'throw' }");
}
case 'anonymous': {
// TODO we should think about the behavior when calling useUser (or getUser) in anonymous with a custom token store. signUpAnonymously always sets the current token store on app level, instead of the one passed to this function
// TODO we shouldn't reload & suspend here, instead we should use a promise that resolves to the new anonymous user
runAsynchronously(async () => {
await this._signUpAnonymously();
if (typeof window !== "undefined") {
window.location.reload();
}
});
suspend();
throw new HexclaveAssertionError("suspend should never return");
}
case undefined:
case "anonymous-if-exists[deprecated]":
case "return-null": {
// do nothing
}
}
}
return useMemo(() => {
return crud && this._currentUserFromCrud(crud, session);
}, [crud, session, options?.or]);
}
}
// END_PLATFORM
// IF_PLATFORM react-like
useUserById(userId: string): ServerUser | null {
const crud = useAsyncCache(this._serverUserCache, [userId], "serverApp.useUserById()");
return useMemo(() => {
return crud && this._serverUserFromCrud(crud);
}, [crud]);
}
// END_PLATFORM
async listUsers(options?: ServerListUsersOptions): Promise<ServerUser[] & { nextCursor: string | null }> {
const crud = Result.orThrow(await this._serverUsersCache.getOrWait([options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query, options?.includeRestricted, options?.includeAnonymous, options?.onlyAnonymous, options?.teamId], "write-only"));
const result: any = crud.items.map((j) => this._serverUserFromCrud(j));
result.nextCursor = crud.pagination?.next_cursor ?? null;
return result as any;
}
// IF_PLATFORM react-like
useUsers(options?: ServerListUsersOptions): ServerUser[] & { nextCursor: string | null } {
const crud = useAsyncCache(this._serverUsersCache, [options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query, options?.includeRestricted, options?.includeAnonymous, options?.onlyAnonymous, options?.teamId] as const, "serverApp.useUsers()");
const result: any = crud.items.map((j) => this._serverUserFromCrud(j));
result.nextCursor = crud.pagination?.next_cursor ?? null;
return result as any;
}
// END_PLATFORM
_serverPermissionFromCrud(crud: TeamPermissionsCrud['Server']['Read'] | ProjectPermissionsCrud['Server']['Read']): AdminTeamPermission {
return {
id: crud.id,
};
}
_serverTeamPermissionDefinitionFromCrud(crud: TeamPermissionDefinitionsCrud['Admin']['Read']): AdminTeamPermissionDefinition {
return {
id: crud.id,
description: crud.description,
containedPermissionIds: crud.contained_permission_ids,
};
}
_serverProjectPermissionDefinitionFromCrud(crud: ProjectPermissionDefinitionsCrud['Admin']['Read']): AdminProjectPermissionDefinition {
return {
id: crud.id,
description: crud.description,
containedPermissionIds: crud.contained_permission_ids,
};
}
async getItem(options: { itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customCustomerId: string }): Promise<ServerItem> {
if ("userId" in options) {
const result = Result.orThrow(await this._serverUserItemsCache.getOrWait([options.userId, options.itemId], "write-only"));
return this._serverItemFromCrud({ type: "user", id: options.userId }, result);
} else if ("teamId" in options) {
const result = Result.orThrow(await this._serverTeamItemsCache.getOrWait([options.teamId, options.itemId], "write-only"));
return this._serverItemFromCrud({ type: "team", id: options.teamId }, result);
} else {
const result = Result.orThrow(await this._serverCustomItemsCache.getOrWait([options.customCustomerId, options.itemId], "write-only"));
return this._serverItemFromCrud({ type: "custom", id: options.customCustomerId }, result);
}
}
async listProducts(options: CustomerProductsRequestOptions): Promise<CustomerProductsList> {
if ("userId" in options) {
const response = Result.orThrow(await this._serverUserProductsCache.getOrWait([options.userId, options.cursor ?? null, options.limit ?? null], "write-only"));
return this._customerProductsFromResponse(response);
} else if ("teamId" in options) {
const response = Result.orThrow(await this._serverTeamProductsCache.getOrWait([options.teamId, options.cursor ?? null, options.limit ?? null], "write-only"));
return this._customerProductsFromResponse(response);
}
const response = Result.orThrow(await this._serverCustomProductsCache.getOrWait([options.customCustomerId, options.cursor ?? null, options.limit ?? null], "write-only"));
return this._customerProductsFromResponse(response);
}
// IF_PLATFORM react-like
useItem(options: { itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customCustomerId: string }): ServerItem {
let type: "user" | "team" | "custom";
let id: string;
let cache: AsyncCache<[string, string], Result<ItemCrud['Client']['Read']>>;
if ("userId" in options) {
type = "user";
id = options.userId;
cache = this._serverUserItemsCache;
} else if ("teamId" in options) {
type = "team";
id = options.teamId;
cache = this._serverTeamItemsCache;
} else {
type = "custom";
id = options.customCustomerId;
cache = this._serverCustomItemsCache;
}
const cacheKey = [id, options.itemId] as [string, string];
const debugLabel = "serverApp.useItem()";
const result = useAsyncCache(cache, cacheKey, debugLabel);
return useMemo(() => this._serverItemFromCrud({ type, id }, result), [result]);
}
// END_PLATFORM
async grantProduct(options: (
({ userId: string } | { teamId: string } | { customCustomerId: string }) &
({ productId: string } | { product: InlineProduct }) &
{ quantity?: number }
)): Promise<void> {
let customerType: "user" | "team" | "custom";
let customerId: string;
if ("userId" in options) {
customerType = "user";
customerId = options.userId;
} else if ("teamId" in options) {
customerType = "team";
customerId = options.teamId;
} else {
customerType = "custom";
customerId = options.customCustomerId;
}
await this._interface.grantProduct({
customerType,
customerId,
productId: "productId" in options ? options.productId : undefined,
product: "product" in options ? options.product : undefined,
quantity: options.quantity,
});
const cache = customerType === "user"
? this._serverUserProductsCache
: customerType === "team"
? this._serverTeamProductsCache
: this._serverCustomProductsCache;
await cache.refresh([customerId, null, null]);
}
async createTeam(data: ServerTeamCreateOptions): Promise<ServerTeam> {
const team = await this._interface.createServerTeam(serverTeamCreateOptionsToCrud(data));
await this._serverTeamsCache.refreshWhere(() => true);
return this._serverTeamFromCrud(team);
}
async listTeams(options?: ServerListTeamsOptions): Promise<ServerTeam[] & { nextCursor: string | null }> {
const crud = Result.orThrow(await this._serverTeamsCache.getOrWait([undefined, options?.orderBy, options?.desc, options?.cursor, options?.limit, options?.query] as const, "write-only"));
const teams: any = crud.items.map((t) => this._serverTeamFromCrud(t));
teams.nextCursor = crud.pagination?.next_cursor ?? null;
return teams as any;
}
// IF_PLATFORM react-like
useTeams(options?: ServerListTeamsOptions): ServerTeam[] & { nextCursor: string | null } {
const crud = useAsyncCache(this._serverTeamsCache, [undefined, options?.orderBy, options?.desc, options?.cursor, options?.limit, options?.query] as const, "serverApp.useTeams()");
return useMemo(() => {
const teams: any = crud.items.map((t) => this._serverTeamFromCrud(t));
teams.nextCursor = crud.pagination?.next_cursor ?? null;
return teams as any;
}, [crud]);
}
// END_PLATFORM
async listTeamMemberPermissions(teamId: string, options?: { recursive?: boolean }): Promise<{ userId: string, permissionId: string }[]> {
const recursive = options?.recursive ?? false;
const rows = Result.orThrow(await this._serverAllTeamMemberPermissionsCache.getOrWait([teamId, recursive] as const, "write-only"));
return rows.map((r) => ({ userId: r.user_id, permissionId: r.id }));
}
// IF_PLATFORM react-like
useTeamMemberPermissions(teamId: string, options?: { recursive?: boolean }): { userId: string, permissionId: string }[] {
const recursive = options?.recursive ?? false;
const rows = useAsyncCache(this._serverAllTeamMemberPermissionsCache, [teamId, recursive] as const, "serverApp.useTeamMemberPermissions()");
return useMemo(() => rows.map((r) => ({ userId: r.user_id, permissionId: r.id })), [rows]);
}
// END_PLATFORM
async getTeam(options: { apiKey: string }): Promise<ServerTeam | null>;
async getTeam(teamId: string): Promise<ServerTeam | null>;
async getTeam(options?: { apiKey: string } | string): Promise<ServerTeam | null> {
if (typeof options === "object" && "apiKey" in options) {
return await this._getTeamByApiKey(options.apiKey);
} else {
const teamId = options;
const teams = await this.listTeams();
return teams.find((t) => t.id === teamId) ?? null;
}
}
// IF_PLATFORM react-like
useTeam(options: { apiKey: string }): ServerTeam | null;
useTeam(teamId: string): ServerTeam | null;
useTeam(options?: { apiKey: string } | string): ServerTeam | null {
if (typeof options === "object" && "apiKey" in options) {
return this._useTeamByApiKey(options.apiKey);
} else {
const teamId = options;
const teams = this.useTeams();
return useMemo(() => {
return teams.find((t) => t.id === teamId) ?? null;
}, [teams, teamId]);
}
}
// END_PLATFORM
protected _createServerDataVaultStore(id: string): DataVaultStore {
const validateOptions = (options: { secret: string }) => {
if (typeof options.secret !== "string") throw new Error("secret must be a string, got " + typeof options.secret);
};
return {
id,
setValue: async (key, value, options) => {
validateOptions(options);
await this._interface.setDataVaultStoreValue(options.secret, id, key, value);
},
getValue: async (key, options) => {
validateOptions(options);
return Result.orThrow(await this._serverDataVaultStoreValueCache.getOrWait([id, key, options.secret], "write-only"));
},
// IF_PLATFORM react-like
useValue: (key, options) => {
validateOptions(options);
return useAsyncCache(this._serverDataVaultStoreValueCache, [id, key, options.secret] as const, "store.useValue()");
},
// END_PLATFORM
};
}
async getDataVaultStore(id: string): Promise<DataVaultStore> {
return this._createServerDataVaultStore(id);
}
// IF_PLATFORM react-like
useDataVaultStore(id: string): DataVaultStore {
return useMemo(() => this._createServerDataVaultStore(id), [id]);
}
// END_PLATFORM
async sendEmail(options: SendEmailOptions): Promise<void> {
await this._interface.sendEmail(options);
await this._emailDeliveryInfoCache.refresh([]);
}
async getEmailDeliveryStats(): Promise<EmailDeliveryInfo> {
return Result.orThrow(await this._emailDeliveryInfoCache.getOrWait([], "write-only"));
}
// IF_PLATFORM react-like
useEmailDeliveryStats(): EmailDeliveryInfo {
return useAsyncCache(this._emailDeliveryInfoCache, [], "stackServerApp.useEmailDeliveryStats()");
}
// END_PLATFORM
async activateEmailCapacityBoost(): Promise<void> {
await this._interface.activateEmailCapacityBoost();
// Refresh the cache so UI updates immediately
await this._emailDeliveryInfoCache.refresh([]);
}
protected override async _refreshSession(session: InternalSession) {
await Promise.all([
super._refreshUser(session),
this._currentServerUserCache.refresh([session]),
]);
}
protected override async _refreshUsers() {
await Promise.all([
super._refreshUsers(),
this._serverUserCache.refreshWhere(() => true),
this._serverUsersCache.refreshWhere(() => true),
this._serverContactChannelsCache.refreshWhere(() => true),
this._serverOAuthProvidersCache.refreshWhere(() => true),
this._serverUserConnectedAccountsCache.refreshWhere(() => true),
]);
}
async createOAuthProvider(options: {
userId: string,
providerConfigId: string,
accountId: string,
email: string,
allowSignIn: boolean,
allowConnectedAccounts: boolean,
}): Promise<Result<ServerOAuthProvider, InstanceType<typeof KnownErrors.OAuthProviderAccountIdAlreadyUsedForSignIn>>> {
try {
const crud = await this._interface.createServerOAuthProvider({
user_id: options.userId,
provider_config_id: options.providerConfigId,
account_id: options.accountId,
email: options.email,
allow_sign_in: options.allowSignIn,
allow_connected_accounts: options.allowConnectedAccounts,
});
await Promise.all([
this._serverOAuthProvidersCache.refresh([options.userId]),
this._serverUserConnectedAccountsCache.refresh([options.userId]),
]);
return Result.ok(this._serverOAuthProviderFromCrud(crud));
} catch (error) {
if (KnownErrors.OAuthProviderAccountIdAlreadyUsedForSignIn.isInstance(error)) {
return Result.error(error);
}
throw error;
}
}
}