@sigrun/sigrun 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.md +352 -36
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +189 -15
- package/dist/harness/agent-env.js +62 -0
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +253 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +80 -6
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -55
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +162 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +20 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +253 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +281 -0
- package/dist/serve.js +82 -7
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +47 -33
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +205 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +144 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +267 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +15 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +15 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -8
- package/webui/dist/assets/index-CxB969qp.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-B4-GYqEb.js +0 -50
- package/webui/dist/assets/index-BiSMljer.css +0 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The refused-address ranges for image-proxy fetching (UI-blocks U7,
|
|
3
|
+
* spec §11.2 verbatim): loopback, `0.0.0.0/8`, RFC 1918 private,
|
|
4
|
+
* link-local, CGNAT `100.64.0.0/10`, unique-local `fc00::/7`,
|
|
5
|
+
* multicast, and the IPv4-mapped IPv6 forms of all of them.
|
|
6
|
+
*
|
|
7
|
+
* IPv4-mapped literals (`::ffff:10.0.0.1`) are normalized by
|
|
8
|
+
* `BlockList.check`, so the IPv4 rules cover every mapped form in
|
|
9
|
+
* both dotted and full 8-group notation. Anything that is not an IP
|
|
10
|
+
* literal at all is refused too (fail closed): the fetcher connects
|
|
11
|
+
* only to literals the vetting understood.
|
|
12
|
+
*/
|
|
13
|
+
import { BlockList, isIP } from "node:net";
|
|
14
|
+
const REFUSED = new BlockList();
|
|
15
|
+
// IPv4
|
|
16
|
+
REFUSED.addSubnet("0.0.0.0", 8, "ipv4");
|
|
17
|
+
REFUSED.addSubnet("127.0.0.0", 8, "ipv4");
|
|
18
|
+
REFUSED.addSubnet("10.0.0.0", 8, "ipv4");
|
|
19
|
+
REFUSED.addSubnet("172.16.0.0", 12, "ipv4");
|
|
20
|
+
REFUSED.addSubnet("192.168.0.0", 16, "ipv4");
|
|
21
|
+
REFUSED.addSubnet("169.254.0.0", 16, "ipv4");
|
|
22
|
+
REFUSED.addSubnet("100.64.0.0", 10, "ipv4");
|
|
23
|
+
REFUSED.addSubnet("224.0.0.0", 4, "ipv4");
|
|
24
|
+
REFUSED.addAddress("255.255.255.255", "ipv4");
|
|
25
|
+
// IPv6
|
|
26
|
+
// (Spec-level gap, known: the deprecated IPv4-compatible form
|
|
27
|
+
// `::127.0.0.1` is NOT refused — only the IPv4-mapped `::ffff:` form
|
|
28
|
+
// normalizes into the IPv4 rules.)
|
|
29
|
+
REFUSED.addSubnet("::", 128, "ipv6");
|
|
30
|
+
REFUSED.addSubnet("::1", 128, "ipv6");
|
|
31
|
+
REFUSED.addSubnet("fc00::", 7, "ipv6");
|
|
32
|
+
REFUSED.addSubnet("fe80::", 10, "ipv6");
|
|
33
|
+
REFUSED.addSubnet("ff00::", 8, "ipv6");
|
|
34
|
+
/** True when `ip` is an address the image proxy must never connect to. */
|
|
35
|
+
export function isRefusedAddress(ip) {
|
|
36
|
+
const family = isIP(ip);
|
|
37
|
+
if (family === 4)
|
|
38
|
+
return REFUSED.check(ip, "ipv4");
|
|
39
|
+
if (family === 6)
|
|
40
|
+
return REFUSED.check(ip, "ipv6");
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The image-source rewriter (UI-blocks U7 Task 1, spec §11.1/§11.2):
|
|
3
|
+
* walks a tree or data JSON value and replaces `https://` image sources
|
|
4
|
+
* with the signer's proxy URL before the value leaves the server:
|
|
5
|
+
*
|
|
6
|
+
* - an `image` block's string `src` (literals only — a binding resolves
|
|
7
|
+
* client-side against the data, whose own slots are rewritten below);
|
|
8
|
+
* - a card `image` slot (a string `image` prop), in a tree's item
|
|
9
|
+
* template AND in data items (spec §5.5.1: the slot takes a binding
|
|
10
|
+
* relative to the item, so the URL usually lives in the data);
|
|
11
|
+
* - `` markdown image links in any string (markdown
|
|
12
|
+
* text, card/callout bodies, and the data strings they bind to).
|
|
13
|
+
*
|
|
14
|
+
* `asset:` and `data:` sources are untouched (§11.1). The walk is pure:
|
|
15
|
+
* the input is never mutated, and a branch is rebuilt only when a
|
|
16
|
+
* descendant changed (copy-on-write, the discipline of
|
|
17
|
+
* `shared/blocks/ops.ts`); an untouched subtree is returned by
|
|
18
|
+
* reference.
|
|
19
|
+
*
|
|
20
|
+
* Scope note (a spec-reading decision): the whole-string rewrite keys
|
|
21
|
+
* on the two shapes the block set defines for image sources — the
|
|
22
|
+
* `image` block's `src` prop and the card `image` prop — plus markdown
|
|
23
|
+
* image links. A binding that resolves to an https URL stored in data
|
|
24
|
+
* under any OTHER prop name is not rewritten (the walk cannot trace
|
|
25
|
+
* bindings); the webui's `img-src 'self' data:` CSP (Task 3) makes such
|
|
26
|
+
* a miss fail closed rather than load.
|
|
27
|
+
*/
|
|
28
|
+
/** A markdown image link: ``. */
|
|
29
|
+
const MARKDOWN_IMAGE = /(!\[[^\]]*\]\()(https:\/\/[^)\s]+)(\))/g;
|
|
30
|
+
/** Whether `s` is an https image source the rewriter replaces. */
|
|
31
|
+
function isHttps(s) {
|
|
32
|
+
return s.startsWith("https://");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Rewrite one string's markdown image links; `null` when nothing
|
|
36
|
+
* matched (so the caller keeps the original reference).
|
|
37
|
+
*/
|
|
38
|
+
function rewriteMarkdown(text, sign) {
|
|
39
|
+
let changed = false;
|
|
40
|
+
const out = text.replace(MARKDOWN_IMAGE, (_all, pre, url, post) => {
|
|
41
|
+
changed = true;
|
|
42
|
+
return `${pre}${sign(url)}${post}`;
|
|
43
|
+
});
|
|
44
|
+
return changed ? out : null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Rewrite every https image source in a tree or data value. Pure; see
|
|
48
|
+
* the module docstring for the three rewrite shapes and the scope note.
|
|
49
|
+
*/
|
|
50
|
+
export function rewriteImages(value, sign) {
|
|
51
|
+
return rewrite(value, sign);
|
|
52
|
+
}
|
|
53
|
+
/** The structural walk (copy-on-write: `null`-free, returns a value). */
|
|
54
|
+
function rewrite(value, sign) {
|
|
55
|
+
if (typeof value === "string") {
|
|
56
|
+
const md = rewriteMarkdown(value, sign);
|
|
57
|
+
return md === null ? value : md;
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
let changed = false;
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const entry of value) {
|
|
63
|
+
const rewritten = rewrite(entry, sign);
|
|
64
|
+
if (rewritten !== entry)
|
|
65
|
+
changed = true;
|
|
66
|
+
out.push(rewritten);
|
|
67
|
+
}
|
|
68
|
+
return changed ? out : value;
|
|
69
|
+
}
|
|
70
|
+
if (typeof value === "object" && value !== null) {
|
|
71
|
+
let changed = false;
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
74
|
+
let rewritten;
|
|
75
|
+
if (typeof entry === "string" &&
|
|
76
|
+
(key === "src" || key === "image") &&
|
|
77
|
+
isHttps(entry)) {
|
|
78
|
+
// The two whole-string shapes: an image block's `src` and a
|
|
79
|
+
// card's `image` slot. Markdown links inside the string (none
|
|
80
|
+
// in a bare URL) are covered by the string branch below, so
|
|
81
|
+
// rewrite directly.
|
|
82
|
+
rewritten = sign(entry);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
rewritten = rewrite(entry, sign);
|
|
86
|
+
}
|
|
87
|
+
if (rewritten !== entry)
|
|
88
|
+
changed = true;
|
|
89
|
+
out[key] = rewritten;
|
|
90
|
+
}
|
|
91
|
+
return changed ? out : value;
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signing half of the image proxy (UI-blocks U7 Task 1, spec §11.2
|
|
3
|
+
* verbatim):
|
|
4
|
+
*
|
|
5
|
+
* - `s` is HMAC-SHA256 (hex) over the exact bytes `u + "\n" + e`, where
|
|
6
|
+
* `u` is the URL string as it appeared in the tree — the server never
|
|
7
|
+
* re-parses or re-encodes it between verifying and fetching.
|
|
8
|
+
* - `e` is an expiry 24 hours after signing (epoch milliseconds).
|
|
9
|
+
* - The key is 32 random bytes at `<stateDir>/image-proxy.key`, mode
|
|
10
|
+
* 0600, created at first boot and reused after. Deleting the file
|
|
11
|
+
* rotates it: old signatures fail and channels re-fetch the surface,
|
|
12
|
+
* which re-signs.
|
|
13
|
+
* - Signatures are not bound to a user; a copied signed URL works for
|
|
14
|
+
* any signed-in user until it expires (spec: that reveals nothing the
|
|
15
|
+
* URL didn't already).
|
|
16
|
+
*/
|
|
17
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
18
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
/** The key file's name under the state dir. */
|
|
22
|
+
export const IMAGE_PROXY_KEY_FILE = "image-proxy.key";
|
|
23
|
+
/** The signature lifetime (spec §11.2): 24 hours. */
|
|
24
|
+
export const SIGNATURE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
/** The proxy path every signed URL is rooted at. */
|
|
26
|
+
const PROXY_PATH = "/v1/image";
|
|
27
|
+
/** Load (or create) the proxy key at `<stateDir>/image-proxy.key`. */
|
|
28
|
+
export async function loadOrCreateKey(stateDir) {
|
|
29
|
+
const path = join(stateDir, IMAGE_PROXY_KEY_FILE);
|
|
30
|
+
try {
|
|
31
|
+
return await readFile(path);
|
|
32
|
+
}
|
|
33
|
+
catch (cause) {
|
|
34
|
+
if (cause.code !== "ENOENT")
|
|
35
|
+
throw cause;
|
|
36
|
+
}
|
|
37
|
+
// First boot: create the key atomically (stage + chmod + rename, the
|
|
38
|
+
// same discipline as the stores' `writeFileAtomic` — chmod BEFORE the
|
|
39
|
+
// rename so there is no window at the wrong mode, and a chmod beats
|
|
40
|
+
// writeFile's `mode` option because umask masks the latter).
|
|
41
|
+
await mkdir(stateDir, { recursive: true });
|
|
42
|
+
const key = randomBytes(32);
|
|
43
|
+
const staged = `${path}.${randomUUID()}.tmp`;
|
|
44
|
+
try {
|
|
45
|
+
await writeFile(staged, key, { mode: 0o600 });
|
|
46
|
+
await chmod(staged, 0o600);
|
|
47
|
+
await rename(staged, path);
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
await rm(staged, { force: true });
|
|
51
|
+
throw cause;
|
|
52
|
+
}
|
|
53
|
+
return key;
|
|
54
|
+
}
|
|
55
|
+
/** Sign one URL at time `now`: `/v1/image?u=…&e=…&s=…`. */
|
|
56
|
+
export function signUrl(key, u, now) {
|
|
57
|
+
const e = String(now + SIGNATURE_TTL_MS);
|
|
58
|
+
const s = signature(key, u, e);
|
|
59
|
+
return `${PROXY_PATH}?u=${encodeURIComponent(u)}&e=${e}&s=${s}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Verify a signed URL's parts at time `now`: the HMAC matches (constant
|
|
63
|
+
* time) AND `now <= e`. Malformed parts (a non-numeric `e`, a wrong-length
|
|
64
|
+
* or non-hex `s`) reject instead of throwing.
|
|
65
|
+
*/
|
|
66
|
+
export function verify(key, u, e, s, now) {
|
|
67
|
+
if (!/^\d+$/.test(e))
|
|
68
|
+
return false;
|
|
69
|
+
if (!/^[0-9a-f]+$/.test(s))
|
|
70
|
+
return false;
|
|
71
|
+
const expected = signature(key, u, e);
|
|
72
|
+
// Constant-time over the fixed 64-byte hex digests; the length check
|
|
73
|
+
// first because timingSafeEqual throws on a length mismatch.
|
|
74
|
+
if (s.length !== expected.length)
|
|
75
|
+
return false;
|
|
76
|
+
if (!timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(s, "utf8"))) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return now <= Number(e);
|
|
80
|
+
}
|
|
81
|
+
/** The HMAC hex digest over the exact bytes `u + "\n" + e`. */
|
|
82
|
+
function signature(key, u, e) {
|
|
83
|
+
return createHmac("sha256", key).update(`${u}\n${e}`, "utf8").digest("hex");
|
|
84
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content sniffing for image-proxy responses (UI-blocks U7, spec
|
|
3
|
+
* §11.2): the body must sniff as PNG, JPEG, GIF or WebP — the
|
|
4
|
+
* `Content-Type` header is never trusted. SVG and AVIF are refused
|
|
5
|
+
* even when the header says `image/png` (SVG can carry script; AVIF
|
|
6
|
+
* has no decoder in pi-tui).
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Sniff `bytes` by magic prefix. Returns `"svg"` / `"avif"` for the
|
|
10
|
+
* refused-but-recognizable types (so callers can name the reason),
|
|
11
|
+
* and `undefined` when nothing matches.
|
|
12
|
+
*/
|
|
13
|
+
export function sniffImage(bytes) {
|
|
14
|
+
if (bytes.length >= 8) {
|
|
15
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
16
|
+
if (bytes[0] === 0x89 &&
|
|
17
|
+
bytes[1] === 0x50 &&
|
|
18
|
+
bytes[2] === 0x4e &&
|
|
19
|
+
bytes[3] === 0x47 &&
|
|
20
|
+
bytes[4] === 0x0d &&
|
|
21
|
+
bytes[5] === 0x0a &&
|
|
22
|
+
bytes[6] === 0x1a &&
|
|
23
|
+
bytes[7] === 0x0a) {
|
|
24
|
+
return "png";
|
|
25
|
+
}
|
|
26
|
+
// GIF: GIF87a / GIF89a
|
|
27
|
+
if (bytes.subarray(0, 3).toString("latin1") === "GIF" &&
|
|
28
|
+
(bytes.subarray(3, 6).toString("latin1") === "87a" ||
|
|
29
|
+
bytes.subarray(3, 6).toString("latin1") === "89a")) {
|
|
30
|
+
return "gif";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// JPEG: FF D8 FF
|
|
34
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
35
|
+
return "jpeg";
|
|
36
|
+
}
|
|
37
|
+
// WebP: "RIFF" + len + "WEBP"
|
|
38
|
+
if (bytes.length >= 12 &&
|
|
39
|
+
bytes.subarray(0, 4).toString("latin1") === "RIFF" &&
|
|
40
|
+
bytes.subarray(8, 12).toString("latin1") === "WEBP") {
|
|
41
|
+
return "webp";
|
|
42
|
+
}
|
|
43
|
+
// AVIF/HEIF: "ftyp" box at offset 4; the brand at offset 8 is
|
|
44
|
+
// avif/avis (or the heic family — refused all the same, but named
|
|
45
|
+
// avif only for actual avif brands so the reason is honest).
|
|
46
|
+
if (bytes.length >= 12 && bytes.subarray(4, 8).toString("latin1") === "ftyp") {
|
|
47
|
+
const brand = bytes.subarray(8, 12).toString("latin1").toLowerCase();
|
|
48
|
+
if (brand === "avif" || brand === "avis")
|
|
49
|
+
return "avif";
|
|
50
|
+
}
|
|
51
|
+
// SVG: an XML prolog or an `<svg` opening anywhere in the first
|
|
52
|
+
// 512 bytes (handles BOM, XML decl, comments, and DOCTYPE).
|
|
53
|
+
const head = bytes.subarray(0, 512).toString("utf8");
|
|
54
|
+
if (/^\uFEFF?\s*(<\?xml[\s\S]*?\?>\s*)?(<!--[\s\S]*?-->\s*)*(!DOCTYPE[\s\S]*?\]\s*)?<svg/i.test(head)) {
|
|
55
|
+
return "svg";
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
/** True only for the four accepted types (the refused ones reject). */
|
|
60
|
+
export function isAcceptedImageType(type) {
|
|
61
|
+
return type === "png" || type === "jpeg" || type === "gif" || type === "webp";
|
|
62
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
* enumeration; the storage scan is authoritative.
|
|
32
32
|
*/
|
|
33
33
|
import Fastify from "fastify";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { LoginFlows } from "./login/flows.js";
|
|
34
36
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
35
37
|
import { buildCommandCatalog, SLASH_COMMANDS } from "./commands.js";
|
|
36
38
|
import { effectiveMethods } from "../config.js";
|
|
@@ -41,7 +43,9 @@ import { storeAgentChange } from "./helpers.js";
|
|
|
41
43
|
import { registerCoreOwnershipParticipant } from "./ownership.js";
|
|
42
44
|
import { createHostBridge } from "./host-bridge.js";
|
|
43
45
|
import { registerJobsOwnershipParticipant } from "../jobs/transfer.js";
|
|
46
|
+
import { registerAdminRoutes } from "./routes/admin.js";
|
|
44
47
|
import { registerAuthRoutes } from "./routes/auth.js";
|
|
48
|
+
import { registerLoginRoutes } from "./routes/login.js";
|
|
45
49
|
import { registerConversationRoutes } from "./routes/conversations.js";
|
|
46
50
|
import { registerEventRoutes } from "./routes/events.js";
|
|
47
51
|
import { registerExtensionRoutes as registerExtensionSettingsRoutes } from "./routes/extensions.js";
|
|
@@ -50,17 +54,26 @@ import { registerExtAssetRoutes } from "./routes/ext-assets.js";
|
|
|
50
54
|
import { registerFileRoutes } from "./routes/files.js";
|
|
51
55
|
import { registerJobRoutes } from "./routes/jobs.js";
|
|
52
56
|
import { registerInteractionRoutes } from "./routes/interactions.js";
|
|
57
|
+
import { registerMeRoutes } from "./routes/me.js";
|
|
53
58
|
import { registerMetaRoutes } from "./routes/meta.js";
|
|
54
59
|
import { registerMetricsRoutes } from "./routes/metrics.js";
|
|
55
60
|
import { registerOnboardingRoutes } from "./routes/onboarding.js";
|
|
56
61
|
import { registerProbeRoutes } from "./routes/probes.js";
|
|
57
62
|
import { registerProviderRoutes } from "./routes/providers.js";
|
|
58
63
|
import { registerSettingsRoutes } from "./routes/settings.js";
|
|
64
|
+
import { registerSettingsSectionsRoutes } from "./routes/settings-sections.js";
|
|
59
65
|
import { registerStreamRoutes } from "./routes/stream.js";
|
|
60
66
|
import { startNotifications } from "./notifications.js";
|
|
67
|
+
import { selfPackageRoot } from "../harness/packages.js";
|
|
61
68
|
import { registerSubmissionRoutes } from "./routes/submissions.js";
|
|
62
69
|
import { registerSurfaceRoutes } from "./routes/surfaces.js";
|
|
70
|
+
import { subscribeChatSurfaceChanged } from "../harness/blocks/tools.js";
|
|
71
|
+
import { loadOrCreateKey, signUrl } from "./images/sign.js";
|
|
72
|
+
import { ImageCache } from "./images/cache.js";
|
|
73
|
+
import { registerImageRoutes } from "./routes/images.js";
|
|
63
74
|
import { SurfaceService } from "./surfaces/service.js";
|
|
75
|
+
import { settingsSurfaceFor } from "./surfaces/settings-surfaces.js";
|
|
76
|
+
import { CORE_SETTINGS_SECTIONS } from "../settings/core-sections.js";
|
|
64
77
|
import { registerUserRoutes } from "./routes/users.js";
|
|
65
78
|
import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
|
|
66
79
|
/**
|
|
@@ -79,8 +92,9 @@ import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
|
|
|
79
92
|
* checks, the public surface, the env service token, the setup gate,
|
|
80
93
|
* then the configured methods — the resolved principal stamps
|
|
81
94
|
* `SessionMeta.owner` at create/fork/import.
|
|
82
|
-
* @param options.listenHost the host the server will bind (
|
|
83
|
-
* `"0.0.0.0"`
|
|
95
|
+
* @param options.listenHost the host the server will bind (fallback
|
|
96
|
+
* `"0.0.0.0"` when omitted; `serve()` passes the dual-stack `::`);
|
|
97
|
+
* the pipeline's Host check and the default method list
|
|
84
98
|
* (`effectiveMethods`) both derive from it.
|
|
85
99
|
* @param contributedBundles the extension-contributed webui-bundle registry
|
|
86
100
|
* (build-and-webui Task 6 threads it here from boot; the serve-time
|
|
@@ -93,7 +107,17 @@ import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
|
|
|
93
107
|
* env-less boot).
|
|
94
108
|
*/
|
|
95
109
|
export async function createServer(opened, config, contributedBundles = [], options) {
|
|
96
|
-
const app = Fastify({
|
|
110
|
+
const app = Fastify({
|
|
111
|
+
logger: false,
|
|
112
|
+
// U7 Task 3: the image route's GET `src=data:` form carries up to
|
|
113
|
+
// 192 KiB of base64 in the query string, which counts toward
|
|
114
|
+
// Node's 16 KiB default header cap and would be 431-rejected at
|
|
115
|
+
// the HTTP layer before the route's own §11.1 413 could answer.
|
|
116
|
+
// 256 KiB admits the cap-sized URL; the route enforces the real
|
|
117
|
+
// limit (192 KiB) itself. The POST form (the body path) stays the
|
|
118
|
+
// recommended shape for long data URLs.
|
|
119
|
+
http: { maxHeaderSize: 256 * 1024 },
|
|
120
|
+
});
|
|
97
121
|
const listenHost = options?.listenHost ?? "0.0.0.0";
|
|
98
122
|
const env = options?.env ?? {};
|
|
99
123
|
// The one body-limit error shape (spec §6.2, W2): Fastify's
|
|
@@ -162,13 +186,38 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
162
186
|
// effective method list), and the boot env (the settings routes'
|
|
163
187
|
// `effective` block reads it).
|
|
164
188
|
const sse = new SseRegistry();
|
|
189
|
+
// The chat-surface seam's unsubscribe (filled in just below, after
|
|
190
|
+
// the events hub exists to publish onto; never reassigned after).
|
|
191
|
+
let unsubscribeChatSurfaceChanged = () => { };
|
|
165
192
|
// Decorated for the shutdown path (serve.ts): fastify's own drain waits
|
|
166
193
|
// on in-flight requests, so the SSE registry must be endable from the
|
|
167
194
|
// OUTSIDE before app.close() — a decoration is the typed, discoverable
|
|
168
195
|
// way to hand that handle up without widening ServerContext's consumers.
|
|
169
196
|
app.decorate("sse", sse);
|
|
197
|
+
// The image-proxy signer (U7 Task 1, spec §11.2): the key is loaded
|
|
198
|
+
// (created at first boot) from the state dir — deleting the file
|
|
199
|
+
// rotates it — and every surface tree/data answer rewrites https
|
|
200
|
+
// image sources through it at serve time.
|
|
201
|
+
const imageKey = await loadOrCreateKey(config.stateDir);
|
|
202
|
+
const imageSign = (u) => signUrl(imageKey, u, Date.now());
|
|
203
|
+
// The proxy cache + fetch deps (U7 Task 3, spec §11.2): the on-disk
|
|
204
|
+
// LRU under `<cacheDir>/images/`; the deps are the real resolver +
|
|
205
|
+
// clock unless a test injects its own.
|
|
206
|
+
const imageCache = new ImageCache(config.cacheDir);
|
|
207
|
+
const imageDeps = {
|
|
208
|
+
resolve: async (host) => {
|
|
209
|
+
const { lookup } = await import("node:dns/promises");
|
|
210
|
+
const addresses = await lookup(host, { all: true, verbatim: true });
|
|
211
|
+
return addresses.map((entry) => entry.address);
|
|
212
|
+
},
|
|
213
|
+
now: Date.now,
|
|
214
|
+
...options?.imageFetch,
|
|
215
|
+
};
|
|
170
216
|
const surfacesService = new SurfaceService(opened.extensionSurfaces, {
|
|
171
217
|
maxUploadBytes: config.maxUploadBytes,
|
|
218
|
+
// Signs https image URLs into expiring proxy URLs (U7 Task 1,
|
|
219
|
+
// spec §11.2) on every tree/data response.
|
|
220
|
+
imageSign,
|
|
172
221
|
// Task 4: bumpRev (actions AND the host seam's changed()) fans the
|
|
173
222
|
// surface.changed event out to the hub; the events route's
|
|
174
223
|
// visibility case delivers it to watching streams only.
|
|
@@ -180,21 +229,90 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
180
229
|
opened.events.publish(event);
|
|
181
230
|
},
|
|
182
231
|
});
|
|
232
|
+
// The settings sections (UI-blocks U4 Task 3, spec §13.3): each
|
|
233
|
+
// declared section adapts to a settings-placement surface served under
|
|
234
|
+
// the declaring extension's own `/v1/ext/<slug>/surfaces/` mount. The
|
|
235
|
+
// save handlers close over the service (the optimistic-concurrency
|
|
236
|
+
// rev read), so the adaptation attaches AFTER the service exists.
|
|
237
|
+
surfacesService.attachSettingsSurfaces(opened.extensionSettingsSections.map((binding) => ({
|
|
238
|
+
extension: binding.extension,
|
|
239
|
+
defs: binding.defs.map((section) => settingsSurfaceFor(binding.extension, section, {
|
|
240
|
+
stores: opened.extensionStores,
|
|
241
|
+
publishSettingsChanged: opened.publishSettingsChanged,
|
|
242
|
+
service: surfacesService,
|
|
243
|
+
})),
|
|
244
|
+
})));
|
|
245
|
+
// The core settings sections (U4 Task 4, spec §13.4): the same
|
|
246
|
+
// renderer under the reserved `core` slug — core and extensions run
|
|
247
|
+
// ONE code path. The core saves write the core SettingsStore (dotted
|
|
248
|
+
// names land at their nested paths, apply-typed), not the extension
|
|
249
|
+
// settings dir.
|
|
250
|
+
surfacesService.attachSettingsSurfaces([
|
|
251
|
+
{
|
|
252
|
+
extension: "core",
|
|
253
|
+
defs: CORE_SETTINGS_SECTIONS.map((section) => settingsSurfaceFor("core", section, {
|
|
254
|
+
stores: opened.extensionStores,
|
|
255
|
+
publishSettingsChanged: opened.publishSettingsChanged,
|
|
256
|
+
service: surfacesService,
|
|
257
|
+
core: {
|
|
258
|
+
settings: opened.settings,
|
|
259
|
+
bootSnapshot: opened.settingsBootSnapshot,
|
|
260
|
+
env,
|
|
261
|
+
// The Tools section's default-tools options (the boot
|
|
262
|
+
// registry's tool names, deduped — the roster the
|
|
263
|
+
// checkbox list edits against).
|
|
264
|
+
toolRoster: [
|
|
265
|
+
...new Set(opened.registry
|
|
266
|
+
.snapshot()
|
|
267
|
+
.tools()
|
|
268
|
+
.map((entry) => entry.tool.name)),
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
})),
|
|
272
|
+
},
|
|
273
|
+
]);
|
|
183
274
|
app.decorate("surfaces", surfacesService);
|
|
275
|
+
// The chat-block change seam (UI-blocks U5 Task 2, spec §8.2):
|
|
276
|
+
// show_blocks/update_blocks AND the user-edit route publish
|
|
277
|
+
// `surface.changed` through the Task-1 in-process seam; binding it
|
|
278
|
+
// here forwards every event onto the hub, where the events route's
|
|
279
|
+
// exhaustive switch gives the CHAT shape the conversation-scoped
|
|
280
|
+
// delivery path (SessionMeta re-snapshot + accessOf per event).
|
|
281
|
+
unsubscribeChatSurfaceChanged = subscribeChatSurfaceChanged((event) => {
|
|
282
|
+
// SAFETY: the chat event IS the wire union's second surface.changed
|
|
283
|
+
// member (same declaration, re-exported through the seam type) —
|
|
284
|
+
// the publish types on the union the events switch discriminates.
|
|
285
|
+
opened.events.publish(event);
|
|
286
|
+
});
|
|
287
|
+
const roots = opened.extensionStores.roots;
|
|
184
288
|
const ctx = {
|
|
185
289
|
opened,
|
|
186
290
|
config,
|
|
291
|
+
managedPaths: {
|
|
292
|
+
roots,
|
|
293
|
+
storagePath: config.storagePath,
|
|
294
|
+
coreRoot: selfPackageRoot() ?? process.cwd(),
|
|
295
|
+
userAreas: (user) => join(roots.dataDir, "users", user),
|
|
296
|
+
},
|
|
187
297
|
rootId,
|
|
188
298
|
webuiBundle,
|
|
189
299
|
commandCatalog,
|
|
190
300
|
listenHost,
|
|
191
301
|
methods,
|
|
192
302
|
env,
|
|
303
|
+
// U4 §13.2: the harness's settings-changed fan-out (hub publish +
|
|
304
|
+
// extension onSettingsChanged + the scheduler re-arm).
|
|
305
|
+
publishSettingsChanged: opened.publishSettingsChanged,
|
|
193
306
|
watchersByConversation: new Map(),
|
|
194
307
|
surfaces: surfacesService,
|
|
195
308
|
sse,
|
|
309
|
+
imageProxy: { cache: imageCache, deps: imageDeps, key: imageKey },
|
|
196
310
|
...(options?.gateway === undefined ? {} : { gateway: options.gateway }),
|
|
311
|
+
...(options?.onRestart === undefined
|
|
312
|
+
? {}
|
|
313
|
+
: { requestRestart: options.onRestart }),
|
|
197
314
|
};
|
|
315
|
+
app.decorate("sigrunContext", ctx);
|
|
198
316
|
// The host bridge (W5 Task 5, spec §9.5/§9.7): ONE bridge over this
|
|
199
317
|
// server's context, bound onto every seam host — the extension-facing
|
|
200
318
|
// conversations/users/access/identity surface then runs the SAME
|
|
@@ -202,7 +320,11 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
202
320
|
// implementation). Bound here, after the context exists and before any
|
|
203
321
|
// route or extension code can serve a request; calls issued earlier
|
|
204
322
|
// (a factory body) have been waiting on exactly this assignment.
|
|
205
|
-
|
|
323
|
+
const hostBridge = createHostBridge(ctx);
|
|
324
|
+
opened.bindHostBridge(hostBridge);
|
|
325
|
+
// Decorated like `sse`/`surfaces`: the bridge is the server-owned
|
|
326
|
+
// handle tests (and only tests) reach the W5 bridge path through.
|
|
327
|
+
app.decorate("hostBridge", hostBridge);
|
|
206
328
|
// The notifications surface (W5 Task 6, spec §9.6): one hub
|
|
207
329
|
// subscription turning host events into per-recipient
|
|
208
330
|
// NotificationEvents for every collected notifier contribution,
|
|
@@ -217,10 +339,16 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
217
339
|
// `request.principal` before any route runs.
|
|
218
340
|
registerAuthPipeline(app, ctx);
|
|
219
341
|
registerAuthRoutes(app, ctx);
|
|
342
|
+
registerLoginRoutes(app, ctx, {
|
|
343
|
+
...(options?.loginResolver === undefined ? {} : { resolver: options.loginResolver }),
|
|
344
|
+
...(options?.loginNow === undefined ? {} : { flows: new LoginFlows(options.loginNow) }),
|
|
345
|
+
});
|
|
220
346
|
registerOnboardingRoutes(app, ctx);
|
|
221
347
|
registerUserRoutes(app, ctx);
|
|
348
|
+
registerMeRoutes(app, ctx);
|
|
222
349
|
registerProbeRoutes(app, ctx);
|
|
223
350
|
registerMetaRoutes(app, ctx);
|
|
351
|
+
registerAdminRoutes(app, ctx);
|
|
224
352
|
registerMetricsRoutes(app, ctx);
|
|
225
353
|
registerConversationRoutes(app, ctx);
|
|
226
354
|
registerStreamRoutes(app, ctx);
|
|
@@ -230,6 +358,7 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
230
358
|
registerFileRoutes(app, ctx);
|
|
231
359
|
registerProviderRoutes(app, ctx);
|
|
232
360
|
registerSettingsRoutes(app, ctx);
|
|
361
|
+
registerSettingsSectionsRoutes(app, ctx);
|
|
233
362
|
registerExtensionSettingsRoutes(app, ctx);
|
|
234
363
|
// The extension HTTP routes (W5 Task 2, spec §9.2): every collected
|
|
235
364
|
// `routes` contribution mounted at `/v1/ext/<slug>/<path>` behind the
|
|
@@ -244,6 +373,10 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
244
373
|
// `/v1/ext/<slug>/assets/*` serves the package's declared
|
|
245
374
|
// `sigrun.assets` dir, realpath-contained, sniffed content types.
|
|
246
375
|
registerExtAssetRoutes(app, ctx);
|
|
376
|
+
// The image-proxy routes (UI-blocks U7 Task 3, spec §11.2): signed
|
|
377
|
+
// remote fetches through the on-disk LRU, the `src=` asset/data form,
|
|
378
|
+
// and the POST long-data-URL form.
|
|
379
|
+
registerImageRoutes(app, ctx);
|
|
247
380
|
// The jobs routes (W4 Task 4, spec §8.4): the access-scoped CRUD +
|
|
248
381
|
// run-now + history surface over `OpenedHarness.jobs` (Task 3's
|
|
249
382
|
// service). Hidden-is-404 per §5.9; `file:`/`ext:` ids are immutable
|
|
@@ -263,5 +396,12 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
263
396
|
// next boot's reconciliation warns (the declared owner no longer
|
|
264
397
|
// exists — the job runs ownerless until the file is edited).
|
|
265
398
|
registerJobsOwnershipParticipant(ctx);
|
|
399
|
+
// The chat-surface seam's teardown: the subscription lives for the
|
|
400
|
+
// app's lifetime (the module seam is process-global), but the
|
|
401
|
+
// unsubscribe is kept so a future embedder (a second createServer
|
|
402
|
+
// over one harness in tests) can detach a bound server cleanly.
|
|
403
|
+
app.addHook("onClose", async () => {
|
|
404
|
+
unsubscribeChatSurfaceChanged();
|
|
405
|
+
});
|
|
266
406
|
return app;
|
|
267
407
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
const FLOW_TTL_MS = 600_000;
|
|
3
|
+
/** Generous per-server ceiling; oldest entries are evicted under pressure. */
|
|
4
|
+
export const MAX_ACTIVE_LOGIN_FLOWS = 10_000;
|
|
5
|
+
/** One short-lived random browser/state credential. */
|
|
6
|
+
function key() {
|
|
7
|
+
return randomBytes(32).toString("base64url");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* In-memory, per-server login flows. `take` is atomic and single-use: a
|
|
11
|
+
* replay, a different browser key, or an expired entry returns undefined.
|
|
12
|
+
*/
|
|
13
|
+
export class LoginFlows {
|
|
14
|
+
#now;
|
|
15
|
+
#flows = new Map();
|
|
16
|
+
#consumed = new Map();
|
|
17
|
+
#pending = new Map();
|
|
18
|
+
constructor(now = Date.now) {
|
|
19
|
+
this.#now = now;
|
|
20
|
+
}
|
|
21
|
+
get size() {
|
|
22
|
+
return this.#flows.size;
|
|
23
|
+
}
|
|
24
|
+
get consumedSize() {
|
|
25
|
+
return this.#consumed.size;
|
|
26
|
+
}
|
|
27
|
+
start(provider, opts) {
|
|
28
|
+
this.#prune();
|
|
29
|
+
const state = key();
|
|
30
|
+
const browserKey = key();
|
|
31
|
+
const flow = {
|
|
32
|
+
provider,
|
|
33
|
+
state,
|
|
34
|
+
callbackUrl: "",
|
|
35
|
+
data: {},
|
|
36
|
+
returnTo: opts.returnTo,
|
|
37
|
+
...(opts.link === undefined ? {} : { link: opts.link }),
|
|
38
|
+
browserKey,
|
|
39
|
+
expiresAt: this.#now() + FLOW_TTL_MS,
|
|
40
|
+
};
|
|
41
|
+
this.#flows.set(state, flow);
|
|
42
|
+
this.#evictOldest(this.#flows);
|
|
43
|
+
return flow;
|
|
44
|
+
}
|
|
45
|
+
/** Save provider scratch data and the callback URL after `begin` succeeds. */
|
|
46
|
+
save(state, flow) {
|
|
47
|
+
const stored = this.#flows.get(state);
|
|
48
|
+
if (stored === undefined)
|
|
49
|
+
return;
|
|
50
|
+
stored.callbackUrl = flow.callbackUrl;
|
|
51
|
+
stored.data = flow.data;
|
|
52
|
+
}
|
|
53
|
+
take(state, browserKey) {
|
|
54
|
+
this.#prune();
|
|
55
|
+
const flow = this.#flows.get(state);
|
|
56
|
+
if (flow === undefined || flow.browserKey !== browserKey)
|
|
57
|
+
return undefined;
|
|
58
|
+
this.#flows.delete(state);
|
|
59
|
+
this.#consumed.set(state, {
|
|
60
|
+
browserKey,
|
|
61
|
+
expiresAt: flow.expiresAt,
|
|
62
|
+
});
|
|
63
|
+
this.#evictOldest(this.#consumed);
|
|
64
|
+
return flow;
|
|
65
|
+
}
|
|
66
|
+
/** Park a verified proof for ten more minutes under a separate, single-use key. */
|
|
67
|
+
park(flow, result) {
|
|
68
|
+
this.#prune();
|
|
69
|
+
const pendingKey = key();
|
|
70
|
+
const pending = {
|
|
71
|
+
provider: flow.provider,
|
|
72
|
+
result,
|
|
73
|
+
returnTo: flow.returnTo,
|
|
74
|
+
expiresAt: this.#now() + FLOW_TTL_MS,
|
|
75
|
+
};
|
|
76
|
+
this.#pending.set(pendingKey, pending);
|
|
77
|
+
this.#evictOldest(this.#pending);
|
|
78
|
+
return { key: pendingKey, pending };
|
|
79
|
+
}
|
|
80
|
+
/** Put a pending proof back after a rejected code, preserving its original expiry. */
|
|
81
|
+
restorePending(pendingKey, pending) {
|
|
82
|
+
this.#prune();
|
|
83
|
+
this.#pending.set(pendingKey, pending);
|
|
84
|
+
this.#evictOldest(this.#pending);
|
|
85
|
+
}
|
|
86
|
+
/** Consume a code-resume key atomically; replay/expiry/restart all fail closed. */
|
|
87
|
+
takePending(pendingKey) {
|
|
88
|
+
this.#prune();
|
|
89
|
+
const pending = this.#pending.get(pendingKey);
|
|
90
|
+
if (pending !== undefined)
|
|
91
|
+
this.#pending.delete(pendingKey);
|
|
92
|
+
return pending;
|
|
93
|
+
}
|
|
94
|
+
/** Explain a failed take without exposing flow data (replay vs expired/unknown). */
|
|
95
|
+
failure(state, browserKey) {
|
|
96
|
+
this.#prune();
|
|
97
|
+
if (this.#flows.has(state))
|
|
98
|
+
return "invalid_state";
|
|
99
|
+
return this.#consumed.get(state)?.browserKey === browserKey
|
|
100
|
+
? "invalid_state"
|
|
101
|
+
: "login_expired";
|
|
102
|
+
}
|
|
103
|
+
#evictOldest(entries) {
|
|
104
|
+
while (entries.size > MAX_ACTIVE_LOGIN_FLOWS) {
|
|
105
|
+
const oldest = entries.keys().next();
|
|
106
|
+
if (oldest.done === true)
|
|
107
|
+
break;
|
|
108
|
+
entries.delete(oldest.value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
#prune() {
|
|
112
|
+
const now = this.#now();
|
|
113
|
+
for (const [state, flow] of this.#flows) {
|
|
114
|
+
if (flow.expiresAt <= now)
|
|
115
|
+
this.#flows.delete(state);
|
|
116
|
+
}
|
|
117
|
+
for (const [state, consumed] of this.#consumed) {
|
|
118
|
+
if (consumed.expiresAt <= now)
|
|
119
|
+
this.#consumed.delete(state);
|
|
120
|
+
}
|
|
121
|
+
for (const [pendingKey, pending] of this.#pending) {
|
|
122
|
+
if (pending.expiresAt <= now)
|
|
123
|
+
this.#pending.delete(pendingKey);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|