@sigrun/sigrun 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +24 -2
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/builders.js +125 -0
  5. package/dist/blocks/images.js +57 -0
  6. package/dist/blocks/index.js +20 -0
  7. package/dist/blocks/limits.js +26 -0
  8. package/dist/blocks/placements.js +99 -0
  9. package/dist/blocks/schema.js +559 -0
  10. package/dist/blocks/validate.js +602 -0
  11. package/dist/config.js +31 -0
  12. package/dist/extension.d.ts +476 -9
  13. package/dist/harness/agent-env.js +62 -0
  14. package/dist/harness/boot.js +8 -1
  15. package/dist/harness/event-bus.js +33 -0
  16. package/dist/harness/identity/users.js +3 -2
  17. package/dist/harness/loader.js +369 -13
  18. package/dist/harness/packages.js +71 -2
  19. package/dist/harness/seam-host.js +69 -2
  20. package/dist/harness/settings.js +5 -0
  21. package/dist/harness/workspace.js +6 -4
  22. package/dist/jobs/fire.js +11 -0
  23. package/dist/jobs/spec.js +19 -0
  24. package/dist/serve.js +29 -39
  25. package/dist/server/auth/pipeline.js +16 -1
  26. package/dist/server/conversation-ops.js +482 -10
  27. package/dist/server/gateway.js +122 -0
  28. package/dist/server/helpers.js +26 -0
  29. package/dist/server/host-bridge.js +141 -4
  30. package/dist/server/index.js +27 -0
  31. package/dist/server/notifications.js +6 -2
  32. package/dist/server/routes/conversations.js +37 -93
  33. package/dist/server/routes/events.js +39 -0
  34. package/dist/server/routes/ext-assets.js +84 -0
  35. package/dist/server/routes/extensions.js +31 -1
  36. package/dist/server/routes/files.js +38 -83
  37. package/dist/server/routes/meta.js +35 -1
  38. package/dist/server/routes/providers.js +5 -21
  39. package/dist/server/routes/submissions.js +36 -111
  40. package/dist/server/routes/surfaces.js +387 -0
  41. package/dist/server/routes/users.js +6 -1
  42. package/dist/server/session-meta.js +12 -6
  43. package/dist/server/shutdown.js +85 -0
  44. package/dist/server/surfaces/access-cache.js +43 -0
  45. package/dist/server/surfaces/dedup.js +67 -0
  46. package/dist/server/surfaces/instance.js +42 -0
  47. package/dist/server/surfaces/mime.js +73 -0
  48. package/dist/server/surfaces/rate-limit.js +63 -0
  49. package/dist/server/surfaces/service.js +493 -0
  50. package/dist/server/surfaces/watchers.js +94 -0
  51. package/node_modules/@sigrun/shared/CHANGELOG.md +9 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
  53. package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
  54. package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
  55. package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
  56. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
  57. package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
  58. package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
  59. package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
  60. package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
  61. package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
  62. package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
  63. package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
  64. package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
  65. package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
  66. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
  67. package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
  68. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
  69. package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
  70. package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
  71. package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
  72. package/node_modules/@sigrun/shared/dist/live.js +82 -0
  73. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
  74. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
  75. package/node_modules/@sigrun/shared/dist/views.d.ts +81 -3
  76. package/node_modules/@sigrun/shared/package.json +10 -2
  77. package/package.json +11 -5
  78. package/webui/dist/assets/index-CiYInlLx.css +1 -0
  79. package/webui/dist/assets/index-JDfOQV9z.js +52 -0
  80. package/webui/dist/index.html +2 -2
  81. package/webui/dist/assets/index-BiSMljer.css +0 -1
  82. package/webui/dist/assets/index-hVLvsizV.js +0 -50
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Magic-byte MIME sniffing (UI-blocks U3 Task 3, spec §7.1): the upload
3
+ * and asset routes report the type sniffed from the BYTES, never a
4
+ * client-supplied filename or content-type. Covers the image types the
5
+ * spec admits (§11.1: PNG, JPEG, GIF, WebP) plus the generic text and
6
+ * JSON an extension form field accepts; anything unrecognized is
7
+ * `application/octet-stream`.
8
+ */
9
+ /** Sniff one buffer's MIME type from its leading bytes. */
10
+ export function sniffMime(bytes) {
11
+ if (bytes.length >= 8 &&
12
+ bytes[0] === 0x89 &&
13
+ bytes[1] === 0x50 && // P
14
+ bytes[2] === 0x4e && // N
15
+ bytes[3] === 0x47 // G
16
+ ) {
17
+ return "image/png";
18
+ }
19
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
20
+ return "image/jpeg";
21
+ }
22
+ if (bytes.length >= 6 &&
23
+ bytes[0] === 0x47 && // G
24
+ bytes[1] === 0x49 && // I
25
+ bytes[2] === 0x46 // F
26
+ ) {
27
+ return "image/gif";
28
+ }
29
+ if (bytes.length >= 12 &&
30
+ bytes[0] === 0x52 && // R
31
+ bytes[1] === 0x49 && // I
32
+ bytes[2] === 0x46 && // F
33
+ bytes[3] === 0x46 && // F
34
+ bytes[8] === 0x57 && // W
35
+ bytes[9] === 0x45 && // E
36
+ bytes[10] === 0x42 && // B
37
+ bytes[11] === 0x50 // P
38
+ ) {
39
+ return "image/webp";
40
+ }
41
+ if (bytes.length >= 4 &&
42
+ bytes[0] === 0x25 && // %
43
+ bytes[1] === 0x50 && // P
44
+ bytes[2] === 0x44 && // D
45
+ bytes[3] === 0x46 // F
46
+ ) {
47
+ return "application/pdf";
48
+ }
49
+ // Strictly valid UTF-8 text (no NUL) reads as text; a JSON-shaped
50
+ // text body reports as JSON.
51
+ if (bytes.length > 0 && isUtf8Text(bytes)) {
52
+ const trimmed = new TextDecoder().decode(bytes).trimStart();
53
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
54
+ return "application/json";
55
+ }
56
+ return "text/plain";
57
+ }
58
+ return "application/octet-stream";
59
+ }
60
+ /** True when the bytes are valid UTF-8 with no NULs (a text file). */
61
+ function isUtf8Text(bytes) {
62
+ for (const byte of bytes) {
63
+ if (byte === 0)
64
+ return false;
65
+ }
66
+ try {
67
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
68
+ return true;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Action rate limiting (UI-blocks U3 Task 3, spec §15): 20 actions per
3
+ * second per principal per surface, burst 40, over → 429. A token
4
+ * bucket keyed `(slug, id, principal.user)`; memory-only, buckets
5
+ * lazily refilled on read and swept when the table grows.
6
+ */
7
+ /** The refill rate (spec §15): 20 tokens per second. */
8
+ export const RATE_PER_SECOND = 20;
9
+ /** The burst capacity (spec §15): 40 tokens. */
10
+ export const BURST_CAPACITY = 40;
11
+ /** The per-principal-per-surface token bucket table. */
12
+ export class ActionRateLimit {
13
+ buckets = new Map();
14
+ clock;
15
+ disabled = false;
16
+ constructor(clock) {
17
+ // Resolve Date.now at CALL time (see ActionDedup's note).
18
+ this.clock = clock ?? (() => Date.now());
19
+ }
20
+ /**
21
+ * Test seam: disable (or re-enable) the limiter. The actions tests
22
+ * disable it for the non-rate tests so per-test state isolation is
23
+ * not required (the service is a per-server singleton).
24
+ */
25
+ setDisabled(disabled) {
26
+ this.disabled = disabled;
27
+ }
28
+ /**
29
+ * Take one token: `true` when the action may proceed, `false` when
30
+ * the bucket is empty (→ 429). Refills lazily from the wall clock.
31
+ */
32
+ take(slug, id, user) {
33
+ if (this.disabled)
34
+ return true;
35
+ const now = this.clock();
36
+ const key = `${slug}${id}${user}`;
37
+ let bucket = this.buckets.get(key);
38
+ if (bucket === undefined) {
39
+ bucket = { tokens: BURST_CAPACITY - 1, lastRefill: now };
40
+ this.buckets.set(key, bucket);
41
+ if (this.buckets.size > 4096)
42
+ this.sweep(now);
43
+ return true;
44
+ }
45
+ const refill = ((now - bucket.lastRefill) / 1000) * RATE_PER_SECOND;
46
+ const tokens = Math.min(BURST_CAPACITY, bucket.tokens + refill);
47
+ bucket.lastRefill = now;
48
+ if (tokens < 1) {
49
+ bucket.tokens = tokens;
50
+ return false;
51
+ }
52
+ bucket.tokens = tokens - 1;
53
+ return true;
54
+ }
55
+ /** Drop stale full buckets (nothing a refill would change). */
56
+ sweep(now) {
57
+ const fullAfterMs = (BURST_CAPACITY / RATE_PER_SECOND) * 1000;
58
+ for (const [key, bucket] of this.buckets) {
59
+ if (now - bucket.lastRefill > fullAfterMs)
60
+ this.buckets.delete(key);
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,493 @@
1
+ /**
2
+ * The surface service (UI-blocks U3 Task 2, spec §7.1–§7.3): renders
3
+ * and refetches extension surfaces over the loader's flattened
4
+ * `ExtensionSurfaceBinding` list, enforcing the per-principal access
5
+ * contract (§6.5) server-side:
6
+ *
7
+ * - `render` validates the extension's tree against its placement with
8
+ * `handlers` = the surface's action keys, caches the verdict per
9
+ * `(slug, id, key, principal.user)` for 60 s, and keeps per-instance
10
+ * revision state (`rev`, `treeRev`, and the shared-data op log);
11
+ * - `data` refetches principal-specific surfaces by calling the
12
+ * extension's `data()` on EVERY fetch; for a surface without `data()`
13
+ * (and without `refreshMs`) the fetch re-runs `render` (the brief's
14
+ * ruling; spec §7.1's "data refetch would re-run render");
15
+ * - `sharedData` surfaces keep the last {@link SHARED_OPS_WINDOW} ops
16
+ * passed to `changed`/`bumpRev` per instance; a `since` inside the
17
+ * window answers `{rev, treeRev, ops}`, a stale `since` falls back to
18
+ * a full `data()` call;
19
+ * - `none` is indistinguishable from an unknown id (the routes map both
20
+ * to the same 404 body).
21
+ *
22
+ * Errors: a render/data throw, or an invalid tree, throws
23
+ * {@link SurfaceFailedError} carrying the validation errors; the route
24
+ * maps it to `500 surface_failed` (bodies carry `errors` only when
25
+ * `server.dev`). The extension stays loaded.
26
+ */
27
+ import { validateTree } from "../../blocks/validate.js";
28
+ import { AccessCache } from "./access-cache.js";
29
+ import { ActionDedup } from "./dedup.js";
30
+ import { eventKey, instanceKey as defaultInstanceKey } from "./instance.js";
31
+ import { sniffMime } from "./mime.js";
32
+ import { ActionRateLimit } from "./rate-limit.js";
33
+ import { WatcherRegistry } from "./watchers.js";
34
+ /** The shared-data op-log window (spec §7.3): the last 100 updates. */
35
+ export const SHARED_OPS_WINDOW = 100;
36
+ /** The action timeout (spec §6.3): 10 s, then the action is a reject. */
37
+ export const ACTION_TIMEOUT_MS = 10 * 1000;
38
+ /** The body sent for a timed-out handler (spec §6.3 step 4). */
39
+ const TIMED_OUT_RESULT = {
40
+ ok: false,
41
+ error: { message: "timed out" },
42
+ };
43
+ /** Errors `act` reports without calling the handler. */
44
+ export class ActionRateLimitedError extends Error {
45
+ constructor() {
46
+ super("rate limited");
47
+ this.name = "ActionRateLimitedError";
48
+ }
49
+ }
50
+ export class UnknownActionError extends Error {
51
+ constructor(action) {
52
+ super(`unknown action: ${action}`);
53
+ this.name = "UnknownActionError";
54
+ }
55
+ }
56
+ function isRec(value) {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value);
58
+ }
59
+ /**
60
+ * Yield every node of a block tree, structurally (the walk only reads
61
+ * `children`, `fields`, `item`, and each field's own props — a tree
62
+ * validated at render time is the expected input, but an unvalidated
63
+ * def tree walks the same way).
64
+ */
65
+ function* walkNodes(root) {
66
+ yield root;
67
+ // The tree record itself: descend its `root`. Blocks descend their
68
+ // `children` (stack/section/form/columns…), a page's `main`, a
69
+ // collection's `item` template, and a form's field lists; a `list`
70
+ // field's `item` field recurses the same way.
71
+ const childLists = [
72
+ root.root,
73
+ root.children,
74
+ root.fields,
75
+ root.main,
76
+ root.sidebar,
77
+ root.detail,
78
+ ];
79
+ for (const list of childLists) {
80
+ if (Array.isArray(list)) {
81
+ for (const child of list) {
82
+ if (isRec(child)) {
83
+ if (isRec(child.block)) {
84
+ // columns/grid wrappers: `{weight?, block}`
85
+ yield* walkNodes(child.block);
86
+ }
87
+ else {
88
+ yield* walkNodes(child);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ else if (isRec(list)) {
94
+ yield* walkNodes(list);
95
+ }
96
+ }
97
+ if (isRec(root.item))
98
+ yield* walkNodes(root.item);
99
+ }
100
+ /** A render/data failure (throw or invalid tree) → 500 surface_failed. */
101
+ export class SurfaceFailedError extends Error {
102
+ errors;
103
+ constructor(message, errors) {
104
+ super(message);
105
+ this.name = "SurfaceFailedError";
106
+ this.errors = errors;
107
+ }
108
+ }
109
+ /**
110
+ * The render/data service over `opened.extensionSurfaces`. Holds the
111
+ * access cache and the per-instance revision state.
112
+ */
113
+ export class SurfaceService {
114
+ bindings;
115
+ cache;
116
+ instances = new Map();
117
+ dedup = new ActionDedup();
118
+ rateLimit = new ActionRateLimit();
119
+ maxUploadBytes;
120
+ watchers;
121
+ publishChanged;
122
+ constructor(bindings, options) {
123
+ this.bindings = bindings;
124
+ this.cache = new AccessCache();
125
+ this.maxUploadBytes = options?.maxUploadBytes ?? Number.MAX_SAFE_INTEGER;
126
+ this.watchers = options?.watchers ?? new WatcherRegistry();
127
+ this.publishChanged = options?.publishChanged;
128
+ }
129
+ /** The watcher registry (the events route reads it per event). */
130
+ get watcherRegistry() {
131
+ return this.watchers;
132
+ }
133
+ /** Find one surface def by slug + id. */
134
+ locate(slug, id) {
135
+ for (const binding of this.bindings) {
136
+ if (binding.extension !== slug)
137
+ continue;
138
+ const def = binding.defs.find((entry) => entry.id === id);
139
+ if (def !== undefined)
140
+ return { slug, def };
141
+ }
142
+ return undefined;
143
+ }
144
+ /** The instance key: the surface's own `instanceKey` or the default. */
145
+ keyFor(def, params) {
146
+ if (def.instanceKey !== undefined)
147
+ return def.instanceKey(params);
148
+ return defaultInstanceKey(params);
149
+ }
150
+ /** The per-instance state, created on first touch (revs start at 0). */
151
+ stateFor(slug, id, key) {
152
+ const composite = `${slug}\u0001${id}\u0001${key}`;
153
+ let state = this.instances.get(composite);
154
+ if (state === undefined) {
155
+ state = { rev: 0, treeRev: 0, ops: [], fileFields: new Map() };
156
+ this.instances.set(composite, state);
157
+ }
158
+ return state;
159
+ }
160
+ /**
161
+ * Render one surface instance for one principal. Throws
162
+ * SurfaceFailedError on an extension throw or an invalid tree; returns
163
+ * `undefined` when the surface id is unknown or access is `none`.
164
+ */
165
+ async render(slug, id, params, principal, _stream) {
166
+ const located = this.locate(slug, id);
167
+ if (located === undefined)
168
+ return undefined;
169
+ const key = this.keyFor(located.def, params);
170
+ const result = await this.callRender(located, key, params, principal);
171
+ const validation = validateTree(result.tree, {
172
+ placement: located.def.placement,
173
+ handlers: new Set(Object.keys(located.def.actions ?? {})),
174
+ });
175
+ if (!validation.ok) {
176
+ throw new SurfaceFailedError(`surface ${slug}/${id} returned an invalid tree`, validation.errors);
177
+ }
178
+ this.cache.set(slug, id, key, principal.user, result.access);
179
+ if (result.access === "none")
180
+ return undefined;
181
+ // The watcher registration (Task 4, spec §7.3): a fetch carrying
182
+ // `X-Sigrun-Stream` creates/refreshes the entry with the access the
183
+ // fetch resolved — the events route reads the STORED access, never
184
+ // re-rendering per event.
185
+ if (_stream !== undefined) {
186
+ this.watchers.touch(_stream, principal.user ?? "", slug, id, key, result.access);
187
+ }
188
+ const state = this.stateFor(slug, id, key);
189
+ // Record the tree's file-field caps for the upload route (spec
190
+ // §7.1: the field's maxBytes, clamped by server.maxUploadBytes).
191
+ state.fileFields.clear();
192
+ for (const node of walkNodes(validation.tree)) {
193
+ if (node.type === "file" && typeof node.name === "string") {
194
+ if (typeof node.maxBytes === "number") {
195
+ state.fileFields.set(node.name, node.maxBytes);
196
+ }
197
+ }
198
+ }
199
+ return {
200
+ tree: validation.tree,
201
+ data: result.data,
202
+ rev: state.rev,
203
+ treeRev: state.treeRev,
204
+ access: result.access,
205
+ };
206
+ }
207
+ /**
208
+ * Fetch one instance's data. Principal-specific surfaces call
209
+ * `data()` on every fetch (or re-run `render` when no `data()` exists
210
+ * — the data-less fallback ruling). `sharedData` surfaces answer from
211
+ * the op log when `since` is inside the window. Returns `undefined`
212
+ * for unknown ids and `none` access alike.
213
+ */
214
+ async data(slug, id, params, principal, since, _stream) {
215
+ const located = this.locate(slug, id);
216
+ if (located === undefined)
217
+ return undefined;
218
+ const key = this.keyFor(located.def, params);
219
+ const state = this.stateFor(slug, id, key);
220
+ const access = await this.accessFor(slug, id, key, params, principal);
221
+ if (access === "none")
222
+ return undefined;
223
+ // The since window (spec §7.3): contiguous iff every rev increment
224
+ // corresponds to one logged op (the full-fetch path below never
225
+ // bumps a sharedData rev, preserving this). `since == rev` answers
226
+ // an empty op list — the client is current.
227
+ if (located.def.sharedData === true &&
228
+ since !== undefined &&
229
+ since >= state.rev - state.ops.length &&
230
+ since <= state.rev) {
231
+ const ops = state.ops
232
+ .filter((entry) => entry.rev > since)
233
+ .flatMap((entry) => entry.ops);
234
+ return { rev: state.rev, treeRev: state.treeRev, ops };
235
+ }
236
+ let data;
237
+ let access2;
238
+ if (located.def.data !== undefined) {
239
+ let result;
240
+ try {
241
+ result = await located.def.data({
242
+ principal,
243
+ params,
244
+ key,
245
+ signal: new AbortController().signal,
246
+ });
247
+ }
248
+ catch (cause) {
249
+ throw new SurfaceFailedError(`surface ${slug}/${id} data() threw: ${cause instanceof Error ? cause.message : String(cause)}`);
250
+ }
251
+ data = result.data;
252
+ access2 = result.access;
253
+ }
254
+ else {
255
+ // No data(): the refetch re-runs render (the fallback ruling).
256
+ const rendered = await this.callRender(located, key, params, principal);
257
+ data = rendered.data;
258
+ access2 = rendered.access;
259
+ }
260
+ this.cache.set(slug, id, key, principal.user, access2);
261
+ if (_stream !== undefined) {
262
+ this.watchers.touch(_stream, principal.user ?? "", slug, id, key, access2);
263
+ }
264
+ if (access2 === "none")
265
+ return undefined;
266
+ // Rev advances only via bumpRev for sharedData surfaces (spec §7.3):
267
+ // the server-assigned rev reflects STORED changes, and a full data()
268
+ // refetch returns data consistent with the stored rev — bumping here
269
+ // would break the since-window invariant (every rev must correspond
270
+ // to one logged op) and strand clients at an unobservable revision.
271
+ // Principal-specific surfaces keep no ops contract; their bump is a
272
+ // per-fetch freshness marker only (gap detection, never consumed for
273
+ // ops).
274
+ if (located.def.sharedData !== true) {
275
+ state.rev += 1;
276
+ }
277
+ return { rev: state.rev, treeRev: state.treeRev, data };
278
+ }
279
+ /**
280
+ * One principal's access to one instance (spec §6.5): the cached
281
+ * verdict when fresh, else a re-render whose answer is cached.
282
+ */
283
+ async accessFor(slug, id, key, params, principal) {
284
+ const cached = this.cache.get(slug, id, key, principal.user);
285
+ if (cached !== undefined)
286
+ return cached;
287
+ const located = this.locate(slug, id);
288
+ if (located === undefined)
289
+ return "none";
290
+ const result = await this.callRender(located, key, params, principal);
291
+ this.cache.set(slug, id, key, principal.user, result.access);
292
+ return result.access;
293
+ }
294
+ /** The composite instance key (`slugidkey`) for one def + params. */
295
+ compositeFor(slug, def, params) {
296
+ return `${slug}${def.id}${this.keyFor(def, params)}`;
297
+ }
298
+ /**
299
+ * The effective upload cap for one field (spec §7.1): the field's
300
+ * own `maxBytes` clamped by the server-wide `server.maxUploadBytes`.
301
+ * A field absent from the last rendered tree answers `undefined`.
302
+ */
303
+ uploadCapBytes(slug, id, key, field) {
304
+ const state = this.instances.get(`${slug}${id}${key}`);
305
+ if (state === undefined)
306
+ return undefined;
307
+ if (!state.fileFields.has(field))
308
+ return undefined;
309
+ const fieldMax = state.fileFields.get(field);
310
+ return Math.min(fieldMax ?? Number.MAX_SAFE_INTEGER, this.maxUploadBytes);
311
+ }
312
+ /** Test seam: enable or disable the action rate limiter. */
313
+ setRateLimitDisabled(disabled) {
314
+ this.rateLimit.setDisabled(disabled);
315
+ }
316
+ /**
317
+ * Invoke one action handler (spec §6.1/§6.3/§15): rate-limited per
318
+ * principal per surface, deduped per actionId per instance for 10
319
+ * minutes (a duplicate returns the FIRST stored result), timed out
320
+ * at 10 s (the handler is abandoned; its late settlement is dropped
321
+ * — the dedup entry still records the timeout body), and wrapped per
322
+ * §6.3: an accept WITH `update.ops` answers `{ok, rev, ops}` (ops
323
+ * logged for sharedData), an accept WITHOUT ops answers `{ok, rev}`
324
+ * with nothing recorded (extension surfaces own their data), and
325
+ * `navigate`/`toast` pass through.
326
+ *
327
+ * Ordering note (a decision, not an accident): the rate limiter
328
+ * takes its token BEFORE the dedup check, so a legitimate retry of
329
+ * an already-processed actionId still costs a token — replayed
330
+ * actionIds can never probe the service rate-free; the trade-off is
331
+ * that a client hammering one actionId can 429 itself.
332
+ *
333
+ * The caller (the route) has ALREADY enforced access; a miss/throw
334
+ * inside the handler maps to {@link SurfaceFailedError}.
335
+ */
336
+ async act(slug, id, key, params, principal, action, payload, actionId, signal) {
337
+ const located = this.locate(slug, id);
338
+ if (located === undefined)
339
+ return { ok: false, error: { message: "unknown surface" } };
340
+ const handler = located.def.actions?.[action];
341
+ if (handler === undefined)
342
+ throw new UnknownActionError(action);
343
+ if (!this.rateLimit.take(slug, id, principal.user ?? "")) {
344
+ throw new ActionRateLimitedError();
345
+ }
346
+ const composite = `${slug}${id}${key}`;
347
+ if (actionId !== undefined) {
348
+ const hit = this.dedup.check(actionId, composite);
349
+ if (hit !== undefined && hit !== null)
350
+ return hit.result;
351
+ }
352
+ const result = await this.invokeWithTimeout(() => handler({ principal, params, key, signal }, payload));
353
+ const wrapped = this.wrapResult(slug, id, key, result);
354
+ if (actionId !== undefined) {
355
+ this.dedup.record(actionId, composite, wrapped);
356
+ }
357
+ return wrapped;
358
+ }
359
+ /**
360
+ * Deliver one upload (spec §7.1/§7.2): the bytes were capped at the
361
+ * route (field `maxBytes` clamped by `server.maxUploadBytes`); the
362
+ * MIME is sniffed from the bytes, never the client name. The
363
+ * handler's result is wrapped like an action's.
364
+ */
365
+ async upload(slug, id, key, params, principal, field, name, bytes, signal) {
366
+ const located = this.locate(slug, id);
367
+ if (located === undefined)
368
+ return { ok: false, error: { message: "unknown surface" } };
369
+ const handler = located.def.actions?.[field];
370
+ if (handler === undefined)
371
+ throw new UnknownActionError(field);
372
+ const file = {
373
+ field,
374
+ name,
375
+ mime: sniffMime(bytes),
376
+ bytes,
377
+ };
378
+ let result;
379
+ try {
380
+ result = await handler({ principal, params, key, signal }, file);
381
+ }
382
+ catch (cause) {
383
+ throw new SurfaceFailedError(`surface ${slug}/${id} upload handler "${field}" threw: ${cause instanceof Error ? cause.message : String(cause)}`);
384
+ }
385
+ return this.wrapResult(slug, id, key, result);
386
+ }
387
+ /**
388
+ * The §6.3 accept rules: WITH `update.ops` the ops log (sharedData)
389
+ * and the answer carries `{ok, rev, ops}`; WITHOUT ops nothing is
390
+ * recorded and the answer is `{ok, rev}` (extension surfaces own
391
+ * their data — the rev bump announces the change). A reject and the
392
+ * `navigate`/`toast` fields pass through unchanged.
393
+ */
394
+ wrapResult(slug, id, key, result) {
395
+ if (!result.ok)
396
+ return result;
397
+ if (result.update !== undefined) {
398
+ const rev = this.bumpRev(slug, id, key, result.update);
399
+ return {
400
+ ok: true,
401
+ rev,
402
+ ops: result.update.ops,
403
+ ...(result.navigate === undefined ? {} : { navigate: result.navigate }),
404
+ ...(result.toast === undefined ? {} : { toast: result.toast }),
405
+ };
406
+ }
407
+ const rev = this.bumpRev(slug, id, key);
408
+ return {
409
+ ok: true,
410
+ rev,
411
+ ...(result.navigate === undefined ? {} : { navigate: result.navigate }),
412
+ ...(result.toast === undefined ? {} : { toast: result.toast }),
413
+ };
414
+ }
415
+ /**
416
+ * Race the handler against the 10 s timeout (spec §6.3 step 4): on a
417
+ * timeout the handler is abandoned (its late settlement is dropped)
418
+ * and the action answers the `timed out` reject body.
419
+ */
420
+ async invokeWithTimeout(invoke) {
421
+ let timer;
422
+ try {
423
+ const outcome = await Promise.race([
424
+ invoke().then((result) => ({
425
+ kind: "done",
426
+ result,
427
+ }), (cause) => ({
428
+ kind: "threw",
429
+ cause,
430
+ })),
431
+ new Promise((resolvePromise) => {
432
+ timer = setTimeout(() => resolvePromise({ kind: "timedout" }), ACTION_TIMEOUT_MS);
433
+ }),
434
+ ]);
435
+ if (outcome.kind === "timedout")
436
+ return TIMED_OUT_RESULT;
437
+ if (outcome.kind === "threw") {
438
+ throw outcome.cause instanceof Error
439
+ ? outcome.cause
440
+ : new Error(String(outcome.cause));
441
+ }
442
+ return outcome.result;
443
+ }
444
+ finally {
445
+ if (timer !== undefined)
446
+ clearTimeout(timer);
447
+ }
448
+ }
449
+ /**
450
+ * Record a change for one instance (spec §7.3): advances `rev`;
451
+ * `ops` are kept only for `sharedData` surfaces (the window above).
452
+ * Returns the new `rev`.
453
+ */
454
+ bumpRev(slug, id, key, ops) {
455
+ const state = this.stateFor(slug, id, key);
456
+ state.rev += 1;
457
+ const located = this.locate(slug, id);
458
+ if (located?.def.sharedData === true && ops !== undefined) {
459
+ state.ops.push({ rev: state.rev, ops: ops.ops });
460
+ while (state.ops.length > SHARED_OPS_WINDOW)
461
+ state.ops.shift();
462
+ }
463
+ // The surface.changed nudge (Task 4, spec §7.3): the hashed
464
+ // instance key, the new rev, NO params and NO data; the events
465
+ // route's visibility case delivers it to watching streams only.
466
+ // The watching-stream list resolves HERE — the registry keys on
467
+ // the plain `key`; the wire's hashed `k` can never be reversed.
468
+ this.publishChanged?.({
469
+ type: "surface.changed",
470
+ slug,
471
+ id,
472
+ k: eventKey(slug, id, key),
473
+ rev: state.rev,
474
+ treeRev: state.treeRev,
475
+ streams: this.watchers.streamsFor(slug, id, key),
476
+ });
477
+ return state.rev;
478
+ }
479
+ /** Call the surface's `render`, mapping throws to SurfaceFailedError. */
480
+ async callRender(located, key, params, principal) {
481
+ try {
482
+ return await located.def.render({
483
+ principal,
484
+ params,
485
+ key,
486
+ signal: new AbortController().signal,
487
+ });
488
+ }
489
+ catch (cause) {
490
+ throw new SurfaceFailedError(`surface ${located.slug}/${located.def.id} render() threw: ${cause instanceof Error ? cause.message : String(cause)}`);
491
+ }
492
+ }
493
+ }