@gotcos/glasses-server 6.41.0 → 6.42.1

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/.env.example CHANGED
@@ -14,9 +14,27 @@ BIND_HOST=0.0.0.0
14
14
  # PORT=3141
15
15
 
16
16
  # ── AUTH ────────────────────────────────────────────────────────────────
17
- # A token is REQUIRED on every /api call. Leave UNSET and the server prints an
18
- # auto-generated one at boot paste it into the phone app. Set a fixed value
19
- # here for a stable token across restarts.
17
+ # A token is required on every /api call EXCEPT six paths. Four are public because
18
+ # a client needs them BEFORE it holds a usable token; two are capability URLs
19
+ # because the browser API that fetches them cannot send a header at all:
20
+ #
21
+ # GET /api/health server availability and capability advertisement
22
+ # GET /api/diag/health recovery diagnostics
23
+ # POST /api/diag/client client-side crash/diagnostic upload
24
+ # GET /api/display-stream the glasses display bus. EventSource cannot set
25
+ # headers, so this returns 200 to anyone — but a
26
+ # subscriber without a token gets a live transport and
27
+ # lifecycle markers ONLY, never transcripts or answers.
28
+ # GET /api/display-stream/<expiry>.<hmac> short-lived capability URLs, each
29
+ # GET /api/tts/play/<uuid> minted by an AUTHENTICATED call, for
30
+ # EventSource and for native audio players respectively
31
+ # — neither can send a header. On the display stream the
32
+ # X-Cos-Token header is accepted as an equivalent, so any
33
+ # caller that CAN send one should; the TTS UUID also names
34
+ # which audio to play, so it is not interchangeable.
35
+ #
36
+ # Leave UNSET and the server prints an auto-generated token at boot — paste it into
37
+ # the phone app. Set a fixed value here for a stable token across restarts.
20
38
  # COS_API_TOKEN=pick-any-long-random-string
21
39
 
22
40
  # Optional durable image-store location. Defaults to
package/CHANGELOG.md CHANGED
@@ -1,3 +1,121 @@
1
+ ## 6.42.1
2
+
3
+ Hardening of the 6.42.0 display-stream ticket, from a four-validator QA pass
4
+ that tested the FIX rather than the original finding. Nothing here changes the
5
+ wire contract; every 6.42.0 client keeps working unchanged.
6
+
7
+ - **A refused ticket now says why.** `explainDisplayTicket` returns
8
+ `expired | bad-signature | malformed`, and the throttled summary line counts
9
+ each. Before this the server could not tell "a client needs to re-mint"
10
+ (expired — expected after every native EventSource retry on a stale URL) from
11
+ "someone holds a ticket this token never signed". The author's own log had
12
+ seven unexplained rejections in three windows.
13
+ - **The allowlist uses `Object.hasOwn`.** The projection map is an object
14
+ literal and inherited `Object.prototype`; a type of `constructor` would have
15
+ resolved to a truthy identity and passed the event through whole. Unreachable
16
+ via the typed union; the allowlist must not depend on that.
17
+ - **Ticketless connects no longer materialise the replay buffer.** Gap detection
18
+ still runs; the up-to-200-event filter does not. A stale install retrying
19
+ every 3s was doing that filter and discarding it each time.
20
+ - **`?probe=1` skips the replay write.** The client's connection probe is
21
+ authorized (it sends the token) and was handed the whole buffer on every
22
+ reconnect, then aborted the socket — 1,164 "Replayed 200" lines in one day.
23
+ A 6.42.0 server ignores the flag (verified live), so a new client against an
24
+ old server loses nothing.
25
+ - **Comments corrected.** `api-auth.ts` no longer claims a path segment avoids
26
+ URL logs (it does not; the TTL is what bounds a leaked URL). `index.ts` names
27
+ both capability URLs. `health.ts` states plainly that authorization is decided
28
+ once per socket and that a per-event re-check would be a breaking change.
29
+ - **The 6.42.0 note "6.8.441 restores full content delivery" was too broad.**
30
+ It is true for the phone companion. The lens entrypoint was untouched in
31
+ 6.8.441 and connects bare; app 6.9.442 ports the ticket to it.
32
+ - Tests: the adversarial event list is now derived from a `Record` keyed on the
33
+ union, so a twelfth event type is a compile error rather than a silent gap; a
34
+ projection that throws is proven not to reach the emitter; the probe is proven
35
+ to receive no replay; every verdict is exercised.
36
+ - **The probe no longer BUILDS the replay buffer either.** The first cut of this
37
+ release skipped only the write for `?probe=1`; `materialize` was still keyed
38
+ on authorization, so an authorized probe filtered up to 200 events and dropped
39
+ them. One term, matched to the write guard, closes it.
40
+ - **Two guards gained the tests that prove them.** A mutation run during
41
+ `/validate-plan` (2026-09-01) found the `Object.hasOwn` allowlist guard and
42
+ the per-reason counter both survived mutation with the suite green.
43
+ `display-ticketless.test.ts` now emits prototype-keyed event types
44
+ (`constructor`, `toString`, …) and asserts exact per-reason counts.
45
+ - `SECURITY.md` and a `bugs` field. Two security releases in a row, and there
46
+ was nowhere to report the next one.
47
+
48
+ ## 6.42.0
49
+
50
+ The display stream stops broadcasting your meetings to the local network.
51
+
52
+ `GET /api/display-stream` has been public since it was written, because
53
+ EventSource cannot attach an `X-Cos-Token` header. That is still true, so the
54
+ route is still public and still returns 200 to everyone — but what it SENDS is
55
+ now decided per event. A subscriber that proves it holds the pairing token gets
56
+ the whole bus. A subscriber that does not gets a live transport, the handshake,
57
+ the keepalive, replay-gap notices, and one projected lifecycle marker. It never
58
+ receives a transcript, an answer, a coaching cue, a tool status or an error.
59
+
60
+ **COS Glasses app 6.8.441 restores full content delivery to the PHONE COMPANION (`index.html`).** It does not touch the lens entrypoint (`glasses.html` / `glasses-entry.ts`), which still connects bare and, wherever it can reach the server at all, now receives lifecycle events only. Whether that entrypoint reaches the server is unproven either way; 6.9.442 ports the ticket to it regardless. Older
61
+ builds keep working — that is the entire reason the connection is not rejected —
62
+ but they connect without a capability, so they will see the content-suppressed
63
+ stream: no live transcript on the lens and no streamed answers, while meeting
64
+ capture, saving, and offline sync continue normally. 6.8.441 is the first app
65
+ build that fetches a ticket and reconnects on `contentAuthorized: false`. Nothing
66
+ else states this, so state it here: update the app with the server.
67
+
68
+ Two ways to prove you hold the token:
69
+
70
+ - `X-Cos-Token` on the request, for every fetch-based consumer (COS Control, the
71
+ companion's own connection probe, curl). Preferred wherever it is possible.
72
+ - `GET /api/display-stream/<expiry>.<hmac>` for EventSource, which cannot send a
73
+ header. The ticket is an HMAC over its own expiry keyed on the pairing token,
74
+ so it is stateless, unforgeable without the key, and invalidated the instant
75
+ the token rotates. `GET /api/models` mints one; `GET /api/health` advertises the
76
+ capability so a client can detect an older server by its absence.
77
+
78
+ An invalid or expired ticket is never rejected. It degrades to the ticketless
79
+ stream, so a client whose ticket died mid-reconnect keeps its transport instead
80
+ of entering a retry loop — and the `ready` frame now carries
81
+ `contentAuthorized: <boolean>` so it can tell the difference and re-mint. Without
82
+ that field a degraded stream is indistinguishable from a healthy one.
83
+
84
+ The ticket TTL is 15 minutes, chosen by the platform rather than by taste: a
85
+ backgrounded Even Hub WebView is suspended and cannot re-mint, and the Even Hub
86
+ review loop locks the phone for five minutes. A 2-minute TTL was guaranteed to
87
+ be dead on the reviewer's exact path. Minting requires the pairing token, so the
88
+ TTL only bounds the value of a ticket that leaks out of a URL.
89
+
90
+ Also in this release:
91
+
92
+ - `recording_stop` reaches ticketless subscribers as a PROJECTION carrying only
93
+ `sessionId`. The production payload also carries `filename`, which embeds the
94
+ transcript-derived meeting title (`..._Q3_Budget_Cuts_Layoffs_....md`), plus
95
+ duration and business domain. An allowlist of event TYPES passed the whole
96
+ event through and would have broadcast the title of every meeting.
97
+ - `replay_gap` reaches ticketless subscribers too. It carries only a reason, the
98
+ cursor the client itself sent, the watermark already in `ready`, and a buffer
99
+ boundary, and withholding it strands a client on a dead cursor after a server
100
+ restart. The buffer it describes is still never served without a ticket.
101
+ - The ticketed path is exempt from the recovery lease classifier. It matched by
102
+ exact string, so the new path segment classified as a leased request — and an
103
+ SSE lease never settles, so a connected lens would have held the recovery gate
104
+ open and 409'd every COS Control server restart. (In this build the admission
105
+ middleware is exported but not mounted, so that was latent, not live; the
106
+ classification is a contract a build that mounts it would inherit.) Lease kinds
107
+ are also redacted now, so `GET /api/recovery/status` can never republish a live
108
+ display ticket or TTS capability as plain text.
109
+ - `mintDisplayTicket` throws on an empty token instead of returning a ticket that
110
+ can never verify, and `/api/models` omits the field rather than advertising a
111
+ dead capability.
112
+ - Ticketless connects are summarized once a minute with a count instead of logged
113
+ per connect. `retry: 3000` means one stale client would otherwise write ~1,200
114
+ lines an hour into the launchd log.
115
+ - `.env.example` said a token is required on every `/api` call. It is not, and it
116
+ never was: four paths are public and two are capability URLs. That file ships in
117
+ the npm tarball.
118
+
1
119
  ## 6.41.0
