browser-broker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,239 @@
1
+ import { BrokerError } from "../errors.js";
2
+ /**
3
+ * `storage_seed` on `browser_claim` (row #65, `SCHEMA.md` §3.2) — the
4
+ * validation, and the shape the driver is handed.
5
+ *
6
+ * ── What this argument is for ───────────────────────────────────────────
7
+ *
8
+ * **Measured: 40 calls across 25 sessions, all one shape** — fetch a token
9
+ * from an interface, write it into storage before the page loads, then
10
+ * navigate. Every one of them reached for an execute-arbitrary-code verb this
11
+ * design does not implement, because *seeding storage before the first load is
12
+ * not something a page can do to itself*: the page that would run the code
13
+ * does not exist until the load that needs the value already in place.
14
+ *
15
+ * That verb was not merely reached for. **Sampled, it was found being used to
16
+ * enumerate other callers' tabs, to read local credential files and to make
17
+ * authenticated outbound requests** (§9.4). This argument covers the narrow,
18
+ * legitimate need in that measurement — a token in storage before first load —
19
+ * and grants none of the rest of that reach, deliberately.
20
+ *
21
+ * ── The safety property is structural, and that is the whole claim ──────
22
+ *
23
+ * **Nothing in this argument is ever passed to an evaluator.** A validated
24
+ * entry is a storage area, an origin, a key and a **string**, and the service
25
+ * hands those to the automation layer's own storage-writing interface, which
26
+ * takes a key and a string. **There is no position in that call in which a
27
+ * caller's bytes could be read as a program** — not because the values are
28
+ * inspected for anything program-shaped, which would be a filter and would
29
+ * eventually be wrong, but because the only sink they reach does not have an
30
+ * interpreting position in it.
31
+ *
32
+ * **What that does and does not amount to, stated honestly.** It means this
33
+ * argument cannot be turned into a channel for running caller-supplied code.
34
+ * It does **not** mean a seeded value is harmless: a token written into an
35
+ * origin's storage is a credential in a browser, and on the signed-in browser
36
+ * that browser is shared (§1.2). What bounds that is the refusal list below
37
+ * and the ledger row, not this paragraph.
38
+ *
39
+ * ── The one thing the type system does not carry ────────────────────────
40
+ *
41
+ * {@link validateStorageSeed} takes `unknown`, because the value arrives from
42
+ * a caller across a surface and a cast at that boundary would make every
43
+ * declaration in this file decorative. Downstream of it the entries are
44
+ * {@link StorageSeedEntry}, and the driver seam declares that type — so the
45
+ * validation is not something a later caller can route around by constructing
46
+ * the object itself.
47
+ */
48
+ /**
49
+ * A refusal from this argument.
50
+ *
51
+ * **Deliberately not the service layer's `CallRefusal`**, for the reason
52
+ * `pages.ts` gives at length about its own refusals: that taxonomy is a closed
53
+ * table of codes whose discipline is "add the row with the guard, never
54
+ * before it", and these guards are wired to a surface by the row that owns
55
+ * that table. What is carried now is the part that must not be lost in the
56
+ * meantime — the §7 rule name, which is what the ledger is grepped by and what
57
+ * §8's parity assertion counts over.
58
+ */
59
+ export class StorageSeedRefusal extends BrokerError {
60
+ /** §3.14's "any details" — the numbers a caller branches on after the rule. */
61
+ detail;
62
+ constructor(rule, message, detail = {}) {
63
+ super(rule, message);
64
+ this.name = 'StorageSeedRefusal';
65
+ this.detail = detail;
66
+ }
67
+ }
68
+ /**
69
+ * The storage areas on offer.
70
+ *
71
+ * **An allowlist, and cookies are refused by not being on it** — the same
72
+ * shape `pages.ts` uses for navigation schemes and for the same reason: a
73
+ * denylist would permit every area nobody thought of. Both of these are
74
+ * per-origin key-value stores of strings, which is what makes them expressible
75
+ * through an interface that has no interpreting position.
76
+ */
77
+ export const SEED_AREAS = ['local', 'session'];
78
+ /**
79
+ * The origin schemes a seed entry may name.
80
+ *
81
+ * Ordinary web traffic only, and **the same rule and the same reason as
82
+ * navigation** (§3.2, §3.7). A local-file origin turns a lease into
83
+ * filesystem reach — which is one of the three things the sampled
84
+ * arbitrary-code usage was actually doing, so it is refused here rather than
85
+ * being left to the navigation that happens afterwards.
86
+ *
87
+ * Note there is no `about:blank` here although navigation permits it: a blank
88
+ * page has no origin to write storage into, so an entry naming one is not a
89
+ * narrower request but an incoherent one.
90
+ */
91
+ export const SEED_ORIGIN_SCHEMES = ['http:', 'https:'];
92
+ /**
93
+ * At most this many entries.
94
+ *
95
+ * **A bound is what makes this a seeding argument rather than a payload
96
+ * channel.** The measured shape is one or two tokens; sixteen is generous
97
+ * against that measurement rather than derived from a limit anything imposes.
98
+ */
99
+ export const MAX_SEED_ENTRIES = 16;
100
+ /**
101
+ * At most this many bytes in one value, measured as bytes rather than as
102
+ * characters.
103
+ *
104
+ * Characters would be the wrong unit for a bound whose purpose is to cap what
105
+ * can be carried: a string of astral-plane characters is twice the bytes of a
106
+ * string of the same length in Latin letters, so a character bound is a byte
107
+ * bound that varies by alphabet.
108
+ */
109
+ export const MAX_SEED_VALUE_BYTES = 4096;
110
+ /**
111
+ * The ledger's view of a seed. **Structurally cannot carry a value.**
112
+ *
113
+ * Deliberately not `entries.map((entry) => ({ ...entry }))` with a delete —
114
+ * the point is that the returned objects are built field by field from the
115
+ * three fields that may be recorded, so a value cannot arrive by being
116
+ * carried along.
117
+ */
118
+ export function seedRecord(entries) {
119
+ return entries.map((entry) => ({ origin: entry.origin, area: entry.area, key: entry.key }));
120
+ }
121
+ /**
122
+ * Validate the argument, or refuse it.
123
+ *
124
+ * Returns the entries the driver will write. **An absent argument is not an
125
+ * error** — it is the ordinary case, and it returns an empty list rather than
126
+ * undefined so every caller downstream has one shape to handle.
127
+ *
128
+ * ── Why this runs before the claim row is written ───────────────────────
129
+ *
130
+ * A refused seed must leave no lease behind. It is an argument on the claim,
131
+ * so a claim whose seed is refused is a claim that did not happen — and the
132
+ * caller is not charged capacity for it, does not hold a key, and has nothing
133
+ * to release. `decideClaim` therefore calls this before its first insert,
134
+ * which is the same position the unknown-browser refusal occupies and for the
135
+ * same reason (§2.2).
136
+ */
137
+ export function validateStorageSeed(seed) {
138
+ if (seed === undefined || seed === null) {
139
+ return [];
140
+ }
141
+ if (!Array.isArray(seed)) {
142
+ throw new StorageSeedRefusal('claim.seed_shape', 'A storage seed is a list of entries, each naming an origin, an area, a key and a string value.', { received: typeof seed });
143
+ }
144
+ if (seed.length > MAX_SEED_ENTRIES) {
145
+ throw new StorageSeedRefusal('claim.seed_bounded', `A storage seed carries at most ${String(MAX_SEED_ENTRIES)} entries and this one carries ${String(seed.length)}. The bound is what keeps this a way to seed a token rather than a way to move a payload.`, { entries: seed.length, maximum: MAX_SEED_ENTRIES });
146
+ }
147
+ return seed.map((entry, index) => validateEntry(entry, index));
148
+ }
149
+ /** One entry, with the position named so a refusal says which one. */
150
+ function validateEntry(entry, index) {
151
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
152
+ throw new StorageSeedRefusal('claim.seed_shape', `Storage seed entry ${String(index)} is not an entry. Each names an origin, an area, a key and a string value.`, { index });
153
+ }
154
+ const candidate = entry;
155
+ return {
156
+ origin: validateOrigin(candidate.origin, index),
157
+ area: validateArea(candidate.area, index),
158
+ key: validateKey(candidate.key, index),
159
+ value: validateValue(candidate.value, index),
160
+ };
161
+ }
162
+ /**
163
+ * The origin refusal — **ordinary web traffic, and nothing else.**
164
+ *
165
+ * The local-file case is named in the sentence rather than matched in the
166
+ * condition, exactly as `pages.ts` does it: matching it would suggest the
167
+ * other schemes are fine, and the allowlist is what actually decides.
168
+ */
169
+ function validateOrigin(origin, index) {
170
+ if (typeof origin !== 'string' || origin.trim() === '') {
171
+ throw new StorageSeedRefusal('claim.seed_origin_allowed', `Storage seed entry ${String(index)} needs an origin: ordinary web traffic, as a scheme and host.`, { index, allowedSchemes: [...SEED_ORIGIN_SCHEMES] });
172
+ }
173
+ let parsed;
174
+ try {
175
+ parsed = new URL(origin.trim());
176
+ }
177
+ catch {
178
+ throw new StorageSeedRefusal('claim.seed_origin_allowed', `Storage seed entry ${String(index)} does not name an origin this service can write into. Use ordinary web traffic (${SEED_ORIGIN_SCHEMES.join(', ')}).`, { index, origin: origin.trim(), allowedSchemes: [...SEED_ORIGIN_SCHEMES] });
179
+ }
180
+ if (!SEED_ORIGIN_SCHEMES.includes(parsed.protocol)) {
181
+ throw new StorageSeedRefusal('claim.seed_origin_allowed', `Storage seed entry ${String(index)} names an origin using "${parsed.protocol}", which this service does not write storage into. Ordinary web traffic (${SEED_ORIGIN_SCHEMES.join(', ')}) only — a local-file origin in particular would turn a browser lease into reach into this machine's filesystem.`, { index, scheme: parsed.protocol, allowedSchemes: [...SEED_ORIGIN_SCHEMES] });
182
+ }
183
+ // The origin, not the address: a path, a query or a fragment is not part of
184
+ // what a storage area is keyed by, and keeping one would record a
185
+ // distinction the browser does not make.
186
+ return parsed.origin;
187
+ }
188
+ /**
189
+ * The area refusal — **and this is where cookies are refused, by name.**
190
+ *
191
+ * §3.2 is explicit that a cookie is not an area on offer: *a cookie is a
192
+ * credential the browser sends automatically to everything matching its
193
+ * domain, and the read side is already limited to names and flags (§3.9), so
194
+ * seeding one is credential injection on a shared profile*, which §3.13
195
+ * refuses by name.
196
+ *
197
+ * **It is refused by not being on the allowlist**, in the same shape as every
198
+ * other allowlist here. It is nevertheless named in the sentence, because a
199
+ * caller that asked for cookies asked for something specific and being told
200
+ * *why* it is not on offer is what stops the next attempt being an ingenious
201
+ * workaround rather than a different approach.
202
+ */
203
+ function validateArea(area, index) {
204
+ if (typeof area === 'string' && SEED_AREAS.includes(area)) {
205
+ return area;
206
+ }
207
+ const named = typeof area === 'string' ? JSON.stringify(area) : 'nothing';
208
+ const cookieNote = typeof area === 'string' && area.toLowerCase().startsWith('cookie')
209
+ ? ' Cookies in particular are not on offer and this is deliberate: a cookie is a credential the browser sends automatically to everything matching its domain, so seeding one is credential injection on a profile other callers share.'
210
+ : '';
211
+ throw new StorageSeedRefusal('claim.seed_area_allowed', `Storage seed entry ${String(index)} names ${named} as its storage area. The areas on offer are ${SEED_AREAS.join(' and ')}.${cookieNote}`, { index, area: typeof area === 'string' ? area : null, allowedAreas: [...SEED_AREAS] });
212
+ }
213
+ function validateKey(key, index) {
214
+ if (typeof key !== 'string' || key === '') {
215
+ throw new StorageSeedRefusal('claim.seed_shape', `Storage seed entry ${String(index)} needs a key, and a key is a non-empty string.`, { index });
216
+ }
217
+ return key;
218
+ }
219
+ /**
220
+ * The value refusal — **a string, and the size bound.**
221
+ *
222
+ * The string rule is the one whose reason is worth keeping in front of
223
+ * whoever relaxes it: *the only thing that could carry a structure is
224
+ * something that gets interpreted, and interpretation is the thing being
225
+ * refused.* A caller that wants to seed an object serialises it itself, and
226
+ * what arrives here is text either way — the difference is that the service
227
+ * never does the deserialising, so there is no step of this operation that
228
+ * takes a caller's bytes and builds something out of them.
229
+ */
230
+ function validateValue(value, index) {
231
+ if (typeof value !== 'string') {
232
+ throw new StorageSeedRefusal('claim.seed_value_string', `Storage seed entry ${String(index)} has a ${typeof value} value. A seeded value is a string and is stored verbatim — the only thing that could carry a structure is something that gets interpreted, and interpretation is what this argument exists to avoid. Serialise it yourself and seed the text.`, { index, received: typeof value });
233
+ }
234
+ const bytes = Buffer.byteLength(value, 'utf8');
235
+ if (bytes > MAX_SEED_VALUE_BYTES) {
236
+ throw new StorageSeedRefusal('claim.seed_bounded', `Storage seed entry ${String(index)} carries ${String(bytes)} bytes and the limit is ${String(MAX_SEED_VALUE_BYTES)}. The bound is what keeps this a way to seed a token rather than a way to move a payload.`, { index, bytes, maximum: MAX_SEED_VALUE_BYTES });
237
+ }
238
+ return value;
239
+ }
@@ -0,0 +1,123 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * Tab lifecycle: the identifier mapping, and the two writes that bring a tab
4
+ * into being (row #21).
5
+ *
6
+ * ── The one rule this file exists to keep ───────────────────────────────
7
+ *
8
+ * **The driver's name for a tab is never returned to a caller on any
9
+ * surface.** `SCHEMA.md` §1.4 puts the consequence plainly: exposing it
10
+ * "hands callers a second, non-opaque way to name a tab, which is the
11
+ * addressing bug arriving through a different door". Callers hold
12
+ * `tabs.id` — a value carrying no information about any other tab, so there
13
+ * is no index arithmetic to get wrong and the whole class of
14
+ * landed-on-the-wrong-tab bugs is deleted rather than guarded.
15
+ *
16
+ * So this module is the **only** place the two namespaces meet, and the
17
+ * direction is one-way by construction: {@link recordTabOpened} takes the
18
+ * driver's name *in* and nothing here hands one back. **Nothing this module
19
+ * returns carries a driver name** — `reserveTab` answers with the opaque
20
+ * identifier and `recordTabOpened` answers with nothing. That is the
21
+ * structural half.
22
+ *
23
+ * The conventional half, said rather than implied: nothing stops a future
24
+ * module selecting `driver_tab_id` out of the store itself and putting it in
25
+ * a response. The column is right there and SQL is not type-checked. What
26
+ * makes that visible is that it would have to be written — a fresh query,
27
+ * naming the column, in a file that is not this one — rather than falling out
28
+ * of reusing a shape that already carries it.
29
+ *
30
+ * ── This module does not reconcile against a browser, and that is a fact ─
31
+ * ── about the design rather than an omission to be quietly filled in ─────
32
+ *
33
+ * There is a strong pull toward putting reconciliation here, because this is
34
+ * where tab rows live. Resist it, and know what is already true before
35
+ * adding anything:
36
+ *
37
+ * **Reclaiming capacity from callers that died is the reason a lease is safe
38
+ * to grant at all**, and that mechanism is `arbitration.ts`, not this file.
39
+ * Its `sweep()` runs as step 1 of **every** arbitration call, expires every
40
+ * lapsed claim across the whole store, and collects the tabs they held.
41
+ * `updateSweptTabs` is exported from there so that release and sweep cannot
42
+ * spell one rule twice — a hazard with a real precedent in this store, where
43
+ * two writers each moved a tab to `closing` and disagreed, violating the
44
+ * schema's own check on the ordinary path. The after-commit close is
45
+ * scheduled by `runArbitration`, outside the transaction, which is what keeps
46
+ * §2.4b (`arbitration.no_browser_io`) true.
47
+ *
48
+ * **If you came here to change how a dead lease's tabs are cleaned up, that
49
+ * is the file, not this one.** A second spelling of that rule placed here
50
+ * would be reached by nothing, and the tests around it would stay green while
51
+ * the running behaviour diverged.
52
+ *
53
+ * ── The half of row #21 this build does not implement ───────────────────
54
+ *
55
+ * `MILESTONES.md` #21 asks for reconciliation against a **live** browser:
56
+ * asking what it actually has open, closing a page no live lease owns, and
57
+ * closing a row a live lease believes it owns that is not there. **No path in
58
+ * `src/` calls `listTabs`**, so the service never asks that question. That
59
+ * gap is recorded on #21 itself and carries its own row.
60
+ *
61
+ * A design for it belongs in a shape that keeps §2.4b obvious rather than
62
+ * incidental: the deciding must be a pure function over what the browser said
63
+ * and what the store holds, the writing must take a database handle and no
64
+ * session, and the asking must sit visibly between them. A single function
65
+ * holding a driver and a database handle at once is the shape that ends up
66
+ * called from inside a transaction, because it is the only thing that looks
67
+ * like it does the whole job.
68
+ */
69
+ /** How long a database timestamp is spelled everywhere in this store. */
70
+ function now() {
71
+ return new Date().toISOString();
72
+ }
73
+ /**
74
+ * Reserve the row for a tab that is about to be opened, and return its opaque
75
+ * identifier.
76
+ *
77
+ * **The row is written before the browser is asked**, in state `opening` with
78
+ * no driver name — which is what §1.4's `CHECK ((state = 'opening') =
79
+ * (driver_tab_id IS NULL))` is describing. The order is not an
80
+ * implementation detail:
81
+ *
82
+ * - The identifier a caller will be given has to exist before anything can
83
+ * fail, or a tab that opened and then lost its answer is a page in a
84
+ * browser with no row naming it — a leak nothing can find, because the
85
+ * administrative operation that clears leaked tabs (§4.3) selects on rows.
86
+ * - It is the half of the work that belongs in the arbitration transaction.
87
+ * Opening the tab is browser work and belongs after the commit (§2.4b), so
88
+ * the split has to fall exactly here.
89
+ *
90
+ * The consequence is a row that can sit in `opening` forever if the open
91
+ * never completes, and that is the honest outcome rather than a gap: a lease
92
+ * whose tab never opened is visible as a lease whose tab never opened.
93
+ */
94
+ export function reserveTab(db, claimId, browserId) {
95
+ const tabId = randomUUID();
96
+ db.prepare(`INSERT INTO tabs (id, claim_id, browser_id, driver_tab_id, state)
97
+ VALUES (?, ?, ?, NULL, 'opening')`).run(tabId, claimId, browserId);
98
+ return tabId;
99
+ }
100
+ /**
101
+ * Record that a reserved tab actually opened, under the driver's name for it.
102
+ *
103
+ * **This is the moment the mapping comes into being**, and it is the only
104
+ * write that sets `driver_tab_id`. `opened_at` is stamped with when the tab
105
+ * opened rather than when anybody noticed, for the same reason §2.4a gives
106
+ * about expiry: the two are different facts and only one of them is about the
107
+ * tab.
108
+ */
109
+ export function recordTabOpened(db, tabId, driverTabId) {
110
+ const changed = db
111
+ .prepare(`UPDATE tabs
112
+ SET driver_tab_id = ?, state = 'open', opened_at = ?, updated_at = ?
113
+ WHERE id = ? AND state = 'opening'`)
114
+ .run(driverTabId, now(), now(), tabId).changes;
115
+ if (changed !== 1) {
116
+ // Not a refusal a caller ever sees: reaching here means this module was
117
+ // asked to open a tab twice, or to open one that was already finished
118
+ // with. Throwing is right because the alternative is a second driver name
119
+ // silently overwriting the first, leaving a real page with no row —
120
+ // exactly the leak `reserveTab` is ordered the way it is to prevent.
121
+ throw new Error(`Tab ${tabId} was not awaiting an open, so the driver name could not be recorded against it.`);
122
+ }
123
+ }
@@ -0,0 +1,99 @@
1
+ import { StartupRefusal } from "../errors.js";
2
+ import { BEGIN_STATEMENT } from "./transaction.js";
3
+ /**
4
+ * Compare this process's tab budget against the store's, and refuse on a
5
+ * disagreement.
6
+ *
7
+ * **The read and the write are one transaction, and that is not tidiness.**
8
+ * Two processes opening an empty store within the same instant would
9
+ * otherwise both read nothing and both write, and the second write is what
10
+ * decides — so the value recorded would be whichever process happened to be
11
+ * slower, and a genuine disagreement between them would be silently resolved
12
+ * in favour of nobody in particular. Inside one immediate transaction the
13
+ * loser reads the winner's row and compares against it, which is the intended
14
+ * behaviour rather than a race.
15
+ *
16
+ * **This is not an arbitration path**, so it opens its own transaction rather
17
+ * than going through the arbitration runner: it runs once per spawn, before
18
+ * any operation exists to arbitrate, and there is nothing to sweep on behalf
19
+ * of. It uses the same immediate mode for the same reason — the read-then-
20
+ * write window above is exactly the shape §1.0a measures as failing when the
21
+ * transaction does not declare its intent to write.
22
+ *
23
+ * The transaction is issued here rather than through `immediate()` from
24
+ * `transaction.ts` only because that helper is asynchronous and a spawn's
25
+ * startup checks are a synchronous sequence; the mode is identical and the
26
+ * literal is imported from that module so the two cannot drift.
27
+ */
28
+ export function agreeOnTabBudget(db, tabs) {
29
+ const recorded = readAndRecord(db, tabs);
30
+ if (recorded.stored !== tabs) {
31
+ throw new StartupRefusal('budget.agrees_with_store', `This process is configured for a tab budget of ${String(tabs)} and the store records ${String(recorded.stored)}. ` +
32
+ 'Several processes arbitrate against this bound at the same moment, so two of them believing different numbers means each admits correctly against its own belief and the ceiling stops being one. ' +
33
+ 'Neither number is adopted and neither is overwritten: set BROKER_TAB_BUDGET to ' +
34
+ `${String(recorded.stored)} in this process's environment, or start against a different store.`);
35
+ }
36
+ return { tabs, recorded: recorded.wrote };
37
+ }
38
+ /**
39
+ * Read the recorded budget, writing this process's value if there is none.
40
+ *
41
+ * Returns what the store holds **after** this transaction, which is the value
42
+ * the caller above compares against — so a process that arrives second
43
+ * compares against the first's number rather than against its own.
44
+ *
45
+ * The insert tolerates a row appearing between the read and the write. That
46
+ * cannot happen inside an immediate transaction on this store, and the clause
47
+ * is there because the cost of being wrong about that is a spawn that fails
48
+ * with a constraint error naming nothing a person can act on, while the cost
49
+ * of the clause is one keyword. The re-read afterwards is what makes the
50
+ * tolerance safe: whatever ends up in the row is what gets compared.
51
+ */
52
+ function readAndRecord(db, tabs) {
53
+ db.prepare(BEGIN_STATEMENT).run();
54
+ try {
55
+ const before = db.prepare('SELECT tabs FROM tab_budget WHERE only_row = 1').get();
56
+ if (before !== undefined) {
57
+ db.prepare('COMMIT').run();
58
+ return { stored: before.tabs, wrote: false };
59
+ }
60
+ db.prepare('INSERT OR IGNORE INTO tab_budget (only_row, tabs) VALUES (1, ?)').run(tabs);
61
+ const after = db.prepare('SELECT tabs FROM tab_budget WHERE only_row = 1').get();
62
+ db.prepare('COMMIT').run();
63
+ return { stored: after.tabs, wrote: after.tabs === tabs };
64
+ }
65
+ catch (error) {
66
+ // ── Why the rollback is guarded and the original error is rethrown ────
67
+ //
68
+ // **Both success paths above have already issued their own `COMMIT`**, so
69
+ // for any failure at or after that point there is no transaction left to
70
+ // roll back, and the rollback statement itself throws `SQLITE_ERROR:
71
+ // cannot rollback - no transaction is active`. Left unguarded that error
72
+ // is the one the caller receives, and the failure that brought us into
73
+ // this catch is never reported at all.
74
+ //
75
+ // Measured: calling `prepareStore` with an environment lacking
76
+ // `tabBudget` surfaces "cannot rollback - no transaction is active",
77
+ // while a statement-level trace shows `ROLLBACK` is the only statement
78
+ // that throws — the actual cause being a `CHECK (tabs > 0)` violation
79
+ // from `schema/step-002-tab-budget.ts`, because `undefined` binds as
80
+ // NULL. `agreeOnTabBudget` runs on **every spawn**, so this sits on the
81
+ // startup path of every process.
82
+ //
83
+ // The rollback is still attempted, because the failure that lands here
84
+ // may well be one that left a transaction open, and leaving it open would
85
+ // hold a write lock every other process is waiting behind. It simply does
86
+ // so silently: a rollback that cannot happen has nothing to report, and
87
+ // the error worth reporting is already in hand.
88
+ // `tests/concurrency/worker-transaction-mode.mjs` carries the same
89
+ // reasoning at its own rollback.
90
+ try {
91
+ db.prepare('ROLLBACK').run();
92
+ }
93
+ catch {
94
+ // A transaction the engine already ended cannot be rolled back, and
95
+ // saying so would drown out the error that is actually worth reading.
96
+ }
97
+ throw error;
98
+ }
99
+ }
@@ -0,0 +1,42 @@
1
+ import { refuseNetworkLocation } from "./network-path.js";
2
+ /**
3
+ * Where the store file is.
4
+ *
5
+ * `store.location_from_environment_only` (`SCHEMA.md` §7.2): the location
6
+ * comes from the environment and is never read from the database. The reason
7
+ * is not a preference — **a value that is only readable after you have opened
8
+ * the file cannot tell you which file to open**, and a wrong value stored
9
+ * inside would be unfixable through the surface it broke (§6.1).
10
+ *
11
+ * That rule is held here structurally: this module imports the environment
12
+ * snapshot and the network-path refusal, and **nothing from `open.ts`**. It
13
+ * has no store client to read through, so there is no read path to police.
14
+ */
15
+ /**
16
+ * Resolve the store location, refusing a network one.
17
+ *
18
+ * **The checks run against both the value as it was configured and the value
19
+ * after resolution, and the first of those is not redundant.**
20
+ *
21
+ * Resolving a path applies the host platform's own rules, and those rules
22
+ * disagree about what a share even is: on a platform whose separator is the
23
+ * forward slash, the two-backslash spelling is not a root at all — it is an
24
+ * ordinary relative filename that happens to contain backslashes, so resolving
25
+ * it prefixes the working directory and the share root is gone. A check that
26
+ * only ever saw the resolved value would therefore refuse a share on one
27
+ * platform and silently create a bizarrely-named local file on another, from
28
+ * identical configuration.
29
+ *
30
+ * **A location that was not configured is not checked in its configured
31
+ * form**, because there is no configured form to check — the resolved value is
32
+ * a path this build computed from the platform's own application-data
33
+ * location, and it is checked on its own account below.
34
+ */
35
+ export function resolveStoreLocation(environment, checks) {
36
+ const location = environment.databasePath;
37
+ if (environment.configuredDatabasePath !== undefined) {
38
+ refuseNetworkLocation(environment.configuredDatabasePath, checks);
39
+ }
40
+ refuseNetworkLocation(location, checks);
41
+ return location;
42
+ }