@augurworks/augur 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
package/src/sw-logic.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Pure decision logic for the Augur service worker (sw.js).
|
|
2
|
+
//
|
|
3
|
+
// Factored out so the safety-critical routing — what the worker is allowed to
|
|
4
|
+
// touch and what it must NEVER touch — is unit-tested in isolation and shipped
|
|
5
|
+
// verbatim (build.js concatenates this file, minus `export`, into dist/sw.js).
|
|
6
|
+
// Keep it pure: no `self`, no `caches`, no `fetch`. Runtime wiring lives in the
|
|
7
|
+
// SW_WIRING template in build.js.
|
|
8
|
+
|
|
9
|
+
// A response may enter the cache only if it is a normal 200 whose Cache-Control
|
|
10
|
+
// does NOT say no-store. The login gate is served `no-store`; real content is
|
|
11
|
+
// `public, no-cache`. So this single predicate is what stops the worker from
|
|
12
|
+
// ever caching (and later re-serving) a logged-out gate page. See the spec.
|
|
13
|
+
export function cacheEligible(status, cacheControl) {
|
|
14
|
+
if (status !== 200) return false;
|
|
15
|
+
const cc = String(cacheControl == null ? "" : cacheControl).toLowerCase();
|
|
16
|
+
if (cc.includes("no-store")) return false;
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Classify a request into a handling strategy. Deliberately narrow: anything not
|
|
21
|
+
// explicitly claimed here is 'passthrough' (the browser's default), so the worker
|
|
22
|
+
// structurally cannot interfere with auth, publishing, data APIs, or realtime.
|
|
23
|
+
//
|
|
24
|
+
// 'passthrough' — do not touch (non-GET, cross-origin, /__*, /sw.js, other)
|
|
25
|
+
// 'swr' — same-origin GET navigation → stale-while-revalidate
|
|
26
|
+
// 'cache-first' — immutable shared asset (/_chrome.*, /fonts/*)
|
|
27
|
+
//
|
|
28
|
+
// Inputs are primitives so this is trivially testable.
|
|
29
|
+
// A DRAFT ADDRESS — `<unit>@<six chars>/…`, the shape src/unit-core.mjs spells with
|
|
30
|
+
// DRAFT_ID_RE — changes on every save, and stale-while-revalidate would paint the previous
|
|
31
|
+
// save first on exactly the reload the live socket just asked for. Network only. Spelled
|
|
32
|
+
// inline because this file is concatenated into sw.js verbatim and cannot import.
|
|
33
|
+
const DRAFT_SEGMENT_RE = /\/@[a-z0-9]{6}(?:\/|$)/;
|
|
34
|
+
|
|
35
|
+
export function swDecision({ method, sameOrigin, mode, path }) {
|
|
36
|
+
if (method !== "GET") return "passthrough";
|
|
37
|
+
if (!sameOrigin) return "passthrough";
|
|
38
|
+
// Collab / data / API / realtime / config / worker — never cached, always live.
|
|
39
|
+
if (path.startsWith("/__")) return "passthrough";
|
|
40
|
+
// The worker script itself must always come from the network (SW update check).
|
|
41
|
+
if (path === "/sw.js") return "passthrough";
|
|
42
|
+
if (DRAFT_SEGMENT_RE.test(path)) return "passthrough";
|
|
43
|
+
// Top-level page navigations get the instant-paint treatment.
|
|
44
|
+
if (mode === "navigate") return "swr";
|
|
45
|
+
// The shared chrome bundle and self-hosted fonts are content-hashed + immutable.
|
|
46
|
+
if (path.startsWith("/_chrome.") || path.startsWith("/fonts/")) return "cache-first";
|
|
47
|
+
return "passthrough";
|
|
48
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// tenantCache — the one way this engine is allowed to keep a cache that outlives a
|
|
2
|
+
// request.
|
|
3
|
+
//
|
|
4
|
+
// WHY IT EXISTS. Module scope is per ISOLATE, and an isolate serves whatever workspaces
|
|
5
|
+
// Cloudflare routes through it. Every cross-tenant leak this repo has closed had the same
|
|
6
|
+
// shape: a module-scope slot holding one workspace's answer, read by the next workspace's
|
|
7
|
+
// request. The avatar index, the board registry, the remark queue, the roster overlay —
|
|
8
|
+
// each was a bare `let`, or a Map that one access reached without naming a workspace.
|
|
9
|
+
//
|
|
10
|
+
// The guard that watched for that shape kept being answered by a new shape. So the shape
|
|
11
|
+
// is no longer what is watched: the SAFE thing is now the only thing that is expressible.
|
|
12
|
+
// This constructor hands back a frozen handle over a Map it keeps in a closure. The Map
|
|
13
|
+
// itself is unreachable — there is no `entries()`, no `values()`, no `forEach()`, no
|
|
14
|
+
// iterator, no way to get the container out — so "read every workspace's entry at once"
|
|
15
|
+
// is not something a caller can write, whether or not a lint is looking. And every method
|
|
16
|
+
// that reaches a value takes the workspace id as its FIRST argument and REFUSES a call
|
|
17
|
+
// without one, so a cache with no key is not something a caller can build either.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IT DOES NOT DO, so nobody mistakes it for more than it is:
|
|
20
|
+
//
|
|
21
|
+
// · It checks that a key was supplied, never that it is the RIGHT workspace. Which
|
|
22
|
+
// workspace a request is for is decided in exactly one place — `resolveTenant()` in
|
|
23
|
+
// src/_worker.js, guarded by scripts/one-tenant-resolver.mjs — and a caller that
|
|
24
|
+
// resolves the wrong id gets a consistent, wrong answer out of every cache at once.
|
|
25
|
+
// This module cannot see that and does not claim to.
|
|
26
|
+
// · It bounds the cache, so an isolate serving many workspaces cannot grow one entry per
|
|
27
|
+
// workspace forever. Eviction costs a re-read and can never hand back a neighbour's
|
|
28
|
+
// value, which is the only direction that matters.
|
|
29
|
+
// · The VALUES are not copied or frozen. A caller that stores a mutable entry and hands
|
|
30
|
+
// it to two workspaces has shared it; the handle only guarantees that the two got it
|
|
31
|
+
// out from under the same key.
|
|
32
|
+
//
|
|
33
|
+
// Entries are stored in insertion order and re-inserted on every touch, so the first key
|
|
34
|
+
// is always the least recently used one — the eviction victim.
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_TENANT_CACHE_MAX = 256;
|
|
37
|
+
|
|
38
|
+
// The key a not-yet-resolved workspace gets. A module-private symbol, so no caller can
|
|
39
|
+
// name it and no two caches can agree on it: every operation under it is a miss and every
|
|
40
|
+
// write under it is dropped, which is "an unresolved request participates in no cache"
|
|
41
|
+
// rather than "unresolved requests share one slot".
|
|
42
|
+
const UNRESOLVED = Symbol("tenant-unresolved");
|
|
43
|
+
|
|
44
|
+
// `label` names the cache in the TypeError a keyless call raises; it is for the person
|
|
45
|
+
// reading the stack, and nothing reads it back.
|
|
46
|
+
export function tenantCache(label, options = {}) {
|
|
47
|
+
const max = options.max ?? DEFAULT_TENANT_CACHE_MAX;
|
|
48
|
+
const store = new Map();
|
|
49
|
+
|
|
50
|
+
// The refusal, and the one state that is not a refusal.
|
|
51
|
+
//
|
|
52
|
+
// `null` is a WORKSPACE THAT IS NOT RESOLVED YET — the cold-isolate context, before
|
|
53
|
+
// resolveTenant() has answered. It is a real state, so it must not throw; what it must
|
|
54
|
+
// not do is get an entry. It gets `UNRESOLVED` instead: reads miss, writes are dropped,
|
|
55
|
+
// and the caller runs uncached. That is the property this module is for — an
|
|
56
|
+
// unresolved request shares nothing with anyone — bought at the price of a re-read
|
|
57
|
+
// rather than a 500.
|
|
58
|
+
//
|
|
59
|
+
// Anything else that is not a non-empty string — `undefined`, `""`, a number, an object
|
|
60
|
+
// — is a caller that forgot the argument, which is precisely how a keyed cache becomes
|
|
61
|
+
// a single slot. That throws, in the isolate that did it.
|
|
62
|
+
const keyed = (tenantId) => {
|
|
63
|
+
if (tenantId === null) return UNRESOLVED;
|
|
64
|
+
if (typeof tenantId !== "string" || tenantId === "") {
|
|
65
|
+
throw new TypeError(
|
|
66
|
+
`tenantCache(${label}): every access must name a workspace — got ${
|
|
67
|
+
tenantId === undefined ? "undefined" : JSON.stringify(tenantId)
|
|
68
|
+
}. A cache reached without a workspace id is a slot the whole isolate shares.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return tenantId;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Nothing is ever stored under UNRESOLVED, so every read of it misses for free.
|
|
75
|
+
const touch = (key, value) => {
|
|
76
|
+
if (key === UNRESOLVED) return value;
|
|
77
|
+
store.delete(key);
|
|
78
|
+
store.set(key, value);
|
|
79
|
+
while (store.size > max) store.delete(store.keys().next().value);
|
|
80
|
+
return value;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
// This workspace's entry, or undefined. Never another workspace's, and never a
|
|
85
|
+
// default: a caller with nothing cached has to decide what "nothing" means.
|
|
86
|
+
get(tenantId) {
|
|
87
|
+
return store.get(keyed(tenantId));
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// Store this workspace's entry and mark it most-recently-used.
|
|
91
|
+
put(tenantId, value) {
|
|
92
|
+
return touch(keyed(tenantId), value);
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// Get-or-create, touched either way. `make` runs only on a miss, so the caller's
|
|
96
|
+
// starting value is built per workspace rather than shared between them — the
|
|
97
|
+
// per-workspace factory rule the tenant context is built on, applied to caches.
|
|
98
|
+
//
|
|
99
|
+
// Insert BEFORE any await at the call site: two concurrent requests for one workspace
|
|
100
|
+
// then fill one entry instead of racing two into the store.
|
|
101
|
+
entry(tenantId, make) {
|
|
102
|
+
const key = keyed(tenantId);
|
|
103
|
+
const hit = store.get(key);
|
|
104
|
+
return touch(key, hit === undefined ? make() : hit);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// Forget this workspace's entry. With `expect`, only if the stored value is still
|
|
108
|
+
// that exact one — a failed fill dropping its own attempt must not drop whatever a
|
|
109
|
+
// later load, or another request, has already put in its place.
|
|
110
|
+
drop(tenantId, expect) {
|
|
111
|
+
const key = keyed(tenantId);
|
|
112
|
+
if (arguments.length > 1 && store.get(key) !== expect) return false;
|
|
113
|
+
return store.delete(key);
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
// Ask this workspace's entry to be re-read on the next request, KEEPING what it holds
|
|
117
|
+
// in the meantime. Entries that want this carry an `at` stamp; zeroing it is "stale",
|
|
118
|
+
// never "gone", so a write handler making its own change visible does not blank what
|
|
119
|
+
// the workspace is being served while the re-read happens.
|
|
120
|
+
bust(tenantId) {
|
|
121
|
+
const e = store.get(keyed(tenantId));
|
|
122
|
+
if (e && typeof e === "object") e.at = 0;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
// Whole-cache operations. Neither can hand one workspace another's value: `clear`
|
|
126
|
+
// takes every entry away, and `size` is a count. They need no key for the same reason.
|
|
127
|
+
clear() {
|
|
128
|
+
store.clear();
|
|
129
|
+
},
|
|
130
|
+
get size() {
|
|
131
|
+
return store.size;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The method names a tenantCache handle answers to, split by whether they reach a value.
|
|
137
|
+
// scripts/no-tenant-globals.mjs reads these rather than carrying its own copy, so adding a
|
|
138
|
+
// method here without deciding which list it belongs on turns the lint red instead of
|
|
139
|
+
// silently widening what a cache may be touched with.
|
|
140
|
+
export const TENANT_CACHE_KEYED_METHODS = Object.freeze(["get", "put", "entry", "drop", "bust"]);
|
|
141
|
+
export const TENANT_CACHE_WHOLE_METHODS = Object.freeze(["clear", "size"]);
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// The tenant context — the value that replaces the worker's module-scope config
|
|
2
|
+
// globals.
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS IS FOR. `src/_worker.js` fills ~28 `let` globals once per isolate from
|
|
5
|
+
// instance.json / routing.json and reads them implicitly at ~110 sites. That shape is
|
|
6
|
+
// single-tenant by construction: a second workspace resolved in the same isolate would
|
|
7
|
+
// overwrite the first one's config, and whichever request read it next would answer with
|
|
8
|
+
// the wrong workspace's users, prefixes and gate. Phase A replaces those globals with one
|
|
9
|
+
// value built per request and passed down. This module owns that value's SHAPE; the
|
|
10
|
+
// worker keeps owning what goes in it.
|
|
11
|
+
//
|
|
12
|
+
// THREE PROPERTIES, EACH LOAD-BEARING.
|
|
13
|
+
//
|
|
14
|
+
// 1. Field names are EXACTLY the current global names. The threading sweep is then a
|
|
15
|
+
// mechanical rename at each read site (`USERS` -> `ctx.USERS`) rather than a rewrite,
|
|
16
|
+
// which is what lets the byte-level response snapshots prove it an observable no-op.
|
|
17
|
+
// Renaming to something tidier would hide real changes inside cosmetic ones.
|
|
18
|
+
//
|
|
19
|
+
// 2. Every default is a FACTORY, never a value. This is the whole point of the file. A
|
|
20
|
+
// shared `new Set()` sitting in a defaults object would be handed to every tenant, and
|
|
21
|
+
// one workspace adding an icon hash would add it for all of them — the precise leak
|
|
22
|
+
// this phase exists to close, reintroduced in the fix for it. `defaults()` therefore
|
|
23
|
+
// calls each factory per context. The test suite asserts two contexts share no
|
|
24
|
+
// reference.
|
|
25
|
+
//
|
|
26
|
+
// 3. `CONFIG_LOADED` defaults to FALSE, and only a genuinely applied instance document
|
|
27
|
+
// sets it true. The gate uses it to tell "raw build with no identity, open by design"
|
|
28
|
+
// from "deployment whose config has not loaded in this cold isolate, must fail
|
|
29
|
+
// closed". A context that defaulted to loaded-but-empty would open the gate on a cold
|
|
30
|
+
// isolate whose first config read failed. Of everything in this file, this is the one
|
|
31
|
+
// that must never regress.
|
|
32
|
+
//
|
|
33
|
+
// The context carries no methods. `isPublicPath` and friends stay free functions taking a
|
|
34
|
+
// context, so each call site is a parameter change and nothing else — behaviour must not
|
|
35
|
+
// ride along on a mechanical refactor.
|
|
36
|
+
|
|
37
|
+
// Every field, its default factory, and where its value comes from. This table is the
|
|
38
|
+
// single description of the shape: add a config global to the worker and it belongs here,
|
|
39
|
+
// or the sweep will thread ~110 sites and silently miss one.
|
|
40
|
+
//
|
|
41
|
+
// `instance` filled from instance.json (bundle: config/instance.json in the store)
|
|
42
|
+
// `routing` filled from routing.json, or derived from live manifests in bundle mode
|
|
43
|
+
// `derived` computed from another field rather than read from a document
|
|
44
|
+
// `runtime` set by the worker as it serves, not by a config document
|
|
45
|
+
const FIELDS = Object.freeze({
|
|
46
|
+
// ---- identity ----------------------------------------------------------------
|
|
47
|
+
CONFIG_USERS: { source: "instance", make: () => [] },
|
|
48
|
+
USERS: { source: "derived", make: () => [] }, // CONFIG_USERS + KV roster overlay
|
|
49
|
+
// The photo hashes this workspace's avatar index vouches for. `/__avatar/` is ungated,
|
|
50
|
+
// so this Set is what it checks BEFORE reading KV — an authorization list, not a memo,
|
|
51
|
+
// and derived from one workspace's roster. Same shape and same reason as
|
|
52
|
+
// SPACE_ICON_KEYS below.
|
|
53
|
+
AVATAR_KEYS: { source: "derived", make: () => new Set() },
|
|
54
|
+
CONFIG_LOADED: { source: "runtime", make: () => false },
|
|
55
|
+
|
|
56
|
+
// ---- gate / routing ----------------------------------------------------------
|
|
57
|
+
PUBLIC_PREFIXES: { source: "routing", make: () => [] },
|
|
58
|
+
PUBLIC_SKILL_PREFIXES: { source: "routing", make: () => [] },
|
|
59
|
+
RESTRICTED_BASES: { source: "routing", make: () => [] }, // permanently empty since the space tier retired
|
|
60
|
+
SPACES: { source: "routing", make: () => [] },
|
|
61
|
+
VERSION_MAP: { source: "routing", make: () => ({}) },
|
|
62
|
+
BUILD_ID: { source: "routing", make: () => "dev" },
|
|
63
|
+
|
|
64
|
+
// ---- chrome ------------------------------------------------------------------
|
|
65
|
+
CHROME_POINTER: { source: "routing", make: () => null },
|
|
66
|
+
RUNTIME_CHROME: { source: "routing", make: () => false },
|
|
67
|
+
|
|
68
|
+
// ---- workspace icons ---------------------------------------------------------
|
|
69
|
+
SPACE_ICON_KEYS: { source: "derived", make: () => new Set() },
|
|
70
|
+
SPACE_ICONS: { source: "routing", make: () => ({}) },
|
|
71
|
+
|
|
72
|
+
// ---- MCP proxy ---------------------------------------------------------------
|
|
73
|
+
MCP_HOST_SUFFIXES: { source: "instance", make: () => [] },
|
|
74
|
+
MCP_HOST_ALLOWLIST: { source: "routing", make: () => [] },
|
|
75
|
+
MCP_HOST_ALLOWLIST_URL: { source: "instance", make: () => "" },
|
|
76
|
+
mcpStaticHosts: { source: "derived", make: () => new Set() }, // Set(MCP_HOST_ALLOWLIST)
|
|
77
|
+
MCP_PATH_ALLOWLIST: { source: "routing", make: () => [] }, // paths the spaces declared, on top of the protocol's own
|
|
78
|
+
|
|
79
|
+
// ---- canvas ------------------------------------------------------------------
|
|
80
|
+
CANVAS_LOADER_EXTRAS: { source: "routing", make: () => "" },
|
|
81
|
+
CANVAS_CATALOG: { source: "routing", make: () => [] },
|
|
82
|
+
CANVAS_TRACKS: { source: "routing", make: () => [] },
|
|
83
|
+
|
|
84
|
+
// ---- instance knobs ----------------------------------------------------------
|
|
85
|
+
VANITY_REDIRECTS: { source: "instance", make: () => ({}) },
|
|
86
|
+
RT_ORIGIN: { source: "instance", make: () => "" },
|
|
87
|
+
// The control plane this workspace redeems a cross-workspace hand-off against
|
|
88
|
+
// (`B-cross-workspace-signin`, `GET /__enter` in src/_worker.js). Unset — every
|
|
89
|
+
// deployment today — leaves the route inert: enterHandoff refuses before it ever
|
|
90
|
+
// reaches the network, the same "no seam revealed" refusal a missing account-key
|
|
91
|
+
// gets. deploy.config.json "accountOrigin", same shape as "rtOrigin" above.
|
|
92
|
+
ACCOUNT_ORIGIN: { source: "instance", make: () => "" },
|
|
93
|
+
INSTANCE_SENTINELS: { source: "instance", make: () => [] },
|
|
94
|
+
MIN_CLIENT_PROTOCOL: { source: "instance", make: () => 0 },
|
|
95
|
+
LOGIN_HINT: { source: "instance", make: () => "" },
|
|
96
|
+
LOGIN_PREFILL_EMAIL: { source: "instance", make: () => "" },
|
|
97
|
+
LOGIN_PREFILL_PASSWORD: { source: "instance", make: () => "" },
|
|
98
|
+
INSTANCE_ENGINE_VERSION: { source: "instance", make: () => "" },
|
|
99
|
+
UPDATE_FEED: { source: "instance", make: () => "" },
|
|
100
|
+
// Whether this instance accepts user-supplied IMAGE BYTES at all (profile photos and
|
|
101
|
+
// canvas images). Defaults to true: every existing instance keeps working, and the one
|
|
102
|
+
// that turns it off is the one whose password is printed on its own login page.
|
|
103
|
+
USER_IMAGES: { source: "instance", make: () => true },
|
|
104
|
+
// Device pairing (`augur connect`). Defaults OFF, which is the opposite default from
|
|
105
|
+
// USER_IMAGES above and deliberately so: this one ADDS an unauthenticated endpoint that
|
|
106
|
+
// ends in a publish token, so an instance opts in rather than discovers it.
|
|
107
|
+
DEVICE_PAIRING: { source: "instance", make: () => false },
|
|
108
|
+
// ⚠️ OFF BY DEFAULT, AND THE DEFAULT IS THE SAFE ONE. With this false the session
|
|
109
|
+
// derivation is byte-for-byte what it has always been: the cookie binds to the
|
|
110
|
+
// credential hash, and no extra read happens on the auth path at all. Turning it on
|
|
111
|
+
// makes the cookie bind to a per-person session key instead, which is what lets a
|
|
112
|
+
// credential change and a session end become separate acts. See sessionBinding in
|
|
113
|
+
// src/_worker.js before enabling it on anything you cannot watch.
|
|
114
|
+
SESSION_KEYS: { source: "instance", make: () => false },
|
|
115
|
+
// The first-run surface: with this on, the FIRST successful invite redemption a person
|
|
116
|
+
// ever makes lands on FIRST_RUN_PATH instead of "/" — once per person, recorded in the
|
|
117
|
+
// workspace's own store (see firstRunLanding in src/_worker.js). OFF by default, and
|
|
118
|
+
// explicit `true` only, same reasoning as DEVICE_PAIRING: a config typo must not move
|
|
119
|
+
// where redemption lands.
|
|
120
|
+
FIRST_RUN: { source: "instance", make: () => false },
|
|
121
|
+
// How long a publish token minted for a PERSON lives, in days. Both human doors —
|
|
122
|
+
// `augur login` and `augur connect` — read this one number, because two doors that hand
|
|
123
|
+
// out the same credential with different lifetimes is a difference nobody chose.
|
|
124
|
+
// Tokens an admin mints by hand for a machine are not covered: nothing re-runs a login
|
|
125
|
+
// for a nightly backup, and an expiry there fails silently at 4am. 0 disables expiry.
|
|
126
|
+
PUBLISH_TOKEN_TTL_DAYS: { source: "instance", make: () => 30 },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export const TENANT_FIELD_NAMES = Object.freeze(Object.keys(FIELDS));
|
|
130
|
+
export const TENANT_FIELD_SOURCES = Object.freeze(
|
|
131
|
+
Object.fromEntries(Object.entries(FIELDS).map(([k, v]) => [k, v.source])),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// ⏳ DEPRECATION WINDOW — the path floor an engine that predates path declarations had.
|
|
135
|
+
//
|
|
136
|
+
// Until the engine learned that a workspace declares its own proxy paths, the floor was
|
|
137
|
+
// four fixed paths: the three the MCP/OAuth protocol speaks, plus one platform API
|
|
138
|
+
// endpoint that sat in the shared engine because a single prototype called it. Removing
|
|
139
|
+
// the fourth is right, and it is what `MCP_PROXY_PATHS` now says. But the declaration
|
|
140
|
+
// that replaces it is written INTO A MANIFEST AT PUBLISH TIME, by the clone that
|
|
141
|
+
// publishes — so an instance whose live manifests were published by an older clone
|
|
142
|
+
// carries no declaration at all, and taking this engine would turn that endpoint's call
|
|
143
|
+
// into a 403 with no action available on the serving side to put it back.
|
|
144
|
+
//
|
|
145
|
+
// So the floor is kept for a manifest of that VINTAGE, and vintage is all it is keyed on:
|
|
146
|
+
// a routing fragment with NO `mcpPaths` key predates declarations and gets the old floor;
|
|
147
|
+
// a fragment carrying an EMPTY ARRAY is a real declaration meaning "no extra paths" and
|
|
148
|
+
// gets nothing. Absent and empty are different, and that difference is the whole shim.
|
|
149
|
+
// Nothing here names a company or reads a hostname — a vintage manifest of ANY workspace
|
|
150
|
+
// gets the same floor, which is exactly what those manifests were serving yesterday.
|
|
151
|
+
//
|
|
152
|
+
// This is the same shape as LEGACY_USER_COOKIES in src/_worker.js: read, never written.
|
|
153
|
+
// Every current build emits the key unconditionally (build.js writes `mcpPaths` into
|
|
154
|
+
// routing.json and into every per-space manifest fragment, `[]` when a workspace declares
|
|
155
|
+
// none), so a single publish from a current clone retires the shim for that workspace on
|
|
156
|
+
// its own, with nothing to migrate.
|
|
157
|
+
//
|
|
158
|
+
// TO DELETE IT: when every live instance's every space manifest carries an `mcpPaths` key
|
|
159
|
+
// — check the served fragment, `routing.mcpPaths`, per space, not a local build — remove
|
|
160
|
+
// this constant and the two ⏳ read sites that name it: `routingFields` below (assets
|
|
161
|
+
// mode) and `derivedRoutingFields` in src/_worker.js (bundle mode), plus the ⏳ note
|
|
162
|
+
// beside MCP_PROXY_PATHS that points here. The ⏳ cases in test/mcp-proxy-paths.test.mjs
|
|
163
|
+
// go in the same change. Nothing else refers to it.
|
|
164
|
+
export const LEGACY_MCP_PATH_FLOOR = Object.freeze(["/web_api/v1/app_configuration"]);
|
|
165
|
+
|
|
166
|
+
// Fresh defaults. Called per context so no two tenants share a mutable value — see
|
|
167
|
+
// property 2 at the top of this file.
|
|
168
|
+
function defaults() {
|
|
169
|
+
const out = {};
|
|
170
|
+
for (const name of TENANT_FIELD_NAMES) out[name] = FIELDS[name].make();
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The context object itself is frozen: assigning `ctx.USERS = …` throws in the module's
|
|
175
|
+
// strict mode instead of silently rewriting one tenant's identity from another's request.
|
|
176
|
+
// The VALUES are not deep-frozen — code still sorts and rebuilds these arrays in place —
|
|
177
|
+
// so freezing here buys the reassignment guarantee, which is the one that was actually
|
|
178
|
+
// being violated by module-scope `let`.
|
|
179
|
+
function freeze(ctx) {
|
|
180
|
+
return Object.freeze(ctx);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The cold-isolate value: nothing loaded, gate must fail closed. `tenantId` is the
|
|
184
|
+
// workspace this context speaks for — statically resolved in Phase A, Host-resolved in
|
|
185
|
+
// Phase B.
|
|
186
|
+
export function emptyTenantContext(tenantId = null) {
|
|
187
|
+
return freeze({ tenantId, ...defaults() });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// instance.json -> the fields it owns. Pure: it reads a document and returns a patch, so
|
|
191
|
+
// a malformed document can be rejected before anything is swapped in. Mirrors the
|
|
192
|
+
// worker's applyInstance() exactly, including its type coercions — a difference here is a
|
|
193
|
+
// behaviour change wearing a refactor's clothes.
|
|
194
|
+
export function instanceFields(inst) {
|
|
195
|
+
const doc = inst && typeof inst === "object" ? inst : {};
|
|
196
|
+
const prefill = doc.loginPrefill && typeof doc.loginPrefill === "object" ? doc.loginPrefill : {};
|
|
197
|
+
return {
|
|
198
|
+
CONFIG_USERS: Array.isArray(doc.users) ? doc.users : [],
|
|
199
|
+
USERS: Array.isArray(doc.users) ? doc.users : [], // applyRoster overlays this next
|
|
200
|
+
INSTANCE_ENGINE_VERSION: doc.engineVersion || "",
|
|
201
|
+
UPDATE_FEED: doc.updateFeed || "",
|
|
202
|
+
MCP_HOST_SUFFIXES: doc.mcpHostSuffixes || [],
|
|
203
|
+
MCP_HOST_ALLOWLIST_URL: doc.mcpHostAllowlistUrl || "",
|
|
204
|
+
VANITY_REDIRECTS: doc.vanityRedirects || {},
|
|
205
|
+
RT_ORIGIN: doc.rtOrigin || "",
|
|
206
|
+
ACCOUNT_ORIGIN: doc.accountOrigin || "",
|
|
207
|
+
INSTANCE_SENTINELS: Array.isArray(doc.sentinels) ? doc.sentinels : [],
|
|
208
|
+
MIN_CLIENT_PROTOCOL:
|
|
209
|
+
Number.isInteger(doc.minClientProtocol) && doc.minClientProtocol > 0 ? doc.minClientProtocol : 0,
|
|
210
|
+
LOGIN_HINT: typeof doc.loginHint === "string" ? doc.loginHint : "",
|
|
211
|
+
// Explicit `false` turns it off. Anything else — absent, null, a typo — leaves it ON,
|
|
212
|
+
// because a config typo must not silently disable a feature every other instance
|
|
213
|
+
// depends on. Turning it off is the deliberate act, so it is the one that must be
|
|
214
|
+
// spelled correctly.
|
|
215
|
+
USER_IMAGES: doc.userImages !== false,
|
|
216
|
+
// Explicit `true` only — the inverse of USER_IMAGES, because the safe state here is
|
|
217
|
+
// off and a typo must not switch a token-minting path on.
|
|
218
|
+
DEVICE_PAIRING: doc.devicePairing === true,
|
|
219
|
+
SESSION_KEYS: doc.sessionKeys === true,
|
|
220
|
+
FIRST_RUN: doc.firstRun === true,
|
|
221
|
+
// A number, and only a number. A typo — a string, null, a negative — falls back to the
|
|
222
|
+
// default rather than to "no expiry": a config mistake must not be the thing that
|
|
223
|
+
// quietly returns publish tokens to living forever. An explicit 0 IS honoured, because
|
|
224
|
+
// turning expiry off is a decision someone can legitimately make and has to spell.
|
|
225
|
+
PUBLISH_TOKEN_TTL_DAYS:
|
|
226
|
+
Number.isFinite(doc.publishTokenTtlDays) && doc.publishTokenTtlDays >= 0
|
|
227
|
+
? doc.publishTokenTtlDays : 30,
|
|
228
|
+
LOGIN_PREFILL_EMAIL: typeof prefill.email === "string" ? prefill.email : "",
|
|
229
|
+
LOGIN_PREFILL_PASSWORD: typeof prefill.password === "string" ? prefill.password : "",
|
|
230
|
+
// An instance document was actually applied — the gate may now trust "no users" to
|
|
231
|
+
// mean no users, rather than "not loaded yet".
|
|
232
|
+
CONFIG_LOADED: true,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// routing.json -> the fields it owns. Mirrors the worker's assets-mode routing block.
|
|
237
|
+
// `mcpStaticHosts` is derived here rather than at the read site so the two can never
|
|
238
|
+
// disagree about which hosts are allowed. `MCP_PATH_ALLOWLIST` needs no such twin: a
|
|
239
|
+
// workspace declares a handful of paths, not hundreds of hosts, so the read site scans
|
|
240
|
+
// the array itself and there is no second copy to fall out of step.
|
|
241
|
+
//
|
|
242
|
+
// ⏳ `mcpPaths` ABSENT is a routing.json older than path declarations, and keeps the old
|
|
243
|
+
// floor (LEGACY_MCP_PATH_FLOOR). `mcpPaths: []` is a declaration meaning none, and keeps
|
|
244
|
+
// nothing. Delete the conditional with the constant.
|
|
245
|
+
export function routingFields(routing) {
|
|
246
|
+
const doc = routing && typeof routing === "object" ? routing : {};
|
|
247
|
+
const allowlist = doc.mcpAllowlist || [];
|
|
248
|
+
return {
|
|
249
|
+
BUILD_ID: doc.buildId || "dev",
|
|
250
|
+
VERSION_MAP: doc.versionMap || {},
|
|
251
|
+
PUBLIC_PREFIXES: doc.publicPrefixes || [],
|
|
252
|
+
PUBLIC_SKILL_PREFIXES: doc.publicSkillPrefixes || [],
|
|
253
|
+
RESTRICTED_BASES: doc.restrictedBases || [],
|
|
254
|
+
CANVAS_LOADER_EXTRAS: doc.canvasLoaderExtras || "",
|
|
255
|
+
CANVAS_CATALOG: doc.canvasCatalog || [],
|
|
256
|
+
CANVAS_TRACKS: doc.canvasTracks || [],
|
|
257
|
+
MCP_HOST_ALLOWLIST: allowlist,
|
|
258
|
+
mcpStaticHosts: new Set(allowlist),
|
|
259
|
+
MCP_PATH_ALLOWLIST: Array.isArray(doc.mcpPaths) ? doc.mcpPaths : [...LEGACY_MCP_PATH_FLOOR],
|
|
260
|
+
SPACES: Array.isArray(doc.spaces) ? doc.spaces : [],
|
|
261
|
+
CHROME_POINTER: doc.chrome || null,
|
|
262
|
+
RUNTIME_CHROME: !!doc.runtimeChrome,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Build a context from whatever documents actually parsed. A document that is absent or
|
|
267
|
+
// unparseable contributes NOTHING rather than clearing the fields it owns — the
|
|
268
|
+
// keep-last-good half of the worker's fail-open-stale cache, preserved. Pass only what
|
|
269
|
+
// loaded; the caller decides whether a failed read is worth swapping the cache for.
|
|
270
|
+
export function buildTenantContext(tenantId, { instance, routing } = {}) {
|
|
271
|
+
const ctx = { tenantId, ...defaults() };
|
|
272
|
+
if (instance) Object.assign(ctx, instanceFields(instance));
|
|
273
|
+
if (routing) Object.assign(ctx, routingFields(routing));
|
|
274
|
+
return freeze(ctx);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Derive a new context from an existing one. The context is immutable, so every update —
|
|
278
|
+
// the KV roster overlay, a derived-routing rebuild — produces a fresh value rather than
|
|
279
|
+
// mutating a shared one. That is what makes "keep the last good config" a matter of not
|
|
280
|
+
// swapping a reference, instead of a half-applied object that a concurrent request can
|
|
281
|
+
// observe mid-write.
|
|
282
|
+
//
|
|
283
|
+
// Unknown field names throw. A typo would otherwise write a field nothing reads, and the
|
|
284
|
+
// symptom would be a config value that silently never takes effect.
|
|
285
|
+
export function withTenantFields(ctx, patch) {
|
|
286
|
+
const next = { ...ctx };
|
|
287
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
288
|
+
if (!(k in FIELDS)) throw new Error(`withTenantFields: unknown field "${k}"`);
|
|
289
|
+
next[k] = v;
|
|
290
|
+
}
|
|
291
|
+
return freeze(next);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- the per-tenant context cache ---------------------------------------------------
|
|
295
|
+
//
|
|
296
|
+
// Replaces the worker's single `cfgAt` timestamp. One stamp for the whole isolate is
|
|
297
|
+
// fine while an isolate only ever serves one workspace; with two, the first workspace's
|
|
298
|
+
// load stamps the clock and the second is told its config is fresh when it has never
|
|
299
|
+
// been read at all. The cache is therefore keyed by tenant, and every semantic of the
|
|
300
|
+
// old single stamp is reproduced deliberately rather than reinvented:
|
|
301
|
+
//
|
|
302
|
+
// STAMP-FIRST. `stamp()` is called BEFORE the async read, so a failing config read
|
|
303
|
+
// retries on the next tick instead of every concurrent request stampeding the store.
|
|
304
|
+
//
|
|
305
|
+
// KEEP-LAST-GOOD. `put()` is only called with a context that actually parsed. A failed
|
|
306
|
+
// read simply never calls it, so the previous good context stays in place — the
|
|
307
|
+
// fail-open-stale half of the old cache, preserved by leaving a reference alone rather
|
|
308
|
+
// than by half-applying an object a concurrent request could observe mid-write.
|
|
309
|
+
//
|
|
310
|
+
// FORCED. A write handler busts the cache (`cfgAt = 0` in ten places today) so its own
|
|
311
|
+
// write is visible on the very next request. `bust()` is that, per tenant, and `due()`
|
|
312
|
+
// reports `forced` for it. A cold tenant reports forced too, exactly as `!cfgAt` does
|
|
313
|
+
// today. Note what forced is NOT for: a cache with its own longer clock does not ride
|
|
314
|
+
// this flag, because a bust that reaches every workspace's cache is the coarse shape
|
|
315
|
+
// the roster overlay was moved off (`ROSTER_OVERLAY` in src/_worker.js is keyed by
|
|
316
|
+
// workspace and busted per workspace by the handler that wrote it).
|
|
317
|
+
//
|
|
318
|
+
// What is NEW, because one global stamp could not have the problem: the cache is BOUNDED.
|
|
319
|
+
// A worker serving many workspaces would otherwise hold every context it ever built for
|
|
320
|
+
// the isolate's lifetime. Eviction is safe in the direction that matters — an evicted
|
|
321
|
+
// tenant rebuilds from `emptyTenantContext`, whose CONFIG_LOADED is false, so the gate
|
|
322
|
+
// fails CLOSED while it reloads. Eviction can cost a config read; it can never open a door.
|
|
323
|
+
|
|
324
|
+
export const TENANT_CONTEXT_TTL_MS = 1500;
|
|
325
|
+
export const TENANT_CONTEXT_CACHE_MAX = 256;
|
|
326
|
+
|
|
327
|
+
export function createTenantContextCache(options = {}) {
|
|
328
|
+
const ttlMs = options.ttlMs ?? TENANT_CONTEXT_TTL_MS;
|
|
329
|
+
const max = options.max ?? TENANT_CONTEXT_CACHE_MAX;
|
|
330
|
+
const now = options.now ?? (() => Date.now());
|
|
331
|
+
// Insertion-ordered, so the oldest touched entry is the first key — the eviction victim.
|
|
332
|
+
const entries = new Map(); // tenantId -> { at, ctx }
|
|
333
|
+
|
|
334
|
+
const touch = (key, entry) => {
|
|
335
|
+
entries.delete(key);
|
|
336
|
+
entries.set(key, entry);
|
|
337
|
+
while (entries.size > max) entries.delete(entries.keys().next().value);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
// Should this tenant load its config now? `forced` means the cache was busted by a
|
|
342
|
+
// write, or this tenant has never loaded — this tenant, never the isolate.
|
|
343
|
+
due(tenantId) {
|
|
344
|
+
const e = entries.get(tenantId);
|
|
345
|
+
if (!e || !e.at) return { due: true, forced: true };
|
|
346
|
+
return { due: now() - e.at >= ttlMs, forced: false };
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
// Mark the attempt BEFORE doing it. Never call this after a load — that is the
|
|
350
|
+
// stampede the ordering exists to prevent.
|
|
351
|
+
stamp(tenantId) {
|
|
352
|
+
const e = entries.get(tenantId) || { at: 0, ctx: null };
|
|
353
|
+
touch(tenantId, { at: now(), ctx: e.ctx });
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
// Only ever called with a context built from documents that actually parsed.
|
|
357
|
+
put(tenantId, ctx) {
|
|
358
|
+
const e = entries.get(tenantId);
|
|
359
|
+
touch(tenantId, { at: e ? e.at : now(), ctx });
|
|
360
|
+
return ctx;
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
// The last good context for this tenant, or null if it has none yet. A null answer
|
|
364
|
+
// must become emptyTenantContext at the call site — never a shared fallback, and
|
|
365
|
+
// never another tenant's.
|
|
366
|
+
get(tenantId) {
|
|
367
|
+
const e = entries.get(tenantId);
|
|
368
|
+
return e && e.ctx ? e.ctx : null;
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
// A write handler making its own change visible on the next request. The context is
|
|
372
|
+
// KEPT: busting asks for a re-read, it does not blank the gate in the meantime.
|
|
373
|
+
bust(tenantId) {
|
|
374
|
+
const e = entries.get(tenantId);
|
|
375
|
+
if (e) entries.set(tenantId, { at: 0, ctx: e.ctx });
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
// Config that is not tenant-scoped changed (an engine or instance-wide push).
|
|
379
|
+
bustAll() {
|
|
380
|
+
for (const [k, e] of entries) entries.set(k, { at: 0, ctx: e.ctx });
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
get size() { return entries.size; },
|
|
384
|
+
has(tenantId) { return entries.has(tenantId); },
|
|
385
|
+
};
|
|
386
|
+
}
|