@sigrun/sigrun 0.1.3 → 0.1.5

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 (79) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +24 -2
  3. package/dist/blocks/builders.js +125 -0
  4. package/dist/blocks/images.js +57 -0
  5. package/dist/blocks/index.js +20 -0
  6. package/dist/blocks/limits.js +26 -0
  7. package/dist/blocks/placements.js +99 -0
  8. package/dist/blocks/schema.js +559 -0
  9. package/dist/blocks/validate.js +602 -0
  10. package/dist/config.js +31 -0
  11. package/dist/extension.d.ts +476 -9
  12. package/dist/harness/boot.js +8 -1
  13. package/dist/harness/event-bus.js +33 -0
  14. package/dist/harness/identity/users.js +3 -2
  15. package/dist/harness/loader.js +369 -13
  16. package/dist/harness/packages.js +71 -2
  17. package/dist/harness/seam-host.js +69 -2
  18. package/dist/harness/settings.js +5 -0
  19. package/dist/jobs/fire.js +11 -0
  20. package/dist/jobs/spec.js +19 -0
  21. package/dist/serve.js +33 -41
  22. package/dist/server/auth/pipeline.js +16 -1
  23. package/dist/server/conversation-ops.js +482 -10
  24. package/dist/server/gateway.js +122 -0
  25. package/dist/server/helpers.js +26 -0
  26. package/dist/server/host-bridge.js +141 -4
  27. package/dist/server/index.js +27 -0
  28. package/dist/server/notifications.js +6 -2
  29. package/dist/server/routes/conversations.js +37 -93
  30. package/dist/server/routes/events.js +39 -0
  31. package/dist/server/routes/ext-assets.js +84 -0
  32. package/dist/server/routes/extensions.js +31 -1
  33. package/dist/server/routes/files.js +38 -83
  34. package/dist/server/routes/meta.js +37 -2
  35. package/dist/server/routes/providers.js +5 -21
  36. package/dist/server/routes/submissions.js +36 -111
  37. package/dist/server/routes/surfaces.js +387 -0
  38. package/dist/server/routes/users.js +6 -1
  39. package/dist/server/schema-fixture.js +7 -0
  40. package/dist/server/session-meta.js +12 -6
  41. package/dist/server/shutdown.js +85 -0
  42. package/dist/server/surfaces/access-cache.js +43 -0
  43. package/dist/server/surfaces/dedup.js +67 -0
  44. package/dist/server/surfaces/instance.js +42 -0
  45. package/dist/server/surfaces/mime.js +73 -0
  46. package/dist/server/surfaces/rate-limit.js +63 -0
  47. package/dist/server/surfaces/service.js +493 -0
  48. package/dist/server/surfaces/watchers.js +94 -0
  49. package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
  50. package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
  51. package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
  53. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
  54. package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
  55. package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
  56. package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
  57. package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
  58. package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
  59. package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
  60. package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
  61. package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
  62. package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
  63. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
  64. package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
  65. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
  66. package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
  67. package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
  68. package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
  69. package/node_modules/@sigrun/shared/dist/live.js +82 -0
  70. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
  71. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
  72. package/node_modules/@sigrun/shared/dist/views.d.ts +83 -3
  73. package/node_modules/@sigrun/shared/package.json +10 -2
  74. package/package.json +9 -3
  75. package/webui/dist/assets/index-B4-GYqEb.js +50 -0
  76. package/webui/dist/assets/index-BiSMljer.css +1 -0
  77. package/webui/dist/index.html +2 -2
  78. package/webui/dist/assets/index-2RfqJO57.css +0 -1
  79. package/webui/dist/assets/index-DkZgTngD.js +0 -50
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Extension surface routes (UI-blocks U3 Task 2, spec §7.2): the render
3
+ * and data GETs plus the actions POST, mounted at core's reserved
4
+ * `surfaces/` prefix under `/v1/ext/<slug>/`, behind the W1 auth
5
+ * pipeline like every `/v1` route.
6
+ *
7
+ * - `GET /v1/ext/<slug>/surfaces/<id>?params=<json>` → `{tree, data,
8
+ * rev, treeRev, access}`; `params` is a JSON object capped at 4 KiB
9
+ * (spec §15) — anything else is `400 invalid_field params`.
10
+ * - `GET …/data?params=<json>&since=<rev>` → `{rev, treeRev, data}` or
11
+ * `{rev, treeRev, ops}` when the server holds the ops since `since`
12
+ * (sharedData surfaces, §7.3).
13
+ * - `POST …/actions` `{action, payload, actionId, params}` → the
14
+ * handler's `ActionResult` wrapped per §6.3 (an accept carries the
15
+ * assigned `rev`, plus the `ops` echoed back when it came with an
16
+ * `update`), deduped per actionId per instance (10-minute window),
17
+ * rate-limited per principal per surface (20/s burst 40 → 429), and
18
+ * timed out at 10 s (`{ok:false, error:{message:"timed out"}}`).
19
+ * Access (§6.5) gates BEFORE the handler runs: `read` → 403, `none`
20
+ * → 404 `surface_not_found` (indistinguishable from an unknown id).
21
+ * - `PUT …/uploads/<field>/<name>?params` → raw octet-stream body,
22
+ * capped at the field's `maxBytes` (clamped by
23
+ * `server.maxUploadBytes`) — over the cap is a 413 answered from the
24
+ * FIRST chunks, without buffering the rest (the parser destroys the
25
+ * request stream, so the socket never sees the remainder). The
26
+ * `onUpload` handler receives `{field, name, mime, bytes}` with the
27
+ * MIME sniffed from the bytes (never the client name).
28
+ *
29
+ * Errors: a render/data throw or an invalid tree is
30
+ * `500 surface_failed` (logged here; the response body carries the
31
+ * validation `errors` only when `server.dev` is true, spec §14.2). The
32
+ * extension stays loaded.
33
+ */
34
+ import { PassThrough } from "node:stream";
35
+ import { instanceKey } from "../surfaces/instance.js";
36
+ import { ActionRateLimitedError, SurfaceFailedError, UnknownActionError, } from "../surfaces/service.js";
37
+ /**
38
+ * The one upload-name rule, shared with core's conversation upload
39
+ * route (files.ts §6.2 — kept in sync by hand): no `/` or `\\`, not
40
+ * `.` or `..`, non-empty. Fastify has already percent-DECODED the
41
+ * `:name` param, so `%2e%2e` arrives as `..` and is rejected.
42
+ */
43
+ function validUploadName(name) {
44
+ return (name.length > 0 &&
45
+ !name.includes("/") &&
46
+ !name.includes("\\") &&
47
+ name !== "." &&
48
+ name !== "..");
49
+ }
50
+ /** The params cap (spec §15): 4 KiB of JSON text. */
51
+ export const PARAMS_MAX_BYTES = 4096;
52
+ /** The not-found body — shared by unknown ids and `access: "none"`. */
53
+ const NOT_FOUND = { error: "surface_not_found" };
54
+ /**
55
+ * Parse the `?params=` query value: absent → `{}`; present must decode
56
+ * as a JSON OBJECT within the 4 KiB cap. Returns the object or the
57
+ * error body (`invalid_field params`).
58
+ */
59
+ function parseParams(raw) {
60
+ if (raw === undefined)
61
+ return { ok: true, params: {} };
62
+ if (typeof raw !== "string")
63
+ return { ok: false };
64
+ if (Buffer.byteLength(raw, "utf8") > PARAMS_MAX_BYTES)
65
+ return { ok: false };
66
+ let decoded;
67
+ try {
68
+ decoded = JSON.parse(raw);
69
+ }
70
+ catch {
71
+ return { ok: false };
72
+ }
73
+ if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
74
+ return { ok: false };
75
+ }
76
+ return { ok: true, params: decoded };
77
+ }
78
+ /** Parse `?since=`: absent → undefined; a non-negative integer else. */
79
+ function parseSince(raw) {
80
+ if (raw === undefined)
81
+ return undefined;
82
+ if (typeof raw !== "string" || !/^\d+$/.test(raw))
83
+ return null;
84
+ return Number.parseInt(raw, 10);
85
+ }
86
+ /** Register the surface routes over the shared context's service. */
87
+ export function registerSurfaceRoutes(app, ctx) {
88
+ const { surfaces } = ctx;
89
+ const uploadCaps = new WeakMap();
90
+ /** The shared failure mapping (logged; errors only under server.dev). */
91
+ const failureReply = async (reply, slug, id, cause) => {
92
+ const failed = cause instanceof SurfaceFailedError
93
+ ? cause
94
+ : new SurfaceFailedError(cause instanceof Error ? cause.message : String(cause));
95
+ console.warn(`surface failed: ${slug}/${id}: ${failed.message}${failed.errors === undefined
96
+ ? ""
97
+ : ` (${failed.errors.map((e) => `${e.path}: ${e.message}`).join("; ")})`}`);
98
+ return reply.code(500).send({
99
+ error: "surface_failed",
100
+ ...(ctx.config.dev && failed.errors !== undefined
101
+ ? { errors: failed.errors }
102
+ : {}),
103
+ });
104
+ };
105
+ app.get("/v1/ext/:slug/surfaces/:id", async (request, reply) => {
106
+ const { slug, id } = request.params;
107
+ const parsed = parseParams(request.query.params);
108
+ if (!parsed.ok) {
109
+ return await reply
110
+ .code(400)
111
+ .send({ error: "invalid_field", field: "params" });
112
+ }
113
+ try {
114
+ const result = await surfaces.render(request.params.slug, id, parsed.params, request.principal, request.headers["x-sigrun-stream"]);
115
+ if (result === undefined) {
116
+ return await reply.code(404).send(NOT_FOUND);
117
+ }
118
+ return await reply.send(result);
119
+ }
120
+ catch (cause) {
121
+ return await failureReply(reply, slug, id, cause);
122
+ }
123
+ });
124
+ app.get("/v1/ext/:slug/surfaces/:id/data", async (request, reply) => {
125
+ const { slug, id } = request.params;
126
+ const parsed = parseParams(request.query.params);
127
+ if (!parsed.ok) {
128
+ return await reply
129
+ .code(400)
130
+ .send({ error: "invalid_field", field: "params" });
131
+ }
132
+ const since = parseSince(request.query.since);
133
+ if (since === null) {
134
+ return await reply
135
+ .code(400)
136
+ .send({ error: "invalid_field", field: "since" });
137
+ }
138
+ try {
139
+ const result = await surfaces.data(slug, id, parsed.params, request.principal, since, request.headers["x-sigrun-stream"]);
140
+ if (result === undefined) {
141
+ return await reply.code(404).send(NOT_FOUND);
142
+ }
143
+ return await reply.send(result);
144
+ }
145
+ catch (cause) {
146
+ return await failureReply(reply, slug, id, cause);
147
+ }
148
+ });
149
+ app.post("/v1/ext/:slug/surfaces/:id/actions", async (request, reply) => {
150
+ const { slug, id } = request.params;
151
+ const body = request.body ?? {};
152
+ if (typeof body.action !== "string") {
153
+ return await reply
154
+ .code(400)
155
+ .send({ error: "invalid_field", field: "action" });
156
+ }
157
+ let params = {};
158
+ if (body.params !== undefined) {
159
+ const encoded = JSON.stringify(body.params);
160
+ if (typeof body.params !== "object" ||
161
+ body.params === null ||
162
+ Array.isArray(body.params) ||
163
+ Buffer.byteLength(encoded, "utf8") > PARAMS_MAX_BYTES) {
164
+ return await reply
165
+ .code(400)
166
+ .send({ error: "invalid_field", field: "params" });
167
+ }
168
+ params = body.params;
169
+ }
170
+ // The §6.5 access gate: cache or re-render decides BEFORE the
171
+ // handler runs. A located-check first keeps unknown slugs/ids on
172
+ // the same 404 body without a render.
173
+ const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
174
+ const def = binding?.defs.find((entry) => entry.id === id);
175
+ if (def === undefined) {
176
+ return await reply.code(404).send(NOT_FOUND);
177
+ }
178
+ // A throwing `instanceKey` is an extension fault: 500
179
+ // surface_failed, the extension stays loaded (deferred minor from
180
+ // Task 2 — this ran outside the try before).
181
+ let key;
182
+ try {
183
+ key =
184
+ def.instanceKey !== undefined
185
+ ? def.instanceKey(params)
186
+ : instanceKey(params);
187
+ }
188
+ catch (cause) {
189
+ return await failureReply(reply, slug, id, cause);
190
+ }
191
+ let access;
192
+ try {
193
+ access = await surfaces.accessFor(slug, id, key, params, request.principal);
194
+ }
195
+ catch (cause) {
196
+ return await failureReply(reply, slug, id, cause);
197
+ }
198
+ if (access === "none") {
199
+ return await reply.code(404).send(NOT_FOUND);
200
+ }
201
+ if (access === "read") {
202
+ return await reply.code(403).send({ error: "forbidden" });
203
+ }
204
+ try {
205
+ const result = await surfaces.act(slug, id, key, params, request.principal, body.action, body.payload, typeof body.actionId === "string" ? body.actionId : undefined, request.signal);
206
+ return await reply.send(result);
207
+ }
208
+ catch (cause) {
209
+ if (cause instanceof UnknownActionError) {
210
+ return await reply.code(400).send({ error: "unknown_action" });
211
+ }
212
+ if (cause instanceof ActionRateLimitedError) {
213
+ return await reply.code(429).send({ error: "rate_limited" });
214
+ }
215
+ return await failureReply(reply, slug, id, cause);
216
+ }
217
+ });
218
+ /**
219
+ * The upload route (spec §7.2). The gate + cap resolution run in
220
+ * `preParsing` — AFTER the headers, BEFORE the body is read. The cap
221
+ * is enforced by the route itself, not Fastify's bodyLimit: the hook
222
+ * wraps the payload in a PassThrough counter that forwards chunks up
223
+ * to the resolved field cap (`maxBytes` from the last rendered tree,
224
+ * clamped by `server.maxUploadBytes`); the first over-cap chunk sends
225
+ * `413 {error:"too_large", maxBytes}` DIRECTLY from the hook and
226
+ * then destroys the request socket (the reply is sent first —
227
+ * destroying the socket before the reply lands makes Node drop the
228
+ * response), so the sender's remaining bytes are never written
229
+ * (Review Focus 5). The handler itself only validates the name and
230
+ * dispatches to `onUpload`.
231
+ */
232
+ app.put("/v1/ext/:slug/surfaces/:id/uploads/:field/:name", {
233
+ // Untyped non-empty bodies would 415 (files.ts' identical
234
+ // default): uploads arrive raw.
235
+ onRequest: async (request) => {
236
+ if (request.headers["content-type"] === undefined) {
237
+ request.headers["content-type"] = "application/octet-stream";
238
+ }
239
+ },
240
+ preParsing: async (request, reply, payload) => {
241
+ const { slug, id, field } = request.params;
242
+ const parsed = parseParams(request.query.params);
243
+ if (!parsed.ok) {
244
+ return await reply
245
+ .code(400)
246
+ .send({ error: "invalid_field", field: "params" });
247
+ }
248
+ const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
249
+ const def = binding?.defs.find((entry) => entry.id === id);
250
+ if (def === undefined) {
251
+ return await reply.code(404).send(NOT_FOUND);
252
+ }
253
+ let key;
254
+ try {
255
+ key =
256
+ def.instanceKey !== undefined
257
+ ? def.instanceKey(parsed.params)
258
+ : instanceKey(parsed.params);
259
+ }
260
+ catch (cause) {
261
+ return await failureReply(reply, slug, id, cause);
262
+ }
263
+ // The §6.5 access gate, BEFORE the body is consumed.
264
+ let access;
265
+ try {
266
+ access = await surfaces.accessFor(slug, id, key, parsed.params, request.principal);
267
+ }
268
+ catch (cause) {
269
+ return await failureReply(reply, slug, id, cause);
270
+ }
271
+ if (access === "none") {
272
+ return await reply.code(404).send(NOT_FOUND);
273
+ }
274
+ if (access === "read") {
275
+ return await reply.code(403).send({ error: "forbidden" });
276
+ }
277
+ // The cap: the field's declared maxBytes (from the last
278
+ // rendered tree) clamped by server.maxUploadBytes. An
279
+ // instance that was never rendered has no recorded caps —
280
+ // re-render to fill them. Unknown field → 400.
281
+ let cap = surfaces.uploadCapBytes(slug, id, key, field);
282
+ if (cap === undefined) {
283
+ try {
284
+ await surfaces.render(slug, id, parsed.params, request.principal);
285
+ }
286
+ catch (cause) {
287
+ return await failureReply(reply, slug, id, cause);
288
+ }
289
+ cap = surfaces.uploadCapBytes(slug, id, key, field);
290
+ }
291
+ if (cap === undefined) {
292
+ return await reply.code(400).send({ error: "unknown_field" });
293
+ }
294
+ // A declared content-length already over the cap: answer
295
+ // 413 without touching the body (Fastify never reads it;
296
+ // the connection-close header drops the socket).
297
+ const declared = Number(request.headers["content-length"]);
298
+ if (Number.isFinite(declared) && declared > cap) {
299
+ reply.header("connection", "close");
300
+ return await reply
301
+ .code(413)
302
+ .send({ error: "too_large", maxBytes: cap });
303
+ }
304
+ uploadCaps.set(request, { cap, key, params: parsed.params, tripped: false });
305
+ // The chunked cut-off (Review Focus 5): wrap the payload in a
306
+ // counter whose first over-cap chunk destroys the request
307
+ // socket (the client's remaining chunks are never written) and
308
+ // sends the 413 DIRECTLY — answering from the hook, not the
309
+ // handler, because the handler only runs after the (destroyed)
310
+ // body finishes. The handler's `gate.tripped` check below
311
+ // stays as the in-process belt (an inject path never trips the
312
+ // stream).
313
+ const gate = uploadCaps.get(request);
314
+ if (gate === undefined) {
315
+ return payload;
316
+ }
317
+ let received = 0;
318
+ const capped = new PassThrough();
319
+ payload.on("data", (chunk) => {
320
+ if (gate.tripped)
321
+ return;
322
+ received += chunk.byteLength;
323
+ if (received > gate.cap) {
324
+ gate.tripped = true;
325
+ // The reply first, then the socket: destroying the
326
+ // request stream before the reply lands makes Node drop
327
+ // the response entirely.
328
+ void reply
329
+ .code(413)
330
+ .send({ error: "too_large", maxBytes: gate.cap });
331
+ // Destroy the counter too: the parser keeps buffering the
332
+ // already-forwarded prefix while it believes the stream is
333
+ // still live.
334
+ setImmediate(() => {
335
+ payload.destroy();
336
+ capped.destroy();
337
+ });
338
+ return;
339
+ }
340
+ capped.write(chunk);
341
+ });
342
+ payload.on("end", () => capped.end());
343
+ payload.on("error", () => capped.end());
344
+ return capped;
345
+ },
346
+ }, async (request, reply) => {
347
+ const { slug, id, field, name } = request.params;
348
+ const gate = uploadCaps.get(request);
349
+ uploadCaps.delete(request);
350
+ if (gate === undefined) {
351
+ // unreachable (the preParsing hook short-circuits every
352
+ // failure); defend anyway.
353
+ return await reply.code(404).send(NOT_FOUND);
354
+ }
355
+ // The one upload-name rule, shared with core's conversation
356
+ // uploads (files.ts §6.2): no separators, not `.`/`..`, on the
357
+ // DECODED param.
358
+ if (!validUploadName(name)) {
359
+ return await reply.code(400).send({ error: "invalid_body" });
360
+ }
361
+ const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
362
+ const def = binding?.defs.find((entry) => entry.id === id);
363
+ if (def === undefined) {
364
+ return await reply.code(404).send(NOT_FOUND);
365
+ }
366
+ const bytes = request.body === undefined
367
+ ? new Uint8Array(0)
368
+ : new Uint8Array(request.body);
369
+ // The stream-side cap tripped (a chunked body cut off mid-way):
370
+ // the body never arrived intact — answer the 413.
371
+ if (gate.tripped) {
372
+ return await reply
373
+ .code(413)
374
+ .send({ error: "too_large", maxBytes: gate.cap });
375
+ }
376
+ try {
377
+ const result = await surfaces.upload(slug, id, gate.key, gate.params, request.principal, field, name, bytes, request.signal);
378
+ return await reply.send(result);
379
+ }
380
+ catch (cause) {
381
+ if (cause instanceof UnknownActionError) {
382
+ return await reply.code(400).send({ error: "unknown_action" });
383
+ }
384
+ return await failureReply(reply, slug, id, cause);
385
+ }
386
+ });
387
+ }
@@ -14,13 +14,18 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
14
14
  import { withClaimLock } from "../auth/claim-lock.js";
