@sigrun/sigrun 0.1.6 → 0.2.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 (137) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +445 -42
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/schema.js +9 -2
  11. package/dist/blocks/validate.d.ts +73 -0
  12. package/dist/blocks/validate.js +57 -8
  13. package/dist/cli.js +26 -2
  14. package/dist/config.js +67 -1
  15. package/dist/extension.d.ts +230 -22
  16. package/dist/harness/ambient.js +39 -185
  17. package/dist/harness/blocks/entries.js +47 -0
  18. package/dist/harness/blocks/state.js +58 -0
  19. package/dist/harness/blocks/tools.js +274 -0
  20. package/dist/harness/boot.js +259 -20
  21. package/dist/harness/builtins.js +15 -11
  22. package/dist/harness/event-bus.js +105 -10
  23. package/dist/harness/identity/accounts.js +186 -0
  24. package/dist/harness/identity/groups.js +14 -0
  25. package/dist/harness/identity/index.js +13 -6
  26. package/dist/harness/identity/json-store.js +15 -4
  27. package/dist/harness/identity/setup-code-check.js +9 -0
  28. package/dist/harness/identity/tokens.js +28 -5
  29. package/dist/harness/identity/users.js +111 -12
  30. package/dist/harness/interactions.js +292 -136
  31. package/dist/harness/loader.js +271 -17
  32. package/dist/harness/packages.js +1 -1
  33. package/dist/harness/reserved-sections.js +21 -0
  34. package/dist/harness/seam-host.js +92 -6
  35. package/dist/harness/sections/context-files.js +1 -1
  36. package/dist/harness/settings.js +63 -2
  37. package/dist/harness/stores.js +241 -4
  38. package/dist/harness/user-services.js +391 -0
  39. package/dist/harness/user-tools.js +187 -0
  40. package/dist/harness/workspace.js +61 -57
  41. package/dist/jobs/fire.js +10 -16
  42. package/dist/sandbox/availability.js +211 -0
  43. package/dist/sandbox/command-observer.js +59 -0
  44. package/dist/sandbox/grants.js +159 -0
  45. package/dist/sandbox/helper.js +29 -0
  46. package/dist/sandbox/isolation.js +29 -0
  47. package/dist/sandbox/launch.js +31 -0
  48. package/dist/sandbox/os-account.js +49 -0
  49. package/dist/sandbox/policy.js +274 -0
  50. package/dist/sandbox/pool.js +548 -0
  51. package/dist/sandbox/protocol.js +167 -0
  52. package/dist/sandbox/sandboxed-env.js +245 -0
  53. package/dist/sandbox/unavailable-env.js +49 -0
  54. package/dist/sandbox/worker.js +261 -0
  55. package/dist/serve.js +213 -25
  56. package/dist/server/auth/access-principal.js +16 -0
  57. package/dist/server/auth/pipeline.js +99 -36
  58. package/dist/server/blocks/answers.js +94 -0
  59. package/dist/server/blocks/chat-surfaces.js +393 -0
  60. package/dist/server/blocks/notes.js +216 -0
  61. package/dist/server/blocks/summaries.js +328 -0
  62. package/dist/server/commands.js +5 -0
  63. package/dist/server/content-parts-lazy.js +13 -0
  64. package/dist/server/conversation-handler.js +2 -5
  65. package/dist/server/conversation-ops.js +138 -37
  66. package/dist/server/helpers.js +18 -6
  67. package/dist/server/host-bridge.js +40 -20
  68. package/dist/server/images/cache.js +213 -0
  69. package/dist/server/images/convert.js +13 -0
  70. package/dist/server/images/fetch.js +302 -0
  71. package/dist/server/images/ranges.js +42 -0
  72. package/dist/server/images/rewrite.js +94 -0
  73. package/dist/server/images/sign.js +84 -0
  74. package/dist/server/images/sniff.js +62 -0
  75. package/dist/server/index.js +152 -4
  76. package/dist/server/login/flows.js +126 -0
  77. package/dist/server/login/http.js +31 -0
  78. package/dist/server/login/resolve.js +133 -0
  79. package/dist/server/login/setup-claim.js +55 -0
  80. package/dist/server/login/usernames.js +15 -0
  81. package/dist/server/notifications.js +21 -4
  82. package/dist/server/ownership.js +13 -0
  83. package/dist/server/routes/admin.js +58 -0
  84. package/dist/server/routes/auth.js +88 -2
  85. package/dist/server/routes/conversations.js +3 -6
  86. package/dist/server/routes/events.js +28 -3
  87. package/dist/server/routes/extensions.js +37 -5
  88. package/dist/server/routes/images.js +274 -0
  89. package/dist/server/routes/interactions.js +57 -68
  90. package/dist/server/routes/jobs.js +2 -8
  91. package/dist/server/routes/login.js +223 -0
  92. package/dist/server/routes/me.js +90 -0
  93. package/dist/server/routes/meta.js +19 -0
  94. package/dist/server/routes/onboarding.js +94 -84
  95. package/dist/server/routes/settings-sections.js +190 -0
  96. package/dist/server/routes/settings.js +16 -4
  97. package/dist/server/routes/submissions.js +1 -1
  98. package/dist/server/routes/surfaces.js +175 -23
  99. package/dist/server/routes/users.js +116 -20
  100. package/dist/server/schema-fixture.js +213 -0
  101. package/dist/server/session-meta.js +3 -0
  102. package/dist/server/shutdown.js +11 -4
  103. package/dist/server/static.js +18 -0
  104. package/dist/server/surfaces/dedup.js +9 -2
  105. package/dist/server/surfaces/service.js +181 -13
  106. package/dist/server/surfaces/settings-surfaces.js +597 -0
  107. package/dist/server/turns/submit-turn.js +26 -0
  108. package/dist/server/views.js +16 -0
  109. package/dist/settings/core-sections.js +532 -0
  110. package/dist/settings/pending.js +181 -0
  111. package/dist/settings/registry.js +453 -0
  112. package/dist/skills/index.js +6 -0
  113. package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
  114. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  115. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  116. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  117. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  118. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  119. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  120. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  121. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  122. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  123. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  124. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  125. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  126. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  127. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  128. package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
  129. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  130. package/node_modules/@sigrun/shared/package.json +1 -1
  131. package/package.json +13 -9
  132. package/webui/dist/assets/index-SO2W4jjk.js +55 -0
  133. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  134. package/webui/dist/index.html +2 -2
  135. package/CONTEXT.md +0 -163
  136. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  137. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -0,0 +1,274 @@
