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,101 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ const INSERT = `
3
+ INSERT INTO comparisons (
4
+ id, source_capture_id, target_capture_id, claim_id,
5
+ colour_tolerance, minimum_region_area, maximum_regions,
6
+ changed_pixels, changed_ratio, changed, regions, overlay_path, truncated
7
+ ) VALUES (
8
+ @id, @sourceCaptureId, @targetCaptureId, @claimId,
9
+ @colourTolerance, @minimumRegionArea, @maximumRegions,
10
+ @changedPixels, @changedRatio, @changed, @regions, @overlayPath, @truncated
11
+ )
12
+ `;
13
+ /**
14
+ * Write one comparison and return its identifier.
15
+ *
16
+ * Synchronous, because the driver is, and because this is called from inside a
17
+ * transaction the caller opened — an `await` in there would let another
18
+ * operation interleave with a write that is meant to be one unit.
19
+ */
20
+ export function insertComparison(db, row) {
21
+ const id = randomUUID();
22
+ db.prepare(INSERT).run({
23
+ id,
24
+ sourceCaptureId: row.sourceCaptureId,
25
+ targetCaptureId: row.targetCaptureId,
26
+ claimId: row.claimId,
27
+ colourTolerance: row.colourTolerance,
28
+ minimumRegionArea: row.minimumRegionArea,
29
+ maximumRegions: row.maximumRegions,
30
+ changedPixels: row.changedPixels,
31
+ changedRatio: row.changedRatio,
32
+ // The column is an integer with a check constraint on nought-or-one, per
33
+ // the schema's own convention for a boolean.
34
+ changed: row.changed ? 1 : 0,
35
+ regions: JSON.stringify(row.regions),
36
+ overlayPath: row.overlayPath,
37
+ truncated: row.truncated ? 1 : 0,
38
+ });
39
+ return id;
40
+ }
41
+ function hydrate(columns) {
42
+ return {
43
+ id: columns.id,
44
+ sourceCaptureId: columns.source_capture_id,
45
+ targetCaptureId: columns.target_capture_id,
46
+ claimId: columns.claim_id,
47
+ at: columns.at,
48
+ colourTolerance: columns.colour_tolerance,
49
+ minimumRegionArea: columns.minimum_region_area,
50
+ maximumRegions: columns.maximum_regions,
51
+ changedPixels: columns.changed_pixels,
52
+ changedRatio: columns.changed_ratio,
53
+ changed: columns.changed === 1,
54
+ regions: JSON.parse(columns.regions),
55
+ overlayPath: columns.overlay_path,
56
+ truncated: columns.truncated === 1,
57
+ };
58
+ }
59
+ const SELECT = `
60
+ SELECT id, source_capture_id, target_capture_id, claim_id, at,
61
+ colour_tolerance, minimum_region_area, maximum_regions,
62
+ changed_pixels, changed_ratio, changed, regions, overlay_path, truncated
63
+ FROM comparisons
64
+ `;
65
+ /**
66
+ * List comparisons, most recent first.
67
+ *
68
+ * **Most recent first** because the reason this surface exists is tuning, and
69
+ * the question tuning asks is what the current numbers have been doing. The
70
+ * indexes on the table are `(source_capture_id, at DESC)` and
71
+ * `(target_capture_id)`, so the first ordering is the one already paid for.
72
+ */
73
+ export function listComparisons(db, query = {}) {
74
+ const conditions = [];
75
+ const parameters = {};
76
+ if (query.sourceCaptureId !== undefined) {
77
+ conditions.push('source_capture_id = @sourceCaptureId');
78
+ parameters['sourceCaptureId'] = query.sourceCaptureId;
79
+ }
80
+ if (query.targetCaptureId !== undefined) {
81
+ conditions.push('target_capture_id = @targetCaptureId');
82
+ parameters['targetCaptureId'] = query.targetCaptureId;
83
+ }
84
+ if (query.claimId !== undefined) {
85
+ conditions.push('claim_id = @claimId');
86
+ parameters['claimId'] = query.claimId;
87
+ }
88
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
89
+ // The limit is interpolated as a validated integer rather than bound,
90
+ // because the driver does not accept a parameter in this position. Coerced
91
+ // through `Math.trunc` and a floor of one so nothing but a number reaches
92
+ // the statement.
93
+ const limit = query.limit === undefined ? '' : ` LIMIT ${String(Math.max(1, Math.trunc(query.limit)))}`;
94
+ const rows = db.prepare(`${SELECT}${where} ORDER BY at DESC, id DESC${limit}`).all(parameters);
95
+ return rows.map(hydrate);
96
+ }
97
+ /** One comparison by identifier, or `null`. */
98
+ export function findComparison(db, id) {
99
+ const row = db.prepare(`${SELECT} WHERE id = @id`).get({ id });
100
+ return row === undefined ? null : hydrate(row);
101
+ }
@@ -0,0 +1,173 @@
1
+ import { fileNameFrom, overlayFileName, regionCropFileName } from "../diff/artifact-path.js";
2
+ import { cutRegionCrops, drawOverlay, paddedRectangle } from "../diff/crops.js";
3
+ import { reconcileGeometry } from "../diff/geometry.js";
4
+ import { decodePng, encodePng } from "../diff/image.js";
5
+ import { computeMask } from "../diff/mask.js";
6
+ import { extractRegions } from "../diff/regions.js";
7
+ /** A result carrying no diff, with the sentence saying why. */
8
+ function noDiff(settings, explanation, extra = {}) {
9
+ return {
10
+ diffed: false,
11
+ changed: false,
12
+ changedPixels: 0,
13
+ changedRatio: 0,
14
+ regions: [],
15
+ overlayPath: null,
16
+ truncated: false,
17
+ comparedAgainst: null,
18
+ settingsApplied: {
19
+ colourTolerance: settings.colourTolerance,
20
+ minimumRegionArea: settings.minimumRegionArea,
21
+ maximumRegions: settings.maximumRegions,
22
+ },
23
+ pageLengthChange: null,
24
+ widthMismatch: false,
25
+ explanation,
26
+ comparisonId: null,
27
+ ...extra,
28
+ };
29
+ }
30
+ /**
31
+ * Write a PNG into a lease's images directory and return its stored path.
32
+ *
33
+ * The store creates the directory, refuses a name that would land outside the
34
+ * root, and hands back the relative path — which is the only form that goes in
35
+ * a row (§1.7a).
36
+ */
37
+ function writeImage(artifacts, claimId, fileName, image) {
38
+ return artifacts.write(claimId, 'images', fileName, encodePng(image)).relativePath;
39
+ }
40
+ /**
41
+ * Run the comparison.
42
+ *
43
+ * The order of the checks is the order of §1.9's own reasoning, and each one
44
+ * returns a picture with an explanation rather than a refusal.
45
+ */
46
+ export async function runComparison(options) {
47
+ const { capture, targetCaptureId, source, settings, artifacts } = options;
48
+ // ── 1. The capture the caller named ───────────────────────────────────
49
+ const target = source.find(targetCaptureId);
50
+ if (target === null) {
51
+ return noDiff(settings, `No diff was produced: there is no capture with the identifier ${JSON.stringify(targetCaptureId)}, so there was nothing to compare against. The picture you asked for is above. Check the identifier — it is the one returned by the earlier capture you meant to compare with.`);
52
+ }
53
+ // ── 2. It has to belong to this lease ─────────────────────────────────
54
+ //
55
+ // §1.9 on the bytes surface: it "serves only artifacts belonging to the
56
+ // asking lease, checked the same way every other tab-addressed operation is
57
+ // checked, and refusing with the same non-disclosing wording as an unknown
58
+ // tab (§7.1) so probing cannot discover another lease's files".
59
+ //
60
+ // **The same non-disclosing wording is used here**, and that is the decision
61
+ // in this branch: a caller that named another lease's capture is told the
62
+ // identical sentence as a caller that named nothing at all. Distinguishing
63
+ // them would turn `diff_against` into a way to enumerate other leases'
64
+ // captures by watching which identifiers produce a different message.
65
+ if (target.claimId !== capture.claimId) {
66
+ return noDiff(settings, `No diff was produced: there is no capture with the identifier ${JSON.stringify(targetCaptureId)}, so there was nothing to compare against. The picture you asked for is above. Check the identifier — it is the one returned by the earlier capture you meant to compare with.`);
67
+ }
68
+ const comparedAgainst = { captureId: target.id, path: target.path };
69
+ // ── 3. Both files have to be readable and decodable ───────────────────
70
+ let earlier;
71
+ let current;
72
+ try {
73
+ earlier = decodePng(await source.readBytes(target));
74
+ current = decodePng(options.captureBytes);
75
+ }
76
+ catch (error) {
77
+ return noDiff(settings, `No diff was produced: the image for capture ${JSON.stringify(targetCaptureId)} could not be read (${error instanceof Error ? error.message : String(error)}). The picture you asked for is above.`, { comparedAgainst });
78
+ }
79
+ // ── 4. Geometry, reported rather than pre-empted (#40) ────────────────
80
+ const geometry = reconcileGeometry({ width: earlier.width, height: earlier.height }, { width: current.width, height: current.height }, capture.kind);
81
+ if (!geometry.comparable || geometry.width === null || geometry.comparableHeight === null) {
82
+ return noDiff(settings, geometry.explanation ?? 'No diff was produced.', {
83
+ comparedAgainst,
84
+ widthMismatch: geometry.widthMismatch,
85
+ pageLengthChange: geometry.pageLengthChange,
86
+ });
87
+ }
88
+ // ── 5. The comparison itself ──────────────────────────────────────────
89
+ const mask = computeMask(earlier, current, {
90
+ colourTolerance: settings.colourTolerance,
91
+ height: geometry.comparableHeight,
92
+ });
93
+ const allRegions = extractRegions(mask, {
94
+ mergeDistance: settings.regionMergeDistance,
95
+ minimumArea: settings.minimumRegionArea,
96
+ });
97
+ // The cap bites here, and only here. The list is ordered largest first, so
98
+ // taking the first N drops the smallest — which is what §1.9 promises, and
99
+ // `truncated` is what stops the shortened list being "a lie about
100
+ // completeness".
101
+ const truncated = allRegions.length > settings.maximumRegions;
102
+ const kept = truncated ? allRegions.slice(0, settings.maximumRegions) : allRegions;
103
+ // ── 6. The crops and the overlay (#42) ────────────────────────────────
104
+ const captureFileName = fileNameFrom(capture.path);
105
+ const regions = [];
106
+ for (const [index, region] of kept.entries()) {
107
+ // One rectangle, padded and clamped once, both crops cut from it. The
108
+ // clamp is against the *compared* geometry rather than either image's own,
109
+ // so the rectangle is guaranteed to fit both — which is what makes "from
110
+ // the same rectangle" true on a full page whose height changed.
111
+ const rectangle = paddedRectangle(region, settings.cropPadding, geometry.width, geometry.comparableHeight);
112
+ if (rectangle === null) {
113
+ continue;
114
+ }
115
+ const crops = cutRegionCrops(earlier, current, rectangle);
116
+ const beforePath = writeImage(artifacts, capture.claimId, regionCropFileName(captureFileName, index, 'before'), crops.before);
117
+ const afterPath = writeImage(artifacts, capture.claimId, regionCropFileName(captureFileName, index, 'after'), crops.after);
118
+ regions.push({
119
+ x: region.x,
120
+ y: region.y,
121
+ width: region.width,
122
+ height: region.height,
123
+ changedPixels: region.changedPixels,
124
+ beforePath,
125
+ afterPath,
126
+ });
127
+ }
128
+ const storedOverlayPath = writeImage(artifacts, capture.claimId, overlayFileName(captureFileName), drawOverlay(current, kept));
129
+ // ── 7. The row (§1.9) ─────────────────────────────────────────────────
130
+ //
131
+ // `changed` is written rather than derived, because §1.9 fixes its
132
+ // definition — "true when at least one region survives filtering, not when
133
+ // any pixel differs" — and it "has to have one answer rather than two".
134
+ const comparedPixels = geometry.width * geometry.comparableHeight;
135
+ const changedRatio = comparedPixels === 0 ? 0 : mask.changedPixels / comparedPixels;
136
+ const changed = regions.length > 0;
137
+ const comparisonId = options.writeRow({
138
+ sourceCaptureId: capture.id,
139
+ targetCaptureId: target.id,
140
+ claimId: capture.claimId,
141
+ colourTolerance: settings.colourTolerance,
142
+ minimumRegionArea: settings.minimumRegionArea,
143
+ maximumRegions: settings.maximumRegions,
144
+ changedPixels: mask.changedPixels,
145
+ changedRatio,
146
+ changed,
147
+ regions,
148
+ overlayPath: storedOverlayPath,
149
+ truncated,
150
+ });
151
+ return {
152
+ diffed: true,
153
+ changed,
154
+ changedPixels: mask.changedPixels,
155
+ changedRatio,
156
+ regions,
157
+ overlayPath: storedOverlayPath,
158
+ truncated,
159
+ comparedAgainst,
160
+ settingsApplied: {
161
+ colourTolerance: settings.colourTolerance,
162
+ minimumRegionArea: settings.minimumRegionArea,
163
+ maximumRegions: settings.maximumRegions,
164
+ },
165
+ pageLengthChange: geometry.pageLengthChange,
166
+ widthMismatch: false,
167
+ // Present on an ordinary comparison only when the page length changed —
168
+ // the one comparable case that still needs a sentence, because a length
169
+ // change the caller cannot see is one it will attribute to something else.
170
+ explanation: geometry.explanation,
171
+ comparisonId,
172
+ };
173
+ }
@@ -0,0 +1,93 @@
1
+ /** The kinds as data, for the test that reconciles them with the store. */
2
+ export const EVENT_KINDS = [
3
+ 'claim_requested',
4
+ 'claim_granted',
5
+ 'claim_queued',
6
+ 'claim_promoted',
7
+ 'claim_renewed',
8
+ 'claim_released',
9
+ 'claim_expired',
10
+ 'claim_revoked',
11
+ 'tab_opening',
12
+ 'tab_open_failed',
13
+ 'tab_closing',
14
+ 'navigate',
15
+ 'act',
16
+ 'read',
17
+ 'evaluate',
18
+ 'capture',
19
+ 'compare',
20
+ 'browser_launched',
21
+ 'browser_adopted',
22
+ 'browser_exited',
23
+ 'launch_race_lost',
24
+ 'sweep',
25
+ 'storage_seeded',
26
+ 'browser_signin_began',
27
+ 'browser_signin_ended',
28
+ ];
29
+ /** The adapters as data, for the same reconciliation test. */
30
+ export const EVENT_ADAPTERS = [
31
+ 'tool-stdio',
32
+ 'tool-http',
33
+ 'cli',
34
+ 'internal',
35
+ ];
36
+ const INSERT = `
37
+ INSERT INTO events (kind, outcome, guard, claim_id, tab_id, session_id, adapter, browser_id, detail)
38
+ VALUES (@kind, @outcome, @guard, @claimId, @tabId, @sessionId, @adapter, @browserId, @detail)
39
+ `;
40
+ /**
41
+ * Append one row and return its identifier.
42
+ *
43
+ * **`at` is not a parameter and is never supplied by a caller.** It is left
44
+ * to the column default, which reads the database's own clock (§1.1): several
45
+ * processes are running by design (§1.0a), and two of them disagreeing by a
46
+ * second would put the ledger subtly out of order in a way nothing reproduces
47
+ * — the cursor would still count upward, so the disorder would be invisible
48
+ * to every query that reads it.
49
+ *
50
+ * §2.4a is the related fact worth not confusing with this one: for an expiry,
51
+ * `at` is when the sweep ran and `claims.expired_at` is when the lease
52
+ * actually lapsed. Two facts, two columns, and only one of them is about the
53
+ * lease.
54
+ */
55
+ export function append(db, event) {
56
+ const result = db.prepare(INSERT).run({
57
+ kind: event.kind,
58
+ outcome: event.outcome,
59
+ guard: event.guard ?? null,
60
+ claimId: event.claimId ?? null,
61
+ tabId: event.tabId ?? null,
62
+ sessionId: event.sessionId ?? null,
63
+ adapter: event.adapter,
64
+ browserId: event.browserId ?? null,
65
+ detail: event.detail == null ? null : JSON.stringify(event.detail),
66
+ });
67
+ return Number(result.lastInsertRowid);
68
+ }
69
+ const SELECT_SINCE = `
70
+ SELECT id, at, kind, outcome, guard,
71
+ claim_id AS claimId, tab_id AS tabId, session_id AS sessionId,
72
+ adapter, browser_id AS browserId, detail
73
+ FROM events
74
+ WHERE id > @after
75
+ ORDER BY id
76
+ LIMIT @limit
77
+ `;
78
+ /**
79
+ * Read a slice, oldest first, starting after a cursor.
80
+ *
81
+ * §1.6: "events are meant to be looked at, not merely written". The read is
82
+ * here rather than in whatever eventually displays it because the ledger is
83
+ * one stream with one cursor, and a second reader written against the table
84
+ * directly is a second place the ordering rule can be got wrong.
85
+ *
86
+ * **Ordered by `id`, not by `at`.** Two rows written in the same millisecond
87
+ * share a timestamp, and ordering by a column with ties makes a page over the
88
+ * ledger able to skip or repeat a row at the boundary. The counter has no
89
+ * ties by construction, which is why §1.6 makes it the cursor.
90
+ */
91
+ export function readSince(db, after, limit) {
92
+ return db.prepare(SELECT_SINCE).all({ after, limit });
93
+ }
@@ -0,0 +1,68 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ /**
3
+ * The lease key: minted once, returned once, and never stored.
4
+ *
5
+ * §1.3 states the property this module exists to keep: **the secret key is
6
+ * never stored** — the claims table holds a one-way hash of it, and every
7
+ * call that carries a key hashes what it was handed and looks the lease up by
8
+ * that value.
9
+ *
10
+ * ── The consequence, which is a price rather than an oversight ──────────
11
+ *
12
+ * §2.2: **a caller that loses the response to its own request cannot get that
13
+ * lease back.** It waits the lease out, or asks an operator to revoke it.
14
+ * There is no recovery path and there is deliberately no second copy to build
15
+ * one from — a stored key would be a credential this service holds on a
16
+ * caller's behalf, and the whole reason it does not is that it then cannot
17
+ * leak one.
18
+ *
19
+ * ── Why a plain hash and not a password-hashing function ────────────────
20
+ *
21
+ * The usual reason to make hashing slow is that the input is a human-chosen
22
+ * password with little entropy, so an attacker who takes the table can guess
23
+ * candidates faster than they can be checked. **Neither half applies here.**
24
+ * The input is {@link KEY_BYTES} bytes from the platform's own random source,
25
+ * so there is no candidate list to work through; and the hash is read inside
26
+ * the arbitration transaction, with every other caller on the machine
27
+ * serialised behind it (§1.0a), so a deliberately slow function would be a
28
+ * deliberately slow lock.
29
+ *
30
+ * **What that trade rests on is the entropy**, which is why it is a constant
31
+ * with a comment rather than a number somebody can lower to make a key
32
+ * prettier.
33
+ */
34
+ /**
35
+ * How many random bytes a key carries.
36
+ *
37
+ * Thirty-two bytes from the platform's cryptographic source. This is the
38
+ * assumption the fast hash above rests on: lower it far enough and guessing
39
+ * becomes possible, at which point the reasoning for a plain hash stops
40
+ * holding and the change that broke it is one number in this file.
41
+ */
42
+ export const KEY_BYTES = 32;
43
+ /**
44
+ * Mint a key. The only place one is created.
45
+ *
46
+ * The value is a base64url string so it survives every transport this service
47
+ * has — a command line argument, an environment variable, a field in a
48
+ * structured message — without escaping, and so a caller copying one out of a
49
+ * terminal cannot pick up a character that was really formatting.
50
+ */
51
+ export function mintKey() {
52
+ return randomBytes(KEY_BYTES).toString('base64url');
53
+ }
54
+ /**
55
+ * Hash a key for storage and for lookup.
56
+ *
57
+ * Unsalted, deliberately, and this is the one place that word should appear
58
+ * in this service. A salt exists to stop one precomputed table covering every
59
+ * row at once, which is a defence against low-entropy inputs; with a
60
+ * {@link KEY_BYTES}-byte random input there is nothing to precompute. And a
61
+ * per-row salt would make lookup impossible: finding the lease for a key
62
+ * means hashing the key and matching a column, and a salted scheme would have
63
+ * to read every row and hash against each one — inside the transaction every
64
+ * other caller is waiting behind.
65
+ */
66
+ export function hashKey(key) {
67
+ return createHash('sha256').update(key, 'utf8').digest('hex');
68
+ }
@@ -0,0 +1,147 @@
1
+ import { append } from "./events.js";
2
+ import { hashKey } from "./keys.js";
3
+ import { CallRefusal } from "./refusals.js";
4
+ /**
5
+ * Find the lease a key names, refusing when it does not name a live one.
6
+ *
7
+ * **Two refusals, deliberately separate** (§7.1). A key this store has never
8
+ * seen is `key.valid`; a key naming a lease that ended is `claim.live`, and
9
+ * that one **names the state and when** (§2.2) — a caller told only "no"
10
+ * cannot tell a revoke it should escalate from an expiry it should simply
11
+ * retry with a fresh lease.
12
+ *
13
+ * **It must be called inside the arbitration transaction, after the sweep.**
14
+ * Reading `state` directly is correct only there: outside it, a row saying
15
+ * `active` whose expiry has elapsed is not an active lease, it is one that
16
+ * lapsed and has not been swept yet (§2.4). The sweep has already run by the
17
+ * time any handler executes, so a lease this returns as live is one this call
18
+ * reconciled.
19
+ */
20
+ export function resolveLease(db, key, options) {
21
+ const row = db
22
+ .prepare(`SELECT id AS claimId, session_id AS sessionId, browser_id AS browserId, state,
23
+ purpose, expires_at AS expiresAt, ttl_seconds AS ttlSeconds,
24
+ renew_count AS renewCount, ended_at AS endedAt, revoke_reason AS revokeReason
25
+ FROM claims
26
+ WHERE key_hash = @keyHash`)
27
+ .get({ keyHash: hashKey(key) });
28
+ if (row === undefined) {
29
+ options.recordRefusal({
30
+ kind: options.kind,
31
+ outcome: 'deny',
32
+ guard: 'key.valid',
33
+ adapter: options.adapter,
34
+ });
35
+ throw new CallRefusal('unrecognised_key', 'That key does not match any lease in this store. A key is returned once and is never recoverable, so a lost key cannot be looked up — wait for the lease to lapse, or ask an operator to revoke it.');
36
+ }
37
+ if (row.state !== 'queued' && row.state !== 'active') {
38
+ // Everything that is not live is final (§2.1), so this branch covers the
39
+ // three ended states and nothing else.
40
+ options.recordRefusal({
41
+ kind: options.kind,
42
+ outcome: 'deny',
43
+ guard: 'claim.live',
44
+ adapter: options.adapter,
45
+ claimId: row.claimId,
46
+ sessionId: row.sessionId,
47
+ browserId: row.browserId,
48
+ detail: { state: row.state, endedAt: row.endedAt },
49
+ });
50
+ throw new CallRefusal('lease_ended', endedSentence(row), {
51
+ detail: {
52
+ claimId: row.claimId,
53
+ state: row.state,
54
+ endedAt: row.endedAt,
55
+ ...(row.revokeReason === null ? {} : { revokeReason: row.revokeReason }),
56
+ },
57
+ });
58
+ }
59
+ // The two refusals above have eliminated every other value, so this is the
60
+ // narrowing the checks already performed, restated for the type system.
61
+ return { ...row, state: row.state === 'queued' ? 'queued' : 'active' };
62
+ }
63
+ /**
64
+ * The sentence a caller gets for a lease that has ended.
65
+ *
66
+ * **It names the state and when** (§2.2, §7.1 `claim.live`), because the
67
+ * three ended states call for three different responses: an expiry is retried
68
+ * with a fresh request, a release was the caller's own doing and usually
69
+ * means two code paths tidying up, and a revoke is an operator taking
70
+ * capacity back with a reason the caller is owed. A refusal that said only
71
+ * "that lease is not usable" would send all three to the same wrong place.
72
+ */
73
+ function endedSentence(row) {
74
+ const when = row.endedAt === null ? 'at an unrecorded moment' : `at ${row.endedAt}`;
75
+ if (row.state === 'revoked') {
76
+ return `That lease was revoked ${when}: ${row.revokeReason ?? 'no reason was recorded'}. An operator took the capacity back; a fresh request is the way on.`;
77
+ }
78
+ if (row.state === 'expired') {
79
+ return `That lease expired ${when} — nobody called in before it lapsed, so its tab is gone. Ask for a fresh lease; there is no way to revive this one.`;
80
+ }
81
+ return `That lease was released ${when}, so its tab is closed. Releasing is final; ask for a fresh lease to work again.`;
82
+ }
83
+ /**
84
+ * Extend a lease by the duration in force for it.
85
+ *
86
+ * **This is row #14, and it is a function every keyed operation calls rather
87
+ * than an operation of its own.** `MILESTONES.md` #14: a dedicated verb would
88
+ * be a second name for an effect every call already has.
89
+ *
90
+ * ── The duration comes off the row, not out of the environment ──────────
91
+ *
92
+ * §1.3 is explicit, and the reasoning is the part worth keeping: `ttl_seconds`
93
+ * is "the duration in force for this lease, fixed when it entered its current
94
+ * state. Stored rather than read from settings on each renewal, **because a
95
+ * renewal has to extend by the duration the caller was told** — re-reading a
96
+ * setting mid-lease silently changes a promise the caller has already acted
97
+ * on."
98
+ *
99
+ * So a caller told ten minutes keeps getting ten minutes even after the
100
+ * environment moves, and the new number reaches it on its next lease.
101
+ *
102
+ * **The extension runs from the moment of the call.** A lease renewed with
103
+ * two minutes left gets a full duration from now rather than a duration
104
+ * stacked on the remainder — the promise is *keep calling in or lose
105
+ * it*, not *call in to accumulate*, and stacking would let a caller poll its
106
+ * way to an unbounded hold.
107
+ */
108
+ export function extendLease(db, lease, options) {
109
+ const row = db
110
+ .prepare(`UPDATE claims
111
+ SET expires_at = strftime('%Y-%m-%dT%H:%M:%fZ', @now, @extend),
112
+ renew_count = renew_count + 1,
113
+ updated_at = @now
114
+ WHERE id = @id AND state IN ('queued', 'active')
115
+ RETURNING expires_at AS expiresAt, renew_count AS renewCount`)
116
+ .get({
117
+ id: lease.claimId,
118
+ now: options.now,
119
+ // Assembled from the row's own duration, which is a number this store
120
+ // wrote. Nothing a caller supplied reaches this modifier.
121
+ extend: `+${String(lease.ttlSeconds)} seconds`,
122
+ });
123
+ if (row === undefined) {
124
+ // The lease was live when it was resolved, moments ago, in this same
125
+ // transaction. Nothing else can have ended it, so this is a bug rather
126
+ // than a race — and returning an unchanged expiry would quietly report a
127
+ // renewal that did not happen.
128
+ throw new Error(`The lease ${lease.claimId} was live when it was resolved and is not live now, inside one transaction.`);
129
+ }
130
+ append(db, {
131
+ kind: 'claim_renewed',
132
+ outcome: 'allow',
133
+ adapter: options.adapter,
134
+ claimId: lease.claimId,
135
+ sessionId: lease.sessionId,
136
+ browserId: lease.browserId,
137
+ detail: {
138
+ expiresAt: row.expiresAt,
139
+ ttlSeconds: lease.ttlSeconds,
140
+ renewCount: row.renewCount,
141
+ // What distinguishes a caller doing work from one polling to hold
142
+ // capacity it is not using (§1.3). Nothing acts on it in this version.
143
+ state: lease.state,
144
+ },
145
+ });
146
+ return row.expiresAt;
147
+ }
@@ -0,0 +1,66 @@
1
+ import { append } from "./events.js";
2
+ /**
3
+ * The leases a session already holds, other than the one being decided.
4
+ *
5
+ * **One comparison over data already in hand** (§2.3a): the admission
6
+ * transaction already counts live claims and already knows the asking
7
+ * session. No new table, no new state, no second query outside the
8
+ * transaction — this is a filter, and it reads the index §1.11 keeps for
9
+ * exactly this query.
10
+ *
11
+ * `state` is read directly, which is correct **only** inside the arbitration
12
+ * transaction after the sweep (§2.4). A lease this reports as held is one the
13
+ * same call has just reconciled.
14
+ */
15
+ export function liveLeasesOfSession(db, sessionId, excludingClaimId) {
16
+ return db
17
+ .prepare(`SELECT id AS claimId, state, purpose, browser_id AS browserId, expires_at AS expiresAt
18
+ FROM claims
19
+ WHERE session_id = @sessionId
20
+ AND state IN ('queued', 'active')
21
+ AND (@excluding IS NULL OR id <> @excluding)
22
+ ORDER BY created_at, id`)
23
+ .all({ sessionId, excluding: excludingClaimId ?? null });
24
+ }
25
+ /**
26
+ * Build the nudge, and record that it fired.
27
+ *
28
+ * Returns `undefined` when the session holds nothing — which is the ordinary
29
+ * case, and the case where the advice would be actively wrong: telling a
30
+ * caller holding nothing to start with what it holds is telling it to do
31
+ * nothing.
32
+ *
33
+ * **The ledger row is written here rather than by the caller**, so a surface
34
+ * cannot attach the advice and forget the evidence. The row is an `allow`,
35
+ * because the nudge is not itself a refusal — whatever refused or queued the
36
+ * claim wrote its own row, and this one records that the caller was told it
37
+ * was its own obstacle.
38
+ */
39
+ export function nudgeIfOwnObstacle(db, options) {
40
+ const holding = liveLeasesOfSession(db, options.sessionId, options.claimId);
41
+ if (holding.length === 0) {
42
+ return undefined;
43
+ }
44
+ const active = holding.filter((lease) => lease.state === 'active').length;
45
+ append(db, {
46
+ kind: 'claim_queued',
47
+ outcome: 'allow',
48
+ adapter: options.adapter,
49
+ claimId: options.claimId,
50
+ sessionId: options.sessionId,
51
+ detail: {
52
+ nudge: 'own_obstacle',
53
+ outcome: options.outcome,
54
+ holding: holding.length,
55
+ holdingActive: active,
56
+ heldClaimIds: holding.map((lease) => lease.claimId),
57
+ },
58
+ });
59
+ return {
60
+ holding,
61
+ advice: `This session already holds ${String(holding.length)} live lease${holding.length === 1 ? '' : 's'}` +
62
+ `${active > 0 ? ` (${String(active)} holding a tab)` : ''}, so some of the capacity being waited for is capacity being held. ` +
63
+ 'Start with what is already held: finishing work on a held tab frees capacity this same session can reuse, which unblocks by working rather than by waiting. ' +
64
+ 'If the work genuinely cannot be serialised, release what is held and ask again.',
65
+ };
66
+ }