@sigrun/sigrun 0.1.4 → 0.1.5
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 +29 -0
- package/README.md +24 -2
- package/dist/blocks/builders.js +125 -0
- package/dist/blocks/images.js +57 -0
- package/dist/blocks/index.js +20 -0
- package/dist/blocks/limits.js +26 -0
- package/dist/blocks/placements.js +99 -0
- package/dist/blocks/schema.js +559 -0
- package/dist/blocks/validate.js +602 -0
- package/dist/config.js +31 -0
- package/dist/extension.d.ts +476 -9
- package/dist/harness/boot.js +8 -1
- package/dist/harness/event-bus.js +33 -0
- package/dist/harness/identity/users.js +3 -2
- package/dist/harness/loader.js +369 -13
- package/dist/harness/packages.js +71 -2
- package/dist/harness/seam-host.js +69 -2
- package/dist/harness/settings.js +5 -0
- package/dist/jobs/fire.js +11 -0
- package/dist/jobs/spec.js +19 -0
- package/dist/serve.js +29 -39
- package/dist/server/auth/pipeline.js +16 -1
- package/dist/server/conversation-ops.js +482 -10
- package/dist/server/gateway.js +122 -0
- package/dist/server/helpers.js +26 -0
- package/dist/server/host-bridge.js +141 -4
- package/dist/server/index.js +27 -0
- package/dist/server/notifications.js +6 -2
- package/dist/server/routes/conversations.js +37 -93
- package/dist/server/routes/events.js +39 -0
- package/dist/server/routes/ext-assets.js +84 -0
- package/dist/server/routes/extensions.js +31 -1
- package/dist/server/routes/files.js +38 -83
- package/dist/server/routes/meta.js +35 -1
- package/dist/server/routes/providers.js +5 -21
- package/dist/server/routes/submissions.js +36 -111
- package/dist/server/routes/surfaces.js +387 -0
- package/dist/server/routes/users.js +6 -1
- package/dist/server/session-meta.js +12 -6
- package/dist/server/shutdown.js +85 -0
- package/dist/server/surfaces/access-cache.js +43 -0
- package/dist/server/surfaces/dedup.js +67 -0
- package/dist/server/surfaces/instance.js +42 -0
- package/dist/server/surfaces/mime.js +73 -0
- package/dist/server/surfaces/rate-limit.js +63 -0
- package/dist/server/surfaces/service.js +493 -0
- package/dist/server/surfaces/watchers.js +94 -0
- package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
- package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
- package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
- package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
- package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
- package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
- package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
- package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
- package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
- package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
- package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
- package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
- package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
- package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
- package/node_modules/@sigrun/shared/dist/live.js +82 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
- package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +81 -3
- package/node_modules/@sigrun/shared/package.json +10 -2
- package/package.json +9 -3
- package/webui/dist/assets/index-B4-GYqEb.js +50 -0
- package/webui/dist/index.html +1 -1
- package/webui/dist/assets/index-hVLvsizV.js +0 -50
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension surface routes (UI-blocks U3 Task 2, spec §7.2): the render
|
|
3
|
+
* and data GETs plus the actions POST, mounted at core's reserved
|
|
4
|
+
* `surfaces/` prefix under `/v1/ext/<slug>/`, behind the W1 auth
|
|
5
|
+
* pipeline like every `/v1` route.
|
|
6
|
+
*
|
|
7
|
+
* - `GET /v1/ext/<slug>/surfaces/<id>?params=<json>` → `{tree, data,
|
|
8
|
+
* rev, treeRev, access}`; `params` is a JSON object capped at 4 KiB
|
|
9
|
+
* (spec §15) — anything else is `400 invalid_field params`.
|
|
10
|
+
* - `GET …/data?params=<json>&since=<rev>` → `{rev, treeRev, data}` or
|
|
11
|
+
* `{rev, treeRev, ops}` when the server holds the ops since `since`
|
|
12
|
+
* (sharedData surfaces, §7.3).
|
|
13
|
+
* - `POST …/actions` `{action, payload, actionId, params}` → the
|
|
14
|
+
* handler's `ActionResult` wrapped per §6.3 (an accept carries the
|
|
15
|
+
* assigned `rev`, plus the `ops` echoed back when it came with an
|
|
16
|
+
* `update`), deduped per actionId per instance (10-minute window),
|
|
17
|
+
* rate-limited per principal per surface (20/s burst 40 → 429), and
|
|
18
|
+
* timed out at 10 s (`{ok:false, error:{message:"timed out"}}`).
|
|
19
|
+
* Access (§6.5) gates BEFORE the handler runs: `read` → 403, `none`
|
|
20
|
+
* → 404 `surface_not_found` (indistinguishable from an unknown id).
|
|
21
|
+
* - `PUT …/uploads/<field>/<name>?params` → raw octet-stream body,
|
|
22
|
+
* capped at the field's `maxBytes` (clamped by
|
|
23
|
+
* `server.maxUploadBytes`) — over the cap is a 413 answered from the
|
|
24
|
+
* FIRST chunks, without buffering the rest (the parser destroys the
|
|
25
|
+
* request stream, so the socket never sees the remainder). The
|
|
26
|
+
* `onUpload` handler receives `{field, name, mime, bytes}` with the
|
|
27
|
+
* MIME sniffed from the bytes (never the client name).
|
|
28
|
+
*
|
|
29
|
+
* Errors: a render/data throw or an invalid tree is
|
|
30
|
+
* `500 surface_failed` (logged here; the response body carries the
|
|
31
|
+
* validation `errors` only when `server.dev` is true, spec §14.2). The
|
|
32
|
+
* extension stays loaded.
|
|
33
|
+
*/
|
|
34
|
+
import { PassThrough } from "node:stream";
|
|
35
|
+
import { instanceKey } from "../surfaces/instance.js";
|
|
36
|
+
import { ActionRateLimitedError, SurfaceFailedError, UnknownActionError, } from "../surfaces/service.js";
|
|
37
|
+
/**
|
|
38
|
+
* The one upload-name rule, shared with core's conversation upload
|
|
39
|
+
* route (files.ts §6.2 — kept in sync by hand): no `/` or `\\`, not
|
|
40
|
+
* `.` or `..`, non-empty. Fastify has already percent-DECODED the
|
|
41
|
+
* `:name` param, so `%2e%2e` arrives as `..` and is rejected.
|
|
42
|
+
*/
|
|
43
|
+
function validUploadName(name) {
|
|
44
|
+
return (name.length > 0 &&
|
|
45
|
+
!name.includes("/") &&
|
|
46
|
+
!name.includes("\\") &&
|
|
47
|
+
name !== "." &&
|
|
48
|
+
name !== "..");
|
|
49
|
+
}
|
|
50
|
+
/** The params cap (spec §15): 4 KiB of JSON text. */
|
|
51
|
+
export const PARAMS_MAX_BYTES = 4096;
|
|
52
|
+
/** The not-found body — shared by unknown ids and `access: "none"`. */
|
|
53
|
+
const NOT_FOUND = { error: "surface_not_found" };
|
|
54
|
+
/**
|
|
55
|
+
* Parse the `?params=` query value: absent → `{}`; present must decode
|
|
56
|
+
* as a JSON OBJECT within the 4 KiB cap. Returns the object or the
|
|
57
|
+
* error body (`invalid_field params`).
|
|
58
|
+
*/
|
|
59
|
+
function parseParams(raw) {
|
|
60
|
+
if (raw === undefined)
|
|
61
|
+
return { ok: true, params: {} };
|
|
62
|
+
if (typeof raw !== "string")
|
|
63
|
+
return { ok: false };
|
|
64
|
+
if (Buffer.byteLength(raw, "utf8") > PARAMS_MAX_BYTES)
|
|
65
|
+
return { ok: false };
|
|
66
|
+
let decoded;
|
|
67
|
+
try {
|
|
68
|
+
decoded = JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { ok: false };
|
|
72
|
+
}
|
|
73
|
+
if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
|
|
74
|
+
return { ok: false };
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, params: decoded };
|
|
77
|
+
}
|
|
78
|
+
/** Parse `?since=`: absent → undefined; a non-negative integer else. */
|
|
79
|
+
function parseSince(raw) {
|
|
80
|
+
if (raw === undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
if (typeof raw !== "string" || !/^\d+$/.test(raw))
|
|
83
|
+
return null;
|
|
84
|
+
return Number.parseInt(raw, 10);
|
|
85
|
+
}
|
|
86
|
+
/** Register the surface routes over the shared context's service. */
|
|
87
|
+
export function registerSurfaceRoutes(app, ctx) {
|
|
88
|
+
const { surfaces } = ctx;
|
|
89
|
+
const uploadCaps = new WeakMap();
|
|
90
|
+
/** The shared failure mapping (logged; errors only under server.dev). */
|
|
91
|
+
const failureReply = async (reply, slug, id, cause) => {
|
|
92
|
+
const failed = cause instanceof SurfaceFailedError
|
|
93
|
+
? cause
|
|
94
|
+
: new SurfaceFailedError(cause instanceof Error ? cause.message : String(cause));
|
|
95
|
+
console.warn(`surface failed: ${slug}/${id}: ${failed.message}${failed.errors === undefined
|
|
96
|
+
? ""
|
|
97
|
+
: ` (${failed.errors.map((e) => `${e.path}: ${e.message}`).join("; ")})`}`);
|
|
98
|
+
return reply.code(500).send({
|
|
99
|
+
error: "surface_failed",
|
|
100
|
+
...(ctx.config.dev && failed.errors !== undefined
|
|
101
|
+
? { errors: failed.errors }
|
|
102
|
+
: {}),
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
app.get("/v1/ext/:slug/surfaces/:id", async (request, reply) => {
|
|
106
|
+
const { slug, id } = request.params;
|
|
107
|
+
const parsed = parseParams(request.query.params);
|
|
108
|
+
if (!parsed.ok) {
|
|
109
|
+
return await reply
|
|
110
|
+
.code(400)
|
|
111
|
+
.send({ error: "invalid_field", field: "params" });
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const result = await surfaces.render(request.params.slug, id, parsed.params, request.principal, request.headers["x-sigrun-stream"]);
|
|
115
|
+
if (result === undefined) {
|
|
116
|
+
return await reply.code(404).send(NOT_FOUND);
|
|
117
|
+
}
|
|
118
|
+
return await reply.send(result);
|
|
119
|
+
}
|
|
120
|
+
catch (cause) {
|
|
121
|
+
return await failureReply(reply, slug, id, cause);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
app.get("/v1/ext/:slug/surfaces/:id/data", async (request, reply) => {
|
|
125
|
+
const { slug, id } = request.params;
|
|
126
|
+
const parsed = parseParams(request.query.params);
|
|
127
|
+
if (!parsed.ok) {
|
|
128
|
+
return await reply
|
|
129
|
+
.code(400)
|
|
130
|
+
.send({ error: "invalid_field", field: "params" });
|
|
131
|
+
}
|
|
132
|
+
const since = parseSince(request.query.since);
|
|
133
|
+
if (since === null) {
|
|
134
|
+
return await reply
|
|
135
|
+
.code(400)
|
|
136
|
+
.send({ error: "invalid_field", field: "since" });
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const result = await surfaces.data(slug, id, parsed.params, request.principal, since, request.headers["x-sigrun-stream"]);
|
|
140
|
+
if (result === undefined) {
|
|
141
|
+
return await reply.code(404).send(NOT_FOUND);
|
|
142
|
+
}
|
|
143
|
+
return await reply.send(result);
|
|
144
|
+
}
|
|
145
|
+
catch (cause) {
|
|
146
|
+
return await failureReply(reply, slug, id, cause);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
app.post("/v1/ext/:slug/surfaces/:id/actions", async (request, reply) => {
|
|
150
|
+
const { slug, id } = request.params;
|
|
151
|
+
const body = request.body ?? {};
|
|
152
|
+
if (typeof body.action !== "string") {
|
|
153
|
+
return await reply
|
|
154
|
+
.code(400)
|
|
155
|
+
.send({ error: "invalid_field", field: "action" });
|
|
156
|
+
}
|
|
157
|
+
let params = {};
|
|
158
|
+
if (body.params !== undefined) {
|
|
159
|
+
const encoded = JSON.stringify(body.params);
|
|
160
|
+
if (typeof body.params !== "object" ||
|
|
161
|
+
body.params === null ||
|
|
162
|
+
Array.isArray(body.params) ||
|
|
163
|
+
Buffer.byteLength(encoded, "utf8") > PARAMS_MAX_BYTES) {
|
|
164
|
+
return await reply
|
|
165
|
+
.code(400)
|
|
166
|
+
.send({ error: "invalid_field", field: "params" });
|
|
167
|
+
}
|
|
168
|
+
params = body.params;
|
|
169
|
+
}
|
|
170
|
+
// The §6.5 access gate: cache or re-render decides BEFORE the
|
|
171
|
+
// handler runs. A located-check first keeps unknown slugs/ids on
|
|
172
|
+
// the same 404 body without a render.
|
|
173
|
+
const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
|
|
174
|
+
const def = binding?.defs.find((entry) => entry.id === id);
|
|
175
|
+
if (def === undefined) {
|
|
176
|
+
return await reply.code(404).send(NOT_FOUND);
|
|
177
|
+
}
|
|
178
|
+
// A throwing `instanceKey` is an extension fault: 500
|
|
179
|
+
// surface_failed, the extension stays loaded (deferred minor from
|
|
180
|
+
// Task 2 — this ran outside the try before).
|
|
181
|
+
let key;
|
|
182
|
+
try {
|
|
183
|
+
key =
|
|
184
|
+
def.instanceKey !== undefined
|
|
185
|
+
? def.instanceKey(params)
|
|
186
|
+
: instanceKey(params);
|
|
187
|
+
}
|
|
188
|
+
catch (cause) {
|
|
189
|
+
return await failureReply(reply, slug, id, cause);
|
|
190
|
+
}
|
|
191
|
+
let access;
|
|
192
|
+
try {
|
|
193
|
+
access = await surfaces.accessFor(slug, id, key, params, request.principal);
|
|
194
|
+
}
|
|
195
|
+
catch (cause) {
|
|
196
|
+
return await failureReply(reply, slug, id, cause);
|
|
197
|
+
}
|
|
198
|
+
if (access === "none") {
|
|
199
|
+
return await reply.code(404).send(NOT_FOUND);
|
|
200
|
+
}
|
|
201
|
+
if (access === "read") {
|
|
202
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const result = await surfaces.act(slug, id, key, params, request.principal, body.action, body.payload, typeof body.actionId === "string" ? body.actionId : undefined, request.signal);
|
|
206
|
+
return await reply.send(result);
|
|
207
|
+
}
|
|
208
|
+
catch (cause) {
|
|
209
|
+
if (cause instanceof UnknownActionError) {
|
|
210
|
+
return await reply.code(400).send({ error: "unknown_action" });
|
|
211
|
+
}
|
|
212
|
+
if (cause instanceof ActionRateLimitedError) {
|
|
213
|
+
return await reply.code(429).send({ error: "rate_limited" });
|
|
214
|
+
}
|
|
215
|
+
return await failureReply(reply, slug, id, cause);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
/**
|
|
219
|
+
* The upload route (spec §7.2). The gate + cap resolution run in
|
|
220
|
+
* `preParsing` — AFTER the headers, BEFORE the body is read. The cap
|
|
221
|
+
* is enforced by the route itself, not Fastify's bodyLimit: the hook
|
|
222
|
+
* wraps the payload in a PassThrough counter that forwards chunks up
|
|
223
|
+
* to the resolved field cap (`maxBytes` from the last rendered tree,
|
|
224
|
+
* clamped by `server.maxUploadBytes`); the first over-cap chunk sends
|
|
225
|
+
* `413 {error:"too_large", maxBytes}` DIRECTLY from the hook and
|
|
226
|
+
* then destroys the request socket (the reply is sent first —
|
|
227
|
+
* destroying the socket before the reply lands makes Node drop the
|
|
228
|
+
* response), so the sender's remaining bytes are never written
|
|
229
|
+
* (Review Focus 5). The handler itself only validates the name and
|
|
230
|
+
* dispatches to `onUpload`.
|
|
231
|
+
*/
|
|
232
|
+
app.put("/v1/ext/:slug/surfaces/:id/uploads/:field/:name", {
|
|
233
|
+
// Untyped non-empty bodies would 415 (files.ts' identical
|
|
234
|
+
// default): uploads arrive raw.
|
|
235
|
+
onRequest: async (request) => {
|
|
236
|
+
if (request.headers["content-type"] === undefined) {
|
|
237
|
+
request.headers["content-type"] = "application/octet-stream";
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
preParsing: async (request, reply, payload) => {
|
|
241
|
+
const { slug, id, field } = request.params;
|
|
242
|
+
const parsed = parseParams(request.query.params);
|
|
243
|
+
if (!parsed.ok) {
|
|
244
|
+
return await reply
|
|
245
|
+
.code(400)
|
|
246
|
+
.send({ error: "invalid_field", field: "params" });
|
|
247
|
+
}
|
|
248
|
+
const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
|
|
249
|
+
const def = binding?.defs.find((entry) => entry.id === id);
|
|
250
|
+
if (def === undefined) {
|
|
251
|
+
return await reply.code(404).send(NOT_FOUND);
|
|
252
|
+
}
|
|
253
|
+
let key;
|
|
254
|
+
try {
|
|
255
|
+
key =
|
|
256
|
+
def.instanceKey !== undefined
|
|
257
|
+
? def.instanceKey(parsed.params)
|
|
258
|
+
: instanceKey(parsed.params);
|
|
259
|
+
}
|
|
260
|
+
catch (cause) {
|
|
261
|
+
return await failureReply(reply, slug, id, cause);
|
|
262
|
+
}
|
|
263
|
+
// The §6.5 access gate, BEFORE the body is consumed.
|
|
264
|
+
let access;
|
|
265
|
+
try {
|
|
266
|
+
access = await surfaces.accessFor(slug, id, key, parsed.params, request.principal);
|
|
267
|
+
}
|
|
268
|
+
catch (cause) {
|
|
269
|
+
return await failureReply(reply, slug, id, cause);
|
|
270
|
+
}
|
|
271
|
+
if (access === "none") {
|
|
272
|
+
return await reply.code(404).send(NOT_FOUND);
|
|
273
|
+
}
|
|
274
|
+
if (access === "read") {
|
|
275
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
276
|
+
}
|
|
277
|
+
// The cap: the field's declared maxBytes (from the last
|
|
278
|
+
// rendered tree) clamped by server.maxUploadBytes. An
|
|
279
|
+
// instance that was never rendered has no recorded caps —
|
|
280
|
+
// re-render to fill them. Unknown field → 400.
|
|
281
|
+
let cap = surfaces.uploadCapBytes(slug, id, key, field);
|
|
282
|
+
if (cap === undefined) {
|
|
283
|
+
try {
|
|
284
|
+
await surfaces.render(slug, id, parsed.params, request.principal);
|
|
285
|
+
}
|
|
286
|
+
catch (cause) {
|
|
287
|
+
return await failureReply(reply, slug, id, cause);
|
|
288
|
+
}
|
|
289
|
+
cap = surfaces.uploadCapBytes(slug, id, key, field);
|
|
290
|
+
}
|
|
291
|
+
if (cap === undefined) {
|
|
292
|
+
return await reply.code(400).send({ error: "unknown_field" });
|
|
293
|
+
}
|
|
294
|
+
// A declared content-length already over the cap: answer
|
|
295
|
+
// 413 without touching the body (Fastify never reads it;
|
|
296
|
+
// the connection-close header drops the socket).
|
|
297
|
+
const declared = Number(request.headers["content-length"]);
|
|
298
|
+
if (Number.isFinite(declared) && declared > cap) {
|
|
299
|
+
reply.header("connection", "close");
|
|
300
|
+
return await reply
|
|
301
|
+
.code(413)
|
|
302
|
+
.send({ error: "too_large", maxBytes: cap });
|
|
303
|
+
}
|
|
304
|
+
uploadCaps.set(request, { cap, key, params: parsed.params, tripped: false });
|
|
305
|
+
// The chunked cut-off (Review Focus 5): wrap the payload in a
|
|
306
|
+
// counter whose first over-cap chunk destroys the request
|
|
307
|
+
// socket (the client's remaining chunks are never written) and
|
|
308
|
+
// sends the 413 DIRECTLY — answering from the hook, not the
|
|
309
|
+
// handler, because the handler only runs after the (destroyed)
|
|
310
|
+
// body finishes. The handler's `gate.tripped` check below
|
|
311
|
+
// stays as the in-process belt (an inject path never trips the
|
|
312
|
+
// stream).
|
|
313
|
+
const gate = uploadCaps.get(request);
|
|
314
|
+
if (gate === undefined) {
|
|
315
|
+
return payload;
|
|
316
|
+
}
|
|
317
|
+
let received = 0;
|
|
318
|
+
const capped = new PassThrough();
|
|
319
|
+
payload.on("data", (chunk) => {
|
|
320
|
+
if (gate.tripped)
|
|
321
|
+
return;
|
|
322
|
+
received += chunk.byteLength;
|
|
323
|
+
if (received > gate.cap) {
|
|
324
|
+
gate.tripped = true;
|
|
325
|
+
// The reply first, then the socket: destroying the
|
|
326
|
+
// request stream before the reply lands makes Node drop
|
|
327
|
+
// the response entirely.
|
|
328
|
+
void reply
|
|
329
|
+
.code(413)
|
|
330
|
+
.send({ error: "too_large", maxBytes: gate.cap });
|
|
331
|
+
// Destroy the counter too: the parser keeps buffering the
|
|
332
|
+
// already-forwarded prefix while it believes the stream is
|
|
333
|
+
// still live.
|
|
334
|
+
setImmediate(() => {
|
|
335
|
+
payload.destroy();
|
|
336
|
+
capped.destroy();
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
capped.write(chunk);
|
|
341
|
+
});
|
|
342
|
+
payload.on("end", () => capped.end());
|
|
343
|
+
payload.on("error", () => capped.end());
|
|
344
|
+
return capped;
|
|
345
|
+
},
|
|
346
|
+
}, async (request, reply) => {
|
|
347
|
+
const { slug, id, field, name } = request.params;
|
|
348
|
+
const gate = uploadCaps.get(request);
|
|
349
|
+
uploadCaps.delete(request);
|
|
350
|
+
if (gate === undefined) {
|
|
351
|
+
// unreachable (the preParsing hook short-circuits every
|
|
352
|
+
// failure); defend anyway.
|
|
353
|
+
return await reply.code(404).send(NOT_FOUND);
|
|
354
|
+
}
|
|
355
|
+
// The one upload-name rule, shared with core's conversation
|
|
356
|
+
// uploads (files.ts §6.2): no separators, not `.`/`..`, on the
|
|
357
|
+
// DECODED param.
|
|
358
|
+
if (!validUploadName(name)) {
|
|
359
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
360
|
+
}
|
|
361
|
+
const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
|
|
362
|
+
const def = binding?.defs.find((entry) => entry.id === id);
|
|
363
|
+
if (def === undefined) {
|
|
364
|
+
return await reply.code(404).send(NOT_FOUND);
|
|
365
|
+
}
|
|
366
|
+
const bytes = request.body === undefined
|
|
367
|
+
? new Uint8Array(0)
|
|
368
|
+
: new Uint8Array(request.body);
|
|
369
|
+
// The stream-side cap tripped (a chunked body cut off mid-way):
|
|
370
|
+
// the body never arrived intact — answer the 413.
|
|
371
|
+
if (gate.tripped) {
|
|
372
|
+
return await reply
|
|
373
|
+
.code(413)
|
|
374
|
+
.send({ error: "too_large", maxBytes: gate.cap });
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const result = await surfaces.upload(slug, id, gate.key, gate.params, request.principal, field, name, bytes, request.signal);
|
|
378
|
+
return await reply.send(result);
|
|
379
|
+
}
|
|
380
|
+
catch (cause) {
|
|
381
|
+
if (cause instanceof UnknownActionError) {
|
|
382
|
+
return await reply.code(400).send({ error: "unknown_action" });
|
|
383
|
+
}
|
|
384
|
+
return await failureReply(reply, slug, id, cause);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
@@ -14,13 +14,18 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
|
14
14
|
import { withClaimLock } from "../auth/claim-lock.js";
|
|
15
15
|
import { transferOwnership } from "../ownership.js";
|
|
16
16
|
/** The wire form of one directory user (the record minus nothing — the
|
|
17
|
-
* routes serving it are admin-gated, and emails are admin material).
|
|
17
|
+
* routes serving it are admin-gated, and emails are admin material).
|
|
18
|
+
* `admin` stays the STORED flag; `groupAdmin` (gateway C8) is the
|
|
19
|
+
* persisted proxy-group grant alongside it. */
|
|
18
20
|
function userWire(record) {
|
|
19
21
|
return {
|
|
20
22
|
user: record.user,
|
|
21
23
|
...(record.name === undefined ? {} : { name: record.name }),
|
|
22
24
|
...(record.email === undefined ? {} : { email: record.email }),
|
|
23
25
|
admin: record.admin,
|
|
26
|
+
...(record.groupAdmin === undefined
|
|
27
|
+
? {}
|
|
28
|
+
: { groupAdmin: record.groupAdmin }),
|
|
24
29
|
createdAt: record.createdAt,
|
|
25
30
|
source: record.source,
|
|
26
31
|
};
|
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
* `Conversation.commit` callback, so concurrent patches cannot lose each
|
|
29
29
|
* other's fields (the mutation line serializes commits).
|
|
30
30
|
*
|
|
31
|
-
* Server-owned fields (`autoName`, `autoNameAttempts`,
|
|
32
|
-
* are written by host code only
|
|
33
|
-
*
|
|
31
|
+
* Server-owned fields (`autoName`, `autoNameAttempts`,
|
|
32
|
+
* `lastActivityAt`, `jobId`, `deliver`) are written by host code only
|
|
33
|
+
* (activity.ts, the A2 auto-namer, fire.ts's job stamp); they are NOT
|
|
34
|
+
* patch surface — the strict validator keeps rejecting them as
|
|
34
35
|
* `invalid_field`.
|
|
35
36
|
*/
|
|
36
37
|
import { defineDoc } from "@earendil-works/pi-durable";
|
|
@@ -154,9 +155,14 @@ export function validateSessionMetaPatch(body) {
|
|
|
154
155
|
break;
|
|
155
156
|
}
|
|
156
157
|
case "source":
|
|
157
|
-
case "owner":
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
case "owner":
|
|
159
|
+
case "jobId":
|
|
160
|
+
case "deliver": {
|
|
161
|
+
// Server-only (spec §5.9 + gateway C7): the KEY itself is the
|
|
162
|
+
// client error, whatever the value — even a `null` clear. The
|
|
163
|
+
// job stamp fields move with the fire (fire.ts's creating
|
|
164
|
+
// commit) and the delivery targets ride the job spec, never a
|
|
165
|
+
// meta patch.
|
|
160
166
|
return { error: "invalid_field", field: key };
|
|
161
167
|
}
|
|
162
168
|
case "sharing": {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ordered shutdown (gateway C2's extraction of serve.ts's inline
|
|
3
|
+
* `shutdown`): seven steps, each in its own try/catch so a failure never
|
|
4
|
+
* skips the remaining steps, with every failure collected into one
|
|
5
|
+
* `AggregateError("sigrun shutdown failed")` after ALL steps ran (the
|
|
6
|
+
* pre-extraction semantics, byte-for-byte).
|
|
7
|
+
*
|
|
8
|
+
* The order (spec §8.0 — the gateway lifecycle slots between the server
|
|
9
|
+
* drain and the event-bus flush):
|
|
10
|
+
*
|
|
11
|
+
* 1. `endStreams` — SSE streams FIRST (hijacked event streams never end
|
|
12
|
+
* on their own, and `closeApp`'s drain waits for in-flight requests —
|
|
13
|
+
* with any webui tab open, shutdown hung forever on its /v1/events
|
|
14
|
+
* connection);
|
|
15
|
+
* 2. `closeApp` — the server drain (in-flight requests finish);
|
|
16
|
+
* 3. `gateway.stopIntake` — the engine closes inbound intake BEFORE the
|
|
17
|
+
* bus drain: the drain is the last admission window, so no chat
|
|
18
|
+
* message admitted mid-drain can outrun it;
|
|
19
|
+
* 4. `eventBusShutdown` — the bus's shutdown sequence (drain pending
|
|
20
|
+
* settles, then deliver `harnessShutdown` — handlers may commit);
|
|
21
|
+
* 5. `gateway.stop` — the engine stops only once every settle has been
|
|
22
|
+
* delivered (its adapters' outbound side saw the final answers);
|
|
23
|
+
* 6. `stopProviderRefresh` — the interval scheduler + the bounded
|
|
24
|
+
* boot-refresh join (no catalog fetch outlives the shutdown);
|
|
25
|
+
* 7. `closeHarness` — the seal (joins tasks, seals storage).
|
|
26
|
+
*
|
|
27
|
+
* Does NOT exit the process — the caller owns exit policy (the deploy
|
|
28
|
+
* entry exits 0/1; the CLI's `run` returns to its exit code).
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Build the ordered shutdown over the seven steps. Failures from ANY step
|
|
32
|
+
* are collected (the remaining steps still run) and thrown together as
|
|
33
|
+
* `AggregateError(failures, "sigrun shutdown failed")` at the end; a clean
|
|
34
|
+
* run resolves.
|
|
35
|
+
*/
|
|
36
|
+
export function createShutdown(steps) {
|
|
37
|
+
return async () => {
|
|
38
|
+
const failures = [];
|
|
39
|
+
try {
|
|
40
|
+
steps.endStreams();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
failures.push(error);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
await steps.closeApp();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
failures.push(error);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
await steps.gateway.stopIntake();
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
failures.push(error);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
await steps.eventBusShutdown();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
failures.push(error);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await steps.gateway.stop();
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
failures.push(error);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
await steps.stopProviderRefresh();
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
failures.push(error);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
await steps.closeHarness();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
failures.push(error);
|
|
80
|
+
}
|
|
81
|
+
if (failures.length > 0) {
|
|
82
|
+
throw new AggregateError(failures, "sigrun shutdown failed");
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-principal access cache (UI-blocks U3 Task 2, spec §6.5): the
|
|
3
|
+
* server caches the access verdict of each render, keyed by
|
|
4
|
+
* `(slug, id, key, principal.user)`, for 60 seconds. An action checks
|
|
5
|
+
* the cached verdict first; a miss (expired or never rendered) is not
|
|
6
|
+
* an allow — the caller re-renders for that principal and caches its
|
|
7
|
+
* answer before deciding. One principal's entry never answers for
|
|
8
|
+
* another (`principal.user` is part of the key).
|
|
9
|
+
*/
|
|
10
|
+
/** The cache TTL (spec §6.5): 60 seconds. */
|
|
11
|
+
export const ACCESS_CACHE_TTL_MS = 60_000;
|
|
12
|
+
/** The cache store: composite-key → verdict, expiring lazily on read. */
|
|
13
|
+
export class AccessCache {
|
|
14
|
+
entries = new Map();
|
|
15
|
+
now;
|
|
16
|
+
/** @param now the clock (tests inject a fake; production passes Date.now). */
|
|
17
|
+
constructor(now = () => Date.now()) {
|
|
18
|
+
this.now = now;
|
|
19
|
+
}
|
|
20
|
+
static composite(slug, id, key, user) {
|
|
21
|
+
// The unit separator keeps segments unambiguous (no escaping needed).
|
|
22
|
+
return [slug, id, key, user ?? ""].join("\u0001");
|
|
23
|
+
}
|
|
24
|
+
/** The cached verdict for this principal, or undefined on a miss/expiry. */
|
|
25
|
+
get(slug, id, key, user) {
|
|
26
|
+
const composite = AccessCache.composite(slug, id, key, user);
|
|
27
|
+
const entry = this.entries.get(composite);
|
|
28
|
+
if (entry === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (entry.expiresAt <= this.now()) {
|
|
31
|
+
this.entries.delete(composite);
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return entry.access;
|
|
35
|
+
}
|
|
36
|
+
/** Cache one principal's verdict for the TTL. */
|
|
37
|
+
set(slug, id, key, user, access) {
|
|
38
|
+
this.entries.set(AccessCache.composite(slug, id, key, user), {
|
|
39
|
+
access,
|
|
40
|
+
expiresAt: this.now() + ACCESS_CACHE_TTL_MS,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action dedup (UI-blocks U3 Task 3, spec §6.1): the server invokes a
|
|
3
|
+
* surface action handler at most once per `actionId` per surface
|
|
4
|
+
* instance within a 10-minute window; a duplicate POST returns the
|
|
5
|
+
* FIRST invocation's stored result (retries are safe). Entries are
|
|
6
|
+
* keyed by actionId alone — actionIds are channel-generated UUIDs —
|
|
7
|
+
* with the instance composite (`slugidkey`) stored on the entry so a
|
|
8
|
+
* collision across instances is treated as a distinct action, not a
|
|
9
|
+
* replay of another instance's result.
|
|
10
|
+
*
|
|
11
|
+
* Process-memory only, like the service's rev state (§7.3). Entries
|
|
12
|
+
* expire lazily on read; a bounded sweep caps memory between reads.
|
|
13
|
+
*/
|
|
14
|
+
/** The dedup window (spec §6.1): 10 minutes. */
|
|
15
|
+
export const DEDUP_WINDOW_MS = 10 * 60 * 1000;
|
|
16
|
+
/**
|
|
17
|
+
* The actionId → result table. `check` returns the stored result on a
|
|
18
|
+
* same-instance duplicate (and `null` when a different instance already
|
|
19
|
+
* holds the actionId — the caller treats that as a fresh action);
|
|
20
|
+
* `record` stores the first result.
|
|
21
|
+
*/
|
|
22
|
+
export class ActionDedup {
|
|
23
|
+
entries = new Map();
|
|
24
|
+
clock;
|
|
25
|
+
sweepCounter = 0;
|
|
26
|
+
constructor(clock) {
|
|
27
|
+
// Resolve Date.now at CALL time, not construction: a default
|
|
28
|
+
// parameter (`clock = Date.now`) would capture the Date.now of
|
|
29
|
+
// whichever fake-timer install was active at construction and
|
|
30
|
+
// freeze this table to that install's clock.
|
|
31
|
+
this.clock = clock ?? (() => Date.now());
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Look one actionId up for one instance: the stored result on a
|
|
35
|
+
* duplicate, `undefined` when never seen, `null` when the actionId
|
|
36
|
+
* belongs to a different instance (treated as fresh by the caller).
|
|
37
|
+
*/
|
|
38
|
+
check(actionId, composite) {
|
|
39
|
+
const entry = this.entries.get(actionId);
|
|
40
|
+
if (entry === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (entry.expiresAt <= this.clock()) {
|
|
43
|
+
this.entries.delete(actionId);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (entry.composite !== composite)
|
|
47
|
+
return null;
|
|
48
|
+
return { result: entry.result };
|
|
49
|
+
}
|
|
50
|
+
/** Record the first result of one actionId for one instance. */
|
|
51
|
+
record(actionId, composite, result) {
|
|
52
|
+
this.entries.set(actionId, {
|
|
53
|
+
composite,
|
|
54
|
+
result,
|
|
55
|
+
expiresAt: this.clock() + DEDUP_WINDOW_MS,
|
|
56
|
+
});
|
|
57
|
+
this.sweepCounter += 1;
|
|
58
|
+
if (this.sweepCounter >= 64) {
|
|
59
|
+
this.sweepCounter = 0;
|
|
60
|
+
const now = this.clock();
|
|
61
|
+
for (const [id, entry] of this.entries) {
|
|
62
|
+
if (entry.expiresAt <= now)
|
|
63
|
+
this.entries.delete(id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance keys (UI-blocks U3 Task 2, spec §7.3): a surface instance is
|
|
3
|
+
* addressed by `(slug, id, key)` where `key` is `instanceKey(params)` —
|
|
4
|
+
* by default the CANONICAL JSON of the params object, with every
|
|
5
|
+
* object's keys sorted recursively, so `{a:1,b:2}` and `{b:2,a:1}`
|
|
6
|
+
* address the same instance. An extension may override `instanceKey`
|
|
7
|
+
* per surface; the spec's hash form (`eventKey`, sha256 over
|
|
8
|
+
* `slug \0 id \0 key`) is what events name instances by — params never
|
|
9
|
+
* appear on the wire (computed here so Task 4's events share it).
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
/**
|
|
13
|
+
* Canonical JSON: every object serializes with keys SORTED (recursive);
|
|
14
|
+
* primitives and arrays serialize as usual. Two params objects equal up
|
|
15
|
+
* to key order produce byte-identical strings.
|
|
16
|
+
*/
|
|
17
|
+
export function canonicalJson(value) {
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === "object" && value !== null) {
|
|
22
|
+
const keys = Object.keys(value).sort();
|
|
23
|
+
const parts = keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`);
|
|
24
|
+
return `{${parts.join(",")}}`;
|
|
25
|
+
}
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The default instance key (spec §7.3): canonical JSON of the params.
|
|
30
|
+
* A surface's own `instanceKey` overrides this; the service applies the
|
|
31
|
+
* override, this is the fallback.
|
|
32
|
+
*/
|
|
33
|
+
export function instanceKey(params) {
|
|
34
|
+
return canonicalJson(params);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The wire name of an instance (spec §7.3): `sha256(slug + "\0" + id +
|
|
38
|
+
* "\0" + key)` hex. Events carry `k`, never the key or the params.
|
|
39
|
+
*/
|
|
40
|
+
export function eventKey(slug, id, key) {
|
|
41
|
+
return createHash("sha256").update([slug, id, key].join("\u0000")).digest("hex");
|
|
42
|
+
}
|