1
+ /**
2
+ * The image-proxy routes (UI-blocks U7 Task 3, spec §11.2):
3
+ *
4
+ * - `GET /v1/image?u=<url>&e=<expiry>&s=<sig>[&as=png]` — the SIGNED
5
+ * form: the signature (Task 1's HMAC over the exact bytes `u + "\n" +
6
+ * e`) gates arbitrary-URL fetching. Rejections are 403 with a
7
+ * distinguishable error: `malformed_expiry` (a non-numeric `e`),
8
+ * `bad_signature` (the HMAC doesn't match), `expired` (it matches but
9
+ * `now > e`). The verified `u` string is fetched VERBATIM — never
10
+ * re-parsed or re-encoded between verify and fetch.
11
+ * - `GET /v1/image?src=<asset:slug/path | data:…>[&as=png]` — the
12
+ * already-validated form (no signature): assets pass the §11.1
13
+ * realpath containment check (an escape or a missing file is 404,
14
+ * indistinguishable) and data URLs the §11.1 type and 192 KiB size
15
+ * checks.
16
+ * - `POST /v1/image?as=png` — a `data:` source too long for a URL is
17
+ * POSTed as the raw text body; the route's `bodyLimit` is the §11.1
18
+ * 192 KiB rule (an over-limit body is Fastify's 413 `too_large`).
19
+ *
20
+ * Fetches go through the on-disk LRU cache: a hit fresher than its
21
+ * clamped max-age re-serves without an upstream call; an older hit
22
+ * revalidates, and a refusal is never replaced by stale bytes.
23
+ * Concurrent requests for one URL share ONE in-flight upstream fetch
24
+ * (Review Focus 4). Failures are never cached. Responses carry the
25
+ * sniffed Content-Type, `X-Content-Type-Options: nosniff`, and
26
+ * `Cache-Control: private, max-age=<upstream hint clamped to 1 h–7 d>`.
27
+ */
28
+ import { open, realpath } from "node:fs/promises";
29
+ import { lookup as dnsLookup } from "node:dns/promises";
30
+ import { relative, sep } from "node:path";
31
+ import { join } from "node:path";
32
+ import { toPng } from "../images/convert.js";
33
+ import { FetchRefusal, fetchImage, } from "../images/fetch.js";
34
+ import { verify } from "../images/sign.js";
35
+ import { isAcceptedImageType, sniffImage } from "../images/sniff.js";
36
+ /** The Cache-Control clamp (spec §11.2): between 1 hour and 7 days. */
37
+ const MAX_AGE_MIN_SECONDS = 3600;
38
+ const MAX_AGE_MAX_SECONDS = 7 * 24 * 3600;
39
+ /** The §11.1 data-URL cap: 192 KiB of base64 text (the POST bodyLimit). */
40
+ const DATA_URL_MAX_CHARS = 192 * 1024;
41
+ /** MIME types by sniffed image type. */
42
+ const MIME = {
43
+ png: "image/png",
44
+ jpeg: "image/jpeg",
45
+ gif: "image/gif",
46
+ webp: "image/webp",
47
+ };
48
+ /** The real fetch deps: dns.lookup + the wall clock. */
49
+ function defaultFetchDeps() {
50
+ return {
51
+ resolve: async (host) => {
52
+ const addresses = await dnsLookup(host, { all: true, verbatim: true });
53
+ return addresses.map((entry) => entry.address);
54
+ },
55
+ now: Date.now,
56
+ };
57
+ }
58
+ /** Resolve one requested asset path inside the package's assets dir
59
+ * (the ext-assets.ts rule, spec §11.1): realpath BOTH sides; an escape
60
+ * or a missing file is `undefined` (the route answers 404). */
61
+ async function resolveContained(assetsDir, path) {
62
+ let realDir;
63
+ try {
64
+ realDir = await realpath(assetsDir);
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ const candidate = join(realDir, path);
70
+ let realFile;
71
+ try {
72
+ realFile = await realpath(candidate);
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ const rel = relative(realDir, realFile);
78
+ if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
79
+ return undefined;
80
+ }
81
+ return realFile;
82
+ }
83
+ /** Read a whole (small, cache-bounded) file. */
84
+ async function readAll(file) {
85
+ const handle = await open(file, "r");
86
+ try {
87
+ return await handle.readFile();
88
+ }
89
+ finally {
90
+ await handle.close();
91
+ }
92
+ }
93
+ /** Decode a §11.1 data URL (`data:image/png|jpeg|gif|webp;base64,…`). */
94
+ function decodeDataUrl(src) {
95
+ const match = /^data:image\/(png|jpeg|gif|webp);base64,(.*)$/s.exec(src);
96
+ if (match === null)
97
+ return undefined;
98
+ return Buffer.from(match[2] ?? "", "base64");
99
+ }
100
+ /** Clamp the upstream max-age hint into the 1 h–7 d band. */
101
+ function clampedMaxAge(maxAge) {
102
+ if (maxAge === undefined)
103
+ return MAX_AGE_MIN_SECONDS;
104
+ return Math.min(MAX_AGE_MAX_SECONDS, Math.max(MAX_AGE_MIN_SECONDS, maxAge));
105
+ }
106
+ /**
107
+ * Register the image-proxy routes. `ctx` carries the shared cache and
108
+ * the (test-injectable) fetch deps on `ctx.imageProxy`, populated by
109
+ * `createServer`.
110
+ */
111
+ export function registerImageRoutes(app, ctx) {
112
+ const { cache, deps, key } = ctx.imageProxy;
113
+ /** In-flight upstream fetches (Review Focus 4): concurrent requests
114
+ * for one (url, variant) share ONE fetch + ONE cache write. */
115
+ const inflight = new Map();
116
+ /** Fetch one URL through the cache, sharing in-flight work. */
117
+ async function fetchThroughCache(u, variant) {
118
+ const now = deps.now();
119
+ const hit = await cache.get(u, variant, now);
120
+ if (hit !== undefined && now - hit.addedAt < hit.maxAge * 1000) {
121
+ const chunks = [];
122
+ for await (const chunk of hit.stream)
123
+ chunks.push(chunk);
124
+ return { bytes: Buffer.concat(chunks), type: hit.type, maxAge: hit.maxAge };
125
+ }
126
+ // A stale hit is never served: close its pin and revalidate.
127
+ if (hit !== undefined)
128
+ hit.stream.destroy();
129
+ const inflightKey = `${variant}${u}`;
130
+ const existing = inflight.get(inflightKey);
131
+ if (existing !== undefined)
132
+ return await existing;
133
+ const work = (async () => {
134
+ const fetched = await fetchImage(u, deps);
135
+ // Cache the original variant first — `as=png` requests cache
136
+ // BOTH variants (the conversion reads the fetched bytes). The
137
+ // clamped hint is BOTH the response header and the cache TTL.
138
+ const maxAge = clampedMaxAge(fetched.maxAge);
139
+ await cache.put(u, "orig", fetched.type, fetched.bytes, deps.now(), maxAge);
140
+ if (variant === "png") {
141
+ const converted = await toPng(fetched.bytes);
142
+ await cache.put(u, "png", "png", converted, deps.now(), maxAge);
143
+ return {
144
+ bytes: converted,
145
+ type: "png",
146
+ ...(fetched.maxAge === undefined ? {} : { maxAge: fetched.maxAge }),
147
+ };
148
+ }
149
+ return fetched;
150
+ })();
151
+ inflight.set(inflightKey, work);
152
+ try {
153
+ return await work;
154
+ }
155
+ finally {
156
+ inflight.delete(inflightKey);
157
+ }
158
+ }
159
+ /** Serve one resolved image. */
160
+ async function sendImage(reply, image) {
161
+ return await reply
162
+ .header("content-type", MIME[image.type])
163
+ .header("x-content-type-options", "nosniff")
164
+ .header("cache-control", `private, max-age=${clampedMaxAge(image.maxAge)}`)
165
+ .send(image.bytes);
166
+ }
167
+ /** Resolve the `src=` form (asset or data URL, both already
168
+ * validated by §11.1's own rules — nothing to sign). */
169
+ async function resolveSource(src) {
170
+ if (src.startsWith("asset:")) {
171
+ const rest = src.slice("asset:".length);
172
+ const slash = rest.indexOf("/");
173
+ if (slash <= 0)
174
+ return "bad";
175
+ const slug = rest.slice(0, slash);
176
+ const path = rest.slice(slash + 1);
177
+ const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
178
+ if (binding?.assetsDir === undefined)
179
+ return undefined;
180
+ const file = await resolveContained(binding.assetsDir, path);
181
+ if (file === undefined)
182
+ return undefined;
183
+ const bytes = await readAll(file);
184
+ const sniffed = sniffImage(bytes);
185
+ if (!isAcceptedImageType(sniffed))
186
+ return undefined;
187
+ return { bytes, type: sniffed };
188
+ }
189
+ if (src.startsWith("data:")) {
190
+ if (src.length > DATA_URL_MAX_CHARS)
191
+ return undefined;
192
+ const bytes = decodeDataUrl(src);
193
+ if (bytes === undefined)
194
+ return "bad";
195
+ const sniffed = sniffImage(bytes);
196
+ if (!isAcceptedImageType(sniffed))
197
+ return undefined;
198
+ return { bytes, type: sniffed };
199
+ }
200
+ return "bad";
201
+ }
202
+ app.get("/v1/image", async (request, reply) => {
203
+ const { u, e, s, src, as } = request.query;
204
+ const wantPng = as === "png";
205
+ // The src form: no signature (assets and data URLs carry their
206
+ // own §11.1 validation).
207
+ if (src !== undefined) {
208
+ const resolved = await resolveSource(src);
209
+ if (resolved === "bad") {
210
+ return await reply.code(400).send({ error: "bad_source" });
211
+ }
212
+ if (resolved === undefined) {
213
+ // Containment misses and over-long data URLs are 404/413 —
214
+ // indistinguishable from a missing asset / a too-large body.
215
+ if (src.startsWith("data:") && src.length > DATA_URL_MAX_CHARS) {
216
+ return await reply.code(413).send({
217
+ error: "too_large",
218
+ maxBytes: DATA_URL_MAX_CHARS,
219
+ });
220
+ }
221
+ return await reply.code(404).send({ error: "asset_not_found" });
222
+ }
223
+ const image = wantPng
224
+ ? { bytes: await toPng(resolved.bytes), type: "png" }
225
+ : resolved;
226
+ return await sendImage(reply, image);
227
+ }
228
+ // The signed form.
229
+ if (u === undefined || e === undefined || s === undefined) {
230
+ return await reply.code(400).send({ error: "bad_signature" });
231
+ }
232
+ if (!/^\d+$/.test(e)) {
233
+ return await reply.code(403).send({ error: "malformed_expiry" });
234
+ }
235
+ // Split the verify so the expired case names its own error: a
236
+ // signature that MATCHES but whose expiry passed is `expired`;
237
+ // a mismatch is `bad_signature`.
238
+ if (!verify(key, u, e, s, Number(e))) {
239
+ return await reply.code(403).send({ error: "bad_signature" });
240
+ }
241
+ if (Date.now() > Number(e)) {
242
+ return await reply.code(403).send({ error: "expired" });
243
+ }
244
+ try {
245
+ const image = await fetchThroughCache(u, wantPng ? "png" : "orig");
246
+ return await sendImage(reply, image);
247
+ }
248
+ catch (cause) {
249
+ if (cause instanceof FetchRefusal) {
250
+ return await reply
251
+ .code(502)
252
+ .send({ error: cause.reason, message: cause.message });
253
+ }
254
+ throw cause;
255
+ }
256
+ });
257
+ // The long-data-URL form: a `data:` source too long for a URL is
258
+ // POSTed as the raw body. `bodyLimit` is the §11.1 192 KiB rule; the
259
+ // server's one error handler turns an over-limit body into 413
260
+ // `too_large`. The `text/plain` string parser is already registered
261
+ // app-wide by the conversations route module (the JSONL import's
262
+ // raw-text parser).
263
+ app.post("/v1/image", { bodyLimit: DATA_URL_MAX_CHARS }, async (request, reply) => {
264
+ const src = typeof request.body === "string" ? request.body : "";
265
+ const resolved = await resolveSource(src);
266
+ if (resolved === "bad" || resolved === undefined) {
267
+ return await reply.code(400).send({ error: "bad_source" });
268
+ }
269
+ const image = request.query.as === "png"
270
+ ? { bytes: await toPng(resolved.bytes), type: "png" }
271
+ : resolved;
272
+ return await sendImage(reply, image);
273
+ });
274
+ }
@@ -33,12 +33,12 @@
33
33
  * through the per-conversation answer route above.
34
34
  */
35
35
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
36
- import { AmbientDoc, AmbientOwnersDoc, } from "../../harness/ambient.js";
37
- import { InteractionDoc, InteractionsIndexDoc, applyAnswer, indexSettled, } from "../../harness/interactions.js";
36
+ import { AmbientDoc, AmbientOwnersDoc, asAmbientRecord, } from "../../harness/ambient.js";
37
+ import { InteractionDoc, InteractionsIndexDoc, answerFromBody, applyAnswer, asInteractionRecord, indexSettled, maskedAnswerValues, } from "../../harness/interactions.js";
38
38
  import { loadConversation } from "../conversation-handler.js";
39
39
  import { ID_PARAMS_SCHEMA, conversationId } from "../helpers.js";
40
40
  import { accessOf } from "@sigrun/shared/access";
41
- import { isSuperuser } from "../auth/principal.js";
41
+ import { accessPrincipalOf } from "../auth/access-principal.js";
42
42
  import { SessionMetaDoc } from "../session-meta.js";
43
43
  /**
44
44
  * Path params for the answer route — `ID_PARAMS_SCHEMA`'s shape plus the
@@ -61,7 +61,7 @@ const ANSWER_PARAMS_SCHEMA = {
61
61
  */
62
62
  async function visibleTo(ctx, convId, principal) {
63
63
  const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, convId, BACKGROUND_CONTEXT);
64
- return (accessOf({ user: principal.user, superuser: isSuperuser(principal) }, meta ?? {}) !== "none");
64
+ return (accessOf(accessPrincipalOf(principal, ctx.opened.identity.users), meta ?? {}) !== "none");
65
65
  }
