@terminus-ai/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1055 -0
- package/bin/agent-discovery.mjs +71 -0
- package/bin/agent-icon.mjs +77 -0
- package/bin/agent-models.mjs +77 -0
- package/bin/agent-type.mjs +51 -0
- package/bin/agentdev.mjs +657 -0
- package/bin/app-route-script.mjs +59 -0
- package/bin/app-runtime-contract.mjs +2 -0
- package/bin/appdev-remote.mjs +346 -0
- package/bin/appdev.mjs +4446 -0
- package/bin/apps.mjs +5512 -0
- package/bin/capability-calls.mjs +437 -0
- package/bin/capsule-data.mjs +260 -0
- package/bin/client.mjs +189 -0
- package/bin/commands.mjs +1194 -0
- package/bin/dev-capsules.mjs +1599 -0
- package/bin/dev-contract.mjs +262 -0
- package/bin/dev-data.mjs +287 -0
- package/bin/dev-members.mjs +18 -0
- package/bin/dev-net.mjs +316 -0
- package/bin/dev-notification-popup.mjs +628 -0
- package/bin/dev-ports.mjs +567 -0
- package/bin/dev-server-binding.mjs +35 -0
- package/bin/dev-server-ops.mjs +1086 -0
- package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
- package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
- package/bin/dev-ui/OFL.txt +92 -0
- package/bin/dev-ui/agent-robot.webp +0 -0
- package/bin/dev-ui/app.js +5217 -0
- package/bin/dev-ui/highlight.js +195 -0
- package/bin/dev-ui/index.html +34 -0
- package/bin/dev-ui/style.css +3640 -0
- package/bin/devlint.mjs +112 -0
- package/bin/devserver.mjs +2127 -0
- package/bin/devtriggers.mjs +367 -0
- package/bin/endpoints.mjs +156 -0
- package/bin/errors.mjs +61 -0
- package/bin/files.mjs +169 -0
- package/bin/horizontal-capabilities/v1/contract.json +280 -0
- package/bin/http.mjs +500 -0
- package/bin/lint-manifests/justbash-commands.json +88 -0
- package/bin/lint-manifests/python-stdlib.json +295 -0
- package/bin/login-page.mjs +488 -0
- package/bin/schedules.mjs +664 -0
- package/bin/server-sandbox.mjs +204 -0
- package/bin/servicedev.mjs +425 -0
- package/bin/sync.mjs +357 -0
- package/bin/terminus.js +3666 -0
- package/bin/toolchain.mjs +125 -0
- package/bin/vendor/app-runtime-v1/app-host.json +124 -0
- package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
- package/bin/vendor/app-runtime-v1/doors.json +2867 -0
- package/bin/vendor/appd/node-harness.mjs +209 -0
- package/bin/vendor/appd/python-harness.py +12 -0
- package/bin/vendor/appd/server-protocol.json +84 -0
- package/bin/vendor/where.mjs +541 -0
- package/bin/versioning.mjs +72 -0
- package/bin/write-rules.mjs +398 -0
- package/package.json +41 -0
package/bin/appdev.mjs
ADDED
|
@@ -0,0 +1,4446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `terminus dev` — a local Terminus-app harness that speaks the production
|
|
3
|
+
* runtime protocol. It serves the built ui/ bundle as real multi-file HTTP
|
|
4
|
+
* (live from disk) plus the same-origin `/_terminus/*` runtime API that
|
|
5
|
+
* `@terminus-ai/app-sdk` uses on `*.apps.terminus.build` — no injected bridge, no
|
|
6
|
+
* harness-only transport: the exact SDK code path that runs in production
|
|
7
|
+
* runs here.
|
|
8
|
+
*
|
|
9
|
+
* The wire is the contract's (doors.json in the SDK's conformance bundle,
|
|
10
|
+
* vendored under bin/vendor/app-runtime-v1/): every door it marks as served
|
|
11
|
+
* by `terminus dev` answers the documented shapes, every refusal is the one
|
|
12
|
+
* `{error: {code, message, details?}}` envelope with the code and status
|
|
13
|
+
* production answers for the same condition, and the doors a local host
|
|
14
|
+
* cannot serve (connectors, services, service jobs, egress, the sign-in
|
|
15
|
+
* callback) answer `501 unsupported_in_dev`.
|
|
16
|
+
*
|
|
17
|
+
* Simulated members make multiplayer development local. Production gives
|
|
18
|
+
* every user their own opaque app session; the harness gives every member
|
|
19
|
+
* their own ORIGIN — one port per member:
|
|
20
|
+
*
|
|
21
|
+
* terminus dev --members 2 --port 8868
|
|
22
|
+
* → alan: http://localhost:8868/ bob: http://localhost:8869/
|
|
23
|
+
*
|
|
24
|
+
* Two tabs, two identities, one shared event bus. Every simulated user gets
|
|
25
|
+
* one app capsule under .terminus/dev/users/{member}/apps/{app}/ containing a
|
|
26
|
+
* real SQLite database plus content-addressed object storage. Collection
|
|
27
|
+
* records, mutation receipts, and change cursors survive restarts; realtime
|
|
28
|
+
* events remain signals rather than truth: nothing is retained or replayed.
|
|
29
|
+
*
|
|
30
|
+
* `--guest` adds one more port after the members': a visitor who is not
|
|
31
|
+
* signed in, as an app open to guests meets one. It gets the contract's guest
|
|
32
|
+
* bootstrap, the app's own files and the capability assets the app declares,
|
|
33
|
+
* and every other door refuses it, 401 unauthorized, as production does. The
|
|
34
|
+
* app's `session.signIn()` lands on a page that picks the member to sign in
|
|
35
|
+
* as; the port then IS that member — the same origin, so what the SDK kept
|
|
36
|
+
* for the guest in this browser moves into the account — until it signs out.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { readFile, rm, stat } from "node:fs/promises";
|
|
40
|
+
import { createServer } from "node:http";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { deflateSync } from "node:zlib";
|
|
43
|
+
|
|
44
|
+
import { APP_RUNTIME_API_VERSION } from "./app-runtime-contract.mjs";
|
|
45
|
+
import { injectAppRouteScript } from "./app-route-script.mjs";
|
|
46
|
+
import { CliError, parseFlags, usageError } from "./client.mjs";
|
|
47
|
+
import { Api, connect } from "./http.mjs";
|
|
48
|
+
import { packageKind, readAppPackage, resolveCreation } from "./apps.mjs";
|
|
49
|
+
import { resolveTemplate } from "./devtriggers.mjs";
|
|
50
|
+
import { DEV_DIRECTORY, ensureDevDirectory, sha256 } from "./files.mjs";
|
|
51
|
+
import { nextCronAfter } from "./schedules.mjs";
|
|
52
|
+
import {
|
|
53
|
+
compareStrings,
|
|
54
|
+
compiledWhereMatches,
|
|
55
|
+
recordSearchMatches,
|
|
56
|
+
searchText,
|
|
57
|
+
sortRecords,
|
|
58
|
+
validateSort,
|
|
59
|
+
validateWhere,
|
|
60
|
+
} from "./vendor/where.mjs";
|
|
61
|
+
import { enforceWriteRules, normalizedWriteRules } from "./write-rules.mjs";
|
|
62
|
+
import { DevCapsuleStore, DevSystemStore } from "./dev-capsules.mjs";
|
|
63
|
+
import {
|
|
64
|
+
APP_HOST,
|
|
65
|
+
DOORS,
|
|
66
|
+
LIMITS,
|
|
67
|
+
idempotencyKey,
|
|
68
|
+
matchDoor,
|
|
69
|
+
refuseUnknownFields,
|
|
70
|
+
returnToPath,
|
|
71
|
+
runtimeError,
|
|
72
|
+
sendRuntimeError,
|
|
73
|
+
} from "./dev-contract.mjs";
|
|
74
|
+
import {
|
|
75
|
+
DevDataPlane,
|
|
76
|
+
pageLimit,
|
|
77
|
+
requestMediaType,
|
|
78
|
+
safeListPrefix,
|
|
79
|
+
safeRelativePath,
|
|
80
|
+
} from "./dev-data.mjs";
|
|
81
|
+
import { DEFAULT_DEV_MEMBER, DEFAULT_DEV_MEMBERS } from "./dev-members.mjs";
|
|
82
|
+
import { fetchPublicImage, fetchPublicPage } from "./dev-net.mjs";
|
|
83
|
+
import {
|
|
84
|
+
answerServerDoor,
|
|
85
|
+
createDevServerTier,
|
|
86
|
+
devInstallationId,
|
|
87
|
+
fetchPublicText,
|
|
88
|
+
serverError,
|
|
89
|
+
} from "./dev-server-ops.mjs";
|
|
90
|
+
import {
|
|
91
|
+
DEV_NOTIFICATION_POPUP_PATH,
|
|
92
|
+
DEV_NOTIFICATION_POPUP_SCRIPT,
|
|
93
|
+
DEV_NOTIFICATION_STREAM_PATH,
|
|
94
|
+
injectDevNotificationPopup,
|
|
95
|
+
} from "./dev-notification-popup.mjs";
|
|
96
|
+
import {
|
|
97
|
+
answerDevAbout,
|
|
98
|
+
assertDevPortRangeAvailable,
|
|
99
|
+
closeDevServer,
|
|
100
|
+
DEV_ABOUT_PATH,
|
|
101
|
+
devPortUnavailableError,
|
|
102
|
+
isLoopbackHost,
|
|
103
|
+
isLoopbackOrigin,
|
|
104
|
+
isSameOrigin,
|
|
105
|
+
listenDevServer,
|
|
106
|
+
refuseCrossSite,
|
|
107
|
+
refuseForeignHost,
|
|
108
|
+
resolveDevPortRange,
|
|
109
|
+
} from "./dev-ports.mjs";
|
|
110
|
+
|
|
111
|
+
/** The app and release every local member runs: the folder, as one release. */
|
|
112
|
+
const DEV_APP_ID = "dev-app";
|
|
113
|
+
const DEV_RELEASE_ID = "dev-release";
|
|
114
|
+
/** Where users.profile sends a browser: the harness's page for a member. */
|
|
115
|
+
const DEV_PROFILE_PREFIX = "/__terminus_dev/users/";
|
|
116
|
+
/** A collect submission's undo window, as the platform keeps it. */
|
|
117
|
+
const COLLECT_UNDO_MS = 120_000;
|
|
118
|
+
/** What a guest is told at a door that needs an account, in production's
|
|
119
|
+
* words; the code (and so the status) is the contract's `guest.refusal`. */
|
|
120
|
+
const GUEST_REFUSAL = "Sign in to use this (guest mode)";
|
|
121
|
+
/** The sign-in door (`host.signin`): a navigation, whose picker on the
|
|
122
|
+
* guest's port posts its form back to the same path. */
|
|
123
|
+
const SIGN_IN_PATH = DOORS.doors.find((door) => door.id === "host.signin").path;
|
|
124
|
+
/** The picker's form is a member's handle and a place: this is plenty. */
|
|
125
|
+
const SIGN_IN_FORM_BYTES = 16 * 1024;
|
|
126
|
+
/** The seven hues Chats draws contact monograms in — the notification
|
|
127
|
+
* corner's too, so a face on the sign-in page is the same face there. */
|
|
128
|
+
const MONOGRAM_HUES = [211, 254, 291, 338, 14, 32, 168];
|
|
129
|
+
|
|
130
|
+
const CONTENT_TYPES = new Map([
|
|
131
|
+
[".html", "text/html; charset=utf-8"],
|
|
132
|
+
[".js", "text/javascript; charset=utf-8"],
|
|
133
|
+
[".mjs", "text/javascript; charset=utf-8"],
|
|
134
|
+
[".css", "text/css; charset=utf-8"],
|
|
135
|
+
[".json", "application/json"],
|
|
136
|
+
[".svg", "image/svg+xml"],
|
|
137
|
+
[".png", "image/png"],
|
|
138
|
+
[".jpg", "image/jpeg"],
|
|
139
|
+
[".jpeg", "image/jpeg"],
|
|
140
|
+
[".webp", "image/webp"],
|
|
141
|
+
[".gif", "image/gif"],
|
|
142
|
+
[".ico", "image/x-icon"],
|
|
143
|
+
[".woff2", "font/woff2"],
|
|
144
|
+
[".woff", "font/woff"],
|
|
145
|
+
[".txt", "text/plain; charset=utf-8"],
|
|
146
|
+
]);
|
|
147
|
+
function horizontalForbidden(message) {
|
|
148
|
+
return runtimeError("forbidden", message);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The contract's guest bootstrap (app-host.json `guest.bootstrap`) with its
|
|
152
|
+
* `{resolve.*}` placeholders filled in from `resolve`, as the app host fills
|
|
153
|
+
* them from the platform's resolve answer; everything else is the
|
|
154
|
+
* contract's own. A placeholder with no value here is a contract the harness
|
|
155
|
+
* has not caught up with, and says so rather than answer a guest wrongly. */
|
|
156
|
+
function fillGuestBootstrap(template, resolve) {
|
|
157
|
+
if (typeof template === "string") {
|
|
158
|
+
const placeholder = /^\{resolve\.([a-z_]+)\}$/u.exec(template);
|
|
159
|
+
if (!placeholder) return template;
|
|
160
|
+
if (!Object.hasOwn(resolve, placeholder[1])) {
|
|
161
|
+
throw new Error(`the contract's guest bootstrap names {resolve.${placeholder[1]}}, which terminus dev does not fill`);
|
|
162
|
+
}
|
|
163
|
+
return resolve[placeholder[1]];
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(template)) return template.map((item) => fillGuestBootstrap(item, resolve));
|
|
166
|
+
if (template && typeof template === "object") {
|
|
167
|
+
return Object.fromEntries(
|
|
168
|
+
Object.entries(template).map(([key, value]) => [key, fillGuestBootstrap(value, resolve)]),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return template;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A face without a picture, as the notification corner draws one: a hue
|
|
175
|
+
* and up to two initials from the handle. */
|
|
176
|
+
function monogram(handle) {
|
|
177
|
+
let hash = 0;
|
|
178
|
+
for (const character of String(handle)) hash = (hash * 31 + character.charCodeAt(0)) | 0;
|
|
179
|
+
const parts = String(handle).replace(/^@/u, "").split(/[\s._-]+/u).filter(Boolean);
|
|
180
|
+
return {
|
|
181
|
+
hue: Math.abs(hash) % MONOGRAM_HUES.length,
|
|
182
|
+
initials: (parts.slice(0, 2).map((part) => [...part][0]).join("") || "?").toUpperCase(),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function declaredHorizontalCapability(capabilities, id, version, operation = null) {
|
|
187
|
+
const declaration = (capabilities?.horizontal ?? []).find(
|
|
188
|
+
(entry) => entry.id === id && entry.version === version,
|
|
189
|
+
);
|
|
190
|
+
if (!declaration) {
|
|
191
|
+
throw horizontalForbidden(
|
|
192
|
+
`app release did not declare horizontal capability '${id}' version ${version}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (operation && Array.isArray(declaration.operations)
|
|
196
|
+
&& !declaration.operations.includes(operation)) {
|
|
197
|
+
throw horizontalForbidden(
|
|
198
|
+
`app release did not declare horizontal operation '${id}' version ${version}#${operation}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return declaration;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The horizontal capability plane, proxied from the platform.
|
|
205
|
+
*
|
|
206
|
+
* The dev harness no longer mirrors capability assets or reimplements
|
|
207
|
+
* operations — the CLI is a thin authenticated wrapper, and the platform is
|
|
208
|
+
* the one implementation of its own capability plane. What stays local is
|
|
209
|
+
* the release-contract gate (the same declaration semantics the production
|
|
210
|
+
* app host enforces) and the declared-set filter on the registry listing,
|
|
211
|
+
* because a CLI login is a user credential rather than an app session, so
|
|
212
|
+
* the platform cannot scope the listing for us. Brokered operations require
|
|
213
|
+
* a real app session and therefore `terminus dev --remote`; here they
|
|
214
|
+
* return the platform's own authorization error, truthfully.
|
|
215
|
+
*
|
|
216
|
+
* A guest reaches the assets alone (the door gate sends nothing else here),
|
|
217
|
+
* and fetches them as the app host fetches a guest's: public bytes, asked
|
|
218
|
+
* for with no credential and no artifact context — never the developer's.
|
|
219
|
+
*/
|
|
220
|
+
async function serveHorizontalCapability(segments, request, response, capabilities, platform, { guest = false } = {}) {
|
|
221
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
222
|
+
if (!platform) {
|
|
223
|
+
throw runtimeError(
|
|
224
|
+
"service_unavailable",
|
|
225
|
+
"platform capabilities need a signed-in CLI: run `terminus login`, then restart `terminus dev`",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const { forwardPlatformRequest } = await import("./appdev-remote.mjs");
|
|
229
|
+
const context = platform.artifactContext ?? null;
|
|
230
|
+
if (segments.length === 0 && method === "GET") {
|
|
231
|
+
// The platform's refusal is the app's to read, so its status comes back
|
|
232
|
+
// as it was answered.
|
|
233
|
+
const upstream = await new Api({ base: platform.apiBase, token: platform.token }).open("GET /v1/capabilities", {
|
|
234
|
+
headers: context ? { "x-terminus-artifact-context": context } : {},
|
|
235
|
+
});
|
|
236
|
+
const registry = await upstream.json().catch(() => null);
|
|
237
|
+
if (!upstream.ok && registry?.error?.code) return sendJson(response, upstream.status, registry);
|
|
238
|
+
if (!upstream.ok || !registry) {
|
|
239
|
+
throw runtimeError("upstream_failed", `the platform capability registry returned ${upstream.status}`);
|
|
240
|
+
}
|
|
241
|
+
return sendJson(response, 200, {
|
|
242
|
+
...registry,
|
|
243
|
+
capabilities: (registry.capabilities ?? []).filter((entry) => (
|
|
244
|
+
(capabilities?.horizontal ?? []).some(
|
|
245
|
+
(declared) => declared.id === entry.id && declared.version === entry.version,
|
|
246
|
+
)
|
|
247
|
+
)),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const [id, rawMajor, surface, ...rest] = segments;
|
|
251
|
+
const version = /^v[1-9][0-9]*$/u.test(rawMajor ?? "") ? Number(rawMajor.slice(1)) : NaN;
|
|
252
|
+
if (!Number.isSafeInteger(version)) {
|
|
253
|
+
throw runtimeError("not_found", "horizontal capability route not found");
|
|
254
|
+
}
|
|
255
|
+
declaredHorizontalCapability(capabilities, id, version);
|
|
256
|
+
const target = (suffix) => `${platform.apiBase}/capabilities/${encodeURIComponent(id)}/${rawMajor}${suffix}`;
|
|
257
|
+
if (!surface && method === "GET") {
|
|
258
|
+
return forwardPlatformRequest(target(""), platform.token, method, request, response, context);
|
|
259
|
+
}
|
|
260
|
+
if (surface === "assets" && rest.length && (method === "GET" || method === "HEAD")) {
|
|
261
|
+
try {
|
|
262
|
+
for (const encoded of rest) {
|
|
263
|
+
const segment = decodeURIComponent(encoded);
|
|
264
|
+
if (!segment || segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\")) {
|
|
265
|
+
throw runtimeError("not_found", "horizontal capability asset not found");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (error?.status) throw error;
|
|
270
|
+
throw runtimeError("not_found", "horizontal capability asset not found");
|
|
271
|
+
}
|
|
272
|
+
return forwardPlatformRequest(
|
|
273
|
+
target(`/assets/${rest.join("/")}`),
|
|
274
|
+
guest ? null : platform.token,
|
|
275
|
+
method,
|
|
276
|
+
request,
|
|
277
|
+
response,
|
|
278
|
+
guest ? null : context,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (surface === "operations" && rest.length === 1 && method === "POST") {
|
|
282
|
+
declaredHorizontalCapability(capabilities, id, version, rest[0]);
|
|
283
|
+
return forwardPlatformRequest(target(`/operations/${encodeURIComponent(rest[0])}`), platform.token, method, request, response, context);
|
|
284
|
+
}
|
|
285
|
+
throw runtimeError("not_found", "horizontal capability route not found");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const MAX_DEV_AVATAR_BYTES = 2 * 1024 * 1024;
|
|
289
|
+
const DEV_HANDLE_PATTERN = /^[a-z0-9][a-z0-9._~-]{0,63}$/;
|
|
290
|
+
|
|
291
|
+
const DEFAULT_DEV_AVATAR_COLORS = [
|
|
292
|
+
[71, 109, 133],
|
|
293
|
+
[123, 91, 138],
|
|
294
|
+
[177, 105, 82],
|
|
295
|
+
[70, 130, 110],
|
|
296
|
+
[150, 117, 57],
|
|
297
|
+
[90, 105, 150],
|
|
298
|
+
];
|
|
299
|
+
const RECORD_ID_PATTERN = new RegExp(LIMITS.record_id_pattern);
|
|
300
|
+
function requireManagedName(raw, kind) {
|
|
301
|
+
const value = String(raw ?? "");
|
|
302
|
+
if (!/^[a-z][a-z0-9_-]{0,63}$/.test(value)) throw new CliError(`invalid ${kind} name`);
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function requireManagedId(raw) {
|
|
307
|
+
const value = String(raw ?? "");
|
|
308
|
+
if (!RECORD_ID_PATTERN.test(value)) {
|
|
309
|
+
throw new CliError("invalid record id");
|
|
310
|
+
}
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** An event's notify, checked as the backend's `validate_event_notify` does:
|
|
315
|
+
* the trimmed title, the body and the route each counted in characters. */
|
|
316
|
+
function normalizeEventNotify(raw) {
|
|
317
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
318
|
+
throw new CliError("invalid notify");
|
|
319
|
+
}
|
|
320
|
+
const title = typeof raw.title === "string" ? raw.title.trim() : "";
|
|
321
|
+
const body = raw.body === undefined ? "" : raw.body;
|
|
322
|
+
const route = raw.route === undefined || raw.route === null ? null : raw.route;
|
|
323
|
+
if (!title
|
|
324
|
+
|| [...title].length > LIMITS.notification_title_chars
|
|
325
|
+
|| typeof body !== "string"
|
|
326
|
+
|| [...body].length > LIMITS.notification_body_chars
|
|
327
|
+
|| (route !== null
|
|
328
|
+
&& (typeof route !== "string" || [...route].length > LIMITS.notification_route_chars))) {
|
|
329
|
+
throw new CliError("invalid notify");
|
|
330
|
+
}
|
|
331
|
+
return { title, body, route };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** How much of what was said a mention row previews. */
|
|
335
|
+
const MENTION_PREVIEW_CHARS = 140;
|
|
336
|
+
/** How long one presence write keeps a member on a topic — the platform's
|
|
337
|
+
* presence field lifetime. The SDK re-asserts every ~25s while a room is open. */
|
|
338
|
+
const PRESENCE_TTL_MS = 55_000;
|
|
339
|
+
|
|
340
|
+
/** The first `count` characters, counted as the platform counts them (code
|
|
341
|
+
* points, so an emoji is never cut in half). */
|
|
342
|
+
function firstChars(text, count) {
|
|
343
|
+
return [...text].slice(0, count).join("");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** A transaction's or a delivery's notify: an event's, plus who its message
|
|
347
|
+
* row is for — "absent" (everyone not looking, the default) or "mentioned"
|
|
348
|
+
* (no message row at all; the mentioned have their own). */
|
|
349
|
+
function normalizeWriteNotify(raw) {
|
|
350
|
+
const notify = normalizeEventNotify(raw);
|
|
351
|
+
if (raw.audience === undefined || raw.audience === null) return notify;
|
|
352
|
+
if (raw.audience !== "absent" && raw.audience !== "mentioned") {
|
|
353
|
+
throw new CliError("notify.audience must be absent or mentioned");
|
|
354
|
+
}
|
|
355
|
+
return { ...notify, audience: raw.audience };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The rings a transaction or a delivery commits with: an optional notify,
|
|
359
|
+
* and the handles to mention (at most the contract's `mentions`). */
|
|
360
|
+
function normalizeWriteRings(body, write) {
|
|
361
|
+
const notify = body?.notify == null ? null : normalizeWriteNotify(body.notify);
|
|
362
|
+
const mentions = body?.mentions ?? [];
|
|
363
|
+
if (!Array.isArray(mentions) || mentions.some((handle) => typeof handle !== "string")) {
|
|
364
|
+
throw new CliError("mentions must be a list of handles");
|
|
365
|
+
}
|
|
366
|
+
if (mentions.length > LIMITS.mentions) {
|
|
367
|
+
throw new CliError(`at most ${LIMITS.mentions} mentions per ${write}`);
|
|
368
|
+
}
|
|
369
|
+
return { notify, mentions };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Handles resolved the way the platform resolves them (`space_member_targets`):
|
|
373
|
+
* trimmed, leading "@"s dropped, lowercased, and matched against `members` —
|
|
374
|
+
* never the sender, each person once. Unknown handles and non-members drop
|
|
375
|
+
* silently. */
|
|
376
|
+
function mentionTargets(members, handles, sender) {
|
|
377
|
+
const wanted = new Set(
|
|
378
|
+
handles
|
|
379
|
+
.map((handle) => String(handle).trim().replace(/^@+/, "").toLowerCase())
|
|
380
|
+
.filter(Boolean),
|
|
381
|
+
);
|
|
382
|
+
return members.filter((candidate) => candidate !== sender && wanted.has(candidate.toLowerCase()));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const COLLECTION_PERMISSION_TOKENS = new Set([
|
|
386
|
+
"allow", "deny", "user", "agent", "automation", "capsule-owner",
|
|
387
|
+
"record-creator", "record-updater", "space-member", "space-editor", "space-admin",
|
|
388
|
+
]);
|
|
389
|
+
|
|
390
|
+
function validateCollectionPermissionRule(rule, { fieldRule = false, depth = 0 } = {}) {
|
|
391
|
+
if (depth > 8) throw new CliError("collection permission rules are nested too deeply");
|
|
392
|
+
if (typeof rule === "string") {
|
|
393
|
+
if (!COLLECTION_PERMISSION_TOKENS.has(rule)) {
|
|
394
|
+
throw new CliError(`unknown collection permission token '${rule}'`);
|
|
395
|
+
}
|
|
396
|
+
if (fieldRule && ["record-creator", "record-updater"].includes(rule)) {
|
|
397
|
+
throw new CliError("field permission rules cannot depend on record provenance");
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
|
402
|
+
throw new CliError("collection permission rules must be tokens or rule objects");
|
|
403
|
+
}
|
|
404
|
+
const entries = Object.entries(rule);
|
|
405
|
+
if (entries.length !== 1) throw new CliError("collection permission rule objects have one operator");
|
|
406
|
+
const [operator, value] = entries[0];
|
|
407
|
+
if (operator === "fieldEqualsActor") {
|
|
408
|
+
if (fieldRule) throw new CliError("field permission rules cannot inspect record fields");
|
|
409
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_.-]{1,64}$/.test(value)) {
|
|
410
|
+
throw new CliError("fieldEqualsActor requires a short field name");
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (operator === "not") {
|
|
415
|
+
validateCollectionPermissionRule(value, { fieldRule, depth: depth + 1 });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (!["anyOf", "allOf"].includes(operator) || !Array.isArray(value)
|
|
419
|
+
|| !value.length || value.length > 16) {
|
|
420
|
+
throw new CliError("permission anyOf/allOf requires 1-16 nested rules");
|
|
421
|
+
}
|
|
422
|
+
for (const nested of value) {
|
|
423
|
+
validateCollectionPermissionRule(nested, { fieldRule, depth: depth + 1 });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function validateCollectionPermissions(raw) {
|
|
428
|
+
if (raw === undefined) return undefined;
|
|
429
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
430
|
+
throw new CliError("collection permissions must be an object");
|
|
431
|
+
}
|
|
432
|
+
for (const [action, rule] of Object.entries(raw)) {
|
|
433
|
+
if (!["read", "create", "update", "delete", "fields"].includes(action)) {
|
|
434
|
+
throw new CliError(`unknown collection permission '${action}'`);
|
|
435
|
+
}
|
|
436
|
+
if (action !== "fields") {
|
|
437
|
+
validateCollectionPermissionRule(rule);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)
|
|
441
|
+
|| Object.keys(rule).length > 32) {
|
|
442
|
+
throw new CliError("collection field permissions must be an object with at most 32 fields");
|
|
443
|
+
}
|
|
444
|
+
for (const [field, permissions] of Object.entries(rule)) {
|
|
445
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(field)
|
|
446
|
+
|| !permissions || typeof permissions !== "object" || Array.isArray(permissions)) {
|
|
447
|
+
throw new CliError(`invalid field permission '${field}'`);
|
|
448
|
+
}
|
|
449
|
+
for (const [operation, fieldRule] of Object.entries(permissions)) {
|
|
450
|
+
if (!["read", "write"].includes(operation)) {
|
|
451
|
+
throw new CliError(`unknown field permission '${field}.${operation}'`);
|
|
452
|
+
}
|
|
453
|
+
validateCollectionPermissionRule(fieldRule, { fieldRule: true });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return structuredClone(raw);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function normalizeCollectionDefinition(name, raw, { strict = true } = {}) {
|
|
461
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
462
|
+
throw new CliError("collection definition must be an object");
|
|
463
|
+
}
|
|
464
|
+
if (strict) {
|
|
465
|
+
const unknown = Object.keys(raw).find(
|
|
466
|
+
(field) => ![
|
|
467
|
+
"ownership", "search", "schema", "indexes", "unique", "relations", "permissions",
|
|
468
|
+
"validate",
|
|
469
|
+
].includes(field),
|
|
470
|
+
);
|
|
471
|
+
if (unknown) throw new CliError(`unknown collection definition field '${unknown}'`);
|
|
472
|
+
}
|
|
473
|
+
const ownership = raw.ownership ?? "personal";
|
|
474
|
+
if (!["personal", "space"].includes(ownership)) {
|
|
475
|
+
throw new CliError("collection ownership must be personal or space");
|
|
476
|
+
}
|
|
477
|
+
const search = raw.search ?? [];
|
|
478
|
+
if (
|
|
479
|
+
!Array.isArray(search)
|
|
480
|
+
|| search.length > 8
|
|
481
|
+
|| new Set(search).size !== search.length
|
|
482
|
+
|| search.some((field) => !/^[A-Za-z0-9_.-]{1,64}$/.test(field))
|
|
483
|
+
) {
|
|
484
|
+
throw new CliError("collection search accepts up to 8 unique short field names");
|
|
485
|
+
}
|
|
486
|
+
const schemaValid = raw.schema === undefined
|
|
487
|
+
|| typeof raw.schema === "string"
|
|
488
|
+
|| (raw.schema !== null && typeof raw.schema === "object" && !Array.isArray(raw.schema));
|
|
489
|
+
if (!schemaValid) {
|
|
490
|
+
throw new CliError("collection schema must be JSON Schema or a bundle path");
|
|
491
|
+
}
|
|
492
|
+
const normalizeFields = (key, maximum) => {
|
|
493
|
+
const fields = raw[key] ?? [];
|
|
494
|
+
if (!Array.isArray(fields) || fields.length > maximum
|
|
495
|
+
|| new Set(fields).size !== fields.length
|
|
496
|
+
|| fields.some((field) => !/^[A-Za-z0-9_.-]{1,64}$/.test(field))) {
|
|
497
|
+
throw new CliError(`collection ${key} accepts up to ${maximum} unique short field names`);
|
|
498
|
+
}
|
|
499
|
+
return [...fields];
|
|
500
|
+
};
|
|
501
|
+
const indexes = normalizeFields("indexes", 16);
|
|
502
|
+
const unique = normalizeFields("unique", 8);
|
|
503
|
+
for (const field of unique) if (!indexes.includes(field)) indexes.push(field);
|
|
504
|
+
if (indexes.length > 16) {
|
|
505
|
+
throw new CliError("collection indexes plus unique fields exceed 16 fields");
|
|
506
|
+
}
|
|
507
|
+
const relations = raw.relations ?? {};
|
|
508
|
+
if (!relations || typeof relations !== "object" || Array.isArray(relations)
|
|
509
|
+
|| Object.keys(relations).length > 16) {
|
|
510
|
+
throw new CliError("collection relations must be an object with at most 16 relations");
|
|
511
|
+
}
|
|
512
|
+
for (const [relationName, relation] of Object.entries(relations)) {
|
|
513
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(relationName)
|
|
514
|
+
|| !relation || typeof relation !== "object" || Array.isArray(relation)) {
|
|
515
|
+
throw new CliError(`invalid relation '${relationName}'`);
|
|
516
|
+
}
|
|
517
|
+
const unknown = Object.keys(relation).find(
|
|
518
|
+
(field) => !["collection", "field", "cardinality", "required", "onDelete"].includes(field),
|
|
519
|
+
);
|
|
520
|
+
if (unknown) throw new CliError(`unknown relation field '${relationName}.${unknown}'`);
|
|
521
|
+
if (!/^[a-z][a-z0-9_-]{0,63}$/.test(relation.collection ?? "")) {
|
|
522
|
+
throw new CliError(`relation '${relationName}' requires a target collection`);
|
|
523
|
+
}
|
|
524
|
+
if (relation.field !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(relation.field)) {
|
|
525
|
+
throw new CliError(`relation '${relationName}' has an invalid record field`);
|
|
526
|
+
}
|
|
527
|
+
if (relation.cardinality !== undefined && !["one", "many"].includes(relation.cardinality)) {
|
|
528
|
+
throw new CliError(`relation '${relationName}' cardinality must be one or many`);
|
|
529
|
+
}
|
|
530
|
+
if (relation.required !== undefined && typeof relation.required !== "boolean") {
|
|
531
|
+
throw new CliError(`relation '${relationName}' required must be boolean`);
|
|
532
|
+
}
|
|
533
|
+
if (relation.onDelete !== undefined && !["restrict", "cascade"].includes(relation.onDelete)) {
|
|
534
|
+
throw new CliError(`relation '${relationName}' onDelete must be restrict or cascade`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const permissions = validateCollectionPermissions(raw.permissions);
|
|
538
|
+
const validate = normalizedWriteRules(raw.validate);
|
|
539
|
+
const definition = {
|
|
540
|
+
name,
|
|
541
|
+
ownership,
|
|
542
|
+
search,
|
|
543
|
+
indexes,
|
|
544
|
+
unique,
|
|
545
|
+
relations: structuredClone(relations),
|
|
546
|
+
...(permissions === undefined ? {} : { permissions }),
|
|
547
|
+
...(raw.schema !== undefined ? { schema: raw.schema } : {}),
|
|
548
|
+
...(validate === undefined ? {} : { validate }),
|
|
549
|
+
};
|
|
550
|
+
if (Buffer.byteLength(JSON.stringify(definition)) > LIMITS.collection_definition_bytes) {
|
|
551
|
+
throw new CliError(`collection definition exceeds ${LIMITS.collection_definition_bytes / 1024} KiB`);
|
|
552
|
+
}
|
|
553
|
+
return definition;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function jsonTypeMatches(value, expected) {
|
|
557
|
+
if (expected === "object") return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
558
|
+
if (expected === "array") return Array.isArray(value);
|
|
559
|
+
if (expected === "string") return typeof value === "string";
|
|
560
|
+
if (expected === "number") return typeof value === "number" && Number.isFinite(value);
|
|
561
|
+
if (expected === "integer") return Number.isSafeInteger(value);
|
|
562
|
+
if (expected === "boolean") return typeof value === "boolean";
|
|
563
|
+
if (expected === "null") return value === null;
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Match the deliberately small production JSON-Schema subset. Richer client
|
|
568
|
+
* validation is welcome, but the persistence door must enforce the same
|
|
569
|
+
* object/required/property-type floor in every environment. */
|
|
570
|
+
function validateRecordSchema(declaration, record) {
|
|
571
|
+
if (record === null || typeof record !== "object" || Array.isArray(record)) {
|
|
572
|
+
throw new CliError("collection records must be JSON objects");
|
|
573
|
+
}
|
|
574
|
+
const schema = declaration.schema;
|
|
575
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return;
|
|
576
|
+
if (typeof schema.type === "string" && !jsonTypeMatches(record, schema.type)) {
|
|
577
|
+
throw new CliError(`record does not match schema type '${schema.type}'`);
|
|
578
|
+
}
|
|
579
|
+
if (Array.isArray(schema.required)) {
|
|
580
|
+
for (const field of schema.required.filter((value) => typeof value === "string")) {
|
|
581
|
+
if (!Object.hasOwn(record, field)) {
|
|
582
|
+
throw new CliError(`record is missing required field '${field}'`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) {
|
|
587
|
+
for (const [field, property] of Object.entries(schema.properties)) {
|
|
588
|
+
if (!Object.hasOwn(record, field)) continue;
|
|
589
|
+
if (
|
|
590
|
+
property
|
|
591
|
+
&& typeof property === "object"
|
|
592
|
+
&& typeof property.type === "string"
|
|
593
|
+
&& !jsonTypeMatches(record[field], property.type)
|
|
594
|
+
) {
|
|
595
|
+
throw new CliError(`record field '${field}' must be ${property.type}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function validateManagedRecord(declaration, record) {
|
|
602
|
+
validateRecordSchema(declaration, record);
|
|
603
|
+
if (Buffer.byteLength(JSON.stringify(record)) > LIMITS.record_value_bytes) {
|
|
604
|
+
throw runtimeError("payload_too_large", `record exceeds ${LIMITS.record_value_bytes / 1024} KiB`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function permissionRuleAllows(rule, actor, record = null) {
|
|
609
|
+
if (typeof rule === "string") {
|
|
610
|
+
return {
|
|
611
|
+
allow: true,
|
|
612
|
+
deny: false,
|
|
613
|
+
user: actor.principal === "user",
|
|
614
|
+
agent: actor.principal === "agent",
|
|
615
|
+
automation: actor.principal === "automation",
|
|
616
|
+
"capsule-owner": actor.userId === actor.capsuleOwner,
|
|
617
|
+
"record-creator": record?.created_by_user_id === actor.userId,
|
|
618
|
+
"record-updater": record?.updated_by_user_id === actor.userId,
|
|
619
|
+
"space-member": Boolean(actor.spaceRole),
|
|
620
|
+
"space-editor": ["manager", "owner", "admin", "editor"].includes(actor.spaceRole),
|
|
621
|
+
"space-admin": ["manager", "owner", "admin"].includes(actor.spaceRole),
|
|
622
|
+
}[rule] ?? false;
|
|
623
|
+
}
|
|
624
|
+
const [operator, operand] = Object.entries(rule ?? {})[0] ?? [];
|
|
625
|
+
if (operator === "fieldEqualsActor") return record?.value?.[operand] === actor.userId;
|
|
626
|
+
if (operator === "anyOf") return operand.some((nested) => permissionRuleAllows(nested, actor, record));
|
|
627
|
+
if (operator === "allOf") return operand.every((nested) => permissionRuleAllows(nested, actor, record));
|
|
628
|
+
if (operator === "not") return !permissionRuleAllows(operand, actor, record);
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function authorizeCollection(declaration, action, actor, record = null) {
|
|
633
|
+
const rule = declaration.permissions?.[action];
|
|
634
|
+
if (rule === undefined || permissionRuleAllows(rule, actor, record)) return;
|
|
635
|
+
const error = new CliError(`collection ${action} permission denied`);
|
|
636
|
+
error.status = 403;
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function fieldPermissionAllows(declaration, field, action, actor) {
|
|
641
|
+
const rule = declaration.permissions?.fields?.[field]?.[action];
|
|
642
|
+
return rule === undefined || permissionRuleAllows(rule, actor);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function authorizeChangedFields(declaration, actor, before, after) {
|
|
646
|
+
for (const field of new Set([
|
|
647
|
+
...Object.keys(before ?? {}),
|
|
648
|
+
...Object.keys(after ?? {}),
|
|
649
|
+
])) {
|
|
650
|
+
if (JSON.stringify(before?.[field]) === JSON.stringify(after?.[field])) continue;
|
|
651
|
+
if (!fieldPermissionAllows(declaration, field, "write", actor)) {
|
|
652
|
+
const error = new CliError(`collection field '${field}' is not writable by this actor`);
|
|
653
|
+
error.status = 403;
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function redactCollectionRecord(declaration, actor, record) {
|
|
660
|
+
return {
|
|
661
|
+
...record,
|
|
662
|
+
value: Object.fromEntries(Object.entries(record.value).filter(([field]) => (
|
|
663
|
+
fieldPermissionAllows(declaration, field, "read", actor)
|
|
664
|
+
))),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function requireReadableField(declaration, actor, field) {
|
|
669
|
+
if (fieldPermissionAllows(declaration, field, "read", actor)) return;
|
|
670
|
+
const error = new CliError(`collection field '${field}' is not readable by this actor`);
|
|
671
|
+
error.status = 403;
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function whereFieldNames(value, fields = new Set()) {
|
|
676
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fields;
|
|
677
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
678
|
+
if (!key.startsWith("$")) fields.add(key);
|
|
679
|
+
if (key === "$and" || key === "$or") {
|
|
680
|
+
for (const branch of Array.isArray(nested) ? nested : []) whereFieldNames(branch, fields);
|
|
681
|
+
} else if (key === "$not") whereFieldNames(nested, fields);
|
|
682
|
+
}
|
|
683
|
+
return fields;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** The §2.6 query grammar is evaluated by the ONE JavaScript evaluator the
|
|
687
|
+
* SDK publishes as `@terminus-ai/app-sdk/where`, vendored byte-for-byte at
|
|
688
|
+
* `bin/vendor/where.mjs` and pinned by `test/contracts/app-runtime-v1/`.
|
|
689
|
+
* These wrappers add only the harness's own door checks (the where byte
|
|
690
|
+
* limit) and the `{ id, value }` record shape the collection store uses. */
|
|
691
|
+
export function validateWhereFilter(filter) {
|
|
692
|
+
if (!filter || typeof filter !== "object" || Array.isArray(filter)) {
|
|
693
|
+
throw new CliError("where must be a JSON object");
|
|
694
|
+
}
|
|
695
|
+
if (Buffer.byteLength(JSON.stringify(filter)) > LIMITS.where_bytes) {
|
|
696
|
+
throw new CliError("where must be a small JSON object");
|
|
697
|
+
}
|
|
698
|
+
return validateWhere(filter);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Does `value` satisfy `where`? Validates like the backend's 400 door. */
|
|
702
|
+
export function whereMatches(value, filter) {
|
|
703
|
+
return compiledWhereMatches(value, validateWhereFilter(filter ?? {}));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Filter `{ id, value }` records by a compiled (or raw) where filter. */
|
|
707
|
+
export function filterManagedRecords(records, filter) {
|
|
708
|
+
const compiled = filter && "containment" in filter && "conditions" in filter
|
|
709
|
+
? filter
|
|
710
|
+
: validateWhereFilter(filter ?? {});
|
|
711
|
+
return records.filter((record) => compiledWhereMatches(record.value, compiled));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** `ORDER BY data->field [DESC] NULLS LAST, record_id` (or record_id alone). */
|
|
715
|
+
export function sortManagedRecords(records, sort) {
|
|
716
|
+
return sortRecords(records, sort || undefined);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Searchable text per §2.6: the declared search fields, else the record. */
|
|
720
|
+
export function searchTextOf(declaration, value) {
|
|
721
|
+
return searchText(declaration?.search, value);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** `websearch_to_tsquery`-style matching (whole tokens, AND/OR/NOT, quoted
|
|
725
|
+
* phrases) over the declared search fields — the production semantics, not
|
|
726
|
+
* a substring scan. */
|
|
727
|
+
export function searchManagedRecords(declaration, records, query) {
|
|
728
|
+
return records.filter((record) => recordSearchMatches(declaration?.search, record.value, query));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Spaces, invitations and blocks as the harness keeps them. The doors
|
|
732
|
+
* decide who may do what (the platform's rules and words); this only holds
|
|
733
|
+
* the state and its one rule of shape: a space's first member is its owner. */
|
|
734
|
+
class DevSpaceRegistry {
|
|
735
|
+
constructor(systemStore) {
|
|
736
|
+
this.systemStore = systemStore;
|
|
737
|
+
this.spaces = [];
|
|
738
|
+
this.invitations = [];
|
|
739
|
+
this.blocks = [];
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
load() {
|
|
743
|
+
this.spaces = this.systemStore.loadSpaces();
|
|
744
|
+
this.invitations = this.systemStore.loadSpaceInvitations();
|
|
745
|
+
this.blocks = this.systemStore.loadUserBlocks();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
save() {
|
|
749
|
+
this.systemStore.saveSpaces(this.spaces);
|
|
750
|
+
this.systemStore.saveSpaceInvitations(this.invitations);
|
|
751
|
+
this.systemStore.saveUserBlocks(this.blocks);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
find(spaceId) {
|
|
755
|
+
return this.spaces.find((space) => space.id === spaceId) ?? null;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
memberRole(spaceId, member) {
|
|
759
|
+
const space = this.find(spaceId);
|
|
760
|
+
if (!space || !space.members.includes(member)) return null;
|
|
761
|
+
return space.roles?.[member] ?? (space.members[0] === member ? "owner" : "editor");
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
canManage(spaceId, member) {
|
|
765
|
+
return ["owner", "admin"].includes(this.memberRole(spaceId, member));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
isBlocked(first, second) {
|
|
769
|
+
return this.blocks.some(({ blocker, blocked }) => (
|
|
770
|
+
(blocker === first && blocked === second) || (blocker === second && blocked === first)
|
|
771
|
+
));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
blockedBy(blocker, blocked) {
|
|
775
|
+
return this.blocks.some((entry) => entry.blocker === blocker && entry.blocked === blocked);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
setBlocked(blocker, blocked, value) {
|
|
779
|
+
this.blocks = this.blocks.filter((entry) => (
|
|
780
|
+
entry.blocker !== blocker || entry.blocked !== blocked
|
|
781
|
+
));
|
|
782
|
+
if (value) this.blocks.push({ blocker, blocked });
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** A direct space's other person, for `member`. */
|
|
786
|
+
peer(space, member) {
|
|
787
|
+
return space.kind === "direct" ? space.members.find((candidate) => candidate !== member) ?? null : null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** The platform's refusal of any activity in a direct space while either
|
|
791
|
+
* person has blocked the other. */
|
|
792
|
+
ensureConversationActive(spaceId, member) {
|
|
793
|
+
const space = this.find(spaceId);
|
|
794
|
+
if (space?.kind !== "direct") return;
|
|
795
|
+
const peer = this.peer(space, member);
|
|
796
|
+
if (peer && this.isBlocked(member, peer)) {
|
|
797
|
+
throw runtimeError(
|
|
798
|
+
"forbidden",
|
|
799
|
+
"direct conversation activity is disabled while either person has blocked the other",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
pendingInvitations(spaceId) {
|
|
805
|
+
const now = Date.now();
|
|
806
|
+
return this.invitations
|
|
807
|
+
.filter((invitation) => invitation.spaceId === spaceId
|
|
808
|
+
&& invitation.status === "pending"
|
|
809
|
+
&& Date.parse(invitation.expiresAt) > now)
|
|
810
|
+
.sort((left, right) => compareStrings(left.createdAt, right.createdAt));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** The invitation for `invitee`: a pending one already there, or a new one. */
|
|
814
|
+
invite(spaceId, inviter, invitee, role = "editor") {
|
|
815
|
+
const existing = this.pendingInvitations(spaceId).find((invitation) => invitation.invitee === invitee);
|
|
816
|
+
if (existing) return { invitation: existing, created: false };
|
|
817
|
+
const createdAt = new Date();
|
|
818
|
+
const invitation = {
|
|
819
|
+
id: crypto.randomUUID(),
|
|
820
|
+
spaceId,
|
|
821
|
+
inviter,
|
|
822
|
+
invitee,
|
|
823
|
+
role,
|
|
824
|
+
status: "pending",
|
|
825
|
+
createdAt: createdAt.toISOString(),
|
|
826
|
+
expiresAt: new Date(createdAt.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
827
|
+
resolvedAt: null,
|
|
828
|
+
};
|
|
829
|
+
this.invitations.push(invitation);
|
|
830
|
+
return { invitation, created: true };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
resolveInvitation(invitationId, invitee, status) {
|
|
834
|
+
const invitation = this.invitations.find((candidate) => candidate.id === invitationId);
|
|
835
|
+
if (!invitation || invitation.invitee !== invitee || invitation.status !== "pending") {
|
|
836
|
+
throw runtimeError("not_found", "pending invitation not found");
|
|
837
|
+
}
|
|
838
|
+
if (Date.parse(invitation.expiresAt) <= Date.now()) {
|
|
839
|
+
throw runtimeError("not_found", "space invitation is invalid or expired");
|
|
840
|
+
}
|
|
841
|
+
const space = this.find(invitation.spaceId);
|
|
842
|
+
if (!space) throw runtimeError("not_found", "space not found");
|
|
843
|
+
invitation.status = status;
|
|
844
|
+
invitation.resolvedAt = new Date().toISOString();
|
|
845
|
+
if (status === "accepted" && !space.members.includes(invitee)) {
|
|
846
|
+
space.members.push(invitee);
|
|
847
|
+
space.roles[invitee] = invitation.role;
|
|
848
|
+
space.joined[invitee] = invitation.resolvedAt;
|
|
849
|
+
space.updatedAt = invitation.resolvedAt;
|
|
850
|
+
}
|
|
851
|
+
return { invitation, space };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Forget a space and its invitations (a space with children is never
|
|
855
|
+
* deleted, so nothing is left pointing at it). */
|
|
856
|
+
removeSpace(spaceId) {
|
|
857
|
+
this.spaces = this.spaces.filter((space) => space.id !== spaceId);
|
|
858
|
+
this.invitations = this.invitations.filter((invitation) => invitation.spaceId !== spaceId);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** A member's own presence topic: a personal install has no space to park
|
|
863
|
+
* presence on, so it lives here — the harness's spelling of the platform's
|
|
864
|
+
* `(user_id, app_id)` topic. Never on the wire. */
|
|
865
|
+
const accountTopic = (member) => `account:${member}`;
|
|
866
|
+
|
|
867
|
+
/** A stream's first message, as the platform opens one: how long its client
|
|
868
|
+
* waits to reconnect after a drop (the SSE `retry:` field) — two seconds
|
|
869
|
+
* plus up to six more, drawn per stream, so a restart that drops every
|
|
870
|
+
* window is not answered by every window at once — then stream_open. */
|
|
871
|
+
function streamOpening() {
|
|
872
|
+
const retryMs = 2000 + Math.floor(Math.random() * 6001);
|
|
873
|
+
return `retry: ${retryMs}\ndata: ${JSON.stringify({ type: "stream_open" })}\n\n`;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* The live transport: one SSE stream per open window, each a member's
|
|
878
|
+
* account stream. It carries frames and nothing else — no sequence, no
|
|
879
|
+
* epoch, no ring to replay from — so a (re)connect starts at stream_open and
|
|
880
|
+
* recovers state through the JSON doors, as on the platform.
|
|
881
|
+
*/
|
|
882
|
+
class DevEventBus {
|
|
883
|
+
constructor(appId) {
|
|
884
|
+
this.appId = appId;
|
|
885
|
+
// { member, res, rooms: Set | null, corner? } — a `corner` stream is the
|
|
886
|
+
// harness's notification corner: account frames only, and not presence.
|
|
887
|
+
this.streams = new Set();
|
|
888
|
+
this.presence = new Map();
|
|
889
|
+
this.beats = new Map();
|
|
890
|
+
this.looks = new Map();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** The common envelope (`space_id` null on account and personal frames). */
|
|
894
|
+
envelope(type, spaceId, from, event, transient) {
|
|
895
|
+
return {
|
|
896
|
+
type,
|
|
897
|
+
transient: Boolean(transient),
|
|
898
|
+
space_id: spaceId ?? null,
|
|
899
|
+
app_id: this.appId,
|
|
900
|
+
from,
|
|
901
|
+
event,
|
|
902
|
+
published_at: new Date().toISOString(),
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** One frame down one stream; its `rooms` filter narrows room_event frames,
|
|
907
|
+
* and a corner stream hears the account and nothing else. */
|
|
908
|
+
send(stream, frame) {
|
|
909
|
+
if (stream.corner && frame.type !== "account_event") return;
|
|
910
|
+
const event = frame.event;
|
|
911
|
+
if (stream.rooms && event?.type === "room_event" && typeof event.room_id === "string"
|
|
912
|
+
&& !stream.rooms.has(event.room_id)) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
stream.res.write(`data: ${JSON.stringify(frame)}\n\n`);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** A frame to every stream of `members`, except `except`'s. */
|
|
919
|
+
deliver(members, frame, { except = null } = {}) {
|
|
920
|
+
const wanted = new Set(members);
|
|
921
|
+
for (const stream of this.streams) {
|
|
922
|
+
if (wanted.has(stream.member) && stream.member !== except) this.send(stream, frame);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Whether `member` has an app open on a live stream — online, as presence
|
|
927
|
+
* counts it. The notification corner is the desk's, not the app's. */
|
|
928
|
+
hasStream(member) {
|
|
929
|
+
for (const stream of this.streams) if (stream.member === member && !stream.corner) return true;
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** Set (or with null clear) `member`'s presence state on `topic`. Every
|
|
934
|
+
* state written is proof of life; an unchanged re-assert — the SDK's
|
|
935
|
+
* heartbeat — changes nothing and is silent. A clear is no beat: it is the
|
|
936
|
+
* member leaving the topic, their state and their presence on it gone.
|
|
937
|
+
* Answers whether the state changed — for a clear, whether there was one. */
|
|
938
|
+
setPresence(topic, member, state) {
|
|
939
|
+
if (!this.presence.has(topic)) this.presence.set(topic, new Map());
|
|
940
|
+
const states = this.presence.get(topic);
|
|
941
|
+
if (state === null || state === undefined) {
|
|
942
|
+
this.beats.get(topic)?.delete(member);
|
|
943
|
+
return states.delete(member);
|
|
944
|
+
}
|
|
945
|
+
this.beat(topic, member);
|
|
946
|
+
if (JSON.stringify(states.get(member)) === JSON.stringify(state)) return false;
|
|
947
|
+
states.set(member, state);
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
presenceStates(topic) {
|
|
952
|
+
return this.presence.get(topic) ?? new Map();
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Every presence write keeps a member on a topic for PRESENCE_TTL_MS. */
|
|
956
|
+
beat(topic, member) {
|
|
957
|
+
if (!this.beats.has(topic)) this.beats.set(topic, new Map());
|
|
958
|
+
this.beats.get(topic).set(member, Date.now());
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
presentOn(topic, member, now = Date.now()) {
|
|
962
|
+
const at = this.beats.get(topic)?.get(member);
|
|
963
|
+
return at !== undefined && now - at <= PRESENCE_TTL_MS;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** One window's word on whether it is looking at `topic`. */
|
|
967
|
+
look(topic, member, window, looking) {
|
|
968
|
+
if (!this.looks.has(topic)) this.looks.set(topic, new Map());
|
|
969
|
+
this.looks.get(topic).set(`${member}\n${window}`, { at: Date.now(), looking });
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Looking, as the platform decides who a message need not ring: any of the
|
|
973
|
+
* member's windows says so within the lifetime — or, when none of their
|
|
974
|
+
* windows says anything, any unexpired presence. */
|
|
975
|
+
lookingOn(topic, member, now = Date.now()) {
|
|
976
|
+
let spoke = false;
|
|
977
|
+
for (const [key, look] of this.looks.get(topic) ?? []) {
|
|
978
|
+
if (!key.startsWith(`${member}\n`) || now - look.at > PRESENCE_TTL_MS) continue;
|
|
979
|
+
if (look.looking) return true;
|
|
980
|
+
spoke = true;
|
|
981
|
+
}
|
|
982
|
+
return !spoke && this.presentOn(topic, member, now);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** The member's personal-install state, which a space query composes in
|
|
986
|
+
* for anyone who never set a space-scoped one. */
|
|
987
|
+
memberPresenceState(member) {
|
|
988
|
+
return this.presence.get(accountTopic(member))?.get(member);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
forgetTopic(topic) {
|
|
992
|
+
this.presence.delete(topic);
|
|
993
|
+
this.beats.delete(topic);
|
|
994
|
+
this.looks.delete(topic);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Drop `member`'s presence — state and liveness — from each topic. */
|
|
998
|
+
forgetMember(topics, member) {
|
|
999
|
+
for (const topic of topics) {
|
|
1000
|
+
this.presence.get(topic)?.delete(member);
|
|
1001
|
+
this.beats.get(topic)?.delete(member);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function isJsonContentType(value) {
|
|
1007
|
+
const essence = String(value ?? "").split(";")[0].trim().toLowerCase();
|
|
1008
|
+
const [type, subtype] = essence.split("/");
|
|
1009
|
+
return type === "application" && Boolean(subtype) && (subtype === "json" || subtype.endsWith("+json"));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** A request body, at most `limit` bytes (413 past it). The body is read to
|
|
1013
|
+
* its end either way, so the refusal reaches the client whole. */
|
|
1014
|
+
async function readBodyBuffer(request, limit) {
|
|
1015
|
+
const chunks = [];
|
|
1016
|
+
let size = 0;
|
|
1017
|
+
for await (const chunk of request) {
|
|
1018
|
+
size += chunk.length;
|
|
1019
|
+
if (size <= limit) chunks.push(chunk);
|
|
1020
|
+
}
|
|
1021
|
+
if (size > limit) {
|
|
1022
|
+
throw runtimeError("payload_too_large", `the request body is larger than ${limit} bytes`);
|
|
1023
|
+
}
|
|
1024
|
+
return Buffer.concat(chunks);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** A JSON door's body: application/json, within `limit`, parsed. */
|
|
1028
|
+
async function readJsonBody(request, limit = LIMITS.default_body_bytes) {
|
|
1029
|
+
if (!isJsonContentType(request.headers["content-type"])) {
|
|
1030
|
+
// Drain it, so the refusal is heard.
|
|
1031
|
+
await readBodyBuffer(request, limit).catch(() => undefined);
|
|
1032
|
+
throw runtimeError("bad_request", "Expected request with `Content-Type: application/json`");
|
|
1033
|
+
}
|
|
1034
|
+
const raw = (await readBodyBuffer(request, limit)).toString("utf8");
|
|
1035
|
+
if (!raw.trim()) throw runtimeError("bad_request", "the request body is empty; send a JSON value");
|
|
1036
|
+
try {
|
|
1037
|
+
return JSON.parse(raw);
|
|
1038
|
+
} catch {
|
|
1039
|
+
throw runtimeError("bad_request", "request body is not valid JSON");
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function sendJson(response, status, value, headers = {}) {
|
|
1044
|
+
response.writeHead(status, { "content-type": "application/json", ...headers });
|
|
1045
|
+
response.end(JSON.stringify(value ?? null));
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function isPlainObject(value) {
|
|
1049
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function crc32(bytes) {
|
|
1053
|
+
let crc = 0xffffffff;
|
|
1054
|
+
for (const byte of bytes) {
|
|
1055
|
+
crc ^= byte;
|
|
1056
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
1057
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function pngChunk(type, data = Buffer.alloc(0)) {
|
|
1064
|
+
const kind = Buffer.from(type, "ascii");
|
|
1065
|
+
const length = Buffer.alloc(4);
|
|
1066
|
+
length.writeUInt32BE(data.length);
|
|
1067
|
+
const checksum = Buffer.alloc(4);
|
|
1068
|
+
checksum.writeUInt32BE(crc32(Buffer.concat([kind, data])));
|
|
1069
|
+
return Buffer.concat([length, kind, data, checksum]);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Small dependency-free platform-style fallback avatar. The color is stable
|
|
1073
|
+
* per handle, so local identities remain visually distinct across restarts. */
|
|
1074
|
+
function generatedDevAvatar(handle) {
|
|
1075
|
+
const width = 64;
|
|
1076
|
+
const height = 64;
|
|
1077
|
+
const digest = sha256(handle, "buffer");
|
|
1078
|
+
const background = DEFAULT_DEV_AVATAR_COLORS[digest[0] % DEFAULT_DEV_AVATAR_COLORS.length];
|
|
1079
|
+
const stride = 1 + width * 4;
|
|
1080
|
+
const pixels = Buffer.alloc(stride * height);
|
|
1081
|
+
for (let y = 0; y < height; y += 1) {
|
|
1082
|
+
const row = y * stride;
|
|
1083
|
+
pixels[row] = 0;
|
|
1084
|
+
for (let x = 0; x < width; x += 1) {
|
|
1085
|
+
const head = (x - 32) ** 2 + (y - 22) ** 2 <= 11 ** 2;
|
|
1086
|
+
const body = ((x - 32) / 24) ** 2 + ((y - 59) / 24) ** 2 <= 1;
|
|
1087
|
+
const color = head || body ? [247, 247, 243] : background;
|
|
1088
|
+
const offset = row + 1 + x * 4;
|
|
1089
|
+
pixels[offset] = color[0];
|
|
1090
|
+
pixels[offset + 1] = color[1];
|
|
1091
|
+
pixels[offset + 2] = color[2];
|
|
1092
|
+
pixels[offset + 3] = 255;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const header = Buffer.alloc(13);
|
|
1096
|
+
header.writeUInt32BE(width, 0);
|
|
1097
|
+
header.writeUInt32BE(height, 4);
|
|
1098
|
+
header.set([8, 6, 0, 0, 0], 8);
|
|
1099
|
+
return Buffer.concat([
|
|
1100
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
1101
|
+
pngChunk("IHDR", header),
|
|
1102
|
+
pngChunk("IDAT", deflateSync(pixels)),
|
|
1103
|
+
pngChunk("IEND"),
|
|
1104
|
+
]);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const defaultDevProfiles = new Map();
|
|
1108
|
+
|
|
1109
|
+
function defaultDevProfile(handle) {
|
|
1110
|
+
const cached = defaultDevProfiles.get(handle);
|
|
1111
|
+
if (cached) return cached;
|
|
1112
|
+
const catalogName = DEFAULT_DEV_MEMBERS.find((name) => name.toLowerCase() === handle);
|
|
1113
|
+
const name = catalogName ?? handle
|
|
1114
|
+
.split(/[._~-]+/)
|
|
1115
|
+
.filter(Boolean)
|
|
1116
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
1117
|
+
.join(" ");
|
|
1118
|
+
const bytes = generatedDevAvatar(handle);
|
|
1119
|
+
const profile = {
|
|
1120
|
+
handle,
|
|
1121
|
+
name,
|
|
1122
|
+
bio: "Local Terminus test account.",
|
|
1123
|
+
avatar: {
|
|
1124
|
+
bytes,
|
|
1125
|
+
contentType: "image/png",
|
|
1126
|
+
version: sha256(bytes).slice(0, 8),
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
1129
|
+
defaultDevProfiles.set(handle, profile);
|
|
1130
|
+
return profile;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function requestedDevMembers(raw, profiles) {
|
|
1134
|
+
if (raw == null) return profiles.size ? [...profiles.keys()] : [DEFAULT_DEV_MEMBER];
|
|
1135
|
+
if (Array.isArray(raw)) return raw;
|
|
1136
|
+
const value = String(raw).trim();
|
|
1137
|
+
if (/^\d+$/.test(value)) {
|
|
1138
|
+
const count = Number(value);
|
|
1139
|
+
if (!Number.isSafeInteger(count) || count < 1 || count > DEFAULT_DEV_MEMBERS.length) {
|
|
1140
|
+
throw new CliError(`--members must be between 1 and ${DEFAULT_DEV_MEMBERS.length}`);
|
|
1141
|
+
}
|
|
1142
|
+
return DEFAULT_DEV_MEMBERS.slice(0, count).map((name) => name.toLowerCase());
|
|
1143
|
+
}
|
|
1144
|
+
return value.split(",").map((member) => member.trim()).filter(Boolean);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function sniffDevAvatar(bytes) {
|
|
1148
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
1149
|
+
return "image/jpeg";
|
|
1150
|
+
}
|
|
1151
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(
|
|
1152
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
1153
|
+
)) {
|
|
1154
|
+
return "image/png";
|
|
1155
|
+
}
|
|
1156
|
+
if (bytes.length >= 12 && bytes.subarray(0, 4).toString() === "RIFF"
|
|
1157
|
+
&& bytes.subarray(8, 12).toString() === "WEBP") {
|
|
1158
|
+
return "image/webp";
|
|
1159
|
+
}
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function escapeHtml(value) {
|
|
1164
|
+
return String(value)
|
|
1165
|
+
.replaceAll("&", "&")
|
|
1166
|
+
.replaceAll("<", "<")
|
|
1167
|
+
.replaceAll(">", ">")
|
|
1168
|
+
.replaceAll('"', """)
|
|
1169
|
+
.replaceAll("'", "'");
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function normalizeDevHandle(raw) {
|
|
1173
|
+
const handle = String(raw ?? "").trim().replace(/^@/, "");
|
|
1174
|
+
if (!DEV_HANDLE_PATTERN.test(handle)) {
|
|
1175
|
+
throw new CliError(`invalid local member handle '${handle}'`);
|
|
1176
|
+
}
|
|
1177
|
+
return handle;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function normalizeDevProfileText(raw, field, max, { multiline = false } = {}) {
|
|
1181
|
+
if (typeof raw !== "string") throw new CliError(`${field} must be a string`);
|
|
1182
|
+
const value = String(raw ?? "").replaceAll("\r\n", "\n").replaceAll("\r", "\n").trim();
|
|
1183
|
+
if ((!multiline && !value) || [...value].length > max) {
|
|
1184
|
+
throw new CliError(`${field} must be ${multiline ? `at most ${max}` : `1-${max}`} characters`);
|
|
1185
|
+
}
|
|
1186
|
+
if ([...value].some((character) => {
|
|
1187
|
+
return /\p{Cc}/u.test(character) && (character !== "\n" || !multiline);
|
|
1188
|
+
})) {
|
|
1189
|
+
throw new CliError(`${field} contains unsupported control characters`);
|
|
1190
|
+
}
|
|
1191
|
+
return value;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function loadDevProfiles(profileFile) {
|
|
1195
|
+
const profiles = new Map();
|
|
1196
|
+
if (!profileFile) return profiles;
|
|
1197
|
+
const absolute = path.resolve(profileFile);
|
|
1198
|
+
let document;
|
|
1199
|
+
try {
|
|
1200
|
+
document = JSON.parse(await readFile(absolute, "utf8"));
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
throw new CliError(`could not read --profiles ${absolute}: ${error.message}`);
|
|
1203
|
+
}
|
|
1204
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
1205
|
+
throw new CliError("--profiles must be a JSON object keyed by local member handle");
|
|
1206
|
+
}
|
|
1207
|
+
for (const [rawHandle, rawProfile] of Object.entries(document)) {
|
|
1208
|
+
const handle = normalizeDevHandle(rawHandle);
|
|
1209
|
+
if (!rawProfile || typeof rawProfile !== "object" || Array.isArray(rawProfile)) {
|
|
1210
|
+
throw new CliError(`profile for @${handle} must be an object`);
|
|
1211
|
+
}
|
|
1212
|
+
const unknown = Object.keys(rawProfile).find(
|
|
1213
|
+
(field) => !["name", "bio", "avatar"].includes(field),
|
|
1214
|
+
);
|
|
1215
|
+
if (unknown) throw new CliError(`unknown profile field '${unknown}' for @${handle}`);
|
|
1216
|
+
const name = normalizeDevProfileText(rawProfile.name ?? handle, `name for @${handle}`, 80);
|
|
1217
|
+
const bio = normalizeDevProfileText(
|
|
1218
|
+
rawProfile.bio ?? "",
|
|
1219
|
+
`bio for @${handle}`,
|
|
1220
|
+
280,
|
|
1221
|
+
{ multiline: true },
|
|
1222
|
+
);
|
|
1223
|
+
let avatar = null;
|
|
1224
|
+
if (rawProfile.avatar != null) {
|
|
1225
|
+
if (typeof rawProfile.avatar !== "string" || !rawProfile.avatar.trim()) {
|
|
1226
|
+
throw new CliError(`avatar for @${handle} must be a file path`);
|
|
1227
|
+
}
|
|
1228
|
+
const avatarPath = path.resolve(path.dirname(absolute), String(rawProfile.avatar));
|
|
1229
|
+
const bytes = await readFile(avatarPath).catch((error) => {
|
|
1230
|
+
throw new CliError(`could not read avatar for @${handle}: ${error.message}`);
|
|
1231
|
+
});
|
|
1232
|
+
if (!bytes.length || bytes.length > MAX_DEV_AVATAR_BYTES) {
|
|
1233
|
+
throw new CliError(`avatar for @${handle} must be 1 byte-${MAX_DEV_AVATAR_BYTES} bytes`);
|
|
1234
|
+
}
|
|
1235
|
+
const contentType = sniffDevAvatar(bytes);
|
|
1236
|
+
if (!contentType) {
|
|
1237
|
+
throw new CliError(`avatar for @${handle} must be a JPEG, PNG, or WebP image`);
|
|
1238
|
+
}
|
|
1239
|
+
avatar = { bytes, contentType, version: sha256(bytes).slice(0, 8) };
|
|
1240
|
+
}
|
|
1241
|
+
profiles.set(handle, { handle, name, bio, avatar });
|
|
1242
|
+
}
|
|
1243
|
+
return profiles;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function parseCollectionCursor(raw) {
|
|
1247
|
+
if (raw === null || raw === undefined || raw === "") return 0;
|
|
1248
|
+
const match = /^c1_(\d+)$/.exec(String(raw));
|
|
1249
|
+
if (!match) throw new CliError("invalid collection cursor");
|
|
1250
|
+
return Number(match[1]);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function collectionCursor(value) {
|
|
1254
|
+
return `c1_${value}`;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function parseCollaborationCursor(raw) {
|
|
1258
|
+
if (raw === null || raw === undefined || raw === "") return 0;
|
|
1259
|
+
const match = /^o1_(\d+)$/.exec(String(raw));
|
|
1260
|
+
if (!match) throw new CliError("invalid collaboration cursor");
|
|
1261
|
+
return Number(match[1]);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function collaborationCursor(value) {
|
|
1265
|
+
return `o1_${value}`;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Start the harness servers: one HTTP port per member, all sharing the same
|
|
1270
|
+
* fixture store and event bus. Returns member→port plus a close handle so
|
|
1271
|
+
* tests can drive the real protocol.
|
|
1272
|
+
*/
|
|
1273
|
+
/** Open an app package for the harness: runtime index only — no source
|
|
1274
|
+
* snapshot (dev materials feed the local runtime, not a ledger) and no file
|
|
1275
|
+
* content (the bundle is served from disk). */
|
|
1276
|
+
export function readDevPackage(dir) {
|
|
1277
|
+
return readAppPackage(dir, { allowMissingUi: true, includeSource: false, includeContent: false });
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** Fail before the harness creates fixture state, contacts the production
|
|
1281
|
+
* runtime, or binds a port when the configured browser bundle is absent. */
|
|
1282
|
+
export function requireBuiltDevBundle(pkg) {
|
|
1283
|
+
if (pkg.hasUi) return;
|
|
1284
|
+
if (pkg.manifest.kind !== "app") {
|
|
1285
|
+
throw new CliError("terminus dev serves browser-app bundles; this package has no browser interface");
|
|
1286
|
+
}
|
|
1287
|
+
throw new CliError(
|
|
1288
|
+
`No built bundle in ${pkg.uiDirectory}/. Run \`npm run build\` before \`terminus dev\`.`,
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/** Static ui/ files, live from disk. Extensionless misses fall back to
|
|
1293
|
+
* index.html. Every HTML document gets the app host's route script, as the
|
|
1294
|
+
* host serves it; `transformHtml` then adds what only the local harness mounts
|
|
1295
|
+
* (its request control) — `dev --remote` passes none. */
|
|
1296
|
+
export async function serveUiAsset(
|
|
1297
|
+
pkg,
|
|
1298
|
+
uiDir,
|
|
1299
|
+
url,
|
|
1300
|
+
request,
|
|
1301
|
+
response,
|
|
1302
|
+
{ transformHtml } = {},
|
|
1303
|
+
) {
|
|
1304
|
+
let relative = url.pathname === "/" ? "index.html" : safeRelativePath(url.pathname);
|
|
1305
|
+
let file = relative ? path.join(uiDir, relative) : null;
|
|
1306
|
+
let exists = file
|
|
1307
|
+
? await stat(file).then((entry) => entry.isFile()).catch(() => false)
|
|
1308
|
+
: false;
|
|
1309
|
+
const acceptsHtml = String(request.headers.accept ?? "").includes("text/html");
|
|
1310
|
+
if (!exists && acceptsHtml && relative && !relative.includes(".")) {
|
|
1311
|
+
relative = "index.html";
|
|
1312
|
+
file = path.join(uiDir, relative);
|
|
1313
|
+
exists = await stat(file).then((entry) => entry.isFile()).catch(() => false);
|
|
1314
|
+
}
|
|
1315
|
+
if (!file || !exists) {
|
|
1316
|
+
// Defensive guidance if a rebuild removes index.html while a running
|
|
1317
|
+
// harness is serving files live from disk.
|
|
1318
|
+
const indexExists = await stat(path.join(uiDir, "index.html"))
|
|
1319
|
+
.then((entry) => entry.isFile())
|
|
1320
|
+
.catch(() => false);
|
|
1321
|
+
if (!indexExists && acceptsHtml) {
|
|
1322
|
+
response.writeHead(503, { "content-type": "text/html; charset=utf-8" });
|
|
1323
|
+
return response.end(
|
|
1324
|
+
`<!doctype html><meta charset="utf-8"><title>terminus dev</title>`
|
|
1325
|
+
+ `<body style="font: 15px/1.6 system-ui; max-width: 34rem; margin: 4rem auto; padding: 0 1rem">`
|
|
1326
|
+
+ `<h1 style="font-size:1.2rem">No built bundle yet</h1>`
|
|
1327
|
+
+ `<p><code>${pkg.manifest.slug}</code> has nothing in <code>${pkg.uiDirectory}/</code>. Either:</p>`
|
|
1328
|
+
+ `<ul><li>run the app's build (<code>terminus build</code> or <code>npm run build</code>) and refresh — `
|
|
1329
|
+
+ `the harness serves it live from disk, or</li>`
|
|
1330
|
+
+ `<li>develop with hot reload: <code>npm run dev</code> and open the Vite port `
|
|
1331
|
+
+ `(the scaffold proxies <code>/_terminus</code> here).</li></ul>`
|
|
1332
|
+
+ `<p>The runtime protocol at <code>/_terminus/*</code> is already live.</p>`,
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
response.writeHead(404, { "content-type": "text/plain" });
|
|
1336
|
+
return response.end("not found");
|
|
1337
|
+
}
|
|
1338
|
+
const type = CONTENT_TYPES.get(path.extname(file).toLowerCase()) ?? "application/octet-stream";
|
|
1339
|
+
const bytes = await readFile(file);
|
|
1340
|
+
let body = bytes;
|
|
1341
|
+
if (type.startsWith("text/html")) {
|
|
1342
|
+
const html = injectAppRouteScript(bytes.toString("utf8"));
|
|
1343
|
+
body = Buffer.from(transformHtml ? transformHtml(html) : html);
|
|
1344
|
+
}
|
|
1345
|
+
response.writeHead(200, { "content-type": type });
|
|
1346
|
+
response.end(body);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
export async function startDevServer(dir, options = {}) {
|
|
1350
|
+
const pkg = options.pkg ?? await readDevPackage(dir);
|
|
1351
|
+
requireBuiltDevBundle(pkg);
|
|
1352
|
+
const profiles = await loadDevProfiles(options.profiles);
|
|
1353
|
+
const members = requestedDevMembers(options.members, profiles).map(normalizeDevHandle);
|
|
1354
|
+
if (!members.length) throw new CliError("--members needs at least one handle");
|
|
1355
|
+
if (new Set(members).size !== members.length) throw new CliError("--members contains duplicates");
|
|
1356
|
+
const unavailableProfile = [...profiles.keys()].find((handle) => !members.includes(handle));
|
|
1357
|
+
if (unavailableProfile) {
|
|
1358
|
+
throw new CliError(`profile @${unavailableProfile} is not included in --members`);
|
|
1359
|
+
}
|
|
1360
|
+
const guest = Boolean(options.guest);
|
|
1361
|
+
// Who each port is. The identity IS the port: a member's port is always
|
|
1362
|
+
// that member (signed out after /_terminus/logout, until the app is opened
|
|
1363
|
+
// again), and the guest's port — the last one, after the members' — is
|
|
1364
|
+
// nobody until someone signs in on it, and nobody again once they sign out.
|
|
1365
|
+
const seats = [
|
|
1366
|
+
...members.map((member) => ({ member, guest: false, signedOut: false, port: null })),
|
|
1367
|
+
...(guest ? [{ member: null, guest: true, signedOut: false, port: null }] : []),
|
|
1368
|
+
];
|
|
1369
|
+
const guestSeat = seats.find((seat) => seat.guest) ?? null;
|
|
1370
|
+
const basePort = resolveDevPortRange(options.port, seats.length);
|
|
1371
|
+
await assertDevPortRangeAvailable({
|
|
1372
|
+
basePort,
|
|
1373
|
+
commandArgs: options.commandArgs,
|
|
1374
|
+
count: seats.length,
|
|
1375
|
+
directory: dir,
|
|
1376
|
+
guest,
|
|
1377
|
+
members,
|
|
1378
|
+
viteProxy: true,
|
|
1379
|
+
});
|
|
1380
|
+
const appId = pkg.id ?? `@local/${pkg.manifest.slug}`;
|
|
1381
|
+
/** The app's name in a bootstrap: the last part of its address. */
|
|
1382
|
+
const appSlug = appId.split("/").at(-1);
|
|
1383
|
+
/** What a guest's bootstrap says: the contract's own, filled in the way the
|
|
1384
|
+
* app host fills it from the platform's resolve answer for this app. */
|
|
1385
|
+
const guestBootstrap = guest
|
|
1386
|
+
? fillGuestBootstrap(APP_HOST.guest.bootstrap, { app_id: DEV_APP_ID, slug: appSlug, name: appSlug })
|
|
1387
|
+
: null;
|
|
1388
|
+
const rootDir = path.join(dir, DEV_DIRECTORY);
|
|
1389
|
+
if (options.fresh) await rm(rootDir, { recursive: true, force: true });
|
|
1390
|
+
await ensureDevDirectory(dir);
|
|
1391
|
+
const systemStore = new DevSystemStore(rootDir, appId);
|
|
1392
|
+
const registry = new DevSpaceRegistry(systemStore);
|
|
1393
|
+
registry.load();
|
|
1394
|
+
const bus = new DevEventBus(DEV_APP_ID);
|
|
1395
|
+
const dataPlane = new DevDataPlane({ rootDir, db: systemStore.db, appId });
|
|
1396
|
+
const uiDir = path.join(dir, pkg.uiDirectory);
|
|
1397
|
+
const notifications = [];
|
|
1398
|
+
// Per member: until when they have paused this app's notifications, as
|
|
1399
|
+
// production keeps it on their installation (`notifications_paused_until`).
|
|
1400
|
+
// In memory, like the notifications themselves.
|
|
1401
|
+
const notificationPauses = new Map();
|
|
1402
|
+
// Per member and space: muted (true) or left to ring (false); no entry
|
|
1403
|
+
// follows the member's default, and `defaultMutes` holds who mutes by
|
|
1404
|
+
// default. Production keeps these on the membership and the installation.
|
|
1405
|
+
const spaceMutes = new Map();
|
|
1406
|
+
const defaultMutes = new Set();
|
|
1407
|
+
// One release: every member runs this folder, so each collection has one
|
|
1408
|
+
// definition here, the same for all of them, and a name is never redefined
|
|
1409
|
+
// (a differing definition is refused). The platform pins definitions per
|
|
1410
|
+
// installation release and so also checks a write to a space collection
|
|
1411
|
+
// against the published release's definition when the writer is on an
|
|
1412
|
+
// older one — one set of rules for data the members share. Locally the
|
|
1413
|
+
// writer's release IS the published one, so that second check would run
|
|
1414
|
+
// the same rules twice; there is nothing to build.
|
|
1415
|
+
const collectionDefinitions = new Map(
|
|
1416
|
+
systemStore.collectionDefinitions().map(([name, definition]) => [
|
|
1417
|
+
name,
|
|
1418
|
+
normalizeCollectionDefinition(name, definition, { strict: false }),
|
|
1419
|
+
]),
|
|
1420
|
+
);
|
|
1421
|
+
for (const definition of pkg.manifest.resources?.collections ?? []) {
|
|
1422
|
+
const normalized = normalizeCollectionDefinition(definition.name, definition, { strict: false });
|
|
1423
|
+
const persisted = normalizeCollectionDefinition(
|
|
1424
|
+
definition.name,
|
|
1425
|
+
systemStore.defineCollection(definition.name, normalized).existing,
|
|
1426
|
+
{ strict: false },
|
|
1427
|
+
);
|
|
1428
|
+
if (JSON.stringify(persisted) !== JSON.stringify(normalized)) {
|
|
1429
|
+
throw runtimeError("conflict", `collection '${definition.name}' was already defined differently for this release`);
|
|
1430
|
+
}
|
|
1431
|
+
collectionDefinitions.set(definition.name, normalized);
|
|
1432
|
+
}
|
|
1433
|
+
const capsules = new Map(
|
|
1434
|
+
members.map((member) => [member, new DevCapsuleStore(rootDir, appId, member)]),
|
|
1435
|
+
);
|
|
1436
|
+
// The platform compacts collaboration journals every 30 s: the operations a
|
|
1437
|
+
// snapshot covers leave the journal, and a reader behind them resets to the
|
|
1438
|
+
// snapshot. Here too, so an app's reset path runs before it is published.
|
|
1439
|
+
const compactor = setInterval(() => {
|
|
1440
|
+
try {
|
|
1441
|
+
systemStore.compactCollaboration();
|
|
1442
|
+
} catch {
|
|
1443
|
+
// The next tick tries again, as the platform's worker does.
|
|
1444
|
+
}
|
|
1445
|
+
}, options.collaborationCompactionMs ?? 30_000);
|
|
1446
|
+
compactor.unref();
|
|
1447
|
+
const automationJobs = new Map();
|
|
1448
|
+
// A queued job starts after this long (the platform's worker picks jobs up
|
|
1449
|
+
// a moment later; tests that cancel one keep it queued longer).
|
|
1450
|
+
const jobStartMs = options.jobStartMs ?? 0;
|
|
1451
|
+
const jobTimers = new Set();
|
|
1452
|
+
const scheduleOverrides = new Map();
|
|
1453
|
+
// The first answer of each optional Idempotency-Key (events, notifications,
|
|
1454
|
+
// collect, jobs): a replay repeats no effect and answers it again.
|
|
1455
|
+
const replays = new Map();
|
|
1456
|
+
// Per-minute and per-day budgets the platform enforces.
|
|
1457
|
+
const budgets = new Map();
|
|
1458
|
+
const collectSubmissions = new Map();
|
|
1459
|
+
// The app's server/ code runs here the way the platform runs it: the sync
|
|
1460
|
+
// door, `server.run` steps, records in this data space, and syscalls onto
|
|
1461
|
+
// the member's own files.
|
|
1462
|
+
const serverTier = createDevServerTier({
|
|
1463
|
+
dir,
|
|
1464
|
+
manifest: pkg.manifest,
|
|
1465
|
+
store: systemStore,
|
|
1466
|
+
capsule: { read: serverCapsuleRead, write: serverCapsuleWrite },
|
|
1467
|
+
collect: serverCollect,
|
|
1468
|
+
// The public web its fetches reach is the net doors' (tests stand one in).
|
|
1469
|
+
fetchText: (url) => fetchPublicText(url, {
|
|
1470
|
+
fetchImpl: options.netFetch ?? null,
|
|
1471
|
+
...(options.netTimeoutMs ? { timeoutMs: options.netTimeoutMs } : {}),
|
|
1472
|
+
}),
|
|
1473
|
+
...(options.serverLog ? { log: options.serverLog } : {}),
|
|
1474
|
+
...(options.serverRelay ? { relay: options.serverRelay } : {}),
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
function capsule(member) {
|
|
1478
|
+
const store = capsules.get(member);
|
|
1479
|
+
if (!store) throw new CliError(`no local capsule for @${member}`);
|
|
1480
|
+
return store;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function profile(member) {
|
|
1484
|
+
const fallback = defaultDevProfile(member);
|
|
1485
|
+
const configured = profiles.get(member);
|
|
1486
|
+
if (!configured) return fallback;
|
|
1487
|
+
return {
|
|
1488
|
+
...fallback,
|
|
1489
|
+
...configured,
|
|
1490
|
+
avatar: configured.avatar ?? fallback.avatar,
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/** PublicUser: `{id, handle, display_name, avatar_url, public_profile_url}`. */
|
|
1495
|
+
function publicIdentity(member) {
|
|
1496
|
+
const fixture = profile(member);
|
|
1497
|
+
return {
|
|
1498
|
+
id: member,
|
|
1499
|
+
handle: member,
|
|
1500
|
+
display_name: fixture.name,
|
|
1501
|
+
avatar_url: fixture.avatar
|
|
1502
|
+
? `/_terminus/users/${encodeURIComponent(member)}/avatar?v=${fixture.avatar.version}`
|
|
1503
|
+
: null,
|
|
1504
|
+
public_profile_url: `/_terminus/users/${encodeURIComponent(member)}/profile`,
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function publicInvitation(invitation) {
|
|
1509
|
+
return {
|
|
1510
|
+
id: invitation.id,
|
|
1511
|
+
space_id: invitation.spaceId,
|
|
1512
|
+
invitee: publicIdentity(invitation.invitee),
|
|
1513
|
+
role: invitation.role,
|
|
1514
|
+
status: invitation.status,
|
|
1515
|
+
created_at: invitation.createdAt,
|
|
1516
|
+
expires_at: invitation.expiresAt,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function invitationReceipt(invitation, created) {
|
|
1521
|
+
return {
|
|
1522
|
+
id: invitation.id,
|
|
1523
|
+
user_id: invitation.invitee,
|
|
1524
|
+
handle: invitation.invitee,
|
|
1525
|
+
created,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** A handle the way the platform reads one from a request: trimmed, one
|
|
1530
|
+
* leading "@" dropped, lowercased. */
|
|
1531
|
+
function handleInput(raw) {
|
|
1532
|
+
if (typeof raw !== "string") throw runtimeError("bad_request", "handles are strings");
|
|
1533
|
+
const handle = raw.trim().replace(/^@/u, "").toLowerCase();
|
|
1534
|
+
if (!handle || handle.length > 64) throw runtimeError("bad_request", "invalid handle");
|
|
1535
|
+
return handle;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** A person in this local directory, or 404 as the platform answers it. */
|
|
1539
|
+
function knownPerson(raw) {
|
|
1540
|
+
const handle = handleInput(raw);
|
|
1541
|
+
if (!members.includes(handle)) throw runtimeError("not_found", `no user with handle @${handle}`);
|
|
1542
|
+
return handle;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/* ── the live transport ──────────────────────────────────────────────── */
|
|
1546
|
+
|
|
1547
|
+
const memberOrigin = (member, role) => ({ kind: "member", user_id: member, ...(role ? { role } : {}) });
|
|
1548
|
+
const PLATFORM = { kind: "platform" };
|
|
1549
|
+
|
|
1550
|
+
/* A platform change signal reaches every member who can read the change,
|
|
1551
|
+
* the writer's own windows included — and nobody else (D3). */
|
|
1552
|
+
|
|
1553
|
+
/** A change to a space's own records, objects or roster: every member
|
|
1554
|
+
* hears it. */
|
|
1555
|
+
function signalSpace(spaceId, from, event, { transient = false } = {}) {
|
|
1556
|
+
const space = registry.find(spaceId);
|
|
1557
|
+
if (!space) return;
|
|
1558
|
+
bus.deliver(space.members, bus.envelope("space_event", spaceId, from, event, transient));
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/** A change to `member`'s own data: their windows alone. Their collection
|
|
1562
|
+
* is theirs even opened inside a space, which the frame still names (their
|
|
1563
|
+
* window there reads it) — nobody else reads that change feed, and a frame
|
|
1564
|
+
* of it on someone else's stream could pass for one of theirs. */
|
|
1565
|
+
function signalOwner(member, spaceId, from, event, { transient = false } = {}) {
|
|
1566
|
+
bus.deliver([member], bus.envelope("space_event", spaceId, from, event, transient));
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** A write to a resource a call names the owner of — a bucket, a
|
|
1570
|
+
* collaboration document: the space's own when the write named a space,
|
|
1571
|
+
* else the writer's own. */
|
|
1572
|
+
function signalWrite(member, spaceId, event, options = {}) {
|
|
1573
|
+
if (spaceId) signalSpace(spaceId, memberOrigin(member), event, options);
|
|
1574
|
+
else signalOwner(member, null, memberOrigin(member), event, options);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/** An account frame to `member`: space_id null, the space named inside. */
|
|
1578
|
+
function accountEvent(member, event) {
|
|
1579
|
+
bus.deliver([member], bus.envelope("account_event", null, PLATFORM, event, false));
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/** A presence frame about `member`: online with the state they set (none
|
|
1583
|
+
* for a window that just opened), or — a cleared state, their last window
|
|
1584
|
+
* closing — `online: false`, which is a leave. */
|
|
1585
|
+
function presenceFrame(spaceId, member, state) {
|
|
1586
|
+
return bus.envelope("presence", spaceId, memberOrigin(member), (
|
|
1587
|
+
state === null ? { online: false } : { online: true, ...(state === undefined ? {} : { state }) }
|
|
1588
|
+
), true);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/** Everyone `member` shares a space with — never `member`. */
|
|
1592
|
+
function coMembers(member) {
|
|
1593
|
+
const peers = new Set();
|
|
1594
|
+
for (const space of registry.spaces) {
|
|
1595
|
+
if (!space.members.includes(member)) continue;
|
|
1596
|
+
for (const peer of space.members) if (peer !== member) peers.add(peer);
|
|
1597
|
+
}
|
|
1598
|
+
return [...peers];
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/** `member`'s last live window closed: every co-member hears `online:
|
|
1602
|
+
* false` in each space they share (the frame names it, so that space's
|
|
1603
|
+
* rooms apply it) and once with no space, for personal-install rooms; and
|
|
1604
|
+
* the member's presence leaves those topics, so the presence query agrees.
|
|
1605
|
+
* Never their own streams (D3). */
|
|
1606
|
+
function announcePresenceGone(member) {
|
|
1607
|
+
const spaces = registry.spaces.filter((space) => space.members.includes(member));
|
|
1608
|
+
bus.forgetMember([...spaces.map((space) => space.id), accountTopic(member)], member);
|
|
1609
|
+
for (const space of spaces) {
|
|
1610
|
+
bus.deliver(space.members.filter((peer) => peer !== member), presenceFrame(space.id, member, null));
|
|
1611
|
+
}
|
|
1612
|
+
bus.deliver(coMembers(member), presenceFrame(null, member, null));
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/* ── spaces ─────────────────────────────────────────────────────────── */
|
|
1616
|
+
|
|
1617
|
+
/** The space, when `member` is in it; the platform's 404 otherwise. */
|
|
1618
|
+
function requireSpace(member, spaceId) {
|
|
1619
|
+
const space = registry.find(spaceId);
|
|
1620
|
+
if (!space || !space.members.includes(member)) throw runtimeError("not_found", "app space not found");
|
|
1621
|
+
return space;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/** A viewer reads a space and writes nothing in it — the platform's rule,
|
|
1625
|
+
* so a member moved to Can view is turned away here as they will be there. */
|
|
1626
|
+
function requireSpaceWriter(member, spaceId) {
|
|
1627
|
+
if (spaceId && registry.memberRole(requireSpace(member, spaceId).id, member) === "viewer") {
|
|
1628
|
+
throw runtimeError("forbidden", "space write access is required");
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function requireGroup(space) {
|
|
1633
|
+
if (space.kind !== "group") {
|
|
1634
|
+
throw runtimeError("bad_request", "direct conversations do not support group management");
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function requireManager(space, member) {
|
|
1639
|
+
if (!registry.canManage(space.id, member)) throw runtimeError("forbidden", "space admin access is required");
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/** Whether `spaceId`'s messages ring `member` with nothing: their own choice
|
|
1643
|
+
* for it, else their default. A mention rings them either way. */
|
|
1644
|
+
function mutedFor(member, spaceId) {
|
|
1645
|
+
return spaceMutes.get(`${member}\n${spaceId}`) ?? defaultMutes.has(member);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/** Space: the caller's view of one space. `peer` names who a direct space
|
|
1649
|
+
* is with — its other member, or, until they answer, the person invited;
|
|
1650
|
+
* null for a group, or once nobody else is in it or invited — so an app
|
|
1651
|
+
* labels a conversation without reading its roster. Blocks are between the
|
|
1652
|
+
* two members; a viewer reads a space and sends nothing into it. */
|
|
1653
|
+
function spaceView(space, member) {
|
|
1654
|
+
const role = registry.memberRole(space.id, member);
|
|
1655
|
+
const direct = space.kind === "direct";
|
|
1656
|
+
const manager = role === "owner" || role === "admin";
|
|
1657
|
+
const writer = manager || role === "editor";
|
|
1658
|
+
const peer = registry.peer(space, member);
|
|
1659
|
+
const asked = direct && !peer ? (registry.pendingInvitations(space.id)[0]?.invitee ?? null) : null;
|
|
1660
|
+
const blockedByMe = Boolean(peer && registry.blockedBy(member, peer));
|
|
1661
|
+
const blockedByPeer = Boolean(peer && registry.blockedBy(peer, member));
|
|
1662
|
+
return {
|
|
1663
|
+
id: space.id,
|
|
1664
|
+
name: space.name,
|
|
1665
|
+
kind: space.kind,
|
|
1666
|
+
peer: (peer ?? asked) ? publicIdentity(peer ?? asked) : null,
|
|
1667
|
+
parent_id: space.parentId ?? null,
|
|
1668
|
+
meta: space.meta ?? null,
|
|
1669
|
+
my_role: role,
|
|
1670
|
+
blocked_by_me: blockedByMe,
|
|
1671
|
+
blocked_by_peer: blockedByPeer,
|
|
1672
|
+
muted: mutedFor(member, space.id),
|
|
1673
|
+
capabilities: {
|
|
1674
|
+
can_send: writer && !(blockedByMe || blockedByPeer),
|
|
1675
|
+
can_invite: !direct && manager,
|
|
1676
|
+
can_remove_members: !direct && manager,
|
|
1677
|
+
can_manage_roles: !direct && manager,
|
|
1678
|
+
can_update: direct || manager,
|
|
1679
|
+
can_leave: !direct && role !== "owner",
|
|
1680
|
+
can_block_peer: direct && Boolean(peer),
|
|
1681
|
+
can_delete: role === "owner",
|
|
1682
|
+
},
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
const ROLE_ORDER = { owner: 0, admin: 1, editor: 2, viewer: 3 };
|
|
1687
|
+
|
|
1688
|
+
/** SpaceMember rows: the owner, then admins, editors and viewers, each by handle. */
|
|
1689
|
+
function memberRows(space) {
|
|
1690
|
+
return space.members
|
|
1691
|
+
.map((handle) => ({
|
|
1692
|
+
...publicIdentity(handle),
|
|
1693
|
+
role: registry.memberRole(space.id, handle),
|
|
1694
|
+
joined_at: space.joined?.[handle] ?? space.createdAt,
|
|
1695
|
+
}))
|
|
1696
|
+
.sort((left, right) => (ROLE_ORDER[left.role] - ROLE_ORDER[right.role])
|
|
1697
|
+
|| compareStrings(left.handle, right.handle));
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function spaceName(raw) {
|
|
1701
|
+
const name = typeof raw === "string" ? raw.trim() : null;
|
|
1702
|
+
if (!name || [...name].length > LIMITS.space_name_chars) {
|
|
1703
|
+
throw runtimeError("bad_request", `space name must be 1-${LIMITS.space_name_chars} characters`);
|
|
1704
|
+
}
|
|
1705
|
+
return name;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/** App-defined meta: a JSON object every member reads, at most the
|
|
1709
|
+
* contract's `space_meta_bytes`. */
|
|
1710
|
+
function spaceMeta(raw) {
|
|
1711
|
+
if (!isPlainObject(raw)) throw runtimeError("bad_request", "meta must be a JSON object or null");
|
|
1712
|
+
if (Buffer.byteLength(JSON.stringify(raw)) > LIMITS.space_meta_bytes) {
|
|
1713
|
+
throw runtimeError("payload_too_large", `space meta is at most ${LIMITS.space_meta_bytes} bytes`);
|
|
1714
|
+
}
|
|
1715
|
+
return structuredClone(raw);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/** How an invitation reads. Being asked to talk to one person and being
|
|
1719
|
+
* added to a room of eleven are different requests, and the difference is
|
|
1720
|
+
* the only thing the recipient needs in order to answer. */
|
|
1721
|
+
function invitationCopy(space, inviter) {
|
|
1722
|
+
const direct = space?.kind === "direct";
|
|
1723
|
+
return {
|
|
1724
|
+
direct,
|
|
1725
|
+
title: direct
|
|
1726
|
+
? `@${inviter} wants to chat with you`
|
|
1727
|
+
: `@${inviter} invited you to ${space?.name ?? "a space"}`,
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function ringInvitation(space, inviter, invitation) {
|
|
1732
|
+
const copy = invitationCopy(space, inviter);
|
|
1733
|
+
notifyMember({
|
|
1734
|
+
id: crypto.randomUUID(),
|
|
1735
|
+
member: invitation.invitee,
|
|
1736
|
+
title: copy.title,
|
|
1737
|
+
body: copy.direct
|
|
1738
|
+
? `Accept to start a conversation in ${pkg.id ?? pkg.manifest.slug}.`
|
|
1739
|
+
: `Review the request before joining ${pkg.id ?? pkg.manifest.slug}.`,
|
|
1740
|
+
route: null,
|
|
1741
|
+
data: { kind: "space.invited", space_id: space.id, invitation_id: invitation.id },
|
|
1742
|
+
read_at: null,
|
|
1743
|
+
created_at: new Date().toISOString(),
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/* ── notifications ──────────────────────────────────────────────────── */
|
|
1748
|
+
|
|
1749
|
+
/** Until when `member` has paused notifications, or null once the pause is
|
|
1750
|
+
* over — it ends on its own, as in production. */
|
|
1751
|
+
function pausedUntil(member) {
|
|
1752
|
+
const until = notificationPauses.get(member);
|
|
1753
|
+
if (until && Date.parse(until) > Date.now()) return until;
|
|
1754
|
+
notificationPauses.delete(member);
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/** AppNotice: the row an app reads. */
|
|
1759
|
+
function appNotice(item) {
|
|
1760
|
+
const kind = { "space.invited": "invite", mention: "mention", message: "message" }[item.data?.kind] ?? "notice";
|
|
1761
|
+
return {
|
|
1762
|
+
id: item.id,
|
|
1763
|
+
kind,
|
|
1764
|
+
title: item.title,
|
|
1765
|
+
body: item.body ?? "",
|
|
1766
|
+
route: item.route ?? null,
|
|
1767
|
+
data: item.data ?? {},
|
|
1768
|
+
space_id: item.data?.space_id ?? null,
|
|
1769
|
+
read_at: item.read_at,
|
|
1770
|
+
created_at: item.created_at,
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/** Ring `notice.member`: a row, and notification.created on their account
|
|
1775
|
+
* stream. A paused member is rung by nothing but an invite, and nothing is
|
|
1776
|
+
* saved up for when the pause ends. One unread message row per member and
|
|
1777
|
+
* space, updated in place. Says whether it rang. */
|
|
1778
|
+
function notifyMember(notice) {
|
|
1779
|
+
if (pausedUntil(notice.member) && notice.data?.kind !== "space.invited") return false;
|
|
1780
|
+
let item = notice;
|
|
1781
|
+
if (notice.data?.kind === "message" && notice.data?.space_id) {
|
|
1782
|
+
const existing = notifications.find((candidate) => candidate.member === notice.member
|
|
1783
|
+
&& candidate.data?.kind === "message"
|
|
1784
|
+
&& candidate.data?.space_id === notice.data.space_id
|
|
1785
|
+
&& !candidate.read_at);
|
|
1786
|
+
if (existing) {
|
|
1787
|
+
Object.assign(existing, {
|
|
1788
|
+
title: notice.title,
|
|
1789
|
+
body: notice.body,
|
|
1790
|
+
route: notice.route,
|
|
1791
|
+
data: notice.data,
|
|
1792
|
+
created_at: notice.created_at,
|
|
1793
|
+
});
|
|
1794
|
+
item = existing;
|
|
1795
|
+
} else {
|
|
1796
|
+
notifications.push(notice);
|
|
1797
|
+
}
|
|
1798
|
+
} else {
|
|
1799
|
+
notifications.push(notice);
|
|
1800
|
+
}
|
|
1801
|
+
accountEvent(item.member, {
|
|
1802
|
+
type: "notification.created",
|
|
1803
|
+
notification_id: item.id,
|
|
1804
|
+
title: item.title,
|
|
1805
|
+
body: item.body ?? "",
|
|
1806
|
+
data: item.data ?? {},
|
|
1807
|
+
});
|
|
1808
|
+
return true;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** Looking at the space right now, as the platform counts it for a bell:
|
|
1812
|
+
* one of the member's windows showing it (visible, with a state), or —
|
|
1813
|
+
* for a client that says nothing about windows — presence on the space's
|
|
1814
|
+
* own topic within its lifetime. An open stream alone is not looking:
|
|
1815
|
+
* every open app holds one stream for all of a member's spaces. */
|
|
1816
|
+
function memberLooking(member, spaceId) {
|
|
1817
|
+
return bus.lookingOn(spaceId, member);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** The app's message row for everyone in `targets` who is not the actor,
|
|
1821
|
+
* not `excluded` (the mentioned: their own row already rang), and not
|
|
1822
|
+
* looking at the space right now — a bell is for people who are not. */
|
|
1823
|
+
function notifyAbsentMembers(space, actor, notify, data, excluded = new Set(), targets = space.members) {
|
|
1824
|
+
for (const target of targets) {
|
|
1825
|
+
if (target === actor || excluded.has(target) || memberLooking(target, space.id)) continue;
|
|
1826
|
+
// A space they muted rings them with nothing but a mention.
|
|
1827
|
+
if (mutedFor(target, space.id)) continue;
|
|
1828
|
+
notifyMember({
|
|
1829
|
+
id: crypto.randomUUID(),
|
|
1830
|
+
member: target,
|
|
1831
|
+
title: notify.title,
|
|
1832
|
+
body: notify.body,
|
|
1833
|
+
route: notify.route,
|
|
1834
|
+
// Who it came from, which the row did not carry and the local
|
|
1835
|
+
// notification corner needs: a card with a face on it is how a person
|
|
1836
|
+
// testing four windows tells four cards apart.
|
|
1837
|
+
data: { ...data, from: publicIdentity(actor) },
|
|
1838
|
+
read_at: null,
|
|
1839
|
+
created_at: new Date().toISOString(),
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** The platform's own row for each person a write names: "@sender mentioned
|
|
1845
|
+
* you in <space>", in the platform's words, one row per mention, and none
|
|
1846
|
+
* for someone who paused the app. */
|
|
1847
|
+
function ringMentions(space, actor, targets, { body, route, data }) {
|
|
1848
|
+
// A direct space is named after the other person from its creator's side
|
|
1849
|
+
// ("in Bob" would read wrong to Bob): it says only who.
|
|
1850
|
+
const title = space.kind === "direct"
|
|
1851
|
+
? `@${actor} mentioned you`
|
|
1852
|
+
: `@${actor} mentioned you in ${space.name}`;
|
|
1853
|
+
for (const target of targets) {
|
|
1854
|
+
const rang = notifyMember({
|
|
1855
|
+
id: crypto.randomUUID(),
|
|
1856
|
+
member: target,
|
|
1857
|
+
title,
|
|
1858
|
+
body,
|
|
1859
|
+
route,
|
|
1860
|
+
data: { kind: "mention", ...data },
|
|
1861
|
+
read_at: null,
|
|
1862
|
+
created_at: new Date().toISOString(),
|
|
1863
|
+
});
|
|
1864
|
+
if (rang) console.log(`[notify @${target}] ${title}: ${body}`);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/** The rings a transaction or a delivery commits with, once, on its first
|
|
1869
|
+
* commit — a replay never gets here. */
|
|
1870
|
+
function ringSpaceWrite(space, actor, candidates, { notify, mentions }, data) {
|
|
1871
|
+
const mentioned = mentionTargets(candidates, mentions, actor);
|
|
1872
|
+
ringMentions(space, actor, mentioned, {
|
|
1873
|
+
body: firstChars(notify?.body ?? "", MENTION_PREVIEW_CHARS),
|
|
1874
|
+
route: notify?.route ?? null,
|
|
1875
|
+
data,
|
|
1876
|
+
});
|
|
1877
|
+
if (notify && notify.audience !== "mentioned") {
|
|
1878
|
+
notifyAbsentMembers(
|
|
1879
|
+
space,
|
|
1880
|
+
actor,
|
|
1881
|
+
notify,
|
|
1882
|
+
{ kind: "message", ...data },
|
|
1883
|
+
new Set(mentioned),
|
|
1884
|
+
candidates,
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/* ── budgets and replays ─────────────────────────────────────────────── */
|
|
1890
|
+
|
|
1891
|
+
/** Count one use of `key` against `limit` per `windowMs`; false when spent. */
|
|
1892
|
+
function spend(key, limit, windowMs) {
|
|
1893
|
+
const now = Date.now();
|
|
1894
|
+
const recent = (budgets.get(key) ?? []).filter((at) => now - at < windowMs);
|
|
1895
|
+
if (recent.length >= limit) {
|
|
1896
|
+
budgets.set(key, recent);
|
|
1897
|
+
return false;
|
|
1898
|
+
}
|
|
1899
|
+
recent.push(now);
|
|
1900
|
+
budgets.set(key, recent);
|
|
1901
|
+
return true;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/** Run `task` once per optional Idempotency-Key: the same key and request
|
|
1905
|
+
* answers the first answer again and repeats no effect; the same key naming
|
|
1906
|
+
* another request is 409 idempotency_conflict. A door checks only its
|
|
1907
|
+
* request's own shape before calling this and every other check inside
|
|
1908
|
+
* `task`, so a replay is recognized before any of them — permission,
|
|
1909
|
+
* budget, space — as the contract says it is. */
|
|
1910
|
+
async function once(door, member, key, request, task) {
|
|
1911
|
+
if (!key) return task();
|
|
1912
|
+
const slot = `${door}\n${member}\n${key}`;
|
|
1913
|
+
const fingerprint = sha256(JSON.stringify(request ?? null));
|
|
1914
|
+
const prior = replays.get(slot);
|
|
1915
|
+
if (prior) {
|
|
1916
|
+
if (prior.fingerprint !== fingerprint) {
|
|
1917
|
+
throw runtimeError("idempotency_conflict", "Idempotency-Key was already used for a different request");
|
|
1918
|
+
}
|
|
1919
|
+
if (prior.pending) {
|
|
1920
|
+
throw runtimeError("still_pending", "a request with this Idempotency-Key is still in flight");
|
|
1921
|
+
}
|
|
1922
|
+
return structuredClone(prior.answer);
|
|
1923
|
+
}
|
|
1924
|
+
replays.set(slot, { fingerprint, pending: true });
|
|
1925
|
+
try {
|
|
1926
|
+
const answer = await task();
|
|
1927
|
+
replays.set(slot, { fingerprint, answer: structuredClone(answer) });
|
|
1928
|
+
return answer;
|
|
1929
|
+
} catch (error) {
|
|
1930
|
+
replays.delete(slot);
|
|
1931
|
+
throw error;
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/** Optional cross-boundary authority stays release-pinned in production;
|
|
1936
|
+
* the harness refuses it identically. */
|
|
1937
|
+
function requireCapability(group, capability) {
|
|
1938
|
+
const declared = pkg.manifest.capabilities?.[group];
|
|
1939
|
+
if (!Array.isArray(declared) || !declared.includes(capability)) {
|
|
1940
|
+
throw runtimeError(
|
|
1941
|
+
"forbidden",
|
|
1942
|
+
`this release did not declare ${group}:${capability} access`
|
|
1943
|
+
+ " — use the corresponding Terminus SDK call so terminus validate can compile the release grant",
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
/* ── collections ────────────────────────────────────────────────────── */
|
|
1949
|
+
|
|
1950
|
+
function collectionScopeKey(declaration, spaceId, name) {
|
|
1951
|
+
if (declaration.ownership === "space") return `space:${spaceId}:${name}`;
|
|
1952
|
+
return spaceId ? `member:${spaceId}:${name}` : `personal:${name}`;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
function visibleCollectionChange(declaration, actor, rawChange) {
|
|
1956
|
+
const policyRecord = rawChange.policy_record === null
|
|
1957
|
+
? null
|
|
1958
|
+
: {
|
|
1959
|
+
value: rawChange.policy_record,
|
|
1960
|
+
created_by_user_id: rawChange.policy_created_by_user_id,
|
|
1961
|
+
updated_by_user_id: rawChange.policy_updated_by_user_id,
|
|
1962
|
+
};
|
|
1963
|
+
try {
|
|
1964
|
+
authorizeCollection(declaration, "read", actor, policyRecord);
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
if (error?.status === 403) return null;
|
|
1967
|
+
throw error;
|
|
1968
|
+
}
|
|
1969
|
+
const committed = { ...rawChange, cursor: collectionCursor(rawChange.cursor) };
|
|
1970
|
+
delete committed.policy_record;
|
|
1971
|
+
delete committed.policy_created_by_user_id;
|
|
1972
|
+
delete committed.policy_updated_by_user_id;
|
|
1973
|
+
if (committed.value && typeof committed.value === "object" && !Array.isArray(committed.value)) {
|
|
1974
|
+
committed.value = redactCollectionRecord(
|
|
1975
|
+
declaration,
|
|
1976
|
+
actor,
|
|
1977
|
+
{ value: committed.value },
|
|
1978
|
+
).value;
|
|
1979
|
+
}
|
|
1980
|
+
return committed;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/** The `record_changed` wake, to who can read the change: a space-owned
|
|
1984
|
+
* collection's members, or a personal collection's owner alone — inside a
|
|
1985
|
+
* space too. The harness's frames are thin — no value and no prev_cursor,
|
|
1986
|
+
* so a live projection always drains the change feed — but they carry the
|
|
1987
|
+
* platform's clock as the hosted ones do: a put says when its record was
|
|
1988
|
+
* first written and when this change committed, a delete when it
|
|
1989
|
+
* committed. */
|
|
1990
|
+
function publishCollectionWake(member, spaceId, { declaration, operation, collection, recordId }, rawChange) {
|
|
1991
|
+
const event = {
|
|
1992
|
+
type: "record_changed",
|
|
1993
|
+
operation,
|
|
1994
|
+
collection,
|
|
1995
|
+
...(recordId === null ? {} : { record_id: recordId }),
|
|
1996
|
+
mutation_id: rawChange.mutation_id,
|
|
1997
|
+
cursor: collectionCursor(rawChange.cursor),
|
|
1998
|
+
...(operation === "put" ? { created_at: rawChange.created_at } : {}),
|
|
1999
|
+
...(operation === "put" || operation === "delete"
|
|
2000
|
+
? { committed_at: rawChange.committed_at }
|
|
2001
|
+
: {}),
|
|
2002
|
+
};
|
|
2003
|
+
if (declaration.ownership === "space") signalSpace(spaceId, memberOrigin(member), event);
|
|
2004
|
+
else signalOwner(member, spaceId, memberOrigin(member), event);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function collectionDefinition(name) {
|
|
2008
|
+
const definition = collectionDefinitions.get(name);
|
|
2009
|
+
if (!definition) throw runtimeError("forbidden", `release did not declare collections:${name}`);
|
|
2010
|
+
return definition;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function requireCollectionScope(declaration, member, requestedSpaceId) {
|
|
2014
|
+
const spaceId = requestedSpaceId || null;
|
|
2015
|
+
if (declaration.ownership === "space" && !spaceId) {
|
|
2016
|
+
throw runtimeError("bad_request", "space-owned collections require space_id");
|
|
2017
|
+
}
|
|
2018
|
+
if (spaceId) requireSpace(member, spaceId);
|
|
2019
|
+
return spaceId;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
function managedStoreMember(declaration, member, spaceId) {
|
|
2023
|
+
if (declaration.ownership !== "space") return member;
|
|
2024
|
+
return registry.find(spaceId).members[0];
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
function managedPolicyActor(declaration, member, spaceId, principal = "user") {
|
|
2028
|
+
return {
|
|
2029
|
+
userId: member,
|
|
2030
|
+
principal,
|
|
2031
|
+
capsuleOwner: managedStoreMember(declaration, member, spaceId),
|
|
2032
|
+
spaceRole: spaceId ? registry.memberRole(spaceId, member) : null,
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/** A store refusal, in the contract's code for it. */
|
|
2037
|
+
function storeConflict(result) {
|
|
2038
|
+
return runtimeError(result.code ?? "conflict", result.conflict);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/** Run a capsule transaction, answering a mid-transaction refusal in its code. */
|
|
2042
|
+
function transact(store, input) {
|
|
2043
|
+
try {
|
|
2044
|
+
return store.transactRecords(input);
|
|
2045
|
+
} catch (error) {
|
|
2046
|
+
if (error?.conflictCode) throw runtimeError(error.conflictCode, error.message);
|
|
2047
|
+
throw error;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function validateLocalDeclaredConstraints(store, member, spaceId, inputOperations) {
|
|
2052
|
+
const baseOwnership = inputOperations[0].declaration.ownership;
|
|
2053
|
+
const compatible = [...collectionDefinitions.entries()].filter(([, declaration]) => (
|
|
2054
|
+
spaceId === null
|
|
2055
|
+
? declaration.ownership !== "space"
|
|
2056
|
+
: (declaration.ownership === "space") === (baseOwnership === "space")
|
|
2057
|
+
));
|
|
2058
|
+
const states = new Map(compatible.map(([name, declaration]) => [
|
|
2059
|
+
name,
|
|
2060
|
+
{
|
|
2061
|
+
declaration,
|
|
2062
|
+
scopeKey: collectionScopeKey(declaration, spaceId, name),
|
|
2063
|
+
records: new Map(
|
|
2064
|
+
store.listRecords(collectionScopeKey(declaration, spaceId, name), name)
|
|
2065
|
+
.map((record) => [record.id, record]),
|
|
2066
|
+
),
|
|
2067
|
+
},
|
|
2068
|
+
]));
|
|
2069
|
+
const operations = inputOperations.map((operation) => ({ ...operation }));
|
|
2070
|
+
for (const operation of operations.filter((candidate) => candidate.operation === "put")) {
|
|
2071
|
+
const state = states.get(operation.collection);
|
|
2072
|
+
const prior = state.records.get(operation.recordId);
|
|
2073
|
+
state.records.set(operation.recordId, {
|
|
2074
|
+
id: operation.recordId,
|
|
2075
|
+
value: operation.value,
|
|
2076
|
+
created_by_user_id: prior?.created_by_user_id ?? member,
|
|
2077
|
+
updated_by_user_id: member,
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
const deleting = new Set(
|
|
2081
|
+
operations.filter((operation) => operation.operation === "delete")
|
|
2082
|
+
.map((operation) => `${operation.collection}\0${operation.recordId}`),
|
|
2083
|
+
);
|
|
2084
|
+
let expanded = true;
|
|
2085
|
+
while (expanded) {
|
|
2086
|
+
expanded = false;
|
|
2087
|
+
for (const [sourceCollection, state] of states) {
|
|
2088
|
+
for (const source of state.records.values()) {
|
|
2089
|
+
const sourceAddress = `${sourceCollection}\0${source.id}`;
|
|
2090
|
+
if (deleting.has(sourceAddress)) continue;
|
|
2091
|
+
for (const [relationName, relation] of Object.entries(state.declaration.relations)) {
|
|
2092
|
+
const raw = source.value[relation.field ?? relationName];
|
|
2093
|
+
const targetIds = relation.cardinality === "many" ? raw : [raw];
|
|
2094
|
+
if (!Array.isArray(targetIds)) continue;
|
|
2095
|
+
if (!targetIds.some((targetId) => deleting.has(`${relation.collection}\0${targetId}`))) {
|
|
2096
|
+
continue;
|
|
2097
|
+
}
|
|
2098
|
+
if ((relation.onDelete ?? "restrict") !== "cascade") {
|
|
2099
|
+
throw runtimeError(
|
|
2100
|
+
"conflict",
|
|
2101
|
+
`cannot delete relation target: '${relationName}' from ${sourceCollection}/${source.id} restricts deletion`,
|
|
2102
|
+
);
|
|
2103
|
+
}
|
|
2104
|
+
deleting.add(sourceAddress);
|
|
2105
|
+
operations.push({
|
|
2106
|
+
scopeKey: state.scopeKey,
|
|
2107
|
+
collection: sourceCollection,
|
|
2108
|
+
recordId: source.id,
|
|
2109
|
+
operation: "delete",
|
|
2110
|
+
expectedVersion: null,
|
|
2111
|
+
declaration: state.declaration,
|
|
2112
|
+
cascaded: true,
|
|
2113
|
+
});
|
|
2114
|
+
expanded = true;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
for (const address of deleting) {
|
|
2120
|
+
const [collection, recordId] = address.split("\0");
|
|
2121
|
+
states.get(collection)?.records.delete(recordId);
|
|
2122
|
+
}
|
|
2123
|
+
for (const [collection, state] of states) {
|
|
2124
|
+
for (const field of state.declaration.unique) {
|
|
2125
|
+
const seen = new Map();
|
|
2126
|
+
for (const record of state.records.values()) {
|
|
2127
|
+
const value = record.value[field];
|
|
2128
|
+
if (value === undefined || value === null) continue;
|
|
2129
|
+
if (!["string", "number", "boolean"].includes(typeof value)
|
|
2130
|
+
|| Buffer.byteLength(JSON.stringify(value)) > 2048) {
|
|
2131
|
+
throw new CliError(`unique field '${field}' must be a scalar no larger than 2 KiB`);
|
|
2132
|
+
}
|
|
2133
|
+
const key = JSON.stringify(value);
|
|
2134
|
+
if (seen.has(key)) {
|
|
2135
|
+
throw runtimeError("conflict", `unique field '${field}' duplicates ${collection}/${seen.get(key)}`);
|
|
2136
|
+
}
|
|
2137
|
+
seen.set(key, record.id);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
for (const field of state.declaration.indexes) {
|
|
2141
|
+
for (const record of state.records.values()) {
|
|
2142
|
+
const value = record.value[field];
|
|
2143
|
+
if (value === undefined || value === null) continue;
|
|
2144
|
+
if (!["string", "number", "boolean"].includes(typeof value)
|
|
2145
|
+
|| Buffer.byteLength(JSON.stringify(value)) > 2048) {
|
|
2146
|
+
throw new CliError(`indexed field '${field}' must be a scalar no larger than 2 KiB`);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
for (const record of state.records.values()) {
|
|
2151
|
+
for (const [relationName, relation] of Object.entries(state.declaration.relations)) {
|
|
2152
|
+
const raw = record.value[relation.field ?? relationName];
|
|
2153
|
+
if (raw === undefined || raw === null) {
|
|
2154
|
+
if (relation.required) throw new CliError(`required relation '${relationName}' is missing`);
|
|
2155
|
+
continue;
|
|
2156
|
+
}
|
|
2157
|
+
const targetIds = relation.cardinality === "many" ? raw : [raw];
|
|
2158
|
+
if (!Array.isArray(targetIds)
|
|
2159
|
+
|| targetIds.some((targetId) => typeof targetId !== "string")) {
|
|
2160
|
+
throw new CliError(`relation '${relationName}' has invalid record ids`);
|
|
2161
|
+
}
|
|
2162
|
+
const target = states.get(relation.collection);
|
|
2163
|
+
if (!target || targetIds.some((targetId) => !target.records.has(targetId))) {
|
|
2164
|
+
throw runtimeError("conflict", `relation '${relationName}' targets a missing record`);
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
return operations;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/** A record write's precondition: `expected` 0 means the record must not
|
|
2173
|
+
* exist yet, a positive one must be its version. */
|
|
2174
|
+
function requireExpectedVersion(prior, expected, address = null) {
|
|
2175
|
+
const current = prior?.version ?? 0;
|
|
2176
|
+
if ((expected === 0 && prior !== null) || (expected !== null && expected > 0 && expected !== current)) {
|
|
2177
|
+
throw runtimeError(
|
|
2178
|
+
"version_conflict",
|
|
2179
|
+
address
|
|
2180
|
+
? `record '${address}' changed (current ${current}); refresh before retrying`
|
|
2181
|
+
: `record version changed (current ${current}); refresh before retrying`,
|
|
2182
|
+
);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/** The first answer of a record write whose Idempotency-Key already has a
|
|
2187
|
+
* receipt, or null. Recognized before any other check — the version, the
|
|
2188
|
+
* collection's permissions and write rules — as the platform recognizes
|
|
2189
|
+
* it (receipt, then version, then permission), so a replay answers the
|
|
2190
|
+
* first answer even where those checks would now refuse the write. */
|
|
2191
|
+
function replayedMutation(store, scopeKey, collection, mutationId, signature) {
|
|
2192
|
+
const receipt = store.mutationReceipt(scopeKey, collection, mutationId);
|
|
2193
|
+
if (!receipt) return null;
|
|
2194
|
+
if (receipt.requestSha256 !== signature) {
|
|
2195
|
+
throw runtimeError("idempotency_conflict", "Idempotency-Key was already used for a different mutation");
|
|
2196
|
+
}
|
|
2197
|
+
return { ...receipt.response, replayed: true };
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
/** One record put or delete through the capsule, with its constraints and
|
|
2201
|
+
* cascades, its wakes, and the receipt the door answers. */
|
|
2202
|
+
function commitRecordMutation({ member, spaceId, declaration, name, id, operation, expected, value, mutationId, principal = "user" }) {
|
|
2203
|
+
const scopeKey = collectionScopeKey(declaration, spaceId, name);
|
|
2204
|
+
const store = capsule(managedStoreMember(declaration, member, spaceId));
|
|
2205
|
+
const policyActor = managedPolicyActor(declaration, member, spaceId, principal);
|
|
2206
|
+
if (operation === "put") validateManagedRecord(declaration, value);
|
|
2207
|
+
const signature = sha256(JSON.stringify({ operation, id, expected, value }));
|
|
2208
|
+
const replay = replayedMutation(store, scopeKey, name, mutationId, signature);
|
|
2209
|
+
if (replay) return replay;
|
|
2210
|
+
const prior = store.getRecord(scopeKey, name, id);
|
|
2211
|
+
requireExpectedVersion(prior, expected);
|
|
2212
|
+
if (operation === "put") {
|
|
2213
|
+
authorizeCollection(declaration, prior ? "update" : "create", policyActor, {
|
|
2214
|
+
value,
|
|
2215
|
+
created_by_user_id: prior?.created_by_user_id ?? member,
|
|
2216
|
+
updated_by_user_id: member,
|
|
2217
|
+
});
|
|
2218
|
+
authorizeChangedFields(declaration, policyActor, prior?.value, value);
|
|
2219
|
+
enforceWriteRules(declaration, member, prior?.value, value);
|
|
2220
|
+
} else if (prior) {
|
|
2221
|
+
authorizeCollection(declaration, "delete", policyActor, prior);
|
|
2222
|
+
}
|
|
2223
|
+
const constrained = validateLocalDeclaredConstraints(store, member, spaceId, [{
|
|
2224
|
+
scopeKey,
|
|
2225
|
+
collection: name,
|
|
2226
|
+
recordId: id,
|
|
2227
|
+
operation,
|
|
2228
|
+
expectedVersion: expected,
|
|
2229
|
+
...(operation === "put" ? { value } : {}),
|
|
2230
|
+
declaration,
|
|
2231
|
+
}]);
|
|
2232
|
+
constrained[0].mutationId = mutationId;
|
|
2233
|
+
const result = constrained.length === 1
|
|
2234
|
+
? store.mutateRecord({ ...constrained[0], requestSha256: signature, actor: member })
|
|
2235
|
+
: transact(store, {
|
|
2236
|
+
transactionScope: `${declaration.ownership === "space" ? "space" : (spaceId ? "member" : "personal")}:${spaceId || member}`,
|
|
2237
|
+
transactionId: `single:${mutationId}`,
|
|
2238
|
+
requestSha256: signature,
|
|
2239
|
+
operations: constrained,
|
|
2240
|
+
actor: member,
|
|
2241
|
+
});
|
|
2242
|
+
if (result.conflict) throw storeConflict(result);
|
|
2243
|
+
for (const [index, rawChange] of result.changes.entries()) {
|
|
2244
|
+
publishCollectionWake(member, spaceId, constrained[index], rawChange);
|
|
2245
|
+
}
|
|
2246
|
+
return constrained.length === 1
|
|
2247
|
+
? result.response
|
|
2248
|
+
: { ...result.response.results[0], cascaded: result.response.cascaded };
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
async function collectionRecords(member, name, spaceId) {
|
|
2252
|
+
const declaration = collectionDefinition(name);
|
|
2253
|
+
requireCollectionScope(declaration, member, spaceId);
|
|
2254
|
+
return capsule(managedStoreMember(declaration, member, spaceId))
|
|
2255
|
+
.listRecords(collectionScopeKey(declaration, spaceId, name), name);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/* ── bootstrap, jobs, server code ───────────────────────────────────── */
|
|
2259
|
+
|
|
2260
|
+
/** The session's own view, trimmed to what is read (D23). */
|
|
2261
|
+
function bootstrap(member) {
|
|
2262
|
+
return {
|
|
2263
|
+
platform_api_version: APP_RUNTIME_API_VERSION,
|
|
2264
|
+
guest: false,
|
|
2265
|
+
app: { id: DEV_APP_ID, slug: appSlug, name: appSlug, icon_url: "/_terminus/icon" },
|
|
2266
|
+
installation: { id: devInstallationId(member), release_id: DEV_RELEASE_ID, release_version: 1 },
|
|
2267
|
+
user: publicIdentity(member),
|
|
2268
|
+
data: { grants: [], resource_grants: [] },
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** An id the platform mints as a UUID (jobs, notifications, collect
|
|
2273
|
+
* submissions — and so does this harness): anything else in the path is a
|
|
2274
|
+
* malformed request, 400, as the platform's path extractor answers it. */
|
|
2275
|
+
function uuidParam(raw, what) {
|
|
2276
|
+
const value = String(raw ?? "");
|
|
2277
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value)) {
|
|
2278
|
+
throw runtimeError("bad_request", `invalid ${what} id`);
|
|
2279
|
+
}
|
|
2280
|
+
return value.toLowerCase();
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function localJob(member, id) {
|
|
2284
|
+
return automationJobs.get(`${member}\0${uuidParam(id, "job")}`) ?? null;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
/** AppJob, as the door answers it. */
|
|
2288
|
+
function jobView(job) {
|
|
2289
|
+
return {
|
|
2290
|
+
id: job.id,
|
|
2291
|
+
automation: job.automation,
|
|
2292
|
+
status: job.status,
|
|
2293
|
+
input: job.input,
|
|
2294
|
+
result: job.result,
|
|
2295
|
+
error: job.error,
|
|
2296
|
+
attempts: job.attempts,
|
|
2297
|
+
created_at: job.created_at,
|
|
2298
|
+
finished_at: job.finished_at,
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/** A job reached a terminal state: its owner's windows hear job.updated. */
|
|
2303
|
+
function finishJob(member, job, status) {
|
|
2304
|
+
job.status = status;
|
|
2305
|
+
job.finished_at = new Date().toISOString();
|
|
2306
|
+
accountEvent(member, { type: "job.updated", job_id: job.id, status });
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
function declaredAutomation(name) {
|
|
2310
|
+
const automation = (pkg.manifest.workloads?.automations ?? [])
|
|
2311
|
+
.find((candidate) => candidate.name === name);
|
|
2312
|
+
if (!automation) throw runtimeError("forbidden", `release did not declare automation:${name}`);
|
|
2313
|
+
return automation;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function mutateAutomationRecord(member, job, index, action, params) {
|
|
2317
|
+
const name = requireManagedName(params.collection, "collection");
|
|
2318
|
+
const id = requireManagedId(params.record_id);
|
|
2319
|
+
const declaration = collectionDefinition(name);
|
|
2320
|
+
const spaceId = params.space_id ?? job.space_id ?? null;
|
|
2321
|
+
requireCollectionScope(declaration, member, spaceId);
|
|
2322
|
+
requireSpaceWriter(member, spaceId);
|
|
2323
|
+
const expected = params.expected_version ?? null;
|
|
2324
|
+
if (expected !== null && (!Number.isSafeInteger(expected) || expected < 0)) {
|
|
2325
|
+
throw new CliError("expected_version must be zero or a positive object version");
|
|
2326
|
+
}
|
|
2327
|
+
return commitRecordMutation({
|
|
2328
|
+
member,
|
|
2329
|
+
spaceId,
|
|
2330
|
+
declaration,
|
|
2331
|
+
name,
|
|
2332
|
+
id,
|
|
2333
|
+
operation: action === "collection.put" ? "put" : "delete",
|
|
2334
|
+
expected,
|
|
2335
|
+
value: action === "collection.put" ? params.value : undefined,
|
|
2336
|
+
mutationId: `automation:${job.id}:${index}`,
|
|
2337
|
+
principal: "automation",
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
/** Server code's capsule syscalls: the member's own private files — the
|
|
2342
|
+
* ones the app's data doors read and write. */
|
|
2343
|
+
async function serverCapsuleRead(member, requested) {
|
|
2344
|
+
if (!safeRelativePath(requested)) throw serverError(400, "bad_request", "invalid path");
|
|
2345
|
+
try {
|
|
2346
|
+
return (await dataPlane.read(dataPlane.root(member), requested)).bytes;
|
|
2347
|
+
} catch (error) {
|
|
2348
|
+
if (error?.apiCode === "not_found") throw serverError(404, "not_found", `not found: ${requested}`);
|
|
2349
|
+
throw serverError(400, "bad_request", error?.message ?? "invalid path");
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
async function serverCapsuleWrite(member, requested, bytes) {
|
|
2354
|
+
if (!safeRelativePath(requested)) throw serverError(400, "bad_request", "invalid path");
|
|
2355
|
+
await dataPlane.write(dataPlane.root(member), requested, Buffer.from(bytes));
|
|
2356
|
+
// Live pages hear it the way the platform tells them: a data object
|
|
2357
|
+
// changed, and the server changed it.
|
|
2358
|
+
signalOwner(member, null, { kind: "server", user_id: member }, {
|
|
2359
|
+
type: "object_changed",
|
|
2360
|
+
plane: "data",
|
|
2361
|
+
zone: "private",
|
|
2362
|
+
path: requested,
|
|
2363
|
+
operation: "put",
|
|
2364
|
+
}, { transient: true });
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
/** A server op's collect submission, bench-shaped like the app's own door. */
|
|
2368
|
+
function serverCollect(member, channel, payload) {
|
|
2369
|
+
const channels = pkg.manifest.capabilities?.collect?.channels ?? {};
|
|
2370
|
+
const declared = Object.hasOwn(channels, channel) ? channels[channel] : null;
|
|
2371
|
+
if (!declared || declared.kind !== "documents") {
|
|
2372
|
+
throw serverError(403, "forbidden", `no declared documents collect channel '${channel}'`);
|
|
2373
|
+
}
|
|
2374
|
+
const id = crypto.randomUUID();
|
|
2375
|
+
console.log(`[collect:${channel}] submission ${id} from @${member}'s server code:`, JSON.stringify(payload));
|
|
2376
|
+
return { delivered: true, id };
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
async function executeLocalAutomation(member, job) {
|
|
2380
|
+
const automation = declaredAutomation(job.automation);
|
|
2381
|
+
const context = {
|
|
2382
|
+
job_id: job.id,
|
|
2383
|
+
app_id: DEV_APP_ID,
|
|
2384
|
+
installation_id: devInstallationId(member),
|
|
2385
|
+
release_id: DEV_RELEASE_ID,
|
|
2386
|
+
user_id: member,
|
|
2387
|
+
space_id: job.space_id,
|
|
2388
|
+
scheduled_for: null,
|
|
2389
|
+
attempt: 1,
|
|
2390
|
+
};
|
|
2391
|
+
const results = [];
|
|
2392
|
+
for (const [index, step] of automation.steps.entries()) {
|
|
2393
|
+
const params = resolveTemplate(step.params, job.input, context, results);
|
|
2394
|
+
let result;
|
|
2395
|
+
if (step.action === "collection.put" || step.action === "collection.delete") {
|
|
2396
|
+
result = mutateAutomationRecord(member, job, index, step.action, params);
|
|
2397
|
+
} else if (step.action === "notification.create") {
|
|
2398
|
+
if (pkg.manifest.shell?.notifications !== true) {
|
|
2399
|
+
throw runtimeError("forbidden", "release did not declare shell.notifications");
|
|
2400
|
+
}
|
|
2401
|
+
const title = String(params.title ?? "").trim();
|
|
2402
|
+
const body = String(params.body ?? "");
|
|
2403
|
+
if (!title || [...title].length > LIMITS.notification_title_chars
|
|
2404
|
+
|| [...body].length > LIMITS.notification_body_chars) {
|
|
2405
|
+
throw new CliError("invalid notification");
|
|
2406
|
+
}
|
|
2407
|
+
const item = {
|
|
2408
|
+
id: crypto.randomUUID(),
|
|
2409
|
+
member,
|
|
2410
|
+
title,
|
|
2411
|
+
body,
|
|
2412
|
+
route: params.route ?? null,
|
|
2413
|
+
data: params.data ?? {},
|
|
2414
|
+
read_at: null,
|
|
2415
|
+
created_at: new Date().toISOString(),
|
|
2416
|
+
};
|
|
2417
|
+
if (notifyMember(item)) {
|
|
2418
|
+
console.log(`[notification] ${item.title}: ${item.body}`);
|
|
2419
|
+
result = { id: item.id, created: true };
|
|
2420
|
+
} else {
|
|
2421
|
+
result = { created: false, paused: true };
|
|
2422
|
+
}
|
|
2423
|
+
} else if (step.action === "connector.request" || step.action === "service.invoke") {
|
|
2424
|
+
throw runtimeError(
|
|
2425
|
+
"unsupported_in_dev",
|
|
2426
|
+
`${step.action} is not available in terminus dev; mock that broker boundary in app code`,
|
|
2427
|
+
);
|
|
2428
|
+
} else if (step.action === "server.run") {
|
|
2429
|
+
result = await serverTier.runStep(member, params);
|
|
2430
|
+
} else {
|
|
2431
|
+
throw new CliError(`unsupported declarative action '${step.action}'`);
|
|
2432
|
+
}
|
|
2433
|
+
results.push({ action: step.action, result });
|
|
2434
|
+
}
|
|
2435
|
+
return { steps: results };
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/* ── data and storage ───────────────────────────────────────────────── */
|
|
2439
|
+
|
|
2440
|
+
/** The data root a request names: its zone, and with space_id the
|
|
2441
|
+
* caller's own member shard in that space. */
|
|
2442
|
+
function dataRoot(member, query, { write = false } = {}) {
|
|
2443
|
+
const spaceId = query.get("space_id") || null;
|
|
2444
|
+
if (spaceId) {
|
|
2445
|
+
requireSpace(member, spaceId);
|
|
2446
|
+
if (write) requireSpaceWriter(member, spaceId);
|
|
2447
|
+
}
|
|
2448
|
+
return { root: dataPlane.root(member, { zone: query.get("zone") || "private", spaceId }) };
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/** A bucket a request names: the space's with space_id, else the caller's. */
|
|
2452
|
+
function bucketScope(member, bucket, query, { write = false } = {}) {
|
|
2453
|
+
if (!/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/u.test(bucket)) {
|
|
2454
|
+
throw runtimeError("bad_request", "invalid bucket name");
|
|
2455
|
+
}
|
|
2456
|
+
const spaceId = query.get("space_id") || null;
|
|
2457
|
+
if (spaceId) {
|
|
2458
|
+
const space = requireSpace(member, spaceId);
|
|
2459
|
+
if (write) requireSpaceWriter(member, spaceId);
|
|
2460
|
+
// A space's objects live in its first member's capsule, beside that
|
|
2461
|
+
// member's own and other spaces' — keyed apart, as the platform keeps them.
|
|
2462
|
+
return { store: capsule(space.members[0]), key: `spaces/${spaceId}/${bucket}`, spaceId };
|
|
2463
|
+
}
|
|
2464
|
+
return { store: capsule(member), key: bucket, spaceId: null };
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function objectPath(raw) {
|
|
2468
|
+
const relative = safeRelativePath(raw);
|
|
2469
|
+
if (!relative) throw runtimeError("bad_request", "invalid bucket object path");
|
|
2470
|
+
return relative;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
/** A delete's precondition names what it deletes: `missing` asks for
|
|
2474
|
+
* absence, which a delete cannot act on (a file's or an object's alike). */
|
|
2475
|
+
function refuseAbsentDelete(query) {
|
|
2476
|
+
if (query.get("expected_sha256") === "missing") {
|
|
2477
|
+
throw runtimeError("bad_request", "expected_sha256=missing asks for absence, and a delete needs something to delete");
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
/** A bucket write's compare-and-set, as a file write's: `expected_sha256`
|
|
2482
|
+
* of `missing` means the object must not exist yet, any other value must
|
|
2483
|
+
* be its current sha256 — so members sharing a space's bucket never write
|
|
2484
|
+
* over each other's files unseen. */
|
|
2485
|
+
function requireObjectVersion(store, key, relative, expected) {
|
|
2486
|
+
if (expected === null || expected === undefined) return;
|
|
2487
|
+
const actual = store.objectEntry(key, relative)?.sha256 ?? "missing";
|
|
2488
|
+
if (actual !== expected) {
|
|
2489
|
+
throw runtimeError("version_conflict", `'${relative}' changed: expected ${expected}, found ${actual}`);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
/* ── the doors ──────────────────────────────────────────────────────── */
|
|
2494
|
+
|
|
2495
|
+
/** A JSON door's body: within its limit, an object, no unknown field. */
|
|
2496
|
+
async function jsonBody(ctx) {
|
|
2497
|
+
const body = await readJsonBody(ctx.request, ctx.door.body_limit_bytes ?? LIMITS.default_body_bytes);
|
|
2498
|
+
if (!ctx.door.request?.$body) {
|
|
2499
|
+
if (!isPlainObject(body)) throw runtimeError("bad_request", "the JSON body must be an object");
|
|
2500
|
+
refuseUnknownFields(ctx.door, body);
|
|
2501
|
+
}
|
|
2502
|
+
return body;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
/** A bytes door's body, within its limit. */
|
|
2506
|
+
function rawBody(ctx) {
|
|
2507
|
+
return readBodyBuffer(ctx.request, ctx.door.body_limit_bytes ?? LIMITS.default_body_bytes);
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function optionalInteger(raw, field) {
|
|
2511
|
+
if (raw === null || raw === undefined || raw === "") return null;
|
|
2512
|
+
if (!/^-?[0-9]+$/u.test(String(raw))) throw runtimeError("bad_request", `${field} must be an integer`);
|
|
2513
|
+
return Number(raw);
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
const HANDLERS = {
|
|
2517
|
+
// A guest's is the contract's guest bootstrap, as the app host answers it.
|
|
2518
|
+
"session.bootstrap": ({ member, response }) => sendJson(
|
|
2519
|
+
response,
|
|
2520
|
+
200,
|
|
2521
|
+
member === null ? guestBootstrap : bootstrap(member),
|
|
2522
|
+
),
|
|
2523
|
+
|
|
2524
|
+
"users.lookup": ({ response, query }) => sendJson(response, 200, publicIdentity(knownPerson(query.get("handle") ?? ""))),
|
|
2525
|
+
|
|
2526
|
+
"users.search": ({ member, response, query }) => {
|
|
2527
|
+
const needle = String(query.get("q") ?? "").trim().replace(/^@/u, "").toLowerCase();
|
|
2528
|
+
const users = needle.length < 2 || needle.length > 64
|
|
2529
|
+
? []
|
|
2530
|
+
: members
|
|
2531
|
+
.filter((handle) => handle !== member
|
|
2532
|
+
&& (handle.startsWith(needle) || profile(handle).name.toLowerCase().startsWith(needle)))
|
|
2533
|
+
.slice(0, LIMITS.user_search_results)
|
|
2534
|
+
.map(publicIdentity);
|
|
2535
|
+
return sendJson(response, 200, { users });
|
|
2536
|
+
},
|
|
2537
|
+
|
|
2538
|
+
"users.avatar": ({ response, params }) => {
|
|
2539
|
+
const handle = String(params.user_id).toLowerCase();
|
|
2540
|
+
if (!members.includes(handle)) throw runtimeError("not_found", `no user @${handle}`);
|
|
2541
|
+
const { avatar } = profile(handle);
|
|
2542
|
+
if (!avatar) throw runtimeError("not_found", `no avatar for @${handle}`);
|
|
2543
|
+
response.writeHead(200, {
|
|
2544
|
+
"content-type": avatar.contentType,
|
|
2545
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
2546
|
+
});
|
|
2547
|
+
return response.end(avatar.bytes);
|
|
2548
|
+
},
|
|
2549
|
+
|
|
2550
|
+
// The platform redirects to the person's public page; locally that page
|
|
2551
|
+
// is the harness's own.
|
|
2552
|
+
"users.profile": ({ response, params }) => {
|
|
2553
|
+
const handle = String(params.handle).toLowerCase();
|
|
2554
|
+
if (!members.includes(handle)) throw runtimeError("not_found", `no user with handle @${handle}`);
|
|
2555
|
+
response.writeHead(307, { location: `${DEV_PROFILE_PREFIX}${encodeURIComponent(handle)}` });
|
|
2556
|
+
return response.end();
|
|
2557
|
+
},
|
|
2558
|
+
|
|
2559
|
+
"spaces.list": ({ member, response }) => sendJson(response, 200, {
|
|
2560
|
+
spaces: registry.spaces
|
|
2561
|
+
.filter((space) => space.members.includes(member))
|
|
2562
|
+
.sort((left, right) => compareStrings(right.updatedAt, left.updatedAt) || compareStrings(left.id, right.id))
|
|
2563
|
+
.slice(0, LIMITS.page_size)
|
|
2564
|
+
.map((space) => spaceView(space, member)),
|
|
2565
|
+
}),
|
|
2566
|
+
|
|
2567
|
+
"spaces.create": async (ctx) => {
|
|
2568
|
+
const { member, response } = ctx;
|
|
2569
|
+
const body = await jsonBody(ctx);
|
|
2570
|
+
const kind = body.kind ?? "group";
|
|
2571
|
+
if (kind !== "direct" && kind !== "group") throw runtimeError("bad_request", "kind must be direct or group");
|
|
2572
|
+
if (!Array.isArray(body.member_handles)) {
|
|
2573
|
+
throw runtimeError("bad_request", "member_handles must be a list of handles");
|
|
2574
|
+
}
|
|
2575
|
+
const name = body.name === undefined || body.name === null ? "New space" : spaceName(body.name);
|
|
2576
|
+
const meta = body.meta === undefined || body.meta === null ? null : spaceMeta(body.meta);
|
|
2577
|
+
let parentId = null;
|
|
2578
|
+
if (body.parent_id !== undefined && body.parent_id !== null) {
|
|
2579
|
+
if (typeof body.parent_id !== "string") throw runtimeError("bad_request", "parent_id must be a space id");
|
|
2580
|
+
parentId = requireSpace(member, body.parent_id).id;
|
|
2581
|
+
}
|
|
2582
|
+
// One transaction: every handle is checked before anything exists.
|
|
2583
|
+
const handles = [...new Set(body.member_handles.map((raw) => knownPerson(raw)))];
|
|
2584
|
+
const others = handles.filter((handle) => handle !== member);
|
|
2585
|
+
if (kind === "direct") {
|
|
2586
|
+
if (!others.length) throw runtimeError("bad_request", "a direct conversation requires another person");
|
|
2587
|
+
if (handles.length !== 1) {
|
|
2588
|
+
throw runtimeError("bad_request", "a direct conversation requires exactly one other person");
|
|
2589
|
+
}
|
|
2590
|
+
if (registry.isBlocked(member, others[0])) {
|
|
2591
|
+
throw runtimeError(
|
|
2592
|
+
"forbidden",
|
|
2593
|
+
"a direct conversation cannot be started while either person has blocked the other",
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
} else if (others.length !== handles.length) {
|
|
2597
|
+
throw runtimeError("bad_request", "you are already in this space");
|
|
2598
|
+
}
|
|
2599
|
+
if (others.length + 1 > LIMITS.delivery_recipients) {
|
|
2600
|
+
throw runtimeError("bad_request", `a space can have at most ${LIMITS.delivery_recipients} people`);
|
|
2601
|
+
}
|
|
2602
|
+
const now = new Date().toISOString();
|
|
2603
|
+
const space = {
|
|
2604
|
+
id: `s-${crypto.randomUUID().slice(0, 8)}`,
|
|
2605
|
+
name,
|
|
2606
|
+
kind,
|
|
2607
|
+
members: [member],
|
|
2608
|
+
roles: { [member]: "owner" },
|
|
2609
|
+
parentId,
|
|
2610
|
+
meta,
|
|
2611
|
+
joined: { [member]: now },
|
|
2612
|
+
createdAt: now,
|
|
2613
|
+
updatedAt: now,
|
|
2614
|
+
};
|
|
2615
|
+
registry.spaces.push(space);
|
|
2616
|
+
const invited = others.map((handle) => registry.invite(space.id, member, handle));
|
|
2617
|
+
registry.save();
|
|
2618
|
+
for (const { invitation, created } of invited) {
|
|
2619
|
+
if (created) ringInvitation(space, member, invitation);
|
|
2620
|
+
}
|
|
2621
|
+
// The creator is its first member: every one of their windows hears it
|
|
2622
|
+
// open on the account stream, the way an invitee's windows hear the
|
|
2623
|
+
// space they joined — a space made in one window lists in all of them.
|
|
2624
|
+
accountEvent(member, { type: "space_membership_joined", space_id: space.id });
|
|
2625
|
+
signalSpace(space.id, PLATFORM, { type: "space_invitations_updated" }, { transient: true });
|
|
2626
|
+
return sendJson(response, 200, {
|
|
2627
|
+
space: spaceView(space, member),
|
|
2628
|
+
invitations: invited.map(({ invitation, created }) => invitationReceipt(invitation, created)),
|
|
2629
|
+
});
|
|
2630
|
+
},
|
|
2631
|
+
|
|
2632
|
+
"spaces.update": async (ctx) => {
|
|
2633
|
+
const { member, response, params } = ctx;
|
|
2634
|
+
const body = await jsonBody(ctx);
|
|
2635
|
+
const space = requireSpace(member, params.space_id);
|
|
2636
|
+
if (!Object.hasOwn(body, "name") && !Object.hasOwn(body, "meta")) {
|
|
2637
|
+
throw runtimeError("bad_request", "send name, meta, or both");
|
|
2638
|
+
}
|
|
2639
|
+
const change = {};
|
|
2640
|
+
if (Object.hasOwn(body, "name")) {
|
|
2641
|
+
change.name = spaceName(body.name);
|
|
2642
|
+
requireGroup(space);
|
|
2643
|
+
requireManager(space, member);
|
|
2644
|
+
}
|
|
2645
|
+
if (Object.hasOwn(body, "meta")) {
|
|
2646
|
+
change.meta = body.meta === null ? null : spaceMeta(body.meta);
|
|
2647
|
+
if (space.kind === "group") requireManager(space, member);
|
|
2648
|
+
}
|
|
2649
|
+
Object.assign(space, change, { updatedAt: new Date().toISOString() });
|
|
2650
|
+
registry.save();
|
|
2651
|
+
signalSpace(space.id, PLATFORM, { type: "space_conversation_updated" }, { transient: true });
|
|
2652
|
+
return sendJson(response, 200, { space: spaceView(space, member) });
|
|
2653
|
+
},
|
|
2654
|
+
|
|
2655
|
+
"spaces.delete": async ({ member, response, params }) => {
|
|
2656
|
+
const space = requireSpace(member, params.space_id);
|
|
2657
|
+
if (registry.memberRole(space.id, member) !== "owner") {
|
|
2658
|
+
throw runtimeError("forbidden", "only the space owner can delete the space");
|
|
2659
|
+
}
|
|
2660
|
+
// A parent goes after its children, whoever is in them: the platform
|
|
2661
|
+
// never leaves a space pointing at one that is gone.
|
|
2662
|
+
if (registry.spaces.some((candidate) => candidate.parentId === space.id)) {
|
|
2663
|
+
throw runtimeError("conflict", "other spaces sit under this one; delete them first");
|
|
2664
|
+
}
|
|
2665
|
+
const everyone = [...space.members];
|
|
2666
|
+
for (const store of capsules.values()) store.deleteSpace(space.id);
|
|
2667
|
+
systemStore.deleteSpace(space.id);
|
|
2668
|
+
await dataPlane.removeSpace(space.id);
|
|
2669
|
+
registry.removeSpace(space.id);
|
|
2670
|
+
registry.save();
|
|
2671
|
+
for (const key of [...spaceMutes.keys()]) {
|
|
2672
|
+
if (key.endsWith(`\n${space.id}`)) spaceMutes.delete(key);
|
|
2673
|
+
}
|
|
2674
|
+
bus.forgetTopic(space.id);
|
|
2675
|
+
for (const target of everyone) accountEvent(target, { type: "space_deleted", space_id: space.id });
|
|
2676
|
+
return sendJson(response, 200, { ok: true });
|
|
2677
|
+
},
|
|
2678
|
+
|
|
2679
|
+
"spaces.members": ({ member, response, params }) => (
|
|
2680
|
+
sendJson(response, 200, { members: memberRows(requireSpace(member, params.space_id)) })
|
|
2681
|
+
),
|
|
2682
|
+
|
|
2683
|
+
"spaces.invite": async (ctx) => {
|
|
2684
|
+
const { member, response, params } = ctx;
|
|
2685
|
+
const body = await jsonBody(ctx);
|
|
2686
|
+
const space = requireSpace(member, params.space_id);
|
|
2687
|
+
requireGroup(space);
|
|
2688
|
+
requireManager(space, member);
|
|
2689
|
+
const role = body.role ?? "editor";
|
|
2690
|
+
if (!["admin", "editor", "viewer"].includes(role)) {
|
|
2691
|
+
throw runtimeError("bad_request", "member role must be admin, editor, or viewer");
|
|
2692
|
+
}
|
|
2693
|
+
if (role === "admin" && registry.memberRole(space.id, member) !== "owner") {
|
|
2694
|
+
throw runtimeError("forbidden", "only the space owner can invite another admin");
|
|
2695
|
+
}
|
|
2696
|
+
if (typeof body.handle !== "string" || !body.handle.trim().replace(/^@/u, "")) {
|
|
2697
|
+
throw runtimeError("bad_request", "invitee is required");
|
|
2698
|
+
}
|
|
2699
|
+
const handle = knownPerson(body.handle);
|
|
2700
|
+
if (handle === member) throw runtimeError("bad_request", "you are already in this space");
|
|
2701
|
+
if (space.members.includes(handle)) throw runtimeError("conflict", "one of those users is already a member");
|
|
2702
|
+
const pending = registry.pendingInvitations(space.id);
|
|
2703
|
+
if (!pending.some((invitation) => invitation.invitee === handle)
|
|
2704
|
+
&& space.members.length + pending.length >= LIMITS.delivery_recipients) {
|
|
2705
|
+
throw runtimeError("bad_request", `a space can have at most ${LIMITS.delivery_recipients} people`);
|
|
2706
|
+
}
|
|
2707
|
+
const { invitation, created } = registry.invite(space.id, member, handle, role);
|
|
2708
|
+
if (created) {
|
|
2709
|
+
space.updatedAt = invitation.createdAt;
|
|
2710
|
+
registry.save();
|
|
2711
|
+
signalSpace(space.id, PLATFORM, { type: "space_invitations_updated" }, { transient: true });
|
|
2712
|
+
ringInvitation(space, member, invitation);
|
|
2713
|
+
}
|
|
2714
|
+
return sendJson(response, 200, { invitation: invitationReceipt(invitation, created) });
|
|
2715
|
+
},
|
|
2716
|
+
|
|
2717
|
+
"spaces.update_member": async (ctx) => {
|
|
2718
|
+
const { member, response, params } = ctx;
|
|
2719
|
+
const body = await jsonBody(ctx);
|
|
2720
|
+
const role = body.role;
|
|
2721
|
+
if (!["admin", "editor", "viewer"].includes(role)) {
|
|
2722
|
+
throw runtimeError("bad_request", "group member role must be admin, editor, or viewer");
|
|
2723
|
+
}
|
|
2724
|
+
const space = requireSpace(member, params.space_id);
|
|
2725
|
+
requireGroup(space);
|
|
2726
|
+
requireManager(space, member);
|
|
2727
|
+
const target = String(params.member_id).toLowerCase();
|
|
2728
|
+
const targetRole = registry.memberRole(space.id, target);
|
|
2729
|
+
if (!targetRole) throw runtimeError("not_found", "space member not found");
|
|
2730
|
+
if (targetRole === "owner") throw runtimeError("forbidden", "the space owner role cannot be changed");
|
|
2731
|
+
if (registry.memberRole(space.id, member) === "admin" && (targetRole === "admin" || role === "admin")) {
|
|
2732
|
+
throw runtimeError("forbidden", "only the space owner can manage admins");
|
|
2733
|
+
}
|
|
2734
|
+
space.roles[target] = role;
|
|
2735
|
+
space.updatedAt = new Date().toISOString();
|
|
2736
|
+
registry.save();
|
|
2737
|
+
signalSpace(space.id, PLATFORM, { type: "space_members_updated" }, { transient: true });
|
|
2738
|
+
return sendJson(response, 200, { member: memberRows(space).find((row) => row.id === target) });
|
|
2739
|
+
},
|
|
2740
|
+
|
|
2741
|
+
"spaces.remove_member": ({ member, response, params }) => {
|
|
2742
|
+
const space = requireSpace(member, params.space_id);
|
|
2743
|
+
requireGroup(space);
|
|
2744
|
+
requireManager(space, member);
|
|
2745
|
+
const target = String(params.member_id).toLowerCase();
|
|
2746
|
+
const targetRole = registry.memberRole(space.id, target);
|
|
2747
|
+
if (!targetRole) throw runtimeError("not_found", "space member not found");
|
|
2748
|
+
if (targetRole === "owner") throw runtimeError("forbidden", "the space owner cannot be removed");
|
|
2749
|
+
if (registry.memberRole(space.id, member) === "admin" && targetRole === "admin") {
|
|
2750
|
+
throw runtimeError("forbidden", "only the space owner can remove an admin");
|
|
2751
|
+
}
|
|
2752
|
+
space.members = space.members.filter((handle) => handle !== target);
|
|
2753
|
+
delete space.roles[target];
|
|
2754
|
+
space.updatedAt = new Date().toISOString();
|
|
2755
|
+
registry.save();
|
|
2756
|
+
accountEvent(target, { type: "space_membership_left", space_id: space.id });
|
|
2757
|
+
signalSpace(space.id, PLATFORM, { type: "space_members_updated" }, { transient: true });
|
|
2758
|
+
return sendJson(response, 200, { ok: true });
|
|
2759
|
+
},
|
|
2760
|
+
|
|
2761
|
+
"spaces.leave": ({ member, response, params }) => {
|
|
2762
|
+
const space = requireSpace(member, params.space_id);
|
|
2763
|
+
requireGroup(space);
|
|
2764
|
+
if (registry.memberRole(space.id, member) === "owner") {
|
|
2765
|
+
throw runtimeError("forbidden", "the space owner cannot be removed");
|
|
2766
|
+
}
|
|
2767
|
+
space.members = space.members.filter((handle) => handle !== member);
|
|
2768
|
+
delete space.roles[member];
|
|
2769
|
+
space.updatedAt = new Date().toISOString();
|
|
2770
|
+
registry.save();
|
|
2771
|
+
accountEvent(member, { type: "space_membership_left", space_id: space.id });
|
|
2772
|
+
signalSpace(space.id, PLATFORM, { type: "space_members_updated" }, { transient: true });
|
|
2773
|
+
return sendJson(response, 200, { ok: true });
|
|
2774
|
+
},
|
|
2775
|
+
|
|
2776
|
+
"spaces.invitations": ({ member, response, params }) => sendJson(response, 200, {
|
|
2777
|
+
invitations: registry.pendingInvitations(requireSpace(member, params.space_id).id)
|
|
2778
|
+
.slice(0, LIMITS.page_size)
|
|
2779
|
+
.map(publicInvitation),
|
|
2780
|
+
}),
|
|
2781
|
+
|
|
2782
|
+
"spaces.block_peer": (ctx) => blockDoor(ctx, true),
|
|
2783
|
+
"spaces.unblock_peer": (ctx) => blockDoor(ctx, false),
|
|
2784
|
+
|
|
2785
|
+
"spaces.cursors": ({ member, response, params }) => {
|
|
2786
|
+
const space = requireSpace(member, params.space_id);
|
|
2787
|
+
const roles = Object.fromEntries(space.members.map((handle) => [handle, registry.memberRole(space.id, handle)]));
|
|
2788
|
+
return sendJson(response, 200, systemStore.spaceCursors(space.id, space.members, roles));
|
|
2789
|
+
},
|
|
2790
|
+
|
|
2791
|
+
"spaces.update_cursor": async (ctx) => {
|
|
2792
|
+
const { member, response, params } = ctx;
|
|
2793
|
+
const space = requireSpace(member, params.space_id);
|
|
2794
|
+
registry.ensureConversationActive(space.id, member);
|
|
2795
|
+
const body = await jsonBody(ctx);
|
|
2796
|
+
if (body.delivered_through === undefined && body.read_through === undefined) {
|
|
2797
|
+
throw runtimeError("bad_request", "at least one space cursor is required");
|
|
2798
|
+
}
|
|
2799
|
+
for (const field of ["delivered_through", "read_through"]) {
|
|
2800
|
+
const value = body[field];
|
|
2801
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
|
|
2802
|
+
throw runtimeError("bad_request", "space cursors must be nonnegative integers");
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
const result = systemStore.updateSpaceCursor(space.id, member, {
|
|
2806
|
+
deliveredThrough: body.delivered_through,
|
|
2807
|
+
readThrough: body.read_through,
|
|
2808
|
+
});
|
|
2809
|
+
if (result.conflict) throw runtimeError("conflict", result.conflict);
|
|
2810
|
+
// Only a real move wakes the space: a read receipt repeated by every
|
|
2811
|
+
// open window tells the other members nothing.
|
|
2812
|
+
if (result.advanced) {
|
|
2813
|
+
signalSpace(space.id, memberOrigin(member), {
|
|
2814
|
+
type: "space_cursor_updated",
|
|
2815
|
+
user_id: member,
|
|
2816
|
+
delivered_through: result.cursor.delivered_through,
|
|
2817
|
+
read_through: result.cursor.read_through,
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
return sendJson(response, 200, result.cursor);
|
|
2821
|
+
},
|
|
2822
|
+
|
|
2823
|
+
"spaces.mute": async (ctx) => {
|
|
2824
|
+
const { member, response, params } = ctx;
|
|
2825
|
+
const body = await jsonBody(ctx);
|
|
2826
|
+
if (!registry.memberRole(params.space_id, member)) throw runtimeError("not_found", "space not found");
|
|
2827
|
+
const muted = body.muted ?? null;
|
|
2828
|
+
if (muted !== null && typeof muted !== "boolean") {
|
|
2829
|
+
throw runtimeError("bad_request", "muted must be true, false or null");
|
|
2830
|
+
}
|
|
2831
|
+
if (muted === null) spaceMutes.delete(`${member}\n${params.space_id}`);
|
|
2832
|
+
else spaceMutes.set(`${member}\n${params.space_id}`, muted);
|
|
2833
|
+
// Their other windows re-read; nobody else hears of it.
|
|
2834
|
+
accountEvent(member, { type: "space_notifications_updated", space_id: params.space_id });
|
|
2835
|
+
return sendJson(response, 200, { muted: mutedFor(member, params.space_id) });
|
|
2836
|
+
},
|
|
2837
|
+
|
|
2838
|
+
"data.list": async ({ member, response, query }) => {
|
|
2839
|
+
const { root } = dataRoot(member, query);
|
|
2840
|
+
return sendJson(response, 200, await dataPlane.list(root, {
|
|
2841
|
+
prefix: query.get("prefix") ?? "",
|
|
2842
|
+
after: query.get("after"),
|
|
2843
|
+
limit: pageLimit(query.get("limit")),
|
|
2844
|
+
}));
|
|
2845
|
+
},
|
|
2846
|
+
|
|
2847
|
+
"data.read": async ({ member, response, query }) => {
|
|
2848
|
+
const { root } = dataRoot(member, query);
|
|
2849
|
+
const { bytes, entry, schema } = await dataPlane.read(root, query.get("path"));
|
|
2850
|
+
response.writeHead(200, {
|
|
2851
|
+
"content-type": entry.media_type,
|
|
2852
|
+
etag: `"${entry.sha256}"`,
|
|
2853
|
+
"x-terminus-data-version": String(entry.version),
|
|
2854
|
+
...(schema.id === null ? {} : { "x-terminus-schema-id": schema.id }),
|
|
2855
|
+
...(schema.version === null ? {} : { "x-terminus-schema-version": String(schema.version) }),
|
|
2856
|
+
});
|
|
2857
|
+
return response.end(bytes);
|
|
2858
|
+
},
|
|
2859
|
+
|
|
2860
|
+
"data.write": async (ctx) => {
|
|
2861
|
+
const { member, request, response, query } = ctx;
|
|
2862
|
+
const { root } = dataRoot(member, query, { write: true });
|
|
2863
|
+
if (query.get("observation_id")) {
|
|
2864
|
+
throw runtimeError("not_found", "observation not found: terminus dev records no connector observations");
|
|
2865
|
+
}
|
|
2866
|
+
const schemaId = query.get("schema_id") || null;
|
|
2867
|
+
if (schemaId !== null && !/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/u.test(schemaId)) {
|
|
2868
|
+
throw runtimeError("bad_request", "invalid schema_id");
|
|
2869
|
+
}
|
|
2870
|
+
const schemaVersion = optionalInteger(query.get("schema_version"), "schema_version");
|
|
2871
|
+
const receipt = await dataPlane.write(root, query.get("path"), await rawBody(ctx), {
|
|
2872
|
+
mediaType: requestMediaType(request),
|
|
2873
|
+
expectedSha256: query.get("expected_sha256"),
|
|
2874
|
+
schemaId,
|
|
2875
|
+
schemaVersion,
|
|
2876
|
+
});
|
|
2877
|
+
// A member's files are their own, their shard in a space included:
|
|
2878
|
+
// the change reaches their windows alone, naming no space, as the
|
|
2879
|
+
// platform's frame for it does.
|
|
2880
|
+
signalOwner(member, null, memberOrigin(member), {
|
|
2881
|
+
type: "object_changed",
|
|
2882
|
+
plane: "data",
|
|
2883
|
+
zone: root.zone,
|
|
2884
|
+
path: receipt.path,
|
|
2885
|
+
operation: "put",
|
|
2886
|
+
}, { transient: true });
|
|
2887
|
+
return sendJson(response, 200, receipt);
|
|
2888
|
+
},
|
|
2889
|
+
|
|
2890
|
+
"data.delete": async ({ member, response, query }) => {
|
|
2891
|
+
refuseAbsentDelete(query);
|
|
2892
|
+
const { root } = dataRoot(member, query, { write: true });
|
|
2893
|
+
const result = await dataPlane.remove(root, query.get("path"), {
|
|
2894
|
+
expectedSha256: query.get("expected_sha256"),
|
|
2895
|
+
});
|
|
2896
|
+
signalOwner(member, null, memberOrigin(member), {
|
|
2897
|
+
type: "object_changed",
|
|
2898
|
+
plane: "data",
|
|
2899
|
+
zone: root.zone,
|
|
2900
|
+
path: safeRelativePath(query.get("path")),
|
|
2901
|
+
operation: "delete",
|
|
2902
|
+
}, { transient: true });
|
|
2903
|
+
return sendJson(response, 200, result);
|
|
2904
|
+
},
|
|
2905
|
+
|
|
2906
|
+
"storage.list": ({ member, response, query, params }) => {
|
|
2907
|
+
const { store, key } = bucketScope(member, params.bucket, query);
|
|
2908
|
+
const prefix = safeListPrefix(query.get("prefix") ?? "");
|
|
2909
|
+
if (prefix === null) throw runtimeError("bad_request", "invalid bucket prefix");
|
|
2910
|
+
return sendJson(response, 200, store.listObjects(key, prefix, {
|
|
2911
|
+
after: query.get("after") || null,
|
|
2912
|
+
limit: pageLimit(query.get("limit")),
|
|
2913
|
+
}));
|
|
2914
|
+
},
|
|
2915
|
+
|
|
2916
|
+
"storage.read": ({ member, response, query, params }) => {
|
|
2917
|
+
const { store, key } = bucketScope(member, params.bucket, query);
|
|
2918
|
+
const object = store.getObject(key, objectPath(query.get("path")));
|
|
2919
|
+
if (!object) throw runtimeError("not_found", "data object not found");
|
|
2920
|
+
response.writeHead(200, { "content-type": object.entry.media_type, etag: `"${object.entry.sha256}"` });
|
|
2921
|
+
return response.end(object.bytes);
|
|
2922
|
+
},
|
|
2923
|
+
|
|
2924
|
+
"storage.write": async (ctx) => {
|
|
2925
|
+
const { member, request, response, query, params } = ctx;
|
|
2926
|
+
const { store, key, spaceId } = bucketScope(member, params.bucket, query, { write: true });
|
|
2927
|
+
const relative = objectPath(query.get("path"));
|
|
2928
|
+
const bytes = await rawBody(ctx);
|
|
2929
|
+
// Checked and written in one turn: nothing lands between the two.
|
|
2930
|
+
requireObjectVersion(store, key, relative, query.get("expected_sha256"));
|
|
2931
|
+
const receipt = store.putObject(key, relative, bytes, requestMediaType(request));
|
|
2932
|
+
signalWrite(member, spaceId, {
|
|
2933
|
+
type: "object_changed",
|
|
2934
|
+
plane: "storage",
|
|
2935
|
+
bucket: params.bucket,
|
|
2936
|
+
path: relative,
|
|
2937
|
+
operation: "put",
|
|
2938
|
+
}, { transient: true });
|
|
2939
|
+
return sendJson(response, 200, receipt);
|
|
2940
|
+
},
|
|
2941
|
+
|
|
2942
|
+
"storage.delete": ({ member, response, query, params }) => {
|
|
2943
|
+
refuseAbsentDelete(query);
|
|
2944
|
+
const { store, key, spaceId } = bucketScope(member, params.bucket, query, { write: true });
|
|
2945
|
+
const relative = objectPath(query.get("path"));
|
|
2946
|
+
requireObjectVersion(store, key, relative, query.get("expected_sha256"));
|
|
2947
|
+
store.deleteObject(key, relative);
|
|
2948
|
+
signalWrite(member, spaceId, {
|
|
2949
|
+
type: "object_changed",
|
|
2950
|
+
plane: "storage",
|
|
2951
|
+
bucket: params.bucket,
|
|
2952
|
+
path: relative,
|
|
2953
|
+
operation: "delete",
|
|
2954
|
+
}, { transient: true });
|
|
2955
|
+
return sendJson(response, 200, { ok: true });
|
|
2956
|
+
},
|
|
2957
|
+
|
|
2958
|
+
"collections.define": async (ctx) => {
|
|
2959
|
+
const name = requireManagedName(ctx.params.collection, "collection");
|
|
2960
|
+
const requested = normalizeCollectionDefinition(name, await jsonBody(ctx));
|
|
2961
|
+
const existing = collectionDefinitions.get(name);
|
|
2962
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(requested)) {
|
|
2963
|
+
throw runtimeError("conflict", `collection '${name}' was already defined differently for this release`);
|
|
2964
|
+
}
|
|
2965
|
+
if (!existing && collectionDefinitions.size >= LIMITS.collections_per_release) {
|
|
2966
|
+
throw new CliError(`a release can define at most ${LIMITS.collections_per_release} collections`);
|
|
2967
|
+
}
|
|
2968
|
+
const persisted = normalizeCollectionDefinition(
|
|
2969
|
+
name,
|
|
2970
|
+
systemStore.defineCollection(name, requested).existing,
|
|
2971
|
+
{ strict: false },
|
|
2972
|
+
);
|
|
2973
|
+
if (JSON.stringify(persisted) !== JSON.stringify(requested)) {
|
|
2974
|
+
throw runtimeError("conflict", `collection '${name}' was already defined differently for this release`);
|
|
2975
|
+
}
|
|
2976
|
+
collectionDefinitions.set(name, requested);
|
|
2977
|
+
return sendJson(ctx.response, 200, requested);
|
|
2978
|
+
},
|
|
2979
|
+
|
|
2980
|
+
"collections.list": async ({ member, response, query, params }) => {
|
|
2981
|
+
const name = requireManagedName(params.collection, "collection");
|
|
2982
|
+
const spaceId = query.get("space_id") || null;
|
|
2983
|
+
const declaration = collectionDefinition(name);
|
|
2984
|
+
requireCollectionScope(declaration, member, spaceId);
|
|
2985
|
+
const store = capsule(managedStoreMember(declaration, member, spaceId));
|
|
2986
|
+
const policyActor = managedPolicyActor(declaration, member, spaceId);
|
|
2987
|
+
// Cursor-before-rows: draining from here may repeat a concurrent commit
|
|
2988
|
+
// but cannot miss one between this point and the scan.
|
|
2989
|
+
const snapshotCursor = collectionCursor(store.scopeWindow(collectionScopeKey(declaration, spaceId, name)).head);
|
|
2990
|
+
let rawWhere = {};
|
|
2991
|
+
if (query.get("where")) {
|
|
2992
|
+
try {
|
|
2993
|
+
rawWhere = JSON.parse(query.get("where"));
|
|
2994
|
+
} catch {
|
|
2995
|
+
throw runtimeError("bad_request", "where must be a JSON object");
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
for (const field of whereFieldNames(rawWhere)) requireReadableField(declaration, policyActor, field);
|
|
2999
|
+
const where = validateWhereFilter(rawWhere);
|
|
3000
|
+
let records = (await collectionRecords(member, name, spaceId)).filter((record) => {
|
|
3001
|
+
try {
|
|
3002
|
+
authorizeCollection(declaration, "read", policyActor, record);
|
|
3003
|
+
return true;
|
|
3004
|
+
} catch (error) {
|
|
3005
|
+
if (error?.status === 403) return false;
|
|
3006
|
+
throw error;
|
|
3007
|
+
}
|
|
3008
|
+
});
|
|
3009
|
+
records = filterManagedRecords(records, where);
|
|
3010
|
+
const search = (query.get("search") ?? "").trim();
|
|
3011
|
+
if (search) {
|
|
3012
|
+
for (const field of declaration.search) requireReadableField(declaration, policyActor, field);
|
|
3013
|
+
records = searchManagedRecords(declaration, records, search);
|
|
3014
|
+
}
|
|
3015
|
+
const sortSpec = query.get("sort");
|
|
3016
|
+
let supportsCursor = true;
|
|
3017
|
+
if (sortSpec) {
|
|
3018
|
+
validateSort(sortSpec);
|
|
3019
|
+
requireReadableField(declaration, policyActor, sortSpec.replace(/^-/u, ""));
|
|
3020
|
+
if (query.get("after")) {
|
|
3021
|
+
throw new CliError("after cursors page the default id order; sorted queries page by limit");
|
|
3022
|
+
}
|
|
3023
|
+
supportsCursor = false;
|
|
3024
|
+
}
|
|
3025
|
+
records = sortManagedRecords(records, sortSpec);
|
|
3026
|
+
const after = supportsCursor ? query.get("after") : null;
|
|
3027
|
+
const start = after ? records.findIndex((record) => compareStrings(record.id, after) > 0) : 0;
|
|
3028
|
+
const from = start < 0 ? records.length : start;
|
|
3029
|
+
const limit = pageLimit(query.get("limit"));
|
|
3030
|
+
const page = records.slice(from, from + limit)
|
|
3031
|
+
.map((record) => redactCollectionRecord(declaration, policyActor, record));
|
|
3032
|
+
const hasMore = records.length > from + limit;
|
|
3033
|
+
return sendJson(response, 200, {
|
|
3034
|
+
records: page,
|
|
3035
|
+
next_cursor: hasMore && supportsCursor ? (page.at(-1)?.id ?? null) : null,
|
|
3036
|
+
has_more: hasMore,
|
|
3037
|
+
sync_cursor: snapshotCursor,
|
|
3038
|
+
});
|
|
3039
|
+
},
|
|
3040
|
+
|
|
3041
|
+
"collections.get": ({ member, response, query, params }) => {
|
|
3042
|
+
const name = requireManagedName(params.collection, "collection");
|
|
3043
|
+
const id = requireManagedId(params.record_id);
|
|
3044
|
+
const spaceId = query.get("space_id") || null;
|
|
3045
|
+
const declaration = collectionDefinition(name);
|
|
3046
|
+
requireCollectionScope(declaration, member, spaceId);
|
|
3047
|
+
const record = capsule(managedStoreMember(declaration, member, spaceId))
|
|
3048
|
+
.getRecord(collectionScopeKey(declaration, spaceId, name), name, id);
|
|
3049
|
+
if (record === null) throw runtimeError("not_found", "record not found");
|
|
3050
|
+
const policyActor = managedPolicyActor(declaration, member, spaceId);
|
|
3051
|
+
authorizeCollection(declaration, "read", policyActor, record);
|
|
3052
|
+
return sendJson(response, 200, redactCollectionRecord(declaration, policyActor, record));
|
|
3053
|
+
},
|
|
3054
|
+
|
|
3055
|
+
"collections.put": (ctx) => recordMutation(ctx, "put"),
|
|
3056
|
+
"collections.delete": (ctx) => recordMutation(ctx, "delete"),
|
|
3057
|
+
|
|
3058
|
+
"collections.deliver": async (ctx) => {
|
|
3059
|
+
const { member, request, response, params } = ctx;
|
|
3060
|
+
const name = requireManagedName(params.collection, "collection");
|
|
3061
|
+
const declaration = collectionDefinition(name);
|
|
3062
|
+
if (declaration.ownership !== "personal") {
|
|
3063
|
+
throw new CliError("delivery requires a personal collection so each recipient owns a replica");
|
|
3064
|
+
}
|
|
3065
|
+
const mutationId = idempotencyKey(request, { required: true, door: "collection delivery" });
|
|
3066
|
+
const body = await jsonBody(ctx);
|
|
3067
|
+
const deliverySpaceId = typeof body.space_id === "string" ? body.space_id : "";
|
|
3068
|
+
if (!deliverySpaceId) throw runtimeError("bad_request", "a collaborative space is required");
|
|
3069
|
+
const id = requireManagedId(body.record_id);
|
|
3070
|
+
const value = body.value;
|
|
3071
|
+
const rings = normalizeWriteRings(body, "delivery");
|
|
3072
|
+
const { notify } = rings;
|
|
3073
|
+
const space = requireSpace(member, deliverySpaceId);
|
|
3074
|
+
requireSpaceWriter(member, deliverySpaceId);
|
|
3075
|
+
registry.ensureConversationActive(deliverySpaceId, member);
|
|
3076
|
+
validateManagedRecord(declaration, value);
|
|
3077
|
+
// Ring intent is part of the idempotent request: the same key naming
|
|
3078
|
+
// other people, or saying something else, is a different delivery.
|
|
3079
|
+
const signature = sha256(JSON.stringify({
|
|
3080
|
+
operation: "deliver",
|
|
3081
|
+
spaceId: deliverySpaceId,
|
|
3082
|
+
collection: name,
|
|
3083
|
+
id,
|
|
3084
|
+
value,
|
|
3085
|
+
...(notify ? { notify } : {}),
|
|
3086
|
+
...(rings.mentions.length ? { mentions: rings.mentions } : {}),
|
|
3087
|
+
}));
|
|
3088
|
+
// A committed delivery's key answers its first answer before the
|
|
3089
|
+
// recipients' permissions and the write rules are asked again.
|
|
3090
|
+
const receipt = systemStore.deliveryReceipt({ spaceId: deliverySpaceId, collection: name, mutationId });
|
|
3091
|
+
if (receipt && (receipt.recordId !== id || receipt.requestSha256 !== signature)) {
|
|
3092
|
+
throw runtimeError("idempotency_conflict", "Idempotency-Key was already used for a different delivery");
|
|
3093
|
+
}
|
|
3094
|
+
if (receipt?.response) return sendJson(response, 200, { ...receipt.response, replayed: true });
|
|
3095
|
+
const recipients = [...space.members];
|
|
3096
|
+
if (!recipients.length || recipients.length > LIMITS.delivery_recipients) {
|
|
3097
|
+
throw new CliError(`record delivery requires a space with 1-${LIMITS.delivery_recipients} current members`);
|
|
3098
|
+
}
|
|
3099
|
+
for (const target of recipients) {
|
|
3100
|
+
const deliveryActor = {
|
|
3101
|
+
userId: member,
|
|
3102
|
+
principal: "user",
|
|
3103
|
+
capsuleOwner: target,
|
|
3104
|
+
spaceRole: registry.memberRole(deliverySpaceId, member),
|
|
3105
|
+
};
|
|
3106
|
+
authorizeCollection(declaration, "create", deliveryActor, {
|
|
3107
|
+
value,
|
|
3108
|
+
created_by_user_id: member,
|
|
3109
|
+
updated_by_user_id: member,
|
|
3110
|
+
});
|
|
3111
|
+
authorizeChangedFields(declaration, deliveryActor, undefined, value);
|
|
3112
|
+
validateLocalDeclaredConstraints(capsule(target), member, deliverySpaceId, [{
|
|
3113
|
+
scopeKey: collectionScopeKey(declaration, deliverySpaceId, name),
|
|
3114
|
+
collection: name,
|
|
3115
|
+
recordId: id,
|
|
3116
|
+
operation: "put",
|
|
3117
|
+
expectedVersion: 0,
|
|
3118
|
+
value,
|
|
3119
|
+
declaration,
|
|
3120
|
+
}]);
|
|
3121
|
+
}
|
|
3122
|
+
// Validate rules run once, sender-side: every recipient stores the
|
|
3123
|
+
// same delivered value, so one enforcement covers the fan-out.
|
|
3124
|
+
enforceWriteRules(declaration, member, null, value);
|
|
3125
|
+
const delivery = systemStore.beginDelivery({
|
|
3126
|
+
spaceId: deliverySpaceId,
|
|
3127
|
+
collection: name,
|
|
3128
|
+
mutationId,
|
|
3129
|
+
recordId: id,
|
|
3130
|
+
requestSha256: signature,
|
|
3131
|
+
recipients,
|
|
3132
|
+
});
|
|
3133
|
+
if (delivery.conflict) throw storeConflict(delivery);
|
|
3134
|
+
if (delivery.response) return sendJson(response, 200, { ...delivery.response, replayed: true });
|
|
3135
|
+
const deliveryScopeKey = `member:${deliverySpaceId}:${name}`;
|
|
3136
|
+
let senderCursor = null;
|
|
3137
|
+
for (const target of delivery.recipients) {
|
|
3138
|
+
const result = capsule(target).mutateRecord({
|
|
3139
|
+
scopeKey: deliveryScopeKey,
|
|
3140
|
+
collection: name,
|
|
3141
|
+
recordId: id,
|
|
3142
|
+
mutationId,
|
|
3143
|
+
requestSha256: signature,
|
|
3144
|
+
operation: "put",
|
|
3145
|
+
expectedVersion: 0,
|
|
3146
|
+
value,
|
|
3147
|
+
actor: member,
|
|
3148
|
+
deliverySequence: delivery.sequence,
|
|
3149
|
+
deliveryRecipients: delivery.recipients,
|
|
3150
|
+
});
|
|
3151
|
+
if (result.conflict) throw storeConflict(result);
|
|
3152
|
+
if (target === member) senderCursor = result.response.sync_cursor;
|
|
3153
|
+
}
|
|
3154
|
+
const result = {
|
|
3155
|
+
id,
|
|
3156
|
+
value,
|
|
3157
|
+
version: 1,
|
|
3158
|
+
sequence: delivery.sequence,
|
|
3159
|
+
recipients: delivery.recipients,
|
|
3160
|
+
mutation_id: mutationId,
|
|
3161
|
+
sync_cursor: senderCursor,
|
|
3162
|
+
};
|
|
3163
|
+
systemStore.commitDelivery({ spaceId: deliverySpaceId, collection: name, mutationId, response: result });
|
|
3164
|
+
// Each recipient the delivery froze hears it once: the record_delivered
|
|
3165
|
+
// frame of their own replica, which their own change feed then reads.
|
|
3166
|
+
// No replica sends a record_changed of its own, to its owner or anyone.
|
|
3167
|
+
bus.deliver(delivery.recipients, bus.envelope("space_event", deliverySpaceId, memberOrigin(member), {
|
|
3168
|
+
type: "record_delivered",
|
|
3169
|
+
collection: name,
|
|
3170
|
+
record_id: id,
|
|
3171
|
+
sequence: delivery.sequence,
|
|
3172
|
+
mutation_id: mutationId,
|
|
3173
|
+
}, false));
|
|
3174
|
+
// Rung among the frozen recipients, on this first commit only: a
|
|
3175
|
+
// replay returned above, before anyone could be rung twice.
|
|
3176
|
+
ringSpaceWrite(space, member, delivery.recipients, rings, {
|
|
3177
|
+
space_id: deliverySpaceId,
|
|
3178
|
+
collection: name,
|
|
3179
|
+
record_id: id,
|
|
3180
|
+
sequence: delivery.sequence,
|
|
3181
|
+
});
|
|
3182
|
+
return sendJson(response, 200, result);
|
|
3183
|
+
},
|
|
3184
|
+
|
|
3185
|
+
"collections.changes": ({ member, response, query, params }) => {
|
|
3186
|
+
const name = requireManagedName(params.collection, "collection");
|
|
3187
|
+
const spaceId = query.get("space_id") || null;
|
|
3188
|
+
const declaration = collectionDefinition(name);
|
|
3189
|
+
requireCollectionScope(declaration, member, spaceId);
|
|
3190
|
+
const store = capsule(managedStoreMember(declaration, member, spaceId));
|
|
3191
|
+
const policyActor = managedPolicyActor(declaration, member, spaceId);
|
|
3192
|
+
const after = parseCollectionCursor(query.get("after"));
|
|
3193
|
+
const page = store.changes(collectionScopeKey(declaration, spaceId, name), after);
|
|
3194
|
+
if (page.resetRequired) {
|
|
3195
|
+
return sendJson(response, 200, {
|
|
3196
|
+
reset_required: true,
|
|
3197
|
+
reason: "cursor_expired",
|
|
3198
|
+
cursor: collectionCursor(page.window.head),
|
|
3199
|
+
retained_after: collectionCursor(page.window.retainedAfter),
|
|
3200
|
+
changes: [],
|
|
3201
|
+
has_more: false,
|
|
3202
|
+
});
|
|
3203
|
+
}
|
|
3204
|
+
const changes = page.changes
|
|
3205
|
+
.map((change) => visibleCollectionChange(declaration, policyActor, change))
|
|
3206
|
+
.filter(Boolean);
|
|
3207
|
+
const scannedCursor = page.changes.at(-1)?.cursor ?? after;
|
|
3208
|
+
return sendJson(response, 200, {
|
|
3209
|
+
reset_required: false,
|
|
3210
|
+
cursor: page.hasMore ? collectionCursor(scannedCursor) : collectionCursor(page.window.head),
|
|
3211
|
+
retained_after: collectionCursor(page.window.retainedAfter),
|
|
3212
|
+
has_more: page.hasMore,
|
|
3213
|
+
changes,
|
|
3214
|
+
});
|
|
3215
|
+
},
|
|
3216
|
+
|
|
3217
|
+
"collections.rebuild": async ({ member, response, query, params }) => {
|
|
3218
|
+
const name = requireManagedName(params.collection, "collection");
|
|
3219
|
+
const spaceId = query.get("space_id") || null;
|
|
3220
|
+
const declaration = collectionDefinition(name);
|
|
3221
|
+
requireCollectionScope(declaration, member, spaceId);
|
|
3222
|
+
const store = capsule(managedStoreMember(declaration, member, spaceId));
|
|
3223
|
+
const records = await collectionRecords(member, name, spaceId);
|
|
3224
|
+
const { change } = store.appendReset(collectionScopeKey(declaration, spaceId, name), name, member, records.length);
|
|
3225
|
+
publishCollectionWake(member, spaceId, { declaration, operation: "reset", collection: name, recordId: null }, change);
|
|
3226
|
+
return sendJson(response, 200, { rebuilt: records.length, sync_cursor: collectionCursor(change.cursor) });
|
|
3227
|
+
},
|
|
3228
|
+
|
|
3229
|
+
"transactions.commit": (ctx) => transactionDoor(ctx),
|
|
3230
|
+
|
|
3231
|
+
"collaboration.append": async (ctx) => {
|
|
3232
|
+
const { member, response, params } = ctx;
|
|
3233
|
+
const documentId = requireManagedId(params.document_id);
|
|
3234
|
+
const body = await jsonBody(ctx);
|
|
3235
|
+
const spaceId = typeof body.space_id === "string" && body.space_id ? body.space_id : null;
|
|
3236
|
+
if (spaceId) requireSpaceWriter(member, spaceId);
|
|
3237
|
+
const operations = body.operations;
|
|
3238
|
+
if (!Array.isArray(operations) || !operations.length || operations.length > LIMITS.collaboration_batch_operations) {
|
|
3239
|
+
throw new CliError(`collaboration batches contain 1-${LIMITS.collaboration_batch_operations} operations`);
|
|
3240
|
+
}
|
|
3241
|
+
const ids = new Set();
|
|
3242
|
+
for (const operation of operations) {
|
|
3243
|
+
const id = String(operation?.id ?? "");
|
|
3244
|
+
if (!id || id.length > LIMITS.idempotency_key_chars || ids.has(id)) {
|
|
3245
|
+
throw new CliError(`operation ids must be 1-${LIMITS.idempotency_key_chars} visible, unique characters`);
|
|
3246
|
+
}
|
|
3247
|
+
ids.add(id);
|
|
3248
|
+
}
|
|
3249
|
+
const result = systemStore.appendCollaboration({
|
|
3250
|
+
scopeKey: spaceId ? `space:${spaceId}` : `personal:${member}`,
|
|
3251
|
+
documentId,
|
|
3252
|
+
actor: member,
|
|
3253
|
+
sessionId: `dev-${member}`,
|
|
3254
|
+
operations,
|
|
3255
|
+
});
|
|
3256
|
+
if (result.conflict) throw storeConflict(result);
|
|
3257
|
+
const cursor = collaborationCursor(result.head);
|
|
3258
|
+
signalWrite(member, spaceId, { type: "collaboration_changed", document_id: documentId, cursor });
|
|
3259
|
+
return sendJson(response, 200, {
|
|
3260
|
+
cursor,
|
|
3261
|
+
operations: result.operations.map((operation) => ({
|
|
3262
|
+
...operation,
|
|
3263
|
+
cursor: collaborationCursor(operation.cursor),
|
|
3264
|
+
})),
|
|
3265
|
+
});
|
|
3266
|
+
},
|
|
3267
|
+
|
|
3268
|
+
"collaboration.read": ({ member, response, query, params }) => {
|
|
3269
|
+
const documentId = requireManagedId(params.document_id);
|
|
3270
|
+
const spaceId = query.get("space_id") || null;
|
|
3271
|
+
if (spaceId) requireSpace(member, spaceId);
|
|
3272
|
+
const after = parseCollaborationCursor(query.get("after"));
|
|
3273
|
+
const result = systemStore.listCollaboration({
|
|
3274
|
+
scopeKey: spaceId ? `space:${spaceId}` : `personal:${member}`,
|
|
3275
|
+
documentId,
|
|
3276
|
+
after,
|
|
3277
|
+
limit: pageLimit(query.get("limit"), LIMITS.page_size),
|
|
3278
|
+
});
|
|
3279
|
+
if (after < result.retainedAfter) {
|
|
3280
|
+
return sendJson(response, 200, {
|
|
3281
|
+
reset_required: true,
|
|
3282
|
+
reason: "cursor_expired",
|
|
3283
|
+
cursor: collaborationCursor(result.head),
|
|
3284
|
+
snapshot_cursor: collaborationCursor(result.snapshotSequence),
|
|
3285
|
+
snapshot: result.snapshot,
|
|
3286
|
+
operations: [],
|
|
3287
|
+
has_more: false,
|
|
3288
|
+
});
|
|
3289
|
+
}
|
|
3290
|
+
const cursor = result.operations.at(-1)?.cursor ?? result.head;
|
|
3291
|
+
return sendJson(response, 200, {
|
|
3292
|
+
reset_required: false,
|
|
3293
|
+
cursor: collaborationCursor(cursor),
|
|
3294
|
+
snapshot_cursor: collaborationCursor(result.snapshotSequence),
|
|
3295
|
+
snapshot: after === 0 ? result.snapshot : null,
|
|
3296
|
+
operations: result.operations.map((operation) => ({
|
|
3297
|
+
...operation,
|
|
3298
|
+
cursor: collaborationCursor(operation.cursor),
|
|
3299
|
+
})),
|
|
3300
|
+
has_more: result.hasMore,
|
|
3301
|
+
});
|
|
3302
|
+
},
|
|
3303
|
+
|
|
3304
|
+
"collaboration.snapshot": async (ctx) => {
|
|
3305
|
+
const { member, response, params } = ctx;
|
|
3306
|
+
const documentId = requireManagedId(params.document_id);
|
|
3307
|
+
const body = await jsonBody(ctx);
|
|
3308
|
+
const spaceId = typeof body.space_id === "string" && body.space_id ? body.space_id : null;
|
|
3309
|
+
if (spaceId) requireSpaceWriter(member, spaceId);
|
|
3310
|
+
const result = systemStore.snapshotCollaboration({
|
|
3311
|
+
scopeKey: spaceId ? `space:${spaceId}` : `personal:${member}`,
|
|
3312
|
+
documentId,
|
|
3313
|
+
through: parseCollaborationCursor(body.through),
|
|
3314
|
+
snapshot: body.snapshot,
|
|
3315
|
+
});
|
|
3316
|
+
if (result.conflict) throw runtimeError("conflict", result.conflict);
|
|
3317
|
+
return sendJson(response, 200, {
|
|
3318
|
+
ok: true,
|
|
3319
|
+
cursor: collaborationCursor(result.head),
|
|
3320
|
+
snapshot_cursor: collaborationCursor(result.through),
|
|
3321
|
+
});
|
|
3322
|
+
},
|
|
3323
|
+
|
|
3324
|
+
"events.publish": (ctx) => publishDoor(ctx),
|
|
3325
|
+
"events.stream": (ctx) => streamDoor(ctx),
|
|
3326
|
+
|
|
3327
|
+
"events.presence": ({ member, response, query }) => {
|
|
3328
|
+
const spaceId = query.get("space_id") || "";
|
|
3329
|
+
if (!spaceId) throw runtimeError("bad_request", "a collaborative space is required");
|
|
3330
|
+
const space = requireSpace(member, spaceId);
|
|
3331
|
+
const online = space.members.filter((handle) => bus.hasStream(handle));
|
|
3332
|
+
// A space-scoped state wins; a personal install only ever parked one on
|
|
3333
|
+
// its own topic, and the space query composes that in — the precedence
|
|
3334
|
+
// `presence_response` applies on the platform.
|
|
3335
|
+
const spaceStates = bus.presenceStates(spaceId);
|
|
3336
|
+
const states = {};
|
|
3337
|
+
for (const handle of online) {
|
|
3338
|
+
const state = spaceStates.has(handle) ? spaceStates.get(handle) : bus.memberPresenceState(handle);
|
|
3339
|
+
if (state !== undefined) states[handle] = state;
|
|
3340
|
+
}
|
|
3341
|
+
return sendJson(response, 200, { online, states });
|
|
3342
|
+
},
|
|
3343
|
+
|
|
3344
|
+
"events.set_presence": async (ctx) => {
|
|
3345
|
+
const { member, response } = ctx;
|
|
3346
|
+
const body = await jsonBody(ctx);
|
|
3347
|
+
const state = body.state ?? null;
|
|
3348
|
+
const window = body.window ?? null;
|
|
3349
|
+
if (window !== null && (typeof window !== "string" || !/^[A-Za-z0-9_-]{1,64}$/u.test(window))) {
|
|
3350
|
+
throw runtimeError("bad_request", "window is 1-64 letters, digits, dashes or underscores");
|
|
3351
|
+
}
|
|
3352
|
+
if (state !== null && Buffer.byteLength(JSON.stringify(state)) > LIMITS.presence_state_bytes) {
|
|
3353
|
+
throw runtimeError("payload_too_large", `presence state exceeds ${LIMITS.presence_state_bytes} bytes`);
|
|
3354
|
+
}
|
|
3355
|
+
const spaceId = typeof body.space_id === "string" && body.space_id ? body.space_id : null;
|
|
3356
|
+
if (!spaceId) {
|
|
3357
|
+
// A personal install parks its state on its own topic, and the change
|
|
3358
|
+
// reaches co-members across every shared space — never the setter. A
|
|
3359
|
+
// clear is a leave, heard as `online: false`.
|
|
3360
|
+
if (bus.setPresence(accountTopic(member), member, state)) {
|
|
3361
|
+
bus.deliver(coMembers(member), presenceFrame(null, member, state));
|
|
3362
|
+
}
|
|
3363
|
+
return sendJson(response, 200, { ok: true });
|
|
3364
|
+
}
|
|
3365
|
+
const space = requireSpace(member, spaceId);
|
|
3366
|
+
registry.ensureConversationActive(spaceId, member);
|
|
3367
|
+
// The space's other members hear the change; a clear — the member left
|
|
3368
|
+
// the space's presence — as `online: false`.
|
|
3369
|
+
if (bus.setPresence(spaceId, member, state)) {
|
|
3370
|
+
bus.deliver(space.members, presenceFrame(spaceId, member, state), { except: member });
|
|
3371
|
+
}
|
|
3372
|
+
// Hidden, or cleared (the space was left), a window is not looking.
|
|
3373
|
+
if (window) bus.look(spaceId, member, window, body.visible !== false && state !== null);
|
|
3374
|
+
return sendJson(response, 200, { ok: true });
|
|
3375
|
+
},
|
|
3376
|
+
|
|
3377
|
+
"notifications.create": async (ctx) => {
|
|
3378
|
+
const { member, request, response } = ctx;
|
|
3379
|
+
const body = await jsonBody(ctx);
|
|
3380
|
+
const title = typeof body.title === "string" ? body.title.trim() : "";
|
|
3381
|
+
const text = body.body ?? "";
|
|
3382
|
+
const route = body.route ?? null;
|
|
3383
|
+
const data = body.data ?? {};
|
|
3384
|
+
if (!title || [...title].length > LIMITS.notification_title_chars
|
|
3385
|
+
|| typeof text !== "string" || [...text].length > LIMITS.notification_body_chars
|
|
3386
|
+
|| (route !== null && (typeof route !== "string" || [...route].length > LIMITS.notification_route_chars))) {
|
|
3387
|
+
throw runtimeError("bad_request", "invalid notification");
|
|
3388
|
+
}
|
|
3389
|
+
if (Buffer.byteLength(JSON.stringify(data)) > LIMITS.notification_data_bytes) {
|
|
3390
|
+
throw runtimeError("payload_too_large", `notification data is at most ${LIMITS.notification_data_bytes} bytes`);
|
|
3391
|
+
}
|
|
3392
|
+
const key = idempotencyKey(request);
|
|
3393
|
+
const answer = await once("notifications.create", member, key, body, () => {
|
|
3394
|
+
if (pkg.manifest.shell?.notifications !== true) {
|
|
3395
|
+
throw runtimeError("forbidden", "release did not declare shell.notifications");
|
|
3396
|
+
}
|
|
3397
|
+
const item = {
|
|
3398
|
+
id: crypto.randomUUID(),
|
|
3399
|
+
member,
|
|
3400
|
+
title,
|
|
3401
|
+
body: text,
|
|
3402
|
+
route,
|
|
3403
|
+
data,
|
|
3404
|
+
read_at: null,
|
|
3405
|
+
created_at: new Date().toISOString(),
|
|
3406
|
+
};
|
|
3407
|
+
if (!notifyMember(item)) return { created: false, paused: true };
|
|
3408
|
+
console.log(`[notification] ${item.title}: ${item.body}`);
|
|
3409
|
+
return { id: item.id, created: true };
|
|
3410
|
+
});
|
|
3411
|
+
return sendJson(response, 200, answer);
|
|
3412
|
+
},
|
|
3413
|
+
|
|
3414
|
+
"notifications.list": ({ member, response, query }) => {
|
|
3415
|
+
const mine = notifications.filter((item) => item.member === member).slice().reverse();
|
|
3416
|
+
const after = query.get("after");
|
|
3417
|
+
let from = 0;
|
|
3418
|
+
if (after) {
|
|
3419
|
+
const index = mine.findIndex((item) => item.id === after);
|
|
3420
|
+
if (index < 0) throw runtimeError("bad_request", "invalid notifications cursor");
|
|
3421
|
+
from = index + 1;
|
|
3422
|
+
}
|
|
3423
|
+
const limit = pageLimit(query.get("limit"), 50);
|
|
3424
|
+
const page = mine.slice(from, from + limit);
|
|
3425
|
+
return sendJson(response, 200, {
|
|
3426
|
+
notifications: page.map(appNotice),
|
|
3427
|
+
next_cursor: mine.length > from + limit ? page.at(-1).id : null,
|
|
3428
|
+
});
|
|
3429
|
+
},
|
|
3430
|
+
|
|
3431
|
+
"notifications.read": ({ member, response, params }) => {
|
|
3432
|
+
const id = uuidParam(params.notification_id, "notification");
|
|
3433
|
+
const item = notifications.find((candidate) => candidate.id === id && candidate.member === member);
|
|
3434
|
+
if (!item) throw runtimeError("not_found", "notification not found");
|
|
3435
|
+
if (!item.read_at) item.read_at = new Date().toISOString();
|
|
3436
|
+
return sendJson(response, 200, { ok: true });
|
|
3437
|
+
},
|
|
3438
|
+
|
|
3439
|
+
"notifications.pause_status": ({ member, response }) => sendJson(response, 200, { paused_until: pausedUntil(member) }),
|
|
3440
|
+
|
|
3441
|
+
"notifications.pause": async (ctx) => {
|
|
3442
|
+
const { member, response } = ctx;
|
|
3443
|
+
const body = await jsonBody(ctx);
|
|
3444
|
+
const until = body.until === null || body.until === undefined ? null : new Date(body.until);
|
|
3445
|
+
if (until && (typeof body.until !== "string" || Number.isNaN(until.getTime()))) {
|
|
3446
|
+
throw runtimeError("bad_request", "until must be an RFC 3339 time or null");
|
|
3447
|
+
}
|
|
3448
|
+
if (until && until.getTime() - Date.now() > LIMITS.notification_pause_days * 24 * 60 * 60 * 1000) {
|
|
3449
|
+
throw runtimeError("bad_request", `a pause can last at most ${LIMITS.notification_pause_days} days`);
|
|
3450
|
+
}
|
|
3451
|
+
if (until && until.getTime() > Date.now()) notificationPauses.set(member, until.toISOString());
|
|
3452
|
+
else notificationPauses.delete(member);
|
|
3453
|
+
return sendJson(response, 200, { paused_until: pausedUntil(member) });
|
|
3454
|
+
},
|
|
3455
|
+
|
|
3456
|
+
"notifications.default_mute_status": ({ member, response }) => sendJson(response, 200, { muted: defaultMutes.has(member) }),
|
|
3457
|
+
|
|
3458
|
+
"notifications.default_mute": async (ctx) => {
|
|
3459
|
+
const { member, response } = ctx;
|
|
3460
|
+
const body = await jsonBody(ctx);
|
|
3461
|
+
if (typeof body.muted !== "boolean") throw runtimeError("bad_request", "muted must be true or false");
|
|
3462
|
+
if (body.muted) defaultMutes.add(member);
|
|
3463
|
+
else defaultMutes.delete(member);
|
|
3464
|
+
accountEvent(member, { type: "space_notifications_updated", space_id: null });
|
|
3465
|
+
return sendJson(response, 200, { muted: defaultMutes.has(member) });
|
|
3466
|
+
},
|
|
3467
|
+
|
|
3468
|
+
"jobs.run": async (ctx) => {
|
|
3469
|
+
const { member, request, response } = ctx;
|
|
3470
|
+
const body = await jsonBody(ctx);
|
|
3471
|
+
if (!isPlainObject(body.input)) {
|
|
3472
|
+
throw runtimeError("bad_request", "automation input must be an object no larger than 256 KiB");
|
|
3473
|
+
}
|
|
3474
|
+
const automation = typeof body.automation === "string" ? body.automation : "";
|
|
3475
|
+
const spaceId = body.space_id ?? null;
|
|
3476
|
+
const key = idempotencyKey(request);
|
|
3477
|
+
// A replayed key answers the job it made before the release's
|
|
3478
|
+
// declarations or the space are asked about it again.
|
|
3479
|
+
const answer = await once("jobs.run", member, key, body, () => {
|
|
3480
|
+
declaredAutomation(automation);
|
|
3481
|
+
if (spaceId !== null) requireSpace(member, String(spaceId));
|
|
3482
|
+
const job = {
|
|
3483
|
+
id: crypto.randomUUID(),
|
|
3484
|
+
automation,
|
|
3485
|
+
status: "queued",
|
|
3486
|
+
input: structuredClone(body.input),
|
|
3487
|
+
result: null,
|
|
3488
|
+
error: null,
|
|
3489
|
+
attempts: 0,
|
|
3490
|
+
space_id: spaceId,
|
|
3491
|
+
created_at: new Date().toISOString(),
|
|
3492
|
+
finished_at: null,
|
|
3493
|
+
};
|
|
3494
|
+
automationJobs.set(`${member}\0${job.id}`, job);
|
|
3495
|
+
const timer = setTimeout(async () => {
|
|
3496
|
+
jobTimers.delete(timer);
|
|
3497
|
+
if (job.status !== "queued") return;
|
|
3498
|
+
job.status = "running";
|
|
3499
|
+
job.attempts = 1;
|
|
3500
|
+
try {
|
|
3501
|
+
job.result = await executeLocalAutomation(member, job);
|
|
3502
|
+
finishJob(member, job, "succeeded");
|
|
3503
|
+
} catch (error) {
|
|
3504
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
3505
|
+
finishJob(member, job, "failed");
|
|
3506
|
+
}
|
|
3507
|
+
}, jobStartMs);
|
|
3508
|
+
jobTimers.add(timer);
|
|
3509
|
+
return { id: job.id, automation: job.automation, status: "queued" };
|
|
3510
|
+
});
|
|
3511
|
+
return sendJson(response, 200, answer);
|
|
3512
|
+
},
|
|
3513
|
+
|
|
3514
|
+
"jobs.get": ({ member, response, params }) => {
|
|
3515
|
+
const job = localJob(member, params.job_id);
|
|
3516
|
+
if (!job) throw runtimeError("not_found", "job not found");
|
|
3517
|
+
return sendJson(response, 200, jobView(job));
|
|
3518
|
+
},
|
|
3519
|
+
|
|
3520
|
+
// Only this member's queued jobs can be cancelled; anything else — one
|
|
3521
|
+
// that ran, or one that is not theirs — is the platform's 409.
|
|
3522
|
+
"jobs.cancel": ({ member, response, params }) => {
|
|
3523
|
+
const job = localJob(member, params.job_id);
|
|
3524
|
+
if (job?.status !== "queued") throw runtimeError("conflict", "only queued non-migration jobs can be cancelled");
|
|
3525
|
+
finishJob(member, job, "cancelled");
|
|
3526
|
+
return sendJson(response, 200, { ok: true });
|
|
3527
|
+
},
|
|
3528
|
+
|
|
3529
|
+
"schedules.list": ({ member, response }) => sendJson(response, 200, {
|
|
3530
|
+
schedules: (pkg.manifest.workloads?.schedules ?? []).map((schedule) => ({
|
|
3531
|
+
name: schedule.name,
|
|
3532
|
+
automation: schedule.automation,
|
|
3533
|
+
cron: schedule.cron,
|
|
3534
|
+
enabled: scheduleOverrides.get(`${member}\0${schedule.name}`) ?? schedule.enabled ?? true,
|
|
3535
|
+
next_run_at: nextCronAfter(schedule.cron),
|
|
3536
|
+
last_enqueued_at: null,
|
|
3537
|
+
origin: "declared",
|
|
3538
|
+
})),
|
|
3539
|
+
}),
|
|
3540
|
+
|
|
3541
|
+
"schedules.set_enabled": async (ctx) => {
|
|
3542
|
+
const { member, response, params } = ctx;
|
|
3543
|
+
const name = params.schedule_name;
|
|
3544
|
+
if (!(pkg.manifest.workloads?.schedules ?? []).some((schedule) => schedule.name === name)) {
|
|
3545
|
+
throw runtimeError("not_found", "schedule not found");
|
|
3546
|
+
}
|
|
3547
|
+
const body = await jsonBody(ctx);
|
|
3548
|
+
if (typeof body.enabled !== "boolean") throw runtimeError("bad_request", "enabled must be boolean");
|
|
3549
|
+
scheduleOverrides.set(`${member}\0${name}`, body.enabled);
|
|
3550
|
+
return sendJson(response, 200, { name, enabled: body.enabled });
|
|
3551
|
+
},
|
|
3552
|
+
|
|
3553
|
+
"lifecycle.status": ({ response }) => {
|
|
3554
|
+
const target = pkg.manifest.lifecycle?.data_version ?? 1;
|
|
3555
|
+
return sendJson(response, 200, {
|
|
3556
|
+
installed_data_version: target,
|
|
3557
|
+
target_data_version: target,
|
|
3558
|
+
migrations: (pkg.manifest.lifecycle?.migrations ?? []).map((migration) => ({
|
|
3559
|
+
from: migration.from,
|
|
3560
|
+
to: migration.to,
|
|
3561
|
+
status: "succeeded",
|
|
3562
|
+
error: null,
|
|
3563
|
+
})),
|
|
3564
|
+
});
|
|
3565
|
+
},
|
|
3566
|
+
|
|
3567
|
+
"logs.write": async (ctx) => {
|
|
3568
|
+
const { member, response } = ctx;
|
|
3569
|
+
const body = await jsonBody(ctx);
|
|
3570
|
+
const level = body.level ?? "info";
|
|
3571
|
+
const message = typeof body.message === "string" ? body.message.trim() : "";
|
|
3572
|
+
if (!["debug", "info", "warn", "error"].includes(level)
|
|
3573
|
+
|| !message || [...message].length > LIMITS.log_message_chars) {
|
|
3574
|
+
throw runtimeError("bad_request", "invalid log entry");
|
|
3575
|
+
}
|
|
3576
|
+
if (body.detail !== undefined && Buffer.byteLength(JSON.stringify(body.detail)) > LIMITS.log_detail_bytes) {
|
|
3577
|
+
throw runtimeError("payload_too_large", `a log line's detail is at most ${LIMITS.log_detail_bytes} bytes`);
|
|
3578
|
+
}
|
|
3579
|
+
if (!spend(`logs\n${member}`, LIMITS.logs_per_minute, 60_000)) {
|
|
3580
|
+
throw runtimeError("rate_limited", "browser logging is rate limited");
|
|
3581
|
+
}
|
|
3582
|
+
console.log(`[app:${level}] ${message}`, body.detail ?? "");
|
|
3583
|
+
return sendJson(response, 200, { ok: true });
|
|
3584
|
+
},
|
|
3585
|
+
|
|
3586
|
+
"net.fetch": async ({ response, query }) => {
|
|
3587
|
+
requireCapability("network_proxy", "public-text");
|
|
3588
|
+
const fetched = await fetchPublicPage(query.get("url"), {
|
|
3589
|
+
partial: ["1", "true"].includes(String(query.get("partial") ?? "")),
|
|
3590
|
+
fetchImpl: options.netFetch ?? null,
|
|
3591
|
+
...(options.netTimeoutMs ? { timeoutMs: options.netTimeoutMs } : {}),
|
|
3592
|
+
});
|
|
3593
|
+
return sendJson(response, 200, fetched);
|
|
3594
|
+
},
|
|
3595
|
+
|
|
3596
|
+
"net.image": async ({ response, query }) => {
|
|
3597
|
+
requireCapability("network_proxy", "public-image");
|
|
3598
|
+
const image = await fetchPublicImage(query.get("url"), {
|
|
3599
|
+
fetchImpl: options.netFetch ?? null,
|
|
3600
|
+
...(options.netTimeoutMs ? { timeoutMs: options.netTimeoutMs } : {}),
|
|
3601
|
+
});
|
|
3602
|
+
response.writeHead(200, {
|
|
3603
|
+
"content-type": image.content_type,
|
|
3604
|
+
"content-length": String(image.bytes.length),
|
|
3605
|
+
"cache-control": "private, no-store",
|
|
3606
|
+
"x-content-type-options": "nosniff",
|
|
3607
|
+
"x-terminus-url": image.url,
|
|
3608
|
+
});
|
|
3609
|
+
return response.end(image.bytes);
|
|
3610
|
+
},
|
|
3611
|
+
|
|
3612
|
+
"capabilities.list": (ctx) => capabilityDoor(ctx, []),
|
|
3613
|
+
"capabilities.describe": (ctx) => capabilityDoor(ctx, [ctx.params.id, ctx.params.major]),
|
|
3614
|
+
"capabilities.invoke": (ctx) => capabilityDoor(ctx, [ctx.params.id, ctx.params.major, "operations", ctx.params.operation_id]),
|
|
3615
|
+
"capabilities.asset": (ctx) => capabilityDoor(ctx, [ctx.params.id, ctx.params.major, "assets", ...ctx.params.path.split("/")]),
|
|
3616
|
+
|
|
3617
|
+
"server.call": async (ctx) => {
|
|
3618
|
+
const body = await jsonBody(ctx);
|
|
3619
|
+
return answerServerDoor(serverTier, ctx.member, ctx.params.op, body, ctx.response);
|
|
3620
|
+
},
|
|
3621
|
+
|
|
3622
|
+
// The collect plane, bench-shaped: declared channels accept submissions
|
|
3623
|
+
// and print them. There are no maintainers or consent grants offline, so
|
|
3624
|
+
// nothing is delivered; a submission is taken back inside its undo
|
|
3625
|
+
// window (undone) or after it (retracted), as on the platform.
|
|
3626
|
+
"collect.submit": async (ctx) => {
|
|
3627
|
+
const { member, request, response, params } = ctx;
|
|
3628
|
+
const body = await jsonBody(ctx);
|
|
3629
|
+
if (Buffer.byteLength(JSON.stringify(body.payload ?? null)) > LIMITS.collect_payload_bytes) {
|
|
3630
|
+
throw runtimeError("payload_too_large", `a collect submission is at most ${LIMITS.collect_payload_bytes} bytes`);
|
|
3631
|
+
}
|
|
3632
|
+
// A replayed key answers its first answer before the channel's
|
|
3633
|
+
// declaration or its daily budget is asked about it again.
|
|
3634
|
+
const answer = await once(`collect.submit\n${params.channel}`, member, idempotencyKey(request), body, () => {
|
|
3635
|
+
const channel = declaredCollectChannel(params.channel);
|
|
3636
|
+
if (!spend(`collect\n${member}\n${channel}`, LIMITS.collect_submissions_per_day, 24 * 60 * 60 * 1000)) {
|
|
3637
|
+
throw runtimeError("rate_limited", "the channel's daily submissions are spent");
|
|
3638
|
+
}
|
|
3639
|
+
const id = crypto.randomUUID();
|
|
3640
|
+
const at = Date.now();
|
|
3641
|
+
collectSubmissions.set(id, { member, channel, at });
|
|
3642
|
+
console.log(`[collect:${channel}] submission ${id}:`, JSON.stringify(body.payload ?? null));
|
|
3643
|
+
return { id, deliver_after: new Date(at + COLLECT_UNDO_MS).toISOString(), delivered: false };
|
|
3644
|
+
});
|
|
3645
|
+
return sendJson(response, 200, answer);
|
|
3646
|
+
},
|
|
3647
|
+
|
|
3648
|
+
"collect.retract": ({ member, response, params }) => {
|
|
3649
|
+
const id = uuidParam(params.document_id, "submission");
|
|
3650
|
+
const channel = declaredCollectChannel(params.channel);
|
|
3651
|
+
const submission = collectSubmissions.get(id);
|
|
3652
|
+
if (!submission || submission.member !== member || submission.channel !== channel) {
|
|
3653
|
+
throw runtimeError("not_found", "no such submission of yours to withdraw");
|
|
3654
|
+
}
|
|
3655
|
+
console.log(`[collect:${channel}] retracted ${id}`);
|
|
3656
|
+
if (Date.now() - submission.at < COLLECT_UNDO_MS) {
|
|
3657
|
+
collectSubmissions.delete(id);
|
|
3658
|
+
return sendJson(response, 200, { id, undone: true });
|
|
3659
|
+
}
|
|
3660
|
+
return sendJson(response, 200, { id, retracted: true });
|
|
3661
|
+
},
|
|
3662
|
+
|
|
3663
|
+
"host.icon": async ({ request, response, method }) => {
|
|
3664
|
+
if (!options.iconRemote) throw runtimeError("not_found", "this draft has no platform icon");
|
|
3665
|
+
// The Studio-owned icon of the linked creation, revalidated by ETag.
|
|
3666
|
+
const { login, appId } = options.iconRemote;
|
|
3667
|
+
const upstream = await login.open("GET /v1/apps/{app_id}/icon", {
|
|
3668
|
+
params: { app_id: appId },
|
|
3669
|
+
headers: request.headers["if-none-match"] ? { "if-none-match": request.headers["if-none-match"] } : {},
|
|
3670
|
+
});
|
|
3671
|
+
const headers = {};
|
|
3672
|
+
for (const name of ["content-type", "cache-control", "etag"]) {
|
|
3673
|
+
const value = upstream.headers.get(name);
|
|
3674
|
+
if (value) headers[name] = value;
|
|
3675
|
+
}
|
|
3676
|
+
response.writeHead(upstream.status, headers);
|
|
3677
|
+
if (method === "HEAD" || upstream.status === 304) return response.end();
|
|
3678
|
+
return response.end(Buffer.from(await upstream.arrayBuffer()));
|
|
3679
|
+
},
|
|
3680
|
+
};
|
|
3681
|
+
|
|
3682
|
+
function declaredCollectChannel(channel) {
|
|
3683
|
+
const declared = pkg.manifest.capabilities?.collect?.channels?.[channel];
|
|
3684
|
+
if (!declared || declared.kind !== "documents") {
|
|
3685
|
+
throw runtimeError("forbidden", `the release does not declare collect channel '${channel}'`);
|
|
3686
|
+
}
|
|
3687
|
+
return channel;
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
function capabilityDoor({ member, request, response }, segments) {
|
|
3691
|
+
return serveHorizontalCapability(
|
|
3692
|
+
segments.map((segment) => encodeURIComponent(segment)),
|
|
3693
|
+
request,
|
|
3694
|
+
response,
|
|
3695
|
+
pkg.manifest.capabilities,
|
|
3696
|
+
options.platform,
|
|
3697
|
+
{ guest: member === null },
|
|
3698
|
+
);
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
async function blockDoor({ member, response, params }, blocked) {
|
|
3702
|
+
const space = requireSpace(member, params.space_id);
|
|
3703
|
+
if (space.kind !== "direct") {
|
|
3704
|
+
throw runtimeError("bad_request", "blocking a peer requires a direct conversation");
|
|
3705
|
+
}
|
|
3706
|
+
const peer = space.members.length === 2 ? registry.peer(space, member) : null;
|
|
3707
|
+
if (!peer) throw runtimeError("conflict", "direct conversation does not currently have one peer");
|
|
3708
|
+
registry.setBlocked(member, peer, blocked);
|
|
3709
|
+
registry.save();
|
|
3710
|
+
signalSpace(space.id, PLATFORM, { type: "space_relationship_updated" }, { transient: true });
|
|
3711
|
+
return sendJson(response, 200, { ok: true, peer_user_id: peer });
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
async function recordMutation(ctx, operation) {
|
|
3715
|
+
const { member, request, response, query, params } = ctx;
|
|
3716
|
+
const name = requireManagedName(params.collection, "collection");
|
|
3717
|
+
const id = requireManagedId(params.record_id);
|
|
3718
|
+
const spaceId = query.get("space_id") || null;
|
|
3719
|
+
const declaration = collectionDefinition(name);
|
|
3720
|
+
requireCollectionScope(declaration, member, spaceId);
|
|
3721
|
+
requireSpaceWriter(member, spaceId);
|
|
3722
|
+
const mutationId = idempotencyKey(request, { required: true, door: "a record write" });
|
|
3723
|
+
const expected = optionalInteger(query.get("expected_version"), "expected_version");
|
|
3724
|
+
if (expected !== null && expected < 0) {
|
|
3725
|
+
throw new CliError("expected_version must be zero or a positive object version");
|
|
3726
|
+
}
|
|
3727
|
+
const value = operation === "put" ? await jsonBody(ctx) : undefined;
|
|
3728
|
+
const receipt = commitRecordMutation({ member, spaceId, declaration, name, id, operation, expected, value, mutationId });
|
|
3729
|
+
return sendJson(response, 200, receipt);
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
async function transactionDoor(ctx) {
|
|
3733
|
+
const { member, request, response } = ctx;
|
|
3734
|
+
const transactionId = idempotencyKey(request, { required: true, door: "a record transaction" });
|
|
3735
|
+
const body = await jsonBody(ctx);
|
|
3736
|
+
if (!Array.isArray(body.operations) || !body.operations.length
|
|
3737
|
+
|| body.operations.length > LIMITS.transaction_operations) {
|
|
3738
|
+
throw new CliError(`record transactions contain 1-${LIMITS.transaction_operations} operations`);
|
|
3739
|
+
}
|
|
3740
|
+
const spaceId = typeof body.space_id === "string" && body.space_id ? body.space_id : null;
|
|
3741
|
+
// Rings go to a space's members, so a personal transaction has none.
|
|
3742
|
+
const rings = normalizeWriteRings(body, "transaction");
|
|
3743
|
+
const ringing = Boolean(rings.notify) || rings.mentions.length > 0;
|
|
3744
|
+
if (ringing && !spaceId) throw new CliError("notify and mentions need a space_id");
|
|
3745
|
+
const addresses = new Set();
|
|
3746
|
+
let transactionScope = null;
|
|
3747
|
+
let storeMember = null;
|
|
3748
|
+
let operations = body.operations.map((operation) => {
|
|
3749
|
+
if (!isPlainObject(operation) || !["put", "delete"].includes(operation.action)) {
|
|
3750
|
+
throw new CliError("transaction operations must be put or delete objects");
|
|
3751
|
+
}
|
|
3752
|
+
const collection = requireManagedName(operation.collection, "collection");
|
|
3753
|
+
const recordId = requireManagedId(operation.id);
|
|
3754
|
+
const address = `${collection}\0${recordId}`;
|
|
3755
|
+
if (addresses.has(address)) {
|
|
3756
|
+
throw new CliError(`transaction addresses '${collection}/${recordId}' more than once`);
|
|
3757
|
+
}
|
|
3758
|
+
addresses.add(address);
|
|
3759
|
+
const declaration = collectionDefinition(collection);
|
|
3760
|
+
requireCollectionScope(declaration, member, spaceId);
|
|
3761
|
+
requireSpaceWriter(member, spaceId);
|
|
3762
|
+
const kind = declaration.ownership === "space" ? "space" : (spaceId ? "member" : "personal");
|
|
3763
|
+
const scope = `${kind}:${spaceId || member}`;
|
|
3764
|
+
const owner = managedStoreMember(declaration, member, spaceId);
|
|
3765
|
+
if (transactionScope !== null && (transactionScope !== scope || storeMember !== owner)) {
|
|
3766
|
+
throw new CliError("one transaction cannot cross capsule or ownership scopes");
|
|
3767
|
+
}
|
|
3768
|
+
transactionScope = scope;
|
|
3769
|
+
storeMember = owner;
|
|
3770
|
+
const expectedVersion = operation.expected_version ?? null;
|
|
3771
|
+
if (expectedVersion !== null && (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0)) {
|
|
3772
|
+
throw new CliError("expected_version must be zero or a positive object version");
|
|
3773
|
+
}
|
|
3774
|
+
if (operation.action === "put") validateManagedRecord(declaration, operation.value);
|
|
3775
|
+
return {
|
|
3776
|
+
scopeKey: collectionScopeKey(declaration, spaceId, collection),
|
|
3777
|
+
collection,
|
|
3778
|
+
recordId,
|
|
3779
|
+
operation: operation.action,
|
|
3780
|
+
expectedVersion,
|
|
3781
|
+
...(operation.action === "put" ? { value: operation.value } : {}),
|
|
3782
|
+
declaration,
|
|
3783
|
+
};
|
|
3784
|
+
});
|
|
3785
|
+
// A transaction that rings is refused in a blocked direct conversation,
|
|
3786
|
+
// as a published event or a delivery is; one that rings no one is the
|
|
3787
|
+
// write it always was.
|
|
3788
|
+
if (ringing) registry.ensureConversationActive(spaceId, member);
|
|
3789
|
+
const transactionStore = capsule(storeMember);
|
|
3790
|
+
const requestSha256 = sha256(JSON.stringify(body));
|
|
3791
|
+
// Receipt, then versions, then permissions — the platform's order: a
|
|
3792
|
+
// replayed batch answers its first answer before a write rule that now
|
|
3793
|
+
// denies its updates, or a record that has moved on, is asked about it.
|
|
3794
|
+
const receipt = transactionStore.transactionReceipt(transactionScope, transactionId);
|
|
3795
|
+
if (receipt) {
|
|
3796
|
+
if (receipt.requestSha256 !== requestSha256) {
|
|
3797
|
+
throw runtimeError("idempotency_conflict", "Idempotency-Key was already used for a different transaction");
|
|
3798
|
+
}
|
|
3799
|
+
return sendJson(response, 200, { ...receipt.response, replayed: true });
|
|
3800
|
+
}
|
|
3801
|
+
for (const operation of operations) {
|
|
3802
|
+
requireExpectedVersion(
|
|
3803
|
+
transactionStore.getRecord(operation.scopeKey, operation.collection, operation.recordId),
|
|
3804
|
+
operation.expectedVersion,
|
|
3805
|
+
`${operation.collection}/${operation.recordId}`,
|
|
3806
|
+
);
|
|
3807
|
+
}
|
|
3808
|
+
for (const operation of operations) {
|
|
3809
|
+
const prior = transactionStore.getRecord(operation.scopeKey, operation.collection, operation.recordId);
|
|
3810
|
+
const policyActor = managedPolicyActor(operation.declaration, member, spaceId);
|
|
3811
|
+
if (operation.operation === "put") {
|
|
3812
|
+
authorizeCollection(operation.declaration, prior ? "update" : "create", policyActor, {
|
|
3813
|
+
value: operation.value,
|
|
3814
|
+
created_by_user_id: prior?.created_by_user_id ?? member,
|
|
3815
|
+
updated_by_user_id: member,
|
|
3816
|
+
});
|
|
3817
|
+
authorizeChangedFields(operation.declaration, policyActor, prior?.value, operation.value);
|
|
3818
|
+
enforceWriteRules(operation.declaration, member, prior?.value, operation.value);
|
|
3819
|
+
} else if (prior) {
|
|
3820
|
+
authorizeCollection(operation.declaration, "delete", policyActor, prior);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
operations = validateLocalDeclaredConstraints(transactionStore, member, spaceId, operations);
|
|
3824
|
+
const result = transact(transactionStore, {
|
|
3825
|
+
transactionScope,
|
|
3826
|
+
transactionId,
|
|
3827
|
+
requestSha256,
|
|
3828
|
+
operations,
|
|
3829
|
+
actor: member,
|
|
3830
|
+
});
|
|
3831
|
+
if (result.conflict) throw storeConflict(result);
|
|
3832
|
+
for (const [index, rawChange] of result.changes.entries()) {
|
|
3833
|
+
publishCollectionWake(member, spaceId, operations[index], rawChange);
|
|
3834
|
+
}
|
|
3835
|
+
// Committed with the write: the first commit rings, a replay rings nobody.
|
|
3836
|
+
if (ringing && !result.response.replayed) {
|
|
3837
|
+
const space = registry.find(spaceId);
|
|
3838
|
+
ringSpaceWrite(space, member, [...space.members], rings, {
|
|
3839
|
+
space_id: spaceId,
|
|
3840
|
+
transaction_id: transactionId,
|
|
3841
|
+
});
|
|
3842
|
+
}
|
|
3843
|
+
return sendJson(response, 200, result.response);
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
/** POST /events: an app event (or a batch) to every OTHER member's streams —
|
|
3847
|
+
* never back to any window of its publisher. */
|
|
3848
|
+
async function publishDoor(ctx) {
|
|
3849
|
+
const { member, request, response } = ctx;
|
|
3850
|
+
const body = await jsonBody(ctx);
|
|
3851
|
+
const mentions = body.mentions ?? [];
|
|
3852
|
+
if (!Array.isArray(mentions) || mentions.some((handle) => typeof handle !== "string")) {
|
|
3853
|
+
throw new CliError("mentions must be a list of handles");
|
|
3854
|
+
}
|
|
3855
|
+
if (mentions.length > LIMITS.mentions) throw new CliError(`at most ${LIMITS.mentions} mentions per event`);
|
|
3856
|
+
if (body.transient !== undefined && typeof body.transient !== "boolean") {
|
|
3857
|
+
throw new CliError("transient must be true or false");
|
|
3858
|
+
}
|
|
3859
|
+
const transient = body.transient === true;
|
|
3860
|
+
if (transient && mentions.length) throw new CliError("transient events cannot carry mentions");
|
|
3861
|
+
const notify = body.notify === undefined || body.notify === null ? null : normalizeEventNotify(body.notify);
|
|
3862
|
+
if (transient && notify) throw new CliError("transient events cannot carry notify");
|
|
3863
|
+
const batch = Object.hasOwn(body, "events");
|
|
3864
|
+
if (batch === Object.hasOwn(body, "event")) throw new CliError("provide exactly one of `event` or `events`");
|
|
3865
|
+
if (batch) {
|
|
3866
|
+
if (mentions.length) throw new CliError("event batches cannot carry mentions");
|
|
3867
|
+
if (notify) throw new CliError("event batches cannot carry notify");
|
|
3868
|
+
if (!Array.isArray(body.events) || !body.events.length || body.events.length > LIMITS.event_batch) {
|
|
3869
|
+
throw new CliError(`events batches carry 1..=${LIMITS.event_batch} events`);
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
const spaceId = typeof body.space_id === "string" ? body.space_id : "";
|
|
3873
|
+
if (!spaceId) throw runtimeError("bad_request", "a collaborative space is required");
|
|
3874
|
+
// A replayed key answers its first answer before the space, a block or
|
|
3875
|
+
// the publish budget is asked about it again.
|
|
3876
|
+
const answer = await once("events.publish", member, idempotencyKey(request), body, () => {
|
|
3877
|
+
const space = requireSpace(member, spaceId);
|
|
3878
|
+
registry.ensureConversationActive(spaceId, member);
|
|
3879
|
+
const role = registry.memberRole(spaceId, member);
|
|
3880
|
+
const publishOne = (event) => {
|
|
3881
|
+
if (Buffer.byteLength(JSON.stringify(event ?? null)) > LIMITS.event_payload_bytes) {
|
|
3882
|
+
return { error: "payload_too_large" };
|
|
3883
|
+
}
|
|
3884
|
+
const budget = transient ? LIMITS.publish_transient_per_minute : LIMITS.publish_durable_per_minute;
|
|
3885
|
+
if (!spend(`publish\n${member}\n${spaceId}\n${transient}`, budget, 60_000)) {
|
|
3886
|
+
return { error: "rate_limited" };
|
|
3887
|
+
}
|
|
3888
|
+
bus.deliver(
|
|
3889
|
+
space.members,
|
|
3890
|
+
bus.envelope("space_event", spaceId, memberOrigin(member, role), event ?? {}, transient),
|
|
3891
|
+
{ except: member },
|
|
3892
|
+
);
|
|
3893
|
+
return { ok: true, transient };
|
|
3894
|
+
};
|
|
3895
|
+
if (batch) return { results: body.events.map(publishOne) };
|
|
3896
|
+
const result = publishOne(body.event);
|
|
3897
|
+
if (result.error === "payload_too_large") {
|
|
3898
|
+
throw runtimeError("payload_too_large", `an event payload is at most ${LIMITS.event_payload_bytes} bytes`);
|
|
3899
|
+
}
|
|
3900
|
+
if (result.error === "rate_limited") throw runtimeError("rate_limited", "event publishing is rate limited");
|
|
3901
|
+
// Mention fan-out, production-contained: current members only, never
|
|
3902
|
+
// the actor, each once; unknown handles drop silently. An event's row
|
|
3903
|
+
// previews the event's own `preview` and opens nowhere in particular.
|
|
3904
|
+
const mentioned = mentionTargets(space.members, mentions, member);
|
|
3905
|
+
const preview = body.event?.preview;
|
|
3906
|
+
ringMentions(space, member, mentioned, {
|
|
3907
|
+
body: firstChars(typeof preview === "string" ? preview : "", MENTION_PREVIEW_CHARS),
|
|
3908
|
+
route: null,
|
|
3909
|
+
data: { space_id: spaceId },
|
|
3910
|
+
});
|
|
3911
|
+
if (notify) {
|
|
3912
|
+
notifyAbsentMembers(space, member, notify, { kind: "message", space_id: spaceId }, new Set(mentioned));
|
|
3913
|
+
}
|
|
3914
|
+
return result;
|
|
3915
|
+
});
|
|
3916
|
+
return sendJson(response, 200, answer);
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3919
|
+
/** GET /events: the member's account stream. stream_open first; after that
|
|
3920
|
+
* only what happens from now on — nothing is replayed. A stream is its
|
|
3921
|
+
* port's, and ends when who that port is changes (endSeatStreams). */
|
|
3922
|
+
function streamDoor({ member, seat, request, response, query }) {
|
|
3923
|
+
response.writeHead(200, {
|
|
3924
|
+
"content-type": "text/event-stream",
|
|
3925
|
+
"cache-control": "no-store",
|
|
3926
|
+
connection: "keep-alive",
|
|
3927
|
+
});
|
|
3928
|
+
response.write(streamOpening());
|
|
3929
|
+
const rooms = String(query.get("rooms") ?? "").split(",").map((room) => room.trim()).filter(Boolean);
|
|
3930
|
+
const joined = !bus.hasStream(member);
|
|
3931
|
+
const stream = { member, seat, res: response, rooms: rooms.length ? new Set(rooms) : null };
|
|
3932
|
+
bus.streams.add(stream);
|
|
3933
|
+
// A member's first live window: co-members' streams hear they came
|
|
3934
|
+
// online. Their last one closing is the leave those streams hear.
|
|
3935
|
+
request.on("close", () => {
|
|
3936
|
+
if (bus.streams.delete(stream) && !bus.hasStream(member)) announcePresenceGone(member);
|
|
3937
|
+
});
|
|
3938
|
+
if (joined) bus.deliver(coMembers(member), presenceFrame(null, member));
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
async function handleRuntime(seat, url, request, response) {
|
|
3942
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
3943
|
+
const doorPath = url.pathname.slice("/_terminus".length);
|
|
3944
|
+
// The guest's sign-in page posts its form back to the sign-in door, which
|
|
3945
|
+
// the contract — a navigation — answers only to GET: the form is the
|
|
3946
|
+
// harness's own, and only the guest's port shows it.
|
|
3947
|
+
if (seat.guest && method === "POST" && doorPath === SIGN_IN_PATH) {
|
|
3948
|
+
return pickSignIn(seat, request, response);
|
|
3949
|
+
}
|
|
3950
|
+
const match = matchDoor(method, doorPath);
|
|
3951
|
+
if (!match) throw runtimeError("not_found", `no door ${url.pathname}`);
|
|
3952
|
+
if (!match.door) {
|
|
3953
|
+
response.setHeader("allow", match.methods.join(", "));
|
|
3954
|
+
throw runtimeError("method_not_allowed", `${url.pathname} does not answer ${method}`);
|
|
3955
|
+
}
|
|
3956
|
+
const { door, params } = match;
|
|
3957
|
+
if (door.id === "host.logout") return signOut(seat, response);
|
|
3958
|
+
if (door.id === "host.signin") return signIn(seat, url, response);
|
|
3959
|
+
const member = seat.member;
|
|
3960
|
+
// A guest reaches what the contract lets a guest reach — the bootstrap,
|
|
3961
|
+
// the app's declared capability assets, the host's own doors — and every
|
|
3962
|
+
// other door refuses them before it looks at anything, as the app host
|
|
3963
|
+
// refuses a visitor who has no session.
|
|
3964
|
+
if (member === null && !door.guest_allowed) {
|
|
3965
|
+
throw runtimeError(APP_HOST.guest.refusal.code, GUEST_REFUSAL);
|
|
3966
|
+
}
|
|
3967
|
+
// Signed out, the member's windows send no session at all — the app
|
|
3968
|
+
// host's sign-out door removed its cookie — and a request without one is
|
|
3969
|
+
// unauthorized, as the app host and `dev --remote` answer it
|
|
3970
|
+
// (session_ended is a session the platform recognizes as revoked or
|
|
3971
|
+
// expired, and the SDK's own answer on the device that signed out).
|
|
3972
|
+
if (door.auth === "app_session" && seat.signedOut) {
|
|
3973
|
+
throw runtimeError("unauthorized", "App session required: open the app again to sign in");
|
|
3974
|
+
}
|
|
3975
|
+
const handler = HANDLERS[door.id];
|
|
3976
|
+
if (door.hosts.dev === "unsupported" || !handler) {
|
|
3977
|
+
throw runtimeError(
|
|
3978
|
+
"unsupported_in_dev",
|
|
3979
|
+
`terminus dev cannot serve ${door.method} /_terminus${door.path}; `
|
|
3980
|
+
+ "exercise it against a real host (terminus dev --remote) and mock it in app code here",
|
|
3981
|
+
);
|
|
3982
|
+
}
|
|
3983
|
+
return handler({ member, seat, request, response, url, query: url.searchParams, params, door, method });
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
/* ── who a port is: sign-in and sign-out ─────────────────────────────── */
|
|
3987
|
+
|
|
3988
|
+
/** The member signed in on `seat`, or the guest's refusal: the harness's
|
|
3989
|
+
* own controls are a person's, and a guest is nobody yet. */
|
|
3990
|
+
function signedInMember(seat) {
|
|
3991
|
+
if (seat.member === null) throw runtimeError(APP_HOST.guest.refusal.code, GUEST_REFUSAL);
|
|
3992
|
+
return seat.member;
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
/** Every stream a port opened ends when who that port is changes — a
|
|
3996
|
+
* sign-out, or someone signing in on the guest's port. A stream is for the
|
|
3997
|
+
* member it opened as: left open, a window that is somebody else now would
|
|
3998
|
+
* go on hearing the last person's frames. Its client reconnects as whoever
|
|
3999
|
+
* the port is now, or is refused. */
|
|
4000
|
+
function endSeatStreams(seat) {
|
|
4001
|
+
for (const stream of [...bus.streams]) {
|
|
4002
|
+
if (stream.seat !== seat) continue;
|
|
4003
|
+
bus.streams.delete(stream);
|
|
4004
|
+
stream.res.end();
|
|
4005
|
+
if (!stream.corner && !bus.hasStream(stream.member)) announcePresenceGone(stream.member);
|
|
4006
|
+
}
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
/** `/_terminus/logout`. Production revokes the session, clears its cookie
|
|
4010
|
+
* and sends the browser to sign in again; until the person does, every
|
|
4011
|
+
* door answers 401. Here the identity is the port, so the port forgets who
|
|
4012
|
+
* signed in on it: a member's port is signed out — the app itself stands
|
|
4013
|
+
* in for the sign-in page, and opening it again (what the desk does when
|
|
4014
|
+
* it opens a window) signs them back in — and the guest's port is a guest
|
|
4015
|
+
* again. */
|
|
4016
|
+
function signOut(seat, response) {
|
|
4017
|
+
if (seat.guest ? seat.member !== null : !seat.signedOut) endSeatStreams(seat);
|
|
4018
|
+
if (seat.guest) seat.member = null;
|
|
4019
|
+
else seat.signedOut = true;
|
|
4020
|
+
response.writeHead(303, { location: "/" });
|
|
4021
|
+
return response.end();
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
/** `/_terminus/signin?return_to=<path>` (`session.signIn()`): sign in, then
|
|
4025
|
+
* come back to the place. A port somebody is on goes straight back — a
|
|
4026
|
+
* member's signed-out port is signed in again, as opening the app does —
|
|
4027
|
+
* and the guest's port gets the page that picks who signs in. */
|
|
4028
|
+
function signIn(seat, url, response) {
|
|
4029
|
+
const place = returnToPath(url.searchParams.get("return_to"));
|
|
4030
|
+
if (seat.member === null) return answerSignInPicker(place, response);
|
|
4031
|
+
seat.signedOut = false;
|
|
4032
|
+
response.writeHead(303, { location: place });
|
|
4033
|
+
return response.end();
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
/** The sign-in page's form: the guest's port becomes the member picked,
|
|
4037
|
+
* then goes to the place. Only a same-origin POST does it — a link that
|
|
4038
|
+
* signs a port in (`?member=`) would be the deleted `?as=` shim again — and
|
|
4039
|
+
* the origin stays the port's, so what the app kept for the guest in this
|
|
4040
|
+
* browser is still there to move into the account. */
|
|
4041
|
+
async function pickSignIn(seat, request, response) {
|
|
4042
|
+
if (!isSameOrigin(request)) {
|
|
4043
|
+
await readBodyBuffer(request, SIGN_IN_FORM_BYTES).catch(() => undefined);
|
|
4044
|
+
throw runtimeError("forbidden", "the sign-in form is answered only for this app's own pages");
|
|
4045
|
+
}
|
|
4046
|
+
const type = String(request.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
4047
|
+
const body = await readBodyBuffer(request, SIGN_IN_FORM_BYTES);
|
|
4048
|
+
if (type !== "application/x-www-form-urlencoded") {
|
|
4049
|
+
throw runtimeError("bad_request", "the sign-in form posts application/x-www-form-urlencoded");
|
|
4050
|
+
}
|
|
4051
|
+
const form = new URLSearchParams(body.toString("utf8"));
|
|
4052
|
+
const place = returnToPath(form.get("return_to"));
|
|
4053
|
+
// Signed in already (another tab of the page got there first) is signed
|
|
4054
|
+
// in, as the platform's sign-in treats a live session: straight back.
|
|
4055
|
+
if (seat.member === null) {
|
|
4056
|
+
const picked = form.get("member");
|
|
4057
|
+
if (!picked) throw runtimeError("bad_request", "pick the member to sign in as");
|
|
4058
|
+
const member = knownPerson(picked);
|
|
4059
|
+
endSeatStreams(seat);
|
|
4060
|
+
seat.member = member;
|
|
4061
|
+
}
|
|
4062
|
+
response.writeHead(303, { location: place });
|
|
4063
|
+
return response.end();
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
/** The guest's sign-in page. On Terminus a guest signs in, or makes an
|
|
4067
|
+
* account, on the platform's own page; here the people are the members, so
|
|
4068
|
+
* the page asks which of them this is. It draws each as a monogram — an
|
|
4069
|
+
* avatar is a door a guest may not open — and loads nothing at all: no
|
|
4070
|
+
* script, no image, nothing from anywhere else. */
|
|
4071
|
+
function answerSignInPicker(place, response) {
|
|
4072
|
+
const people = members.map((handle) => {
|
|
4073
|
+
const face = monogram(handle);
|
|
4074
|
+
return `<li><button type="submit" name="member" value="${escapeHtml(handle)}">`
|
|
4075
|
+
+ `<span class="face hue-${face.hue}" aria-hidden="true">${escapeHtml(face.initials)}</span>`
|
|
4076
|
+
+ `<span class="who"><span class="name">${escapeHtml(profile(handle).name)}</span>`
|
|
4077
|
+
+ `<span class="handle">@${escapeHtml(handle)}</span></span></button></li>`;
|
|
4078
|
+
}).join("");
|
|
4079
|
+
const hues = MONOGRAM_HUES.map((hue, index) => `.hue-${index}{--hue:${hue}}`).join("");
|
|
4080
|
+
response.writeHead(200, {
|
|
4081
|
+
"cache-control": "no-store",
|
|
4082
|
+
"content-type": "text/html; charset=utf-8",
|
|
4083
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; "
|
|
4084
|
+
+ "base-uri 'none'; frame-ancestors 'none'",
|
|
4085
|
+
"x-content-type-options": "nosniff",
|
|
4086
|
+
});
|
|
4087
|
+
return response.end(
|
|
4088
|
+
`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">`
|
|
4089
|
+
+ `<title>Sign in · ${escapeHtml(appSlug)}</title>`
|
|
4090
|
+
+ `<style>body{margin:0;background:#f7f7f3;color:#20211f;font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}`
|
|
4091
|
+
+ `main{max-width:26rem;margin:12vh auto;padding:0 1.5rem}`
|
|
4092
|
+
+ `.tag{margin:0 0 .75rem;color:#696b65;font-size:12px;letter-spacing:.04em;text-transform:uppercase}`
|
|
4093
|
+
+ `h1{font:600 1.7rem/1.15 Georgia,serif;margin:0 0 .5rem}.lede{color:#4a4b47;margin:0 0 1.5rem}`
|
|
4094
|
+
+ `ul{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}`
|
|
4095
|
+
+ `button{display:flex;align-items:center;gap:.75rem;width:100%;padding:.6rem .75rem;`
|
|
4096
|
+
+ `border:1px solid #dcdcd4;border-radius:12px;background:#fff;color:inherit;font:inherit;text-align:left;cursor:pointer}`
|
|
4097
|
+
+ `button:hover,button:focus-visible{border-color:#2d68d8;outline:none;box-shadow:0 0 0 3px rgb(45 104 216/.15)}`
|
|
4098
|
+
+ `.face{flex:none;display:grid;place-items:center;width:2.25rem;height:2.25rem;border-radius:50%;`
|
|
4099
|
+
+ `color:#fff;font-weight:600;font-size:.85rem;`
|
|
4100
|
+
+ `background:linear-gradient(180deg,hsl(var(--hue) 44% 69%),hsl(var(--hue) 42% 53%))}${hues}`
|
|
4101
|
+
+ `.who{display:grid}.name{font-weight:600}.handle{color:#696b65;font-size:13px}`
|
|
4102
|
+
+ `.back{display:inline-block;margin-top:1.25rem;color:#2d68d8;text-decoration:none}`
|
|
4103
|
+
+ `.back:hover{text-decoration:underline}</style>`
|
|
4104
|
+
+ `<main><p class="tag">terminus dev</p><h1>Sign in to ${escapeHtml(appSlug)}</h1>`
|
|
4105
|
+
+ `<p class="lede">You are a guest here: nobody is signed in on this port. On Terminus this is where `
|
|
4106
|
+
+ `a guest signs in or makes an account; here, pick who signs in.</p>`
|
|
4107
|
+
+ `<form method="post" action="/_terminus${SIGN_IN_PATH}">`
|
|
4108
|
+
+ `<input type="hidden" name="return_to" value="${escapeHtml(place)}"><ul>${people}</ul></form>`
|
|
4109
|
+
+ `<a class="back" href="${escapeHtml(place)}">Stay a guest</a></main></html>`,
|
|
4110
|
+
);
|
|
4111
|
+
}
|
|
4112
|
+
|
|
4113
|
+
const portByMember = new Map();
|
|
4114
|
+
|
|
4115
|
+
/** The harness's own invitation control: a person's pending invitations,
|
|
4116
|
+
* and their answer to one. On Terminus this is the account's Notification
|
|
4117
|
+
* Center, which an app never draws. */
|
|
4118
|
+
async function handleDevRequests(member, url, request, response) {
|
|
4119
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
4120
|
+
const rest = url.pathname.replace(/^\/__terminus_dev\/requests\/?/u, "").split("/").filter(Boolean);
|
|
4121
|
+
if (rest.length === 0 && method === "GET") {
|
|
4122
|
+
return sendJson(response, 200, {
|
|
4123
|
+
requests: registry.invitations
|
|
4124
|
+
.filter((invitation) => invitation.invitee === member
|
|
4125
|
+
&& invitation.status === "pending"
|
|
4126
|
+
&& Date.parse(invitation.expiresAt) > Date.now())
|
|
4127
|
+
.map((invitation) => {
|
|
4128
|
+
const space = registry.find(invitation.spaceId);
|
|
4129
|
+
return {
|
|
4130
|
+
id: invitation.id,
|
|
4131
|
+
space_id: invitation.spaceId,
|
|
4132
|
+
space_name: space?.name ?? "Space",
|
|
4133
|
+
kind: space?.kind ?? "group",
|
|
4134
|
+
title: invitationCopy(space, invitation.inviter).title,
|
|
4135
|
+
inviter: publicIdentity(invitation.inviter),
|
|
4136
|
+
// Who else is already in the room: a group invitation is a
|
|
4137
|
+
// question about the people in it.
|
|
4138
|
+
members: (space?.members ?? []).map((handle) => publicIdentity(handle)),
|
|
4139
|
+
created_at: invitation.createdAt,
|
|
4140
|
+
expires_at: invitation.expiresAt,
|
|
4141
|
+
};
|
|
4142
|
+
}),
|
|
4143
|
+
});
|
|
4144
|
+
}
|
|
4145
|
+
if (rest.length === 2 && method === "POST" && ["accept", "decline"].includes(rest[1])) {
|
|
4146
|
+
const invitationId = decodeURIComponent(rest[0]);
|
|
4147
|
+
const status = rest[1] === "accept" ? "accepted" : "declined";
|
|
4148
|
+
const { invitation, space } = registry.resolveInvitation(invitationId, member, status);
|
|
4149
|
+
registry.save();
|
|
4150
|
+
for (const notice of notifications) {
|
|
4151
|
+
if (notice.member === member && notice.data?.invitation_id === invitationId && !notice.read_at) {
|
|
4152
|
+
notice.read_at = new Date().toISOString();
|
|
4153
|
+
}
|
|
4154
|
+
}
|
|
4155
|
+
accountEvent(member, { type: "space_invitation_resolved", space_id: invitation.spaceId });
|
|
4156
|
+
if (status === "accepted") {
|
|
4157
|
+
accountEvent(member, { type: "space_membership_joined", space_id: invitation.spaceId });
|
|
4158
|
+
signalSpace(invitation.spaceId, PLATFORM, { type: "space_members_updated" }, { transient: true });
|
|
4159
|
+
} else {
|
|
4160
|
+
signalSpace(invitation.spaceId, PLATFORM, { type: "space_invitations_updated" }, { transient: true });
|
|
4161
|
+
}
|
|
4162
|
+
return sendJson(response, 200, {
|
|
4163
|
+
ok: true,
|
|
4164
|
+
status,
|
|
4165
|
+
space: { id: space.id, name: space.name },
|
|
4166
|
+
});
|
|
4167
|
+
}
|
|
4168
|
+
throw runtimeError("not_found", "unknown local request-control route");
|
|
4169
|
+
}
|
|
4170
|
+
|
|
4171
|
+
/** The notification corner's stream: the member's account frames, as the
|
|
4172
|
+
* desk hears them on Terminus — stream_open, then only what happens from
|
|
4173
|
+
* now on. It is not an app's stream, so it never counts as the member being
|
|
4174
|
+
* online, and no presence frame announces it. */
|
|
4175
|
+
function answerCornerStream(seat, request, response) {
|
|
4176
|
+
const member = signedInMember(seat);
|
|
4177
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
4178
|
+
if (method !== "GET") {
|
|
4179
|
+
response.setHeader("allow", "GET");
|
|
4180
|
+
throw runtimeError("method_not_allowed", "the notification corner's stream is read with GET");
|
|
4181
|
+
}
|
|
4182
|
+
response.writeHead(200, {
|
|
4183
|
+
"content-type": "text/event-stream",
|
|
4184
|
+
"cache-control": "no-store",
|
|
4185
|
+
connection: "keep-alive",
|
|
4186
|
+
});
|
|
4187
|
+
response.write(streamOpening());
|
|
4188
|
+
const stream = { member, seat, res: response, rooms: null, corner: true };
|
|
4189
|
+
bus.streams.add(stream);
|
|
4190
|
+
request.on("close", () => bus.streams.delete(stream));
|
|
4191
|
+
}
|
|
4192
|
+
|
|
4193
|
+
/** A local member's public page: where users.profile sends a browser. */
|
|
4194
|
+
function answerDevProfile(url, response) {
|
|
4195
|
+
const handle = decodeURIComponent(url.pathname.slice(DEV_PROFILE_PREFIX.length)).toLowerCase();
|
|
4196
|
+
if (!members.includes(handle)) throw runtimeError("not_found", `no user with handle @${handle}`);
|
|
4197
|
+
const fixture = profile(handle);
|
|
4198
|
+
const identity = publicIdentity(handle);
|
|
4199
|
+
const avatar = identity.avatar_url
|
|
4200
|
+
? `<img src="${escapeHtml(identity.avatar_url)}" alt="">`
|
|
4201
|
+
: `<span aria-hidden="true">${escapeHtml([...fixture.name][0]?.toUpperCase() ?? "?")}</span>`;
|
|
4202
|
+
response.writeHead(200, {
|
|
4203
|
+
"content-type": "text/html; charset=utf-8",
|
|
4204
|
+
"content-security-policy": "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'",
|
|
4205
|
+
});
|
|
4206
|
+
return response.end(
|
|
4207
|
+
`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">`
|
|
4208
|
+
+ `<title>${escapeHtml(fixture.name)} · Local Terminus profile</title>`
|
|
4209
|
+
+ `<style>body{margin:0;background:#f7f7f3;color:#20211f;font:15px/1.6 system-ui,sans-serif}`
|
|
4210
|
+
+ `main{max-width:38rem;margin:12vh auto;padding:0 1.5rem}.avatar{width:5rem;height:5rem;border-radius:50%;`
|
|
4211
|
+
+ `display:grid;place-items:center;overflow:hidden;background:#dedfd8;font:600 2rem serif}`
|
|
4212
|
+
+ `.avatar img{width:100%;height:100%;object-fit:cover}h1{font:600 2.2rem/1.1 Georgia,serif;margin:1rem 0 .2rem}`
|
|
4213
|
+
+ `.handle{color:#696b65}.bio{white-space:pre-wrap;margin-top:1.5rem;max-width:34rem}</style>`
|
|
4214
|
+
+ `<main><div class="avatar">${avatar}</div><h1>${escapeHtml(fixture.name)}</h1>`
|
|
4215
|
+
+ `<div class="handle">@${escapeHtml(handle)}</div>`
|
|
4216
|
+
+ (fixture.bio ? `<p class="bio">${escapeHtml(fixture.bio)}</p>` : "")
|
|
4217
|
+
+ `</main></html>`,
|
|
4218
|
+
);
|
|
4219
|
+
}
|
|
4220
|
+
|
|
4221
|
+
/** One request to `seat`'s port, answered as whoever that port is now. */
|
|
4222
|
+
async function handleRequest(seat, request, response) {
|
|
4223
|
+
// The runtime brokers capabilities billed to the developer, and no page
|
|
4224
|
+
// but one addressed to localhost may reach it (see isLoopbackHost).
|
|
4225
|
+
if (!isLoopbackHost(request)) return refuseForeignHost(request, response);
|
|
4226
|
+
const url = new URL(request.url ?? "/", `http://localhost:${request.socket.localPort}`);
|
|
4227
|
+
// The runtime and the harness's own controls answer only pages on this
|
|
4228
|
+
// machine (see isLoopbackOrigin); the app's pages stay open to a link
|
|
4229
|
+
// from anywhere.
|
|
4230
|
+
const runtime = url.pathname === "/_terminus" || url.pathname.startsWith("/_terminus/");
|
|
4231
|
+
if ((runtime || url.pathname.startsWith("/__terminus_dev/")) && !isLoopbackOrigin(request)) {
|
|
4232
|
+
return refuseCrossSite(response);
|
|
4233
|
+
}
|
|
4234
|
+
try {
|
|
4235
|
+
if (url.pathname === "/__terminus_dev/requests" || url.pathname.startsWith("/__terminus_dev/requests/")) {
|
|
4236
|
+
return await handleDevRequests(signedInMember(seat), url, request, response);
|
|
4237
|
+
}
|
|
4238
|
+
if (url.pathname === DEV_ABOUT_PATH) {
|
|
4239
|
+
return answerDevAbout(request, response, {
|
|
4240
|
+
kind: "app",
|
|
4241
|
+
app: pkg.id ?? pkg.manifest.slug,
|
|
4242
|
+
directory: path.resolve(dir),
|
|
4243
|
+
member: seat.member,
|
|
4244
|
+
ports: Object.fromEntries(portByMember),
|
|
4245
|
+
...(guestSeat ? { guest_port: guestSeat.port } : {}),
|
|
4246
|
+
});
|
|
4247
|
+
}
|
|
4248
|
+
if (url.pathname.startsWith(DEV_PROFILE_PREFIX)) return answerDevProfile(url, response);
|
|
4249
|
+
if (url.pathname === DEV_NOTIFICATION_STREAM_PATH) return answerCornerStream(seat, request, response);
|
|
4250
|
+
if (url.pathname === DEV_NOTIFICATION_POPUP_PATH) {
|
|
4251
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
4252
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
4253
|
+
response.setHeader("allow", "GET, HEAD");
|
|
4254
|
+
throw runtimeError("method_not_allowed", "the notification corner is a script to load");
|
|
4255
|
+
}
|
|
4256
|
+
response.writeHead(200, {
|
|
4257
|
+
"cache-control": "no-store",
|
|
4258
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
4259
|
+
});
|
|
4260
|
+
return response.end(method === "HEAD" ? undefined : DEV_NOTIFICATION_POPUP_SCRIPT);
|
|
4261
|
+
}
|
|
4262
|
+
if (runtime) return await handleRuntime(seat, url, request, response);
|
|
4263
|
+
// Opening the app — a page, not a script or an image — signs a member
|
|
4264
|
+
// who signed out back in. The guest's port has nobody to sign back in:
|
|
4265
|
+
// a guest signs in through the app (session.signIn()).
|
|
4266
|
+
if (seat.signedOut && String(request.headers.accept ?? "").includes("text/html")) {
|
|
4267
|
+
seat.signedOut = false;
|
|
4268
|
+
}
|
|
4269
|
+
await serveUiAsset(pkg, uiDir, url, request, response, {
|
|
4270
|
+
// The notification corner is a person's account: a guest has none.
|
|
4271
|
+
transformHtml: seat.member === null ? undefined : injectDevNotificationPopup,
|
|
4272
|
+
});
|
|
4273
|
+
} catch (error) {
|
|
4274
|
+
if (response.headersSent) return response.end();
|
|
4275
|
+
sendRuntimeError(response, error);
|
|
4276
|
+
}
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
const servers = [];
|
|
4280
|
+
try {
|
|
4281
|
+
// A server per seat, and the seat is fixed when the server is made: who
|
|
4282
|
+
// a request is never depends on looking its port up — nor on a fallback
|
|
4283
|
+
// that could answer a guest's port as somebody.
|
|
4284
|
+
for (const [index, seat] of seats.entries()) {
|
|
4285
|
+
const server = createServer((request, response) => handleRequest(seat, request, response));
|
|
4286
|
+
const requested = basePort === 0 ? 0 : basePort + index;
|
|
4287
|
+
await listenDevServer(server, requested);
|
|
4288
|
+
seat.port = server.address().port;
|
|
4289
|
+
if (!seat.guest) portByMember.set(seat.member, seat.port);
|
|
4290
|
+
servers.push(server);
|
|
4291
|
+
}
|
|
4292
|
+
} catch (error) {
|
|
4293
|
+
clearInterval(compactor);
|
|
4294
|
+
await Promise.all(servers.map(closeDevServer));
|
|
4295
|
+
await serverTier.close();
|
|
4296
|
+
for (const store of capsules.values()) store.close();
|
|
4297
|
+
systemStore.close();
|
|
4298
|
+
if (error?.code === "EADDRINUSE" && basePort !== 0) {
|
|
4299
|
+
const port = basePort + servers.length;
|
|
4300
|
+
throw await devPortUnavailableError({
|
|
4301
|
+
basePort,
|
|
4302
|
+
commandArgs: options.commandArgs,
|
|
4303
|
+
count: seats.length,
|
|
4304
|
+
directory: dir,
|
|
4305
|
+
guest,
|
|
4306
|
+
members,
|
|
4307
|
+
failures: [{ port, error }],
|
|
4308
|
+
viteProxy: true,
|
|
4309
|
+
});
|
|
4310
|
+
}
|
|
4311
|
+
throw error;
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4314
|
+
return {
|
|
4315
|
+
manifest: pkg.manifest,
|
|
4316
|
+
hasUi: pkg.hasUi,
|
|
4317
|
+
uiDirectory: pkg.uiDirectory,
|
|
4318
|
+
rootDir,
|
|
4319
|
+
profilesFile: options.profiles ? path.resolve(options.profiles) : null,
|
|
4320
|
+
ports: portByMember,
|
|
4321
|
+
/** The guest's port (--guest), after the members'; null without one. */
|
|
4322
|
+
guestPort: guestSeat?.port ?? null,
|
|
4323
|
+
/** Per-member capsule stores (tests use them to age the change window). */
|
|
4324
|
+
capsules,
|
|
4325
|
+
/** The doors this harness serves, by door id (a test holds it to doors.json). */
|
|
4326
|
+
doors: Object.keys(HANDLERS),
|
|
4327
|
+
async close() {
|
|
4328
|
+
clearInterval(compactor);
|
|
4329
|
+
for (const timer of jobTimers) clearTimeout(timer);
|
|
4330
|
+
for (const stream of bus.streams) stream.res.end();
|
|
4331
|
+
bus.streams.clear();
|
|
4332
|
+
await Promise.all(servers.map(closeDevServer));
|
|
4333
|
+
await serverTier.close();
|
|
4334
|
+
for (const store of capsules.values()) store.close();
|
|
4335
|
+
systemStore.close();
|
|
4336
|
+
},
|
|
4337
|
+
};
|
|
4338
|
+
}
|
|
4339
|
+
|
|
4340
|
+
export async function devCommand(args) {
|
|
4341
|
+
const flags = parseFlags(args, "dev");
|
|
4342
|
+
const dir = path.resolve(flags._[0] ?? ".");
|
|
4343
|
+
// Agents get the browser dev (the VM-free draft dev, seeded by a push
|
|
4344
|
+
// of the local folder; no terminal REPL — decision of
|
|
4345
|
+
// 2026-08-31), not the browser-app fixture harness.
|
|
4346
|
+
const kind = await packageKind(dir);
|
|
4347
|
+
if (kind === "agent") {
|
|
4348
|
+
const { agentDevCommand } = await import("./agentdev.mjs");
|
|
4349
|
+
return agentDevCommand(dir, flags, args);
|
|
4350
|
+
}
|
|
4351
|
+
// Services get a local test session against the deployed endpoint: the
|
|
4352
|
+
// package's own dev/ page on a local port, invocations relayed through the
|
|
4353
|
+
// platform's draft-test lane.
|
|
4354
|
+
if (kind === "service") {
|
|
4355
|
+
const { serviceDevCommand } = await import("./servicedev.mjs");
|
|
4356
|
+
return serviceDevCommand(dir, flags, args);
|
|
4357
|
+
}
|
|
4358
|
+
const pkg = await readDevPackage(dir);
|
|
4359
|
+
requireBuiltDevBundle(pkg);
|
|
4360
|
+
if (flags.remote) return remoteDevCommand(dir, flags, pkg, args);
|
|
4361
|
+
let iconRemote = null;
|
|
4362
|
+
let platform = null;
|
|
4363
|
+
try {
|
|
4364
|
+
const api = await connect(flags);
|
|
4365
|
+
platform = { apiBase: api.base, token: api.token };
|
|
4366
|
+
const linked = pkg.id ? await resolveCreation(api, pkg.id) : null;
|
|
4367
|
+
if (linked) iconRemote = { login: api, appId: linked.id };
|
|
4368
|
+
try {
|
|
4369
|
+
// A capability dev session makes the platform's brokered operations
|
|
4370
|
+
// (web search, geocoding, provider tokens) work against this local
|
|
4371
|
+
// folder exactly as they would for the published app — gated by the
|
|
4372
|
+
// locally compiled declarations, spending the developer's own budgets
|
|
4373
|
+
// and credits.
|
|
4374
|
+
const dev = await api.json("POST /v1/app-runtime/dev-sessions", {
|
|
4375
|
+
body: {
|
|
4376
|
+
app_id: linked?.id ?? null,
|
|
4377
|
+
app_ref: pkg.id ?? `@local/${pkg.manifest.slug}`,
|
|
4378
|
+
capabilities: pkg.manifest.capabilities ?? {},
|
|
4379
|
+
},
|
|
4380
|
+
});
|
|
4381
|
+
if (dev?.token) platform = { apiBase: api.base, token: dev.token, artifactContext: "app" };
|
|
4382
|
+
} catch {
|
|
4383
|
+
// Without a dev session, the pure capability lanes still answer under
|
|
4384
|
+
// the login bearer.
|
|
4385
|
+
}
|
|
4386
|
+
} catch {
|
|
4387
|
+
// Local runtime/data development remains available offline. Once logged
|
|
4388
|
+
// in, restarting the harness reconnects the platform capability plane
|
|
4389
|
+
// and the Studio-owned icon.
|
|
4390
|
+
}
|
|
4391
|
+
const harness = await startDevServer(dir, {
|
|
4392
|
+
commandArgs: args,
|
|
4393
|
+
fresh: Boolean(flags.fresh),
|
|
4394
|
+
guest: Boolean(flags.guest),
|
|
4395
|
+
members: flags.members,
|
|
4396
|
+
port: flags.port,
|
|
4397
|
+
profiles: flags.profiles,
|
|
4398
|
+
iconRemote,
|
|
4399
|
+
platform,
|
|
4400
|
+
pkg,
|
|
4401
|
+
});
|
|
4402
|
+
console.log(`terminus dev — ${pkg.id ?? harness.manifest.slug}`);
|
|
4403
|
+
console.log(`Data space: ${harness.rootDir}`);
|
|
4404
|
+
if (harness.profilesFile) console.log(`Profile fixtures: ${harness.profilesFile}`);
|
|
4405
|
+
for (const [member, port] of harness.ports) {
|
|
4406
|
+
console.log(` @${member}: http://localhost:${port}/`);
|
|
4407
|
+
}
|
|
4408
|
+
if (harness.guestPort !== null) console.log(` guest: http://localhost:${harness.guestPort}/ (signed out)`);
|
|
4409
|
+
console.log("One port per member: open two members in two tabs to develop multiplayer behaviour live.");
|
|
4410
|
+
if (harness.guestPort !== null) {
|
|
4411
|
+
console.log("The guest is someone who is not signed in; the app's sign-in (session.signIn()) picks a member there.");
|
|
4412
|
+
}
|
|
4413
|
+
await new Promise(() => {}); // runs until Ctrl-C
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
/** `dev --remote`: the local bundle over the production runtime. Opt-in —
|
|
4417
|
+
* the SQLite harness stays the default. */
|
|
4418
|
+
async function remoteDevCommand(dir, flags, pkg, commandArgs) {
|
|
4419
|
+
for (const flag of ["members", "profiles", "guest", "fresh"]) {
|
|
4420
|
+
if (flags[flag] !== undefined) throw usageError(`--${flag} applies to the local harness, not --remote`);
|
|
4421
|
+
}
|
|
4422
|
+
if (!pkg.id) {
|
|
4423
|
+
throw usageError("dev --remote needs a published app: set `id` in terminus.json (terminus remote add)");
|
|
4424
|
+
}
|
|
4425
|
+
const api = await connect(flags);
|
|
4426
|
+
const app = await resolveCreation(api, pkg.id);
|
|
4427
|
+
if (!app) throw new CliError(`no published app with address ${pkg.id} in your account`);
|
|
4428
|
+
const { mintRemoteAppSession, startRemoteDevServer } = await import("./appdev-remote.mjs");
|
|
4429
|
+
const minted = await mintRemoteAppSession({ api, appId: app.id });
|
|
4430
|
+
const harness = await startRemoteDevServer(dir, {
|
|
4431
|
+
commandArgs,
|
|
4432
|
+
port: flags.port,
|
|
4433
|
+
pkg,
|
|
4434
|
+
remote: {
|
|
4435
|
+
login: api,
|
|
4436
|
+
token: minted.token,
|
|
4437
|
+
appId: app.id,
|
|
4438
|
+
webHost: minted.webHost,
|
|
4439
|
+
},
|
|
4440
|
+
});
|
|
4441
|
+
console.log(`terminus dev --remote — ${pkg.id} (${minted.appName ?? app.name ?? app.id})`);
|
|
4442
|
+
console.log(`Runtime: ${api.base}/app-runtime/* as the app session of ${minted.webHost}`);
|
|
4443
|
+
if (minted.expiresAt) console.log(`App session expires: ${minted.expiresAt}`);
|
|
4444
|
+
console.log(` http://localhost:${harness.port}/`);
|
|
4445
|
+
await new Promise(() => {}); // runs until Ctrl-C
|
|
4446
|
+
}
|