@protobase/server 0.50.0 → 0.51.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.
@@ -1,7 +1,14 @@
1
1
  import { defineTool } from "./tools.js";
2
2
  import { keyOf, keyParameter, refused, resourceOf } from "./record-tool-args.js";
3
+ import { deleteApproval } from "./write-prompts.js";
4
+ import { approvalTexts, recordBefore } from "./record-before.js";
3
5
  import { onlyNote, outside, resourceParameterFor } from "./write-tool-limits.js";
4
6
  //#region src/assistant/delete-record-tool.ts
7
+ /**
8
+ * `delete_record`: deleting one record as the caller, through the REST API's delete path (its access rules, ETag check,
9
+ * write hooks and soft delete), after the user approves it. Before asking it only reads: the resource's config and the
10
+ * record the approval names, since MCP runs it again from the start with the user's answer.
11
+ */
5
12
  const parameterNames = [
6
13
  "resource",
7
14
  "key",
@@ -16,15 +23,6 @@ const deleteKeyOf = (value) => {
16
23
  };
17
24
  const etagOf = (value) => typeof value === "string" && /^"[\x21\x23-\x2b\x2d-\x7e]+"$/.test(value) ? value : void 0;
18
25
  const usage = "Pass only the resource, key and etag as { \"resource\": \"<name>\", \"key\": \"<key>\", \"etag\": \"<the etag get_record gave>\" }; nothing was deleted.";
19
- const approvalOf = (resource, shownKey, softDelete) => ({
20
- title: softDelete ? `Delete this ${resource} record? It can be restored: the record is kept, marked deleted.` : `Delete this ${resource} record for good? This cannot be undone: the record is removed.`,
21
- fields: [{
22
- label: "Key",
23
- value: shownKey
24
- }],
25
- tone: softDelete ? "warning" : "danger",
26
- approveLabel: softDelete ? "Delete" : "Delete for good"
27
- });
28
26
  /** Deletes one record by its key and ETag once the user approves it, under the caller's access rules. */
29
27
  const deleteRecordTool = (input = {}) => defineTool({
30
28
  name: "delete_record",
@@ -60,8 +58,10 @@ const deleteRecordTool = (input = {}) => defineTool({
60
58
  if (refusal) return refusal;
61
59
  const softDelete = await context.records.softDeleteField(resource).then((field) => ({ field }), refused);
62
60
  if (typeof softDelete === "string") return softDelete;
63
- const shownKey = Array.isArray(key) ? key.join(",") : String(key);
64
- const answer = await context.approve(approvalOf(resource, shownKey, softDelete.field));
61
+ const before = await recordBefore(context, resource, key, etag);
62
+ if (typeof before === "string") return before;
63
+ const { text, described } = await approvalTexts(context, resource);
64
+ const answer = await context.approve(deleteApproval(text, described, key, before?.record ?? {}, softDelete.field !== void 0));
65
65
  if (answer !== "approved") return `The user did not approve this delete (${answer}); nothing was deleted.`;
66
66
  const deleted = () => JSON.stringify({
67
67
  deleted: true,
@@ -0,0 +1,20 @@
1
+ import { HttpProblem, preconditionFailed } from "../problem.js";
2
+ import { ifMatchSatisfied } from "../etag.js";
3
+ import { refused } from "./record-tool-args.js";
4
+ import { writeText } from "./write-messages.js";
5
+ import { describedResource } from "./write-prompts.js";
6
+ //#region src/assistant/record-before.ts
7
+ const hidden = (error) => error instanceof HttpProblem && (error.status === 403 || error.status === 404);
8
+ /**
9
+ * The record as the caller reads it now, for what an approval names and shows before a write: undefined when they may
10
+ * not read it. A record changed since `etag` is refused before asking, as the write would refuse it after the user
11
+ * approved; so is any other refusal of the read, as the model's to read.
12
+ */
13
+ const recordBefore = (context, resource, key, etag) => context.records.get(resource, key).then((current) => etag && !ifMatchSatisfied(etag, current.etag) ? refused(preconditionFailed("The record changed since it was read; fetch it again")) : current, (error) => hidden(error) ? void 0 : refused(error));
14
+ /** What an approval is written with: the texts of the user's language, and the resource as the caller may read it. */
15
+ const approvalTexts = async (context, resource) => ({
16
+ text: writeText(context.locale ?? "en"),
17
+ described: describedResource(await context.resources(), resource)
18
+ });
19
+ //#endregion
20
+ export { approvalTexts, recordBefore };
@@ -2,6 +2,7 @@ import { Session } from "../types.js";
2
2
  import { ToolRecords } from "./records.js";
3
3
  import { Conversation } from "./conversations.js";
4
4
  import { ApprovalAnswer, ApprovalRequest } from "./approval.js";
5
+ import { Locale } from "@protobase/schema/internal";
5
6
  import { AssistantPart, ResourceModel, ViewModel } from "@protobase/schema";
6
7
  //#region src/assistant/tools.d.ts
7
8
  /** The resources a caller may read, each restricted to their fields, with their views. */
@@ -11,6 +12,8 @@ export type ReadableResources = {
11
12
  };
12
13
  export type ToolContext = {
13
14
  session: Session;
15
+ /** The language the user reads, for the texts a tool shows them such as its approvals; English when absent. */
16
+ locale?: Locale;
14
17
  /** The dock's conversation; absent over MCP. */
15
18
  conversation?: Conversation;
16
19
  /** Adds a part, such as a table of results, to the assistant message the turn is writing. */
@@ -0,0 +1,41 @@
1
+ import { defineMessages } from "../locale/messages.js";
2
+ //#region src/assistant/write-messages.ts
3
+ /** The texts of the approvals the write tools ask for, which the user reads in their client. */
4
+ const writeText = defineMessages({
5
+ en: {
6
+ createTitle: (resource, title) => `Create ${resource}${title === void 0 ? "" : ` "${title}"`}?`,
7
+ updateTitle: (record) => `Update ${record}?`,
8
+ deleteTitle: (record) => `Delete ${record}?`,
9
+ create: "Create",
10
+ update: "Update",
11
+ delete: "Delete",
12
+ deleteForGood: "Delete for good",
13
+ reject: "Cancel",
14
+ noChanges: "No field changes.",
15
+ restorable: "It can be restored: the record is kept, marked deleted.",
16
+ permanent: "This cannot be undone: the record is removed for good.",
17
+ empty: "empty",
18
+ hidden: "hidden",
19
+ yes: "Yes",
20
+ no: "No"
21
+ },
22
+ nl: {
23
+ createTitle: (resource, title) => `${resource}${title === void 0 ? "" : ` "${title}"`} aanmaken?`,
24
+ updateTitle: (record) => `${record} bijwerken?`,
25
+ deleteTitle: (record) => `${record} verwijderen?`,
26
+ create: "Aanmaken",
27
+ update: "Bijwerken",
28
+ delete: "Verwijderen",
29
+ deleteForGood: "Definitief verwijderen",
30
+ reject: "Annuleren",
31
+ noChanges: "Er verandert geen veld.",
32
+ restorable: "Het kan worden teruggezet: het record blijft bewaard, gemarkeerd als verwijderd.",
33
+ permanent: "Dit kan niet ongedaan worden gemaakt: het record wordt definitief verwijderd.",
34
+ empty: "leeg",
35
+ hidden: "verborgen",
36
+ yes: "Ja",
37
+ no: "Nee"
38
+ }
39
+ });
40
+ //#endregion
41
+ export { writeText };
@@ -0,0 +1,79 @@
1
+ import { humanize, recordTitle } from "@protobase/schema/internal";
2
+ import { enumLabel } from "@protobase/schema";
3
+ //#region src/assistant/write-prompts.ts
4
+ const describedResource = (readable, name) => ({
5
+ name,
6
+ model: readable.models.find((model) => model.name === name),
7
+ view: readable.views.find((view) => view.resource === name)
8
+ });
9
+ const hiddenCharacters = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
10
+ const longest = 200;
11
+ /** `text` on one line with nothing hidden in it, cut at 200 characters. */
12
+ const shown = (text) => {
13
+ const line = text.replace(hiddenCharacters, " ");
14
+ return line.length > longest ? `${line.slice(0, 199)}…` : line;
15
+ };
16
+ const resourceLabel = ({ name, view }) => shown(view?.names?.singular ?? humanize(name));
17
+ const fieldLabel = ({ view }, name) => shown(view?.fields[name]?.label ?? humanize(name));
18
+ const shownValue = (text, described, name, value) => {
19
+ const field = described.model?.fields[name];
20
+ if (field?.sensitive) return text.hidden;
21
+ if (value === null || value === void 0) return text.empty;
22
+ if (typeof value === "boolean") return value ? text.yes : text.no;
23
+ if (field?.type === "enum" && typeof value === "string") return shown(enumLabel(value, described.view?.fields[name]?.valueLabels));
24
+ if (typeof value === "string") return shown(value);
25
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
26
+ return shown(JSON.stringify(value));
27
+ };
28
+ /** `Task #42 "Fix the sign-in lockout"`: the resource's label, the record's key, and its title when it has one apart from the key. */
29
+ const recordName = (described, key, record) => {
30
+ const shownKey = shown(Array.isArray(key) ? key.join(",") : String(key));
31
+ const title = described.model ? shown(recordTitle(described.model, described.view, record, shownKey)) : shownKey;
32
+ return `${resourceLabel(described)} #${shownKey}${title === shownKey ? "" : ` "${title}"`}`;
33
+ };
34
+ const titleOfNew = (described, data) => {
35
+ if (!described.model) return void 0;
36
+ const title = shown(recordTitle(described.model, described.view, data, ""));
37
+ return title === "" ? void 0 : title;
38
+ };
39
+ const same = (before, after) => JSON.stringify(before ?? null) === JSON.stringify(after ?? null);
40
+ /** `Create Task "Fix the sign-in lockout"?`, with each field the new record is given by its label. */
41
+ const createApproval = (text, described, data) => ({
42
+ title: text.createTitle(resourceLabel(described), titleOfNew(described, data)),
43
+ fields: Object.entries(data).map(([name, value]) => ({
44
+ label: fieldLabel(described, name),
45
+ value: shownValue(text, described, name, value)
46
+ })),
47
+ approveLabel: text.create,
48
+ rejectLabel: text.reject
49
+ });
50
+ /**
51
+ * `Update Task #42 "Fix the sign-in lockout"?`, with each field that changes as `In flight -> Done`. A field the
52
+ * caller cannot read before, such as a sensitive one, shows its new value alone.
53
+ */
54
+ const updateApproval = (text, described, key, current, data) => {
55
+ const fields = Object.entries(data).filter(([name, value]) => !Object.hasOwn(current, name) || !same(current[name], value)).map(([name, value]) => {
56
+ const after = shownValue(text, described, name, value);
57
+ const before = Object.hasOwn(current, name) ? `${shownValue(text, described, name, current[name])} -> ` : "";
58
+ return {
59
+ label: fieldLabel(described, name),
60
+ value: `${before}${after}`
61
+ };
62
+ });
63
+ return {
64
+ title: text.updateTitle(recordName(described, key, current)),
65
+ ...fields.length > 0 ? { fields } : { body: text.noChanges },
66
+ approveLabel: text.update,
67
+ rejectLabel: text.reject
68
+ };
69
+ };
70
+ /** `Delete Task #42 "Fix the sign-in lockout"?`, saying whether it can be restored (a soft delete) or is gone for good. */
71
+ const deleteApproval = (text, described, key, current, restorable) => ({
72
+ title: text.deleteTitle(recordName(described, key, current)),
73
+ body: restorable ? text.restorable : text.permanent,
74
+ tone: restorable ? "warning" : "danger",
75
+ approveLabel: restorable ? text.delete : text.deleteForGood,
76
+ rejectLabel: text.reject
77
+ });
78
+ //#endregion
79
+ export { createApproval, deleteApproval, describedResource, updateApproval };
@@ -1,5 +1,7 @@
1
1
  import { defineTool } from "./tools.js";
2
2
  import { keyOf, keyParameter, refused, resourceOf } from "./record-tool-args.js";
3
+ import { createApproval, updateApproval } from "./write-prompts.js";
4
+ import { approvalTexts, recordBefore } from "./record-before.js";
3
5
  import { forbiddenText, notApproved, onlyNote, outside, resourceParameterFor } from "./write-tool-limits.js";
4
6
  import { deleteRecordTool } from "./delete-record-tool.js";
5
7
  //#region src/assistant/write-record-tools.ts
@@ -32,11 +34,8 @@ const createRecordTool = (input = {}) => defineTool({
32
34
  if (!resource || !data) return "Pass the resource and data as { \"resource\": \"<name>\", \"data\": { \"<field>\": <value> } }.";
33
35
  const refusal = outside(input, resource);
34
36
  if (refusal) return refusal;
35
- const answer = await context.approve({
36
- title: `Create a record in ${resource}?`,
37
- code: JSON.stringify(data, null, 2),
38
- approveLabel: "Create"
39
- });
37
+ const { text, described } = await approvalTexts(context, resource);
38
+ const answer = await context.approve(createApproval(text, described, data));
40
39
  if (answer !== "approved") return notApproved(answer);
41
40
  return context.records.create(resource, data).then((record) => JSON.stringify(record), refused);
42
41
  }
@@ -76,14 +75,12 @@ const updateRecordTool = (input = {}) => defineTool({
76
75
  const softDelete = await context.records.softDeleteField(resource).then((field) => ({ field }), refused);
77
76
  if (typeof softDelete === "string") return softDelete;
78
77
  if (softDelete.field && Object.hasOwn(data, softDelete.field)) return forbiddenText(`update_record does not set ${softDelete.field}, which deleting sets; delete with delete_record. Nothing was written.`);
79
- const shownKey = Array.isArray(key) ? key.join(",") : String(key);
80
- const answer = await context.approve({
81
- title: `Update ${resource} ${shownKey}?`,
82
- code: JSON.stringify(data, null, 2),
83
- approveLabel: "Update"
84
- });
85
- if (answer !== "approved") return notApproved(answer);
86
78
  const etag = typeof args.etag === "string" && args.etag ? args.etag : void 0;
79
+ const before = await recordBefore(context, resource, key, etag);
80
+ if (typeof before === "string") return before;
81
+ const { text, described } = await approvalTexts(context, resource);
82
+ const answer = await context.approve(updateApproval(text, described, key, before?.record ?? {}, data));
83
+ if (answer !== "approved") return notApproved(answer);
87
84
  return context.records.update(resource, key, data, { etag }).then((record) => JSON.stringify(record), refused);
88
85
  }
89
86
  });
@@ -80,6 +80,7 @@ import { functionRoutes } from "./functions/routes.js";
80
80
  import { supabaseFunctionRoutes } from "./functions/supabase-routes.js";
81
81
  import { upgradeAuthenticator } from "./functions/upgrade-caller.js";
82
82
  import { localeSetting } from "./locale/locale-option.js";
83
+ import { requestLocale } from "./locale/request-locale.js";
83
84
  import { mapSettings } from "./maps/map-settings.js";
84
85
  import { pluginOptions, pluginsBeforeSignIn } from "./plugins/plugin-settings.js";
85
86
  import { runtimeUrl } from "./runtime/runtime-settings.js";
@@ -329,17 +330,20 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
329
330
  writes
330
331
  });
331
332
  const approvals = mcpApprovals();
332
- const server = (session) => mcpServer({
333
- info: {
334
- name: appName,
335
- version: "1.0.0"
336
- },
337
- tools: tools(session),
338
- context: mcpToolContext(deps, meta, session),
339
- report,
340
- rolesWithoutApproval: rolesWithoutApproval(writes),
341
- approvals
342
- });
333
+ const server = (session, request) => {
334
+ const context = mcpToolContext(deps, meta, session, request ? requestLocale(locale, request) : locale);
335
+ return mcpServer({
336
+ info: {
337
+ name: appName,
338
+ version: "1.0.0"
339
+ },
340
+ tools: tools(session),
341
+ context,
342
+ report,
343
+ rolesWithoutApproval: rolesWithoutApproval(writes),
344
+ approvals
345
+ });
346
+ };
343
347
  mcpRoutes(app, {
344
348
  path: auth.mcp.path,
345
349
  handler: mcpHandler({
@@ -0,0 +1,8 @@
1
+ //#region src/locale/messages.ts
2
+ /**
3
+ * An area's built-in texts in every language the screens come in: the lookup gives those of one locale, as the server
4
+ * has no single active one. Dutch must translate every English text, which the types check.
5
+ */
6
+ const defineMessages = (catalogue) => (locale) => catalogue[locale];
7
+ //#endregion
8
+ export { defineMessages };
@@ -0,0 +1,14 @@
1
+ import { pickLocale } from "@protobase/schema/internal";
2
+ //#region src/locale/request-locale.ts
3
+ const acceptedTags = (header) => header.split(",").map((entry) => {
4
+ const [tag = "", ...params] = entry.trim().split(";");
5
+ const weight = params.map((param) => param.trim()).find((param) => param.startsWith("q="));
6
+ return {
7
+ tag: tag.trim(),
8
+ q: weight ? Number(weight.slice(2)) : 1
9
+ };
10
+ }).filter(({ tag, q }) => tag !== "" && tag !== "*" && q > 0).toSorted((a, b) => b.q - a.q).map(({ tag }) => tag);
11
+ /** The language a request's user reads: the app's (`options.locale`) when it sets one, else their `Accept-Language`'s. */
12
+ const requestLocale = (appLocale, request) => appLocale ?? pickLocale(acceptedTags(request.headers.get("accept-language") ?? ""));
13
+ //#endregion
14
+ export { requestLocale };
@@ -1,3 +1,4 @@
1
+ import { defineMessages } from "../locale/messages.js";
1
2
  import { inputRequired, inputResponse } from "@modelcontextprotocol/server";
2
3
  //#region src/mcp/mcp-approval.ts
3
4
  /** Thrown by `approve` over MCP when the request carries no answer yet: the server then asks the client for one. */
@@ -41,20 +42,30 @@ const messageOf = (request) => [
41
42
  request.fields?.map(({ label, value }) => `${label}: ${value}`).join("\n"),
42
43
  request.code
43
44
  ].filter(Boolean).join("\n\n");
44
- const cannotAsk = {
45
+ const text = defineMessages({
46
+ en: {
47
+ cannotAsk: "This client cannot ask you to approve a change, so nothing was written; use a client that supports MCP elicitation.",
48
+ approve: "Approve"
49
+ },
50
+ nl: {
51
+ cannotAsk: "Deze client kan je niet vragen een wijziging goed te keuren, dus er is niets opgeslagen; gebruik een client die MCP-elicitation ondersteunt.",
52
+ approve: "Goedkeuren"
53
+ }
54
+ });
55
+ const cannotAsk = (locale) => ({
45
56
  content: [{
46
57
  type: "text",
47
- text: "This client cannot ask you to approve a change, so nothing was written; use a client that supports MCP elicitation."
58
+ text: text(locale).cannotAsk
48
59
  }],
49
60
  isError: true
50
- };
61
+ });
51
62
  /**
52
63
  * The answer to a call whose tool asked for approval: a form asking the user to approve `request` when the client can
53
- * show one, filed under the nonce of the state `ask` mints for the call, else a refusal. A 2025-protocol request
54
- * declares no capabilities per request, so it is always refused.
64
+ * show one, filed under the nonce of the state `ask` mints for the call, else a refusal in `locale`. A 2025-protocol
65
+ * request declares no capabilities per request, so it is always refused.
55
66
  */
56
- const approvalAsked = async (request, capabilities, ask) => {
57
- if (!asksForms(capabilities)) return cannotAsk;
67
+ const approvalAsked = async (request, capabilities, ask, locale = "en") => {
68
+ if (!asksForms(capabilities)) return cannotAsk(locale);
58
69
  const { state, nonce } = await ask();
59
70
  return inputRequired({
60
71
  inputRequests: { [approvalKey(nonce)]: inputRequired.elicit({
@@ -63,7 +74,7 @@ const approvalAsked = async (request, capabilities, ask) => {
63
74
  type: "object",
64
75
  properties: { approve: {
65
76
  type: "boolean",
66
- title: request.approveLabel ?? "Approve"
77
+ title: request.approveLabel ?? text(locale).approve
67
78
  } },
68
79
  required: ["approve"]
69
80
  }
@@ -2,10 +2,12 @@ import { Session } from "../types.js";
2
2
  import { Deps } from "../deps.js";
3
3
  import { ToolContext } from "../assistant/tools.js";
4
4
  import { Meta } from "../meta.js";
5
+ import { Locale } from "@protobase/schema/internal";
5
6
  //#region src/mcp/mcp-context.d.ts
6
7
  /**
7
- * What tools get over MCP for `session`: its records and the resources it may read, under its access rules. Nothing
8
- * shows beside a tool's result, and `mcpServer` gives each call its own `approve`.
8
+ * What tools get over MCP for `session`: its records and the resources it may read, under its access rules, and
9
+ * `locale`, the language its approvals are asked in. Nothing shows beside a tool's result, and `mcpServer` gives each
10
+ * call its own `approve`.
9
11
  */
10
- export declare const mcpToolContext: (deps: Deps, meta: Pick<Meta, "forCaller">, session: Session) => Omit<ToolContext, "approve">;
12
+ export declare const mcpToolContext: (deps: Deps, meta: Pick<Meta, "forCaller">, session: Session, locale?: Locale) => Omit<ToolContext, "approve">;
11
13
  //#endregion
@@ -2,11 +2,13 @@ import { assistantRecords } from "../assistant/records.js";
2
2
  import { readableResources } from "../assistant/readable-resources.js";
3
3
  //#region src/mcp/mcp-context.ts
4
4
  /**
5
- * What tools get over MCP for `session`: its records and the resources it may read, under its access rules. Nothing
6
- * shows beside a tool's result, and `mcpServer` gives each call its own `approve`.
5
+ * What tools get over MCP for `session`: its records and the resources it may read, under its access rules, and
6
+ * `locale`, the language its approvals are asked in. Nothing shows beside a tool's result, and `mcpServer` gives each
7
+ * call its own `approve`.
7
8
  */
8
- const mcpToolContext = (deps, meta, session) => ({
9
+ const mcpToolContext = (deps, meta, session, locale) => ({
9
10
  session,
11
+ ...locale && { locale },
10
12
  records: assistantRecords(deps, session),
11
13
  resources: () => readableResources(meta, session),
12
14
  show: () => {}
@@ -3,8 +3,11 @@ import { McpGateInput } from "./mcp-gate.js";
3
3
  import { McpServer } from "@modelcontextprotocol/server";
4
4
  //#region src/mcp/mcp-handler.d.ts
5
5
  export type McpHandlerInput = McpGateInput & {
6
- /** Builds the server for the caller of one request, such as `mcpServer(...)` with tools of your own registered on it. */
7
- server: (session: Session) => McpServer | Promise<McpServer>;
6
+ /**
7
+ * Builds the server for the caller of one request, such as `mcpServer(...)` with tools of your own registered on it;
8
+ * `request` is the HTTP request it serves, such as for the language its user reads.
9
+ */
10
+ server: (session: Session, request: Request | undefined) => McpServer | Promise<McpServer>;
8
11
  };
9
12
  /**
10
13
  * The MCP endpoint as a fetch handler: refuses a request addressed to another host than the resource's, then one
@@ -9,7 +9,7 @@ import { createMcpHandler, hostHeaderValidationResponse } from "@modelcontextpro
9
9
  const mcpHandler = ({ server, ...gate }) => {
10
10
  const hostname = new URL(gate.resource).hostname;
11
11
  const admit = mcpGate(gate);
12
- const handler = createMcpHandler((context) => server(sessionOf(context.authInfo)), { legacy: "stateless" });
12
+ const handler = createMcpHandler((context) => server(sessionOf(context.authInfo), context.requestInfo), { legacy: "stateless" });
13
13
  return async (request) => {
14
14
  const wrongHost = hostHeaderValidationResponse(request, [hostname]);
15
15
  if (wrongHost) return wrongHost;
@@ -70,7 +70,7 @@ const mcpServer = ({ info, tools, context, report, rolesWithoutApproval = [], ap
70
70
  } catch (error) {
71
71
  if (!(error instanceof ApprovalNeeded)) throw error;
72
72
  const { scope, call } = bound();
73
- return approvalAsked(error.request, capabilitiesOf(ctx.mcpReq.envelope), () => approvals.ask(scope, call));
73
+ return approvalAsked(error.request, capabilitiesOf(ctx.mcpReq.envelope), () => approvals.ask(scope, call), context.locale);
74
74
  }
75
75
  };
76
76
  server.registerTool(tool.name, config, async (args, ctx) => {
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.50.0";
2
+ var version = "0.51.0";
3
3
  //#endregion
4
4
  export { version };
@@ -0,0 +1,61 @@
1
+ import { isDatabaseError } from "../error-handler.js";
2
+ import { writeTransaction } from "../transactions.js";
3
+ import { callerSettings } from "../rest/caller-settings.js";
4
+ import { sql } from "kysely";
5
+ import { randomUUID } from "node:crypto";
6
+ //#region src/realtime/channel-access.ts
7
+ const insufficientPrivilege = "42501";
8
+ /**
9
+ * Supabase Realtime Authorization: the row security policies on `realtime.messages` decide what a caller may do on a
10
+ * private channel's topic, as Supabase's Realtime checks them. Each check runs in a transaction of its own, under a
11
+ * savepoint it rolls back, so nothing it inserts stays. It sets the caller's role and claims as `/api/sb/rest/v1` does,
12
+ * and the topic, without its `realtime:` prefix, in `realtime.topic`, which `realtime.topic()` reads. A database
13
+ * without `realtime.messages` allows nothing.
14
+ */
15
+ const channelAccess = ({ db, statementTimeoutMs }) => {
16
+ const probe = (denied, run) => writeTransaction(db, statementTimeoutMs, async (trx) => {
17
+ const { rows } = await sql`select to_regclass('realtime.messages') is not null as found`.execute(trx);
18
+ if (!rows[0].found) return denied;
19
+ await sql`savepoint access_probe`.execute(trx);
20
+ const result = await run(trx);
21
+ await sql`rollback to savepoint access_probe`.execute(trx);
22
+ return result;
23
+ });
24
+ const actAs = (trx, caller, topic) => {
25
+ const settings = {
26
+ role: "none",
27
+ "request.jwt.claim.sub": "",
28
+ ...callerSettings(caller),
29
+ "realtime.topic": topic
30
+ };
31
+ return sql`select ${sql.join(Object.entries(settings).map(([name, value]) => sql`set_config(${name}, ${value}, true)`))}`.execute(trx);
32
+ };
33
+ return {
34
+ /**
35
+ * Whether the caller may receive each extension's messages on the topic: as the connection's own role, which must
36
+ * own `realtime.messages` or bypass its policies, it inserts a message of each, then selects them as the caller.
37
+ */
38
+ read: (caller, topic, extensions) => probe(/* @__PURE__ */ new Map(), async (trx) => {
39
+ const probes = extensions.map((extension) => ({
40
+ id: randomUUID(),
41
+ extension
42
+ }));
43
+ await sql`insert into realtime.messages (id, topic, extension, private)
44
+ select id, ${topic}, extension, true from jsonb_to_recordset(${JSON.stringify(probes)}::jsonb) as p(id uuid, extension text)`.execute(trx);
45
+ await actAs(trx, caller, topic);
46
+ const { rows } = await sql`select id::text as id from realtime.messages where id = any(${probes.map((probe) => probe.id)}::uuid[])`.execute(trx);
47
+ const seen = new Set(rows.map((row) => row.id));
48
+ return new Map(probes.map((probe) => [probe.extension, seen.has(probe.id)]));
49
+ }),
50
+ /** Whether the caller may send the extension's messages on the topic: whether it may insert one. */
51
+ write: (caller, topic, extension) => probe(false, async (trx) => {
52
+ await actAs(trx, caller, topic);
53
+ return sql`insert into realtime.messages (topic, extension, private) values (${topic}, ${extension}, true)`.execute(trx).then(() => true, (error) => {
54
+ if (isDatabaseError(error) && error.code === insufficientPrivilege) return false;
55
+ throw error;
56
+ });
57
+ })
58
+ };
59
+ };
60
+ //#endregion
61
+ export { channelAccess };
@@ -0,0 +1,46 @@
1
+ import { isDatabaseError } from "../error-handler.js";
2
+ //#region src/realtime/channel-gate.ts
3
+ /** A public channel, which may do anything. */
4
+ const publicGate = {
5
+ authorize: async () => void 0,
6
+ hears: () => true,
7
+ mayWrite: async () => true
8
+ };
9
+ /** Supabase's message for a refusal to read the topic, which it names without its `realtime:` prefix. */
10
+ const readRefusalText = (topic, refusal) => refusal === "revoked" ? `You no longer have permission to read from this Channel topic: ${subTopic(topic)}` : `You do not have permissions to read from this Channel topic: ${subTopic(topic)}`;
11
+ const subTopic = (topic) => topic.replace(/^realtime:/, "");
12
+ /**
13
+ * A private channel, as Supabase Realtime Authorization checks one: its reads of broadcasts, and of presence when it
14
+ * listens to presence, are checked by `authorize`, and each write the first time the channel sends it after that, and
15
+ * kept until its next token. A policy that fails, such as one calling a function that raises, allows nothing, and its
16
+ * error goes to `report`.
17
+ */
18
+ const privateGate = (access, topic, presence, report) => {
19
+ let reads = /* @__PURE__ */ new Map();
20
+ let writes = /* @__PURE__ */ new Map();
21
+ const denied = (nothing) => (error) => {
22
+ if (!isDatabaseError(error)) throw error;
23
+ report(error);
24
+ return nothing;
25
+ };
26
+ return {
27
+ authorize: async (caller) => {
28
+ const before = reads;
29
+ reads = await access.read(caller, subTopic(topic), presence ? ["broadcast", "presence"] : ["broadcast"]).catch(denied(/* @__PURE__ */ new Map()));
30
+ writes = /* @__PURE__ */ new Map();
31
+ if (![...reads.values()].some(Boolean)) return "denied";
32
+ if ([...before].some(([extension, allowed]) => allowed && !reads.get(extension))) return "revoked";
33
+ },
34
+ hears: (extension) => reads.get(extension) === true,
35
+ mayWrite: async (caller, extension) => {
36
+ const known = writes.get(extension);
37
+ if (known !== void 0) return known;
38
+ return access.write(caller, subTopic(topic), extension).then((allowed) => {
39
+ writes.set(extension, allowed);
40
+ return allowed;
41
+ }, denied(false));
42
+ }
43
+ };
44
+ };
45
+ //#endregion
46
+ export { privateGate, publicGate, readRefusalText };
@@ -1,10 +1,12 @@
1
1
  import { changeChecker } from "./change-checker.js";
2
2
  import { changeHub } from "./change-hub.js";
3
+ import { channelAccess } from "./channel-access.js";
3
4
  import { realtimeRoutes } from "./realtime-routes.js";
4
5
  //#region src/realtime/realtime-app.ts
5
6
  /**
6
7
  * `/api/sb/realtime/v1`: the `postgres_changes` of the live resources, each checked under the subscriber's role, claims and
7
- * the tables' RLS policies before it is sent, and broadcasts and presence between the channels of the process.
8
+ * the tables' RLS policies before it is sent, and broadcasts and presence between the channels of the process, on a
9
+ * private channel as the policies on `realtime.messages` allow.
8
10
  */
9
11
  const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
10
12
  const live = deps.registry.entries.filter((entry) => entry.model.live);
@@ -19,11 +21,16 @@ const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
19
21
  entry: (resource) => live.find((entry) => entry.name === resource),
20
22
  report
21
23
  });
24
+ const access = channelAccess({
25
+ db: deps.db,
26
+ statementTimeoutMs: deps.statementTimeoutMs
27
+ });
22
28
  return realtimeRoutes({
23
29
  upgradeWebSocket,
24
30
  caller,
25
31
  live,
26
32
  hub,
33
+ access,
27
34
  report
28
35
  });
29
36
  };
@@ -16,7 +16,7 @@ const keyRequest = (apikey, token) => new Request("http://realtime.invalid/", {
16
16
  * key is refused before the upgrade, with 401. A socket whose raw socket is not one of npm `ws`, such as Bun's own `ws`
17
17
  * shim's, is closed at once with 1011, since its unsent bytes cannot be counted (`realtimeLimits.maxUnsentBytes`).
18
18
  */
19
- const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
19
+ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report }) => {
20
20
  const app = new Hono();
21
21
  const broadcasts = broadcastHub();
22
22
  const presences = presenceHub();
@@ -45,6 +45,7 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
45
45
  hub,
46
46
  broadcasts,
47
47
  presences,
48
+ access,
48
49
  nextId,
49
50
  report
50
51
  });
@@ -4,6 +4,7 @@ import { fixedWindow, fixedWindows } from "./fixed-window.js";
4
4
  import { realtimeLimits } from "./realtime-limits.js";
5
5
  import { decodeMessage, encodeMessage, reply } from "./phoenix-message.js";
6
6
  import { decodeBroadcastPush, encodeBroadcast, fitsFrame } from "./broadcast-frame.js";
7
+ import { privateGate, publicGate, readRefusalText } from "./channel-gate.js";
7
8
  import { randomUUID } from "node:crypto";
8
9
  import { Buffer } from "node:buffer";
9
10
  //#region src/realtime/realtime-socket.ts
@@ -26,7 +27,8 @@ const refusal = (error) => {
26
27
  * One WebSocket of `/api/sb/realtime/v1`, speaking Phoenix channels as realtime-js expects: heartbeats, channel joins with
27
28
  * `postgres_changes` bindings and an access token, token refreshes on an open channel, broadcasts with their `self`
28
29
  * and `ack` options, presence tracking, and leaves. A channel's token ends it when it expires, unless a newer one came
29
- * first. Messages are handled one at a time, in order. Private channels are refused for now.
30
+ * first. Messages are handled one at a time, in order. A private channel joins, hears, broadcasts and tracks presence
31
+ * only as the policies on `realtime.messages` allow (see `privateGate`), checked again at each new token.
30
32
  *
31
33
  * It holds a client to `realtimeLimits`: a push over a rate, a channel over the cap or a presence meta too big gets an
32
34
  * error reply where Supabase would close the channel, and is not relayed. Rates count per socket, presence per socket
@@ -35,7 +37,7 @@ const refusal = (error) => {
35
37
  * earlier messages wait. A close, the client's or the server's, leaves every channel, a join still checking its token
36
38
  * included, and drops whatever is still queued.
37
39
  */
38
- const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadcasts, presences, nextId, report, idleMs = 6e4, now = Date.now }) => {
40
+ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadcasts, presences, access, nextId, report, idleMs = 6e4, now = Date.now }) => {
39
41
  const channels = /* @__PURE__ */ new Map();
40
42
  let queue = Promise.resolve();
41
43
  let queuedMessages = 0;
@@ -71,6 +73,7 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
71
73
  payload
72
74
  });
73
75
  const hear = (topic, channel, event, payload) => {
76
+ if (!channel.gate.hears("broadcast")) return;
74
77
  if (vsn === "2.0.0") return deliver(encodeBroadcast(topic, event, payload));
75
78
  if (payload instanceof ArrayBuffer) return;
76
79
  toChannel(topic, channel, "broadcast", {
@@ -109,12 +112,16 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
109
112
  if (!channels.has(message.topic) && channels.size >= realtimeLimits.channelsPerSocket) return push(reply(message, "error", { reason: "ChannelRateLimitReached: Too many channels" }));
110
113
  leave(message.topic);
111
114
  const config = isRecord(message.payload.config) ? message.payload.config : {};
112
- if (config.private === true) return push(reply(message, "error", { reason: "Not implemented: private channels" }));
113
115
  const broadcast = isRecord(config.broadcast) ? config.broadcast : {};
114
116
  const presence = isRecord(config.presence) ? config.presence : {};
117
+ const listens = presence.enabled === true;
118
+ const gate = config.private === true ? privateGate(access, message.topic, listens, report) : publicGate;
115
119
  const caller = await resolve(tokenOf(message.payload)).catch(refusal);
116
120
  if (!open) return;
117
121
  if (typeof caller === "string") return push(reply(message, "error", { reason: caller }));
122
+ const refused = await gate.authorize(caller);
123
+ if (!open) return;
124
+ if (refused) return push(reply(message, "error", { reason: `Unauthorized: ${readRefusalText(message.topic, refused)}` }));
118
125
  const parsed = parseBindings(config.postgres_changes, live, nextId);
119
126
  if (typeof parsed === "string") return push(reply(message, "error", { reason: parsed }));
120
127
  const { topic } = message;
@@ -123,10 +130,11 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
123
130
  receive: (event, payload) => hear(topic, channel, event, payload)
124
131
  };
125
132
  const key = typeof presence.key === "string" && presence.key !== "" ? presence.key : randomUUID();
126
- const listens = presence.enabled === true;
127
133
  const seat = presences.join(topic, {
128
134
  key,
129
- listens,
135
+ get listens() {
136
+ return listens && gate.hears("presence");
137
+ },
130
138
  receive: (diff) => toChannel(topic, channel, "presence_diff", diff)
131
139
  });
132
140
  const offBroadcast = broadcasts.join(topic, member);
@@ -138,6 +146,7 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
138
146
  const channel = {
139
147
  joinRef: message.joinRef,
140
148
  caller,
149
+ gate,
141
150
  ack: broadcast.ack === true,
142
151
  member,
143
152
  presence: seat,
@@ -150,7 +159,7 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
150
159
  channels.set(topic, channel);
151
160
  expireAt(topic, channel);
152
161
  push(reply(message, "ok", { postgres_changes: parsed.replies }));
153
- if (listens) toChannel(topic, channel, "presence_state", seat.state());
162
+ if (listens && gate.hears("presence")) toChannel(topic, channel, "presence_state", seat.state());
154
163
  if (parsed.bindings.length > 0) toChannel(message.topic, channel, "system", {
155
164
  extension: "postgres_changes",
156
165
  status: "ok",
@@ -163,25 +172,37 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
163
172
  const channel = channels.get(message.topic);
164
173
  if (!channel) return;
165
174
  if (typeof caller === "string") return end(message.topic, caller);
175
+ const refused = await channel.gate.authorize(caller);
176
+ if (channels.get(message.topic) !== channel) return;
177
+ if (refused) return end(message.topic, readRefusalText(message.topic, refused));
166
178
  channel.caller = caller;
167
179
  expireAt(message.topic, channel);
168
180
  };
169
- const sendBroadcast = (message, channel) => {
181
+ const sendBroadcast = async (message, channel) => {
170
182
  const { event, payload } = message.payload;
171
183
  if (!events()) return push(reply(message, "error", { reason: tooFast }));
172
184
  if (typeof event !== "string") return push(reply(message, "error", { reason: "A broadcast needs an event" }));
173
185
  if (!fitsFrame(message.topic) || !fitsFrame(event)) return push(reply(message, "error", { reason: "A broadcast topic and event are at most 255 bytes each" }));
186
+ const allowed = await channel.gate.mayWrite(channel.caller, "broadcast");
187
+ if (channels.get(message.topic) !== channel) return;
188
+ if (!allowed) return push(reply(message, "error", { error: "unauthorized" }));
174
189
  if (!broadcasts.send(message.topic, channel.member, event, payload)) return push(reply(message, "error", { reason: `${tooFast} on this topic` }));
175
190
  if (channel.ack) push(reply(message, "ok", {}));
176
191
  };
177
- const updatePresence = (message, channel) => {
192
+ const updatePresence = async (message, channel) => {
178
193
  const { event, payload } = message.payload;
179
194
  if (!events()) return push(reply(message, "error", { reason: tooFast }));
180
195
  if (!presenceRate(message.topic)) return push(reply(message, "error", { reason: "Client presence rate limit exceeded" }));
181
- if (event === "track" && isRecord(payload) && Buffer.byteLength(JSON.stringify(payload)) > realtimeLimits.maxPresenceBytes) return push(reply(message, "error", { reason: "Track message size exceeded" }));
182
- if (event === "track" && isRecord(payload)) channel.presence.track(payload);
183
- else if (event === "untrack") channel.presence.untrack();
184
- else return push(reply(message, "error", { reason: "A presence message tracks an object or untracks" }));
196
+ if (event === "untrack") {
197
+ channel.presence.untrack();
198
+ return push(reply(message, "ok", {}));
199
+ }
200
+ if (event !== "track" || !isRecord(payload)) return push(reply(message, "error", { reason: "A presence message tracks an object or untracks" }));
201
+ if (Buffer.byteLength(JSON.stringify(payload)) > realtimeLimits.maxPresenceBytes) return push(reply(message, "error", { reason: "Track message size exceeded" }));
202
+ const allowed = await channel.gate.mayWrite(channel.caller, "presence");
203
+ if (channels.get(message.topic) !== channel) return;
204
+ if (!allowed) return push(reply(message, "error", {}));
205
+ channel.presence.track(payload);
185
206
  push(reply(message, "ok", {}));
186
207
  };
187
208
  const handle = async (message) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.50.0",
3
+ "version": "0.51.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": {
@@ -40,9 +40,9 @@
40
40
  "@better-auth/passkey": "^1.7.12",
41
41
  "@better-auth/sso": "^1.7.12",
42
42
  "@modelcontextprotocol/server": "^2.3.1",
43
- "@protobase/layout": "0.50.0",
44
- "@protobase/query": "0.50.0",
45
- "@protobase/schema": "0.50.0",
43
+ "@protobase/layout": "0.51.0",
44
+ "@protobase/query": "0.51.0",
45
+ "@protobase/schema": "0.51.0",
46
46
  "@react-email/render": "^2.1.0",
47
47
  "@scalar/hono-api-reference": "^0.12.10",
48
48
  "@sentry/core": "^11.7.0",