66
66
  /** Register the interaction-seam and ambient-surface routes. */
67
67
  export function registerInteractionRoutes(app, ctx) {
@@ -102,7 +102,10 @@ export function registerInteractionRoutes(app, ctx) {
102
102
  // but a raced settle can leave a stale entry — serve only
103
103
  // what still reads pending (the per-conversation list's guard).
104
104
  if (record !== undefined && record.status === "pending") {
105
- interactions.push({ ...record, conversationId: id });
105
+ interactions.push({
106
+ ...asInteractionRecord(record),
107
+ conversationId: id,
108
+ });
106
109
  }
107
110
  }
108
111
  }
@@ -128,11 +131,35 @@ export function registerInteractionRoutes(app, ctx) {
128
131
  for (const callId of pending) {
129
132
  const record = await opened.harness.snapshot(InteractionDoc, convId, callId, BACKGROUND_CONTEXT);
130
133
  if (record !== undefined && record.status === "pending") {
131
- records.push(record);
134
+ records.push(asInteractionRecord(record));
132
135
  }
133
136
  }
134
137
  return { interactions: records };
135
138
  });
139
+ // The single-record read (U6 Task 2): ANY status — the channel's
140
+ // refetch path (Review Focus 1's “a GET returns the winning answer”;
141
+ // Review Focus 2's expired refetch; the transport `load`). The SAME
142
+ // §5.9 visibility half as the list (hidden → the indistinguishable
143
+ // 404), applied in-handler by the polling convention, and the same
144
+ // ACCESS as the list: read membership is enough (a read-only sharee
145
+ // watches the decision too — writes stay behind the answer route's
146
+ // write gate). The record rides as-is (envelope + body + details) —
147
+ // the stored answer carries only the SECRET_MASK for secret fields
148
+ // (never the values).
149
+ app.get("/v1/conversations/:id/interactions/:callId", {
150
+ schema: { params: ANSWER_PARAMS_SCHEMA },
151
+ preHandler: loadConversation(ctx),
152
+ }, async (request, reply) => {
153
+ const { callId } = request.params;
154
+ const conversation = request.conversation;
155
+ const record = await opened.harness.snapshot(InteractionDoc, conversation.id, callId, BACKGROUND_CONTEXT);
156
+ // Unknown callId (an empty doc materializes with kind "") is the
157
+ // same 404 as the answer route's.
158
+ if (record === undefined || record.kind === "") {
159
+ return await reply.code(404).send({ error: "no_such_interaction" });
160
+ }
161
+ return asInteractionRecord(record);
162
+ });
136
163
  // The ONE generic answer route: writes `answer` into the envelope with
