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,577 @@
|
|
|
1
|
+
import { BrokerError } from "../errors.js";
|
|
2
|
+
import { recordFeedback, refuseSubmission, isFeedbackCategory, } from "../feedback/record.js";
|
|
3
|
+
import { hashKey } from "./keys.js";
|
|
4
|
+
import { CallRefusal } from "./refusals.js";
|
|
5
|
+
/**
|
|
6
|
+
* The join between the service and the routes: a {@link Broker} presented as
|
|
7
|
+
* the one-method {@link BrokerService} every adapter calls.
|
|
8
|
+
*
|
|
9
|
+
* ── Why this file has to exist at all ───────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* The two shapes are deliberately different and neither is wrong.
|
|
12
|
+
* {@link Broker} is ten typed methods because an internal caller should not be
|
|
13
|
+
* able to pass a claim's arguments to a release. {@link BrokerService} is one
|
|
14
|
+
* method over an opaque record because a *route* must not be able to compose
|
|
15
|
+
* two service calls and present the result as one operation — the seam the
|
|
16
|
+
* `service-seam.ts` header describes as "the seam through which a route grows
|
|
17
|
+
* its own rules". Bridging them is therefore translation work with a home of
|
|
18
|
+
* its own, rather than a cast either side could have avoided.
|
|
19
|
+
*
|
|
20
|
+
* ── What this file may decide, which is nothing ─────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* `CLAUDE.md`: **every adapter is a thin shell over a service call**, and no
|
|
23
|
+
* adapter may reach the database or a guard directly. This sits below the
|
|
24
|
+
* adapters and above the service, and it holds to the same rule: it shapes
|
|
25
|
+
* arguments and it names one operation. Every question of whether an
|
|
26
|
+
* operation is *allowed* — is the key real, is the lease live, is the tab
|
|
27
|
+
* this lease's, is there capacity — is decided inside the arbitration
|
|
28
|
+
* transaction, after this function has handed off, and this file re-checks
|
|
29
|
+
* none of it.
|
|
30
|
+
*
|
|
31
|
+
* The one place that claim needs defending is {@link tabForKey}; its own
|
|
32
|
+
* comment does the defending.
|
|
33
|
+
*/
|
|
34
|
+
/** Read an argument under either the surface spelling or the service one. */
|
|
35
|
+
function argument(args, ...names) {
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
const value = args[name];
|
|
38
|
+
if (value !== undefined) {
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The lease key, as both surfaces spell it.
|
|
46
|
+
*
|
|
47
|
+
* The tool surface names it `lease_key` and the command line takes
|
|
48
|
+
* `--lease-key`, which `parseArguments` normalises to the same word. `key` is
|
|
49
|
+
* accepted too because it is the name the service's own inputs use, and a
|
|
50
|
+
* caller driving the dispatcher in process writes the service's spelling.
|
|
51
|
+
*/
|
|
52
|
+
function keyFrom(args) {
|
|
53
|
+
const value = argument(args, 'lease_key', 'leaseKey', 'key', 'lease');
|
|
54
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
55
|
+
throw new CallRefusal('key_missing', 'This operation carries your lease key, written out on the call. It was returned once by the claim that granted the lease and is not recoverable from anywhere else.');
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The tab this lease holds.
|
|
61
|
+
*
|
|
62
|
+
* ── Why the tab is looked up rather than taken from the caller ──────────
|
|
63
|
+
*
|
|
64
|
+
* `SCHEMA.md` §2.3: **a lease is one tab**, and §3.4 states the consequence
|
|
65
|
+
* outright — "there is no `tabs` argument, and its absence is the model...
|
|
66
|
+
* nothing that takes a list of tabs, not as a restriction but because there
|
|
67
|
+
* was never more than one to list". So no surface offers a tab argument and
|
|
68
|
+
* none should; the tab is a fact about the lease, and the caller naming it
|
|
69
|
+
* again could only ever be a way to name a different one.
|
|
70
|
+
*
|
|
71
|
+
* The service's inputs still carry `tabId` because the service is addressed
|
|
72
|
+
* by tab, so somebody has to turn the one into the other. That is this
|
|
73
|
+
* function, and it is a **read taken to shape an input**, not a decision:
|
|
74
|
+
*
|
|
75
|
+
* - It authorises nothing. `resolveOwnedTabOrRefuse` re-resolves the tab inside the
|
|
76
|
+
* arbitration transaction and refuses with `tab.owned` if it does not
|
|
77
|
+
* belong to the lease the key names. A wrong answer here is caught there.
|
|
78
|
+
* - It is not the read the reader rule (§2.4, §5.2) forbids. That rule exists
|
|
79
|
+
* because liveness is *derived* rather than stored, so a route printing
|
|
80
|
+
* `state` from a table would report leases that do not exist. Nothing
|
|
81
|
+
* derived is read here and nothing is reported to the caller from it — the
|
|
82
|
+
* value goes into the service call and the service decides.
|
|
83
|
+
* - It cannot widen access. It selects only tabs whose `claim_id` is the
|
|
84
|
+
* claim the presented key hashes to, so a caller without the key resolves
|
|
85
|
+
* nothing.
|
|
86
|
+
*
|
|
87
|
+
* A key matching no claim resolves nothing, and the empty string is passed
|
|
88
|
+
* through so that the transaction produces the ordinary `unrecognised_key`
|
|
89
|
+
* refusal from `resolveLease` — refusing here would answer a different
|
|
90
|
+
* question in a different order, and §3.14's ordering is a property callers
|
|
91
|
+
* branch on.
|
|
92
|
+
*/
|
|
93
|
+
function tabForKey(db, key) {
|
|
94
|
+
const row = db
|
|
95
|
+
.prepare(`SELECT t.id AS tabId
|
|
96
|
+
FROM tabs t
|
|
97
|
+
JOIN claims c ON c.id = t.claim_id
|
|
98
|
+
WHERE c.key_hash = @keyHash
|
|
99
|
+
AND t.state IN ('opening', 'open')
|
|
100
|
+
ORDER BY t.created_at DESC
|
|
101
|
+
LIMIT 1`)
|
|
102
|
+
.get({ keyHash: hashKey(key) });
|
|
103
|
+
return row?.tabId ?? '';
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Turn a thrown refusal into the outcome shape a route returns.
|
|
107
|
+
*
|
|
108
|
+
* Only {@link BrokerError} is caught. Anything else is a fault rather than a
|
|
109
|
+
* decision, and swallowing it here would turn a broken build into a refusal
|
|
110
|
+
* a caller would retry forever.
|
|
111
|
+
*/
|
|
112
|
+
function refusalFrom(error) {
|
|
113
|
+
const detail = error instanceof CallRefusal ? error.detail : undefined;
|
|
114
|
+
return {
|
|
115
|
+
outcome: 'refused',
|
|
116
|
+
code: error instanceof CallRefusal ? error.code : error.rule,
|
|
117
|
+
rule: error.rule,
|
|
118
|
+
message: error.message,
|
|
119
|
+
...(detail === undefined || Object.keys(detail).length === 0 ? {} : { details: detail }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Present a {@link Broker} as the {@link BrokerService} the routes call.
|
|
124
|
+
*
|
|
125
|
+
* Every branch below is one broker method with its arguments shaped, and the
|
|
126
|
+
* shaping is the whole of what happens between the route and the service.
|
|
127
|
+
*/
|
|
128
|
+
export function serviceFor(options) {
|
|
129
|
+
const { broker, db } = options;
|
|
130
|
+
const perform = async (request) => {
|
|
131
|
+
try {
|
|
132
|
+
const value = await dispatch(request);
|
|
133
|
+
return { outcome: 'accepted', value };
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof BrokerError) {
|
|
137
|
+
return refusalFrom(error);
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
/** One request, one service call. The `switch` is exhaustive by type. */
|
|
143
|
+
const dispatch = async (request) => {
|
|
144
|
+
const args = request.arguments;
|
|
145
|
+
const operation = request.operation;
|
|
146
|
+
switch (operation) {
|
|
147
|
+
case 'claim': {
|
|
148
|
+
// **Omitted, not empty.** `browser` is optional (§3.2): unstated
|
|
149
|
+
// resolves to the first signed-in browser, which the tool description
|
|
150
|
+
// promises is "what most work wants". Coercing an absent argument to
|
|
151
|
+
// `''` turned that documented default into `claim.browser_known`
|
|
152
|
+
// refusing a browser named `""` — on the very first call a new caller
|
|
153
|
+
// makes, and for an argument they were told they could leave out.
|
|
154
|
+
// Keeping absence absent is what makes the resolution the service
|
|
155
|
+
// already implements reachable.
|
|
156
|
+
const browser = asOptionalString(argument(args, 'browser'));
|
|
157
|
+
const result = await broker.claim({
|
|
158
|
+
sessionId: asString(argument(args, 'session_id', 'sessionId')),
|
|
159
|
+
...(browser === undefined ? {} : { browser }),
|
|
160
|
+
purpose: asString(argument(args, 'purpose')),
|
|
161
|
+
...(argument(args, 'storage_seed', 'storageSeed') === undefined
|
|
162
|
+
? {}
|
|
163
|
+
: { storageSeed: argument(args, 'storage_seed', 'storageSeed') }),
|
|
164
|
+
});
|
|
165
|
+
return { ...result };
|
|
166
|
+
}
|
|
167
|
+
case 'status':
|
|
168
|
+
return { ...(await broker.status({ key: keyFrom(args) })) };
|
|
169
|
+
case 'release':
|
|
170
|
+
return { ...(await broker.release({ key: keyFrom(args) })) };
|
|
171
|
+
case 'navigate': {
|
|
172
|
+
const key = keyFrom(args);
|
|
173
|
+
return {
|
|
174
|
+
...(await broker.navigate({
|
|
175
|
+
key,
|
|
176
|
+
tabId: tabForKey(db, key),
|
|
177
|
+
url: argument(args, 'url'),
|
|
178
|
+
})),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
case 'act': {
|
|
182
|
+
const key = keyFrom(args);
|
|
183
|
+
return {
|
|
184
|
+
...(await broker.act({ key, tabId: tabForKey(db, key), request: actionFrom(args) })),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
case 'read': {
|
|
188
|
+
const key = keyFrom(args);
|
|
189
|
+
return {
|
|
190
|
+
...(await broker.read({
|
|
191
|
+
key,
|
|
192
|
+
tabId: tabForKey(db, key),
|
|
193
|
+
...(argument(args, 'what', 'artifacts') === undefined
|
|
194
|
+
? {}
|
|
195
|
+
: { artifacts: artifactsFrom(argument(args, 'what', 'artifacts')) }),
|
|
196
|
+
})),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
case 'evaluate': {
|
|
200
|
+
const key = keyFrom(args);
|
|
201
|
+
return {
|
|
202
|
+
...(await broker.evaluate({
|
|
203
|
+
key,
|
|
204
|
+
tabId: tabForKey(db, key),
|
|
205
|
+
expression: argument(args, 'expression'),
|
|
206
|
+
})),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
case 'capture': {
|
|
210
|
+
const key = keyFrom(args);
|
|
211
|
+
const fullPage = argument(args, 'full_page', 'fullPage');
|
|
212
|
+
const selector = argument(args, 'selector');
|
|
213
|
+
// The tool surface spells it `compare_to` and the command line
|
|
214
|
+
// `--compare-to`, which `parseArguments` normalises to `compare_to`
|
|
215
|
+
// by turning dashes into underscores — so both surfaces arrive at
|
|
216
|
+
// the same key and the command line needs no entry of its own. The
|
|
217
|
+
// camel spelling is accepted too, for a caller driving the dispatcher
|
|
218
|
+
// in process with the service's own vocabulary.
|
|
219
|
+
// Read here, beside the two arguments that were already
|
|
220
|
+
// being carried, because a surface that declares an argument and
|
|
221
|
+
// drops it is worse than one that never offered it: the caller is
|
|
222
|
+
// told the diff is available, passes it, and gets a capture with no
|
|
223
|
+
// comparison and nothing saying why.
|
|
224
|
+
const compareTo = argument(args, 'compare_to', 'compareTo');
|
|
225
|
+
return {
|
|
226
|
+
...(await broker.capture({
|
|
227
|
+
key,
|
|
228
|
+
tabId: tabForKey(db, key),
|
|
229
|
+
...(fullPage === undefined ? {} : { fullPage: asBoolean(fullPage) }),
|
|
230
|
+
...(typeof selector === 'string' ? { selector } : {}),
|
|
231
|
+
...(typeof compareTo === 'string' && compareTo.length > 0 ? { compareTo } : {}),
|
|
232
|
+
})),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
case 'tab_replace': {
|
|
236
|
+
const key = keyFrom(args);
|
|
237
|
+
return { ...(await broker.tab_replace({ key, tabId: tabForKey(db, key) })) };
|
|
238
|
+
}
|
|
239
|
+
case 'sign_in': {
|
|
240
|
+
// **The tab is not an argument and must never become one.** A lease is
|
|
241
|
+
// one tab (§2.3), so the operation resolves it from the lease itself —
|
|
242
|
+
// the same rule `tabForKey` exists for, and the reason there is no
|
|
243
|
+
// `tabForKey` call here: this operation reads its own tab inside the
|
|
244
|
+
// transaction, where the answer is reconciled.
|
|
245
|
+
return {
|
|
246
|
+
...(await broker.sign_in({
|
|
247
|
+
key: keyFrom(args),
|
|
248
|
+
what: asString(argument(args, 'what', 'signing_into', 'signingInto')),
|
|
249
|
+
...(argument(args, 'request_seconds', 'requestSeconds') === undefined
|
|
250
|
+
? {}
|
|
251
|
+
: { requestSeconds: asSeconds(argument(args, 'request_seconds', 'requestSeconds')) }),
|
|
252
|
+
})),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
case 'sign_in_done':
|
|
256
|
+
return { ...(await broker.sign_in_done({ key: keyFrom(args) })) };
|
|
257
|
+
case 'feedback':
|
|
258
|
+
return await submitFeedback(db, args);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
return { perform };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* A required string argument.
|
|
265
|
+
*
|
|
266
|
+
* Left as loose as the service's own inputs are: `ClaimInput.browser` is
|
|
267
|
+
* `string` rather than the two-literal union precisely so that an unknown
|
|
268
|
+
* browser is refused by `claim.browser_known` inside the transaction, on the
|
|
269
|
+
* ledger, rather than by a route quietly. So this checks that a string
|
|
270
|
+
* arrived and nothing about what it says.
|
|
271
|
+
*/
|
|
272
|
+
function asString(value) {
|
|
273
|
+
return typeof value === 'string' ? value : '';
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* An optional string argument: absent stays absent.
|
|
277
|
+
*
|
|
278
|
+
* The counterpart to {@link asString}, and the distinction is the whole
|
|
279
|
+
* point. Coercing a missing optional argument to `''` does not produce "no
|
|
280
|
+
* value" — it produces a value that happens to be empty, which every guard
|
|
281
|
+
* downstream then has to treat as a real answer. `claim.browser_known`
|
|
282
|
+
* correctly refused a browser named `""` for exactly that reason.
|
|
283
|
+
*
|
|
284
|
+
* An empty string from the caller is also read as absence. A shell cannot
|
|
285
|
+
* easily express the difference between `--browser ''` and no flag at all, and
|
|
286
|
+
* nothing in this service has a use for a browser whose name is empty, so
|
|
287
|
+
* treating the two alike is what makes the default reachable from both
|
|
288
|
+
* surfaces rather than only from the one that can omit a JSON key.
|
|
289
|
+
*/
|
|
290
|
+
function asOptionalString(value) {
|
|
291
|
+
if (typeof value !== 'string' || value === '') {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* A duration in seconds, as each surface spells one.
|
|
298
|
+
*
|
|
299
|
+
* The command line has no types, so `--request-seconds=60` arrives as the two
|
|
300
|
+
* characters. **A value that is not a number becomes `NaN` rather than being
|
|
301
|
+
* refused here**, deliberately: the operation owns what a legal duration is,
|
|
302
|
+
* and a route that refused first would answer a different question in a
|
|
303
|
+
* different order than the other route does — the exact drift §8's parity
|
|
304
|
+
* assertion exists to catch.
|
|
305
|
+
*/
|
|
306
|
+
function asSeconds(value) {
|
|
307
|
+
return typeof value === 'number' ? value : Number(value);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* A flag, as each surface spells one.
|
|
311
|
+
*
|
|
312
|
+
* The command line has no types: `--full-page` with no value parses to `true`
|
|
313
|
+
* and `--full-page=true` parses to the four-character string. Both mean the
|
|
314
|
+
* flag was set, and a route that treated the string as truthy-by-default
|
|
315
|
+
* would also treat `--full-page=false` as set.
|
|
316
|
+
*/
|
|
317
|
+
function asBoolean(value) {
|
|
318
|
+
return value === true || value === 'true';
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* The action, assembled from the three arguments the tool surface takes.
|
|
322
|
+
*
|
|
323
|
+
* `browser_act` takes `action`, `target` and `value` separately (§3.8) while
|
|
324
|
+
* the service takes one request object, so the assembly happens somewhere.
|
|
325
|
+
* It happens here rather than in either adapter, so both surfaces assemble it
|
|
326
|
+
* identically — and it is assembly only: which action names are legal, and
|
|
327
|
+
* which of them require an element reference, is validated inside the
|
|
328
|
+
* operation.
|
|
329
|
+
*
|
|
330
|
+
* ── `target` on the surface is `ref` in the service, and it must be renamed ──
|
|
331
|
+
*
|
|
332
|
+
* The surface argument is described as "the element reference, from a
|
|
333
|
+
* snapshot, where the action needs one", and every member of `ActionRequest`
|
|
334
|
+
* that addresses an element spells that field `ref`. They are the same value
|
|
335
|
+
* under two names, so one of them has to be translated into the other, and a
|
|
336
|
+
* bridge that passed `target` through unchanged would build a request with
|
|
337
|
+
* no `ref` at all — `act.ref_required` on every click, type, fill, select,
|
|
338
|
+
* check and hover a caller ever sent, on both surfaces.
|
|
339
|
+
*
|
|
340
|
+
* `drag` is the one action addressing a second element, and it spells that
|
|
341
|
+
* one `targetRef`; it is carried under its own name because it is a distinct
|
|
342
|
+
* field rather than another spelling of this one.
|
|
343
|
+
*
|
|
344
|
+
* A request passed whole is passed through, which is what an in-process
|
|
345
|
+
* caller writing the service's own spelling sends.
|
|
346
|
+
*/
|
|
347
|
+
function actionFrom(args) {
|
|
348
|
+
const whole = argument(args, 'request');
|
|
349
|
+
if (whole !== undefined) {
|
|
350
|
+
return whole;
|
|
351
|
+
}
|
|
352
|
+
const action = argument(args, 'action');
|
|
353
|
+
if (action === undefined) {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
const ref = argument(args, 'ref', 'target');
|
|
357
|
+
const value = argument(args, 'value');
|
|
358
|
+
const targetRef = argument(args, 'target_ref', 'targetRef');
|
|
359
|
+
const viewport = viewportFrom(args);
|
|
360
|
+
const preferences = preferencesFrom(args);
|
|
361
|
+
const response = argument(args, 'response');
|
|
362
|
+
const fields = argument(args, 'fields');
|
|
363
|
+
return {
|
|
364
|
+
action,
|
|
365
|
+
...(ref === undefined ? {} : { ref }),
|
|
366
|
+
...(value === undefined ? {} : { value }),
|
|
367
|
+
...(targetRef === undefined ? {} : { targetRef }),
|
|
368
|
+
...(viewport === undefined ? {} : { viewport }),
|
|
369
|
+
...(preferences === undefined ? {} : { preferences }),
|
|
370
|
+
...(response === undefined ? {} : { response }),
|
|
371
|
+
...(fields === undefined ? {} : { fields }),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The viewport a `resize` sets, assembled from whatever the caller could
|
|
376
|
+
* express.
|
|
377
|
+
*
|
|
378
|
+
* ── Why this exists: the refusal was unsatisfiable ──────────────────────
|
|
379
|
+
*
|
|
380
|
+
* `validateAction` wants `viewport: { width, height }` — an object of two
|
|
381
|
+
* integers. The command line produces flat strings and nothing else, so there
|
|
382
|
+
* was **no argument a command-line caller could type that would ever parse**.
|
|
383
|
+
* A field session tried `--value 390x844`, `390,844`, `"390 844"`, the JSON
|
|
384
|
+
* object, and `--width 390 --height 844`, and got the identical refusal every
|
|
385
|
+
* time:
|
|
386
|
+
*
|
|
387
|
+
* > A resize sets the tab's viewport, so it needs a width and a height in
|
|
388
|
+
* > pixels.
|
|
389
|
+
*
|
|
390
|
+
* Every one of those *is* a width and a height in pixels. The message
|
|
391
|
+
* describes the semantics and never the syntax, so it reads as though the
|
|
392
|
+
* caller supplied the wrong kind of thing when they supplied the wrong shape
|
|
393
|
+
* of thing — and there was no shape that worked. Adding the syntax to the
|
|
394
|
+
* message would have been a fix for a different bug: the verb was
|
|
395
|
+
* **unreachable**, not merely undocumented, and mobile-breakpoint review was
|
|
396
|
+
* impossible on the only working client.
|
|
397
|
+
*
|
|
398
|
+
* ── What is accepted, and why more than one form ────────────────────────
|
|
399
|
+
*
|
|
400
|
+
* `--width 390 --height 844` is the documented pair, and `--value 390x844` is
|
|
401
|
+
* accepted because it is what a person reaches for first and because a
|
|
402
|
+
* viewport is conventionally written that way. Both arrive here as strings,
|
|
403
|
+
* so both are coerced to integers; the tool surface's own object is passed
|
|
404
|
+
* straight through, since a JSON caller can already say what it means.
|
|
405
|
+
*
|
|
406
|
+
* **Coercion only, never validation.** Whether a side is positive, whole and
|
|
407
|
+
* within the bound is `requireViewportSide`'s decision, inside the operation,
|
|
408
|
+
* on the ledger. A `NaN` from an unparseable word is handed on deliberately —
|
|
409
|
+
* it fails that guard and produces the refusal a caller should get, rather
|
|
410
|
+
* than a different one invented here.
|
|
411
|
+
*/
|
|
412
|
+
function viewportFrom(args) {
|
|
413
|
+
const given = argument(args, 'viewport');
|
|
414
|
+
if (given !== undefined) {
|
|
415
|
+
return given;
|
|
416
|
+
}
|
|
417
|
+
const width = argument(args, 'width');
|
|
418
|
+
const height = argument(args, 'height');
|
|
419
|
+
if (width !== undefined || height !== undefined) {
|
|
420
|
+
return { width: asInteger(width), height: asInteger(height) };
|
|
421
|
+
}
|
|
422
|
+
// `--value 390x844`, and the two other separators a person reaches for.
|
|
423
|
+
const value = argument(args, 'value');
|
|
424
|
+
if (typeof value === 'string') {
|
|
425
|
+
const sides = value.split(/[x×,\s]+/u).filter((part) => part !== '');
|
|
426
|
+
if (sides.length === 2) {
|
|
427
|
+
return { width: asInteger(sides[0]), height: asInteger(sides[1]) };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The media preferences an `emulate` sets, assembled from flat flags.
|
|
434
|
+
*
|
|
435
|
+
* The same unreachability as {@link viewportFrom}, and it went unreported for
|
|
436
|
+
* longer because the refusal is otherwise a model of the kind this service is
|
|
437
|
+
* proud of — it names its three options exactly the way `claim.browser_known`
|
|
438
|
+
* names the browsers:
|
|
439
|
+
*
|
|
440
|
+
* > An emulate sets media preferences, so it names at least one of:
|
|
441
|
+
* > colourScheme, reducedMotion, forcedColours.
|
|
442
|
+
*
|
|
443
|
+
* A caller reading that types `--colour-scheme dark` and is refused
|
|
444
|
+
* identically, because the service wants them nested under `preferences` and
|
|
445
|
+
* `parseArguments` cannot nest. So a good message pointed at a door that was
|
|
446
|
+
* not there, and dark-mode review was as unreachable as the mobile breakpoint.
|
|
447
|
+
*
|
|
448
|
+
* The hyphenated spellings are what a terminal reads; `parseArguments`
|
|
449
|
+
* normalises `--colour-scheme` to `colour_scheme`, and the service's own
|
|
450
|
+
* camel-case names are accepted too so an in-process caller writing
|
|
451
|
+
* `colourScheme` is understood. **Which values are legal is not decided
|
|
452
|
+
* here** — the operation checks them against its table and refuses by name.
|
|
453
|
+
*/
|
|
454
|
+
function preferencesFrom(args) {
|
|
455
|
+
const given = argument(args, 'preferences');
|
|
456
|
+
if (given !== undefined) {
|
|
457
|
+
return given;
|
|
458
|
+
}
|
|
459
|
+
const named = {};
|
|
460
|
+
for (const preference of MEDIA_PREFERENCE_SPELLINGS) {
|
|
461
|
+
const value = argument(args, ...preference.spellings);
|
|
462
|
+
if (value !== undefined) {
|
|
463
|
+
named[preference.name] = value;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return Object.keys(named).length === 0 ? undefined : named;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Each media preference, and every spelling a caller might arrive with.
|
|
470
|
+
*
|
|
471
|
+
* The service's name first, then the terminal's — `parseArguments` turns
|
|
472
|
+
* `--colour-scheme` into `colour_scheme`, so that is the spelling this file
|
|
473
|
+
* actually receives from the command line. Both British and American
|
|
474
|
+
* spellings of "colour" are read, because a caller who types the one this
|
|
475
|
+
* service does not use should get a dark theme rather than a refusal that
|
|
476
|
+
* looks like they named nothing at all.
|
|
477
|
+
*/
|
|
478
|
+
const MEDIA_PREFERENCE_SPELLINGS = [
|
|
479
|
+
{
|
|
480
|
+
name: 'colourScheme',
|
|
481
|
+
spellings: ['colourScheme', 'colour_scheme', 'colorScheme', 'color_scheme'],
|
|
482
|
+
},
|
|
483
|
+
{ name: 'reducedMotion', spellings: ['reducedMotion', 'reduced_motion'] },
|
|
484
|
+
{
|
|
485
|
+
name: 'forcedColours',
|
|
486
|
+
spellings: ['forcedColours', 'forced_colours', 'forcedColors', 'forced_colors'],
|
|
487
|
+
},
|
|
488
|
+
];
|
|
489
|
+
/**
|
|
490
|
+
* A whole number, from whatever a surface could carry.
|
|
491
|
+
*
|
|
492
|
+
* The command line has only strings, so `--width 390` arrives as `"390"` and
|
|
493
|
+
* has to become `390` before the operation's guard can judge it. An
|
|
494
|
+
* unparseable word becomes `NaN`, which that guard refuses by name — which is
|
|
495
|
+
* the right refusal, and the reason nothing is validated here.
|
|
496
|
+
*/
|
|
497
|
+
function asInteger(value) {
|
|
498
|
+
if (typeof value === 'number') {
|
|
499
|
+
return value;
|
|
500
|
+
}
|
|
501
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
502
|
+
return Number(value);
|
|
503
|
+
}
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The artefacts asked for, as a list.
|
|
508
|
+
*
|
|
509
|
+
* The tool surface takes an array; the command line can only produce a
|
|
510
|
+
* string, so a comma-separated one is split. Which names are legal is the
|
|
511
|
+
* operation's decision and is not checked here — an unknown name has to reach
|
|
512
|
+
* the transaction to be refused there, on the ledger, rather than dropped.
|
|
513
|
+
*/
|
|
514
|
+
function artifactsFrom(value) {
|
|
515
|
+
if (typeof value === 'string') {
|
|
516
|
+
return value
|
|
517
|
+
.split(',')
|
|
518
|
+
.map((word) => word.trim())
|
|
519
|
+
.filter((word) => word.length > 0);
|
|
520
|
+
}
|
|
521
|
+
return value;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* `feedback` — the tenth operation, and the one with no arbitration handler.
|
|
525
|
+
*
|
|
526
|
+
* It takes no lease and touches no tab (§3.16), so there is nothing for the
|
|
527
|
+
* arbitration runner to arbitrate; it validates and appends a row. That is
|
|
528
|
+
* why it is not a {@link Broker} method and why this is the one branch above
|
|
529
|
+
* that does not call one.
|
|
530
|
+
*
|
|
531
|
+
* The validation is `refuseSubmission`, which is the module that owns it —
|
|
532
|
+
* the same function the surfaces would otherwise each have had to call, which
|
|
533
|
+
* is exactly the duplication that puts a rule on one route and not another.
|
|
534
|
+
*/
|
|
535
|
+
async function submitFeedback(db, args) {
|
|
536
|
+
const rawRating = argument(args, 'rating');
|
|
537
|
+
const rating = typeof rawRating === 'string' && /^\d+$/u.test(rawRating) ? Number(rawRating) : rawRating;
|
|
538
|
+
const category = argument(args, 'category');
|
|
539
|
+
const note = argument(args, 'note');
|
|
540
|
+
const refusal = refuseSubmission({ rating, category, note });
|
|
541
|
+
if (refusal !== undefined) {
|
|
542
|
+
throw new BrokerError(refusal.rule, refusal.message);
|
|
543
|
+
}
|
|
544
|
+
// `refuseSubmission` has just established all three, but it returns a
|
|
545
|
+
// refusal rather than a narrowed type, so the compiler does not know it.
|
|
546
|
+
// These re-check rather than assert: a cast would be this file claiming a
|
|
547
|
+
// fact, and if `refuseSubmission` ever stopped checking one of the three
|
|
548
|
+
// the cast would carry the gap into the database while this throws.
|
|
549
|
+
if (typeof rating !== 'number' ||
|
|
550
|
+
typeof category !== 'string' ||
|
|
551
|
+
!isFeedbackCategory(category) ||
|
|
552
|
+
typeof note !== 'string') {
|
|
553
|
+
throw new BrokerError('feedback.validated', 'The submission passed validation but is not the shape validation promises. This is a fault in this build rather than anything the caller did.');
|
|
554
|
+
}
|
|
555
|
+
const sessionId = argument(args, 'session_id', 'sessionId');
|
|
556
|
+
const leaseKey = argument(args, 'lease_key', 'leaseKey', 'key');
|
|
557
|
+
// `leaseKeyHash` and not the key itself. `record.ts` states the rule and
|
|
558
|
+
// states that the hashing "belongs to the service layer (row #10) and is
|
|
559
|
+
// not built here" — so this is the layer that owes it, and supplying it is
|
|
560
|
+
// what attaches a feedback row to the lease it was written about. Until
|
|
561
|
+
// now the column was null on every row, which was the documented no-lease
|
|
562
|
+
// path standing in for a capability that had nowhere to live.
|
|
563
|
+
//
|
|
564
|
+
// A key is hashed and discarded in the same expression; nothing downstream
|
|
565
|
+
// of here holds the secret.
|
|
566
|
+
const submission = {
|
|
567
|
+
rating,
|
|
568
|
+
category,
|
|
569
|
+
note,
|
|
570
|
+
...(typeof sessionId === 'string' ? { sessionId } : {}),
|
|
571
|
+
...(typeof leaseKey === 'string' && leaseKey.length > 0
|
|
572
|
+
? { leaseKeyHash: hashKey(leaseKey) }
|
|
573
|
+
: {}),
|
|
574
|
+
};
|
|
575
|
+
const recorded = await recordFeedback(db, submission);
|
|
576
|
+
return { id: recorded.id, recorded: true };
|
|
577
|
+
}
|