@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.
- package/CHANGELOG.md +104 -0
- package/README.md +352 -36
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +189 -15
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +253 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +80 -6
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -57
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +162 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +20 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +253 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +281 -0
- package/dist/serve.js +82 -7
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +47 -33
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +205 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +144 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +267 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +15 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +11 -6
- package/webui/dist/assets/index-CxB969qp.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- 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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
66
|
-
*
|
|
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
|
|
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
|
|
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
|
|
3
|
-
* channel-neutral records for "this conversation is
|
|
4
|
-
* decision", written by extensions through the host
|
|
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 /
|
|
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:
|
|
18
|
-
*
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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(["
|
|
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
|
|
167
|
-
|
|
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 (
|
|
211
|
-
return "
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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 =
|
|
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
|
+
}
|