@protobase/server 0.63.0 → 0.63.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -113,10 +113,10 @@ export type CreateAuthOptions = {
113
113
  /**
114
114
  * The mail providers people may link their own mailbox with, from the account page, for the app's functions to send
115
115
  * mail from it (`context.personalMail`), such as `['gmail']`. Each links through its sign-in provider, which
116
- * `socialProviders` must have: Gmail through `google`. Default none.
116
+ * `socialProviders` must have: Gmail through `google`. Needs `encryptOAuthTokens`. Default none.
117
117
  */
118
118
  personalMail?: PersonalMailProviderId[];
119
- /** Encrypt the OAuth access, refresh and ID tokens Better Auth stores for social sign-ins, with `secret`. Default off. */
119
+ /** Encrypt the OAuth access and refresh tokens Better Auth stores for social sign-ins, with `secret`; `personalMail` needs it. Default off. */
120
120
  encryptOAuthTokens?: boolean;
121
121
  /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */
122
122
  onUserCreated?: UserCreatedHook;
@@ -78,6 +78,7 @@ const tokenLifetime = "15m";
78
78
  /** The configured roles with `admin` first; `admin` is added when missing. */
79
79
  const normalizeRoles = (roles) => roleDefinitions(roles).names;
80
80
  const adminImpersonationPaths = ["/admin/impersonate-user", "/admin/stop-impersonating"];
81
+ const providerTokenPaths = ["/get-access-token", "/refresh-token"];
81
82
  /** The optional plugins `options` turns on, imported: an app without SAML or MCP loads neither. */
82
83
  const loadAuthPlugins = async (options) => ({
83
84
  ...(options.sso?.saml.length ?? 0) > 0 && { samlPlugin: (await import("./saml-plugin.js")).samlPlugin },
@@ -268,6 +269,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
268
269
  ...accountDeletion ? [] : [selfDeletionPath],
269
270
  ...unusedEmailCodePaths,
270
271
  ...adminImpersonationPaths,
272
+ ...providerTokenPaths,
271
273
  ...organizations ? organizationPaths : [],
272
274
  ...saml.length > 0 ? samlDisabledPaths : [],
273
275
  ...mcp ? mcpUnusedPaths : []
@@ -371,7 +373,7 @@ const createAuth = (options, plugins = {}) => {
371
373
  const socialProviders = Object.keys(options.socialProviders ?? {});
372
374
  const platformSignIn = platformSignInFor(signInSettings && resolvePlatformSignIn(signInSettings), socialProviders);
373
375
  const saml = resolveSamlProviders(options.sso?.saml ?? [], [...socialProviders, ...platformSignIn ? [platformSignIn.provider] : []]);
374
- const personalMail = resolvePersonalMail(options.personalMail ?? [], socialProviders);
376
+ const personalMail = resolvePersonalMail(options.personalMail ?? [], socialProviders, options.encryptOAuthTokens === true);
375
377
  const origins = resolveOrigins(options.baseURL, options.origins ?? platformOrigins());
376
378
  const passkeyDomain = resolvePasskeyDomain(options.passkeyDomain ?? platformPasskeyDomain(), [options.baseURL, ...origins]);
377
379
  const organizations = options.organizations && resolveOrganizations(options.organizations, definitions, options.baseURL);
@@ -477,4 +479,4 @@ const createAuth = (options, plugins = {}) => {
477
479
  /** `createAuth` with the optional plugins its options turn on loaded first (see `loadAuthPlugins`). */
478
480
  const buildAuth = async (options) => createAuth(options, await loadAuthPlugins(options));
479
481
  //#endregion
480
- export { authBasePath, betterAuthOptions, buildAuth, createAuth, loadAuthPlugins, normalizeRoles };
482
+ export { authBasePath, betterAuthOptions, buildAuth, createAuth, loadAuthPlugins, normalizeRoles, providerTokenPaths };
@@ -37,21 +37,28 @@ const organizationClaims = async (adapter, user, activeOrganizationId) => {
37
37
  return {};
38
38
  };
39
39
  /**
40
+ * The organization a new session of `user` starts in (`initialOrganization`): the one they last worked in while they
41
+ * may still enter it, else their oldest membership, else none.
42
+ */
43
+ const startingOrganization = async (adapter, user) => {
44
+ const last = user.lastOrganizationId;
45
+ return initialOrganization({
46
+ memberships: summaries(await membershipsOf(adapter, user.id)),
47
+ last,
48
+ entersAny: globalRoles(user.role).length > 0,
49
+ lastExists: Boolean(last) && await findOrganization(adapter, last) !== null
50
+ });
51
+ };
52
+ /**
40
53
  * Better Auth's session hooks for organizations: a new session starts in the organization the person last worked in
41
- * (`initialOrganization`), and a session's change of organization is kept on the user as the next one's start.
54
+ * (`startingOrganization`), and a session's change of organization is kept on the user as the next one's start.
42
55
  */
43
56
  const sessionOrganizationHooks = (context) => ({
44
57
  create: { before: async (session) => {
45
58
  const { adapter, internalAdapter } = await context();
46
59
  const user = await internalAdapter.findUserById(session.userId);
47
60
  if (!user) return;
48
- const last = user.lastOrganizationId;
49
- const activeOrganizationId = initialOrganization({
50
- memberships: summaries(await membershipsOf(adapter, user.id)),
51
- last,
52
- entersAny: globalRoles(user.role).length > 0,
53
- lastExists: Boolean(last) && await findOrganization(adapter, last) !== null
54
- });
61
+ const activeOrganizationId = await startingOrganization(adapter, user);
55
62
  return activeOrganizationId ? { data: {
56
63
  ...session,
57
64
  activeOrganizationId
@@ -65,4 +72,4 @@ const sessionOrganizationHooks = (context) => ({
65
72
  } }
66
73
  });
67
74
  //#endregion
68
- export { lastOrganizationField, mayEnter, organizationClaims, sessionOrganizationHooks };
75
+ export { lastOrganizationField, mayEnter, organizationClaims, sessionOrganizationHooks, startingOrganization };
@@ -4,12 +4,18 @@ export type IssueTokenOptions = {
4
4
  email: string;
5
5
  /** Lifetime in seconds. Default 15 minutes, at most 24 hours. */
6
6
  ttlSeconds?: number;
7
+ /**
8
+ * With organizations, the organization the token works in, by id or slug; refused unless the person may work in it.
9
+ * Default: the one a new session of theirs would start in.
10
+ */
11
+ organization?: string;
7
12
  };
8
13
  export declare const maxTokenTtlSeconds: number;
9
14
  /**
10
- * Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims
11
- * (`sub`, `email`, `role`) as `/api/auth/token`, so the API accepts it like a token from a browser session. It carries
12
- * the role the user last chose, else their first.
15
+ * Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims as
16
+ * `/api/auth/token` for a new session of theirs (see `tokenClaims`), so the API accepts it like a token from a browser
17
+ * session. It carries the role the user last chose, else their first; with organizations, the claims of
18
+ * `options.organization`, else of the organization a new session would start in.
13
19
  * Host side only: there is no HTTP route for this.
14
20
  */
15
21
  export declare const issueToken: (auth: AdminAuth, options: IssueTokenOptions) => Promise<{
@@ -1,17 +1,26 @@
1
- import { parseRoles } from "./parse-roles.js";
2
- import { narrowClaims } from "./roles/active-role.js";
1
+ import { mayEnter, startingOrganization } from "./organizations/session-organization.js";
2
+ import { tokenClaims } from "./token-claims.js";
3
+ import { findOrganizationByKey } from "./organizations/host.js";
3
4
  //#region src/better-auth/tokens.ts
4
5
  const defaultTtlSeconds = 900;
5
6
  const maxTokenTtlSeconds = 86400;
7
+ const organizationOf = async (auth, adapter, user, organization) => {
8
+ if (organization === void 0) return startingOrganization(adapter, user);
9
+ const found = await findOrganizationByKey(auth, organization);
10
+ if (!await mayEnter(adapter, user, found.id)) throw new Error(`${user.email} may not work in organization "${organization}"`);
11
+ return found.id;
12
+ };
6
13
  /**
7
- * Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims
8
- * (`sub`, `email`, `role`) as `/api/auth/token`, so the API accepts it like a token from a browser session. It carries
9
- * the role the user last chose, else their first.
14
+ * Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims as
15
+ * `/api/auth/token` for a new session of theirs (see `tokenClaims`), so the API accepts it like a token from a browser
16
+ * session. It carries the role the user last chose, else their first; with organizations, the claims of
17
+ * `options.organization`, else of the organization a new session would start in.
10
18
  * Host side only: there is no HTTP route for this.
11
19
  */
12
20
  const issueToken = async (auth, options) => {
13
21
  const ttl = options.ttlSeconds ?? defaultTtlSeconds;
14
22
  if (!Number.isInteger(ttl) || ttl < 1 || ttl > 86400) throw new Error(`Token lifetime must be between 1 second and ${maxTokenTtlSeconds} seconds (24 hours)`);
23
+ if (options.organization !== void 0 && !auth.organizations) throw new Error("Organizations are off: a token cannot name an organization");
15
24
  const { adapter } = await auth.$context;
16
25
  const user = await adapter.findOne({
17
26
  model: "user",
@@ -22,15 +31,19 @@ const issueToken = async (auth, options) => {
22
31
  });
23
32
  if (!user) throw new Error(`No user with email ${options.email}`);
24
33
  if (user.banned) throw new Error(`${options.email} is banned`);
34
+ const organizationId = auth.organizations ? await organizationOf(auth, adapter, user, options.organization) : void 0;
35
+ const claims = await tokenClaims(user, {
36
+ wanted: [user.lastRole],
37
+ organizationId,
38
+ ...auth.organizations && { organizations: adapter }
39
+ });
25
40
  const issuedAt = Math.floor(Date.now() / 1e3);
26
- const { role } = narrowClaims({ role: parseRoles(user.role).join(",") }, [user.lastRole], Boolean(auth.organizations));
27
41
  await auth.api.ensureSigningKey();
28
42
  const { token } = await auth.api.signJWT({ body: { payload: {
29
43
  iat: issuedAt,
30
44
  exp: issuedAt + ttl,
31
45
  sub: user.id,
32
- email: user.email,
33
- role
46
+ ...claims
34
47
  } } });
35
48
  return {
36
49
  token,
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.63.0";
2
+ var version = "0.63.2";
3
3
  //#endregion
4
4
  export { version };
@@ -4,13 +4,17 @@ import { gmail } from "./gmail.js";
4
4
  const personalMailProviders = { gmail };
5
5
  /**
6
6
  * The providers of `ids`, each checked against the app's sign-in providers: linking one goes through its sign-in
7
- * provider, so an app without it is a configuration error at startup.
7
+ * provider, so an app without it is a configuration error at startup. So is one whose OAuth tokens are not encrypted
8
+ * (`encryptOAuthTokens`): a linked account's tokens send mail as the person, so they are never stored in plain text.
8
9
  */
9
- const resolvePersonalMail = (ids, signInProviders) => ids.map((id) => {
10
- if (!Object.hasOwn(personalMailProviders, id)) throw new Error(`auth.personalMail: "${id}" is not a mail provider; use one of ${Object.keys(personalMailProviders).join(", ")}`);
11
- const provider = personalMailProviders[id]();
12
- if (!signInProviders.includes(provider.signInProvider)) throw new Error(`auth.personalMail: ${provider.name} links through the sign-in provider "${provider.signInProvider}"; add it to auth.socialProviders`);
13
- return provider;
14
- });
10
+ const resolvePersonalMail = (ids, signInProviders, encryptOAuthTokens) => {
11
+ if (ids.length > 0 && !encryptOAuthTokens) throw new Error("auth.personalMail: the linked mailboxes' tokens would be stored in plain text; turn on auth.encryptOAuthTokens");
12
+ return ids.map((id) => {
13
+ if (!Object.hasOwn(personalMailProviders, id)) throw new Error(`auth.personalMail: "${id}" is not a mail provider; use one of ${Object.keys(personalMailProviders).join(", ")}`);
14
+ const provider = personalMailProviders[id]();
15
+ if (!signInProviders.includes(provider.signInProvider)) throw new Error(`auth.personalMail: ${provider.name} links through the sign-in provider "${provider.signInProvider}"; add it to auth.socialProviders`);
16
+ return provider;
17
+ });
18
+ };
15
19
  //#endregion
16
20
  export { personalMailProviders, resolvePersonalMail };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.63.0",
3
+ "version": "0.63.2",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -42,9 +42,9 @@
42
42
  "@better-auth/sso": "^1.7.12",
43
43
  "@livekit/protocol": "1.53.1",
44
44
  "@modelcontextprotocol/server": "^2.3.1",
45
- "@protobase/layout": "0.63.0",
46
- "@protobase/query": "0.63.0",
47
- "@protobase/schema": "0.63.0",
45
+ "@protobase/layout": "0.63.2",
46
+ "@protobase/query": "0.63.2",
47
+ "@protobase/schema": "0.63.2",
48
48
  "@react-email/render": "^2.1.0",
49
49
  "@scalar/hono-api-reference": "^0.12.10",
50
50
  "@sentry/core": "^11.7.0",