@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.
- package/README.md +94 -0
- package/dist/cli/args.d.ts +14 -0
- package/dist/cli/args.js +51 -0
- package/dist/cli/index.js +416 -100
- package/dist/lib/backend.d.ts +44 -20
- package/dist/lib/backend.js +98 -51
- package/dist/lib/diagnostics.d.ts +27 -2
- package/dist/lib/diagnostics.js +73 -8
- package/dist/lib/initCommand.d.ts +14 -0
- package/dist/lib/initCommand.js +75 -0
- package/dist/lib/lifecycle.d.ts +85 -19
- package/dist/lib/lifecycle.js +133 -130
- package/dist/lib/org.d.ts +29 -0
- package/dist/lib/org.js +45 -0
- package/dist/lib/projects.d.ts +82 -0
- package/dist/lib/projects.js +217 -0
- package/dist/lib/relayInfo.d.ts +41 -3
- package/dist/lib/relayInfo.js +94 -0
- package/dist/lib/whoami.d.ts +11 -0
- package/dist/lib/whoami.js +38 -0
- package/dist/login/authBackend.d.ts +30 -0
- package/dist/login/authBackend.js +80 -0
- package/dist/login/guard.d.ts +13 -0
- package/dist/login/guard.js +17 -0
- package/dist/login/loginCommand.d.ts +39 -0
- package/dist/login/loginCommand.js +72 -0
- package/dist/login/pkce.d.ts +47 -0
- package/dist/login/pkce.js +106 -0
- package/dist/login/pkceLogin.d.ts +53 -0
- package/dist/login/pkceLogin.js +152 -0
- package/dist/mcp/server.js +52 -25
- package/dist/preview/api.d.ts +84 -0
- package/dist/preview/api.js +174 -0
- package/dist/preview/command.d.ts +96 -0
- package/dist/preview/command.js +276 -0
- package/dist/preview/copy.d.ts +209 -0
- package/dist/preview/copy.js +444 -0
- package/dist/preview/index.d.ts +2 -3
- package/dist/preview/index.js +5 -6
- package/dist/preview/proxy.d.ts +12 -1
- package/dist/preview/proxy.js +17 -7
- package/dist/preview/qr.d.ts +30 -0
- package/dist/preview/qr.js +89 -0
- package/dist/preview/runner.d.ts +155 -30
- package/dist/preview/runner.js +219 -68
- package/dist/preview/stream.d.ts +52 -0
- package/dist/preview/stream.js +171 -0
- package/dist/preview/viewers.d.ts +113 -0
- package/dist/preview/viewers.js +242 -0
- package/dist/publish/api.d.ts +67 -0
- package/dist/publish/api.js +96 -0
- package/dist/publish/bounds.d.ts +25 -0
- package/dist/publish/bounds.js +53 -0
- package/dist/publish/classify.d.ts +51 -0
- package/dist/publish/classify.js +138 -0
- package/dist/publish/command.d.ts +27 -0
- package/dist/publish/command.js +328 -0
- package/dist/publish/consoleVerbs.d.ts +17 -0
- package/dist/publish/consoleVerbs.js +26 -0
- package/dist/publish/copy.d.ts +180 -0
- package/dist/publish/copy.js +606 -0
- package/dist/publish/defaults.d.ts +16 -0
- package/dist/publish/defaults.js +52 -0
- package/dist/publish/manifest.d.ts +27 -0
- package/dist/publish/manifest.js +81 -0
- package/dist/publish/paths.d.ts +25 -0
- package/dist/publish/paths.js +78 -0
- package/dist/publish/progress.d.ts +17 -0
- package/dist/publish/progress.js +45 -0
- package/dist/publish/prompt.d.ts +4 -0
- package/dist/publish/prompt.js +31 -0
- package/dist/publish/qa.f4.fixtures.d.ts +25 -0
- package/dist/publish/qa.f4.fixtures.js +140 -0
- package/dist/publish/upload.d.ts +30 -0
- package/dist/publish/upload.js +93 -0
- package/dist/relay/server.d.ts +32 -0
- package/dist/relay/server.js +80 -12
- package/dist/relay/store.d.ts +32 -3
- package/dist/relay/store.js +76 -22
- package/dist/relay/testers.d.ts +90 -0
- package/dist/relay/testers.js +144 -0
- package/dist/shared/authError.d.ts +1 -0
- package/dist/shared/authError.js +11 -0
- package/dist/shared/config.d.ts +73 -5
- package/dist/shared/config.js +133 -20
- package/package.json +2 -3
- package/dist/login/login.d.ts +0 -34
- package/dist/login/login.js +0 -148
- package/dist/preview/cloudflared.d.ts +0 -13
- package/dist/preview/cloudflared.js +0 -46
- package/dist/preview/tunnel.d.ts +0 -14
- package/dist/preview/tunnel.js +0 -28
package/dist/relay/server.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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) => {
|
package/dist/relay/store.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
9
|
-
|
|
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
|
}
|
package/dist/relay/store.js
CHANGED
|
@@ -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
|
|
4
|
-
// server in server.ts is a thin wrapper that feeds this
|
|
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
|
-
|
|
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
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
53
|
-
// so
|
|
54
|
-
//
|
|
55
|
-
const
|
|
56
|
-
|
|
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
|
-
[
|
|
107
|
+
...(viewer === undefined ? {} : { [VIEWER]: viewer }),
|
|
108
|
+
...(tester === undefined ? {} : { [TESTER]: tester }),
|
|
59
109
|
});
|
|
60
110
|
}
|
|
61
|
-
function
|
|
62
|
-
return e[
|
|
111
|
+
function viewerOf(e) {
|
|
112
|
+
return e[VIEWER];
|
|
113
|
+
}
|
|
114
|
+
function testerOf(e) {
|
|
115
|
+
return e[TESTER];
|
|
63
116
|
}
|
|
64
|
-
function
|
|
117
|
+
function stripTags(e) {
|
|
65
118
|
const copy = { ...e };
|
|
66
|
-
delete copy[
|
|
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
|
+
}
|