@sigrun/sigrun 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +352 -36
  3. package/dist/blocks/schema.js +9 -2
  4. package/dist/blocks/validate.js +57 -8
  5. package/dist/cli.js +26 -2
  6. package/dist/config.js +67 -1
  7. package/dist/extension.d.ts +189 -15
  8. package/dist/harness/ambient.js +39 -185
  9. package/dist/harness/blocks/entries.js +47 -0
  10. package/dist/harness/blocks/state.js +58 -0
  11. package/dist/harness/blocks/tools.js +274 -0
  12. package/dist/harness/boot.js +253 -20
  13. package/dist/harness/builtins.js +15 -11
  14. package/dist/harness/event-bus.js +105 -10
  15. package/dist/harness/identity/accounts.js +186 -0
  16. package/dist/harness/identity/groups.js +14 -0
  17. package/dist/harness/identity/index.js +13 -6
  18. package/dist/harness/identity/json-store.js +15 -4
  19. package/dist/harness/identity/setup-code-check.js +9 -0
  20. package/dist/harness/identity/tokens.js +28 -5
  21. package/dist/harness/identity/users.js +111 -12
  22. package/dist/harness/interactions.js +292 -136
  23. package/dist/harness/loader.js +271 -17
  24. package/dist/harness/packages.js +1 -1
  25. package/dist/harness/reserved-sections.js +21 -0
  26. package/dist/harness/seam-host.js +80 -6
  27. package/dist/harness/settings.js +63 -2
  28. package/dist/harness/stores.js +241 -4
  29. package/dist/harness/user-services.js +391 -0
  30. package/dist/harness/user-tools.js +187 -0
  31. package/dist/harness/workspace.js +61 -57
  32. package/dist/jobs/fire.js +10 -16
  33. package/dist/sandbox/availability.js +162 -0
  34. package/dist/sandbox/command-observer.js +59 -0
  35. package/dist/sandbox/grants.js +159 -0
  36. package/dist/sandbox/helper.js +29 -0
  37. package/dist/sandbox/isolation.js +29 -0
  38. package/dist/sandbox/launch.js +20 -0
  39. package/dist/sandbox/os-account.js +49 -0
  40. package/dist/sandbox/policy.js +253 -0
  41. package/dist/sandbox/pool.js +548 -0
  42. package/dist/sandbox/protocol.js +167 -0
  43. package/dist/sandbox/sandboxed-env.js +245 -0
  44. package/dist/sandbox/unavailable-env.js +49 -0
  45. package/dist/sandbox/worker.js +281 -0
  46. package/dist/serve.js +82 -7
  47. package/dist/server/auth/access-principal.js +16 -0
  48. package/dist/server/auth/pipeline.js +47 -33
  49. package/dist/server/blocks/answers.js +94 -0
  50. package/dist/server/blocks/chat-surfaces.js +393 -0
  51. package/dist/server/blocks/notes.js +216 -0
  52. package/dist/server/blocks/summaries.js +328 -0
  53. package/dist/server/commands.js +5 -0
  54. package/dist/server/content-parts-lazy.js +13 -0
  55. package/dist/server/conversation-handler.js +2 -5
  56. package/dist/server/conversation-ops.js +138 -37
  57. package/dist/server/helpers.js +18 -6
  58. package/dist/server/host-bridge.js +40 -20
  59. package/dist/server/images/cache.js +205 -0
  60. package/dist/server/images/convert.js +13 -0
  61. package/dist/server/images/fetch.js +302 -0
  62. package/dist/server/images/ranges.js +42 -0
  63. package/dist/server/images/rewrite.js +94 -0
  64. package/dist/server/images/sign.js +84 -0
  65. package/dist/server/images/sniff.js +62 -0
  66. package/dist/server/index.js +144 -4
  67. package/dist/server/login/flows.js +126 -0
  68. package/dist/server/login/http.js +31 -0
  69. package/dist/server/login/resolve.js +133 -0
  70. package/dist/server/login/setup-claim.js +55 -0
  71. package/dist/server/login/usernames.js +15 -0
  72. package/dist/server/ownership.js +13 -0
  73. package/dist/server/routes/admin.js +58 -0
  74. package/dist/server/routes/auth.js +88 -2
  75. package/dist/server/routes/conversations.js +3 -6
  76. package/dist/server/routes/events.js +28 -3
  77. package/dist/server/routes/extensions.js +37 -5
  78. package/dist/server/routes/images.js +267 -0
  79. package/dist/server/routes/interactions.js +57 -68
  80. package/dist/server/routes/jobs.js +2 -8
  81. package/dist/server/routes/login.js +223 -0
  82. package/dist/server/routes/me.js +90 -0
  83. package/dist/server/routes/meta.js +15 -0
  84. package/dist/server/routes/onboarding.js +94 -84
  85. package/dist/server/routes/settings-sections.js +190 -0
  86. package/dist/server/routes/settings.js +16 -4
  87. package/dist/server/routes/submissions.js +1 -1
  88. package/dist/server/routes/surfaces.js +175 -23
  89. package/dist/server/routes/users.js +116 -20
  90. package/dist/server/schema-fixture.js +213 -0
  91. package/dist/server/session-meta.js +3 -0
  92. package/dist/server/shutdown.js +11 -4
  93. package/dist/server/static.js +18 -0
  94. package/dist/server/surfaces/dedup.js +9 -2
  95. package/dist/server/surfaces/service.js +181 -13
  96. package/dist/server/surfaces/settings-surfaces.js +597 -0
  97. package/dist/server/turns/submit-turn.js +26 -0
  98. package/dist/server/views.js +16 -0
  99. package/dist/settings/core-sections.js +532 -0
  100. package/dist/settings/pending.js +181 -0
  101. package/dist/settings/registry.js +453 -0
  102. package/dist/skills/index.js +6 -0
  103. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
  104. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  105. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  106. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  107. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  108. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  109. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  110. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  111. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  112. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  113. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  114. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  115. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  116. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  117. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  118. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  119. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  120. package/node_modules/@sigrun/shared/package.json +1 -1
  121. package/package.json +11 -6
  122. package/webui/dist/assets/index-CxB969qp.js +55 -0
  123. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  124. package/webui/dist/index.html +2 -2
  125. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  126. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -5,7 +5,8 @@