2
120
 
3
121
  Allowlist mode can finally read the workspace it was pointed at.
package/SECURITY.md ADDED
@@ -0,0 +1,32 @@
1
+ # Security
2
+
3
+ COS Glasses server runs on your own machine and holds your meeting transcripts,
4
+ so a flaw here is a flaw in your privacy, not ours. Reports are welcome and
5
+ taken seriously.
6
+
7
+ ## Reporting
8
+
9
+ Open an issue at <https://github.com/ukaoma/cos-glasses-server/issues> titled
10
+ `security` with **no details in the body**. A maintainer will reply with a
11
+ private channel within two business days. Do not post the finding publicly
12
+ until a fixed version is on npm.
13
+
14
+ Please include the server version (`/api/health` → `server_version`), the
15
+ client (COS Glasses EHPK version or COS Control version), and the smallest
16
+ reproduction you have. Do not include real transcripts.
17
+
18
+ ## What to expect
19
+
20
+ - Acknowledgement within two business days.
21
+ - A fix released as a patch version, with the finding described in
22
+ `CHANGELOG.md` once a fixed version is on npm.
23
+ - Credit in the changelog if you want it. TJ's 2026-08 report on the display
24
+ stream (fixed in 6.42.0 and hardened in 6.42.1) is the model.
25
+
26
+ ## Scope notes
27
+
28
+ - The pairing token (`X-Cos-Token`) is the only credential. Rotating it
29
+ invalidates every display-stream ticket; there is no server-side ticket store.
30
+ - `GET /api/display-stream` is public by design and returns lifecycle events
31
+ only. Content requires a ticket or the token header. See
32
+ `server/routes/display.ts` for the allowlist.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.41.0",
3
+ "version": "6.42.1",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,8 @@
40
40
  ".cos-profile.example.json",
41
41
  "README.md",
42
42
  "LICENSE",
43
- "CHANGELOG.md"
43
+ "CHANGELOG.md",
44
+ "SECURITY.md"
44
45
  ],
45
46
  "author": "COS Contributors",
46
47
  "license": "MIT",
@@ -49,6 +50,7 @@
49
50
  "url": "git+https://github.com/ukaoma/cos-glasses-server.git"
50
51
  },
51
52
  "homepage": "https://www.gotcos.com",
53
+ "bugs": { "url": "https://github.com/ukaoma/cos-glasses-server/issues" },
52
54
  "publishConfig": {
53
55
  "access": "public"
54
56
  },
package/server/index.ts CHANGED
@@ -181,9 +181,10 @@ app.use(cors({
181
181
  }))
182
182
  // Auth middleware — always active (token is auto-generated if not set).
183
183
  // Mounted before body parsers so rejected uploads cannot consume parse memory.
