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,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
+ }