@protobase/server 0.28.4 → 0.29.1

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.
package/dist/auth/jwt.js CHANGED
@@ -32,8 +32,8 @@ const jwtVerifier = (options) => {
32
32
  })).payload;
33
33
  };
34
34
  };
35
- /** Verifies `Authorization: Bearer <jwt>` against a JWKS; `sub` is the user id. Without the header, `MissingCredentials`. */
36
- const jwtAuthenticator = (options) => {
35
+ /** Like `jwtAuthenticator`, and also returns the verified claims, for an authenticator that checks more of them. */
36
+ const jwtSessions = (options) => {
37
37
  const { rolesClaim = "roles", tenantClaim = "tenant" } = options;
38
38
  const verify = jwtVerifier(options);
39
39
  return async (request) => {
@@ -48,13 +48,21 @@ const jwtAuthenticator = (options) => {
48
48
  const claimed = typeof tenant === "string" || typeof tenant === "number" ? tenant : void 0;
49
49
  const tenantValue = options.tenant === void 0 ? claimed : typeof options.tenant === "function" ? await options.tenant() : options.tenant;
50
50
  return {
51
- user: {
52
- id: sub,
53
- roles: [.../* @__PURE__ */ new Set([...rolesOf(roles), ...extraRoles])]
54
- },
55
- ...tenantValue !== void 0 && { tenant: tenantValue }
51
+ payload,
52
+ session: {
53
+ user: {
54
+ id: sub,
55
+ roles: [.../* @__PURE__ */ new Set([...rolesOf(roles), ...extraRoles])]
56
+ },
57
+ ...tenantValue !== void 0 && { tenant: tenantValue }
58
+ }
56
59
  };
57
60
  };
58
61
  };
62
+ /** Verifies `Authorization: Bearer <jwt>` against a JWKS; `sub` is the user id. Without the header, `MissingCredentials`. */
63
+ const jwtAuthenticator = (options) => {
64
+ const sessions = jwtSessions(options);
65
+ return async (request) => (await sessions(request)).session;
66
+ };
59
67
  //#endregion
60
- export { jwtAuthenticator, jwtVerifier };
68
+ export { jwtAuthenticator, jwtSessions, jwtVerifier };
@@ -28,7 +28,10 @@ export declare const betterAuthTokens: (options: Pick<BetterAuthAuthenticatorOpt
28
28
  * Verifies the bearer JWT that Better Auth's jwt plugin issues. `sub` is the user id, the `role` claim (a comma
29
29
  * separated string from the admin plugin) becomes `roles`. With organizations, the `org` claim is the tenant and the
30
30
  * organization, and the membership's `app_roles` are added to the global roles; `user`, which stands for no global
31
- * role, is left out. With `apps`, the tokens of those apps are admitted too, with the roles each gives its users here.
31
+ * role, is left out. With `auth`, every request also reads the store to check that the token still stands
32
+ * (`checkTokenStanding`), so a ban, a lowered role, a deleted user or a sign-out ends it at once; with `jwksUrl` alone
33
+ * a token works until it expires. With `apps`, the tokens of those apps are admitted too, with the roles each gives
34
+ * its users here.
32
35
  */
33
36
  export declare const betterAuthAuthenticator: (options: BetterAuthAuthenticatorOptions) => Authenticator;
34
37
  //#endregion
@@ -1,7 +1,8 @@
1
- import { jwtAuthenticator } from "../auth/jwt.js";
1
+ import { jwtSessions } from "../auth/jwt.js";
2
2
  import { noGlobalRole } from "./organizations/role-definitions.js";
3
3
  import { cachedKeys } from "./jwks-cache.js";
4
4
  import { withTrustedApps } from "./app-tokens.js";
5
+ import { checkTokenStanding, tokenClaims } from "./token-standing.js";
5
6
  //#region src/better-auth/authenticator.ts
6
7
  /** How the tokens of this Better Auth instance verify: its key set, and its issuer as both issuer and audience. */
7
8
  const betterAuthTokens = (options) => {
@@ -18,13 +19,16 @@ const betterAuthTokens = (options) => {
18
19
  * Verifies the bearer JWT that Better Auth's jwt plugin issues. `sub` is the user id, the `role` claim (a comma
19
20
  * separated string from the admin plugin) becomes `roles`. With organizations, the `org` claim is the tenant and the
20
21
  * organization, and the membership's `app_roles` are added to the global roles; `user`, which stands for no global
21
- * role, is left out. With `apps`, the tokens of those apps are admitted too, with the roles each gives its users here.
22
+ * role, is left out. With `auth`, every request also reads the store to check that the token still stands
23
+ * (`checkTokenStanding`), so a ban, a lowered role, a deleted user or a sign-out ends it at once; with `jwksUrl` alone
24
+ * a token works until it expires. With `apps`, the tokens of those apps are admitted too, with the roles each gives
25
+ * its users here.
22
26
  */
23
27
  const betterAuthAuthenticator = (options) => {
24
28
  const tokens = betterAuthTokens(options);
25
29
  const { issuer } = tokens;
26
30
  const organizations = options.organizations ?? Boolean(options.auth?.organizations);
27
- const verify = jwtAuthenticator({
31
+ const sessions = jwtSessions({
28
32
  ...tokens,
29
33
  rolesClaim: "role",
30
34
  ...organizations && {
@@ -33,6 +37,12 @@ const betterAuthAuthenticator = (options) => {
33
37
  },
34
38
  ...options.tenant !== void 0 && { tenant: options.tenant }
35
39
  });
40
+ const { auth } = options;
41
+ const verify = async (request) => {
42
+ const { payload, session } = await sessions(request);
43
+ if (auth) await checkTokenStanding(await auth.$context, tokenClaims(payload, session.user.roles, organizations ? session.tenant : void 0), organizations);
44
+ return session;
45
+ };
36
46
  return withTrustedApps(!organizations ? verify : async (request) => {
37
47
  const session = await verify(request);
38
48
  const user = {
@@ -1165,24 +1165,28 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
1165
1165
  }) => Promise<{
1166
1166
  role: any;
1167
1167
  email: string;
1168
+ sid: string;
1168
1169
  } | {
1169
1170
  org?: undefined;
1170
1171
  org_role?: undefined;
1171
1172
  app_roles?: undefined;
1172
1173
  role: any;
1173
1174
  email: string;
1175
+ sid: string;
1174
1176
  } | {
1175
1177
  org: string;
1176
1178
  org_role: OrganizationRole;
1177
1179
  app_roles: string[];
1178
1180
  role: any;
1179
1181
  email: string;
1182
+ sid: string;
1180
1183
  } | {
1181
1184
  org: string;
1182
1185
  org_role?: undefined;
1183
1186
  app_roles?: undefined;
1184
1187
  role: any;
1185
1188
  email: string;
1189
+ sid: string;
1186
1190
  }>;
1187
1191
  };
1188
1192
  disableSettingJwtHeader: true;
@@ -94,6 +94,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
94
94
  };
95
95
  return {
96
96
  email: user.email,
97
+ sid: session.id,
97
98
  ...narrowClaims(claims, [activeRole, user.lastRole], Boolean(organizations))
98
99
  };
99
100
  }
@@ -0,0 +1,55 @@
1
+ import { unauthorized } from "../problem.js";
2
+ import { noGlobalRole } from "./organizations/role-definitions.js";
3
+ import { parseRoles } from "./parse-roles.js";
4
+ import { effectiveRoles, globalRoles } from "./organizations/app-roles.js";
5
+ import { findMember, findOrganization } from "./organizations/store.js";
6
+ //#region src/better-auth/token-standing.ts
7
+ const tokenClaims = (payload, roles, tenant) => ({
8
+ userId: payload.sub,
9
+ ...typeof payload.sid === "string" && { sessionId: payload.sid },
10
+ roles: roles.filter((role) => role !== noGlobalRole),
11
+ ...typeof tenant === "string" && { organizationId: tenant }
12
+ });
13
+ const revoked = () => unauthorized("The bearer token was revoked: get a new one");
14
+ const bannedNow = (user, now) => Boolean(user.banned) && (!user.banExpires || user.banExpires > now);
15
+ const heldWhere = async ({ adapter }, user, organizations, organizationId) => {
16
+ if (!organizations) return parseRoles(user.role);
17
+ const global = globalRoles(user.role);
18
+ if (!organizationId) return global;
19
+ const member = await findMember(adapter, {
20
+ organizationId,
21
+ userId: user.id
22
+ });
23
+ if (member) return effectiveRoles(global, member.appRoles);
24
+ return global.length > 0 && await findOrganization(adapter, organizationId) !== null ? global : void 0;
25
+ };
26
+ /**
27
+ * Whether a token still stands, read from the store on every request: its user exists and is not banned, its session
28
+ * is not signed out or expired, and each role it carries is one the user still holds there. A token that fails is a
29
+ * 401, so the client asks its session for a new one, which carries the roles as they are now or is refused.
30
+ */
31
+ const checkTokenStanding = async (context, claims, organizations, now = /* @__PURE__ */ new Date()) => {
32
+ const { adapter } = context;
33
+ if (claims.sessionId !== void 0) {
34
+ const session = await adapter.findOne({
35
+ model: "session",
36
+ where: [{
37
+ field: "id",
38
+ value: claims.sessionId
39
+ }]
40
+ });
41
+ if (!session || session.userId !== claims.userId || session.expiresAt <= now) throw revoked();
42
+ }
43
+ const user = await adapter.findOne({
44
+ model: "user",
45
+ where: [{
46
+ field: "id",
47
+ value: claims.userId
48
+ }]
49
+ });
50
+ if (!user || bannedNow(user, now)) throw revoked();
51
+ const held = await heldWhere(context, user, organizations, claims.organizationId);
52
+ if (!held || claims.roles.some((role) => !held.includes(role))) throw revoked();
53
+ };
54
+ //#endregion
55
+ export { checkTokenStanding, tokenClaims };
@@ -270,6 +270,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
270
270
  basePath: `${systemPath}/functions`,
271
271
  functions: apiFunctions,
272
272
  authenticate,
273
+ access: deps.accessOptions,
273
274
  db,
274
275
  records: (session) => callerRecords(deps, session),
275
276
  onError,
@@ -1,5 +1,6 @@
1
1
  import { Session } from "../types.js";
2
2
  import { FunctionRecords } from "./records.js";
3
+ import { Capability } from "@protobase/schema";
3
4
  import { Db } from "@protobase/query";
4
5
  //#region src/functions/define-function.d.ts
5
6
  /** What every function gets: its name and the raw database. */
@@ -47,6 +48,11 @@ export type ProtectedFunctionOptions = SharedOptions & {
47
48
  public?: false;
48
49
  /** The caller needs one of these roles, otherwise the call is a 403 problem. */
49
50
  roles?: string[];
51
+ /**
52
+ * The caller's roles must grant all of this capability (`roles.holds`), such as `vms.update`, otherwise the call is a
53
+ * 403 problem. Needs the app's `roles`; not together with `roles` here.
54
+ */
55
+ capability?: Capability;
50
56
  };
51
57
  export type FunctionOptions = PublicFunctionOptions | ProtectedFunctionOptions;
52
58
  /** A function as `createAdmin` serves it: its options and what answers its requests. */
@@ -63,7 +69,8 @@ export type ApiFunction = {
63
69
  export type FunctionSource = ApiFunction | FunctionHandler | FunctionApp;
64
70
  /**
65
71
  * A function of your own, served at `/api/functions/<name>`. Without options it needs a signed-in caller, through the
66
- * app's `authenticate`; `{ public: true }` opens it to anyone, as for a webhook.
72
+ * app's `authenticate`, and with the app's `roles` one whose roles hold `*`; `{ roles }` or `{ capability }` opens it
73
+ * to more, `{ public: true }` to anyone, as for a webhook.
67
74
  */
68
75
  export declare function defineFunction(handler: FunctionHandler | FunctionApp): ApiFunction;
69
76
  export declare function defineFunction(options: PublicFunctionOptions, handler: PublicFunctionHandler | FunctionApp<PublicFunctionContext>): ApiFunction;
@@ -0,0 +1,36 @@
1
+ import { forbidden } from "../problem.js";
2
+ import { parseCapability } from "@protobase/schema/internal";
3
+ //#region src/functions/function-gate.ts
4
+ const open = () => true;
5
+ const holdsOf = (name, access) => {
6
+ const holds = access.roles?.holds;
7
+ if (!holds) throw new Error(`The function "${name}" is gated by the app's roles, but they have no holds(user, capability): define them with defineRoles`);
8
+ return holds;
9
+ };
10
+ /**
11
+ * Who may call a function, checked at startup: anyone, for a public one; one of its `roles`, or a caller holding all of its
12
+ * `capability`. Without either, an app with roles lets in only callers holding `*`, unless `defaultAccess` is `allow`;
13
+ * an app without roles lets in every signed-in caller.
14
+ */
15
+ const functionGate = (name, options, access) => {
16
+ if (options.public) return open;
17
+ const { roles, capability } = options;
18
+ if (roles && capability) throw new Error(`The function "${name}" has both roles and a capability: give one`);
19
+ if (roles) return (session) => roles.some((role) => session.user.roles.includes(role));
20
+ if (capability) {
21
+ if (!access.roles) throw new Error(`The function "${name}" needs the capability "${capability}", but the app defines no roles`);
22
+ parseCapability(capability);
23
+ const holds = holdsOf(name, access);
24
+ return (session) => holds(session.user, capability);
25
+ }
26
+ if (!access.roles || access.defaultAccess === "allow") return open;
27
+ const holds = holdsOf(name, access);
28
+ return (session) => holds(session.user, "*");
29
+ };
30
+ /** Throws the 403 problem for a caller the gate refuses. */
31
+ const requireGate = (name, gate, session) => {
32
+ if (gate(session)) return;
33
+ throw forbidden("function-forbidden", `You may not call the function "${name}"`);
34
+ };
35
+ //#endregion
36
+ export { functionGate, requireGate };
@@ -1,6 +1,7 @@
1
- import { forbidden, notFound } from "../problem.js";
1
+ import { notFound } from "../problem.js";
2
2
  import { invokeFunction } from "./define-function.js";
3
3
  import { runInBackground } from "../background-work.js";
4
+ import { functionGate, requireGate } from "./function-gate.js";
4
5
  import { Hono } from "hono";
5
6
  import { cors } from "hono/cors";
6
7
  //#region src/functions/routes.ts
@@ -9,17 +10,13 @@ const relativeRequest = (request, prefix) => {
9
10
  url.pathname = url.pathname.slice(prefix.length) || "/";
10
11
  return new Request(url, request);
11
12
  };
12
- const requireRoles = (name, session, roles) => {
13
- if (!roles || roles.some((role) => session.user.roles.includes(role))) return;
14
- throw forbidden("function-forbidden", `You may not call the function "${name}"`);
15
- };
16
13
  /**
17
14
  * Every function at `<basePath>/<name>` and below, any method. A protected function authenticates the request first, so
18
- * a call without a valid token is the authenticator's 401 problem; a public one gets the caller only when it asks. Each
15
+ * a call without a valid token is the authenticator's 401 problem, and one its gate refuses a 403; a public one gets the caller only when it asks. Each
19
16
  * call runs as background work, so calls that hold the database for long leave a connection to page requests. Once a
20
17
  * call ends, failed or not, `wake` runs, so what it wrote reaches open pages without waiting for the dispatcher's poll.
21
18
  */
22
- const functionRoutes = ({ basePath, functions, authenticate, db, records, onError, wake }) => {
19
+ const functionRoutes = ({ basePath, functions, authenticate, access, db, records, onError, wake }) => {
23
20
  const app = new Hono().basePath(basePath);
24
21
  app.onError(onError);
25
22
  const callerOf = async (request) => {
@@ -33,6 +30,7 @@ const functionRoutes = ({ basePath, functions, authenticate, db, records, onErro
33
30
  for (const [name, fn] of Object.entries(functions)) {
34
31
  const prefix = `${basePath}/${name}`;
35
32
  const { options } = fn;
33
+ const gate = functionGate(name, options, access);
36
34
  if (options.cors) app.use(`/${name}/*`, cors(options.cors));
37
35
  app.all(`/${name}/*`, (c) => runInBackground(() => call(async () => {
38
36
  const request = relativeRequest(c.req.raw, prefix);
@@ -45,7 +43,7 @@ const functionRoutes = ({ basePath, functions, authenticate, db, records, onErro
45
43
  });
46
44
  }
47
45
  const caller = await callerOf(c.req.raw);
48
- requireRoles(name, caller.session, options.roles);
46
+ requireGate(name, gate, caller.session);
49
47
  const context = {
50
48
  name,
51
49
  db,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.28.4",
3
+ "version": "0.29.1",
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": {
@@ -32,9 +32,9 @@
32
32
  "dependencies": {
33
33
  "@better-auth/passkey": "^1.7.7",
34
34
  "@better-auth/sso": "^1.7.7",
35
- "@protobase/layout": "0.28.4",
36
- "@protobase/query": "0.28.4",
37
- "@protobase/schema": "0.28.4",
35
+ "@protobase/layout": "0.29.1",
36
+ "@protobase/query": "0.29.1",
37
+ "@protobase/schema": "0.29.1",
38
38
  "@react-email/render": "^2.1.0",
39
39
  "@scalar/hono-api-reference": "^0.12.10",
40
40
  "@sentry/core": "^11.7.0",