@sigrun/sigrun 0.1.3 → 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 +46 -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 +33 -41
- 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 +37 -2
- 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/schema-fixture.js +7 -0
- 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 +83 -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/assets/index-BiSMljer.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-2RfqJO57.css +0 -1
- package/webui/dist/assets/index-DkZgTngD.js +0 -50
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|