@vincentt-xr/cli 0.1.5 → 0.3.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 (92) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +14 -0
  3. package/dist/cli/args.js +51 -0
  4. package/dist/cli/index.js +416 -100
  5. package/dist/lib/backend.d.ts +44 -20
  6. package/dist/lib/backend.js +98 -51
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +85 -19
  12. package/dist/lib/lifecycle.js +133 -130
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +52 -25
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/publish/api.d.ts +67 -0
  51. package/dist/publish/api.js +96 -0
  52. package/dist/publish/bounds.d.ts +25 -0
  53. package/dist/publish/bounds.js +53 -0
  54. package/dist/publish/classify.d.ts +51 -0
  55. package/dist/publish/classify.js +138 -0
  56. package/dist/publish/command.d.ts +27 -0
  57. package/dist/publish/command.js +328 -0
  58. package/dist/publish/consoleVerbs.d.ts +17 -0
  59. package/dist/publish/consoleVerbs.js +26 -0
  60. package/dist/publish/copy.d.ts +180 -0
  61. package/dist/publish/copy.js +606 -0
  62. package/dist/publish/defaults.d.ts +16 -0
  63. package/dist/publish/defaults.js +52 -0
  64. package/dist/publish/manifest.d.ts +27 -0
  65. package/dist/publish/manifest.js +81 -0
  66. package/dist/publish/paths.d.ts +25 -0
  67. package/dist/publish/paths.js +78 -0
  68. package/dist/publish/progress.d.ts +17 -0
  69. package/dist/publish/progress.js +45 -0
  70. package/dist/publish/prompt.d.ts +4 -0
  71. package/dist/publish/prompt.js +31 -0
  72. package/dist/publish/qa.f4.fixtures.d.ts +25 -0
  73. package/dist/publish/qa.f4.fixtures.js +140 -0
  74. package/dist/publish/upload.d.ts +30 -0
  75. package/dist/publish/upload.js +93 -0
  76. package/dist/relay/server.d.ts +32 -0
  77. package/dist/relay/server.js +80 -12
  78. package/dist/relay/store.d.ts +32 -3
  79. package/dist/relay/store.js +76 -22
  80. package/dist/relay/testers.d.ts +90 -0
  81. package/dist/relay/testers.js +144 -0
  82. package/dist/shared/authError.d.ts +1 -0
  83. package/dist/shared/authError.js +11 -0
  84. package/dist/shared/config.d.ts +73 -5
  85. package/dist/shared/config.js +133 -20
  86. package/package.json +2 -3
  87. package/dist/login/login.d.ts +0 -34
  88. package/dist/login/login.js +0 -148
  89. package/dist/preview/cloudflared.d.ts +0 -13
  90. package/dist/preview/cloudflared.js +0 -46
  91. package/dist/preview/tunnel.d.ts +0 -14
  92. package/dist/preview/tunnel.js +0 -28
@@ -8,8 +8,10 @@ import { randomBytes } from "node:crypto";
8
8
  import path from "node:path";
9
9
  import { WebSocketServer } from "ws";
10
10
  import { EventStore } from "./store.js";
11
+ import { TesterRegistry, isTesterId } from "./testers.js";
11
12
  import { AnnotationStore, annotationsDir } from "./annotations.js";
12
13
  import { claudeCodeInjector, createDispatcher, pasteInjector, } from "./injectors.js";
14
+ import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "@vincentt-xr/harness/tunnel";
13
15
  /** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
14
16
  const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
15
17
  /** Clamp the long-poll hold so a request can't park forever. */
@@ -18,6 +20,7 @@ export function startRelay(opts = {}) {
18
20
  const port = opts.port ?? 7331;
19
21
  const wsPath = opts.path ?? "/__harness";
20
22
  const store = new EventStore(opts.capacity);
23
+ const testers = opts.testerRegistry ?? new TesterRegistry();
21
24
  const log = opts.onLog ?? (() => undefined);
22
25
  const makeId = opts.makeId ?? (() => randomBytes(3).toString("hex"));
23
26
  // Reverse channel is enabled when we know which project to bind annotations to.
@@ -34,15 +37,44 @@ export function startRelay(opts = {}) {
34
37
  pasteInjector((p) => log(`annotation ready to paste:\n${p}`)),
35
38
  ], log);
