@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.
- package/dist/assistant/delete-record-tool.js +11 -11
- package/dist/assistant/record-before.js +20 -0
- package/dist/assistant/tools.d.ts +3 -0
- package/dist/assistant/write-messages.js +41 -0
- package/dist/assistant/write-prompts.js +79 -0
- package/dist/assistant/write-record-tools.js +9 -12
- package/dist/create-admin.js +15 -11
- package/dist/locale/messages.js +8 -0
- package/dist/locale/request-locale.js +14 -0
- package/dist/mcp/mcp-approval.js +19 -8
- package/dist/mcp/mcp-context.d.ts +5 -3
- package/dist/mcp/mcp-context.js +5 -3
- package/dist/mcp/mcp-handler.d.ts +5 -2
- package/dist/mcp/mcp-handler.js +1 -1
- package/dist/mcp/mcp-server.js +1 -1
- package/dist/package.js +1 -1
- package/dist/realtime/channel-access.js +61 -0
- package/dist/realtime/channel-gate.js +46 -0
- package/dist/realtime/realtime-app.js +8 -1
- package/dist/realtime/realtime-routes.js +2 -1
- package/dist/realtime/realtime-socket.js +33 -12
- package/package.json +4 -4
|
@@ -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
|
|
64
|
-
|
|
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
|
|
36
|
-
|
|
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
|
});
|
package/dist/create-admin.js
CHANGED
|
@@ -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) =>
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 };
|
package/dist/mcp/mcp-approval.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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 ??
|
|
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
|
|
8
|
-
* shows beside a tool's result, and `mcpServer` gives each
|
|
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
|
package/dist/mcp/mcp-context.js
CHANGED
|
@@ -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
|
|
6
|
-
* shows beside a tool's result, and `mcpServer` gives each
|
|
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
|
-
/**
|
|
7
|
-
|
|
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
|
package/dist/mcp/mcp-handler.js
CHANGED
|
@@ -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;
|
package/dist/mcp/mcp-server.js
CHANGED
|
@@ -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
|
@@ -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.
|
|
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 === "
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
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.
|
|
44
|
-
"@protobase/query": "0.
|
|
45
|
-
"@protobase/schema": "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",
|