@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1055 -0
  3. package/bin/agent-discovery.mjs +71 -0
  4. package/bin/agent-icon.mjs +77 -0
  5. package/bin/agent-models.mjs +77 -0
  6. package/bin/agent-type.mjs +51 -0
  7. package/bin/agentdev.mjs +657 -0
  8. package/bin/app-route-script.mjs +59 -0
  9. package/bin/app-runtime-contract.mjs +2 -0
  10. package/bin/appdev-remote.mjs +346 -0
  11. package/bin/appdev.mjs +4446 -0
  12. package/bin/apps.mjs +5512 -0
  13. package/bin/capability-calls.mjs +437 -0
  14. package/bin/capsule-data.mjs +260 -0
  15. package/bin/client.mjs +189 -0
  16. package/bin/commands.mjs +1194 -0
  17. package/bin/dev-capsules.mjs +1599 -0
  18. package/bin/dev-contract.mjs +262 -0
  19. package/bin/dev-data.mjs +287 -0
  20. package/bin/dev-members.mjs +18 -0
  21. package/bin/dev-net.mjs +316 -0
  22. package/bin/dev-notification-popup.mjs +628 -0
  23. package/bin/dev-ports.mjs +567 -0
  24. package/bin/dev-server-binding.mjs +35 -0
  25. package/bin/dev-server-ops.mjs +1086 -0
  26. package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
  27. package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
  28. package/bin/dev-ui/OFL.txt +92 -0
  29. package/bin/dev-ui/agent-robot.webp +0 -0
  30. package/bin/dev-ui/app.js +5217 -0
  31. package/bin/dev-ui/highlight.js +195 -0
  32. package/bin/dev-ui/index.html +34 -0
  33. package/bin/dev-ui/style.css +3640 -0
  34. package/bin/devlint.mjs +112 -0
  35. package/bin/devserver.mjs +2127 -0
  36. package/bin/devtriggers.mjs +367 -0
  37. package/bin/endpoints.mjs +156 -0
  38. package/bin/errors.mjs +61 -0
  39. package/bin/files.mjs +169 -0
  40. package/bin/horizontal-capabilities/v1/contract.json +280 -0
  41. package/bin/http.mjs +500 -0
  42. package/bin/lint-manifests/justbash-commands.json +88 -0
  43. package/bin/lint-manifests/python-stdlib.json +295 -0
  44. package/bin/login-page.mjs +488 -0
  45. package/bin/schedules.mjs +664 -0
  46. package/bin/server-sandbox.mjs +204 -0
  47. package/bin/servicedev.mjs +425 -0
  48. package/bin/sync.mjs +357 -0
  49. package/bin/terminus.js +3666 -0
  50. package/bin/toolchain.mjs +125 -0
  51. package/bin/vendor/app-runtime-v1/app-host.json +124 -0
  52. package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
  53. package/bin/vendor/app-runtime-v1/doors.json +2867 -0
  54. package/bin/vendor/appd/node-harness.mjs +209 -0
  55. package/bin/vendor/appd/python-harness.py +12 -0
  56. package/bin/vendor/appd/server-protocol.json +84 -0
  57. package/bin/vendor/where.mjs +541 -0
  58. package/bin/versioning.mjs +72 -0
  59. package/bin/write-rules.mjs +398 -0
  60. 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("&", "&amp;")
1166
+ .replaceAll("<", "&lt;")
1167
+ .replaceAll(">", "&gt;")
1168
+ .replaceAll('"', "&quot;")
1169
+ .replaceAll("'", "&#39;");
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
+ }