@vincentt-xr/harness 1.0.0 → 1.2.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.
@@ -0,0 +1,318 @@
1
+ // The in-app Share control — the creator's desktop-to-phone handoff, rendered
2
+ // inside the running preview. An icon-only QR-glyph chip in the shared top-right
3
+ // cluster; clicking it drops a popover with an inline SVG QR of the preview origin,
4
+ // a heading + instruction, and the URL as selectable text (no Copy button).
5
+ //
6
+ // It re-presents ONLY `window.location.origin + "/"` — the capability URL the
7
+ // link-holder already has (B17). It makes NO network call and sends nothing to the
8
+ // relay (B19): pure client-side rendering of a string already in the page.
9
+ //
10
+ // DOM-only, no React. Loaded through the dynamic import in HarnessProvider, so the
11
+ // whole module — and `qrcode`, reached only from here — tree-shakes out of prod.
12
+ import QRCode from "qrcode";
13
+ import { getClusterContainer, releaseClusterContainer } from "./cluster.js";
14
+ /**
15
+ * The compiled-in preview-apex allowlist. Previews sit on their own registrable
16
+ * domain, `vincentt.dev` (f3's `D-Preview-is-its-own-registrable-domain`); staging
17
+ * and dev are subdomains of it (`*.staging.vincentt.dev`, `*.local.vincentt.dev`),
18
+ * so a single `.vincentt.dev` suffix matches every environment. The leading dot is a
19
+ * real subdomain-label boundary, so `<label>.vincentt.dev` matches but a bare
20
+ * `vincentt.dev` or a look-alike (`evilvincentt.dev`) does not. This is a public DNS
21
+ * name, not a secret — the same host the viewer's address bar already shows.
22
+ */
23
+ export const PREVIEW_APEXES = [".vincentt.dev"];
24
+ /**
25
+ * The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
26
+ * navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
27
+ * never encoded into the QR or the text field.
28
+ */
29
+ export function deriveShareUrl(location) {
30
+ return location.origin + "/";
31
+ }
32
+ /**
33
+ * Is the viewer a phone? True iff BOTH `(pointer: coarse)` and `(max-width: 820px)`
34
+ * match — a coarse-pointer narrow viewer is the device you would hand off to, so the
35
+ * button is pointless there. A missing or THROWING `matchMedia`, or a viewer we cannot
36
+ * classify, folds to `false` ⇒ the button shows (the "unknown shows" rule: a missing
37
+ * handoff on a desktop is worse than a stray, send-nothing button on an odd device).
38
+ * `matchMedia` is injected so the predicate is pure and testable without a DOM.
39
+ */
40
+ export function isPhone(matchMedia) {
41
+ if (typeof matchMedia !== "function")
42
+ return false;
43
+ try {
44
+ return (matchMedia("(pointer: coarse)").matches && matchMedia("(max-width: 820px)").matches);
45
+ }
46
+ catch {
47
+ // A throwing matchMedia is "unknown", never an error to propagate ⇒ show.
48
+ return false;
49
+ }
50
+ }
51
+ /**
52
+ * Is this a real, phone-reachable preview origin? `https:` AND the hostname ends with
53
+ * a `PREVIEW_APEXES` suffix (`.vincentt.dev`). On the creator's plain local loop
54
+ * (`http://localhost`) or a self-signed LAN origin (`https://192.168.1.20`, `foo.local`)
55
+ * there is no phone-reachable preview link to encode, so the button does not appear.
56
+ */
57
+ export function isPreviewOrigin(loc) {
58
+ return (loc.protocol === "https:" &&
59
+ PREVIEW_APEXES.some((apex) => loc.hostname.endsWith(apex)));
60
+ }
61
+ /**
62
+ * Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
63
+ * (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
64
+ * terminal one.
65
+ */
66
+ export function renderQr(url) {
67
+ return QRCode.toString(url, {
68
+ type: "svg",
69
+ margin: 2,
70
+ errorCorrectionLevel: "M",
71
+ });
72
+ }
73
+ const HEADING = "Open on your phone";
74
+ const INSTRUCTION = "Point your phone's camera at the code, or type in the link below.";
75
+ // A single QR-code glyph (three finder squares + a scattering of modules), ~18px,
76
+ // `currentColor` so it inherits the chip's `#f5f2ef`. Chosen over a share arrow
77
+ // because a QR square most directly says "get this onto a phone".
78
+ const QR_GLYPH_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 3h8v8H3V3Zm2 2v4h4V5H5Zm-2 8h8v8H3v-8Zm2 2v4h4v-4H5ZM13 3h8v8h-8V3Zm2 2v4h4V5h-4Zm-2 8h2v2h-2v-2Zm2 2h2v2h-2v-2Zm2-2h2v2h-2v-2Zm0 4h2v2h-2v-2Zm2-2h2v2h-2v-2Zm0 4h2v2h-2v-2Zm-4 0h2v2h-2v-2Z"/></svg>`;
79
+ /**
80
+ * Mount the icon-only Share chip into the shared cluster and wire its popover.
81
+ *
82
+ * The show-gate is evaluated ONCE, here at mount: render iff the viewer is not a
83
+ * phone AND the origin is a real preview origin. If the gate fails, nothing is
84
+ * appended to the DOM (no disabled state) and the returned unmount is a no-op.
85
+ *
86
+ * Returns an unmount function that removes the chip + popover, tears down listeners,
87
+ * and releases the shared cluster.
88
+ */
89
+ export function mountShareButton(opts = {}) {
90
+ if (typeof document === "undefined")
91
+ return () => undefined;
92
+ const matchMedia = opts.matchMedia ?? (typeof window !== "undefined" ? window.matchMedia : undefined);
93
+ const location = opts.location ?? (typeof window !== "undefined" ? window.location : undefined);
94
+ if (!location)
95
+ return () => undefined;
96
+ const show = !isPhone(matchMedia) && isPreviewOrigin(location);
97
+ if (!show)
98
+ return () => undefined;
99
+ const url = deriveShareUrl(location);
100
+ const container = getClusterContainer();
101
+ let released = false;
102
+ const btn = document.createElement("button");
103
+ btn.type = "button";
104
+ btn.setAttribute("aria-label", "Share preview");
105
+ btn.title = "Share preview";
106
+ btn.setAttribute("aria-haspopup", "dialog");
107
+ btn.setAttribute("aria-controls", "vt-popover");
108
+ btn.setAttribute("aria-expanded", "false");
109
+ btn.innerHTML = QR_GLYPH_SVG;
110
+ Object.assign(btn.style, {
111
+ width: "36px",
112
+ height: "36px",
113
+ padding: "0",
114
+ display: "inline-flex",
115
+ alignItems: "center",
116
+ justifyContent: "center",
117
+ borderRadius: "10px",
118
+ border: "1px solid rgba(255,255,255,.14)",
119
+ background: "rgba(28,27,26,.92)",
120
+ color: "#f5f2ef",
121
+ boxShadow: "0 1px 4px rgba(0,0,0,.5)",
122
+ cursor: "pointer",
123
+ transition: "background 100ms, border-color 100ms",
124
+ });
125
+ const IDLE_BG = "rgba(28,27,26,.92)";
126
+ const IDLE_BORDER = "rgba(255,255,255,.14)";
127
+ const HOVER_BG = "rgba(40,38,36,.95)";
128
+ const HOVER_BORDER = "rgba(255,255,255,.24)";
129
+ const ACTIVE_BG = "rgba(40,38,36,.95)";
130
+ const ACTIVE_BORDER = "rgba(147,184,240,.5)";
131
+ const applyIdle = () => {
132
+ btn.style.background = IDLE_BG;
133
+ btn.style.borderColor = IDLE_BORDER;
134
+ };
135
+ const applyActive = () => {
136
+ btn.style.background = ACTIVE_BG;
137
+ btn.style.borderColor = ACTIVE_BORDER;
138
+ };
139
+ btn.addEventListener("mouseenter", () => {
140
+ if (popover)
141
+ return; // active state wins over hover
142
+ btn.style.background = HOVER_BG;
143
+ btn.style.borderColor = HOVER_BORDER;
144
+ });
145
+ btn.addEventListener("mouseleave", () => {
146
+ if (popover)
147
+ return;
148
+ applyIdle();
149
+ });
150
+ // Focus ring: a brand-blue outline that reads on any background. Set inline on
151
+ // focus/blur since there is no stylesheet on this surface for :focus-visible.
152
+ btn.addEventListener("focus", () => {
153
+ btn.style.outline = "2px solid #93b8f0";
154
+ btn.style.outlineOffset = "2px";
155
+ });
156
+ btn.addEventListener("blur", () => {
157
+ btn.style.outline = "none";
158
+ });
159
+ let popover = null;
160
+ let outsideHandler = null;
161
+ let keyHandler = null;
162
+ const prefersReducedMotion = typeof matchMedia === "function"
163
+ ? (() => {
164
+ try {
165
+ return matchMedia("(prefers-reduced-motion: reduce)").matches;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ })()
171
+ : false;
172
+ const closePopover = (returnFocus) => {
173
+ if (!popover)
174
+ return;
175
+ popover.remove();
176
+ popover = null;
177
+ if (outsideHandler) {
178
+ document.removeEventListener("mousedown", outsideHandler, true);
179
+ outsideHandler = null;
180
+ }
181
+ if (keyHandler) {
182
+ document.removeEventListener("keydown", keyHandler);
183
+ keyHandler = null;
184
+ }
185
+ btn.setAttribute("aria-expanded", "false");
186
+ applyIdle();
187
+ if (returnFocus)
188
+ btn.focus();
189
+ };
190
+ const buildPopover = () => {
191
+ const pop = document.createElement("div");
192
+ pop.id = "vt-popover";
193
+ pop.setAttribute("role", "dialog");
194
+ pop.setAttribute("aria-label", "Open this preview on your phone");
195
+ Object.assign(pop.style, {
196
+ position: "fixed",
197
+ top: "56px",
198
+ right: "12px",
199
+ zIndex: "2147483647",
200
+ width: "260px",
201
+ padding: "16px",
202
+ borderRadius: "12px",
203
+ border: "1px solid rgba(255,255,255,.14)",
204
+ background: "rgba(23,22,21,.86)",
205
+ backdropFilter: "blur(16px) saturate(1.1)",
206
+ // @ts-expect-error vendor-prefixed for Safari; not in the typed CSSStyleDeclaration
207
+ WebkitBackdropFilter: "blur(16px) saturate(1.1)",
208
+ boxShadow: "0 12px 32px rgba(0,0,0,.45)",
209
+ boxSizing: "border-box",
210
+ transformOrigin: "top right",
211
+ });
212
+ const qrTile = document.createElement("div");
213
+ qrTile.className = "vt-qr";
214
+ Object.assign(qrTile.style, {
215
+ width: "100%",
216
+ aspectRatio: "1 / 1",
217
+ background: "#fff",
218
+ padding: "8px",
219
+ borderRadius: "8px",
220
+ border: "1px solid rgba(0,0,0,.06)",
221
+ boxSizing: "border-box",
222
+ });
223
+ // Async QR render; the tile holds its space via aspect-ratio until it lands.
224
+ void renderQr(url).then((svg) => {
225
+ // Guard against a close-before-resolve race.
226
+ if (qrTile.isConnected)
227
+ qrTile.innerHTML = svg;
228
+ });
229
+ const heading = document.createElement("div");
230
+ heading.className = "vt-heading";
231
+ heading.textContent = HEADING;
232
+ Object.assign(heading.style, {
233
+ font: "600 14px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
234
+ color: "#f5f2ef",
235
+ margin: "12px 0 2px",
236
+ });
237
+ const instr = document.createElement("div");
238
+ instr.className = "vt-instr";
239
+ instr.textContent = INSTRUCTION;
240
+ Object.assign(instr.style, {
241
+ font: "400 13px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
242
+ color: "#b8b2a9",
243
+ margin: "0 0 10px",
244
+ });
245
+ const urlField = document.createElement("div");
246
+ urlField.className = "vt-url";
247
+ urlField.textContent = url;
248
+ Object.assign(urlField.style, {
249
+ font: "500 12px/1.5 ui-monospace,monospace",
250
+ color: "#93b8f0",
251
+ background: "rgba(255,255,255,.06)",
252
+ border: "1px solid rgba(255,255,255,.10)",
253
+ borderRadius: "8px",
254
+ padding: "8px 10px",
255
+ wordBreak: "break-all",
256
+ cursor: "text",
257
+ userSelect: "all",
258
+ // @ts-expect-error vendor-prefixed for Safari
259
+ WebkitUserSelect: "all",
260
+ boxSizing: "border-box",
261
+ });
262
+ pop.append(qrTile, heading, instr, urlField);
263
+ if (!prefersReducedMotion) {
264
+ pop.style.opacity = "0";
265
+ pop.style.transform = "translateY(-4px) scale(.98)";
266
+ pop.style.transition =
267
+ "opacity 160ms cubic-bezier(0.16,1,0.3,1), transform 160ms cubic-bezier(0.16,1,0.3,1)";
268
+ // Next frame: animate to the resting state.
269
+ requestAnimationFrame(() => {
270
+ pop.style.opacity = "1";
271
+ pop.style.transform = "translateY(0) scale(1)";
272
+ });
273
+ }
274
+ return pop;
275
+ };
276
+ const openPopover = () => {
277
+ if (popover)
278
+ return;
279
+ popover = buildPopover();
280
+ container.appendChild(popover);
281
+ btn.setAttribute("aria-expanded", "true");
282
+ applyActive();
283
+ // Click-outside: a capture-phase mousedown outside both popover and button
284
+ // closes it. Registered only while open, removed on close.
285
+ outsideHandler = (e) => {
286
+ const target = e.target;
287
+ if (!target)
288
+ return;
289
+ if (popover && (popover.contains(target) || btn.contains(target)))
290
+ return;
291
+ closePopover(false);
292
+ };
293
+ document.addEventListener("mousedown", outsideHandler, true);
294
+ // Escape closes and returns focus to the Share button.
295
+ keyHandler = (e) => {
296
+ if (e.key === "Escape") {
297
+ e.stopPropagation();
298
+ closePopover(true);
299
+ }
300
+ };
301
+ document.addEventListener("keydown", keyHandler);
302
+ };
303
+ btn.addEventListener("click", () => {
304
+ if (popover)
305
+ closePopover(false);
306
+ else
307
+ openPopover();
308
+ });
309
+ container.appendChild(btn);
310
+ return () => {
311
+ closePopover(false);
312
+ btn.remove();
313
+ if (!released) {
314
+ released = true;
315
+ releaseClusterContainer();
316
+ }
317
+ };
318
+ }
@@ -1,5 +1,30 @@
1
1
  /** Monotonic-ish wall-clock ms since epoch, stamped by the client at capture. */
2
2
  export type Timestamp = number;
3
+ /**
4
+ * The grammar of the client's session id — the key the relay groups a TESTER by.
5
+ *
6
+ * It lives here, in the file all three parts import, for the same reason
7
+ * VIEWER_LABEL_RE lives beside the codec that produces the label: the party that
8
+ * mints the value and the party that validates it must not be able to disagree
9
+ * about its shape. The client writes this shape; the relay accepts this shape and
10
+ * nothing else, with no sanitize-and-keep branch.
11
+ *
12
+ * `s2_` is generation two. Generation one was `s_<base36 time>_<base36 rand>` over
13
+ * ~10^6 of Math.random, which grouped a RUN. Grouping a TESTER makes a collision a
14
+ * silent merge of two people's streams, so the space is now 128 bits and the
15
+ * prefix is what lets a relay tell the two generations apart on sight.
16
+ */
17
+ export declare const CLIENT_SESSION_ID_RE: RegExp;
18
+ /**
19
+ * A relay-assigned tester id: `t1`, `t2`, … Minted on the CREATOR'S machine and
20
+ * never on the wire from the device — it is a local relabeling of a key the
21
+ * device supplied, which is what keeps the platform free of a correlator (B22).
22
+ *
23
+ * No leading zero. Ids are counted from 1 and never zero-padded, so `t01` is a
24
+ * string the registry cannot have produced; accepting it at the filter boundary
25
+ * would admit a second spelling for a value with one canonical form.
26
+ */
27
+ export declare const TESTER_ID_RE: RegExp;
3
28
  /** Kinds of thing the diagnostics limb observes. */
4
29
  export type DiagEventKind = "log" | "network" | "trace";
5
30
  interface DiagEventBase {
@@ -60,20 +85,92 @@ export interface ClientToRelay {
60
85
  sessionId: string;
61
86
  events: DiagEvent[];
62
87
  }
88
+ /**
89
+ * The FIRST message on the diagnostics WS, sent at WS-open before HarnessProvider
90
+ * mounts and independent of the EventBuffer having pending events. It carries the
91
+ * same `sessionStorage` key the events batch carries, so the relay can resolve the
92
+ * TESTER on connect rather than waiting for a batch — which is why a SILENT app
93
+ * (no console/network/trace events) and an app that crashes during mount are both
94
+ * attributed instead of reading as an absence.
95
+ *
96
+ * It is a relay-layer message in the `{type:"events"}` family, NOT a tunnel
97
+ * control frame: the edge splices the tunnel as opaque bytes and never reads it.
98
+ * The `sessionId` is grammar-guarded by the relay against CLIENT_SESSION_ID_RE
99
+ * with no sanitize-and-keep branch, exactly as the batch key is — a malformed key
100
+ * is ingested un-keyed, never a crash.
101
+ */
102
+ export interface ClientHello {
103
+ type: "hello";
104
+ /** The client's `sessionStorage` key — same value the events batch carries. */
105
+ sessionId: string;
106
+ }
107
+ /** Every message the client sends up the diagnostics WS. Discriminated on `type`. */
108
+ export type ClientMessage = ClientToRelay | ClientHello;
63
109
  /** What the MCP server asks the relay for. `since` is an exclusive seq cursor. */
64
110
  export interface RelayQuery {
65
- sessionId?: string;
66
111
  kind?: DiagEventKind;
67
112
  since?: number;
68
113
  /** Cap the returned count (newest-biased); relay clamps to its buffer size. */
69
114
  limit?: number;
115
+ /**
116
+ * Filter to one TESTER — one browser tab, recognised across refreshes. This is
117
+ * the filter that answers "it breaks on their phone but not mine", because it
118
+ * survives the reload the tester does first.
119
+ *
120
+ * A malformed id filters to NOTHING rather than being ignored: silently
121
+ * widening a filter shows the creator several testers' events under a heading
122
+ * naming one.
123
+ */
124
+ tester?: string;
125
+ /**
126
+ * Filter to one viewer — one CONNECTION (2.8), not an identity (B22). The
127
+ * sub-coordinate below the tester: a single tab opens several of these under
128
+ * HTTP/1.1, so this narrows to one of them and is for connection-level
129
+ * debugging rather than for "whose phone".
130
+ */
131
+ viewer?: string;
70
132
  }
71
133
  export interface RelayResult {
72
134
  events: DiagEvent[];
135
+ /**
136
+ * `eventViewers[i]` is the label that produced `events[i]`, or undefined when
137
+ * the relay could not attribute it.
138
+ *
139
+ * It rides ALONGSIDE the events rather than on them because a viewer label is
140
+ * a fact about the CONNECTION that carried an observation, not a property of
141
+ * the observation — putting it on the event would make it look like something
142
+ * the device reported about itself, which is the identity promotion B22
143
+ * forbids.
144
+ */
145
+ eventViewers?: (string | undefined)[];
146
+ /**
147
+ * `eventTesters[i]` is the tester that produced `events[i]`, or undefined when
148
+ * the relay could not attribute it (the pre-mount window, a crashed app, a
149
+ * non-app request, or a session past the cap).
150
+ *
151
+ * Rides alongside for the same reason as `eventViewers`: which tester an
152
+ * observation came from is a fact about the CONNECTION that carried it, not a
153
+ * property of the observation.
154
+ */
155
+ eventTesters?: (string | undefined)[];
73
156
  /** Highest seq the relay currently holds, so the caller can advance `since`. */
74
157
  latestSeq: number;
75
- /** Sessions the relay has seen events for (so the agent can disambiguate). */
76
- sessions: string[];
158
+ /**
159
+ * Tester ids the relay has assigned, in arrival order. Every member matches
160
+ * TESTER_ID_RE.
161
+ *
162
+ * This REPLACED a `sessions` array that carried the raw client-supplied key to
163
+ * the terminal and to MCP tool output. That was a viewer-controlled string
164
+ * with no validation reaching the creator's coding agent — do not reintroduce
165
+ * it. The raw key is an opaque map key inside the relay and nothing else.
166
+ */
167
+ testers: string[];
168
+ /**
169
+ * Viewer labels the relay has seen. Labels churn — a reconnecting device gets
170
+ * a NEW label and the old one is never reused — so the caller needs the live
171
+ * set rather than remembering one.
172
+ */
173
+ viewers: string[];
77
174
  }
78
175
  /** A freehand stroke over the frame, in normalized [0,1] frame coordinates. */
79
176
  export interface AnnotationStroke {
@@ -105,7 +202,12 @@ export interface AnnotationInput {
105
202
  /** Annotated frame as a data URL (image/png). */
106
203
  screenshot: string;
107
204
  spec?: AnnotationSpec;
108
- /** Optional preview session id, to correlate with diag events. */
205
+ /**
206
+ * The client's session key, so the annotation can be correlated with the diag
207
+ * events from the same tab. Client-supplied and therefore hostile input: the
208
+ * relay REPLACES it with the tester id it resolves to, or drops it. It is
209
+ * never persisted or forwarded verbatim — this record reaches an agent.
210
+ */
109
211
  sessionId?: string;
110
212
  }
111
213
  /** A persisted annotation the agent consumes (via `vincentt feedback --wait`). */
@@ -123,6 +225,10 @@ export interface Annotation {
123
225
  /** Absolute path to the full-res annotated screenshot on disk. */
124
226
  screenshotPath: string;
125
227
  spec: AnnotationSpec;
228
+ /**
229
+ * The TESTER this annotation came from, or absent when the relay could not
230
+ * attribute it. Never the raw client key — see AnnotationInput.sessionId.
231
+ */
126
232
  sessionId?: string;
127
233
  }
128
234
  export {};
@@ -3,4 +3,28 @@
3
3
  // server (which serves them to the agent). All three import THIS file, so the
4
4
  // three can never drift on the shape of an event. Nothing here imports React,
5
5
  // node, or MCP — it is pure data.
6
- export {};
6
+ /**
7
+ * The grammar of the client's session id — the key the relay groups a TESTER by.
8
+ *
9
+ * It lives here, in the file all three parts import, for the same reason
10
+ * VIEWER_LABEL_RE lives beside the codec that produces the label: the party that
11
+ * mints the value and the party that validates it must not be able to disagree
12
+ * about its shape. The client writes this shape; the relay accepts this shape and
13
+ * nothing else, with no sanitize-and-keep branch.
14
+ *
15
+ * `s2_` is generation two. Generation one was `s_<base36 time>_<base36 rand>` over
16
+ * ~10^6 of Math.random, which grouped a RUN. Grouping a TESTER makes a collision a
17
+ * silent merge of two people's streams, so the space is now 128 bits and the
18
+ * prefix is what lets a relay tell the two generations apart on sight.
19
+ */
20
+ export const CLIENT_SESSION_ID_RE = /^s2_[0-9a-f]{32}$/;
21
+ /**
22
+ * A relay-assigned tester id: `t1`, `t2`, … Minted on the CREATOR'S machine and
23
+ * never on the wire from the device — it is a local relabeling of a key the
24
+ * device supplied, which is what keeps the platform free of a correlator (B22).
25
+ *
26
+ * No leading zero. Ids are counted from 1 and never zero-padded, so `t01` is a
27
+ * string the registry cannot have produced; accepting it at the filter boundary
28
+ * would admit a second spelling for a value with one canonical form.
29
+ */
30
+ export const TESTER_ID_RE = /^t[1-9][0-9]{0,2}$/;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The CBOR SUBSET, Node half. It mirrors `api/internal/edge/cbor.go` decision
3
+ * for decision, because two implementations of one wire format that were
4
+ * written independently are two implementations that drift.
5
+ *
6
+ * It decodes CONTROL payloads only — OPEN and HEAD. It NEVER touches a DATA
7
+ * payload.
8
+ *
9
+ * It is hand-written rather than a library for the same reason the Go one is: a
10
+ * general CBOR library's allocation behavior on hostile input would be someone
11
+ * else's decision, and the limits below are the whole point.
12
+ *
13
+ * Everything indefinite-length, every tag, every float, every byte string and
14
+ * every negative integer is REFUSED — not because they are dangerous in
15
+ * themselves, but because the encoder we own never emits them, so accepting
16
+ * them would only widen the input this parser has to be correct about.
17
+ */
18
+ /**
19
+ * VIEWER_LABEL_RE is the SAME regex the edge assigns against, and this client
20
+ * RE-VALIDATES rather than trusting the edge. That is closure (c) of the three
21
+ * that keep viewer-supplied bytes out of the creator's agent's context: the
22
+ * label flows viewerLabel -> the relay ring-buffer key -> CLI terminal output
23
+ * and MCP tool output, which is a terminal/log-injection path INTO the agent,
24
+ * and a Go-only check would pass on a client that trusted the edge.
25
+ */
26
+ export declare const VIEWER_LABEL_RE: RegExp;
27
+ export declare function parseViewerLabel(s: string): string | null;
28
+ /**
29
+ * UA_CLASSES is the CLOSED ENUM OF SIX, mirroring the Go edge. `unknown` is the
30
+ * TOTAL fallback. This client validates the class it receives against this set
31
+ * before stamping the relay handshake, so an unmatched or forged class becomes
32
+ * `unknown` rather than an echo.
33
+ */
34
+ export declare const UA_CLASSES: readonly ["ios_safari", "android_chrome", "desktop_chrome", "desktop_safari", "desktop_firefox", "unknown"];
35
+ export type UAClass = (typeof UA_CLASSES)[number];
36
+ export declare function parseUAClass(s: string): UAClass;
37
+ export interface OpenPayload {
38
+ viewerLabel: string;
39
+ method: string;
40
+ path: string;
41
+ headers: [string, string][];
42
+ upgrade?: string;
43
+ }
44
+ export interface HeadPayload {
45
+ status: number;
46
+ headers: [string, string][];
47
+ }
48
+ /**
49
+ * `viewer_gone` is the ONE non-terminal kind: the other three mean the session
50
+ * is ending, this one means a viewer's connection closed while the session
51
+ * continues. It is also the only kind carrying a second key.
52
+ */
53
+ export type ControlKind = "revoke" | "expiring" | "superseded" | "viewer_gone";
54
+ /** A decoded CONTROL payload. `viewerLabel` is present only on `viewer_gone`. */
55
+ export interface ControlPayload {
56
+ kind: ControlKind;
57
+ viewerLabel?: string;
58
+ }
59
+ export declare function decodeOpen(b: Uint8Array): OpenPayload;
60
+ export declare function decodeHead(b: Uint8Array): HeadPayload;
61
+ /**
62
+ * The map arity is asserted AGAINST THE KIND, so a payload claiming `revoke`
63
+ * with a trailing label is refused as malformed rather than quietly accepted
64
+ * with the extra key ignored.
65
+ */
66
+ export declare function decodeControl(b: Uint8Array): ControlPayload;
67
+ /** Canonical key order: viewerLabel, method, path, headers, [upgrade]. */
68
+ export declare function encodeOpen(p: OpenPayload): Uint8Array;
69
+ /** Canonical key order: status, headers. */
70
+ export declare function encodeHead(p: HeadPayload): Uint8Array;
71
+ export declare function encodeControl(kind: ControlKind, viewerLabel?: string): Uint8Array;