36
39
  }
37
- const http = createServer((req, res) => handleHttp(req, res, { store, annotations, dispatcher, wsPath, makeId, log }));
40
+ const http = createServer((req, res) => handleHttp(req, res, {
41
+ store,
42
+ annotations,
43
+ dispatcher,
44
+ testers,
45
+ wsPath,
46
+ makeId,
47
+ log,
48
+ onCapReached: opts.onCapReached,
49
+ }));
38
50
  const wss = new WebSocketServer({ server: http, path: wsPath });
39
- wss.on("connection", (ws) => {
40
- log("client connected");
51
+ wss.on("connection", (ws, req) => {
52
+ // The label comes from the CONNECTION, stamped by the tunnel client on this
53
+ // machine after it re-validated the edge's value. It is re-validated ONCE
54
+ // MORE here — this is the last hop before a string reaches the terminal and
55
+ // MCP tool output, which for this product is adjacent to prompt injection,
56
+ // and a check that runs only upstream is a check a direct localhost client
57
+ // skips.
58
+ const viewer = readViewerLabel(req.headers[VIEWER_STAMP_HEADER]);
59
+ log(viewer ? `client connected · ${viewer}` : "client connected");
60
+ // The tester is resolved ONCE per connection, from the first batch's key.
61
+ // Re-resolving per batch would let one socket's events land under two ids if
62
+ // a client changed its key mid-stream.
63
+ let tester;
64
+ let resolved = false;
41
65
  ws.on("message", (data) => {
42
66
  try {
43
67
  const msg = JSON.parse(String(data));
44
68
  if (msg.type === "events" && Array.isArray(msg.events)) {
45
- store.ingest(msg.sessionId, msg.events);
69
+ if (!resolved) {
70
+ resolved = true;
71
+ const attribution = testers.attribute(msg.sessionId);
72
+ tester = attribution.tester;
73
+ if (attribution.warnCapReached)
74
+ opts.onCapReached?.();
75
+ }
76
+ store.ingest(msg.events, { viewer, tester });
77
+ opts.onEvents?.(msg.events, { viewer, tester });
46
78
  }
47
79
  }
48
80
  catch {
@@ -55,6 +87,7 @@ export function startRelay(opts = {}) {
55
87
  return {
56
88
  store,
57
89
  annotations,
90
+ testers,
58
91
  close: () => new Promise((resolve) => {
59
92
  annotations?.close();
60
93
  wss.close();
@@ -62,6 +95,17 @@ export function startRelay(opts = {}) {
62
95
  }),
63
96
  };
64
97
  }
98
+ /**
99
+ * readViewerLabel accepts ONLY `^viewer-[0-9]{1,4}$` and returns undefined for
100
+ * everything else — an array-valued header (a duplicate), a forged class, an
101
+ * ANSI escape, a newline. There is no "sanitize and keep" branch: a label that
102
+ * does not match is not a label, and the events simply carry no viewer.
103
+ */
104
+ export function readViewerLabel(raw) {
105
+ if (typeof raw !== "string")
106
+ return undefined;
107
+ return VIEWER_LABEL_RE.test(raw) ? raw : undefined;
108
+ }
65
109
  function handleHttp(req, res, ctx) {
66
110
  const url = new URL(req.url ?? "/", "http://localhost");
67
111
  const p = url.pathname;
@@ -105,10 +149,16 @@ function handleAnnotationPost(req, res, ctx) {
105
149
  res.writeHead(400).end("annotation requires message + screenshot");
106
150
  return;
107
151
  }
108
- const annotation = await ctx.annotations.add(input, {
109
- now: Date.now(),
110
- id: ctx.makeId(),
111
- });
152
+ // The annotation's session key is RESOLVED, never assigned — `resolve`,
153
+ // not `attribute`. This route is unauthenticated and reachable by anyone
154
+ // holding the capability URL, so a door that minted here would let 64 bare
155
+ // POSTs exhaust the registry before the creator's phone connected, and the
156
+ // terminal only prints attributed events.
157
+ //
158
+ // The key is still hostile input either way: the annotation is appended to
159
+ // a queue a running Claude Code /loop drains, so the raw string must never
160
+ // survive onto the record.
161
+ const annotation = await ctx.annotations.add({ ...input, sessionId: ctx.testers.resolve(input.sessionId) }, { now: Date.now(), id: ctx.makeId() });
112
162
  // Fire the injector chain best-effort — a failed dispatch must not fail the
113
163
  // POST; the long-poll consumer still sees the persisted annotation.
114
164
  if (ctx.dispatcher) {
@@ -150,22 +200,40 @@ function handleFeedbackNext(res, url, ctx) {
150
200
  res.writeHead(400).end();
151
201
  });
152
202
  }
203
+ /**
204
+ * A `tester` filter that is not a well-formed id becomes a sentinel that matches
205
+ * no stored tag, so it filters to an EMPTY list rather than erroring or being
206
+ * dropped (which would silently widen the query to everyone). The sentinel
207
+ * cannot collide with a real id: the registry only ever mints `t<n>`.
208
+ */
209
+ const NO_SUCH_TESTER = "none";
210
+ function normalizeTester(q) {
211
+ if (q.tester === undefined)
212
+ return q;
213
+ return isTesterId(q.tester) ? q : { ...q, tester: NO_SUCH_TESTER };
214
+ }
153
215
  async function readQuery(req, url) {
154
216
  if (req.method === "POST") {
155
217
  const body = await readBody(req);
156
- return body ? JSON.parse(body) : {};
218
+ return normalizeTester(body ? JSON.parse(body) : {});
157
219
  }
158
220
  const q = {};
159
221
  const p = url.searchParams;
160
- if (p.get("sessionId"))
161
- q.sessionId = p.get("sessionId");
162
222
  if (p.get("kind"))
163
223
  q.kind = p.get("kind");
164
224
  if (p.get("since"))
165
225
  q.since = Number(p.get("since"));
166
226
  if (p.get("limit"))
167
227
  q.limit = Number(p.get("limit"));
168
- return q;
228
+ // `tester` and `viewer` both filter to NOTHING when malformed rather than
229
+ // being ignored: silently widening a filter would show the creator several
230
+ // testers' events under a heading naming one. There is no `sessionId` param —
231
+ // it was the only surface that would teach a creator the raw key exists.
232
+ if (p.get("tester"))
233
+ q.tester = p.get("tester");
234
+ if (p.get("viewer"))
235
+ q.viewer = p.get("viewer");
236
+ return normalizeTester(q);
169
237
  }
170
238
  function readBody(req, maxBytes = Infinity) {
171
239
  return new Promise((resolve, reject) => {
@@ -2,12 +2,41 @@ import type { DiagEvent, RelayQuery, RelayResult } from "@vincentt-xr/harness/ev
2
2
  export declare class EventStore {
3
3
  private readonly capacity;
4
4
  private readonly buf;
5
- private readonly sessionsSeen;
5
+ /**
6
+ * Insertion-ordered, so the roster reads in arrival order rather than in
7
+ * whatever order a hash lands. Labels are never removed: a viewer that
8
+ * disconnected 20 seconds ago still has events in this buffer, and dropping
9
+ * its label would make them unfilterable.
10
+ */
11
+ private readonly viewersSeen;
12
+ /**
13
+ * Same rule, same reason, for testers — and it matters MORE here, because
14
+ * `--tester t1` is what a creator runs at the moment the tester who hit the
15
+ * bug has already closed their tab.
16
+ *
17
+ * Note what is NOT here: a set of raw client session keys. The store used to
18
+ * hold one and return it in `RelayResult.sessions`, which put a
19
+ * viewer-controlled string on the terminal and in MCP tool output. The raw key
20
+ * never enters this file; the relay maps it to a tester id first.
21
+ */
22
+ private readonly testersSeen;
6
23
  private latest;
7
24
  constructor(capacity?: number);
8
- /** Ingest a batch from a client. Tracks the session and advances latestSeq. */
9
- ingest(sessionId: string, events: DiagEvent[]): void;
25
+ /**
26
+ * Ingest a batch from a client. Both the VIEWER and the TESTER come from the
27
+ * connection's own resolution, never from the payload — the device never
28
+ * proposes its own label (B22), and an identifier taken from the body would be
29
+ * a viewer-supplied string flowing to the terminal and to MCP tool output.
30
+ */
31
+ ingest(events: DiagEvent[], tags?: {
32
+ viewer?: string;
33
+ tester?: string;
34
+ }): void;
10
35
  /** Answer an MCP query against the buffer. Newest-biased when limited. */
11
36
  query(q?: RelayQuery): RelayResult;
37
+ /** The label an event carries, for the live terminal stream. */
38
+ viewerFor(e: DiagEvent): string | undefined;
39
+ /** The tester an event carries, for the live terminal stream. */
40
+ testerFor(e: DiagEvent): string | undefined;
12
41
  size(): number;
13
42
  }
@@ -1,22 +1,52 @@
1
1
  // The relay's memory: a bounded ring buffer of recent diagnostics events,
2
2
  // queryable the way the MCP server asks ("network events since seq 40", "last
3
- // 20 errors"). Pure and dependency-free so it is fully unit-testable; the WS
4
- // server in server.ts is a thin wrapper that feeds this and answers queries
5
- // from it.
3
+ // 20 errors", "just tester t2"). Pure and dependency-free so it is fully
4
+ // unit-testable; the WS server in server.ts is a thin wrapper that feeds this
5
+ // and answers queries from it.
6
+ //
7
+ // This buffer is where diagnostics content lives, and the ONLY place besides
8
+ // the phone's memory (B19). It is on the CREATOR'S OWN MACHINE.
6
9
  export class EventStore {
7
10
  capacity;
8
11
  buf = [];
9
- sessionsSeen = new Set();
12
+ /**
13
+ * Insertion-ordered, so the roster reads in arrival order rather than in
14
+ * whatever order a hash lands. Labels are never removed: a viewer that
15
+ * disconnected 20 seconds ago still has events in this buffer, and dropping
16
+ * its label would make them unfilterable.
17
+ */
18
+ viewersSeen = new Set();
19
+ /**
20
+ * Same rule, same reason, for testers — and it matters MORE here, because
21
+ * `--tester t1` is what a creator runs at the moment the tester who hit the
22
+ * bug has already closed their tab.
23
+ *
24
+ * Note what is NOT here: a set of raw client session keys. The store used to
25
+ * hold one and return it in `RelayResult.sessions`, which put a
26
+ * viewer-controlled string on the terminal and in MCP tool output. The raw key
27
+ * never enters this file; the relay maps it to a tester id first.
28
+ */
29
+ testersSeen = new Set();
10
30
  latest = -1;
11
31
  constructor(capacity = 5000) {
12
32
  this.capacity = capacity;
13
33
  }
14
- /** Ingest a batch from a client. Tracks the session and advances latestSeq. */
15
- ingest(sessionId, events) {
16
- this.sessionsSeen.add(sessionId);
34
+ /**
35
+ * Ingest a batch from a client. Both the VIEWER and the TESTER come from the
36
+ * connection's own resolution, never from the payload — the device never
37
+ * proposes its own label (B22), and an identifier taken from the body would be
38
+ * a viewer-supplied string flowing to the terminal and to MCP tool output.
39
+ */
40
+ ingest(events, tags = {}) {
41
+ const { viewer, tester } = tags;
42
+ if (viewer !== undefined)
43
+ this.viewersSeen.add(viewer);
44
+ if (tester !== undefined)
45
+ this.testersSeen.add(tester);
17
46
  for (const e of events) {
18
- // Tag ownership on the stored copy so cross-session queries can filter.
19
- this.buf.push(withSession(e, sessionId));
47
+ // Tag ownership on the stored copy so per-tester and per-viewer queries
48
+ // can filter.
49
+ this.buf.push(withTags(e, viewer, tester));
20
50
  if (e.seq > this.latest)
21
51
  this.latest = e.seq;
22
52
  }
@@ -28,7 +58,12 @@ export class EventStore {
28
58
  /** Answer an MCP query against the buffer. Newest-biased when limited. */
29
59
  query(q = {}) {
30
60
  let out = this.buf.filter((e) => {
31
- if (q.sessionId && sessionOf(e) !== q.sessionId)
61
+ // Filters INTERSECT. A caller passing both wants the events matching both;
62
+ // ORing them would answer a question nobody asked with more data than they
63
+ // requested, under a heading naming one.
64
+ if (q.tester && testerOf(e) !== q.tester)
65
+ return false;
66
+ if (q.viewer && viewerOf(e) !== q.viewer)
32
67
  return false;
33
68
  if (q.kind && e.kind !== q.kind)
34
69
  return false;
@@ -40,29 +75,48 @@ export class EventStore {
40
75
  out = out.slice(out.length - q.limit); // keep the newest `limit`
41
76
  }
42
77
  return {
43
- events: out.map(stripSession),
78
+ events: out.map(stripTags),
79
+ // Parallel to `events`, so a follower can print the label in column one
80
+ // without inventing a placeholder for an unattributed event.
81
+ eventViewers: out.map(viewerOf),
82
+ eventTesters: out.map(testerOf),
44
83
  latestSeq: this.latest,
45
- sessions: [...this.sessionsSeen],
84
+ testers: [...this.testersSeen],
85
+ viewers: [...this.viewersSeen],
46
86
  };
47
87
  }
88
+ /** The label an event carries, for the live terminal stream. */
89
+ viewerFor(e) {
90
+ return viewerOf(e);
91
+ }
92
+ /** The tester an event carries, for the live terminal stream. */
93
+ testerFor(e) {
94
+ return testerOf(e);
95
+ }
48
96
  size() {
49
97
  return this.buf.length;
50
98
  }
51
99
  }
52
- // The store tags each event with its owning session out-of-band (a symbol key)
53
- // so it survives in the buffer without polluting the wire type. Queries strip
54
- // it before returning.
55
- const SESSION = Symbol("session");
56
- function withSession(e, sessionId) {
100
+ // The store tags each event with its owning viewer and tester out-of-band
101
+ // (symbol keys) so they survive in the buffer without polluting the wire type.
102
+ // Queries strip them before returning.
103
+ const VIEWER = Symbol("viewer");
104
+ const TESTER = Symbol("tester");
105
+ function withTags(e, viewer, tester) {
57
106
  return Object.assign(Object.create(Object.getPrototypeOf(e)), e, {
58
- [SESSION]: sessionId,
107
+ ...(viewer === undefined ? {} : { [VIEWER]: viewer }),
108
+ ...(tester === undefined ? {} : { [TESTER]: tester }),
59
109
  });
60
110
  }
61
- function sessionOf(e) {
62
- return e[SESSION];
111
+ function viewerOf(e) {
112
+ return e[VIEWER];
113
+ }
114
+ function testerOf(e) {
115
+ return e[TESTER];
63
116
  }
64
- function stripSession(e) {
117
+ function stripTags(e) {
65
118
  const copy = { ...e };
66
- delete copy[SESSION];
119
+ delete copy[VIEWER];
120
+ delete copy[TESTER];
67
121
  return copy;
68
122
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Attributed testers per run. Justified against D-Many-viewers-are-normal's
3
+ * 40-person demo, NOT against the platform's MaxConcurrentStreams = 256 — those
4
+ * count different things. 256 is per-session CONCURRENT CONNECTIONS, and one tab
5
+ * opens 2-6 under HTTP/1.1, so 64 tabs already exceeds what the platform admits
6
+ * and the platform's cap binds first. Memory does not choose this number: 64
7
+ * entries is about 4 KB.
8
+ *
9
+ * That argument holds ONLY because minting requires a WebSocket the harness
10
+ * opened — see `resolve` versus `attribute`. A door that could mint without a
11
+ * stream would not be bounded by the platform's stream cap at all, and filling
12
+ * this registry is a denial of the creator's live terminal, not merely of an id.
13
+ */
14
+ export declare const MAX_TESTERS = 64;
15
+ /**
16
+ * Refused keys remembered, so the cap warning prints ONCE per refused session
17
+ * rather than once per batch.
18
+ *
19
+ * This set is what makes print-once bounded. The key is client-supplied, so
20
+ * without a cap here its size is a number a client chooses by cycling
21
+ * sessionStorage. Past this many, further sessions are still refused but no
22
+ * longer remembered — so a pathological client can re-trigger its own warning
23
+ * line. That is the correct failure: noisy, local and self-inflicted, versus
24
+ * unbounded memory on the creator's machine.
25
+ */
26
+ export declare const MAX_REFUSED_TOMBSTONES = 256;
27
+ /** What the relay learned from one batch's key. */
28
+ export interface Attribution {
29
+ /** The tester id, or undefined when this key is not attributed. */
30
+ readonly tester?: string;
31
+ /**
32
+ * True exactly once per refused session — the batch that should print the cap
33
+ * warning. Every later batch from the same refused key reports false.
34
+ */
35
+ readonly warnCapReached: boolean;
36
+ }
37
+ export declare class TesterRegistry {
38
+ private readonly maxTesters;
39
+ private readonly maxRefused;
40
+ /** Insertion-ordered, so ids read in arrival order. */
41
+ private readonly byKey;
42
+ /** Refused keys, for print-once. Holds no id — these were never assigned one. */
43
+ private readonly refused;
44
+ private next;
45
+ constructor(maxTesters?: number, maxRefused?: number);
46
+ /**
47
+ * Resolve a client-supplied key to a tester id, assigning one on first sight.
48
+ *
49
+ * The key is validated against the SAME grammar the client mints, with no
50
+ * sanitize-and-keep branch — the readViewerLabel discipline, applied to the
51
+ * field beside the label that never had it. A key that misses the grammar is
52
+ * un-attributed: its events are still ingested, only the tester tag is absent.
53
+ *
54
+ * Entries are NEVER removed for the life of the process. That mirrors
55
+ * viewersSeen in the event store and for its stated reason: the ring buffer
56
+ * holds events that outlive their producer, and dropping a tester's id would
57
+ * make its already-stored events unfilterable — worse than never having
58
+ * labelled them. `--tester t1` must keep answering for the tester who just hit
59
+ * the bug, because that is the moment the creator runs it.
60
+ */
61
+ attribute(key: unknown): Attribution;
62
+ /**
63
+ * Resolve a key WITHOUT ever assigning one — the read-only door.
64
+ *
65
+ * The annotation POST uses this, and the distinction is load-bearing. That
66
+ * route is UNAUTHENTICATED and reachable by anyone holding the preview's
67
+ * capability URL, so if it could mint, 64 bare `fetch` calls would exhaust the
68
+ * registry before the creator's own phone ever connected. Entries are never
69
+ * evicted, so that state would be permanent for the run — and because the
70
+ * terminal only prints attributed events, the creator's live stream would go
71
+ * blank for every real tester.
72
+ *
73
+ * Minting requires a WebSocket the harness actually opened. An annotation can
74
+ * therefore be attributed to a tester that already exists, and is otherwise
75
+ * un-attributed, which is the correct failure: the creator still gets their
76
+ * bug report, it just carries no id.
77
+ */
78
+ resolve(key: unknown): string | undefined;
79
+ /** Ids assigned so far, in arrival order. Never includes a refused session. */
80
+ testers(): string[];
81
+ /** How many keys hold an id. Bounded by maxTesters. */
82
+ get size(): number;
83
+ /** Whether this id was ever assigned — for `--tester` miss messages. */
84
+ has(tester: string): boolean;
85
+ }
86
+ /**
87
+ * Is this a well-formed tester id? Used at the query boundary so a malformed
88
+ * `--tester` filters to nothing rather than erroring or matching broadly.
89
+ */
90
+ export declare function isTesterId(s: unknown): s is string;
@@ -0,0 +1,144 @@
1
+ // The tester registry: the one place a client-supplied session key becomes a
2
+ // tester id the creator can read.
3
+ //
4
+ // A TESTER is one browser tab on one preview, recognised across refreshes. It is
5
+ // deliberately NOT called a device: a tab is what the key can honestly
6
+ // distinguish, one person with two tabs is two testers, and over-claiming is how
7
+ // a debugging coordinate turns into an identity.
8
+ //
9
+ // The whole structure lives in the `vincentt preview` process and dies with it.
10
+ // It is NEVER persisted: a tester id surviving a restart would be exactly the
11
+ // cross-session correlator this design refuses (B22).
12
+ //
13
+ // Pure and dependency-free, like store.ts beside it, so every rule below is
14
+ // unit-testable without a socket.
15
+ import { CLIENT_SESSION_ID_RE, TESTER_ID_RE } from "@vincentt-xr/harness/events";
16
+ /**
17
+ * Attributed testers per run. Justified against D-Many-viewers-are-normal's
18
+ * 40-person demo, NOT against the platform's MaxConcurrentStreams = 256 — those
19
+ * count different things. 256 is per-session CONCURRENT CONNECTIONS, and one tab
20
+ * opens 2-6 under HTTP/1.1, so 64 tabs already exceeds what the platform admits
21
+ * and the platform's cap binds first. Memory does not choose this number: 64
22
+ * entries is about 4 KB.
23
+ *
24
+ * That argument holds ONLY because minting requires a WebSocket the harness
25
+ * opened — see `resolve` versus `attribute`. A door that could mint without a
26
+ * stream would not be bounded by the platform's stream cap at all, and filling
27
+ * this registry is a denial of the creator's live terminal, not merely of an id.
28
+ */
29
+ export const MAX_TESTERS = 64;
30
+ /**
31
+ * Refused keys remembered, so the cap warning prints ONCE per refused session
32
+ * rather than once per batch.
33
+ *
34
+ * This set is what makes print-once bounded. The key is client-supplied, so
35
+ * without a cap here its size is a number a client chooses by cycling
36
+ * sessionStorage. Past this many, further sessions are still refused but no
37
+ * longer remembered — so a pathological client can re-trigger its own warning
38
+ * line. That is the correct failure: noisy, local and self-inflicted, versus
39
+ * unbounded memory on the creator's machine.
40
+ */
41
+ export const MAX_REFUSED_TOMBSTONES = 256;
42
+ const UNATTRIBUTED = { warnCapReached: false };
43
+ export class TesterRegistry {
44
+ maxTesters;
45
+ maxRefused;
46
+ /** Insertion-ordered, so ids read in arrival order. */
47
+ byKey = new Map();
48
+ /** Refused keys, for print-once. Holds no id — these were never assigned one. */
49
+ refused = new Set();
50
+ next = 1;
51
+ constructor(maxTesters = MAX_TESTERS, maxRefused = MAX_REFUSED_TOMBSTONES) {
52
+ this.maxTesters = maxTesters;
53
+ this.maxRefused = maxRefused;
54
+ // TESTER_ID_RE tops out at t999, and `--tester` validates against it. A cap
55
+ // above that would mint ids the filter then refuses, making those testers
56
+ // permanently unselectable — a silent failure that would surface only to
57
+ // whoever raised the cap.
58
+ if (maxTesters > 999) {
59
+ throw new Error(`maxTesters ${maxTesters} exceeds the t999 id grammar`);
60
+ }
61
+ }
62
+ /**
63
+ * Resolve a client-supplied key to a tester id, assigning one on first sight.
64
+ *
65
+ * The key is validated against the SAME grammar the client mints, with no
66
+ * sanitize-and-keep branch — the readViewerLabel discipline, applied to the
67
+ * field beside the label that never had it. A key that misses the grammar is
68
+ * un-attributed: its events are still ingested, only the tester tag is absent.
69
+ *
70
+ * Entries are NEVER removed for the life of the process. That mirrors
71
+ * viewersSeen in the event store and for its stated reason: the ring buffer
72
+ * holds events that outlive their producer, and dropping a tester's id would
73
+ * make its already-stored events unfilterable — worse than never having
74
+ * labelled them. `--tester t1` must keep answering for the tester who just hit
75
+ * the bug, because that is the moment the creator runs it.
76
+ */
77
+ attribute(key) {
78
+ if (typeof key !== "string" || !CLIENT_SESSION_ID_RE.test(key))
79
+ return UNATTRIBUTED;
80
+ const existing = this.byKey.get(key);
81
+ if (existing !== undefined)
82
+ return { tester: existing, warnCapReached: false };
83
+ if (this.byKey.size >= this.maxTesters) {
84
+ // REFUSE. Never recycle.
85
+ //
86
+ // Every natural cap implementation merges or splits silently. `hash(key) %
87
+ // N` is the one a reader reaches for because it is stateless, and it
88
+ // deterministically collides the 65th tester with an existing one.
89
+ // LRU-evict is the same failure mirrored: one tester reappears as two.
90
+ // Refusing is the only branch whose failure is VISIBLE.
91
+ if (this.refused.has(key))
92
+ return UNATTRIBUTED;
93
+ if (this.refused.size < this.maxRefused)
94
+ this.refused.add(key);
95
+ return { warnCapReached: true };
96
+ }
97
+ const tester = `t${this.next++}`;
98
+ this.byKey.set(key, tester);
99
+ return { tester, warnCapReached: false };
100
+ }
101
+ /**
102
+ * Resolve a key WITHOUT ever assigning one — the read-only door.
103
+ *
104
+ * The annotation POST uses this, and the distinction is load-bearing. That
105
+ * route is UNAUTHENTICATED and reachable by anyone holding the preview's
106
+ * capability URL, so if it could mint, 64 bare `fetch` calls would exhaust the
107
+ * registry before the creator's own phone ever connected. Entries are never
108
+ * evicted, so that state would be permanent for the run — and because the
109
+ * terminal only prints attributed events, the creator's live stream would go
110
+ * blank for every real tester.
111
+ *
112
+ * Minting requires a WebSocket the harness actually opened. An annotation can
113
+ * therefore be attributed to a tester that already exists, and is otherwise
114
+ * un-attributed, which is the correct failure: the creator still gets their
115
+ * bug report, it just carries no id.
116
+ */
117
+ resolve(key) {
118
+ if (typeof key !== "string")
119
+ return undefined;
120
+ return this.byKey.get(key);
121
+ }
122
+ /** Ids assigned so far, in arrival order. Never includes a refused session. */
123
+ testers() {
124
+ return [...this.byKey.values()];
125
+ }
126
+ /** How many keys hold an id. Bounded by maxTesters. */
127
+ get size() {
128
+ return this.byKey.size;
129
+ }
130
+ /** Whether this id was ever assigned — for `--tester` miss messages. */
131
+ has(tester) {
132
+ for (const t of this.byKey.values())
133
+ if (t === tester)
134
+ return true;
135
+ return false;
136
+ }
137
+ }
138
+ /**
139
+ * Is this a well-formed tester id? Used at the query boundary so a malformed
140
+ * `--tester` filters to nothing rather than erroring or matching broadly.
141
+ */
142
+ export function isTesterId(s) {
143
+ return typeof s === "string" && TESTER_ID_RE.test(s);
144
+ }
@@ -0,0 +1 @@
1
+ export declare function authFailureMessage(label: string, status: number, detail: string): string;
@@ -0,0 +1,11 @@
1
+ // A rejected PAT (401 from an authenticated call) is, after the auth cutover, the
2
+ // universal first-run state: every existing `vct_` token was invalidated, so the raw
3
+ // "(401): <body>" is unactionable noise for every user at once. Name the cause and give
4
+ // the one instruction that fixes it. Any other status keeps its existing "(<n>): <body>"
5
+ // shape — a 500/timeout is a different problem and must not be mislabeled a stale token.
6
+ export function authFailureMessage(label, status, detail) {
7
+ if (status === 401) {
8
+ return "Your Vincentt access token is no longer valid — run `vincentt login` to sign in again.";
9
+ }
10
+ return `${label} (${status})${detail ? `: ${detail}` : ""}`;
11
+ }