15
15
  import { transferOwnership } from "../ownership.js";
16
16
  /** The wire form of one directory user (the record minus nothing — the
17
- * routes serving it are admin-gated, and emails are admin material). */
17
+ * routes serving it are admin-gated, and emails are admin material).
18
+ * `admin` stays the STORED flag; `groupAdmin` (gateway C8) is the
19
+ * persisted proxy-group grant alongside it. */
18
20
  function userWire(record) {
19
21
  return {
20
22
  user: record.user,
21
23
  ...(record.name === undefined ? {} : { name: record.name }),
22
24
  ...(record.email === undefined ? {} : { email: record.email }),
23
25
  admin: record.admin,
26
+ ...(record.groupAdmin === undefined
27
+ ? {}
28
+ : { groupAdmin: record.groupAdmin }),
24
29
  createdAt: record.createdAt,
25
30
  source: record.source,
26
31
  };
@@ -35,6 +35,13 @@ import { createRequire } from "node:module";
35
35
  * edit — the fixture and this version both follow the pin.
36
36
  */
37
37
  export const HARNESS_VERSION = createRequire(import.meta.url)("@earendil-works/pi-durable/package.json").version;
38
+ /**
39
+ * The SERVER's own version (the sigrun package manifest) — surfaced by
40
+ * `/v1/meta` and the boot log so an operator quotes what they run in a
41
+ * bug report. Same bundler-stable createRequire shape as
42
+ * {@link HARNESS_VERSION}.
43
+ */
44
+ export const SIGRUN_VERSION = createRequire(import.meta.url)("../../package.json").version;
38
45
  const NULL = { kind: "null" };
39
46
  const BOOLEAN = { kind: "boolean" };
40
47
  const NUMBER = { kind: "number" };
@@ -28,9 +28,10 @@
28
28
  * `Conversation.commit` callback, so concurrent patches cannot lose each
29
29
  * other's fields (the mutation line serializes commits).
30
30
  *
31
- * Server-owned fields (`autoName`, `autoNameAttempts`, `lastActivityAt`)
32
- * are written by host code only (activity.ts, the A2 auto-namer); they
33
- * are NOT patch surface — the strict validator keeps rejecting them as
31
+ * Server-owned fields (`autoName`, `autoNameAttempts`,
32
+ * `lastActivityAt`, `jobId`, `deliver`) are written by host code only
33
+ * (activity.ts, the A2 auto-namer, fire.ts's job stamp); they are NOT
34
+ * patch surface — the strict validator keeps rejecting them as
34
35
  * `invalid_field`.
35
36
  */
36
37
  import { defineDoc } from "@earendil-works/pi-durable";
@@ -154,9 +155,14 @@ export function validateSessionMetaPatch(body) {
154
155
  break;
155
156
  }
156
157
  case "source":
157
- case "owner": {
158
- // Server-only (spec §5.9): the KEY itself is the client error,
159
- // whatever the value — even a `null` clear.
158
+ case "owner":
159
+ case "jobId":
160
+ case "deliver": {
161
+ // Server-only (spec §5.9 + gateway C7): the KEY itself is the
162
+ // client error, whatever the value — even a `null` clear. The
163
+ // job stamp fields move with the fire (fire.ts's creating
164
+ // commit) and the delivery targets ride the job spec, never a
165
+ // meta patch.
160
166
  return { error: "invalid_field", field: key };
161
167
  }
162
168
  case "sharing": {
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The ordered shutdown (gateway C2's extraction of serve.ts's inline
3
+ * `shutdown`): seven steps, each in its own try/catch so a failure never
4
+ * skips the remaining steps, with every failure collected into one
5
+ * `AggregateError("sigrun shutdown failed")` after ALL steps ran (the
6
+ * pre-extraction semantics, byte-for-byte).
7
+ *
8
+ * The order (spec §8.0 — the gateway lifecycle slots between the server
9
+ * drain and the event-bus flush):
10
+ *
11
+ * 1. `endStreams` — SSE streams FIRST (hijacked event streams never end
12
+ * on their own, and `closeApp`'s drain waits for in-flight requests —
13
+ * with any webui tab open, shutdown hung forever on its /v1/events
14
+ * connection);
15
+ * 2. `closeApp` — the server drain (in-flight requests finish);
16
+ * 3. `gateway.stopIntake` — the engine closes inbound intake BEFORE the
17
+ * bus drain: the drain is the last admission window, so no chat
18
+ * message admitted mid-drain can outrun it;
19
+ * 4. `eventBusShutdown` — the bus's shutdown sequence (drain pending
20
+ * settles, then deliver `harnessShutdown` — handlers may commit);
21
+ * 5. `gateway.stop` — the engine stops only once every settle has been
22
+ * delivered (its adapters' outbound side saw the final answers);
23
+ * 6. `stopProviderRefresh` — the interval scheduler + the bounded
24
+ * boot-refresh join (no catalog fetch outlives the shutdown);
25
+ * 7. `closeHarness` — the seal (joins tasks, seals storage).
26
+ *
27
+ * Does NOT exit the process — the caller owns exit policy (the deploy
28
+ * entry exits 0/1; the CLI's `run` returns to its exit code).
29
+ */
30
+ /**
31
+ * Build the ordered shutdown over the seven steps. Failures from ANY step
32
+ * are collected (the remaining steps still run) and thrown together as
33
+ * `AggregateError(failures, "sigrun shutdown failed")` at the end; a clean
34
+ * run resolves.
35
+ */
36
+ export function createShutdown(steps) {
37
+ return async () => {
38
+ const failures = [];
39
+ try {
40
+ steps.endStreams();
41
+ }
42
+ catch (error) {
43
+ failures.push(error);
44
+ }
45
+ try {
46
+ await steps.closeApp();
47
+ }
48
+ catch (error) {
49
+ failures.push(error);
50
+ }
51
+ try {
52
+ await steps.gateway.stopIntake();
53
+ }
54
+ catch (error) {
55
+ failures.push(error);
56
+ }
57
+ try {
58
+ await steps.eventBusShutdown();
59
+ }
60
+ catch (error) {
61
+ failures.push(error);
62
+ }
63
+ try {
64
+ await steps.gateway.stop();
65
+ }
66
+ catch (error) {
67
+ failures.push(error);
68
+ }
69
+ try {
70
+ await steps.stopProviderRefresh();
71
+ }
72
+ catch (error) {
73
+ failures.push(error);
74
+ }
75
+ try {
76
+ await steps.closeHarness();
77
+ }
78
+ catch (error) {
79
+ failures.push(error);
80
+ }
81
+ if (failures.length > 0) {
82
+ throw new AggregateError(failures, "sigrun shutdown failed");
83
+ }
84
+ };
85
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The per-principal access cache (UI-blocks U3 Task 2, spec §6.5): the
3
+ * server caches the access verdict of each render, keyed by
4
+ * `(slug, id, key, principal.user)`, for 60 seconds. An action checks
5
+ * the cached verdict first; a miss (expired or never rendered) is not
6
+ * an allow — the caller re-renders for that principal and caches its
7
+ * answer before deciding. One principal's entry never answers for
8
+ * another (`principal.user` is part of the key).
9
+ */
10
+ /** The cache TTL (spec §6.5): 60 seconds. */
11
+ export const ACCESS_CACHE_TTL_MS = 60_000;
12
+ /** The cache store: composite-key → verdict, expiring lazily on read. */
13
+ export class AccessCache {
14
+ entries = new Map();
15
+ now;
16
+ /** @param now the clock (tests inject a fake; production passes Date.now). */
17
+ constructor(now = () => Date.now()) {
18
+ this.now = now;
19
+ }
20
+ static composite(slug, id, key, user) {
21
+ // The unit separator keeps segments unambiguous (no escaping needed).
22
+ return [slug, id, key, user ?? ""].join("\u0001");
23
+ }
24
+ /** The cached verdict for this principal, or undefined on a miss/expiry. */
25
+ get(slug, id, key, user) {
26
+ const composite = AccessCache.composite(slug, id, key, user);
27
+ const entry = this.entries.get(composite);
28
+ if (entry === undefined)
29
+ return undefined;
30
+ if (entry.expiresAt <= this.now()) {
31
+ this.entries.delete(composite);
32
+ return undefined;
33
+ }
34
+ return entry.access;
35
+ }
36
+ /** Cache one principal's verdict for the TTL. */
37
+ set(slug, id, key, user, access) {
38
+ this.entries.set(AccessCache.composite(slug, id, key, user), {
39
+ access,
40
+ expiresAt: this.now() + ACCESS_CACHE_TTL_MS,
41
+ });
42
+ }
43
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Action dedup (UI-blocks U3 Task 3, spec §6.1): the server invokes a
3
+ * surface action handler at most once per `actionId` per surface
4
+ * instance within a 10-minute window; a duplicate POST returns the
5
+ * FIRST invocation's stored result (retries are safe). Entries are
6
+ * keyed by actionId alone — actionIds are channel-generated UUIDs —
7
+ * with the instance composite (`slugidkey`) stored on the entry so a
8
+ * collision across instances is treated as a distinct action, not a
9
+ * replay of another instance's result.
10
+ *
11
+ * Process-memory only, like the service's rev state (§7.3). Entries
12
+ * expire lazily on read; a bounded sweep caps memory between reads.
13
+ */
14
+ /** The dedup window (spec §6.1): 10 minutes. */
15
+ export const DEDUP_WINDOW_MS = 10 * 60 * 1000;
16
+ /**
17
+ * The actionId → result table. `check` returns the stored result on a
18
+ * same-instance duplicate (and `null` when a different instance already
19
+ * holds the actionId — the caller treats that as a fresh action);
20
+ * `record` stores the first result.
21
+ */
22
+ export class ActionDedup {
23
+ entries = new Map();
24
+ clock;
25
+ sweepCounter = 0;
26
+ constructor(clock) {
27
+ // Resolve Date.now at CALL time, not construction: a default
28
+ // parameter (`clock = Date.now`) would capture the Date.now of
29
+ // whichever fake-timer install was active at construction and
30
+ // freeze this table to that install's clock.
31
+ this.clock = clock ?? (() => Date.now());
32
+ }
33
+ /**
34
+ * Look one actionId up for one instance: the stored result on a
35
+ * duplicate, `undefined` when never seen, `null` when the actionId
36
+ * belongs to a different instance (treated as fresh by the caller).
37
+ */
38
+ check(actionId, composite) {
39
+ const entry = this.entries.get(actionId);
40
+ if (entry === undefined)
41
+ return undefined;
42
+ if (entry.expiresAt <= this.clock()) {
43
+ this.entries.delete(actionId);
44
+ return undefined;
45
+ }
46
+ if (entry.composite !== composite)
47
+ return null;
48
+ return { result: entry.result };
49
+ }
50
+ /** Record the first result of one actionId for one instance. */
51
+ record(actionId, composite, result) {
52
+ this.entries.set(actionId, {
53
+ composite,
54
+ result,
55
+ expiresAt: this.clock() + DEDUP_WINDOW_MS,
56
+ });
57
+ this.sweepCounter += 1;
58
+ if (this.sweepCounter >= 64) {
59
+ this.sweepCounter = 0;
60
+ const now = this.clock();
61
+ for (const [id, entry] of this.entries) {
62
+ if (entry.expiresAt <= now)
63
+ this.entries.delete(id);
64
+ }
65
+ }
66
+ }
67
+ }