@vincentt-xr/cli 0.20.0 → 0.22.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 (68) hide show
  1. package/MIGRATION.md +30 -0
  2. package/dist/cli/index.js +86 -12
  3. package/dist/domain/api.js +16 -12
  4. package/dist/domain/command.js +9 -0
  5. package/dist/dx/command.d.ts +74 -18
  6. package/dist/dx/command.js +140 -86
  7. package/dist/dx/consent.d.ts +25 -6
  8. package/dist/dx/consent.js +64 -35
  9. package/dist/dx/enablement.d.ts +63 -11
  10. package/dist/dx/enablement.js +93 -21
  11. package/dist/dx/report.d.ts +4 -24
  12. package/dist/dx/report.js +32 -25
  13. package/dist/dx/reportIo.js +6 -4
  14. package/dist/dx/send.js +6 -4
  15. package/dist/lib/backend.js +5 -10
  16. package/dist/lib/exitCodes.d.ts +6 -0
  17. package/dist/lib/exitCodes.js +6 -0
  18. package/dist/lib/terminalText.d.ts +6 -0
  19. package/dist/lib/terminalText.js +10 -0
  20. package/dist/lib/version.d.ts +28 -2
  21. package/dist/lib/version.js +30 -3
  22. package/dist/lib/whoami.js +3 -0
  23. package/dist/library/assetsCommand.js +4 -0
  24. package/dist/library/client.js +6 -8
  25. package/dist/login/authBackend.js +18 -15
  26. package/dist/login/loginCommand.js +31 -4
  27. package/dist/login/pkceLogin.d.ts +10 -0
  28. package/dist/login/pkceLogin.js +24 -1
  29. package/dist/platform/releaseFetch.js +36 -69
  30. package/dist/preview/api.js +9 -12
  31. package/dist/preview/command.d.ts +3 -2
  32. package/dist/preview/command.js +10 -5
  33. package/dist/preview/copy.d.ts +13 -1
  34. package/dist/preview/copy.js +31 -3
  35. package/dist/preview/proxy.js +5 -39
  36. package/dist/preview/runner.d.ts +31 -9
  37. package/dist/preview/runner.js +43 -15
  38. package/dist/publish/api.js +14 -14
  39. package/dist/publish/command.d.ts +3 -0
  40. package/dist/publish/command.js +74 -7
  41. package/dist/publish/copy.d.ts +15 -0
  42. package/dist/publish/copy.js +109 -19
  43. package/dist/publish/keyScan.d.ts +25 -0
  44. package/dist/publish/keyScan.js +88 -0
  45. package/dist/publish/manifest.d.ts +60 -19
  46. package/dist/publish/manifest.js +290 -71
  47. package/dist/publish/upload.d.ts +18 -0
  48. package/dist/publish/upload.js +38 -0
  49. package/dist/publish/weight.d.ts +23 -0
  50. package/dist/publish/weight.js +113 -0
  51. package/dist/shared/config.d.ts +6 -6
  52. package/dist/shared/credentialNames.d.ts +17 -0
  53. package/dist/shared/credentialNames.js +72 -0
  54. package/dist/shared/http.d.ts +34 -0
  55. package/dist/shared/http.js +98 -2
  56. package/dist/shared/upgradeError.d.ts +62 -0
  57. package/dist/shared/upgradeError.js +215 -0
  58. package/dist/tunnel/caps.d.ts +19 -0
  59. package/dist/tunnel/caps.js +52 -0
  60. package/dist/tunnel/cbor.d.ts +39 -12
  61. package/dist/tunnel/cbor.js +84 -10
  62. package/dist/tunnel/client.d.ts +56 -2
  63. package/dist/tunnel/client.js +160 -44
  64. package/dist/tunnel/frame.d.ts +1 -1
  65. package/dist/tunnel/frame.js +8 -4
  66. package/dist/tunnel/index.d.ts +3 -2
  67. package/dist/tunnel/index.js +3 -2
  68. package/package.json +4 -3
@@ -1,9 +1,11 @@
1
1
  import { findClusters, rankClusters } from "./clusters.js";
2
- import { enablementLine } from "./enablement.js";
2
+ import { dxConsentJson, enablementLine, resolveConsentView } from "./enablement.js";
3
3
  import { journalEnabled, journalPath, readJournal } from "./journal.js";
4
4
  import { buildReport, renderPayload } from "./send.js";
5
5
  import { resolveBuiltWith } from "../platform/resolve.js";
6
6
  import { EXIT } from "../lib/exitCodes.js";
