@protobase/server 0.37.0 → 0.37.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.
@@ -7,11 +7,16 @@ const messageOf = (body) => {
7
7
  };
8
8
  /**
9
9
  * The bearer token of the session a request's cookie carries, minted as `GET /api/auth/token` would, with the same
10
- * plugin hooks (the sign-in policy's hold-back included) but without its rate limit: a forged cookie fails its
11
- * signature before any store read. No session is a 401 problem, a refusal a 403 with Better Auth's message; any other
12
- * error stays as it is.
10
+ * plugin hooks (the sign-in policy's hold-back included). The direct call has no rate limit, so nothing before the
11
+ * signature check may read the store: the session is read first, which refuses a cookie that is missing, forged or for
12
+ * no session before any store read, and the policy is read only for a session that exists. No session is a 401
13
+ * problem, a refusal a 403 with Better Auth's message; any other error, such as a store failure, stays as it is.
13
14
  */
14
15
  const betterAuthSessionToken = (auth) => async (request) => {
16
+ if (await auth.api.getSession({
17
+ headers: request.headers,
18
+ query: { disableRefresh: true }
19
+ }) === null) throw unauthorized("Sign in first");
15
20
  try {
16
21
  const { token } = await auth.api.getToken({ headers: request.headers });
17
22
  return token;
@@ -75,8 +75,9 @@ export type CreateAdminInput = {
75
75
  /** @internal Verifies a user's token on `/api/sb/rest/v1`, `/api/sb/realtime/v1` and `/api/sb/storage/v1` and gives its claims, which tests fake; those `/api/sb/auth/v1` signs otherwise. */
76
76
  verifyToken?: (token: string) => Promise<JWTPayload>;
77
77
  /**
78
- * The host's WebSocket upgrade, such as `@hono/node-server`'s, with which an app with keys and live resources serves
79
- * supabase-js's Realtime at `/api/sb/realtime/v1/websocket`; without it, the app serves none.
78
+ * The host's WebSocket upgrade, `@hono/node-server`'s `upgradeWebSocket`, the only one supported: function sockets use
79
+ * its `ws` socket's send callback and `bufferedAmount`. With it, functions accept sockets, and an app with keys and
80
+ * live resources serves supabase-js's Realtime at `/api/sb/realtime/v1/websocket`; without it, the app serves none.
80
81
  */
81
82
  upgradeWebSocket?: UpgradeWebSocket;
82
83
  };
@@ -14,9 +14,11 @@ export type PublicFunctionContext = {
14
14
  /** The caller, through the app's own `authenticate`: rejects with a 401 problem when the request carries no valid token. */
15
15
  caller: () => Promise<FunctionCaller>;
16
16
  /**
17
- * Answers a WebSocket upgrade with a socket: return its response. A request that is no upgrade is a 426 problem. A
18
- * protected function's caller is checked again every minute while the socket is open, which closes with 1008 once
19
- * they would be refused; a public function checks its own callers.
17
+ * Answers a WebSocket upgrade with a socket: return its response. A request that is no upgrade is a 426 problem. While
18
+ * the socket is open, the caller of a protected function, and of a public one once `caller()` resolved, is checked
19
+ * again every minute: their credential, the function's roles, and the same user, tenant, organization and roles. The
20
+ * socket closes with 1008 once they would be refused. The function's own checks, such as access to one record, are
21
+ * not repeated unless it passes them as `recheck`.
20
22
  */
21
23
  acceptWebSocket: (events: SocketEvents) => Response;
22
24
  };
@@ -14,5 +14,11 @@ export type SocketEvents = {
14
14
  /** Text arrives as a string, binary as an ArrayBuffer. The next message waits until a returned promise settles. */
15
15
  onMessage?: (data: string | ArrayBuffer, socket: FunctionSocket) => void | Promise<void>;
16
16
  onClose?: (code: number, reason: string) => void | Promise<void>;
17
+ /**
18
+ * Checked every minute after the framework's own check, while the socket is open; what it returns is ignored. Throw
19
+ * or reject with a 401, 403 or 404 problem to close the socket with 1008; any other error is reported and closes it
20
+ * with 1011.
21
+ */
22
+ recheck?: () => unknown;
17
23
  };
18
24
  //#endregion
@@ -61,8 +61,12 @@ const socketEvents = (events, { report, recheck }) => {
61
61
  return raw.bufferedAmount;
62
62
  }
63
63
  };
64
- if (recheck) stopRecheck = recheckWhileOpen({
65
- check: recheck,
64
+ const check = async () => {
65
+ await recheck?.();
66
+ await events.recheck?.();
67
+ };
68
+ if (recheck || events.recheck) stopRecheck = recheckWhileOpen({
69
+ check,
66
70
  close: end,
67
71
  report
68
72
  });
@@ -18,8 +18,8 @@ const isUpgrade = (request) => request.headers.get("upgrade")?.toLowerCase() ===
18
18
  * 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
19
  * call runs as background work, so calls that hold the database for long leave a connection to page requests. Once a
20
20
  * call ends, failed or not, `wake` runs, so what it wrote reaches open pages without waiting for the dispatcher's poll.
21
- * A function may answer an upgrade with a socket, only once it returned; a protected one's caller is checked again
22
- * every minute while it is open.
21
+ * A function may answer an upgrade with a socket, only once it returned. While it is open, its caller is checked again
22
+ * every minute: a protected function's always, a public one's once `caller()` resolved.
23
23
  */
24
24
  const functionRoutes = ({ basePath, functions, authenticate, access, db, records, onError, wake, upgradeWebSocket, upgradeAuthenticate = authenticate, report }) => {
25
25
  const app = new Hono().basePath(basePath);
@@ -33,6 +33,11 @@ const functionRoutes = ({ basePath, functions, authenticate, access, db, records
33
33
  };
34
34
  };
35
35
  const call = (work) => work().finally(wake);
36
+ const recheckCaller = async (name, gate, request, first) => {
37
+ const again = await upgradeAuthenticate(request);
38
+ requireGate(name, gate, again);
39
+ if (!sameCaller(first, again)) throw forbidden("caller-changed", "Your access changed: open the socket again");
40
+ };
36
41
  for (const [name, fn] of Object.entries(functions)) {
37
42
  const prefix = `${basePath}/${name}`;
38
43
  const { options } = fn;
@@ -42,26 +47,28 @@ const functionRoutes = ({ basePath, functions, authenticate, access, db, records
42
47
  const request = relativeRequest(c.req.raw, prefix);
43
48
  if (options.public) {
44
49
  let caller;
50
+ let admitted;
51
+ const resolveCaller = () => caller ??= callerOf(c.req.raw).then((resolved) => (admitted = resolved.session, resolved));
52
+ const recheck = async () => {
53
+ if (admitted) await recheckCaller(name, gate, c.req.raw, admitted);
54
+ };
45
55
  const acceptor = socketAcceptor({
46
56
  c,
47
57
  upgradeWebSocket,
48
- report
58
+ report,
59
+ recheck
49
60
  });
50
61
  const context = {
51
62
  name,
52
63
  db,
53
- caller: () => caller ??= callerOf(c.req.raw),
64
+ caller: resolveCaller,
54
65
  acceptWebSocket: acceptor.accept
55
66
  };
56
67
  return acceptor.answer(name, await invokeFunction(name, fn, request, context));
57
68
  }
58
69
  const caller = await callerOf(c.req.raw);
59
70
  requireGate(name, gate, caller.session);
60
- const recheck = async () => {
61
- const again = await upgradeAuthenticate(c.req.raw);
62
- requireGate(name, gate, again);
63
- if (!sameCaller(caller.session, again)) throw forbidden("caller-changed", "Your access changed: open the socket again");
64
- };
71
+ const recheck = () => recheckCaller(name, gate, c.req.raw, caller.session);
65
72
  const acceptor = socketAcceptor({
66
73
  c,
67
74
  upgradeWebSocket,
@@ -1,10 +1,17 @@
1
1
  import { HttpProblem } from "../problem.js";
2
2
  //#region src/functions/socket-recheck.ts
3
- const isRefusal = (error) => error instanceof HttpProblem && (error.status === 401 || error.status === 403);
3
+ const refusals = /* @__PURE__ */ new Set([
4
+ 401,
5
+ 403,
6
+ 404
7
+ ]);
8
+ const isRefusal = (error) => error instanceof HttpProblem && refusals.has(error.status);
4
9
  /**
5
- * Runs `check` every `everyMs` while a socket is open, one check after the other. A refusal closes the socket with 1008
6
- * (Policy Violation) and the problem's title; any other error is reported and closes it with 1011, so an outage that
7
- * stops the check ends the access too. Returns the function that stops it.
10
+ * Runs `check` every `everyMs` while a socket is open, one check after the other. A refusal, such as a record the caller
11
+ * can no longer see (404), closes the socket with 1008 (Policy Violation) and the problem's title; any other error is
12
+ * reported and closes it with 1011, so an outage that stops the check ends the access too. A check still pending after
13
+ * another `everyMs` counts as such an error, so a hung store never leaves a socket open unchecked. Returns the function
14
+ * that stops it.
8
15
  */
9
16
  const recheckWhileOpen = ({ check, close, report, everyMs = 6e4 }) => {
10
17
  let stopped = false;
@@ -16,9 +23,14 @@ const recheckWhileOpen = ({ check, close, report, everyMs = 6e4 }) => {
16
23
  report(error);
17
24
  close(1011, "Internal Error");
18
25
  };
19
- const run = () => check().then(() => {
20
- if (!stopped) schedule();
21
- }, end);
26
+ const run = () => {
27
+ timer = setTimeout(() => end(/* @__PURE__ */ new Error(`A socket's check did not finish within ${everyMs} ms`)), everyMs);
28
+ timer.unref?.();
29
+ check().then(() => {
30
+ clearTimeout(timer);
31
+ if (!stopped) schedule();
32
+ }, end);
33
+ };
22
34
  const schedule = () => {
23
35
  timer = setTimeout(run, everyMs);
24
36
  timer.unref?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.37.0",
3
+ "version": "0.37.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": {
@@ -36,9 +36,9 @@
36
36
  "@better-auth/passkey": "^1.7.12",
37
37
  "@better-auth/sso": "^1.7.12",
38
38
  "@modelcontextprotocol/server": "^2.3.1",
39
- "@protobase/layout": "0.37.0",
40
- "@protobase/query": "0.37.0",
41
- "@protobase/schema": "0.37.0",
39
+ "@protobase/layout": "0.37.2",
40
+ "@protobase/query": "0.37.2",
41
+ "@protobase/schema": "0.37.2",
42
42
  "@react-email/render": "^2.1.0",
43
43
  "@scalar/hono-api-reference": "^0.12.10",
44
44
  "@sentry/core": "^11.7.0",