@sigrun/sigrun 0.1.6 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +192 -0
- package/README.md +445 -42
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +230 -22
- 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 +259 -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 +92 -6
- package/dist/harness/sections/context-files.js +1 -1
- 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 +211 -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 +31 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +274 -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 +261 -0
- package/dist/serve.js +213 -25
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +99 -36
- 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 +213 -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 +152 -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/notifications.js +21 -4
- 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 +274 -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 +19 -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 +11 -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 +202 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -9
- package/webui/dist/assets/index-SO2W4jjk.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/CONTEXT.md +0 -163
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The image-proxy routes (UI-blocks U7 Task 3, spec §11.2):
|
|
3
|
+
*
|
|
4
|
+
* - `GET /v1/image?u=<url>&e=<expiry>&s=<sig>[&as=png]` — the SIGNED
|
|
5
|
+
* form: the signature (Task 1's HMAC over the exact bytes `u + "\n" +
|
|
6
|
+
* e`) gates arbitrary-URL fetching. Rejections are 403 with a
|
|
7
|
+
* distinguishable error: `malformed_expiry` (a non-numeric `e`),
|
|
8
|
+
* `bad_signature` (the HMAC doesn't match), `expired` (it matches but
|
|
9
|
+
* `now > e`). The verified `u` string is fetched VERBATIM — never
|
|
10
|
+
* re-parsed or re-encoded between verify and fetch.
|
|
11
|
+
* - `GET /v1/image?src=<asset:slug/path | data:…>[&as=png]` — the
|
|
12
|
+
* already-validated form (no signature): assets pass the §11.1
|
|
13
|
+
* realpath containment check (an escape or a missing file is 404,
|
|
14
|
+
* indistinguishable) and data URLs the §11.1 type and 192 KiB size
|
|
15
|
+
* checks.
|
|
16
|
+
* - `POST /v1/image?as=png` — a `data:` source too long for a URL is
|
|
17
|
+
* POSTed as the raw text body; the route's `bodyLimit` is the §11.1
|
|
18
|
+
* 192 KiB rule (an over-limit body is Fastify's 413 `too_large`).
|
|
19
|
+
*
|
|
20
|
+
* Fetches go through the on-disk LRU cache: a hit fresher than its
|
|
21
|
+
* clamped max-age re-serves without an upstream call; an older hit
|
|
22
|
+
* revalidates, and a refusal is never replaced by stale bytes.
|
|
23
|
+
* Concurrent requests for one URL share ONE in-flight upstream fetch
|
|
24
|
+
* (Review Focus 4). Failures are never cached. Responses carry the
|
|
25
|
+
* sniffed Content-Type, `X-Content-Type-Options: nosniff`, and
|
|
26
|
+
* `Cache-Control: private, max-age=<upstream hint clamped to 1 h–7 d>`.
|
|
27
|
+
*/
|
|
28
|
+
import { open, realpath } from "node:fs/promises";
|
|
29
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
30
|
+
import { relative, sep } from "node:path";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { toPng } from "../images/convert.js";
|
|
33
|
+
import { FetchRefusal, fetchImage, } from "../images/fetch.js";
|
|
34
|
+
import { verify } from "../images/sign.js";
|
|
35
|
+
import { isAcceptedImageType, sniffImage } from "../images/sniff.js";
|
|
36
|
+
/** The Cache-Control clamp (spec §11.2): between 1 hour and 7 days. */
|
|
37
|
+
const MAX_AGE_MIN_SECONDS = 3600;
|
|
38
|
+
const MAX_AGE_MAX_SECONDS = 7 * 24 * 3600;
|
|
39
|
+
/** The §11.1 data-URL cap: 192 KiB of base64 text (the POST bodyLimit). */
|
|
40
|
+
const DATA_URL_MAX_CHARS = 192 * 1024;
|
|
41
|
+
/** MIME types by sniffed image type. */
|
|
42
|
+
const MIME = {
|
|
43
|
+
png: "image/png",
|
|
44
|
+
jpeg: "image/jpeg",
|
|
45
|
+
gif: "image/gif",
|
|
46
|
+
webp: "image/webp",
|
|
47
|
+
};
|
|
48
|
+
/** The real fetch deps: dns.lookup + the wall clock. */
|
|
49
|
+
function defaultFetchDeps() {
|
|
50
|
+
return {
|
|
51
|
+
resolve: async (host) => {
|
|
52
|
+
const addresses = await dnsLookup(host, { all: true, verbatim: true });
|
|
53
|
+
return addresses.map((entry) => entry.address);
|
|
54
|
+
},
|
|
55
|
+
now: Date.now,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Resolve one requested asset path inside the package's assets dir
|
|
59
|
+
* (the ext-assets.ts rule, spec §11.1): realpath BOTH sides; an escape
|
|
60
|
+
* or a missing file is `undefined` (the route answers 404). */
|
|
61
|
+
async function resolveContained(assetsDir, path) {
|
|
62
|
+
let realDir;
|
|
63
|
+
try {
|
|
64
|
+
realDir = await realpath(assetsDir);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const candidate = join(realDir, path);
|
|
70
|
+
let realFile;
|
|
71
|
+
try {
|
|
72
|
+
realFile = await realpath(candidate);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const rel = relative(realDir, realFile);
|
|
78
|
+
if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return realFile;
|
|
82
|
+
}
|
|
83
|
+
/** Read a whole (small, cache-bounded) file. */
|
|
84
|
+
async function readAll(file) {
|
|
85
|
+
const handle = await open(file, "r");
|
|
86
|
+
try {
|
|
87
|
+
return await handle.readFile();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await handle.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Decode a §11.1 data URL (`data:image/png|jpeg|gif|webp;base64,…`). */
|
|
94
|
+
function decodeDataUrl(src) {
|
|
95
|
+
const match = /^data:image\/(png|jpeg|gif|webp);base64,(.*)$/s.exec(src);
|
|
96
|
+
if (match === null)
|
|
97
|
+
return undefined;
|
|
98
|
+
return Buffer.from(match[2] ?? "", "base64");
|
|
99
|
+
}
|
|
100
|
+
/** Clamp the upstream max-age hint into the 1 h–7 d band. */
|
|
101
|
+
function clampedMaxAge(maxAge) {
|
|
102
|
+
if (maxAge === undefined)
|
|
103
|
+
return MAX_AGE_MIN_SECONDS;
|
|
104
|
+
return Math.min(MAX_AGE_MAX_SECONDS, Math.max(MAX_AGE_MIN_SECONDS, maxAge));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Register the image-proxy routes. `ctx` carries the shared cache and
|
|
108
|
+
* the (test-injectable) fetch deps on `ctx.imageProxy`, populated by
|
|
109
|
+
* `createServer`.
|
|
110
|
+
*/
|
|
111
|
+
export function registerImageRoutes(app, ctx) {
|
|
112
|
+
const { cache, deps, key } = ctx.imageProxy;
|
|
113
|
+
/** In-flight upstream fetches (Review Focus 4): concurrent requests
|
|
114
|
+
* for one (url, variant) share ONE fetch + ONE cache write. */
|
|
115
|
+
const inflight = new Map();
|
|
116
|
+
/** Fetch one URL through the cache, sharing in-flight work. */
|
|
117
|
+
async function fetchThroughCache(u, variant) {
|
|
118
|
+
const now = deps.now();
|
|
119
|
+
const hit = await cache.get(u, variant, now);
|
|
120
|
+
if (hit !== undefined && now - hit.addedAt < hit.maxAge * 1000) {
|
|
121
|
+
const chunks = [];
|
|
122
|
+
for await (const chunk of hit.stream)
|
|
123
|
+
chunks.push(chunk);
|
|
124
|
+
return { bytes: Buffer.concat(chunks), type: hit.type, maxAge: hit.maxAge };
|
|
125
|
+
}
|
|
126
|
+
// A stale hit is never served: close its pin and revalidate.
|
|
127
|
+
if (hit !== undefined)
|
|
128
|
+
hit.stream.destroy();
|
|
129
|
+
const inflightKey = `${variant}${u}`;
|
|
130
|
+
const existing = inflight.get(inflightKey);
|
|
131
|
+
if (existing !== undefined)
|
|
132
|
+
return await existing;
|
|
133
|
+
const work = (async () => {
|
|
134
|
+
const fetched = await fetchImage(u, deps);
|
|
135
|
+
// Cache the original variant first — `as=png` requests cache
|
|
136
|
+
// BOTH variants (the conversion reads the fetched bytes). The
|
|
137
|
+
// clamped hint is BOTH the response header and the cache TTL.
|
|
138
|
+
const maxAge = clampedMaxAge(fetched.maxAge);
|
|
139
|
+
await cache.put(u, "orig", fetched.type, fetched.bytes, deps.now(), maxAge);
|
|
140
|
+
if (variant === "png") {
|
|
141
|
+
const converted = await toPng(fetched.bytes);
|
|
142
|
+
await cache.put(u, "png", "png", converted, deps.now(), maxAge);
|
|
143
|
+
return {
|
|
144
|
+
bytes: converted,
|
|
145
|
+
type: "png",
|
|
146
|
+
...(fetched.maxAge === undefined ? {} : { maxAge: fetched.maxAge }),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return fetched;
|
|
150
|
+
})();
|
|
151
|
+
inflight.set(inflightKey, work);
|
|
152
|
+
try {
|
|
153
|
+
return await work;
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
inflight.delete(inflightKey);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Serve one resolved image. */
|
|
160
|
+
async function sendImage(reply, image) {
|
|
161
|
+
return await reply
|
|
162
|
+
.header("content-type", MIME[image.type])
|
|
163
|
+
.header("x-content-type-options", "nosniff")
|
|
164
|
+
.header("cache-control", `private, max-age=${clampedMaxAge(image.maxAge)}`)
|
|
165
|
+
.send(image.bytes);
|
|
166
|
+
}
|
|
167
|
+
/** Resolve the `src=` form (asset or data URL, both already
|
|
168
|
+
* validated by §11.1's own rules — nothing to sign). */
|
|
169
|
+
async function resolveSource(src) {
|
|
170
|
+
if (src.startsWith("asset:")) {
|
|
171
|
+
const rest = src.slice("asset:".length);
|
|
172
|
+
const slash = rest.indexOf("/");
|
|
173
|
+
if (slash <= 0)
|
|
174
|
+
return "bad";
|
|
175
|
+
const slug = rest.slice(0, slash);
|
|
176
|
+
const path = rest.slice(slash + 1);
|
|
177
|
+
const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
|
|
178
|
+
if (binding?.assetsDir === undefined)
|
|
179
|
+
return undefined;
|
|
180
|
+
const file = await resolveContained(binding.assetsDir, path);
|
|
181
|
+
if (file === undefined)
|
|
182
|
+
return undefined;
|
|
183
|
+
const bytes = await readAll(file);
|
|
184
|
+
const sniffed = sniffImage(bytes);
|
|
185
|
+
if (!isAcceptedImageType(sniffed))
|
|
186
|
+
return undefined;
|
|
187
|
+
return { bytes, type: sniffed };
|
|
188
|
+
}
|
|
189
|
+
if (src.startsWith("data:")) {
|
|
190
|
+
if (src.length > DATA_URL_MAX_CHARS)
|
|
191
|
+
return undefined;
|
|
192
|
+
const bytes = decodeDataUrl(src);
|
|
193
|
+
if (bytes === undefined)
|
|
194
|
+
return "bad";
|
|
195
|
+
const sniffed = sniffImage(bytes);
|
|
196
|
+
if (!isAcceptedImageType(sniffed))
|
|
197
|
+
return undefined;
|
|
198
|
+
return { bytes, type: sniffed };
|
|
199
|
+
}
|
|
200
|
+
return "bad";
|
|
201
|
+
}
|
|
202
|
+
app.get("/v1/image", async (request, reply) => {
|
|
203
|
+
const { u, e, s, src, as } = request.query;
|
|
204
|
+
const wantPng = as === "png";
|
|
205
|
+
// The src form: no signature (assets and data URLs carry their
|
|
206
|
+
// own §11.1 validation).
|
|
207
|
+
if (src !== undefined) {
|
|
208
|
+
const resolved = await resolveSource(src);
|
|
209
|
+
if (resolved === "bad") {
|
|
210
|
+
return await reply.code(400).send({ error: "bad_source" });
|
|
211
|
+
}
|
|
212
|
+
if (resolved === undefined) {
|
|
213
|
+
// Containment misses and over-long data URLs are 404/413 —
|
|
214
|
+
// indistinguishable from a missing asset / a too-large body.
|
|
215
|
+
if (src.startsWith("data:") && src.length > DATA_URL_MAX_CHARS) {
|
|
216
|
+
return await reply.code(413).send({
|
|
217
|
+
error: "too_large",
|
|
218
|
+
maxBytes: DATA_URL_MAX_CHARS,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return await reply.code(404).send({ error: "asset_not_found" });
|
|
222
|
+
}
|
|
223
|
+
const image = wantPng
|
|
224
|
+
? { bytes: await toPng(resolved.bytes), type: "png" }
|
|
225
|
+
: resolved;
|
|
226
|
+
return await sendImage(reply, image);
|
|
227
|
+
}
|
|
228
|
+
// The signed form.
|
|
229
|
+
if (u === undefined || e === undefined || s === undefined) {
|
|
230
|
+
return await reply.code(400).send({ error: "bad_signature" });
|
|
231
|
+
}
|
|
232
|
+
if (!/^\d+$/.test(e)) {
|
|
233
|
+
return await reply.code(403).send({ error: "malformed_expiry" });
|
|
234
|
+
}
|
|
235
|
+
// Split the verify so the expired case names its own error: a
|
|
236
|
+
// signature that MATCHES but whose expiry passed is `expired`;
|
|
237
|
+
// a mismatch is `bad_signature`.
|
|
238
|
+
if (!verify(key, u, e, s, Number(e))) {
|
|
239
|
+
return await reply.code(403).send({ error: "bad_signature" });
|
|
240
|
+
}
|
|
241
|
+
if (Date.now() > Number(e)) {
|
|
242
|
+
return await reply.code(403).send({ error: "expired" });
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const image = await fetchThroughCache(u, wantPng ? "png" : "orig");
|
|
246
|
+
return await sendImage(reply, image);
|
|
247
|
+
}
|
|
248
|
+
catch (cause) {
|
|
249
|
+
if (cause instanceof FetchRefusal) {
|
|
250
|
+
return await reply
|
|
251
|
+
.code(502)
|
|
252
|
+
.send({ error: cause.reason, message: cause.message });
|
|
253
|
+
}
|
|
254
|
+
throw cause;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
// The long-data-URL form: a `data:` source too long for a URL is
|
|
258
|
+
// POSTed as the raw body. `bodyLimit` is the §11.1 192 KiB rule; the
|
|
259
|
+
// server's one error handler turns an over-limit body into 413
|
|
260
|
+
// `too_large`. The `text/plain` string parser is already registered
|
|
261
|
+
// app-wide by the conversations route module (the JSONL import's
|
|
262
|
+
// raw-text parser).
|
|
263
|
+
app.post("/v1/image", { bodyLimit: DATA_URL_MAX_CHARS }, async (request, reply) => {
|
|
264
|
+
const src = typeof request.body === "string" ? request.body : "";
|
|
265
|
+
const resolved = await resolveSource(src);
|
|
266
|
+
if (resolved === "bad" || resolved === undefined) {
|
|
267
|
+
return await reply.code(400).send({ error: "bad_source" });
|
|
268
|
+
}
|
|
269
|
+
const image = request.query.as === "png"
|
|
270
|
+
? { bytes: await toPng(resolved.bytes), type: "png" }
|
|
271
|
+
: resolved;
|
|
272
|
+
return await sendImage(reply, image);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
* through the per-conversation answer route above.
|
|
34
34
|
*/
|
|
35
35
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
36
|
-
import { AmbientDoc, AmbientOwnersDoc, } from "../../harness/ambient.js";
|
|
37
|
-
import { InteractionDoc, InteractionsIndexDoc, applyAnswer, indexSettled, } from "../../harness/interactions.js";
|
|
36
|
+
import { AmbientDoc, AmbientOwnersDoc, asAmbientRecord, } from "../../harness/ambient.js";
|
|
37
|
+
import { InteractionDoc, InteractionsIndexDoc, answerFromBody, applyAnswer, asInteractionRecord, indexSettled, maskedAnswerValues, } from "../../harness/interactions.js";
|
|
38
38
|
import { loadConversation } from "../conversation-handler.js";
|
|
39
39
|
import { ID_PARAMS_SCHEMA, conversationId } from "../helpers.js";
|
|
40
40
|
import { accessOf } from "@sigrun/shared/access";
|
|
41
|
-
import {
|
|
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(
|
|
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({
|
|
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
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|