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.
- package/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- 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
|
+
}
|