5
5
  * - `GET /v1/extensions` — one entry per loaded outcome with `status`,
6
6
  * `error?`, the manifest `version?`, the declared
7
7
  * `interactionKinds`/`ambientKeys`, the idle-worker projection, and
8
- * the contributed channel ids (gateway C1, `channels?`).
8
+ * the contributed channel ids (gateway C1, `channels?`). Every principal
9
+ * may read the inventory; `services` is populated for admins only.
9
10
  * - `GET`/`PUT /v1/extensions/:slug/settings` (PUT admin) — the
10
11
  * `settings/<slug>.json` object verbatim. Values an extension reads at
11
12
  * factory time apply at RESTART; per-run reads see the write live.
@@ -35,7 +36,7 @@ export function registerExtensionRoutes(app, ctx) {
35
36
  // row here — `builtin: true`, `replacedBy` the configured specifier,
36
37
  // status "replaced" — while a DISABLED one is absent entirely
37
38
  // (ruling 6).
38
- app.get("/v1/extensions", async () => {
39
+ app.get("/v1/extensions", async (request) => {
39
40
  const workerStatuses = opened.workerStatuses();
40
41
  const extensions = opened.extensionOutcomes.map((outcome) => {
41
42
  const kinds = opened.seamKinds.get(outcome.name);
@@ -65,6 +66,9 @@ export function registerExtensionRoutes(app, ctx) {
65
66
  interactionKinds: [...(kinds?.interaction ?? [])],
66
67
  ambientKeys: [...(kinds?.ambient ?? [])],
67
68
  workers: workers ?? [],
69
+ services: request.principal.admin
70
+ ? opened.userServices.statuses().filter((s) => s.slug === outcome.name)
71
+ : [],
68
72
  routes: routes?.routes.map((route) => ({
69
73
  method: route.method,
70
74
  path: route.path,
@@ -106,6 +110,9 @@ export function registerExtensionRoutes(app, ctx) {
106
110
  interactionKinds: [],
107
111
  ambientKeys: [],
108
112
  workers: [],
113
+ services: request.principal.admin
114
+ ? opened.userServices.statuses().filter((s) => s.slug === slug)
115
+ : [],
109
116
  routes: [],
110
117
  });
111
118
  }
@@ -113,13 +120,32 @@ export function registerExtensionRoutes(app, ctx) {
113
120
  });
114
121
  // The per-extension settings object (`settings/<slug>.json`), absent
115
122
  // file → `{}`. GET is ungated beyond the pipeline: settings values are
116
- // operator-facing config, not secrets.
123
+ // operator-facing config, not secrets. U4 (spec §13.2): the response
124
+ // carries `pendingRestart` too — extension-declared fields default to
125
+ // LIVE (§13.1) and core carries no extension-restart keys, so the list
126
+ // is always `[]` today; the field exists so the §13.4 callout renders
127
+ // from one shape and an extension that later declares restart fields
128
+ // has its slot.
117
129
  app.get("/v1/extensions/:slug/settings", async (request, reply) => {
118
130
  const { slug } = request.params;
119
131
  if (!isKnownSlug(slug)) {
120
132
  return await reply.code(404).send({ error: "extension_not_found" });
121
133
  }
122
- return opened.extensionStores.settingsFor(slug).get();
134
+ const stored = await opened.extensionStores.settingsFor(slug).get();
135
+ // Declared secret keys NEVER leave this route (U4 fix wave): a
136
+ // settings file predating the write-only rule (or a hand edit)
137
+ // can carry one in the clear — the surface's read path strips
138
+ // it; the raw GET must too.
139
+ const declaredSecrets = new Set((opened.extensionSettingsSections.find((binding) => binding.extension === slug)?.defs ?? []).flatMap((def) => (def.secrets ?? []).map((secret) => secret.key)));
140
+ const settings = {};
141
+ for (const [key, value] of Object.entries(stored)) {
142
+ if (!declaredSecrets.has(key))
143
+ settings[key] = value;
144
+ }
145
+ return {
146
+ settings,
147
+ pendingRestart: [],
148
+ };
123
149
  });
124
150
  // Replace the settings object VERBATIM (admin — a settings file is
125
151
  // code-adjacent input the extension consumes). The body must be a
@@ -142,7 +168,13 @@ export function registerExtensionRoutes(app, ctx) {
142
168
  }
143
169
  const value = body;
144
170
  await opened.extensionStores.settingsFor(slug).set(value);
145
- return value;
171
+ // U4 §13.2: the write committed — publish `settings.changed`
172
+ // (extension scope) and fire every extension's
173
+ // `onSettingsChanged`. The keys are the object's TOP-LEVEL
174
+ // fields (§13.1's extension vocabulary: an extension's fields
175
+ // are its own schema; core's dotted registry does not apply).
176
+ ctx.publishSettingsChanged({ extension: slug }, Object.keys(value));
177
+ return { settings: value, pendingRestart: [] };
146
178
  });
147
179
  // The secret key list: stored keys plus the env fallbacks that are not
148
180
  // stored. Key names and sources only — the response never carries a
@@ -0,0 +1,267 @@
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 (`<cacheDir>/images/`,
21
+ * `images/cache.ts`): a hit re-serves without an upstream call, and
22
+ * concurrent requests for one URL share ONE in-flight upstream fetch
23
+ * (Review Focus 4). Failures are never cached. Responses carry the
24
+ * sniffed Content-Type, `X-Content-Type-Options: nosniff`, and
25
+ * `Cache-Control: private, max-age=<upstream hint clamped to 1 h–7 d>`.
26
+ */
27
+ import { open, realpath } from "node:fs/promises";
28
+ import { lookup as dnsLookup } from "node:dns/promises";
29
+ import { relative, sep } from "node:path";
30
+ import { join } from "node:path";
31
+ import { toPng } from "../images/convert.js";
32
+ import { FetchRefusal, fetchImage, } from "../images/fetch.js";
33
+ import { verify } from "../images/sign.js";
34
+ import { isAcceptedImageType, sniffImage } from "../images/sniff.js";
35
+ /** The Cache-Control clamp (spec §11.2): between 1 hour and 7 days. */
36
+ const MAX_AGE_MIN_SECONDS = 3600;
37
+ const MAX_AGE_MAX_SECONDS = 7 * 24 * 3600;
38
+ /** The §11.1 data-URL cap: 192 KiB of base64 text (the POST bodyLimit). */
39
+ const DATA_URL_MAX_CHARS = 192 * 1024;
40
+ /** MIME types by sniffed image type. */
41
+ const MIME = {
42
+ png: "image/png",
43
+ jpeg: "image/jpeg",
44
+ gif: "image/gif",
45
+ webp: "image/webp",
46
+ };
47
+ /** The real fetch deps: dns.lookup + the wall clock. */
48
+ function defaultFetchDeps() {
49
+ return {
50
+ resolve: async (host) => {
51
+ const addresses = await dnsLookup(host, { all: true, verbatim: true });
52
+ return addresses.map((entry) => entry.address);
53
+ },
54
+ now: Date.now,
55
+ };
56
+ }
57
+ /** Resolve one requested asset path inside the package's assets dir
58
+ * (the ext-assets.ts rule, spec §11.1): realpath BOTH sides; an escape
59
+ * or a missing file is `undefined` (the route answers 404). */
60
+ async function resolveContained(assetsDir, path) {
61
+ let realDir;
62
+ try {
63
+ realDir = await realpath(assetsDir);
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ const candidate = join(realDir, path);
69
+ let realFile;
70
+ try {
71
+ realFile = await realpath(candidate);
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ const rel = relative(realDir, realFile);
77
+ if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
78
+ return undefined;
79
+ }
80
+ return realFile;
81
+ }
82
+ /** Read a whole (small, cache-bounded) file. */
83
+ async function readAll(file) {
84
+ const handle = await open(file, "r");
85
+ try {
86
+ return await handle.readFile();
87
+ }
88
+ finally {
89
+ await handle.close();
90
+ }
91
+ }
92
+ /** Decode a §11.1 data URL (`data:image/png|jpeg|gif|webp;base64,…`). */
93
+ function decodeDataUrl(src) {
94
+ const match = /^data:image\/(png|jpeg|gif|webp);base64,(.*)$/s.exec(src);
95
+ if (match === null)
96
+ return undefined;
97
+ return Buffer.from(match[2] ?? "", "base64");
98
+ }
99
+ /** Clamp the upstream max-age hint into the 1 h–7 d band. */
100
+ function clampedMaxAge(maxAge) {
101
+ if (maxAge === undefined)
102
+ return MAX_AGE_MIN_SECONDS;
103
+ return Math.min(MAX_AGE_MAX_SECONDS, Math.max(MAX_AGE_MIN_SECONDS, maxAge));
104
+ }
105
+ /**
106
+ * Register the image-proxy routes. `ctx` carries the shared cache and
107
+ * the (test-injectable) fetch deps on `ctx.imageProxy`, populated by
108
+ * `createServer`.
109
+ */
110
+ export function registerImageRoutes(app, ctx) {
111
+ const { cache, deps, key } = ctx.imageProxy;
112
+ /** In-flight upstream fetches (Review Focus 4): concurrent requests
113
+ * for one (url, variant) share ONE fetch + ONE cache write. */
114
+ const inflight = new Map();
115
+ /** Fetch one URL through the cache, sharing in-flight work. */
116
+ async function fetchThroughCache(u, variant) {
117
+ const hit = await cache.get(u, variant);
118
+ if (hit !== undefined) {
119
+ const chunks = [];
120
+ for await (const chunk of hit.stream)
121
+ chunks.push(chunk);
122
+ return { bytes: Buffer.concat(chunks), type: hit.type };
123
+ }
124
+ const inflightKey = `${variant}${u}`;
125
+ const existing = inflight.get(inflightKey);
126
+ if (existing !== undefined)
127
+ return await existing;
128
+ const work = (async () => {
129
+ const fetched = await fetchImage(u, deps);
130
+ // Cache the original variant first — `as=png` requests cache
131
+ // BOTH variants (the conversion reads the fetched bytes).
132
+ await cache.put(u, "orig", fetched.type, fetched.bytes);
133
+ if (variant === "png") {
134
+ const converted = await toPng(fetched.bytes);
135
+ await cache.put(u, "png", "png", converted);
136
+ return {
137
+ bytes: converted,
138
+ type: "png",
139
+ ...(fetched.maxAge === undefined ? {} : { maxAge: fetched.maxAge }),
140
+ };
141
+ }
142
+ return fetched;
143
+ })();
144
+ inflight.set(inflightKey, work);
145
+ try {
146
+ return await work;
147
+ }
148
+ finally {
149
+ inflight.delete(inflightKey);
150
+ }
151
+ }
152
+ /** Serve one resolved image. */
153
+ async function sendImage(reply, image) {
154
+ return await reply
155
+ .header("content-type", MIME[image.type])
156
+ .header("x-content-type-options", "nosniff")
157
+ .header("cache-control", `private, max-age=${clampedMaxAge(image.maxAge)}`)
158
+ .send(image.bytes);
159
+ }
160
+ /** Resolve the `src=` form (asset or data URL, both already
161
+ * validated by §11.1's own rules — nothing to sign). */
162
+ async function resolveSource(src) {
163
+ if (src.startsWith("asset:")) {
164
+ const rest = src.slice("asset:".length);
165
+ const slash = rest.indexOf("/");
166
+ if (slash <= 0)
167
+ return "bad";
168
+ const slug = rest.slice(0, slash);
169
+ const path = rest.slice(slash + 1);
170
+ const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
171
+ if (binding?.assetsDir === undefined)
172
+ return undefined;
173
+ const file = await resolveContained(binding.assetsDir, path);
174
+ if (file === undefined)
175
+ return undefined;
176
+ const bytes = await readAll(file);
177
+ const sniffed = sniffImage(bytes);
178
+ if (!isAcceptedImageType(sniffed))
179
+ return undefined;
180
+ return { bytes, type: sniffed };
181
+ }
182
+ if (src.startsWith("data:")) {
183
+ if (src.length > DATA_URL_MAX_CHARS)
184
+ return undefined;
185
+ const bytes = decodeDataUrl(src);
186
+ if (bytes === undefined)
187
+ return "bad";
188
+ const sniffed = sniffImage(bytes);
189
+ if (!isAcceptedImageType(sniffed))
190
+ return undefined;
191
+ return { bytes, type: sniffed };
192
+ }
193
+ return "bad";
194
+ }
195
+ app.get("/v1/image", async (request, reply) => {
196
+ const { u, e, s, src, as } = request.query;
197
+ const wantPng = as === "png";
198
+ // The src form: no signature (assets and data URLs carry their
199
+ // own §11.1 validation).
200
+ if (src !== undefined) {
201
+ const resolved = await resolveSource(src);
202
+ if (resolved === "bad") {
203
+ return await reply.code(400).send({ error: "bad_source" });
204
+ }
205
+ if (resolved === undefined) {
206
+ // Containment misses and over-long data URLs are 404/413 —
207
+ // indistinguishable from a missing asset / a too-large body.
208
+ if (src.startsWith("data:") && src.length > DATA_URL_MAX_CHARS) {
209
+ return await reply.code(413).send({
210
+ error: "too_large",
211
+ maxBytes: DATA_URL_MAX_CHARS,
212
+ });
213
+ }
214
+ return await reply.code(404).send({ error: "asset_not_found" });
215
+ }
216
+ const image = wantPng
217
+ ? { bytes: await toPng(resolved.bytes), type: "png" }
218
+ : resolved;
219
+ return await sendImage(reply, image);
220
+ }
221
+ // The signed form.
222
+ if (u === undefined || e === undefined || s === undefined) {
223
+ return await reply.code(400).send({ error: "bad_signature" });
224
+ }
225
+ if (!/^\d+$/.test(e)) {
226
+ return await reply.code(403).send({ error: "malformed_expiry" });
227
+ }
228
+ // Split the verify so the expired case names its own error: a
229
+ // signature that MATCHES but whose expiry passed is `expired`;
230
+ // a mismatch is `bad_signature`.
231
+ if (!verify(key, u, e, s, Number(e))) {
232
+ return await reply.code(403).send({ error: "bad_signature" });
233
+ }
234
+ if (Date.now() > Number(e)) {
235
+ return await reply.code(403).send({ error: "expired" });
236
+ }
237
+ try {
238
+ const image = await fetchThroughCache(u, wantPng ? "png" : "orig");
239
+ return await sendImage(reply, image);
240
+ }
241
+ catch (cause) {
242
+ if (cause instanceof FetchRefusal) {
243
+ return await reply
244
+ .code(502)
245
+ .send({ error: cause.reason, message: cause.message });
246
+ }
247
+ throw cause;
248
+ }
249
+ });
250
+ // The long-data-URL form: a `data:` source too long for a URL is
251
+ // POSTed as the raw body. `bodyLimit` is the §11.1 192 KiB rule; the
252
+ // server's one error handler turns an over-limit body into 413
253
+ // `too_large`. The `text/plain` string parser is already registered
254
+ // app-wide by the conversations route module (the JSONL import's
255
+ // raw-text parser).
256
+ app.post("/v1/image", { bodyLimit: DATA_URL_MAX_CHARS }, async (request, reply) => {
257
+ const src = typeof request.body === "string" ? request.body : "";
258
+ const resolved = await resolveSource(src);
259
+ if (resolved === "bad" || resolved === undefined) {
260
+ return await reply.code(400).send({ error: "bad_source" });
261
+ }
262
+ const image = request.query.as === "png"
263
+ ? { bytes: await toPng(resolved.bytes), type: "png" }
264
+ : resolved;
265
+ return await sendImage(reply, image);
266
+ });
267
+ }
@@ -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