137
164
  // first-answer-wins inside the commit. 404 for unknown/not-pending;
138
165
  // 400 for a malformed answer. `by` is "human" (the only v1 caller).
@@ -142,59 +169,13 @@ export function registerInteractionRoutes(app, ctx) {
142
169
  }, async (request, reply) => {
143
170
  const { callId } = request.params;
144
171
  const conversation = request.conversation;
145
- const body = request.body;
146
- // SAFETY: the spread of an explicitly-undefined InteractionAnswer
147
- // builds an empty mutable scaffold — every field is optional, and
148
- // each is set only after its body value passed validation, so the
149
- // loose local type never leaks an unvalidated field.
150
- const answer = {
151
- ...undefined,
152
- };
153
- if (body !== undefined && body !== null) {
154
- if (typeof body !== "object" || Array.isArray(body)) {
155
- return await reply.code(400).send({ error: "invalid_body" });
156
- }
157
- const b = body;
158
- const known = new Set(["ids", "values", "text"]);
159
- const unknownKey = Object.keys(b).find((k) => !known.has(k));
160
- if (unknownKey !== undefined) {
161
- return await reply
162
- .code(400)
163
- .send({ error: "invalid_field", field: unknownKey });
164
- }
165
- if (b.ids !== undefined) {
166
- if (!Array.isArray(b.ids) ||
167
- b.ids.some((v) => typeof v !== "string")) {
168
- return await reply
169
- .code(400)
170
- .send({ error: "invalid_body" });
171
- }
172
- answer.ids = b.ids;
173
- // (mutable local; the wire type stays readonly)
174
- }
175
- if (b.values !== undefined) {
176
- if (typeof b.values !== "object" || b.values === null) {
177
- return await reply.code(400).send({ error: "invalid_body" });
178
- }
179
- const values = {};
180
- for (const [k, v] of Object.entries(b.values)) {
181
- if (typeof v !== "string" &&
182
- typeof v !== "number" &&
183
- typeof v !== "boolean") {
184
- return await reply
185
- .code(400)
186
- .send({ error: "invalid_field", field: `values.${k}` });
187
- }
188
- values[k] = v;
189
- }
190
- answer.values = values;
191
- }
192
- if (b.text !== undefined) {
193
- if (typeof b.text !== "string") {
194
- return await reply.code(400).send({ error: "invalid_body" });
195
- }
196
- answer.text = b.text;
197
- }
172
+ // Ruling 20: the route body is `{values}` ONLY (every field's value
173
+ // under its name); the legacy `{ids, text}` shapes are a 400 — no
174
+ // compatibility layer (ruling 16). An absent body is a 400 too: an
175
+ // answer carries values.
176
+ const answer = answerFromBody(request.body);
177
+ if (answer === null) {
178
+ return await reply.code(400).send({ error: "invalid_body" });
198
179
  }
199
180
  // The commit returns a plain outcome; reply shaping happens
200
181
  // AFTER the commit (a Fastify reply must never travel through
@@ -203,21 +184,29 @@ export function registerInteractionRoutes(app, ctx) {
203
184
  const draft = await tx.doc(InteractionDoc, conversation.id, callId, callId);
204
185
  // Unknown callId: the doc materializes empty — detect via kind
205
186
  // never having been seeded (an empty seed has kind "").
206
- if (draft.kind === "")
187
+ if (draft.kind === "") {
207
188
  return { found: false };
208
- const applied = applyAnswer(draft, answer, "human", Date.now(), request.principal.user);
209
- if (!applied)
189
+ }
190
+ // Review Focus 4: project the STORED (masked) answer for the
191
+ // response BEFORE the mutation — reading the tree is safe (the
192
+ // body is never mutated), and re-reading a mutated nested field
193
+ // after the write trips chord's settled-overlay check.
194
+ const stored = maskedAnswerValues(draft.body, answer);
195
+ const applied = applyAnswer(draft, answer, "human", Date.now(), request.principal.user, { conversationId: conversation.id });
196
+ if (!applied) {
197
+ // A loser of the first-answer-wins race: the delivery-table
198
+ // entry (when there is one) belongs to the WINNER — the loser
199
+ // never recorded one (applyAnswer guards on status), so there
200
+ // is nothing of the loser's to sweep. Sweeping here would
201
+ // destroy the winner's undelivered secret.
210
202
  return { found: true, applied: false };
211
- // NEVER re-read a mutated draft field (even before the next
212
- // await): reading a newly-set nested value off the tracked
213
- // draft trips chord's settled-overlay check. The response
214
- // projects the INPUT answer — identical to what was written.
203
+ }
215
204
  const index = await tx.doc(InteractionsIndexDoc);
216
205
  indexSettled(index, conversation.id, callId);
217
206
  return {
218
207
  found: true,
219
208
  applied: true,
220
- answer,
209
+ answer: stored,
221
210
  };
222
211
  }, BACKGROUND_CONTEXT);
223
212
  if (!committed.found) {
@@ -252,7 +241,7 @@ export function registerInteractionRoutes(app, ctx) {
252
241
  // absent (owner registration is session-global; surfaces are
253
242
  // conversation-scoped).
254
243
  if (surface !== undefined && surface.body !== undefined) {
255
- surfaces.push(surface);
244
+ surfaces.push(asAmbientRecord(surface));
256
245
  }
257
246
  }
258
247
  return { ambient: surfaces };
@@ -22,16 +22,9 @@
22
22
  * the caller's write access and the job's own `overlap`.
23
23
  */
24
24
  import { accessOf } from "@sigrun/shared/access";
25
- import { isSuperuser } from "../auth/principal.js";
25
+ import { accessPrincipalOf } from "../auth/access-principal.js";
26
26
  import { JobDeclaredError, JobNotFoundError } from "../../jobs/index.js";
27
27
  import { validateJobSpec } from "../../jobs/spec.js";
28
- /** The principal form `accessOf` takes (§5.9). */
29
- function accessPrincipal(request) {
30
- return {
31
- user: request.principal.user,
32
- superuser: isSuperuser(request.principal),
33
- };
34
- }
35
28
  /** The §5.9 resource form ({owner?, sharing?}) of a job row. */
36
29
  function resourceOf(row) {
37
30
  return {
@@ -64,6 +57,7 @@ async function domainError(reply, error) {
64
57
  export function registerJobRoutes(app, ctx) {
65
58
  const { opened } = ctx;
66
59
  const jobs = opened.jobs;
60
+ const accessPrincipal = (request) => accessPrincipalOf(request.principal, opened.identity.users);
67
61
  /**
68
62
  * Resolve one job for the access rule: the live record's row when it
69
63
  * exists; for a RETIRED job (the history routes' case) the newest