7
+ import { isAuthRefused } from "../shared/authError.js";
8
+ import { UpgradeRequiredError } from "../shared/upgradeError.js";
7
9
  const MINUTE_MS = 60 * 1000;
8
10
  const HOUR_MS = 60 * MINUTE_MS;
9
11
  const DAY_MS = 24 * HOUR_MS;
@@ -120,19 +122,27 @@ export function dxCommand(deps, options) {
120
122
  return 0;
121
123
  }
122
124
  const clusters = rankClusters(findClusters(entries));
123
- const enablement = deps.enablement?.() ?? { enabled: false };
125
+ const local = deps.enablement?.() ?? { enabled: false };
126
+ const view = deps.consentView?.() ?? {
127
+ shown: local,
128
+ local,
129
+ checked: "machine",
130
+ signedIn: false,
131
+ };
124
132
  if (options.json) {
125
133
  // The JSON object carries enablement as FIELDS because --json is the path an
126
134
  // AGENT reads, and an agent that can send unattended should be able to see
127
- // that it can.
135
+ // that it can. `dxSendEnabled*` stay this machine's flag; `dxConsent` is the
136
+ // account-first answer and says where it was checked.
128
137
  deps.print(JSON.stringify({
129
138
  window: options.window.label,
130
139
  runs: entries.length,
131
140
  clusters,
132
- dxSendEnabled: enablement.enabled,
133
- ...(enablement.enabled && enablement.at !== undefined
134
- ? { dxSendEnabledAt: enablement.at }
141
+ dxSendEnabled: view.local.enabled,
142
+ ...(view.local.enabled && view.local.at !== undefined
143
+ ? { dxSendEnabledAt: view.local.at }
135
144
  : {}),
145
+ dxConsent: dxConsentJson(view),
136
146
  }, null, 2));
137
147
  return 0;
138
148
  }
@@ -162,13 +172,13 @@ export function dxCommand(deps, options) {
162
172
  // Beside it, and ONLY when enabled. Enablement is durable and has no expiry, so
163
173
  // this line is the mitigation rather than a status decoration; printing an
164
174
  // `off` counterpart on every run is how it would get skimmed past.
165
- const line = enablementLine(enablement, deps.now());
175
+ const line = enablementLine(view.shown, deps.now());
166
176
  if (line !== undefined)
167
177
  deps.print(line);
168
178
  return 0;
169
179
  }
170
180
  /** Wire the command to the real journal. */
171
- export function runDx(args, print, enablement = () => ({ enabled: false })) {
181
+ export function runDx(args, print, enablement = () => ({ enabled: false }), consentView) {
172
182
  return dxCommand({
173
183
  entries: (window) => readJournal(window),
174
184
  print,
@@ -176,6 +186,7 @@ export function runDx(args, print, enablement = () => ({ enabled: false })) {
176
186
  enabled: () => journalEnabled(),
177
187
  path: journalPath,
178
188
  enablement,
189
+ consentView,
179
190
  }, parseDxOptions(args));
180
191
  }
181
192
  /** A date a human can read, for the sunset refusal. */
@@ -186,11 +197,13 @@ function sunsetDate(sunset) {
186
197
  * `vincentt dx enable` — grant durable permission to send DX reports without asking
187
198
  * first.
188
199
  *
189
- * TWO PATHS, and which one runs is decided by whether there is a terminal to ask in:
200
+ * TWO PATHS to ONE account grant, and which one runs is decided by whether there is a
201
+ * terminal to ask in:
190
202
  *
191
- * - At a TTY: the original terminal prompt, unchanged. A person is already here and
192
- * can read the disclosure where they stand; sending them to a browser would be
193
- * ceremony.
203
+ * - At a TTY: the terminal prompt. A person is already here and can read the
204
+ * disclosure where they stand; sending them to a browser would be ceremony. On `y`
205
+ * the CLI POSTs /me/dx-consent with the PAT, and writes the local flag only after
206
+ * the account recorded the grant.
194
207
  * - With no TTY: the BROWSER path. This is the case the feature exists for — a
195
208
  * creator driving the CLI through a coding agent has no terminal, so the old code
196
209
  * refused and the flow dead-ended. It now opens the console, where a human can
@@ -222,44 +235,87 @@ export async function dxEnableCommand(deps, sunset) {
222
235
  // signed out cannot complete either path, and reading a consent disclosure before
223
236
  // being told so is wasted attention.
224
237
  const auth = await deps.auth?.();
225
- if (auth === undefined) {
226
- deps.print("");
227
- deps.print(" Allowing unattended reports is recorded on your Vincentt account, and");
228
- deps.print(" this machine is not signed in. Nothing was changed.");
229
- deps.print("");
230
- deps.print(" Run `vincentt login` first.");
231
- deps.print("");
232
- return EXIT.NOT_AUTHENTICATED;
233
- }
238
+ if (auth === undefined)
239
+ return signedOutRefusal(deps);
234
240
  if (!deps.interactive()) {
235
241
  return enableViaBrowser(deps);
236
242
  }
237
243
  // What is being granted, INCLUDING the note risk. The note is the part a
238
244
  // creator does not picture: enablement is what lets an agent compose prose they
239
245
  // have not read and put it on the wire.
246
+ // The wording names the ACCOUNT because the answer reaches every machine on it; a
247
+ // prompt that said "this machine" would ask for less than it grants.
240
248
  deps.print("");
241
249
  deps.print(" Unattended DX submission");
242
250
  deps.print("");
243
- deps.print(" Your agent will be able to send friction reports from this machine without");
244
- deps.print(" asking you first. A report includes a note, which is sent exactly as written.");
245
- deps.print(" An agent may compose a note you have not read.");
251
+ deps.print(" Your agent will be able to send friction reports without asking you first,");
252
+ deps.print(" from any machine signed in to your Vincentt account. A report includes a note,");
253
+ deps.print(" which is sent exactly as written. An agent may compose a note you have not read.");
246
254
  deps.print("");
247
- deps.print(" This stays on until you run `vincentt dx disable`.");
255
+ deps.print(" This stays on until you run `vincentt dx disable` or turn it off in the console.");
248
256
  deps.print("");
249
- if (!(await deps.confirm("Enable on this machine?"))) {
257
+ if (!(await deps.confirm("Allow unattended reports on your account?"))) {
250
258
  deps.print("");
251
259
  deps.print(" Not enabled.");
252
260
  deps.print("");
253
261
  return 0;
254
262
  }
255
- // Written on every enable, so the age reports from the LATEST grant rather than
256
- // the first — a re-enable is a fresh decision and dates from when it was made.
257
- await deps.save({ at: new Date(deps.now()).toISOString() });
263
+ return grantAtTerminal(deps, auth);
264
+ }
265
+ /**
266
+ * The terminal grant: the account first, then this machine's copy.
267
+ *
268
+ * ORDER IS THE PROPERTY. A local flag written before the account recorded the grant is a
269
+ * machine-only grant, which is exactly what this path no longer makes; so every failure
270
+ * arm returns before `save` and leaves config.json untouched. A 426 or 401 propagates,
271
+ * so the caller renders the shared version or sign-in refusal.
272
+ *
273
+ * The local date is the ACCOUNT's, so a re-grant re-dates both and the age always
274
+ * reports from the latest grant.
275
+ */
276
+ async function grantAtTerminal(deps, auth) {
277
+ let result;
278
+ try {
279
+ result =
280
+ deps.grantAccountConsent === undefined
281
+ ? { kind: "unreachable" }
282
+ : await deps.grantAccountConsent(auth);
283
+ }
284
+ catch (err) {
285
+ if (err instanceof UpgradeRequiredError)
286
+ throw err;
287
+ if (isAuthRefused(err))
288
+ return signedOutRefusal(deps);
289
+ result = { kind: "unreachable" };
290
+ }
291
+ if (result.kind !== "granted" || result.enablement.at === undefined) {
292
+ deps.print("");
293
+ deps.print(` ✗ ${GRANT_FAILURE[result.kind === "granted" ? "not_granted" : result.kind]}`);
294
+ deps.print(" Nothing was changed.");
295
+ deps.print("");
296
+ return 1;
297
+ }
298
+ await deps.save({ at: result.enablement.at });
258
299
  deps.print("");
259
- deps.print(" Enabled. Reports may now be sent without a prompt.");
300
+ deps.print(" Enabled for your account. Reports may now be sent without a prompt from any");
301
+ deps.print(" machine signed in to it.");
260
302
  deps.print("");
261
303
  return 0;
262
304
  }
305
+ const GRANT_FAILURE = {
306
+ not_granted: "Vincentt did not record your consent.",
307
+ rate_limited: "Too many consent changes. Wait a minute and try again.",
308
+ unreachable: "Could not reach Vincentt to record your consent.",
309
+ };
310
+ function signedOutRefusal(deps) {
311
+ deps.print("");
312
+ deps.print(" Allowing unattended reports is recorded on your Vincentt account, and");
313
+ deps.print(" this machine is not signed in. Nothing was changed.");
314
+ deps.print("");
315
+ deps.print(" Run `vincentt login` first.");
316
+ deps.print("");
317
+ return EXIT.NOT_AUTHENTICATED;
318
+ }
263
319
  /**
264
320
  * The no-TTY arm: hand the decision to a human in a browser.
265
321
  *
@@ -324,8 +380,8 @@ async function enableViaBrowser(deps) {
324
380
  * Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
325
381
  * with. Both halves are needed: a creator who runs `disable` means "stop sending", and
326
382
  * clearing only the local flag would leave the account grant standing, so the very next
327
- * `--send` would be authorized by arm (c) and the command would appear not to have
328
- * worked.
383
+ * `--send` would be authorized by the account and the command would appear not to
384
+ * have worked.
329
385
  *
330
386
  * ORDER IS DELIBERATE: local first, network second. The local clear is the one that
331
387
  * cannot fail and the one that stops THIS machine, which is the machine the creator is
@@ -370,6 +426,41 @@ export async function dxDisableCommand(deps) {
370
426
  deps.print("");
371
427
  return 0;
372
428
  }
429
+ /**
430
+ * The one consent gate both send paths call (f7 Scenario 3), checked in order:
431
+ *
432
+ * 1. an interactive session → ask, then send. No /me read at all, so a creator at their
433
+ * own terminal is never blocked by an API being slow or down.
434
+ * 2. signed in and GET /me answers: a strict grant sends (source: account); any other
435
+ * 200 clears this machine's flag and refuses.
436
+ * 3. /me cannot be read and this machine's flag is valid → send (source: machine).
437
+ *
438
+ * An unreadable /me never authorizes by itself; it only lets an existing local flag stand.
439
+ */
440
+ export async function gateSend(deps, auth) {
441
+ if (deps.interactive())
442
+ return { arm: "interactive" };
443
+ const view = await resolveConsentView({
444
+ auth,
445
+ readAccount: deps.accountConsent,
446
+ local: () => deps.enablement?.() ?? { enabled: false },
447
+ clearLocal: () => deps.clearEnablement?.() ?? Promise.resolve(),
448
+ });
449
+ return view.shown.enabled
450
+ ? { arm: "unattended", enablement: view.shown }
451
+ : { arm: "refused" };
452
+ }
453
+ /**
454
+ * The gate's refusal. It NAMES the enabling command: a refusal is an answer to a
455
+ * command the caller typed, which makes it SOLICITED output — the prohibition is on
456
+ * UNBIDDEN output instructing a caller. A refusal naming nothing leaves a human at
457
+ * exit 1 with no path forward, for no security gain.
458
+ */
459
+ export const SEND_REFUSAL_LINES = [
460
+ "Sending a report needs consent that has not been given on this account.",
461
+ "`vincentt dx enable` opens the Vincentt console so you can allow it.",
462
+ "It takes one click, and it works from here.",
463
+ ];
373
464
  /**
374
465
  * `vincentt dx --send`.
375
466
  *
@@ -377,7 +468,7 @@ export async function dxDisableCommand(deps) {
377
468
  * who cannot see what leaves their machine before it leaves has not agreed to
378
469
  * anything:
379
470
  *
380
- * friction → GATE (three arms) → auth → RESOLVE → build → SHOW → ask → post
471
+ * friction → auth → GATE → RESOLVE → build → SHOW → ask → post
381
472
  */
382
473
  export async function dxSendCommand(deps, options) {
383
474
  if (!deps.enabled()) {
@@ -410,59 +501,20 @@ export async function dxSendCommand(deps, options) {
410
501
  // the check sits on the act being authorized rather than on a flag that only
411
502
  // modifies how it is confirmed.
412
503
  //
413
- // The second arm is the SUPPORTED unattended path: a person, at a terminal,
414
- // ran `vincentt dx enable` on this machine. That is a prior deliberate act
415
- // rather than a per-send answer, which is what makes it consent at all.
504
+ // The unattended path is a prior deliberate act — a person granted it on the
505
+ // account, at a terminal or in the console — rather than a per-send answer, which
506
+ // is what makes it consent at all. See gateSend for the order.
416
507
  //
417
- // The THIRD arm is the same kind of prior deliberate act, made in the console and
418
- // recorded on the ACCOUNT rather than on this machine. It exists because the second
419
- // arm cannot be reached at all by the creator this feature is for: driving the CLI
420
- // through a coding agent, there is no terminal to run `dx enable` in.
421
- const localEnablement = deps.enablement?.() ?? { enabled: false };
422
- const interactive = deps.interactive();
423
- // Auth is resolved BEFORE the gate now, because arm (c) needs a credential to read
424
- // /me. The signed-out refusal still comes AFTER the gate, so a caller with neither a
508
+ // Auth is resolved BEFORE the gate, because the account read needs a credential.
509
+ // The signed-out refusal still comes AFTER the gate, so a caller with neither a
425
510
  // terminal nor consent gets the refusal about consent — the thing they need to fix —
426
511
  // rather than being sent to `login` first and only then told that logging in was not
427
512
  // sufficient.
428
513
  const auth = await deps.auth();
429
- // Arm (c) is consulted ONLY when the first two have not already allowed the send.
430
- // Skipping it otherwise is not just an optimization: it keeps `--send` from making a
431
- // network call it does not need, so a creator at their own terminal is never blocked
432
- // by an API being slow or down.
433
- let accountEnablement = { enabled: false };
434
- if (!interactive && !localEnablement.enabled && auth !== undefined) {
435
- try {
436
- accountEnablement = (await deps.accountConsent?.(auth)) ?? { enabled: false };
437
- }
438
- catch {
439
- // A NETWORK FAILURE FALLS BACK TO THE OTHER TWO ARMS, and since we only get here
440
- // when both have already said no, that means refusing. This is the fail-CLOSED
441
- // direction: an unreachable API can never authorize a send.
442
- //
443
- // The swallow is deliberate and is the whole point of the arm's placement — a
444
- // /me that is down must never BLOCK a send that (a) or (b) already allows, and it
445
- // cannot, because those paths never reach this call at all.
446
- accountEnablement = { enabled: false };
447
- }
448
- }
449
- // The grant that authorizes this send, and the one whose age gets reported. The local
450
- // flag wins when both exist: it is the more specific statement (this machine) and it
451
- // is the one `dx disable` can always clear.
452
- const enablement = localEnablement.enabled
453
- ? localEnablement
454
- : accountEnablement;
455
- if (!interactive && !enablement.enabled) {
456
- // This refusal NAMES the enabling command. A refusal is an answer to a
457
- // command the caller typed, which makes it SOLICITED output — the
458
- // prohibition is on UNBIDDEN output instructing a caller (the nudge names
459
- // neither `enable` nor `--send`, and still must not). A refusal naming
460
- // nothing satisfies the letter of that rule and leaves a human at exit 1
461
- // with no path forward, for no security gain: guessability was never the
462
- // threat.
463
- deps.print("Sending a report needs consent that has not been given on this account.");
464
- deps.print("`vincentt dx enable` opens the Vincentt console so you can allow it.");
465
- deps.print("It takes one click, and it works from here.");
514
+ const gate = await gateSend(deps, auth);
515
+ if (gate.arm === "refused") {
516
+ for (const l of SEND_REFUSAL_LINES)
517
+ deps.print(l);
466
518
  return 1;
467
519
  }
468
520
  if (auth === undefined) {
@@ -491,9 +543,9 @@ export async function dxSendCommand(deps, options) {
491
543
  const report = buildReport(clusters, deps.cliVersion(), options.note, builtWith);
492
544
  // Above the payload, on the unattended path only: name that this is enabled,
493
545
  // how old the grant is, WHERE it was granted, and that nothing is going to ask.
494
- const unattended = !interactive && enablement.enabled;
495
- if (unattended) {
496
- const line = enablementLine(enablement, deps.now());
546
+ const unattended = gate.arm === "unattended";
547
+ if (gate.arm === "unattended") {
548
+ const line = enablementLine(gate.enablement, deps.now());
497
549
  if (line !== undefined) {
498
550
  deps.print("");
499
551
  deps.print(`${line}. Not asking.`);
@@ -511,8 +563,8 @@ export async function dxSendCommand(deps, options) {
511
563
  for (const l of renderPayload(report))
512
564
  deps.print(l);
513
565
  deps.print("");
514
- // The enabled machine does not ask. --yes still means only "skip the question"
515
- // and is inert without one of the two arms above.
566
+ // The unattended path does not ask. --yes still means only "skip the question"
567
+ // and is inert without a grant.
516
568
  if (!unattended && !options.yes && !(await deps.confirm("Send this report?"))) {
517
569
  deps.print("Not sent.");
518
570
  return 0;
@@ -526,6 +578,8 @@ export async function dxSendCommand(deps, options) {
526
578
  return 0;
527
579
  }
528
580
  catch (err) {
581
+ if (err instanceof UpgradeRequiredError)
582
+ throw err;
529
583
  deps.print("");
530
584
  deps.print(`✗ ${err instanceof Error ? err.message : String(err)}`);
531
585
  return 1;
@@ -1,16 +1,35 @@
1
- import { type Enablement } from "./enablement.js";
1
+ import { type AccountRead, type Enablement } from "./enablement.js";
2
+ /** POST /me/dx-consent, sorted into the outcomes `dx enable` answers differently. */
3
+ export type GrantResult = {
4
+ kind: "granted";
5
+ enablement: Enablement;
6
+ } | {
7
+ kind: "not_granted";
8
+ } | {
9
+ kind: "rate_limited";
10
+ } | {
11
+ kind: "unreachable";
12
+ };
2
13
  /**
3
14
  * The account-consent seam.
4
15
  *
5
- * `read` returns the parsed Enablement rather than the raw envelope so the strict
16
+ * Both reads return parsed results rather than the raw envelope so the strict
6
17
  * `=== true` interpretation happens in exactly one place and cannot be re-derived
7
18
  * loosely by a second caller.
8
19
  */
9
20
  export interface ConsentBackend {
10
- /** GET /me → the account's dxReportsConsent fields, read strictly. */
11
- read(apiUrl: string, pat: string, now: number): Promise<Enablement>;
12
- /** POST /me/dx-consent — grant. Idempotent; a re-grant refreshes the timestamp. */
13
- grant(apiUrl: string, pat: string, now: number): Promise<Enablement>;
21
+ /**
22
+ * GET /me → the account's consent, read strictly. A 426, a 5xx, a network error or a
23
+ * timeout is `unreadable`: the CLI floor refuses /me while reports stay floor-exempt,
24
+ * so a below-floor CLI must fall back to its local flag rather than stop. A 401 still
25
+ * rejects with the shared sign-in error.
26
+ */
27
+ read(apiUrl: string, pat: string, now: number, timeoutMs?: number): Promise<AccountRead>;
28
+ /**
29
+ * POST /me/dx-consent — grant. A re-grant refreshes the timestamp. Rejects only with
30
+ * the version refusal (426) or the sign-in error (401); every other outcome is a result.
31
+ */
32
+ grant(apiUrl: string, pat: string, now: number): Promise<GrantResult>;
14
33
  /** DELETE /me/dx-consent — revoke. Never fails on already-revoked. */
15
34
  revoke(apiUrl: string, pat: string): Promise<void>;
16
35
  }
@@ -1,17 +1,11 @@
1
1
  /**
2
- * The ACCOUNT half of unattended-send authorization.
2
+ * The ACCOUNT grant for unattended sends: the authority, which the local flag caches.
3
3
  *
4
- * `vincentt dx enable` used to require a TTY, which dead-ends the flow this feature is
5
- * actually for: a creator drives the CLI through a coding agent, the agent has no
6
- * terminal, and the command refuses. Consent therefore moves to the console, where a
7
- * human can actually read the disclosure and click — the CLI opens a browser and waits.
8
- *
9
- * The grant is PER-ACCOUNT, not per-machine: one decision covers every machine the
10
- * creator signs in on. That is a wider blast radius than the local flag, which is why
11
- * the console page carries the full disclosure (including that an agent may compose a
12
- * note the user has not read) and why the visibility line names the console as the
13
- * source — a creator who wants it off needs to know that clearing a local flag is not
14
- * enough.
4
+ * A person grants it at a terminal (`dx enable` POSTs /me/dx-consent with the PAT) or,
5
+ * with no terminal, on the console page the CLI opens. Either way the grant is
6
+ * PER-ACCOUNT: one decision covers every machine the creator signs in on, which is why
7
+ * both surfaces carry the full disclosure (including that an agent may compose a note
8
+ * the user has not read) and why the visibility line names where it was granted.
15
9
  *
16
10
  * Every call is isolated behind ConsentBackend so the command layer is unit-testable
17
11
  * with no network.
@@ -21,10 +15,10 @@ import { waitForLoopbackCallback } from "../login/loopback.js";
21
15
  import { browserPage } from "../login/pkceLogin.js";
22
16
  import { stateMatches } from "../login/pkce.js";
23
17
  import { getFreePort } from "../preview/net.js";
24
- import { authFailureMessage } from "../shared/authError.js";
25
- import { clientHeaders } from "../shared/clientHeader.js";
26
- import { apiFetch } from "../shared/http.js";
27
- import { readAccountConsent } from "./enablement.js";
18
+ import { AuthExpiredError, authFailureMessage, rejectAuthExpired, } from "../shared/authError.js";
19
+ import { apiFetch, apiRequest, withDeadline } from "../shared/http.js";
20
+ import { rejectUpgradeRequired, UpgradeRequiredError } from "../shared/upgradeError.js";
21
+ import { readAccountEnablement, } from "./enablement.js";
28
22
  async function safeText(res) {
29
23
  try {
30
24
  return (await res.text()).slice(0, 300);
@@ -48,32 +42,67 @@ function unwrapMe(raw) {
48
42
  }
49
43
  /** The real HTTP-backed ConsentBackend, pointed at a Vincentt API base URL. */
50
44
  export function httpConsentBackend() {
51
- const strip = (u) => u.replace(/\/$/, "");
45
+ const call = async (apiUrl, pat, path, init) => {
46
+ const res = await apiRequest({ apiUrl, pat }, path, init, { authenticated: true });
47
+ await rejectUpgradeRequired(res);
48
+ return res;
49
+ };
52
50
  return {
53
- async read(apiUrl, pat, now) {
54
- const res = await apiFetch(`${strip(apiUrl)}/me`, {
55
- headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
56
- });
57
- if (!res.ok) {
58
- throw new Error(authFailureMessage("Could not read your account", res.status, await safeText(res)));
51
+ async read(apiUrl, pat, now, timeoutMs) {
52
+ const fetchImpl = timeoutMs === undefined
53
+ ? apiFetch
54
+ : withDeadline((input, init) => globalThis.fetch(input, init), timeoutMs);
55
+ let res;
56
+ let body;
57
+ try {
58
+ res = await apiRequest({ apiUrl, pat }, "/me", {}, { authenticated: true }, fetchImpl);
59
+ if (res.status === 401)
60
+ rejectAuthExpired(res);
61
+ if (!res.ok)
62
+ return { kind: "unreadable" };
63
+ body = await res.json();
64
+ }
65
+ catch (err) {
66
+ if (err instanceof AuthExpiredError)
67
+ throw err;
68
+ // A 200 whose body does not parse (a captive portal, a proxy page) is not an
69
+ // account answer, so it must not clear this machine's flag.
70
+ return { kind: "unreadable" };
59
71
  }
60
- return readAccountConsent(unwrapMe(await res.json()), now);
72
+ const enablement = readAccountEnablement(unwrapMe(body), now);
73
+ return enablement.enabled
74
+ ? { kind: "granted", enablement }
75
+ : { kind: "not_granted" };
61
76
  },
62
77
  async grant(apiUrl, pat, now) {
63
- const res = await apiFetch(`${strip(apiUrl)}/me/dx-consent`, {
64
- method: "POST",
65
- headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
66
- });
67
- if (!res.ok) {
68
- throw new Error(authFailureMessage("Could not record consent", res.status, await safeText(res)));
78
+ let res;
79
+ try {
80
+ res = await call(apiUrl, pat, "/me/dx-consent", { method: "POST" });
81
+ }
82
+ catch (err) {
83
+ if (err instanceof UpgradeRequiredError)
84
+ throw err;
85
+ return { kind: "unreachable" };
86
+ }
87
+ rejectAuthExpired(res);
88
+ if (res.status === 429)
89
+ return { kind: "rate_limited" };
90
+ if (!res.ok)
91
+ return { kind: "unreachable" };
92
+ let body;
93
+ try {
94
+ body = await res.json();
95
+ }
96
+ catch {
97
+ return { kind: "not_granted" };
69
98
  }
70
- return readAccountConsent(unwrapMe(await res.json()), now);
99
+ const enablement = readAccountEnablement(unwrapMe(body), now);
100
+ return enablement.enabled
101
+ ? { kind: "granted", enablement }
102
+ : { kind: "not_granted" };
71
103
  },
72
104
  async revoke(apiUrl, pat) {
73
- const res = await apiFetch(`${strip(apiUrl)}/me/dx-consent`, {
74
- method: "DELETE",
75
- headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
76
- });
105
+ const res = await call(apiUrl, pat, "/me/dx-consent", { method: "DELETE" });
77
106
  if (!res.ok) {
78
107
  throw new Error(authFailureMessage("Could not revoke consent", res.status, await safeText(res)));
79
108
  }
@@ -11,21 +11,21 @@
11
11
  */
12
12
  export declare const DX_UNATTENDED_SUNSET: number;
13
13
  /**
14
- * WHERE the grant was made. Consent can now be given in the console (per-ACCOUNT, so it
15
- * covers every machine the creator signs in on) or at this terminal (per-MACHINE).
16
- *
17
- * The source is carried rather than inferred because the two have different blast radii
18
- * and different places to revoke. A creator reading `ENABLED` on a machine they never
19
- * ran `dx enable` on has no way to act on it without being told the grant lives in their
20
- * account — they would go looking for a local flag that is not there.
14
+ * WHERE the answer came from. The account is the authority whenever GET /me answers;
15
+ * `machine` means this machine's cached copy was used because the account could not be
16
+ * read. The two are reported differently because only one of them was checked just now.
21
17
  */
22
- export type EnablementSource = "console" | "machine";
18
+ export type EnablementSource = "account" | "machine";
19
+ /** Where the person answered the account grant, as the api recorded it. */
20
+ export type EnablementVia = "cli" | "console";
23
21
  export interface Enablement {
24
22
  enabled: boolean;
25
23
  /** The raw ISO8601 grant time. Present only when `enabled` is true. */
26
24
  at?: string;
27
- /** Where the grant came from. Present only when `enabled` is true. */
25
+ /** Where the answer came from. Present only when `enabled` is true. */
28
26
  source?: EnablementSource;
27
+ /** Account grants only, and only when the api recorded it. */
28
+ via?: EnablementVia;
29
29
  }
30
30
  /**
31
31
  * Decide enablement from an already-parsed config object. Pure, and STRICT:
@@ -49,7 +49,57 @@ export declare function readEnablement(cfg: unknown, now: number): Enablement;
49
49
  * stops being honored too, or "forgetting the sunset turns the feature off" would only be
50
50
  * true of the local half.
51
51
  */
52
- export declare function readAccountConsent(raw: unknown, now: number): Enablement;
52
+ export declare function readAccountEnablement(raw: unknown, now: number): Enablement;
53
+ /** GET /me, as the consent gate and the visibility surfaces need it. */
54
+ export type AccountRead = {
55
+ kind: "granted";
56
+ enablement: Enablement;
57
+ } | {
58
+ kind: "not_granted";
59
+ } | {
60
+ kind: "unreadable";
61
+ };
62
+ /**
63
+ * What the consent surfaces show: the answer that decides (`shown`), this machine's copy
64
+ * (`local`, after any clear), and where the answer was checked.
65
+ */
66
+ export interface ConsentView {
67
+ shown: Enablement;
68
+ local: Enablement;
69
+ checked: "account" | "machine";
70
+ signedIn: boolean;
71
+ }
72
+ export interface ConsentViewDeps {
73
+ auth: {
74
+ apiUrl: string;
75
+ pat: string;
76
+ } | undefined;
77
+ /** Allowed to throw; a throw reads as unreadable. */
78
+ readAccount?: (auth: {
79
+ apiUrl: string;
80
+ pat: string;
81
+ }) => Promise<AccountRead>;
82
+ local: () => Promise<Enablement> | Enablement;
83
+ /** Clears this machine's copy. Allowed to throw; see below. */
84
+ clearLocal: () => Promise<unknown>;
85
+ /** Bounds the account read from outside, so a backend that hangs cannot hang the caller. */
86
+ timeoutMs?: number;
87
+ }
88
+ /**
89
+ * The account-first read shared by the send gate, `vincentt dx` and `vincentt version`
90
+ * (f7 Scenario 3, 3c). The account decides when it answers; a "not granted" answer clears
91
+ * this machine's copy so a revoke made elsewhere reaches it. Only when the account cannot
92
+ * be read does the local flag stand on its own.
93
+ */
94
+ export declare function resolveConsentView(deps: ConsentViewDeps): Promise<ConsentView>;
95
+ /** `dxConsent` in `vincentt dx --json` (f7 Scenario 3c). */
96
+ export interface DxConsentJson {
97
+ state: "on" | "off" | "unknown";
98
+ checked: "account" | "machine";
99
+ at?: string;
100
+ via?: EnablementVia;
101
+ }
102
+ export declare function dxConsentJson(view: ConsentView): DxConsentJson;
53
103
  /**
54
104
  * Read enablement from the real machine config.
55
105
  *
@@ -66,8 +116,10 @@ export declare function grantedOn(at: string): string;
66
116
  /**
67
117
  * The one visibility line, in the single pinned shape every human surface uses:
68
118
  *
119
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted from the CLI
69
120
  * Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
70
- * Unattended send: ENABLED since 2026-04-01 (145 days) · granted on this machine
121
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · on your account
122
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · this machine (account not checked)
71
123
  *
72
124
  * Returns undefined when not enabled. Nothing is printed in that case, on
73
125
  * purpose: a line that prints unconditionally is a line readers learn to skip,