@protobase/server 0.28.4 → 0.29.0

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.
@@ -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.0",
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.0",
36
+ "@protobase/query": "0.29.0",
37
+ "@protobase/schema": "0.29.0",
38
38
  "@react-email/render": "^2.1.0",
39
39
  "@scalar/hono-api-reference": "^0.12.10",
40
40
  "@sentry/core": "^11.7.0",