@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
@@ -15,15 +15,55 @@ import { MutationQueue, readRecords, writeJson } from "./json-store.js";
15
15
  /** The username rule (spec §5.8): lowercase-first, then `[a-z0-9._-]`. */
16
16
  const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
17
17
  /**
18
- * The file-backed user directory. Reads are synchronous (mtime reload
19
- * per access); every mutation is a whole-file read-modify-write that
20
- * serializes on the store's {@link MutationQueue}.
18
+ * Strip the retired C8 field at every write boundary. A legacy on-disk
19
+ * value is readable for compatibility, but never survives another write
20
+ * and never participates in effective-admin derivation.
21
21
  */
22
+ function withoutLegacyGroupAdmin(record) {
23
+ const clean = { ...record };
24
+ delete clean.groupAdmin;
25
+ return clean;
26
+ }
27
+ export class AdmissionRefusedError extends Error {
28
+ code;
29
+ constructor(code, message) {
30
+ super(message);
31
+ this.code = code;
32
+ this.name = "AdmissionRefusedError";
33
+ }
34
+ }
35
+ /** The one multi-user admission policy shared by server boot and the CLI. */
36
+ export function createAdmissionGuard(opts) {
37
+ return () => {
38
+ if (!opts.isolation.available || opts.mode === "off") {
39
+ throw new AdmissionRefusedError("isolation_required", `isolation_required: ${opts.isolation.reason ?? "isolation mode is off"}`);
40
+ }
41
+ if (opts.methods.length === 0) {
42
+ throw new AdmissionRefusedError("auth_required", "auth_required: configure token or proxy authentication before adding another user");
43
+ }
44
+ if (opts.methods.includes("proxy") && opts.proxySecretHeader === undefined) {
45
+ throw new AdmissionRefusedError("isolation_required", "isolation_required: proxy authentication requires server.auth.proxy.secretHeader before isolation can activate");
46
+ }
47
+ };
48
+ }
22
49
  export class UserDirectory {
23
50
  path;
51
+ options;
24
52
  mutations = new MutationQueue();
25
- constructor(path) {
53
+ constructor(path, options = {}) {
26
54
  this.path = path;
55
+ this.options = options;
56
+ }
57
+ /** Attach the process's admission policy (boot/CLI), preserving existing rows. */
58
+ configureAdmission(options) {
59
+ this.options = { ...this.options, ...options };
60
+ }
61
+ async writeRecords(records) {
62
+ await writeJson(this.path, records.map(withoutLegacyGroupAdmin));
63
+ }
64
+ /** Run the same admission guard used by add, before consuming an invite. */
65
+ checkAdmission() {
66
+ this.options.admit?.(this.list().length);
27
67
  }
28
68
  /** Every user, insertion order (the first add is the onboarding claim). */
29
69
  list() {
@@ -55,16 +95,21 @@ export class UserDirectory {
55
95
  if (records.some((record) => record.user === user.user)) {
56
96
  throw new Error(`user "${user.user}" already exists`);
57
97
  }
58
- const record = { ...user, createdAt: Date.now() };
59
- await writeJson(this.path, [...records, record]);
98
+ if (records.length >= 1) {
99
+ this.options.admit?.(records.length);
100
+ if (records.length === 1 && records[0].workspace === undefined && this.options.firstWorkspace !== undefined) {
101
+ records[0] = { ...records[0], workspace: this.options.firstWorkspace };
102
+ }
103
+ }
104
+ const record = withoutLegacyGroupAdmin({ ...user, createdAt: Date.now() });
105
+ await this.writeRecords([...records, record]);
60
106
  return record;
61
107
  });
62
108
  }
63
109
  /**
64
110
  * Patch one user's mutable fields (`name`/`email`/`admin` — the
65
- * username itself is immutable; `groupAdmin` is the pipeline's own
66
- * write-through field, never the PATCH route's surface). Absent user
67
- * throws; the file is rewritten atomically.
111
+ * username itself is immutable). Absent user throws; the file is
112
+ * rewritten atomically and any legacy `groupAdmin` value is dropped.
68
113
  */
69
114
  update(user, patch) {
70
115
  return this.mutations.run(async () => {
@@ -73,12 +118,66 @@ export class UserDirectory {
73
118
  if (index === -1) {
74
119
  throw new Error(`unknown user "${user}"`);
75
120
  }
76
- const updated = { ...records[index], ...patch };
121
+ const updated = withoutLegacyGroupAdmin({ ...records[index], ...patch });
122
+ records[index] = updated;
123
+ await this.writeRecords(records);
124
+ return updated;
125
+ });
126
+ }
127
+ /**
128
+ * Replace one source's groups. Identical values are a true no-op:
129
+ * neither the file nor its mtime changes (busy proxy requests must
130
+ * not rewrite the directory). Other sources are preserved.
131
+ */
132
+ setGroups(user, source, values, now = Date.now()) {
133
+ return this.mutations.run(async () => {
134
+ const records = this.list();
135
+ const index = records.findIndex((record) => record.user === user);
136
+ if (index === -1)
137
+ throw new Error(`unknown user "${user}"`);
138
+ const current = records[index];
139
+ const groups = [...(current.groups ?? [])];
140
+ const sourceIndex = groups.findIndex((entry) => entry.source === source);
141
+ const previous = sourceIndex === -1 ? undefined : groups[sourceIndex];
142
+ if (previous !== undefined &&
143
+ previous.values.length === values.length &&
144
+ previous.values.every((value, i) => value === values[i])) {
145
+ return current;
146
+ }
147
+ const entry = { source, values: [...values], updatedAt: now };
148
+ if (sourceIndex === -1)
149
+ groups.push(entry);
150
+ else
151
+ groups[sourceIndex] = entry;
152
+ const updated = withoutLegacyGroupAdmin({ ...current, groups });
77
153
  records[index] = updated;
78
- await writeJson(this.path, records);
154
+ await this.writeRecords(records);
79
155
  return updated;
80
156
  });
81
157
  }
158
+ /**
159
+ * Remove one user and append a transfer grant to the recipient in one
160
+ * serialized read-modify-write. Current recipient grants are read inside
161
+ * the mutation, so a queued concurrent update cannot be clobbered.
162
+ */
163
+ removeWithGrant(user, to, grant) {
164
+ return this.mutations.run(async () => {
165
+ const records = this.list();
166
+ const sourceIndex = records.findIndex((record) => record.user === user);
167
+ if (sourceIndex === -1)
168
+ throw new Error(`unknown user "${user}"`);
169
+ const recipientIndex = records.findIndex((record) => record.user === to);
170
+ if (recipientIndex === -1)
171
+ throw new Error(`unknown user "${to}"`);
172
+ const recipient = records[recipientIndex];
173
+ records[recipientIndex] = {
174
+ ...recipient,
175
+ grants: [...(recipient.grants ?? []), grant],
176
+ };
177
+ records.splice(sourceIndex, 1);
178
+ await this.writeRecords(records);
179
+ });
180
+ }
82
181
  /** Remove one user (their tokens are NOT revoked here — caller's job). */
83
182
  remove(user) {
84
183
  return this.mutations.run(async () => {
@@ -88,7 +187,7 @@ export class UserDirectory {
88
187
  throw new Error(`unknown user "${user}"`);
89
188
  }
90
189
  records.splice(index, 1);
91
- await writeJson(this.path, records);
190
+ await this.writeRecords(records);
92
191
  });
93
192
  }
94
193
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The interaction seam (spec: docs/approval-ux-seams.md): core-owned,
3
- * channel-neutral records for "this conversation is waiting on a human
4
- * decision", written by extensions through the host context and answered
5
- * by channels through one generic route.
2
+ * The interaction seam (spec: docs/approval-ux-seams.md; the block
3
+ * mapping: UI-blocks design §9.1): core-owned, channel-neutral records for "this conversation is
4
+ * waiting on a human decision", written by extensions through the host
5
+ * context and answered by channels through one generic route.
6
6
  *
7
7
  * SPLIT (build-and-webui Task 1): the canonical TYPE declarations live in
8
8
  * the `@sigrun/shared` package (`shared/src/interactions.ts` — the records
@@ -10,12 +10,13 @@
10
10
  * this module owns the docs, validation, and index RUNTIME, importing the
11
11
  * types (type-only — no runtime dependency on the built package).
12
12
  *
13
- * Layering (strict envelope / closed primitives / opaque details):
13
+ * Layering (strict envelope / block-tree body / opaque details):
14
14
  *
15
15
  * - envelope: kind, callId, status, timestamps, answer — load-bearing for
16
16
  * joining tool slots and lifecycle arbitration. Strictly validated.
17
- * - body: composed ONLY from the closed primitives (text, options,
18
- * fields, diff). Structurally validated; unknown body keys quarantine.
17
+ * - body: a block tree (placement `interaction` — a root `form` whose
18
+ * submit is the answer). Validated with `validateTree`; legacy-shaped
19
+ * bodies FAIL with paths (ruling 16 — no compatibility layer).
19
20
  * - details: opaque extension-owned JSON. Never interpreted.
20
21
  *
21
22
  * Answer transport: the generic route commits `answer` into the record.
@@ -32,6 +33,30 @@
32
33
  * read the list route, which snapshots the index and then the records.
33
34
  */
34
35
  import { defineDoc, defineDocFamily } from "@earendil-works/pi-durable";
36
+ import { validateTree } from "../blocks/validate.js";
37
+ /**
38
+ * Narrow a stored record to the public wire record. SAFETY: the two
39
+ * shapes are identical at runtime — a stored body passed `validateTree`
40
+ * (placement `interaction`) at write time, and a validated tree IS the
41
+ * JSON the mirror describes; the cast only restores the type-level
42
+ * naming `JsonObject`-erasure removed.
43
+ */
44
+ export function asInteractionRecord(value) {
45
+ // SAFETY: see the doc comment — the mirror differs only in the erased
46
+ // body's structural naming; a validated tree is that JSON.
47
+ return value;
48
+ }
49
+ /**
50
+ * Widen the public wire record to the stored shape (the seed path's
51
+ * `Object.assign` input). SAFETY: same runtime shape as
52
+ * `asInteractionRecord` — the body was validated as a tree moments
53
+ * before this cast in `validateInteraction`.
54
+ */
55
+ export function asStoredInteractionRecord(value) {
56
+ // SAFETY: see the doc comment — the mirror differs only in the erased
57
+ // body's structural naming; a validated tree is that JSON.
58
+ return value;
59
+ }
35
60
  /** The interaction record doc family: one record per callId, per conversation. */
36
61
  export const InteractionDoc = defineDocFamily({
37
62
  kind: "sigrun.interaction",
@@ -63,19 +88,11 @@ export const TERMINAL = new Set([
63
88
  "expired",
64
89
  "cancelled",
65
90
  ]);
66
- const TONES = new Set([
67
- "danger",
68
- "warn",
69
- "ok",
70
- "neutral",
71
- ]);
72
- const FIELD_TYPES = new Set([
73
- "string",
74
- "number",
75
- "boolean",
76
- "secret",
77
- ]);
78
- /** Structural validation of one interaction record. Returns the error text or null. */
91
+ /**
92
+ * Structural validation of one interaction record: the envelope's own
93
+ * checks, then the body tree through `validateTree({placement:
94
+ * "interaction"})`. Returns the error text (with the tree paths) or null.
95
+ */
79
96
  export function validateInteraction(value) {
80
97
  if (typeof value !== "object" || value === null) {
81
98
  return "interaction record must be an object";
@@ -108,125 +125,43 @@ export function validateInteraction(value) {
108
125
  if (r.answeredBy !== undefined && r.answeredBy !== "human" && r.answeredBy !== "extension") {
109
126
  return "answeredBy must be 'human' or 'extension'";
110
127
  }
111
- const body = r.body;
112
- if (body !== undefined) {
113
- if (typeof body !== "object" || body === null) {
114
- return "body must be an object";
115
- }
116
- const b = body;
117
- const known = new Set(["text", "options", "fields", "diff"]);
118
- const unknownKey = Object.keys(b).find((k) => !known.has(k));
119
- if (unknownKey !== undefined) {
120
- return `body.${unknownKey} is outside the closed primitives (move it to details)`;
121
- }
122
- if (b.text !== undefined && typeof b.text !== "string") {
123
- return "body.text must be a string";
124
- }
125
- if (b.options !== undefined) {
126
- if (!Array.isArray(b.options))
127
- return "body.options must be an array";
128
- for (const o of b.options) {
129
- const problem = validateOption(o);
130
- if (problem !== null)
131
- return problem;
132
- }
133
- }
134
- if (b.fields !== undefined) {
135
- if (!Array.isArray(b.fields))
136
- return "body.fields must be an array";
137
- for (const f of b.fields) {
138
- const problem = validateField(f);
139
- if (problem !== null)
140
- return problem;
141
- }
142
- }
143
- if (b.diff !== undefined) {
144
- const problem = validateDiff(b.diff);
145
- if (problem !== null)
146
- return problem;
128
+ if (r.body !== undefined) {
129
+ // Ruling 16 (no compatibility layer): legacy-shaped bodies (objects
130
+ // keyed text/options/fields/diff rather than {v, root}) fail HERE,
131
+ // with the path — a tree is {v, root}.
132
+ const result = validateTree(r.body, { placement: "interaction" });
133
+ if (!result.ok) {
134
+ const first = result.errors[0];
135
+ if (first === undefined)
136
+ return "body must be a block tree";
137
+ // The tree-relative path joins the record's field ("body.root…")
138
+ // so the error names where the record is wrong, not just the tree.
139
+ return `body is not a valid interaction tree: body.${first.path} ${first.message}`;
147
140
  }
148
141
  }
149
142
  return null;
150
143
  }
151
- function validateAnswer(value) {
144
+ /**
145
+ * An answer is `{values}` ONLY (ruling 20): every field's value under its
146
+ * name. The legacy `{ids, text}` (and the string/number/boolean-only
147
+ * `values`) are rejected — no compatibility layer.
148
+ */
149
+ export function validateAnswer(value) {
152
150
  if (typeof value !== "object" || value === null) {
153
151
  return "answer must be an object";
154
152
  }
155
153
  const a = value;
156
- const known = new Set(["ids", "values", "text"]);
154
+ const known = new Set(["values"]);
157
155
  const unknownKey = Object.keys(a).find((k) => !known.has(k));
158
- if (unknownKey !== undefined)
159
- return `answer.${unknownKey} is not a valid field`;
160
- if (a.ids !== undefined && !Array.isArray(a.ids)) {
161
- return "answer.ids must be an array of strings";
162
- }
163
- if (Array.isArray(a.ids) && a.ids.some((id) => typeof id !== "string")) {
164
- return "answer.ids must be an array of strings";
156
+ if (unknownKey !== undefined) {
157
+ return `answer.${unknownKey} is not a valid field (an answer is {values})`;
165
158
  }
166
- if (a.values !== undefined) {
167
- if (typeof a.values !== "object" || a.values === null) {
168
- return "answer.values must be an object";
169
- }
170
- const bad = Object.entries(a.values).find(([, v]) => typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean");
171
- if (bad !== undefined) {
172
- return `answer.values.${bad[0]} must be string, number, or boolean`;
173
- }
174
- }
175
- if (a.text !== undefined && typeof a.text !== "string") {
176
- return "answer.text must be a string";
177
- }
178
- return null;
179
- }
180
- function validateOption(value) {
181
- if (typeof value !== "object" || value === null)
182
- return "option must be an object";
183
- const o = value;
184
- if (typeof o.id !== "string" || o.id.length === 0)
185
- return "option.id must be a non-empty string";
186
- if (typeof o.label !== "string" || o.label.length === 0) {
187
- return "option.label must be a non-empty string";
188
- }
189
- if (o.description !== undefined && typeof o.description !== "string") {
190
- return "option.description must be a string";
191
- }
192
- if (o.tone !== undefined && !TONES.has(String(o.tone))) {
193
- return `option.tone must be one of danger|warn|ok|neutral (got ${String(o.tone)})`;
194
- }
195
- if (o.recommended !== undefined && typeof o.recommended !== "boolean") {
196
- return "option.recommended must be a boolean";
197
- }
198
- return null;
199
- }
200
- function validateField(value) {
201
- if (typeof value !== "object" || value === null)
202
- return "field must be an object";
203
- const f = value;
204
- if (typeof f.name !== "string" || f.name.length === 0) {
205
- return "field.name must be a non-empty string";
206
- }
207
- if (typeof f.type !== "string" || !FIELD_TYPES.has(f.type)) {
208
- return `field.type must be one of string|number|boolean|secret (got ${String(f.type)})`;
159
+ if (a.values === undefined) {
160
+ return "answer.values is required";
209
161
  }
210
- if (f.label !== undefined && typeof f.label !== "string") {
211
- return "field.label must be a string";
162
+ if (typeof a.values !== "object" || a.values === null || Array.isArray(a.values)) {
163
+ return "answer.values must be an object";
212
164
  }
213
- if (f.required !== undefined && typeof f.required !== "boolean") {
214
- return "field.required must be a boolean";
215
- }
216
- return null;
217
- }
218
- function validateDiff(value) {
219
- if (typeof value !== "object" || value === null)
220
- return "diff must be an object";
221
- const d = value;
222
- if (typeof d.before !== "string")
223
- return "diff.before must be a string";
224
- if (typeof d.after !== "string")
225
- return "diff.after must be a string";
226
- if (d.path !== undefined && typeof d.path !== "string")
227
- return "diff.path must be a string";
228
- if (d.lang !== undefined && typeof d.lang !== "string")
229
- return "diff.lang must be a string";
230
165
  return null;
231
166
  }
232
167
  /**
@@ -255,23 +190,196 @@ export function indexSettled(draft, conversationId, callId) {
255
190
  draft.pending[key] = next;
256
191
  }
257
192
  }
193
+ /** A JSON value that is structurally valid in an answer's `values`. */
194
+ function isJsonValue(value) {
195
+ if (value === null)
196
+ return true;
197
+ const t = typeof value;
198
+ if (t === "string" || t === "number" || t === "boolean")
199
+ return true;
200
+ if (t !== "object")
201
+ return false;
202
+ if (Array.isArray(value))
203
+ return value.every(isJsonValue);
204
+ for (const child of Object.values(value)) {
205
+ if (!isJsonValue(child))
206
+ return false;
207
+ }
208
+ return true;
209
+ }
210
+ /**
211
+ * The literal that replaces a `secret` field's value in every STORED answer
212
+ * (Review Focus 4, U6 Task 2): the stored record, every HTTP read, and the
213
+ * wire only ever carry this marker. The extension receives the true value
214
+ * exactly once, transiently, through the host-side read (the delivery
215
+ * table below).
216
+ */
217
+ export const SECRET_MASK = "[secret]";
218
+ /**
219
+ * The transient full-answer delivery table (supervisor ruling, U6 Task 2):
220
+ * in-memory, runtime-owned, keyed (conversationId, callId). A `secret`
221
+ * field's value never lands in the stored record — the answer commit
222
+ * writes the MASK into the record and records the TRUE answer here, and
223
+ * the extension-only read path (`host.snapshotInteraction`) merges the true
224
+ * values back once, then deletes the entry (one-shot delivery; the
225
+ * extension is expected to persist the secret in its own scoped stores).
226
+ * The table is deliberately NOT durable: a crash between the answer commit
227
+ * and the extension's poll loses the value and the human re-answers — the
228
+ * safe failure mode (a raw secret must never survive a restart in the DB).
229
+ */
230
+ /**
231
+ * Delivery TTL (minor 5): a delivery whose interaction never settles and
232
+ * is never polled (the host crashed mid-round-trip, the extension was
233
+ * uninstalled) must not hold a raw secret forever — expire after an hour.
234
+ */
235
+ const SECRET_DELIVERY_TTL_MS = 60 * 60 * 1000;
236
+ class SecretDeliveries {
237
+ /** conversationId|callId → the true answer (+ recordedAt), waiting for one read. */
238
+ #pending = new Map();
239
+ /** Drop entries older than the TTL (a never-settled secret has a bound). */
240
+ #expire(now) {
241
+ for (const [key, entry] of this.#pending) {
242
+ if (now - entry.recordedAt > SECRET_DELIVERY_TTL_MS) {
243
+ this.#pending.delete(key);
244
+ }
245
+ }
246
+ }
247
+ /** Record the true answer for one settled interaction (answer path). */
248
+ record(conversationId, callId, answer) {
249
+ const now = Date.now();
250
+ this.#expire(now);
251
+ this.#pending.set(`${String(conversationId)}|${callId}`, {
252
+ answer,
253
+ recordedAt: now,
254
+ });
255
+ }
256
+ /**
257
+ * Take the delivery once, if present: the true answer, then delete.
258
+ * Returns `undefined` when nothing was recorded (no secret, or already
259
+ * delivered, or swept, or past the TTL).
260
+ */
261
+ take(conversationId, callId) {
262
+ const now = Date.now();
263
+ this.#expire(now);
264
+ const key = `${String(conversationId)}|${callId}`;
265
+ const entry = this.#pending.get(key);
266
+ if (entry === undefined)
267
+ return undefined;
268
+ this.#pending.delete(key);
269
+ return entry.answer;
270
+ }
271
+ /** Drop the entry without reading it (settle/expiry sweep — no leak). */
272
+ sweep(conversationId, callId) {
273
+ this.#pending.delete(`${String(conversationId)}|${callId}`);
274
+ }
275
+ }
276
+ /** The session-singleton delivery table (one per boot). */
277
+ const secretDeliveries = new SecretDeliveries();
278
+ /**
279
+ * The names of the `secret` fields in an interaction's form tree: every
280
+ * `field` node with `type: "secret"`, from the root form and its sections.
281
+ * Used by the answer path to strip values before they land in the record.
282
+ */
283
+ function secretFieldNames(body) {
284
+ const names = new Set();
285
+ if (typeof body !== "object" || body === null)
286
+ return names;
287
+ const root = body.root;
288
+ if (typeof root !== "object" || root === null)
289
+ return names;
290
+ /**
291
+ * The block schema's child slots: every place a nested block or field
292
+ * can live (schema.ts:17-22). A generic descent that walks ONLY these
293
+ * slots — recursing over every record/array value would trip on data
294
+ * props (rows, series) that happen to carry a `{type, name}` record.
295
+ */
296
+ const walkNode = (node) => {
297
+ if (typeof node !== "object" || node === null)
298
+ return;
299
+ const rec = node;
300
+ if (rec.type === "secret" && typeof rec.name === "string") {
301
+ names.add(rec.name);
302
+ }
303
+ walkNodeList(rec.children);
304
+ walkNodeList(rec.main);
305
+ walkNodeList(rec.sidebar);
306
+ walkNodeList(rec.detail);
307
+ walkNode(rec.item);
308
+ walkNode(rec.switcher);
309
+ if (Array.isArray(rec.bulkActions)) {
310
+ for (const bulk of rec.bulkActions) {
311
+ if (typeof bulk !== "object" || bulk === null)
312
+ continue;
313
+ walkNode(bulk.form?.root);
314
+ }
315
+ }
316
+ };
317
+ const walkNodeList = (nodes) => {
318
+ if (!Array.isArray(nodes))
319
+ return;
320
+ for (const child of nodes) {
321
+ if (typeof child !== "object" || child === null)
322
+ continue;
323
+ // columns/grid children are `{weight?, block}` wrappers. Walk
324
+ // BOTH: unknown props pass validation, so a stray `block` prop
325
+ // on a real node must not shadow the node's own children.
326
+ const rec = child;
327
+ walkNode(child);
328
+ if (rec.block !== undefined)
329
+ walkNode(rec.block);
330
+ }
331
+ };
332
+ walkNode(root);
333
+ return names;
334
+ }
335
+ /**
336
+ * The ONE masking rule (minor 3): secret names map to {@link SECRET_MASK},
337
+ * every other value passes through. Shared by `applyAnswer` (the stored
338
+ * write) and `maskedAnswerValues` (the route's response projection) so the
339
+ * two can never drift. `dropNonJson` mirrors applyAnswer's belt: values
340
+ * failing `isJsonValue` are skipped (the projection's inputs are already
341
+ * validated, so it passes false and keeps every value).
342
+ */
343
+ function maskValues(secrets, values, dropNonJson = true) {
344
+ const masked = {};
345
+ for (const [key, value] of Object.entries(values)) {
346
+ if (dropNonJson && !isJsonValue(value))
347
+ continue;
348
+ masked[key] = secrets.has(key) ? SECRET_MASK : value;
349
+ }
350
+ return masked;
351
+ }
258
352
  /**
259
353
  * The committed answer for one record, or `undefined` when the record was
260
354
  * already terminal (first-answer-wins: the caller must NOT overwrite).
261
355
  * The draft is mutated in place — this runs inside the caller's commit.
356
+ *
357
+ * Review Focus 4: when the interaction's form carries `secret` fields, the
358
+ * stored answer's values for those names are replaced by
359
+ * {@link SECRET_MASK} and the TRUE answer is recorded in the delivery
360
+ * table for the extension's one-shot read (`opts.conversationId` — the
361
+ * table is keyed (conversation, callId), and only the caller knows the
362
+ * conversation; omitting it skips the delivery).
262
363
  */
263
- export function applyAnswer(draft, answer, answeredBy, now, answeredByUser) {
364
+ export function applyAnswer(draft, answer, answeredBy, now, answeredByUser, opts) {
264
365
  if (draft.status !== "pending")
265
366
  return false;
266
- const clean = {};
267
- if (answer.ids !== undefined)
268
- clean.ids = [...answer.ids];
269
- if (answer.values !== undefined)
270
- clean.values = { ...answer.values };
271
- if (answer.text !== undefined)
272
- clean.text = answer.text;
367
+ // Review Focus 4: the SECRET rule. The tree's secret field names decide
368
+ // which values never persist: the stored copy carries the MASK, the
369
+ // true copy rides the one-shot delivery table (extension-only read).
370
+ const secrets = secretFieldNames(draft.body);
371
+ const values = maskValues(secrets, answer.values ?? {});
372
+ const hasSecret = Object.values(values).some((value) => value === SECRET_MASK);
373
+ if (hasSecret && opts?.conversationId !== undefined) {
374
+ secretDeliveries.record(opts.conversationId, draft.callId, {
375
+ values: answer.values,
376
+ });
377
+ }
378
+ // SAFETY: the draft's declared shape is readonly; the doc family's
379
+ // tracked-draft write pattern mutates through a structural record view
380
+ // (every key below exists on the stored record by construction).
273
381
  const mutable = draft;
274
- mutable.answer = clean;
382
+ mutable.answer = { values };
275
383
  mutable.answeredBy = answeredBy;
276
384
  // The answering USER rides alongside the actor class (spec §5.9): who
277
385
  // answered matters to every participant a conversation is shared with.
@@ -281,3 +389,51 @@ export function applyAnswer(draft, answer, answeredBy, now, answeredByUser) {
281
389
  mutable.settledAt = now;
282
390
  return true;
283
391
  }
392
+ /**
393
+ * Take one interaction's secret delivery (the extension-only read path):
394
+ * the TRUE answer when a secret was answered and never yet read — the
395
+ * entry is deleted by this very read (one-shot). The HTTP GETs NEVER call
396
+ * this: only `host.snapshotInteraction` merges it back.
397
+ */
398
+ export function takeSecretDelivery(conversationId, callId) {
399
+ return secretDeliveries.take(conversationId, callId);
400
+ }
401
+ /**
402
+ * Drop one interaction's unread secret delivery without reading it (the
403
+ * settle/expiry sweep: terminal transitions the extension never polled
404
+ * must not leave raw values in the table).
405
+ */
406
+ export function sweepSecretDelivery(conversationId, callId) {
407
+ secretDeliveries.sweep(conversationId, callId);
408
+ }
409
+ /**
410
+ * The stored-answer projection of an input answer (the route's response
411
+ * body): the values with every secret-field name replaced by
412
+ * {@link SECRET_MASK} — EXACTLY what `applyAnswer` writes, computed
413
+ * without a commit so the route can project it after the commit without
414
+ * re-reading the mutated draft (the chord settled-overlay rule).
415
+ */
416
+ export function maskedAnswerValues(body, answer) {
417
+ const secrets = secretFieldNames(body);
418
+ return { values: maskValues(secrets, answer.values, false) };
419
+ }
420
+ /** Narrow an unvalidated route body to `{values: JsonObject}`, or null. */
421
+ export function answerFromBody(body) {
422
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
423
+ return null;
424
+ }
425
+ const b = body;
426
+ for (const key of Object.keys(b)) {
427
+ if (key !== "values")
428
+ return null;
429
+ }
430
+ const values = b.values;
431
+ if (typeof values !== "object" || values === null || Array.isArray(values)) {
432
+ return null;
433
+ }
434
+ for (const value of Object.values(values)) {
435
+ if (!isJsonValue(value))
436
+ return null;
437
+ }
438
+ return { values: values };
439
+ }