@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
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// no-tenant-globals — a CHEAP FIRST FILTER against per-tenant state in module scope.
|
|
3
|
+
//
|
|
4
|
+
// ⚠️ READ THIS BEFORE READING ITS GREEN AS COVER. This lint is not the guarantee that two
|
|
5
|
+
// workspaces do not share anything, and it never was. It checks one thing: that every
|
|
6
|
+
// module-scope BINDING in the worker's graph is either a keyed cache, a frozen table, or a
|
|
7
|
+
// counted slot. State that has no binding — a memo hung off a function object, a field on
|
|
8
|
+
// the default export, a write into an object nested inside a frozen table — is invisible
|
|
9
|
+
// to it, and one of those was the shape that most recently mattered. The measured list is
|
|
10
|
+
// under "WHAT IT DOES NOT CATCH" below; every claim there was produced by injecting the
|
|
11
|
+
// shape into a copy of src/ and running this file, not by reasoning about it.
|
|
12
|
+
//
|
|
13
|
+
// THE GUARANTEE IS A TEST, NOT A SCAN: `test/tenant-route-sweep.test.mjs` drives the real
|
|
14
|
+
// worker in BUNDLE mode with two workspaces over every route, sequentially inside each
|
|
15
|
+
// TTL and concurrently, and requires each workspace's own answer AND evidence that its own
|
|
16
|
+
// store was read. That is what found the leaks this lint was green on, and that is what to
|
|
17
|
+
// extend when a new route or a new cache appears. This file's job is to make the CHEAP
|
|
18
|
+
// mistake — a new shared slot with a name — impossible to make quietly. Nothing more.
|
|
19
|
+
//
|
|
20
|
+
// WHY. `src/_worker.js` was written for one workspace per deployment, so it kept its
|
|
21
|
+
// config in module-scope `let`s filled once per isolate. An isolate that serves two
|
|
22
|
+
// workspaces answers the second one with the first one's users, prefixes and gate. The
|
|
23
|
+
// fix is a per-request context (`src/tenant-context.mjs`), threaded through the read
|
|
24
|
+
// sites. That sweep is DONE — no field of the tenant context is declared at module scope
|
|
25
|
+
// any longer — and its dangerous failure was never a red test. It is one convenient new
|
|
26
|
+
// global, which nothing in a single-tenant era can observe: the deployment has one
|
|
27
|
+
// workspace, so a value shared between workspaces and a value belonging to the only
|
|
28
|
+
// workspace there is are the same bytes. This lint is what observes it, and it goes on
|
|
29
|
+
// mattering after the sweep because the next global costs nothing to add and gives back
|
|
30
|
+
// the bug in full.
|
|
31
|
+
//
|
|
32
|
+
// WHAT IT READS. Not one file — the whole module graph the worker pulls into the isolate,
|
|
33
|
+
// discovered by following relative imports from `src/_worker.js`. Module scope is per
|
|
34
|
+
// ISOLATE, so a `let` in a module the worker imports is shared exactly as widely as a
|
|
35
|
+
// `let` in the worker itself; a lint that read only the entry would be answered by moving
|
|
36
|
+
// the state one import away. Bare specifiers are not followed: a dependency's internals
|
|
37
|
+
// are not ours to allowlist, and this repo's worker has none.
|
|
38
|
+
//
|
|
39
|
+
// ---- WHY IT IS BUILT THIS WAY, AND NOT THE OTHER WAY --------------------------------
|
|
40
|
+
//
|
|
41
|
+
// THE HISTORY THIS FILE IS THE THIRD ANSWER TO. Cross-tenant leaks kept being found in
|
|
42
|
+
// code this lint was green on. Each rebuild caught the shape in front of it and was
|
|
43
|
+
// answered by the next shape: a bare `let`; then a Map whose accesses did not carry a
|
|
44
|
+
// key; then a factory call (`const SLOT = makeSlot()`) the binding scanner did not even
|
|
45
|
+
// count as a binding, a literal key inside an allowlisted keyed cache, and a `??=` write
|
|
46
|
+
// into a table the list called invariant. Three rounds of "enumerate the unsafe shapes"
|
|
47
|
+
// lost three times, because there is always another shape and the person adding it is the
|
|
48
|
+
// one choosing it.
|
|
49
|
+
//
|
|
50
|
+
// SO THE UNSAFE SHAPES ARE NO LONGER WHAT IS ENUMERATED. Two inversions:
|
|
51
|
+
//
|
|
52
|
+
// 1. A CONSTRUCTOR, NOT A PATTERN. There is exactly one way to keep a cache across
|
|
53
|
+
// requests: `tenantCache()` in src/tenant-cache.mjs. It hands back a frozen handle
|
|
54
|
+
// over a Map held in a closure. The Map cannot be reached, so there is no
|
|
55
|
+
// `entries()`, no `values()`, no `forEach()`, no iterator and no way to pass the
|
|
56
|
+
// container anywhere — "read every workspace's entry at once" is not expressible,
|
|
57
|
+
// whether or not this lint is looking. Every method that reaches a value takes the
|
|
58
|
+
// workspace id FIRST and refuses a call without one. An unkeyed cache is therefore
|
|
59
|
+
// not a shape to be detected; it is a thing that cannot be built.
|
|
60
|
+
//
|
|
61
|
+
// The same move for fixed tables: an `frozen` entry is `Object.freeze(…)` at the
|
|
62
|
+
// declaration, so the JS engine refuses every write at every site — including the
|
|
63
|
+
// ones a regex scanner cannot parse. `VALID_STATUS[p] ??= x` used to slip past a
|
|
64
|
+
// write scan that only knew about `=`; against a frozen object it throws.
|
|
65
|
+
//
|
|
66
|
+
// 2. WHAT COUNTS AS STATE IS AN ALLOWLIST, NOT A DENYLIST. The old scanner asked "is
|
|
67
|
+
// this initializer one of the mutable shapes I know?" — array literal, object
|
|
68
|
+
// literal, `new Map/Set`. `const SLOT = makeSlot()` is none of them, so it was
|
|
69
|
+
// invisible, and a factory is the one thing every state-hiding trick has in common.
|
|
70
|
+
// This one asks the opposite question: "is this initializer PROVABLY not state?" A
|
|
71
|
+
// number, a string or template, a regex, a symbol, a function, or a call to a
|
|
72
|
+
// same-module arrow that returns a string — those, and nothing else. Everything
|
|
73
|
+
// else is state and must be on the list, including every call. The failure mode is
|
|
74
|
+
// now a false ALARM (a genuinely-constant call the list has to name) rather than a
|
|
75
|
+
// silent pass, which is the direction a guard is allowed to be wrong in.
|
|
76
|
+
//
|
|
77
|
+
// THE THREE KINDS, and there is no fourth:
|
|
78
|
+
//
|
|
79
|
+
// cache `const X = tenantCache(…)`. Checked: the declaration is that call, the
|
|
80
|
+
// module really imports it from src/tenant-cache.mjs, every touch is one of
|
|
81
|
+
// the handle's own methods (read off the module, not copied here), and every
|
|
82
|
+
// method that reaches a value is given a `tenantId` expression.
|
|
83
|
+
//
|
|
84
|
+
// frozen `const X = Object.freeze(…)`. Checked: `const`, and that call at the
|
|
85
|
+
// declaration. Not a Map or a Set — freezing one leaves `.add()` and
|
|
86
|
+
// `.set()` working, so a frozen Set is a table with a lock painted on it.
|
|
87
|
+
//
|
|
88
|
+
// unkeyed a bare per-isolate slot. THE SHAPE EVERY LEAK HAD, kept as a quarantine
|
|
89
|
+
// because the engine still has four. An entry names a `proof` — a test file
|
|
90
|
+
// that exists and speaks the binding's name — and the TOTAL across every
|
|
91
|
+
// module must equal `UNKEYED_BUDGET` exactly. Exact, not a ceiling: closing
|
|
92
|
+
// one forces the number down in the same commit, and opening one forces a
|
|
93
|
+
// diff line that reads "I increased the number of per-isolate slots two
|
|
94
|
+
// workspaces share". That line is the review.
|
|
95
|
+
//
|
|
96
|
+
// AND THE REASONS ARE GONE. The failure that outlived every rebuild is that an entry's
|
|
97
|
+
// stated reason is PROSE, and no checker can tell whether prose is true — every closed
|
|
98
|
+
// leak was on the list under a sentence asserting the safety it did not have ("a hash is
|
|
99
|
+
// content-addressed, so it means the same thing everywhere"). The answer is not a better
|
|
100
|
+
// sentence or a scan for weasel words. It is that `cache` and `frozen` are ARRAYS OF
|
|
101
|
+
// NAMES: there is no field to write a claim into, so no reader can be persuaded by one.
|
|
102
|
+
// What a human needs to know sits in a `//` comment beside the name, which is visibly
|
|
103
|
+
// commentary rather than data. Prose survives in exactly one place — `unkeyed`, where a
|
|
104
|
+
// slot's danger genuinely cannot be checked — and that place is capped by a number.
|
|
105
|
+
//
|
|
106
|
+
// FOUR OLDER DIRECTIONS, ALL STILL FATAL.
|
|
107
|
+
//
|
|
108
|
+
// UNLISTED a binding the module's allowlist has never heard of. Fail. A module with
|
|
109
|
+
// no section at all allows nothing, so a NEW module carrying state fails on
|
|
110
|
+
// its first binding rather than on nobody remembering to name it somewhere.
|
|
111
|
+
// STALE an allowlist entry with no binding left in its module — the sweep removed
|
|
112
|
+
// it. Its line goes with it, so the list SHRINKS as threading lands instead
|
|
113
|
+
// of rotting into standing permission for whatever gets added later.
|
|
114
|
+
// UNREACHABLE an allowlisted module the worker no longer imports. Same rule one level
|
|
115
|
+
// up: a section outlives its module otherwise.
|
|
116
|
+
// READMITTED an allowlist entry that names a field of the tenant context. The sweep is
|
|
117
|
+
// done, so the route back to a shared config global is not a new name but an
|
|
118
|
+
// old one re-declared with a plausible reason attached. A per-workspace field
|
|
119
|
+
// cannot be fixed, so the entry is refused unread.
|
|
120
|
+
//
|
|
121
|
+
// ---- WHAT IT DOES NOT CATCH ----------------------------------------------------------
|
|
122
|
+
//
|
|
123
|
+
// MEASURED, not assumed: each shape below was injected into a copy of `src/` and this file
|
|
124
|
+
// was run against it. A guard that overstates itself is how three leaks shipped, so the
|
|
125
|
+
// verdicts are written down rather than described.
|
|
126
|
+
//
|
|
127
|
+
// MISSED — real per-isolate state, and this lint passes green on all four:
|
|
128
|
+
//
|
|
129
|
+
// · MEMOISATION HUNG OFF A FUNCTION OBJECT. `statusApi.__memo = {at, map}` in a GET
|
|
130
|
+
// path, read back on the next request. No new declaration, no allowlist edit, nothing
|
|
131
|
+
// for a binding scanner to see — and it is a cache every workspace in the isolate
|
|
132
|
+
// shares. This is the shape that beat the last two rebuilds of this file.
|
|
133
|
+
// · THE SAME TRICK ON AN ARROW THE MODULE ALREADY DECLARES.
|
|
134
|
+
// `readCanvasRegistry.__shared ||= {…}`, handed to a keyed cache as its per-workspace
|
|
135
|
+
// factory value, so the container is keyed and every entry points at one object.
|
|
136
|
+
// · A FIELD ON THE DEFAULT EXPORT. `this.x = …` inside `fetch()`. Every worker has one
|
|
137
|
+
// `export default {…}` and the runtime calls its methods, so flagging the shape would
|
|
138
|
+
// flag the one legitimate case and say nothing.
|
|
139
|
+
// · A PER-REQUEST WRITE INTO A TABLE THIS FILE CALLS INVARIANT. `Object.freeze` IS
|
|
140
|
+
// SHALLOW: it refuses every write to the table and none to an object inside it, so
|
|
141
|
+
// `AVATAR_MIMES["image/png"].lastSeenAt = Date.now()` runs, on a name the `frozen`
|
|
142
|
+
// list vouches for. Today every frozen table here is one level deep at the point of
|
|
143
|
+
// use; nothing checks that it stays so.
|
|
144
|
+
//
|
|
145
|
+
// CAUGHT TODAY — and worth naming anyway, because each was MISSED by an earlier generation
|
|
146
|
+
// of this same file, which is the reason not to read today's green as a guarantee:
|
|
147
|
+
//
|
|
148
|
+
// · `const SLOT = makeSlot()` — a const initialised by a call. The binding scanner did
|
|
149
|
+
// not count it as a binding at all; the allowlist inversion is what closed it.
|
|
150
|
+
// · A CONSTANT KEY INSIDE A KEYED CONTAINER — `MANIFESTS.get("one")`,
|
|
151
|
+
// `PITI_REMARKS.entry("all", …)`. The container looked keyed on the allowlist while
|
|
152
|
+
// every access named the same slot.
|
|
153
|
+
// · A NEW MODULE-SCOPE BINDING used as the shared default inside a keyed cache's
|
|
154
|
+
// factory. Caught as an unlisted binding, not as the leak it is.
|
|
155
|
+
//
|
|
156
|
+
// ---- THE OTHER LIMITS, stated for the same reason -------------------------------------
|
|
157
|
+
// · THE KEY MUST SAY `tenantId`, and that is a name, not a proof. `M.get(x.tenantId)`
|
|
158
|
+
// passes whatever `x` is, so an object built with a `tenantId` field of the wrong
|
|
159
|
+
// value would pass. Aliases are refused precisely to keep this narrow (see
|
|
160
|
+
// TENANT_KEY below), but the residue is real: WHICH workspace a request is for is
|
|
161
|
+
// `resolveTenant`'s answer, guarded by scripts/one-tenant-resolver.mjs, not this
|
|
162
|
+
// one's.
|
|
163
|
+
// · A `cache` HANDLE IS KEYED, NOT CORRECT. It guarantees the caller named a workspace,
|
|
164
|
+
// never that the value stored under that name belongs to it. A handler that writes
|
|
165
|
+
// workspace A's document under B's id is beyond anything structural here.
|
|
166
|
+
// · THE BUDGET IS A LINE IN THIS FILE, and the same commit that adds a slot can raise
|
|
167
|
+
// it. On purpose: the point is not that it is impossible, it is that it is LOUD.
|
|
168
|
+
// What IS impossible now is doing it the way every leak was done — by adding a
|
|
169
|
+
// sentence to a list of sentences.
|
|
170
|
+
//
|
|
171
|
+
// Usage: node scripts/no-tenant-globals.mjs [--entry <path>] [--quiet]
|
|
172
|
+
// No config, no dependencies. Exit 1 on any failure.
|
|
173
|
+
|
|
174
|
+
import fs from "node:fs";
|
|
175
|
+
import path from "node:path";
|
|
176
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
177
|
+
|
|
178
|
+
import { TENANT_FIELD_NAMES } from "../src/tenant-context.mjs";
|
|
179
|
+
import {
|
|
180
|
+
tenantCache,
|
|
181
|
+
TENANT_CACHE_KEYED_METHODS,
|
|
182
|
+
TENANT_CACHE_WHOLE_METHODS,
|
|
183
|
+
} from "../src/tenant-cache.mjs";
|
|
184
|
+
|
|
185
|
+
// The DEPLOY entry, which since the plain-Worker split is `src/entry.js` rather than the
|
|
186
|
+
// worker itself. This constant decides what gets scanned — check.yml runs this lint with
|
|
187
|
+
// no arguments on purpose — so it has to name the file wrangler's `main` points at, not
|
|
188
|
+
// the file that happens to hold the fetch handler. A module reachable only from a new
|
|
189
|
+
// entry would otherwise be silently unscanned, which is the escape by omission this lint
|
|
190
|
+
// exists to catch. test/worker-entry.test.mjs pins it to the shell template's `main`.
|
|
191
|
+
export const ENTRY = "src/entry.js";
|
|
192
|
+
|
|
193
|
+
// The module that owns the one cache constructor. A `cache` entry is only believed in a
|
|
194
|
+
// module that actually imports it from here — otherwise `tenantCache` is just a name
|
|
195
|
+
// somebody could declare locally to mean anything.
|
|
196
|
+
export const CACHE_MODULE = "src/tenant-cache.mjs";
|
|
197
|
+
|
|
198
|
+
// How many bare per-isolate slots this engine still has. EXACT, and it only goes down —
|
|
199
|
+
// see `unkeyed` above. Every one of them is a workspace-shared slot that a Host-resolving
|
|
200
|
+
// isolate will get wrong, so this number is the phase's remaining debt, counted.
|
|
201
|
+
export const UNKEYED_BUDGET = 4;
|
|
202
|
+
|
|
203
|
+
// ---- the allowlist, per module -------------------------------------------------------
|
|
204
|
+
//
|
|
205
|
+
// `cache` and `frozen` are LISTS OF NAMES. They carry no reason, because a reason is the
|
|
206
|
+
// thing that failed: every verdict below is read off the declaration and its uses, and
|
|
207
|
+
// there is deliberately nowhere in this structure to assert a safety the code does not
|
|
208
|
+
// have. Notes are comments — commentary, not data.
|
|
209
|
+
//
|
|
210
|
+
// Keyed by module because a name is only meaningful in the module that declares it: an
|
|
211
|
+
// entry vouching for `FIELDS` in the context module must not quietly vouch for a `FIELDS`
|
|
212
|
+
// somebody adds to the worker.
|
|
213
|
+
const ALLOWLIST = {
|
|
214
|
+
"src/_worker.js": {
|
|
215
|
+
// Per-workspace caches. Each holds one workspace's own documents — its published
|
|
216
|
+
// manifests and the routing that decides its gate; its store fill; its resolved proxy
|
|
217
|
+
// hosts; the boards and the queued remarks its two UNGATED poll routes serve; and the
|
|
218
|
+
// roster overlay, which decides who exists in a workspace and what they may do.
|
|
219
|
+
// Every one of them was a single slot once, and three of them were reproduced serving
|
|
220
|
+
// one workspace another's content.
|
|
221
|
+
cache: [
|
|
222
|
+
"MANIFESTS",
|
|
223
|
+
"STORAGE_CACHE",
|
|
224
|
+
"mcpHostAllowlist",
|
|
225
|
+
"CANVAS_REGISTRY",
|
|
226
|
+
"PITI_REMARKS",
|
|
227
|
+
"DERIVED", // the two store reads a derived gallery needs, per manifest version — the status baseline and the design-system catalog
|
|
228
|
+
"ROSTER_OVERLAY",
|
|
229
|
+
"FREEZE_STATE",
|
|
230
|
+
"SUSPENSION_STATE",
|
|
231
|
+
],
|
|
232
|
+
|
|
233
|
+
// Fixed tables, frozen at the declaration so a write throws rather than turning the
|
|
234
|
+
// table into per-isolate state under a constant's name.
|
|
235
|
+
frozen: [
|
|
236
|
+
"LEGACY_USER_COOKIES", // ⏳ the two names the session cookie used to be issued under
|
|
237
|
+
"ROLES", // admin, editor, viewer
|
|
238
|
+
"AVATAR_MIMES", // accepted avatar formats and their magic-byte tests
|
|
239
|
+
"ENGINE_CHROME_PATHS", // the shared chrome paths no space may write
|
|
240
|
+
"MCP_PROXY_PATHS", // the three paths the MCP/OAuth protocol speaks
|
|
241
|
+
"VALID_STATUS", // the prototype status vocabulary
|
|
242
|
+
"IMAGES_OFF", // the refusal body when an instance has user images switched off
|
|
243
|
+
"OVERLAY_KV_KEYS", // family -> the KV document name it lives under; the same for every workspace
|
|
244
|
+
"STATE_KV_PREFIXED", // which inventory entries are one document per key when exported
|
|
245
|
+
"NEVER_CLEARED", // families a reset may never empty; a fact about credentials, not a workspace
|
|
246
|
+
"KV_CUTOVER", // which identity families read from the workspace object; a deploy-wide fact, and one word per family is the revert
|
|
247
|
+
"IDENTITY_TENANCY", // which identity KV documents carry a workspace segment; the same shape, the same revert, and the same deploy-wide fact
|
|
248
|
+
"IDENTITY_KV_FAMILIES", // which KV documents each identity family owns; the worker's own copy of the fact src/kv-identity.mjs also lists
|
|
249
|
+
"FIRST_RUN_COPY", // the first-run page's placeholder words; one table so iterating on the copy touches nothing that routes
|
|
250
|
+
"REKEY_FAMILIES", // which of those families a re-key may be asked to move — the vocabulary of one route, identical for every workspace
|
|
251
|
+
"REKEY_DEFAULT_FAMILIES", // and which it moves when asked for none
|
|
252
|
+
"CAP_ROUTES", // capability -> the routes it grants; deny-by-default, identical for every workspace
|
|
253
|
+
"PUBLISH_READ_OPS", // which publish ops only READ a space, and by which method; a fact about the routes, the same on every deployment
|
|
254
|
+
"RESERVED_UNIT_FOLDERS", // top-level folders a NEW unit may not be created under; a fact about how a site is laid out, the same for every workspace
|
|
255
|
+
"FROZEN_WRITES", // the paths a migration freeze closes; the same list for every workspace
|
|
256
|
+
"SUSPENDED_ALLOWED", // what a paused workspace still answers — the published promise, as a list
|
|
257
|
+
"SUSPENDED_ALLOWED_READS", // and the read verbs of the bundle store an export walks
|
|
258
|
+
"__testables", // the table of helpers the suite imports; the request path reads none of it
|
|
259
|
+
],
|
|
260
|
+
|
|
261
|
+
// UNKEYED — the quarantine. Every one of these is a single slot the whole isolate
|
|
262
|
+
// shares, which is the shape every reproduced leak had. `why` says what it holds and
|
|
263
|
+
// what a second workspace would therefore be answered with; `proof` names where two
|
|
264
|
+
// workspaces are driven against it. Count them: UNKEYED_BUDGET.
|
|
265
|
+
unkeyed: {
|
|
266
|
+
cfgAt: {
|
|
267
|
+
why: "the config TTL tick for the TENANT_CTX slot below; one clock for the isolate, so it decides when the SINGLE context slot is refilled rather than when this workspace's is — superseded per workspace by createTenantContextCache",
|
|
268
|
+
proof: "test/tenant-isolation.test.mjs",
|
|
269
|
+
},
|
|
270
|
+
cfgGoodAt: {
|
|
271
|
+
why: "when the config in the TENANT_CTX slot last loaded without a read failing — the staleness ceiling's clock; it is read only when the slot holds the workspace being asked about, so a second workspace finds no last-good and fails closed rather than inheriting this one's",
|
|
272
|
+
proof: "test/tenant-isolation.test.mjs",
|
|
273
|
+
},
|
|
274
|
+
TENANT_CTX: {
|
|
275
|
+
why: "the last good context this isolate loaded, in ONE slot; it would answer a second workspace with the first one's config, and the per-workspace createTenantContextCache is what replaces it when the resolver stops answering with one static id",
|
|
276
|
+
proof: "test/tenant-isolation.test.mjs",
|
|
277
|
+
},
|
|
278
|
+
tenantMemo: {
|
|
279
|
+
why: "the static tenant id resolveTenant() read from instance.json; a wrong answer the moment an isolate serves two workspaces, and the Host resolver that makes that possible replaces the body it belongs to",
|
|
280
|
+
proof: "test/tenant-isolation.test.mjs",
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
// The derived pages: pure renderers over the live store. Every table here is a fact
|
|
286
|
+
// about the engine's vocabulary — tier names, status words and glyphs, the emoji pool a
|
|
287
|
+
// card's leading glyph is picked from — the same for every workspace.
|
|
288
|
+
"src/galleries.mjs": {
|
|
289
|
+
frozen: [
|
|
290
|
+
"TIERS", // the four library tiers whose demos are units
|
|
291
|
+
"TIER_TITLE", // their titles
|
|
292
|
+
"TIER_COPY", // their hints and empty states
|
|
293
|
+
"STATUS_META", // status word → label + class
|
|
294
|
+
"STATUS_ICONS", // status word → glyph
|
|
295
|
+
"STATUS_RANK", // the card order a status implies
|
|
296
|
+
"EMOJI_POOL", // the leading-emoji pool, picked by slug hash
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
"src/bundle-keys.mjs": {
|
|
301
|
+
frozen: [
|
|
302
|
+
"BUNDLE_TENANCY", // which bundle-store families carry a workspace segment; one word per family is the revert, and a deploy-wide fact — moved here from the worker so the workspace object shares the key shape
|
|
303
|
+
],
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
"src/tenant-context.mjs": {
|
|
307
|
+
frozen: [
|
|
308
|
+
"FIELDS", // the context's SHAPE: a table of default FACTORIES, never of values
|
|
309
|
+
"TENANT_FIELD_NAMES", // the field names, derived from FIELDS at load
|
|
310
|
+
"TENANT_FIELD_SOURCES", // where each field's value comes from
|
|
311
|
+
"LEGACY_MCP_PATH_FLOOR", // ⏳ the path floor a pre-declaration manifest keeps
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
"src/tenant-cache.mjs": {
|
|
316
|
+
frozen: [
|
|
317
|
+
"TENANT_CACHE_KEYED_METHODS", // the handle methods that reach a value
|
|
318
|
+
"TENANT_CACHE_WHOLE_METHODS", // the ones that cannot hand back one workspace's value
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
// The canvas room. Its per-board state lives in the Durable Object instance, never at
|
|
323
|
+
// module scope — one DO per board is the isolation, and it is structural.
|
|
324
|
+
"src/board-room.mjs": {
|
|
325
|
+
frozen: [
|
|
326
|
+
"COLORS", // the presence-chip palette, read by index
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
// The checks an instance runs on itself from its own cron. Every function here is pure —
|
|
331
|
+
// a build stamp, a clock and at most one outbound fetch in, a report out — so there is
|
|
332
|
+
// nothing per-workspace to hold. The stamp is passed in by the caller, which is what
|
|
333
|
+
// keeps it that way.
|
|
334
|
+
"src/health-cron.mjs": {
|
|
335
|
+
frozen: [
|
|
336
|
+
"HEALTH_GRACE", // the grace windows, kept equal to templates/shell/health.yml's
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
// The account of what an instance stores and where each family goes. A fact about the
|
|
341
|
+
// ENGINE, identical for every workspace — the per-workspace part is the data it names,
|
|
342
|
+
// which lives in each workspace's own store.
|
|
343
|
+
"src/state-inventory.mjs": {
|
|
344
|
+
frozen: [
|
|
345
|
+
"STATE_INVENTORY", // the list itself; the export endpoint walks it
|
|
346
|
+
"INVENTORY_IDS", // the ids, derived from the list at load
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
// How a member's chip reads when nobody typed it. Pure string work, shared by the two
|
|
351
|
+
// writers of a `members` row — the worker's invite and the object's provisioning.
|
|
352
|
+
"src/roster-chip.mjs": {
|
|
353
|
+
frozen: [
|
|
354
|
+
"ROSTER_COLORS", // the presence-chip palette, indexed by a hash of the address
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
// Which workspace a hostname names. Pure string work, no env and no clock — nothing here
|
|
359
|
+
// is about a particular workspace, which is why it can be a table at all.
|
|
360
|
+
"src/tenant-host.mjs": {
|
|
361
|
+
frozen: [
|
|
362
|
+
"RESERVED_LABELS", // hostnames no workspace may be; the same list the name generator reads
|
|
363
|
+
"NO_EXTRA", // the empty deployment list, one frozen array so callers need no default alloc
|
|
364
|
+
],
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
// What each PLAN allows. Note the direction: this is a table of plans, not of
|
|
368
|
+
// workspaces. A workspace's own ceilings are seeded into its Durable Object and read
|
|
369
|
+
// from there, so nothing here is about a particular one — which is the whole reason the
|
|
370
|
+
// quotas live in the store rather than in a constant a deploy would have to move.
|
|
371
|
+
"src/tenant-quotas.mjs": {
|
|
372
|
+
frozen: [
|
|
373
|
+
"PLANS", // plan name -> ceilings; seeded into a workspace, never read live per request
|
|
374
|
+
"QUOTA_FIELDS", // the field names, so a plan missing one fails rather than being unlimited
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
// The workspace store. Same shape as the room above and for the same reason: one DO per
|
|
379
|
+
// workspace is the isolation, so nothing per-workspace is at module scope here either.
|
|
380
|
+
"src/tenant-do.js": {
|
|
381
|
+
frozen: [
|
|
382
|
+
"TENANT_SCHEMA", // the DDL, identical for every workspace — what differs is where it is applied
|
|
383
|
+
"FORBIDDEN_COLUMNS", // column names the schema may not have; a fact about credentials
|
|
384
|
+
"CONTROL_VERBS", // the whole of what the control plane may ask; the same list for every workspace
|
|
385
|
+
"SEEDABLE_FAMILIES", // which overlay families a seed pack may write; a fact about the schema
|
|
386
|
+
"IDENTITY_FAMILIES", // which identity families a copy may write; a fact about the schema, and the list that keeps a credential out
|
|
387
|
+
"MEMBER_ROLES", // the three roles the members CHECK constraint allows; the same three everywhere
|
|
388
|
+
"TENANT_SCHEMA_ADDITIONS", // columns a table built at an earlier version is missing; a fact about the schema's history
|
|
389
|
+
"DORMANCY_SUSPENSION_REASONS", // which suspension reasons a sign-in may lift; a published policy, identical for every workspace
|
|
390
|
+
],
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
// One unit's Durable Object. Same shape as tenant-do.js above and for the same reason:
|
|
394
|
+
// one DO per unit is the isolation, so nothing per-unit is at module scope here either.
|
|
395
|
+
"src/unit-object.mjs": {
|
|
396
|
+
frozen: [
|
|
397
|
+
"UNIT_SCHEMA", // the DDL statement list, identical for every unit — applied once per object
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
"src/kv-identity.mjs": {
|
|
402
|
+
frozen: [
|
|
403
|
+
"UNMAPPED_WORKSPACE_FAMILIES", // which workspace-destined families this translation does NOT carry, and why; a fact about the schema
|
|
404
|
+
"IDENTITY_KV_FAMILIES", // which KV documents it DOES read; the other half of the same fact, read by the inventory guard
|
|
405
|
+
"MEMBER_ROLE_SET", // the roles `members.role` accepts; the same three everywhere, and the copy's copy of tenant-do's list
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
"src/mail.mjs": {
|
|
410
|
+
frozen: [
|
|
411
|
+
"DRIVERS", // the shape of HTTP request each provider takes; every deployment value arrives in env
|
|
412
|
+
"TEMPLATES", // the three message bodies, as render functions
|
|
413
|
+
"MAIL_RATE", // the per-recipient send caps; the counters they govern live in KV
|
|
414
|
+
"MAIL_ACTOR_RATE", // the per-actor cap — one number, same for every workspace
|
|
415
|
+
"MAIL_GLOBAL_RATE", // the per-instance cap; the counter it governs lives in KV
|
|
416
|
+
],
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
"src/chrome/appchrome.mjs": {
|
|
420
|
+
frozen: [
|
|
421
|
+
"ACRONYMS", // words the title-caser must not sentence-case; a fact about English
|
|
422
|
+
"LIB_KEYS", // the fixed gallery tiers
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
// What is current in a workspace and what has been left behind. Pure — manifest stamps
|
|
427
|
+
// and a status map in, rows out — and it deliberately stores nothing: staleness is
|
|
428
|
+
// DERIVED from the per-file editedAt the commit handler already records, so there is no
|
|
429
|
+
// per-workspace anything to keep here. The threshold and the age arithmetic are number
|
|
430
|
+
// literals the lint proves harmless on its own.
|
|
431
|
+
"src/currency.mjs": {
|
|
432
|
+
frozen: [
|
|
433
|
+
"STATUS_LABELS", // the status vocabulary's words; the same four for every workspace
|
|
434
|
+
],
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// Flattened for the checker and for anything that wants to read the list: module -> name
|
|
439
|
+
// -> { kind, why?, proof? }. `why` exists only on the quarantine.
|
|
440
|
+
export const ALLOWED = Object.freeze(
|
|
441
|
+
Object.fromEntries(
|
|
442
|
+
Object.entries(ALLOWLIST).map(([mod, groups]) => [
|
|
443
|
+
mod,
|
|
444
|
+
Object.freeze({
|
|
445
|
+
...Object.fromEntries((groups.cache || []).map((k) => [k, { kind: "cache" }])),
|
|
446
|
+
...Object.fromEntries((groups.frozen || []).map((k) => [k, { kind: "frozen" }])),
|
|
447
|
+
...Object.fromEntries(
|
|
448
|
+
Object.entries(groups.unkeyed || {}).map(([k, e]) => [k, { kind: "unkeyed", why: e.why, proof: e.proof }]),
|
|
449
|
+
),
|
|
450
|
+
}),
|
|
451
|
+
]),
|
|
452
|
+
),
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
export const KINDS = ["cache", "frozen", "unkeyed"];
|
|
456
|
+
|
|
457
|
+
// ---- the module graph ----------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
// A static import/export specifier, and a dynamic import of a literal. Only the relative
|
|
460
|
+
// ones matter: a bare specifier is a dependency, whose module scope is not ours to vouch
|
|
461
|
+
// for, and the worker has none.
|
|
462
|
+
const SPECIFIER = /(?:\b(?:import|export)\s[^;]*?\bfrom\s*|\bimport\s*\(\s*|^\s*import\s*)["']([^"']+)["']/gm;
|
|
463
|
+
|
|
464
|
+
const CANDIDATES = ["", ".mjs", ".js", "/index.mjs", "/index.js"];
|
|
465
|
+
|
|
466
|
+
// Resolution goes through the same `read` the scan uses — "does this file exist" is
|
|
467
|
+
// "can I read it" — so a test can describe a module graph without putting one on disk,
|
|
468
|
+
// and the walker has no second source of truth about what is there.
|
|
469
|
+
function resolveLocal(fromFile, spec, read) {
|
|
470
|
+
if (!spec.startsWith(".")) return null;
|
|
471
|
+
const base = path.resolve(path.dirname(fromFile), spec);
|
|
472
|
+
for (const ext of CANDIDATES) {
|
|
473
|
+
try {
|
|
474
|
+
const p = base + ext;
|
|
475
|
+
read(p);
|
|
476
|
+
return p;
|
|
477
|
+
} catch (e) { /* not this one */ }
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Every module the isolate loads because the entry imports it, transitively, as
|
|
483
|
+
// repo-relative paths in visit order. This is the answer to "which files can hold state
|
|
484
|
+
// that outlives a request" — not a list somebody maintains.
|
|
485
|
+
export function discoverModules(entryAbs, root, read = (p) => fs.readFileSync(p, "utf8")) {
|
|
486
|
+
const seen = new Map(); // abs -> rel
|
|
487
|
+
const queue = [entryAbs];
|
|
488
|
+
while (queue.length) {
|
|
489
|
+
const abs = queue.shift();
|
|
490
|
+
if (seen.has(abs)) continue;
|
|
491
|
+
const rel = path.relative(root, abs).split(path.sep).join("/");
|
|
492
|
+
seen.set(abs, rel);
|
|
493
|
+
// Comments are stripped BEFORE specifiers are matched: a commented-out import is not
|
|
494
|
+
// an import, and following one drags a module that nothing loads into the scan. The
|
|
495
|
+
// deploy entry documents its future Durable Object export as a commented line, which
|
|
496
|
+
// is exactly this case — the class is not in the graph until the line is real.
|
|
497
|
+
// `stripComments` is the conservative one (whole-line and block only), so it cannot
|
|
498
|
+
// swallow a live import that shares a line with something else.
|
|
499
|
+
const source = stripComments(read(abs));
|
|
500
|
+
for (const m of source.matchAll(SPECIFIER)) {
|
|
501
|
+
const next = resolveLocal(abs, m[1], read);
|
|
502
|
+
if (next && !seen.has(next)) queue.push(next);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return [...seen.values()];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---- comments ------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
// Comment LINES only, plus block comments. Deliberately not a general comment stripper: a
|
|
511
|
+
// trailing `//` after code is indistinguishable from the inside of a regex literal without
|
|
512
|
+
// a parser, and getting that wrong would DELETE an access instead of merely reporting a
|
|
513
|
+
// sentence. Full-line comments are this codebase's style and are safe to drop.
|
|
514
|
+
export function stripComments(source) {
|
|
515
|
+
const out = [];
|
|
516
|
+
let inBlock = false;
|
|
517
|
+
for (const line of source.split("\n")) {
|
|
518
|
+
let l = line;
|
|
519
|
+
if (inBlock) {
|
|
520
|
+
const end = l.indexOf("*/");
|
|
521
|
+
if (end === -1) { out.push(""); continue; }
|
|
522
|
+
l = l.slice(end + 2);
|
|
523
|
+
inBlock = false;
|
|
524
|
+
}
|
|
525
|
+
if (l.trim().startsWith("//")) { out.push(""); continue; }
|
|
526
|
+
const open = l.indexOf("/*");
|
|
527
|
+
if (open > -1 && !/["'`]/.test(l.slice(0, open))) {
|
|
528
|
+
const end = l.indexOf("*/", open + 2);
|
|
529
|
+
if (end === -1) { inBlock = true; l = l.slice(0, open); }
|
|
530
|
+
else l = l.slice(0, open) + l.slice(end + 2);
|
|
531
|
+
}
|
|
532
|
+
out.push(l);
|
|
533
|
+
}
|
|
534
|
+
return out.join("\n");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---- "is this initializer provably NOT state?" ---------------------------------------
|
|
538
|
+
//
|
|
539
|
+
// The inversion. Everything this function cannot PROVE harmless is state and has to be on
|
|
540
|
+
// the list. Being wrong here costs a false alarm and a line on the allowlist; being wrong
|
|
541
|
+
// the other way is how `const SLOT = makeSlot()` was invisible.
|
|
542
|
+
|
|
543
|
+
// Consume one primitive literal at `i`. Returns the index after it, or -1.
|
|
544
|
+
function eatLiteral(s, i) {
|
|
545
|
+
const c = s[i];
|
|
546
|
+
if (c === '"' || c === "'") {
|
|
547
|
+
for (let j = i + 1; j < s.length; j++) {
|
|
548
|
+
if (s[j] === "\\") { j++; continue; }
|
|
549
|
+
if (s[j] === c) return j + 1;
|
|
550
|
+
}
|
|
551
|
+
return -1;
|
|
552
|
+
}
|
|
553
|
+
if (c === "`") {
|
|
554
|
+
// A template literal always EVALUATES to a string, whatever is interpolated into it,
|
|
555
|
+
// so the substitutions only have to be skipped, never understood.
|
|
556
|
+
for (let j = i + 1; j < s.length; j++) {
|
|
557
|
+
if (s[j] === "\\") { j++; continue; }
|
|
558
|
+
if (s[j] === "`") return j + 1;
|
|
559
|
+
if (s[j] === "$" && s[j + 1] === "{") {
|
|
560
|
+
let depth = 1;
|
|
561
|
+
j += 2;
|
|
562
|
+
while (j < s.length && depth) {
|
|
563
|
+
if (s[j] === "{") depth++;
|
|
564
|
+
else if (s[j] === "}") depth--;
|
|
565
|
+
j++;
|
|
566
|
+
}
|
|
567
|
+
j--;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return -1;
|
|
571
|
+
}
|
|
572
|
+
const num = /^-?(?:0[xX][0-9a-fA-F_]+|\d[\d_]*(?:\.[\d_]+)?(?:[eE][-+]?\d+)?|\.\d[\d_]*)/.exec(s.slice(i));
|
|
573
|
+
if (num) return i + num[0].length;
|
|
574
|
+
const word = /^(?:true|false|null|undefined)\b/.exec(s.slice(i));
|
|
575
|
+
if (word) return i + word[0].length;
|
|
576
|
+
return -1;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// A regex literal at position 0, and nothing after it.
|
|
580
|
+
const REGEX_LITERAL = /^\/(?:\\.|\[(?:\\.|[^\]])*\]|[^/\\\n])+\/[gimsuyd]*$/;
|
|
581
|
+
|
|
582
|
+
// `Symbol("x")` / `Symbol.for("x")` — a primitive, like a number. The only call form on
|
|
583
|
+
// this side of the line, and it is here because a symbol cannot hold anything.
|
|
584
|
+
const SYMBOL_CALL = /^Symbol(?:\.for)?\s*\(\s*(?:(["'])(?:\\.|(?!\1).)*\1\s*)?\)$/;
|
|
585
|
+
|
|
586
|
+
// A chain of primitive literals joined by arithmetic/concatenation — `60 * 60 * 24 * 7`,
|
|
587
|
+
// `"pbkdf2$"`, `` `a${b}c` ``, `1024 * 64`. Whatever the operands, the result of `+ - * /
|
|
588
|
+
// %` over literals is a primitive.
|
|
589
|
+
function isLiteralExpression(init) {
|
|
590
|
+
let i = 0;
|
|
591
|
+
const s = init.trim();
|
|
592
|
+
if (!s) return false;
|
|
593
|
+
for (;;) {
|
|
594
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
595
|
+
const next = eatLiteral(s, i);
|
|
596
|
+
if (next === -1) return false;
|
|
597
|
+
i = next;
|
|
598
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
599
|
+
if (i >= s.length) return true;
|
|
600
|
+
if (!/[+\-*/%]/.test(s[i])) return false;
|
|
601
|
+
i++;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// A function expression: `function (…)`, `async function`, or an arrow — recognised by a
|
|
606
|
+
// `=>` at depth 0, which `foo(() => 1)` does not have and `(a, b) => …` does.
|
|
607
|
+
function isFunctionExpression(init) {
|
|
608
|
+
if (/^(?:async\s+)?function\b/.test(init)) return true;
|
|
609
|
+
let depth = 0;
|
|
610
|
+
let quote = null;
|
|
611
|
+
for (let i = 0; i < init.length; i++) {
|
|
612
|
+
const c = init[i];
|
|
613
|
+
if (quote) {
|
|
614
|
+
if (c === "\\") i++;
|
|
615
|
+
else if (c === quote) quote = null;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (c === '"' || c === "'" || c === "`") { quote = c; continue; }
|
|
619
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
620
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
621
|
+
else if (c === "=" && init[i + 1] === ">" && depth === 0) return true;
|
|
622
|
+
}
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Module-scope arrows whose BODY is a string — `const ic = (inner) => \`<svg …\`;`. A call
|
|
627
|
+
// to one of those binds a string, and a string is not state. This is the only call form
|
|
628
|
+
// admitted beyond `Symbol`, and it is admitted structurally: the body has to start with a
|
|
629
|
+
// quote or a backtick, so a factory with a block body (`() => { let v; … }` — exactly the
|
|
630
|
+
// bypass) is not one of these.
|
|
631
|
+
export function stringBuilderNames(source) {
|
|
632
|
+
const names = new Set();
|
|
633
|
+
const re = /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>\s*[`"']/gm;
|
|
634
|
+
for (const m of source.matchAll(re)) names.add(m[1]);
|
|
635
|
+
const re1 = /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*[A-Za-z_$][\w$]*\s*=>\s*[`"']/gm;
|
|
636
|
+
for (const m of source.matchAll(re1)) names.add(m[1]);
|
|
637
|
+
return names;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// The whole question, in one place.
|
|
641
|
+
export function isProvablyNotState(init, builders = new Set()) {
|
|
642
|
+
const s = String(init || "").trim().replace(/;+$/, "").trim();
|
|
643
|
+
if (!s) return false;
|
|
644
|
+
if (isLiteralExpression(s)) return true;
|
|
645
|
+
if (REGEX_LITERAL.test(s)) return true;
|
|
646
|
+
if (SYMBOL_CALL.test(s)) return true;
|
|
647
|
+
if (isFunctionExpression(s)) return true;
|
|
648
|
+
const call = /^([A-Za-z_$][\w$]*)\s*\(/.exec(s);
|
|
649
|
+
if (call && builders.has(call[1]) && s.endsWith(")")) return true;
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---- module-scope bindings -----------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
// `export` in front changes nothing about where the state lives, so it is optional here.
|
|
656
|
+
const DECL = /^(?:export\s+)?(let|var|const)\s+([A-Za-z_$][\w$]*)\s*(?:=|;|$)/;
|
|
657
|
+
const DESTRUCTURE = /^(?:export\s+)?(let|var|const)\s*[[{]/;
|
|
658
|
+
|
|
659
|
+
// Is there a comma outside every bracket and string on this line? `let A = [], B = [];`
|
|
660
|
+
// would otherwise hide B from the lint behind A's allowlist entry.
|
|
661
|
+
function hasTopLevelComma(line) {
|
|
662
|
+
let depth = 0;
|
|
663
|
+
let quote = null;
|
|
664
|
+
for (let i = 0; i < line.length; i++) {
|
|
665
|
+
const c = line[i];
|
|
666
|
+
if (quote) {
|
|
667
|
+
if (c === "\\") i++;
|
|
668
|
+
else if (c === quote) quote = null;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (c === '"' || c === "'" || c === "`") quote = c;
|
|
672
|
+
else if (c === "/" && line[i + 1] === "/") break;
|
|
673
|
+
else if (c === "(" || c === "[" || c === "{") depth++;
|
|
674
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
675
|
+
else if (c === "," && depth === 0) return true;
|
|
676
|
+
}
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// The initializer text, from just after the `=` to the `;` that ends the statement —
|
|
681
|
+
// across as many lines as it takes, so a wrapped declaration is classified by its whole
|
|
682
|
+
// value rather than by whichever fragment landed on the first line.
|
|
683
|
+
function initializerAt(source, eqIndex) {
|
|
684
|
+
let depth = 0;
|
|
685
|
+
let quote = null;
|
|
686
|
+
for (let i = eqIndex + 1; i < source.length; i++) {
|
|
687
|
+
const c = source[i];
|
|
688
|
+
if (quote) {
|
|
689
|
+
if (c === "\\") { i++; continue; }
|
|
690
|
+
if (c === quote) quote = null;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (c === '"' || c === "'" || c === "`") { quote = c; continue; }
|
|
694
|
+
// A `//` starts a line comment whenever we are not inside a quote — that is true at
|
|
695
|
+
// any bracket depth, not only at 0. Gating this on `depth === 0` was the bug: an
|
|
696
|
+
// apostrophe inside a `//` comment NESTED in an object literal (an initializer's own
|
|
697
|
+
// doc comments, exactly what this scanner walks) opened a fake quote that swallowed
|
|
698
|
+
// real braces/parens until some unrelated later quote happened to close it, so the
|
|
699
|
+
// scanner could run off the end of the real initializer into the rest of the module —
|
|
700
|
+
// observed on `CAP_ROUTES`, whose `Object.freeze({…})` carries prose comments with
|
|
701
|
+
// apostrophes at depth 2.
|
|
702
|
+
if (c === "/" && source[i + 1] === "/") {
|
|
703
|
+
const nl = source.indexOf("\n", i);
|
|
704
|
+
if (nl === -1) return source.slice(eqIndex + 1, i);
|
|
705
|
+
i = nl;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
709
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
710
|
+
else if (c === ";" && depth === 0) return source.slice(eqIndex + 1, i);
|
|
711
|
+
}
|
|
712
|
+
return source.slice(eqIndex + 1);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Every module-scope binding that can hold state. Column 0 is the whole test for "module
|
|
716
|
+
// scope": the worker's embedded client scripts live inside template literals and are all
|
|
717
|
+
// indented, so nothing nested is reachable from here.
|
|
718
|
+
export function moduleScopeBindings(source) {
|
|
719
|
+
const builders = stringBuilderNames(source);
|
|
720
|
+
const out = [];
|
|
721
|
+
const lines = source.split("\n");
|
|
722
|
+
let offset = 0;
|
|
723
|
+
for (let i = 0; i < lines.length; i++) {
|
|
724
|
+
const line = lines[i];
|
|
725
|
+
const lineStart = offset;
|
|
726
|
+
offset += line.length + 1;
|
|
727
|
+
if (DESTRUCTURE.test(line)) {
|
|
728
|
+
out.push({ name: null, keyword: line.trim().split(/\s+/)[0], line: i + 1, destructured: true });
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const m = DECL.exec(line);
|
|
732
|
+
if (!m) continue;
|
|
733
|
+
const [, keyword, name] = m;
|
|
734
|
+
// DECL's match ends ON the `=` when there is one. A declaration with no initializer,
|
|
735
|
+
// or one whose `=` wrapped onto the next line, gets an empty initializer — which is
|
|
736
|
+
// not provably harmless, so it counts as state. That is the safe direction.
|
|
737
|
+
const eq = m[0].endsWith("=") ? m[0].length - 1 : -1;
|
|
738
|
+
const init = eq === -1 ? "" : initializerAt(source, lineStart + eq).trim();
|
|
739
|
+
// A `let`/`var` is state whatever it holds — it can be reassigned from a request.
|
|
740
|
+
if (keyword === "const" && isProvablyNotState(init, builders)) continue;
|
|
741
|
+
out.push({ name, keyword, line: i + 1, init, multi: hasTopLevelComma(line) });
|
|
742
|
+
}
|
|
743
|
+
return out;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ---- reading the USES of a cache handle ----------------------------------------------
|
|
747
|
+
|
|
748
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
749
|
+
|
|
750
|
+
// The text between the parens starting at `i`, balanced and quote-aware. Null if unclosed.
|
|
751
|
+
function callArgs(source, i) {
|
|
752
|
+
let depth = 0;
|
|
753
|
+
let quote = null;
|
|
754
|
+
for (let j = i; j < source.length; j++) {
|
|
755
|
+
const c = source[j];
|
|
756
|
+
if (quote) {
|
|
757
|
+
if (c === "\\") j++;
|
|
758
|
+
else if (c === quote) quote = null;
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (c === '"' || c === "'" || c === "`") { quote = c; continue; }
|
|
762
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
763
|
+
else if (c === ")" || c === "]" || c === "}") {
|
|
764
|
+
depth--;
|
|
765
|
+
if (depth === 0) return source.slice(i + 1, j);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function firstArg(args) {
|
|
772
|
+
if (args == null) return null;
|
|
773
|
+
let depth = 0;
|
|
774
|
+
let quote = null;
|
|
775
|
+
for (let i = 0; i < args.length; i++) {
|
|
776
|
+
const c = args[i];
|
|
777
|
+
if (quote) {
|
|
778
|
+
if (c === "\\") i++;
|
|
779
|
+
else if (c === quote) quote = null;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (c === '"' || c === "'" || c === "`") { quote = c; continue; }
|
|
783
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
784
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
785
|
+
else if (c === "," && depth === 0) return args.slice(0, i).trim();
|
|
786
|
+
}
|
|
787
|
+
return args.trim();
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// A workspace key SAYS SO: `tenantId`, or a member chain ending in `.tenantId`.
|
|
791
|
+
//
|
|
792
|
+
// There are deliberately NO aliases. The previous version accepted any local ever
|
|
793
|
+
// assigned from a tenant id anywhere in the module — a module-wide set of trusted names —
|
|
794
|
+
// so `const key = "everyone"` in one function passed because `const key = tctx.tenantId`
|
|
795
|
+
// existed in another. An alias is a local name CLAIMING to be the workspace, which is a
|
|
796
|
+
// sentence in identifier form, and sentences are what this file no longer reads. Code
|
|
797
|
+
// that wants a key in a local writes `tctx.tenantId` at the access instead.
|
|
798
|
+
const TENANT_KEY = /^(?:[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*\.\s*)?tenantId$/;
|
|
799
|
+
|
|
800
|
+
const isTenantKey = (expr) => !!expr && TENANT_KEY.test(expr.trim());
|
|
801
|
+
|
|
802
|
+
const KEYED_METHODS = new Set(TENANT_CACHE_KEYED_METHODS);
|
|
803
|
+
const WHOLE_METHODS = new Set(TENANT_CACHE_WHOLE_METHODS);
|
|
804
|
+
|
|
805
|
+
const lineOf = (source, index) => source.slice(0, index).split("\n").length;
|
|
806
|
+
|
|
807
|
+
// Does this module import the cache constructor from the module that owns it? A `cache`
|
|
808
|
+
// entry is a claim about `tenantCache`, and a locally-declared `tenantCache` would be a
|
|
809
|
+
// different function with the same name.
|
|
810
|
+
const IMPORTS_CACHE =
|
|
811
|
+
/import\s*\{[^}]*\btenantCache\b[^}]*\}\s*from\s*["'][^"']*tenant-cache\.mjs["']/;
|
|
812
|
+
|
|
813
|
+
// Every problem with how a cache handle is declared and touched, in ONE module. Nothing
|
|
814
|
+
// here reads a note, because there is no note to read.
|
|
815
|
+
export function checkCacheUses(source, name, declLine) {
|
|
816
|
+
const code = stripComments(source);
|
|
817
|
+
const problems = [];
|
|
818
|
+
|
|
819
|
+
const re = new RegExp(`(?<![\\w$])${esc(name)}(?![\\w$])(\\s*[.:]?)`, "g");
|
|
820
|
+
for (const m of code.matchAll(re)) {
|
|
821
|
+
const at = m.index;
|
|
822
|
+
const line = lineOf(code, at);
|
|
823
|
+
if (line === declLine) continue;
|
|
824
|
+
const tail = m[1];
|
|
825
|
+
// `{ mcpHostAllowlist: m }` — a property NAME in a pattern or literal, not a read of
|
|
826
|
+
// the binding. The read in `{ a: NAME }` is the occurrence with no colon after it.
|
|
827
|
+
if (tail.trim() === ":") continue;
|
|
828
|
+
if (tail.trim() !== ".") {
|
|
829
|
+
problems.push({
|
|
830
|
+
kind: "cache-escapes", name, line,
|
|
831
|
+
message:
|
|
832
|
+
"a per-workspace cache used as a value rather than through one of its own methods — passing the handle " +
|
|
833
|
+
"somewhere hands over every workspace's entry to whatever that place does with it. Touch it only as " +
|
|
834
|
+
`${name}.${TENANT_CACHE_KEYED_METHODS.join("/")}(<tenantId>, …)`,
|
|
835
|
+
});
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
const rest = code.slice(at + m[0].length);
|
|
839
|
+
const mem = /^\s*([A-Za-z_$][\w$]*)/.exec(rest);
|
|
840
|
+
if (!mem) continue;
|
|
841
|
+
const member = mem[1];
|
|
842
|
+
if (WHOLE_METHODS.has(member)) continue;
|
|
843
|
+
if (!KEYED_METHODS.has(member)) {
|
|
844
|
+
problems.push({
|
|
845
|
+
kind: "cache-unknown-method", name, line,
|
|
846
|
+
message:
|
|
847
|
+
`.${member} is not one of the handle's methods (${[...TENANT_CACHE_KEYED_METHODS, ...TENANT_CACHE_WHOLE_METHODS].join(", ")}) ` +
|
|
848
|
+
"— a tenantCache handle is frozen, so this either throws at runtime or is not the cache at all",
|
|
849
|
+
});
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
const paren = rest.indexOf("(", mem.index + member.length);
|
|
853
|
+
const key = paren === -1 ? null : firstArg(callArgs(rest, paren));
|
|
854
|
+
if (isTenantKey(key)) continue;
|
|
855
|
+
problems.push({
|
|
856
|
+
kind: "cache-bad-key", name, line,
|
|
857
|
+
message:
|
|
858
|
+
`.${member}(${key === null ? "" : key}) does not name a workspace — the first argument must be a \`tenantId\` ` +
|
|
859
|
+
"expression (`tenantId`, or something ending `.tenantId`). A literal, or a local that merely looks like a " +
|
|
860
|
+
"workspace, is one slot with extra syntax: `const key = \"everyone\"; M.get(key)` is how a keyed cache became " +
|
|
861
|
+
"a shared one while this lint was green",
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
return problems;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ---- the per-module check ------------------------------------------------------------
|
|
868
|
+
|
|
869
|
+
// Returns every problem found in ONE module, each as `{ kind, name, line, message }`. An
|
|
870
|
+
// empty list is the pass condition; the caller decides how to report.
|
|
871
|
+
export function checkModuleGlobals(source, options = {}) {
|
|
872
|
+
// `allowed` is injectable so a test can ask the checker about a list other than the one
|
|
873
|
+
// shipped — the only way to prove the readmission direction fires without shipping the
|
|
874
|
+
// very entry it is there to refuse.
|
|
875
|
+
const allowed = options.allowed || {};
|
|
876
|
+
const bindings = moduleScopeBindings(source);
|
|
877
|
+
const problems = [];
|
|
878
|
+
const seen = new Set();
|
|
879
|
+
const importsCache = IMPORTS_CACHE.test(source);
|
|
880
|
+
|
|
881
|
+
for (const b of bindings) {
|
|
882
|
+
if (b.destructured) {
|
|
883
|
+
problems.push({
|
|
884
|
+
kind: "destructured", name: "(destructuring)", line: b.line,
|
|
885
|
+
message: "module-scope destructuring hides the binding names from this lint — declare one named binding instead",
|
|
886
|
+
});
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
seen.add(b.name);
|
|
890
|
+
if (b.keyword === "var") {
|
|
891
|
+
problems.push({
|
|
892
|
+
kind: "var", name: b.name, line: b.line,
|
|
893
|
+
message: "`var` at module scope — use `let` (or thread it onto the tenant context)",
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
if (b.multi) {
|
|
897
|
+
problems.push({
|
|
898
|
+
kind: "multi", name: b.name, line: b.line,
|
|
899
|
+
message: "declares more than one binding on one line — split it so every global is named and accounted for",
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
const entry = allowed[b.name];
|
|
903
|
+
if (!entry) {
|
|
904
|
+
problems.push({
|
|
905
|
+
kind: "unlisted", name: b.name, line: b.line,
|
|
906
|
+
message:
|
|
907
|
+
"new module-scope state. If it is tenant config, add it to FIELDS in src/tenant-context.mjs and thread it " +
|
|
908
|
+
"rather than declaring it here. If it is a cache, build it with `tenantCache()` from src/tenant-cache.mjs " +
|
|
909
|
+
"and list it under `cache`. If it is a fixed table, wrap it in `Object.freeze(…)` and list it under " +
|
|
910
|
+
"`frozen`. Anything else is a slot the whole isolate shares: `unkeyed`, which costs a proof and a line off " +
|
|
911
|
+
"UNKEYED_BUDGET. NOTE that a call initializer counts as state — `const X = makeSomething()` can return a " +
|
|
912
|
+
"closure over a module-scope slot, and that is exactly how one got past this lint",
|
|
913
|
+
});
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
if (!KINDS.includes(entry.kind)) {
|
|
917
|
+
problems.push({
|
|
918
|
+
kind: "bad-kind", name: b.name, line: b.line,
|
|
919
|
+
message: `allowlisted under an unknown kind "${entry.kind}" — the kinds are ${KINDS.join(", ")}`,
|
|
920
|
+
});
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// The structural half. `cache` and `frozen` are decided from the declaration — and
|
|
925
|
+
// for a cache, from every use of the name too. `unkeyed` has nothing to check because
|
|
926
|
+
// being a bare slot IS what it declares; it is charged for at the graph level instead.
|
|
927
|
+
if (entry.kind === "cache") {
|
|
928
|
+
if (!/^tenantCache\s*\(/.test(b.init || "")) {
|
|
929
|
+
problems.push({
|
|
930
|
+
kind: "cache-not-constructed", name: b.name, line: b.line,
|
|
931
|
+
message:
|
|
932
|
+
"listed as a per-workspace cache but not built by `tenantCache(…)`. That constructor is the whole claim: " +
|
|
933
|
+
"its handle has no way to reach a value without naming a workspace and no way to enumerate what it holds, " +
|
|
934
|
+
"so a cache made any other way is a shape somebody has to be trusted about. Build it with tenantCache, or " +
|
|
935
|
+
"move the entry to `unkeyed` and pay its budget",
|
|
936
|
+
});
|
|
937
|
+
} else if (!importsCache) {
|
|
938
|
+
problems.push({
|
|
939
|
+
kind: "cache-not-imported", name: b.name, line: b.line,
|
|
940
|
+
message:
|
|
941
|
+
`built by a local \`tenantCache\` — this module does not import it from ${CACHE_MODULE}, so the name ` +
|
|
942
|
+
"vouches for nothing",
|
|
943
|
+
});
|
|
944
|
+
} else {
|
|
945
|
+
problems.push(...checkCacheUses(source, b.name, b.line));
|
|
946
|
+
}
|
|
947
|
+
} else if (entry.kind === "frozen") {
|
|
948
|
+
if (b.keyword !== "const") {
|
|
949
|
+
problems.push({
|
|
950
|
+
kind: "frozen-not-const", name: b.name, line: b.line,
|
|
951
|
+
message:
|
|
952
|
+
`listed as a fixed table but declared \`${b.keyword}\` — a rebindable name is not a table, whatever its ` +
|
|
953
|
+
"current value is frozen",
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
if (!/^Object\s*\.\s*freeze\s*\(/.test(b.init || "")) {
|
|
957
|
+
problems.push({
|
|
958
|
+
kind: "frozen-not-frozen", name: b.name, line: b.line,
|
|
959
|
+
message:
|
|
960
|
+
"listed as a fixed table but not wrapped in `Object.freeze(…)`. The freeze is what makes this checkable: " +
|
|
961
|
+
"the engine then refuses every write at every site, including the ones a scanner cannot parse — a `??=` " +
|
|
962
|
+
"into an unfrozen table is how a per-request write into an \"invariant\" table went unseen",
|
|
963
|
+
});
|
|
964
|
+
} else if (/^Object\s*\.\s*freeze\s*\(\s*new\s+(?:Map|Set|WeakMap|WeakSet)\b/.test(b.init || "")) {
|
|
965
|
+
problems.push({
|
|
966
|
+
kind: "frozen-collection", name: b.name, line: b.line,
|
|
967
|
+
message:
|
|
968
|
+
"a frozen Map or Set is not a fixed table — `Object.freeze` does not touch their contents, so `.set()`, " +
|
|
969
|
+
"`.add()`, `.delete()` and `.clear()` all still work. Use a frozen array or object literal, or make it a " +
|
|
970
|
+
"`tenantCache` if it is really a cache",
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
for (const name of Object.keys(allowed)) {
|
|
977
|
+
if (seen.has(name)) continue;
|
|
978
|
+
problems.push({
|
|
979
|
+
kind: "stale", name, line: 0,
|
|
980
|
+
message: "allowlisted but no longer declared in this module — delete the entry so the list keeps shrinking",
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// The allowlist may not re-admit a threaded field. The way back to a shared config
|
|
985
|
+
// global is not a new name — it is an OLD one, put back at module scope with a
|
|
986
|
+
// plausible cache or constant reason attached. A field of the tenant context is per
|
|
987
|
+
// workspace BY DEFINITION, so no reason can make it fixed, and this refuses the claim
|
|
988
|
+
// rather than reading it.
|
|
989
|
+
for (const name of Object.keys(allowed)) {
|
|
990
|
+
if (!TENANT_FIELD_NAMES.includes(name)) continue;
|
|
991
|
+
problems.push({
|
|
992
|
+
kind: "readmitted", name, line: 0,
|
|
993
|
+
message: "allowlisted but it is a field of the tenant context — per-workspace config cannot be shared between workspaces whatever the entry claims; read it off the context instead",
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return { bindings, problems };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ---- the graph -----------------------------------------------------------------------
|
|
1001
|
+
|
|
1002
|
+
// Does the constructor still answer to exactly the methods this file checks against? If
|
|
1003
|
+
// somebody adds a `values()` to the handle, every `cache` verdict silently widens — so
|
|
1004
|
+
// the handle is built and asked, rather than described here.
|
|
1005
|
+
export function checkCacheApi() {
|
|
1006
|
+
const handle = tenantCache("lint-probe");
|
|
1007
|
+
const actual = Object.keys(handle).sort();
|
|
1008
|
+
const declared = [...TENANT_CACHE_KEYED_METHODS, ...TENANT_CACHE_WHOLE_METHODS].sort();
|
|
1009
|
+
if (actual.join(",") === declared.join(",")) return [];
|
|
1010
|
+
return [{
|
|
1011
|
+
kind: "cache-api-drift", module: CACHE_MODULE, name: "tenantCache", line: 0,
|
|
1012
|
+
message:
|
|
1013
|
+
`the handle answers to [${actual.join(", ")}] but ${CACHE_MODULE} declares [${declared.join(", ")}]. ` +
|
|
1014
|
+
"Every `cache` verdict is read off those two lists, so a method that is on neither is a way to touch a cache " +
|
|
1015
|
+
"this lint has no opinion about — put it on the keyed list or the whole-cache list",
|
|
1016
|
+
}];
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// The whole graph: discover, then check each module against ITS section, then check that
|
|
1020
|
+
// every section still has a module, then charge the unkeyed quarantine.
|
|
1021
|
+
//
|
|
1022
|
+
// `budget` is the exact number of unkeyed entries the whole graph may carry. It defaults
|
|
1023
|
+
// to UNKEYED_BUDGET when the shipped list is the one being checked, and to null (skip)
|
|
1024
|
+
// when a test injects its own list, because a fixture's list is not this engine's debt.
|
|
1025
|
+
export function checkGraph(root, options = {}) {
|
|
1026
|
+
const allowed = options.allowed || ALLOWED;
|
|
1027
|
+
const entryRel = options.entry || ENTRY;
|
|
1028
|
+
const read = options.read || ((p) => fs.readFileSync(p, "utf8"));
|
|
1029
|
+
const budget = options.budget !== undefined
|
|
1030
|
+
? options.budget
|
|
1031
|
+
: (allowed === ALLOWED ? UNKEYED_BUDGET : null);
|
|
1032
|
+
const modules = discoverModules(path.join(root, entryRel), root, read);
|
|
1033
|
+
|
|
1034
|
+
const problems = [...checkCacheApi()];
|
|
1035
|
+
const bindings = [];
|
|
1036
|
+
for (const rel of modules) {
|
|
1037
|
+
const r = checkModuleGlobals(read(path.join(root, rel)), { allowed: allowed[rel] || {} });
|
|
1038
|
+
for (const b of r.bindings) bindings.push({ ...b, module: rel });
|
|
1039
|
+
for (const p of r.problems) problems.push({ ...p, module: rel });
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
for (const rel of Object.keys(allowed)) {
|
|
1043
|
+
if (modules.includes(rel)) continue;
|
|
1044
|
+
problems.push({
|
|
1045
|
+
kind: "unreachable", module: rel, name: "(module)", line: 0,
|
|
1046
|
+
message: "allowlisted but the worker no longer imports it — delete its section so the list keeps shrinking",
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// The quarantine, charged. Each unkeyed slot must point at a test file that EXISTS and
|
|
1051
|
+
// speaks its name — which proves the pointer resolves and nothing more, and whose real
|
|
1052
|
+
// work is that deleting or renaming that file turns this red. Then the count.
|
|
1053
|
+
let unkeyed = 0;
|
|
1054
|
+
for (const [rel, entries] of Object.entries(allowed)) {
|
|
1055
|
+
for (const [name, entry] of Object.entries(entries)) {
|
|
1056
|
+
if (!entry || entry.kind !== "unkeyed") continue;
|
|
1057
|
+
unkeyed++;
|
|
1058
|
+
if (!entry.proof) {
|
|
1059
|
+
problems.push({
|
|
1060
|
+
kind: "no-proof", module: rel, name, line: 0,
|
|
1061
|
+
message:
|
|
1062
|
+
"an unkeyed per-isolate slot with no `proof` — name the test file that drives two workspaces at it. A slot " +
|
|
1063
|
+
"nobody has pointed a second workspace at is a slot nobody has looked at",
|
|
1064
|
+
});
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
let proofSource = null;
|
|
1068
|
+
try { proofSource = read(path.join(root, entry.proof)); } catch (e) { /* reported below */ }
|
|
1069
|
+
if (proofSource == null) {
|
|
1070
|
+
problems.push({
|
|
1071
|
+
kind: "proof-missing", module: rel, name, line: 0,
|
|
1072
|
+
message: `\`proof\` names ${entry.proof}, which does not exist — a proof that has been deleted or renamed is not one`,
|
|
1073
|
+
});
|
|
1074
|
+
} else if (!new RegExp(`(?<![\\w$])${esc(name)}(?![\\w$])`).test(proofSource)) {
|
|
1075
|
+
problems.push({
|
|
1076
|
+
kind: "proof-silent", module: rel, name, line: 0,
|
|
1077
|
+
message: `\`proof\` names ${entry.proof}, which never mentions ${name} — point at the file that actually drives two workspaces at this slot`,
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (budget !== null && unkeyed !== budget) {
|
|
1083
|
+
problems.push({
|
|
1084
|
+
kind: "budget", module: "scripts/no-tenant-globals.mjs", name: "UNKEYED_BUDGET", line: 0,
|
|
1085
|
+
message:
|
|
1086
|
+
`${unkeyed} unkeyed per-isolate slot(s) allowlisted, UNKEYED_BUDGET says ${budget}. ` +
|
|
1087
|
+
(unkeyed > budget
|
|
1088
|
+
? "Adding one is adding a slot two workspaces share — the shape every closed cross-tenant leak had. If it is " +
|
|
1089
|
+
"genuinely unavoidable, raise the budget in the same commit and say in the message which workspace's data " +
|
|
1090
|
+
"the isolate now shares"
|
|
1091
|
+
: "One has been closed — lower the budget in the same commit, so the number keeps being this phase's remaining debt " +
|
|
1092
|
+
"rather than standing permission for the next one"),
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
return { modules, bindings, problems, unkeyed };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ---- CLI ----------------------------------------------------------------------------
|
|
1100
|
+
|
|
1101
|
+
function main(argv) {
|
|
1102
|
+
const opt = (flag, fallback) => {
|
|
1103
|
+
const i = argv.indexOf(flag);
|
|
1104
|
+
return i > -1 && argv[i + 1] ? argv[i + 1] : fallback;
|
|
1105
|
+
};
|
|
1106
|
+
const quiet = argv.includes("--quiet");
|
|
1107
|
+
const root = opt("--root", path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
1108
|
+
const entry = opt("--entry", ENTRY);
|
|
1109
|
+
|
|
1110
|
+
const { modules, bindings, problems, unkeyed } = checkGraph(root, { entry });
|
|
1111
|
+
|
|
1112
|
+
if (problems.length) {
|
|
1113
|
+
for (const p of problems) {
|
|
1114
|
+
console.error(`${p.line ? `${p.module}:${p.line}` : p.module}: ${p.name}: ${p.message}`);
|
|
1115
|
+
}
|
|
1116
|
+
console.error(`\n${problems.length} problem(s) — see the header of scripts/no-tenant-globals.mjs`);
|
|
1117
|
+
return 1;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (!quiet) {
|
|
1121
|
+
const counts = { cache: 0, frozen: 0, unkeyed: 0 };
|
|
1122
|
+
for (const b of bindings) counts[ALLOWED[b.module][b.name].kind]++;
|
|
1123
|
+
console.log(
|
|
1124
|
+
`${modules.length} modules reachable from ${entry}: ${bindings.length} module-scope bindings, all accounted ` +
|
|
1125
|
+
`for — ${counts.cache} caches built by tenantCache and keyed at every access, ${counts.frozen} frozen ` +
|
|
1126
|
+
`tables, ${counts.unkeyed} shared slots (budget ${UNKEYED_BUDGET}), no tenant config`,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
return 0;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1133
|
+
process.exit(main(process.argv.slice(2)));
|
|
1134
|
+
}
|