184
- // The only capability-URL exception is a canonical /tts/play/<UUID> GET/HEAD;
185
- // authenticated /tts/prepare mints it for native audio players that cannot set
186
- // X-Cos-Token headers.
184
+ // Two capability-URL exceptions: a canonical /tts/play/<UUID> GET/HEAD (minted by
185
+ // authenticated /tts/prepare for native audio players) and
186
+ // /display-stream/<exp>.<hmac> GET/HEAD (minted on authenticated /api/models for
187
+ // EventSource, which cannot set X-Cos-Token either). See api-auth.ts.
187
188
  app.use('/api', requireApiToken(API_TOKEN))
188
189
 
189
190
  // Fail-closed catch-all for mutation routes that do not own a more specific
@@ -13,13 +13,32 @@ const PUBLIC_API_PATHS = new Set([
13
13
  // Native HTML audio requests cannot attach X-Cos-Token. The UUID minted by
14
14
  // authenticated POST /tts/prepare is therefore a short-lived bearer
15
15
  // capability. Keep this exception exact: GET/HEAD only, one canonical v4 UUID
16
- // path segment, and no query-token fallback that could leak into URL logs.
16
+ // path segment, and no query-token fallback. (A path segment reaches access and
17
+ // proxy logs exactly as a query string does — the TTL is what bounds a leaked
18
+ // URL; the point of refusing a query form is that it is trivially added back by
19
+ // accident and routinely forwarded.)
17
20
  const TTS_PLAYBACK_CAPABILITY_PATH = /^\/tts\/play\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
18
21
 
22
+ // EventSource has the identical constraint, so the display stream reuses the same
23
+ // shape: `/display-stream/<expUnixSeconds>.<hex hmac>`, GET/HEAD only, path segment,
24
+ // no query-token fallback (same reasoning as above — this is not log hygiene). Admission here is SHAPE ONLY — the signature is verified
25
+ // in the route, which is the only place that holds the API token.
26
+ //
27
+ // `/display-stream` itself STAYS PUBLIC, deliberately. Removing it would make a
28
+ // ticketless connect fall through to the token check and 401, and neither EventSource
29
+ // in the client can send a header — every installed build would enter a permanent
30
+ // reconnect loop and, because `displayBusConnected` would never turn true, would also
31
+ // stop syncing already-recorded offline meetings. Confidentiality is enforced by
32
+ // withholding CONTENT in the route, not by rejecting the connection. The route also
33
+ // accepts a valid X-Cos-Token header as equivalent authorization, for the fetch-based
34
+ // callers that can send one; the ticket exists only for the ones that cannot.
35
+ const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64}$/
36
+
19
37
  export function isPublicApiRequest(method: string, path: string): boolean {
20
38
  if (PUBLIC_API_PATHS.has(path)) return true
21
- return (method === 'GET' || method === 'HEAD')
22
- && TTS_PLAYBACK_CAPABILITY_PATH.test(path)
39
+ if (method !== 'GET' && method !== 'HEAD') return false
40
+ return TTS_PLAYBACK_CAPABILITY_PATH.test(path)
41
+ || DISPLAY_STREAM_CAPABILITY_PATH.test(path)
23
42
  }
24
43
 
25
44
  /** Global /api authentication boundary. Mount before all body parsers. */
@@ -52,7 +52,12 @@ export function getDisplayWatermark(): { bootId: string; eventId: number } {
52
52
  return { bootId: serverMetrics.bootId, eventId }
53
53
  }
54
54
 
55
- export function replayDisplayEvents(bootId: string | null, afterEventId: number): DisplayReplayResult {
55
+ export function replayDisplayEvents(
56
+ bootId: string | null,
57
+ afterEventId: number,
58
+ opts: { materialize?: boolean } = {},
59
+ ): DisplayReplayResult {
60
+ const materialize = opts.materialize !== false
56
61
  const oldestEventId = replayBuffer[0]?.eventId ?? eventId + 1
57
62
  const latestEventId = eventId
58
63
  if (bootId && bootId !== serverMetrics.bootId) {
@@ -65,7 +70,7 @@ export function replayDisplayEvents(bootId: string | null, afterEventId: number)
65
70
  return { events: [], gap: true, reason: 'buffer_overflow', oldestEventId, latestEventId }
66
71
  }
67
72
  return {
68
- events: replayBuffer.filter(item => item.eventId > afterEventId),
73
+ events: materialize ? replayBuffer.filter(item => item.eventId > afterEventId) : [],
69
74
  gap: false,
70
75
  oldestEventId,
71
76
  latestEventId,
@@ -0,0 +1,119 @@
1
+ import { createHmac } from 'node:crypto'
2
+ import { timingSafeTokenEqual } from './token-auth.js'
3
+
4
+ /**
5
+ * Short-lived capability for GET /api/display-stream.
6
+ *
7
+ * WHY A CAPABILITY AT ALL. `EventSource` cannot attach `X-Cos-Token` — the same
8
+ * constraint native HTML audio has, which is why `api-auth.ts` already carries a
9
+ * TTS playback capability. This reuses that reviewed shape rather than inventing
10
+ * a second one: **a path segment, GET/HEAD only, and no query-token fallback that
11
+ * could leak into URL logs.**
12
+ *
13
+ * WHY STATELESS. An HMAC over the expiry needs no store, so there is no eviction
14
+ * policy to get wrong, no unbounded Map, and no timer to leak. It also invalidates
15
+ * every outstanding ticket the moment the pairing token rotates, which a UUID store
16
+ * would not. The TTS capability needs a store because its UUID carries no claims;
17
+ * this one carries its own expiry.
18
+ *
19
+ * WHY REPLAY WITHIN THE TTL IS FINE. Minting requires the pairing token, so an
20
+ * attacker who could mint already has full API access. The TTL bounds the value of
21
+ * a ticket that leaks out of a URL, which is the actual threat. Single-use would be
22
+ * strictly worse: the server sends `retry: 3000`, so a browser-initiated reconnect
23
+ * replays the same URL, and a consumed ticket would turn every transport blip into a
24
+ * hard failure.
25
+ */
26
+
27
+ /**
28
+ * 15 minutes, set by the PLATFORM, not by taste.
29
+ *
30
+ * The first draft used 120s, which is exactly the Even Hub reviewer's boundary: the
31
+ * review rubric checks a two-minute idle, and the pre-submission loop locks the phone
32
+ * for five. A backgrounded Even Hub WebView is SUSPENDED — no timer, no fetch, no
33
+ * re-mint — so a 120s ticket is guaranteed to be dead on the exact path a reviewer
34
+ * exercises, and the app would come back to a content-suppressed stream.
35
+ *
36
+ * The TTL is not what protects the stream. MINTING requires the pairing token, so
37
+ * anyone who can mint already has full API access; the TTL only bounds the value of
38
+ * a ticket that leaks out of a URL (proxy log, screen share, shoulder surf). 15
39
+ * minutes keeps that window small while surviving every suspension a phone actually
40
+ * imposes.
41
+ */
42
+ export const DISPLAY_TICKET_TTL_SECONDS = 900
43
+
44
+ /** Domain separation. Without a purpose string, any future feature that HMACs an
45
+ * integer under the same key would mint cross-usable display tickets. */
46
+ const TICKET_PURPOSE = 'display-stream'
47
+
48
+ function signature(apiToken: string, expSeconds: number): string {
49
+ return createHmac('sha256', apiToken)
50
+ .update(`${TICKET_PURPOSE}:${expSeconds}`)
51
+ .digest('hex')
52
+ }
53
+
54
+ /**
55
+ * `<expUnixSeconds>.<hex sha256 hmac>` — safe in a path segment, no encoding needed.
56
+ *
57
+ * THROWS on an empty token rather than returning a ticket. An HMAC keyed on '' is a
58
+ * well-formed string that `verifyDisplayTicket` rejects unconditionally (it fails
59
+ * closed on `!apiToken`), so a silent mint would publish a capability that can never
60
+ * be redeemed — the caller would advertise `ticketSupported` and hand out a value
61
+ * that is dead on arrival. Callers must decide what to do without a token; they may
62
+ * not be handed a placeholder.
63
+ */
64
+ export function mintDisplayTicket(
65
+ apiToken: string,
66
+ nowMs: number = Date.now(),
67
+ ): string {
68
+ if (!apiToken) {
69
+ throw new Error('mintDisplayTicket requires a non-empty API token — a ticket minted without one can never verify')
70
+ }
71
+ const exp = Math.floor(nowMs / 1000) + DISPLAY_TICKET_TTL_SECONDS
72
+ return `${exp}.${signature(apiToken, exp)}`
73
+ }
74
+
75
+ /**
76
+ * True only for a well-formed, unexpired, correctly-signed ticket.
77
+ *
78
+ * Fails CLOSED on every malformed input. The expiry is parsed from the ticket
79
+ * itself, so a forged expiry changes the signed message and fails the HMAC — the
80
+ * claim cannot be edited without the key.
81
+ */
82
+ /**
83
+ * Why a ticket was refused. `verifyDisplayTicket` collapsed every refusal to
84
+ * `false`, so the server's only signal was "N rejected" with no way to tell an
85
+ * EXPIRED ticket (a client that needs to re-mint — expected on every native
86
+ * EventSource retry after a WebView suspension) from a BAD SIGNATURE (a rotated
87
+ * token, or a forgery). QA on 2026-09-01 found >=7 rejections in three
88
+ * consecutive windows on the author's own device and could not say which.
89
+ */
90
+ export type DisplayTicketVerdict = 'ok' | 'malformed' | 'expired' | 'bad-signature'
91
+
92
+ export function explainDisplayTicket(
93
+ apiToken: string,
94
+ ticket: unknown,
95
+ nowMs: number = Date.now(),
96
+ ): DisplayTicketVerdict {
97
+ if (typeof ticket !== 'string' || !apiToken) return 'malformed'
98
+ const separator = ticket.indexOf('.')
99
+ if (separator <= 0) return 'malformed'
100
+ const expRaw = ticket.slice(0, separator)
101
+ const provided = ticket.slice(separator + 1)
102
+ if (!/^\d{1,15}$/.test(expRaw) || !/^[0-9a-f]{64}$/.test(provided)) return 'malformed'
103
+ const exp = Number(expRaw)
104
+ // Unreachable while the regex caps exp at 15 digits (MAX_SAFE_INTEGER has 16);
105
+ // kept as the belt for a widened regex. A mutation test proved it dead.
106
+ if (!Number.isSafeInteger(exp)) return 'malformed'
107
+ // Expiry is checked BEFORE the compare so a stale ticket cannot be probed for
108
+ // signature validity, and so the common rejection costs no hashing.
109
+ if (Math.floor(nowMs / 1000) >= exp) return 'expired'
110
+ return timingSafeTokenEqual(provided, signature(apiToken, exp)) ? 'ok' : 'bad-signature'
111
+ }
112
+
113
+ export function verifyDisplayTicket(
114
+ apiToken: string,
115
+ ticket: unknown,
116
+ nowMs: number = Date.now(),
117
+ ): boolean {
118
+ return explainDisplayTicket(apiToken, ticket, nowMs) === 'ok'
119
+ }
@@ -29,6 +29,40 @@ const EXEMPT_EXACT = new Set([
29
29
  'POST /api/recovery/server/restart',
30
30
  ])
31
31
 
32
+ /**
33
+ * Exempt by PREFIX, because the display stream grew a path segment.
34
+ *
35
+ * EXEMPT_EXACT matches whole strings, so `GET /api/display-stream/<exp>.<hmac>`
36
+ * (6.42.0's ticketed form) fell through to 'request' and would take a lease. An SSE
37
+ * connection has no `finish`, and `close` only converts the lease to a 120s grace
38
+ * window that a live socket never reaches — so one connected lens would hold the
39
+ * recovery gate open indefinitely and 409 every COS Control server restart. A
40
+ * long-lived stream is the exact shape that must never take a lease.
41
+ *
42
+ * SCOPE, stated honestly: in THIS build `recoveryAdmissionMiddleware` is exported
43
+ * but never mounted (`grep -rn "recovery-activity" server` shows routes/recovery.ts
44
+ * importing only acquireMaintenance and getRecoveryActivityStatus), so no lease is
45
+ * taken for any route and the 409 above is latent rather than live. The
46
+ * classification is still a published contract that a build mounting the middleware
47
+ * would inherit, so it is fixed here rather than left as a trap.
48
+ */
49
+ const EXEMPT_GET_PREFIXES = ['/api/display-stream/']
50
+
51
+ /**
52
+ * A capability URL is a live bearer credential. GET /api/recovery/status publishes
53
+ * every lease `kind` verbatim, and that route is reachable by anyone who can already
54
+ * read status — so a kind built from the raw path would republish a working display
55
+ * ticket (or a TTS playback capability) as plain text in a diagnostic response.
56
+ *
57
+ * Redaction happens where the kind is BUILT, not where it is read, so no future
58
+ * reader of `active` can reintroduce the leak.
59
+ */
60
+ export function redactCapabilityPath(path: string): string {
61
+ return path
62
+ .replace(/^(\/api\/display-stream)\/[^/]+$/, '$1/<ticket>')
63
+ .replace(/^(\/api\/tts\/play)\/[^/]+$/, '$1/<capability>')
64
+ }
65
+
32
66
  const OPERATION_GET_PREFIXES = [
33
67
  '/api/models',
34
68
  '/v1/models',
@@ -53,6 +87,10 @@ export function classifyRecoveryRoute(method: string, path: string): RecoveryRou
53
87
  const verb = method.toUpperCase()
54
88
  const normalized = path.split('?')[0]
55
89
  if (EXEMPT_EXACT.has(`${verb} ${normalized}`)) return 'exempt'
90
+ if ((verb === 'GET' || verb === 'HEAD')
91
+ && EXEMPT_GET_PREFIXES.some(prefix => normalized.startsWith(prefix))) {
92
+ return 'exempt'
93
+ }
56
94
  if (!normalized.startsWith('/api/') && !normalized.startsWith('/v1/')) return 'exempt'
57
95
  if (verb !== 'GET' && verb !== 'HEAD') return 'operation'
58
96
  if (OPERATION_GET_PREFIXES.some(prefix => normalized === prefix || normalized.startsWith(prefix))) {
@@ -144,7 +182,10 @@ export function recoveryAdmissionMiddleware(req: Request, res: Response, next: N
144
182
  }
145
183
 
146
184
  const id = `${routeClass}:${++sequence}`
147
- const release = createLease(`${req.method} ${(req.originalUrl || req.path).split('?')[0]}`, id)
185
+ const release = createLease(
186
+ `${req.method} ${redactCapabilityPath((req.originalUrl || req.path).split('?')[0])}`,
187
+ id,
188
+ )
148
189
  res.once('finish', release)
149
190
  res.once('close', () => {
150
191
  const lease = active.get(id)
@@ -2,30 +2,199 @@
2
2
  // Any connected glasses client receives real-time query responses
3
3
  // regardless of which interface submitted the query
4
4
 
5
- import { Router, type Response } from 'express'
5
+ import { Router, type Request, type Response } from 'express'
6
6
  import {
7
7
  emitDisplay,
8
8
  getDisplayWatermark,
9
9
  onDisplay,
10
10
  replayDisplayEvents,
11
+ type DisplayEvent,
11
12
  type PublishedDisplayEvent,
12
13
  } from '../lib/display-bus.js'
14
+ import { type DisplayTicketVerdict, explainDisplayTicket } from '../lib/display-ticket.js'
15
+ import { timingSafeTokenEqual } from '../lib/token-auth.js'
13
16
 
14
17
  export const displayRouter = Router()
15
18
 
19
+ /**
20
+ * What a TICKETLESS subscriber may receive, as a per-type PROJECTION.
21
+ *
22
+ * ALLOWLIST, NEVER A DENYLIST. A new member of the DisplayEvent union is withheld
23
+ * by default, so adding an event type can never silently widen the unauthenticated
24
+ * surface.
25
+ *
26
+ * A projection, not a pass-through, because an allowlisted TYPE can still carry
27
+ * content in its payload — which is exactly the bug the first cut of this shipped.
28
+ * The projection is the contract: whatever the emitter grows later, only the fields
29
+ * named here can ever leave.
30
+ *
31
+ * The other ten members of the union all carry user content: `transcript_chunk` and
32
+ * `prompt_transcript` carry meeting speech with speaker labels, `chunk`/`done` carry
33
+ * answer text, `session_restore` carries conversation state, `coaching_nudge` carries
34
+ * derived guidance, `start` carries session metadata, `tool_status` carries a message,
35
+ * `error` carries error text, and `recording_start` is covered by its own note below.
36
+ *
37
+ * NOT ROUTED THROUGH HERE: `keepalive` is an SSE comment (`: keepalive`), not an
38
+ * event. `ready` and `replay_gap` are written directly by the handler below — both
39
+ * are transport metadata with no user content, and `replay_gap` is deliberately
40
+ * ticketless-visible (see the handler).
41
+ */
42
+ const TICKETLESS_PROJECTIONS: {
43
+ readonly [K in DisplayEvent['type']]?: (data: Record<string, unknown>) => Record<string, unknown>
44
+ } = {
45
+ /**
46
+ * `recording_stop` is a lifecycle marker: the lens uses it to clear a stale
47
+ * "recording" indicator that would otherwise persist forever.
48
+ *
49
+ * BUT THE PRODUCTION PAYLOAD IS NOT BARE. Both emitters (routes/meeting.ts, the
50
+ * durable save path and the orphan-recovery path) send
51
+ * `{ sessionId, filename, durationMin, domain }`, and `filename` is built by
52
+ * meeting-store.ts `filenameStem()` from the transcript-derived meeting TITLE —
53
+ * `2026-08-31_Q3_Budget_Cuts_Layoffs_1a2b3c4d.md`. Passing the event through whole
54
+ * broadcast the meeting title, its duration and its business domain to every
55
+ * unauthenticated listener on the LAN, which is precisely what a ticket exists to
56
+ * withhold.
57
+ *
58
+ * Only `sessionId` survives. It is the id the CLIENT supplied when it started the
59
+ * capture (meeting-store `normalizeSessionId` constrains it to
60
+ * `[A-Za-z0-9:_-]{3,96}` and the server never derives it from the transcript), so
61
+ * it is the one field the subscriber already holds and the one the lens needs to
62
+ * match the marker to its own indicator.
63
+ */
64
+ // `durationMin` rides along because the lens renders `Meeting saved — ${n}m`
65
+ // straight from this frame (glasses-entry.ts). Projecting it away left a valid
66
+ // JSON payload — so the client's catch never fired — and painted
67
+ // "Meeting saved — undefinedm". A duration is a scalar with no transcript in it;
68
+ // `filename` stays stripped precisely because it embeds the meeting TITLE.
69
+ recording_stop: data => ({ sessionId: data.sessionId, durationMin: data.durationMin }),
70
+
71
+ /**
72
+ * `recording_start` is NOT here, as a DECISION rather than an omission.
73
+ *
74
+ * Three reasons, in order of weight:
75
+ * 1. The failure modes are asymmetric. A missing `stop` leaves the lens asserting
76
+ * something FALSE — a recording indicator burning with no recording behind it.
77
+ * A missing `start` leaves it showing nothing, which is exactly what the
78
+ * ticketless contract promises anyway. Only the wrong state needs repairing.
79
+ * 2. `start` is a live presence signal. "A meeting is beginning on this machine,
80
+ * right now" is occupancy intelligence for any listener on the network; `stop`
81
+ * is the erasure of a signal already shown.
82
+ * 3. Nothing in this server emits it. `grep -rn "type: 'recording_start'" server`
83
+ * returns no emitter as of 6.42.0 — it exists in the union for client-side
84
+ * use — so allowlisting it would advertise a path that never runs.
85
+ *
86
+ * If a server-side emitter is ever added, revisit this WITH a projection; do not
87
+ * simply add the key.
88
+ */
89
+ }
90
+
91
+ /**
92
+ * The event a ticketless subscriber may receive, or null when it may receive none.
93
+ * Never returns the input event unchanged — the projection is always applied.
94
+ */
95
+ function projectForTicketless(event: PublishedDisplayEvent): PublishedDisplayEvent | null {
96
+ // hasOwn, not a bare index: the map is an object literal and inherits
97
+ // Object.prototype, so a type of "constructor" would resolve to `Object` — a
98
+ // truthy identity function — and pass the event through whole. Unreachable via
99
+ // the typed union today; the allowlist must not depend on that staying true.
100
+ const project = Object.hasOwn(TICKETLESS_PROJECTIONS, event.type)
101
+ ? TICKETLESS_PROJECTIONS[event.type]
102
+ : undefined
103
+ if (!project) return null
104
+ return { ...event, data: project(event.data) }
105
+ }
106
+
16
107
  function writeEvent(res: Response, event: PublishedDisplayEvent): void {
17
108
  const data = JSON.stringify({
18
109
  ...event.data,
110
+ // NESTED, and it must stay nested. Every shipped client reads
111
+ // `parsed._cosDisplayCursor` and RETURNS EARLY when it is absent
112
+ // (Main.ts, identically in 6.8.353 and 6.8.441), so flattening these three
113
+ // fields to the top level silently freezes the client cursor at its
114
+ // connect-time watermark: `rememberCursor` never fires, and every reconnect
115
+ // then re-replays and re-processes everything since the connect (duplicate
116
+ // `done` renders), or trips a spurious buffer_overflow replay_gap past 200
117
+ // events. 6.42.0 briefly shipped the flattened shape in development; it was
118
+ // caught pre-release. A test that only asserts `"eventId":1` cannot see the
119
+ // difference, because that substring is present in BOTH shapes.
19
120
  _cosDisplayCursor: {
20
121
  bootId: event.bootId,
21
122
  eventId: event.eventId,
22
123
  publishedAt: event.publishedAt,
23
124
  },
24
125
  })
126
+ // SSE `id:` is the reconnect cursor EventSource echoes back as Last-Event-ID.
127
+ // Namespaced by bootId so a restarted server cannot look like a resumable gap.
25
128
  res.write(`id: ${event.bootId}:${event.eventId}\nevent: ${event.type}\ndata: ${data}\n\n`)
26
129
  }
27
130
 
28
- displayRouter.get('/display-stream', (req, res) => {
131
+ /**
132
+ * Ticketless-connect accounting, summarized rather than logged per connect.
133
+ *
134
+ * The server sends `retry: 3000`, so ONE client that cannot mint reconnects every
135
+ * three seconds — 1,200 lines an hour into the launchd log, forever, from a single
136
+ * stale install. The counter is the point (adoption is measured, not guessed); the
137
+ * per-line volume is not.
138
+ */
139
+ const TICKETLESS_LOG_INTERVAL_MS = 60_000
140
+ let ticketlessConnects = 0
141
+ let ticketlessRejectedTickets = 0
142
+ let ticketlessLoggedAt = 0
143
+ // Per-reason so the log can tell "a client needs to re-mint" (expired — expected
144
+ // after every native EventSource retry on a stale URL) from "someone is holding
145
+ // a ticket this token never signed" (bad-signature — a rotation, or a probe).
146
+ const ticketlessByReason: Record<Exclude<DisplayTicketVerdict, 'ok'> | 'none', number> = {
147
+ none: 0, malformed: 0, expired: 0, 'bad-signature': 0,
148
+ }
149
+
150
+ function noteTicketlessConnect(reason: Exclude<DisplayTicketVerdict, 'ok'> | 'none'): void {
151
+ ticketlessConnects++
152
+ if (reason !== 'none') ticketlessRejectedTickets++
153
+ ticketlessByReason[reason]++
154
+ const now = Date.now()
155
+ if (ticketlessLoggedAt !== 0 && now - ticketlessLoggedAt < TICKETLESS_LOG_INTERVAL_MS) return
156
+ ticketlessLoggedAt = now
157
+ const { expired, 'bad-signature': bad, malformed, none } = ticketlessByReason
158
+ console.warn(
159
+ `[display-bus] ${ticketlessConnects} ticketless subscriber(s)`
160
+ + ` (${ticketlessRejectedTickets} with a rejected ticket:`
161
+ + ` ${expired} expired, ${bad} bad-signature, ${malformed} malformed; ${none} bare)`
162
+ + ` — content withheld, lifecycle only`,
163
+ )
164
+ ticketlessConnects = 0
165
+ ticketlessRejectedTickets = 0
166
+ for (const k of Object.keys(ticketlessByReason) as Array<keyof typeof ticketlessByReason>) ticketlessByReason[k] = 0
167
+ }
168
+
169
+ export function __resetDisplayStreamLogForTests(): void {
170
+ ticketlessConnects = 0
171
+ ticketlessRejectedTickets = 0
172
+ ticketlessLoggedAt = 0
173
+ for (const k of Object.keys(ticketlessByReason) as Array<keyof typeof ticketlessByReason>) ticketlessByReason[k] = 0
174
+ }
175
+
176
+ /**
177
+ * A valid `X-Cos-Token` header is equivalent authorization to a ticket.
178
+ *
179
+ * The ticket exists only because EventSource cannot set headers. Every fetch-based
180
+ * consumer can — including the client's own `probeConnectionTarget`, COS Control, and
181
+ * curl — so requiring those to mint first would suppress content for callers that are
182
+ * already fully authenticated, and would permanently pollute the ticketless adoption
183
+ * counter with connections that were never the problem.
184
+ */
185
+ function headerAuthorized(req: Request): boolean {
186
+ return timingSafeTokenEqual(req.headers['x-cos-token'], process.env.COS_API_TOKEN ?? '')
187
+ }
188
+
189
+ /**
190
+ * Shared handler for both registrations.
191
+ *
192
+ * `authorized` decides CONTENT, never admission. A ticketless subscriber still gets
193
+ * 200, `ready`, the keepalive, lifecycle markers, and replay-gap notices, so every
194
+ * already-installed client keeps a live transport, keeps `displayBusConnected` true,
195
+ * and keeps syncing offline meetings. It simply never receives transcripts or answers.
196
+ */
197
+ function serveDisplayStream(req: Request, res: Response, authorized: boolean): void {
29
198
  res.writeHead(200, {
30
199
  'Content-Type': 'text/event-stream',
31
200
  'Cache-Control': 'no-cache',
@@ -38,26 +207,62 @@ displayRouter.get('/display-stream', (req, res) => {
38
207
  // Tell EventSource to retry quickly on disconnect (3s instead of browser default ~5-10s)
39
208
  res.write('retry: 3000\n\n')
40
209
 
210
+ // DO NOT move this inside an `if (authorized)`. It looks unused on the ticketless
211
+ // path and is not: a ticketless subscriber never receives buffered events, but it
212
+ // MUST still be told when its cursor is unresumable (see replay_gap below), and
213
+ // that verdict is a function of the cursor. Hiding the cursor behind the
214
+ // authorization check would silently delete ticketless gap reporting.
41
215
  const headerCursor = String(req.headers['last-event-id'] ?? '')
42
216
  const [headerBootId, headerEventId] = headerCursor.includes(':')
43
217
  ? headerCursor.split(':', 2)
44
218
  : ['', headerCursor]
45
219
  const cursorBootId = String(req.query.bootId ?? headerBootId ?? '') || null
46
220
  const cursorEventId = Number(req.query.eventId ?? headerEventId ?? 0)
47
- const replay = replayDisplayEvents(cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0)
48
221
 
49
222
  // Ready is a transport handshake, not proof that replay was consumed. It
50
223
  // must precede application events so build 188 can finish admission first.
224
+ //
225
+ // `contentAuthorized` is what makes a degraded stream DETECTABLE. Without it the
226
+ // two handshakes are byte-identical, so a client whose ticket expired sets
227
+ // `displayBusConnected = true`, sees a healthy socket, and never re-mints — the
228
+ // stream stays silent forever with nothing anywhere reporting a fault. Old clients
229
+ // JSON.parse this frame and read only bootId/eventId, so the extra key is inert
230
+ // for them.
51
231
  const watermark = getDisplayWatermark()
52
- res.write(`event: ready\ndata: ${JSON.stringify(watermark)}\n\n`)
232
+ res.write(`event: ready\ndata: ${JSON.stringify({ ...watermark, contentAuthorized: authorized })}\n\n`)
233
+
234
+ // Gap detection is needed for every subscriber; MATERIALISING the up-to-200
235
+ // event buffer is only needed for one we will actually write it to. A stale
236
+ // ticketless install retrying every 3s was filtering the whole buffer each
237
+ // time and discarding it — and so was every authorized `probe=1` connect,
238
+ // whose write is skipped below. The term here must match that `else if`.
239
+ const materialize = authorized && req.query.probe !== '1'
240
+ const replay = replayDisplayEvents(
241
+ cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0, { materialize },
242
+ )
53
243
  if (replay.gap) {
244
+ // Ticketless-VISIBLE on purpose. The payload is transport metadata only —
245
+ // reason, the cursor the client itself sent, the watermark already in `ready`,
246
+ // and a buffer boundary — so it discloses nothing. Withholding it silently
247
+ // breaks the client's replay-reconciliation branch after a server restart: the
248
+ // client would sit on a dead cursor waiting for a resume that cannot come.
54
249
  res.write(`event: replay_gap\ndata: ${JSON.stringify({
55
250
  reason: replay.reason,
56
251
  requested: { bootId: cursorBootId, eventId: cursorEventId },
57
252
  watermark,
58
253
  oldestEventId: replay.oldestEventId,
59
254
  })}\n\n`)
60
- } else {
255
+ } else if (authorized && req.query.probe !== '1') {
256
+ // The replay buffer holds up to REPLAY_BUFFER_SIZE past events, so serving it
257
+ // to a ticketless subscriber would be a retroactive transcript dump — a larger
258
+ // disclosure than the live subscription. Skipped entirely rather than filtered,
259
+ // so no future event type can leak through a per-item test here.
260
+ //
261
+ // `probe=1` is the client's connection probe: it opens this stream ONLY to read
262
+ // the `ready` watermark and then aborts. It sends the token, so it is
263
+ // authorized — and was being handed the full buffer on every reconnect and
264
+ // throwing it away (1,164 "Replayed 200" lines in one day's log). A probe is
265
+ // never a consumer; it gets the handshake and nothing else.
61
266
  for (const event of replay.events) writeEvent(res, event)
62
267
  if (replay.events.length > 0) {
63
268
  console.log(`[display-bus] Replayed ${replay.events.length} publish-owned events after ${cursorEventId}`)
@@ -70,13 +275,43 @@ displayRouter.get('/display-stream', (req, res) => {
70
275
  }, 15_000)
71
276
 
72
277
  const unsub = onDisplay((event) => {
73
- try { writeEvent(res, event) } catch { /* client gone */ }
278
+ // The projection runs INSIDE the try, not above it. This is a synchronous
279
+ // EventEmitter listener, so anything that throws here propagates back out
280
+ // through `bus.emit()` into whoever called `emitDisplay` — and one of those
281
+ // callers is the meeting-save path. A malformed event must never be able to
282
+ // reach a recording. 6.41.0 guarded the whole listener body; keep that.
283
+ try {
284
+ const outgoing = authorized ? event : projectForTicketless(event)
285
+ if (!outgoing) return
286
+ writeEvent(res, outgoing)
287
+ } catch { /* client gone, or an event this subscriber simply cannot render */ }
74
288
  })
75
289
 
76
290
  req.on('close', () => {
77
291
  clearInterval(ping)
78
292
  unsub()
79
293
  })
294
+ }
295
+
296
+ // Ticketless path. Stays public so installed clients keep a live transport; content
297
+ // is withheld above unless the caller sent a valid token header.
298
+ displayRouter.get('/display-stream', (req, res) => {
299
+ const authorized = headerAuthorized(req)
300
+ if (!authorized) noteTicketlessConnect('none')
301
+ serveDisplayStream(req, res, authorized)
302
+ })
303
+
304
+ // Ticketed path. `api-auth` admits it on SHAPE alone; the signature is verified
305
+ // here, where the API token lives. A bad or expired ticket is not rejected — it
306
+ // degrades to exactly the ticketless stream, so a client whose ticket expired
307
+ // mid-reconnect keeps its transport instead of entering a retry loop. It learns it
308
+ // is degraded from `contentAuthorized:false` in the `ready` frame, and re-mints.
309
+ displayRouter.get('/display-stream/:ticket', (req, res) => {
310
+ const apiToken = process.env.COS_API_TOKEN ?? ''
311
+ const verdict = explainDisplayTicket(apiToken, req.params.ticket)
312
+ const authorized = verdict === 'ok' || headerAuthorized(req)
313
+ if (!authorized) noteTicketlessConnect(verdict)
314
+ serveDisplayStream(req, res, authorized)
80
315
  })
81
316
 
82
317
  // POST /api/display-session — broadcast session restore to glasses (cross-surface sync)
@@ -10,6 +10,7 @@ import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contra
10
10
  import { isSileroAvailable } from '../lib/vad-silero.js'
11
11
  import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
12
12
  import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
13
+ import { mintDisplayTicket, DISPLAY_TICKET_TTL_SECONDS } from '../lib/display-ticket.js'
13
14
  import { correctionStoreStats } from '../lib/meeting-corrections.js'
14
15
  import { meetingAudioStats } from '../lib/meeting-audio-archive.js'
15
16
  import { adaptivePlaybackStatus } from '../lib/adaptive-playback-audio.js'
@@ -353,6 +354,14 @@ healthRouter.get('/health', async (_req, res) => {
353
354
  review_audio: reviewAudio,
354
355
  ...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
355
356
  capabilities: {
357
+ // Advertised on the PUBLIC health route on purpose: a client deciding whether
358
+ // to request a display ticket may not hold a usable token yet, and an old
359
+ // server simply omits this key, which is how a new client detects it.
360
+ displayStream: {
361
+ ticketSupported: true,
362
+ ticketTtlSeconds: DISPLAY_TICKET_TTL_SECONDS,
363
+ contentRequiresTicket: true,
364
+ },
356
365
  transcription: {
357
366
  ...transcription,
358
367
  live: transcriptionLive,
@@ -429,6 +438,9 @@ healthRouter.get('/models', async (req, res) => {
429
438
  // THIS surface and Main.ts states outright that /api/health alone is not used,
430
439
  // so a capability published only there is invisible to the phone.
431
440
  const threadAttach = threadAttachCapability()
441
+ // mintDisplayTicket refuses an empty key, because a ticket signed with '' can
442
+ // never verify. Omit the field rather than publish a dead capability.
443
+ const apiToken = process.env.COS_API_TOKEN ?? ''
432
444
  res.json({
433
445
  ...catalog,
434
446
  ...threadAttachHealthFields(threadAttach),
@@ -447,6 +459,42 @@ healthRouter.get('/models', async (req, res) => {
447
459
  },
448
460
  ollamaReady: isOllamaProviderReady(),
449
461
  serverInstanceId: getServerInstanceId(),
462
+ // Minted here rather than on a route of its own: this response is already
463
+ // authenticated, and the mint is stateless, so it costs nothing and stores
464
+ // nothing.
465
+ //
466
+ // TWO THINGS THIS ROUTE IS NOT, both of which the client must plan around:
467
+ //
468
+ // 1. It IS fetched before every connect in the 6.8.441 client — every retry
469
+ // calls connectDisplayBus() with no argument, which re-runs
470
+ // probeConnectionTarget, which fetches this route. Do not assume that of
471
+ // any OTHER client: nothing in the protocol requires it, and a client that
472
+ // minted once at pairing would go content-free one TTL later.
473
+ // 2. Its drain behaviour is NOT what classifyRecoveryRoute implies. That
474
+ // classifier puts /api/models in OPERATION_GET_PREFIXES, but its only
475
+ // consumer — recoveryAdmissionMiddleware — is never mounted in this build,
476
+ // and the gate that IS mounted (server/index.ts) returns next() for every
477
+ // GET/HEAD/OPTIONS on its first line. So this route answers 200 during a
478
+ // COS Control drain today. Treat the classifier's verdict as the contract a
479
+ // build that mounts that middleware would enforce, not as current behaviour.
480
+ // (The ticketed display-stream path is exempt either way, so an already-held
481
+ // ticket keeps working through a drain — which is still why the TTL has to
482
+ // outlive one.)
483
+ //
484
+ // So the CLIENT carries the re-mint obligation: fetch /api/models and reconnect
485
+ // when a display-stream `ready` frame reports `contentAuthorized: false`,
486
+ // retrying if the fetch 503s. Nothing on the server can re-mint on the
487
+ // client's behalf.
488
+ //
489
+ // AUTHORIZATION IS DECIDED ONCE PER SOCKET, at connect. A ticket that was
490
+ // valid when the EventSource opened keeps that socket authorized for its whole
491
+ // life — QA on 2026-09-01 held an 8-second ticket open for 32s and content
492
+ // kept flowing. That is deliberate: the shipped client re-mints only on
493
+ // RECONNECT, never on a timer, so a per-event re-check would blank every
494
+ // 6.8.441 lens fifteen minutes into a meeting. The TTL therefore bounds how
495
+ // long a LEAKED URL can open a new socket, not how long an open socket lives.
496
+ // Changing that is a breaking change that needs a client version gate first.
497
+ ...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
450
498
  capabilities: {
451
499
  durableQueryJobs: {
452
500
  enabled: durableJobs.enabled,
@@ -161,9 +161,13 @@ queryRouter.post('/query', async (req, res) => {
161
161
  emitDisplay({ type: 'tool_status', data: { message } })
162
162
  }
163
163
  },
164
- // Activity lines stay on this authenticated request stream. The global
165
- // display stream is intentionally unauthenticated for Even Hub recovery,
166
- // so observable command/output text must never be broadcast there.
164
+ // Activity lines stay on this authenticated request stream. The guard is
165
+ // unchanged; the reason has moved. Since 6.42.0 the global display stream is
166
+ // not "unauthenticated" it ADMITS ticketless subscribers (200 for everyone,
167
+ // so an installed client keeps a live transport) and withholds CONTENT per
168
+ // event instead. Broadcasting observable command/output text onto that bus
169
+ // would leave it one allowlist entry away from an unauthenticated listener,
170
+ // so it is never emitted there at all.
167
171
  ...(activityToolMode === 'preview' ? {
168
172
  onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => {
169
173
  if (!done) {