@gotcos/glasses-server 6.40.2 → 6.42.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/.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,104 @@
1
+ ## 6.42.0
2
+
3
+ The display stream stops broadcasting your meetings to the local network.
4
+
5
+ `GET /api/display-stream` has been public since it was written, because
6
+ EventSource cannot attach an `X-Cos-Token` header. That is still true, so the
7
+ route is still public and still returns 200 to everyone — but what it SENDS is
8
+ now decided per event. A subscriber that proves it holds the pairing token gets
9
+ the whole bus. A subscriber that does not gets a live transport, the handshake,
10
+ the keepalive, replay-gap notices, and one projected lifecycle marker. It never
11
+ receives a transcript, an answer, a coaching cue, a tool status or an error.
12
+
13
+ **COS Glasses app 6.8.441 is required to restore full content delivery.** Older
14
+ builds keep working — that is the entire reason the connection is not rejected —
15
+ but they connect without a capability, so they will see the content-suppressed
16
+ stream: no live transcript on the lens and no streamed answers, while meeting
17
+ capture, saving, and offline sync continue normally. 6.8.441 is the first app
18
+ build that fetches a ticket and reconnects on `contentAuthorized: false`. Nothing
19
+ else states this, so state it here: update the app with the server.
20
+
21
+ Two ways to prove you hold the token:
22
+
23
+ - `X-Cos-Token` on the request, for every fetch-based consumer (COS Control, the
24
+ companion's own connection probe, curl). Preferred wherever it is possible.
25
+ - `GET /api/display-stream/<expiry>.<hmac>` for EventSource, which cannot send a
26
+ header. The ticket is an HMAC over its own expiry keyed on the pairing token,
27
+ so it is stateless, unforgeable without the key, and invalidated the instant
28
+ the token rotates. `GET /api/models` mints one; `GET /api/health` advertises the
29
+ capability so a client can detect an older server by its absence.
30
+
31
+ An invalid or expired ticket is never rejected. It degrades to the ticketless
32
+ stream, so a client whose ticket died mid-reconnect keeps its transport instead
33
+ of entering a retry loop — and the `ready` frame now carries
34
+ `contentAuthorized: <boolean>` so it can tell the difference and re-mint. Without
35
+ that field a degraded stream is indistinguishable from a healthy one.
36
+
37
+ The ticket TTL is 15 minutes, chosen by the platform rather than by taste: a
38
+ backgrounded Even Hub WebView is suspended and cannot re-mint, and the Even Hub
39
+ review loop locks the phone for five minutes. A 2-minute TTL was guaranteed to
40
+ be dead on the reviewer's exact path. Minting requires the pairing token, so the
41
+ TTL only bounds the value of a ticket that leaks out of a URL.
42
+
43
+ Also in this release:
44
+
45
+ - `recording_stop` reaches ticketless subscribers as a PROJECTION carrying only
46
+ `sessionId`. The production payload also carries `filename`, which embeds the
47
+ transcript-derived meeting title (`..._Q3_Budget_Cuts_Layoffs_....md`), plus
48
+ duration and business domain. An allowlist of event TYPES passed the whole
49
+ event through and would have broadcast the title of every meeting.
50
+ - `replay_gap` reaches ticketless subscribers too. It carries only a reason, the
51
+ cursor the client itself sent, the watermark already in `ready`, and a buffer
52
+ boundary, and withholding it strands a client on a dead cursor after a server
53
+ restart. The buffer it describes is still never served without a ticket.
54
+ - The ticketed path is exempt from the recovery lease classifier. It matched by
55
+ exact string, so the new path segment classified as a leased request — and an
56
+ SSE lease never settles, so a connected lens would have held the recovery gate
57
+ open and 409'd every COS Control server restart. (In this build the admission
58
+ middleware is exported but not mounted, so that was latent, not live; the
59
+ classification is a contract a build that mounts it would inherit.) Lease kinds
60
+ are also redacted now, so `GET /api/recovery/status` can never republish a live
61
+ display ticket or TTS capability as plain text.
62
+ - `mintDisplayTicket` throws on an empty token instead of returning a ticket that
63
+ can never verify, and `/api/models` omits the field rather than advertising a
64
+ dead capability.
65
+ - Ticketless connects are summarized once a minute with a count instead of logged
66
+ per connect. `retry: 3000` means one stale client would otherwise write ~1,200
67
+ lines an hour into the launchd log.
68
+ - `.env.example` said a token is required on every `/api` call. It is not, and it
69
+ never was: four paths are public and two are capability URLs. That file ships in
70
+ the npm tarball.
71
+
72
+ ## 6.41.0
73
+
74
+ Allowlist mode can finally read the workspace it was pointed at.
75
+
76
+ A public user set `COS_CLAUDE_TRUST_MODE=allowlist` (the security-conscious
77
+ choice, tried first) and got "I don't have access to your workspace files."
78
+ That was the code working as written: in allowlist mode the per-query tool
79
+ list is the entire tool universe, and it was built as WebSearch + WebFetch,
80
+ with Read added only when the query carried a photo. The CLI's working
81
+ directory correctly pointed at the user's workspace; nothing in the list
82
+ could open a file in it.
83
+
84
+ Three changes, one release:
85
+
86
+ - The base tool list now always includes `Read`, `Glob`, and `Grep` — the
87
+ read-only exploration trio. Allowlist mode becomes "web plus read-only
88
+ workspace": shell, edits, and writes remain impossible by construction,
89
+ and a new test pins that Bash/Edit/Write can never appear in this list.
90
+ In trusted mode (the default) the list is only an auto-approve hint, so
91
+ behavior there is unchanged.
92
+ - The allowlist capability prompt now affirms workspace readability — but
93
+ only when the list actually grants it, so the prompt can never over-claim.
94
+ Without the affirmation, a model told it is "genuinely limited" tends to
95
+ refuse reads it has.
96
+ - The one listless spawn (prewarm) no longer passes an empty `--tools` in
97
+ allowlist mode. On the current CLI that flag triggers a spurious context
98
+ compaction and a synthetic 400 (the same pathology isolated by the fork
99
+ bisection on 2026-08-26); denial now rides `--permission-mode dontAsk`
100
+ plus an empty `--allowedTools` alone.
101
+
1
102
  ## 6.40.2
2
103
 
3
104
  A finished answer could leave the stream open forever.
package/README.md CHANGED
@@ -87,8 +87,12 @@ without silently losing completed replies.
87
87
  > has those tools, so you will see it use them more readily than before.
88
88
  > Set `COS_CLAUDE_TRUST_MODE=allowlist` to remove Claude's permission bypass
89
89
  > and restrict it to COS's explicit per-query tool allowlist; undeclared tools
90
- > then fail closed without prompting. Only the exact value `allowlist` restricts
91
- > anything any other value logs a warning and stays trusted.
90
+ > then fail closed without prompting. In allowlist mode the query keeps web
91
+ > search/fetch and **read-only workspace access** (Read, Glob, Grep) no
92
+ > shell, no edits, no writes. Only the exact value `allowlist` restricts
93
+ > anything — any other value logs a warning and stays trusted. Servers before
94
+ > 6.41.0 denied ALL workspace reads in allowlist mode; if a hardened install
95
+ > answers "I don't have access to your workspace files", update the server.
92
96
 
93
97
  ## Connect your phone (the one gotcha)
94
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.40.2",
3
+ "version": "6.42.0",
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": {
@@ -16,10 +16,26 @@ const PUBLIC_API_PATHS = new Set([
16
16
  // path segment, and no query-token fallback that could leak into URL logs.
17
17
  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
18
 
19
+ // EventSource has the identical constraint, so the display stream reuses the same
20
+ // shape: `/display-stream/<expUnixSeconds>.<hex hmac>`, GET/HEAD only, path segment,
21
+ // no query-token fallback. Admission here is SHAPE ONLY — the signature is verified
22
+ // in the route, which is the only place that holds the API token.
23
+ //
24
+ // `/display-stream` itself STAYS PUBLIC, deliberately. Removing it would make a
25
+ // ticketless connect fall through to the token check and 401, and neither EventSource
26
+ // in the client can send a header — every installed build would enter a permanent
27
+ // reconnect loop and, because `displayBusConnected` would never turn true, would also
28
+ // stop syncing already-recorded offline meetings. Confidentiality is enforced by
29
+ // withholding CONTENT in the route, not by rejecting the connection. The route also
30
+ // accepts a valid X-Cos-Token header as equivalent authorization, for the fetch-based
31
+ // callers that can send one; the ticket exists only for the ones that cannot.
32
+ const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64}$/
33
+
19
34
  export function isPublicApiRequest(method: string, path: string): boolean {
20
35
  if (PUBLIC_API_PATHS.has(path)) return true
21
- return (method === 'GET' || method === 'HEAD')
22
- && TTS_PLAYBACK_CAPABILITY_PATH.test(path)
36
+ if (method !== 'GET' && method !== 'HEAD') return false
37
+ return TTS_PLAYBACK_CAPABILITY_PATH.test(path)
38
+ || DISPLAY_STREAM_CAPABILITY_PATH.test(path)
23
39
  }
24
40
 
25
41
  /** Global /api authentication boundary. Mount before all body parsers. */
@@ -459,7 +459,6 @@ export async function callClaudeStreaming(
459
459
  throw err
460
460
  }
461
461
  const allowedToolList = buildClaudeToolList({
462
- includeRead: imagePaths.length > 0,
463
462
  publisherTool: outputImagePublisher?.claudeAllowedTool,
464
463
  })
465
464
  let mcpConfigArgs: string[]
@@ -45,10 +45,16 @@ export function claudePermissionArgs(
45
45
  : ['--dangerously-skip-permissions', '--allowedTools', allowedTools]
46
46
  }
47
47
 
48
- const tools = allowedTools ?? ''
48
+ // No per-query list (the prewarm spawn): deny everything via dontAsk plus an
49
+ // empty allowedTools, but never pass an empty --tools — on the current CLI
50
+ // an empty --tools triggers a spurious context compaction and a synthetic
51
+ // 400 (single-flag bisection 2026-08-26, documented in fork-thread.ts).
52
+ if (allowedTools === null) {
53
+ return ['--permission-mode', 'dontAsk', '--allowedTools', '']
54
+ }
49
55
  return [
50
56
  '--permission-mode', 'dontAsk',
51
- '--tools', tools,
52
- '--allowedTools', tools,
57
+ '--tools', allowedTools,
58
+ '--allowedTools', allowedTools,
53
59
  ]
54
60
  }
@@ -89,12 +89,15 @@ export function reportClaudeExtraToolConfiguration(
89
89
  }
90
90
 
91
91
  export function buildClaudeToolList(input: {
92
- includeRead?: boolean
93
92
  publisherTool?: string
94
93
  env?: NodeJS.ProcessEnv
95
94
  } = {}): string[] {
96
- const tools = ['WebSearch', 'WebFetch']
97
- if (input.includeRead) tools.push('Read')
95
+ // Read-only workspace tools are unconditional: in allowlist mode this list
96
+ // is the entire tool universe, and without them a hardened install cannot
97
+ // read the workspace the glasses are pointed at (field report 2026-08-27).
98
+ // Shell/Edit/Write stay excluded, so allowlist keeps its no-side-effects
99
+ // property. In trusted mode the list is only an auto-approve hint.
100
+ const tools = ['WebSearch', 'WebFetch', 'Read', 'Glob', 'Grep']
98
101
  tools.push(...configuredClaudeExtraTools(input.env))
99
102
  if (input.publisherTool) tools.push(input.publisherTool)
100
103
  return [...new Set(tools)]
@@ -186,8 +189,15 @@ export function claudeToolCapabilityPrompt(
186
189
  const honesty = TOOL_HONESTY_CLAUSE
187
190
 
188
191
  if (mode === 'allowlist') {
192
+ // Affirm workspace readability ONLY when the list actually grants it —
193
+ // this prompt is something the session trusts, and promising reads a
194
+ // caller did not include would recreate the 2026-07-28 class of header
195
+ // mismatch in the opposite direction.
196
+ const workspaceLine = tools.includes('Read')
197
+ ? `\nYour working directory is the user's COS workspace: Read, Glob, and Grep are in the list so you can search and read its files. The restriction here is on shell, writes, and undeclared tools — not on reading the workspace. Never refuse a workspace read in this mode.`
198
+ : ''
189
199
  return `TOOL CAPABILITY CONTRACT:
190
- This request runs in RESTRICTED allowlist mode and is genuinely limited to these tool selectors: ${list}. Undeclared tools are denied without prompting, so a call outside this list will fail.
200
+ This request runs in RESTRICTED allowlist mode and is genuinely limited to these tool selectors: ${list}. Undeclared tools are denied without prompting, so a call outside this list will fail.${workspaceLine}
191
201
  Selectors are permissions, not proof that a connector is online. Use a tool only when it is actually present in this session. If the user asks for a tool or connector that is absent, or a tool call fails, say that it is unavailable. ${honesty}
192
202
  ${UNTRUSTED_CONTENT_CLAUSE}`
193
203
  }
@@ -0,0 +1,99 @@
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
+ export function verifyDisplayTicket(
83
+ apiToken: string,
84
+ ticket: unknown,
85
+ nowMs: number = Date.now(),
86
+ ): boolean {
87
+ if (typeof ticket !== 'string' || !apiToken) return false
88
+ const separator = ticket.indexOf('.')
89
+ if (separator <= 0) return false
90
+ const expRaw = ticket.slice(0, separator)
91
+ const provided = ticket.slice(separator + 1)
92
+ if (!/^\d{1,15}$/.test(expRaw) || !/^[0-9a-f]{64}$/.test(provided)) return false
93
+ const exp = Number(expRaw)
94
+ if (!Number.isSafeInteger(exp)) return false
95
+ // Expiry is checked BEFORE the compare so a stale ticket cannot be probed for
96
+ // signature validity, and so the common rejection costs no hashing.
97
+ if (Math.floor(nowMs / 1000) >= exp) return false
98
+ return timingSafeTokenEqual(provided, signature(apiToken, exp))
99
+ }
@@ -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,181 @@
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 { verifyDisplayTicket } 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
+ const project = TICKETLESS_PROJECTIONS[event.type]
97
+ if (!project) return null
98
+ return { ...event, data: project(event.data) }
99
+ }
100
+
16
101
  function writeEvent(res: Response, event: PublishedDisplayEvent): void {
17
102
  const data = JSON.stringify({
18
103
  ...event.data,
104
+ // NESTED, and it must stay nested. Every shipped client reads
105
+ // `parsed._cosDisplayCursor` and RETURNS EARLY when it is absent
106
+ // (Main.ts, identically in 6.8.353 and 6.8.441), so flattening these three
107
+ // fields to the top level silently freezes the client cursor at its
108
+ // connect-time watermark: `rememberCursor` never fires, and every reconnect
109
+ // then re-replays and re-processes everything since the connect (duplicate
110
+ // `done` renders), or trips a spurious buffer_overflow replay_gap past 200
111
+ // events. 6.42.0 briefly shipped the flattened shape in development; it was
112
+ // caught pre-release. A test that only asserts `"eventId":1` cannot see the
113
+ // difference, because that substring is present in BOTH shapes.
19
114
  _cosDisplayCursor: {
20
115
  bootId: event.bootId,
21
116
  eventId: event.eventId,
22
117
  publishedAt: event.publishedAt,
23
118
  },
24
119
  })
120
+ // SSE `id:` is the reconnect cursor EventSource echoes back as Last-Event-ID.
121
+ // Namespaced by bootId so a restarted server cannot look like a resumable gap.
25
122
  res.write(`id: ${event.bootId}:${event.eventId}\nevent: ${event.type}\ndata: ${data}\n\n`)
26
123
  }
27
124
 
28
- displayRouter.get('/display-stream', (req, res) => {
125
+ /**
126
+ * Ticketless-connect accounting, summarized rather than logged per connect.
127
+ *
128
+ * The server sends `retry: 3000`, so ONE client that cannot mint reconnects every
129
+ * three seconds — 1,200 lines an hour into the launchd log, forever, from a single
130
+ * stale install. The counter is the point (adoption is measured, not guessed); the
131
+ * per-line volume is not.
132
+ */
133
+ const TICKETLESS_LOG_INTERVAL_MS = 60_000
134
+ let ticketlessConnects = 0
135
+ let ticketlessRejectedTickets = 0
136
+ let ticketlessLoggedAt = 0
137
+
138
+ function noteTicketlessConnect(rejectedTicket: boolean): void {
139
+ ticketlessConnects++
140
+ if (rejectedTicket) ticketlessRejectedTickets++
141
+ const now = Date.now()
142
+ if (ticketlessLoggedAt !== 0 && now - ticketlessLoggedAt < TICKETLESS_LOG_INTERVAL_MS) return
143
+ ticketlessLoggedAt = now
144
+ console.warn(
145
+ `[display-bus] ${ticketlessConnects} ticketless subscriber(s)`
146
+ + ` (${ticketlessRejectedTickets} with a rejected ticket) — content withheld, lifecycle only`,
147
+ )
148
+ ticketlessConnects = 0
149
+ ticketlessRejectedTickets = 0
150
+ }
151
+
152
+ export function __resetDisplayStreamLogForTests(): void {
153
+ ticketlessConnects = 0
154
+ ticketlessRejectedTickets = 0
155
+ ticketlessLoggedAt = 0
156
+ }
157
+
158
+ /**
159
+ * A valid `X-Cos-Token` header is equivalent authorization to a ticket.
160
+ *
161
+ * The ticket exists only because EventSource cannot set headers. Every fetch-based
162
+ * consumer can — including the client's own `probeConnectionTarget`, COS Control, and
163
+ * curl — so requiring those to mint first would suppress content for callers that are
164
+ * already fully authenticated, and would permanently pollute the ticketless adoption
165
+ * counter with connections that were never the problem.
166
+ */
167
+ function headerAuthorized(req: Request): boolean {
168
+ return timingSafeTokenEqual(req.headers['x-cos-token'], process.env.COS_API_TOKEN ?? '')
169
+ }
170
+
171
+ /**
172
+ * Shared handler for both registrations.
173
+ *
174
+ * `authorized` decides CONTENT, never admission. A ticketless subscriber still gets
175
+ * 200, `ready`, the keepalive, lifecycle markers, and replay-gap notices, so every
176
+ * already-installed client keeps a live transport, keeps `displayBusConnected` true,
177
+ * and keeps syncing offline meetings. It simply never receives transcripts or answers.
178
+ */
179
+ function serveDisplayStream(req: Request, res: Response, authorized: boolean): void {
29
180
  res.writeHead(200, {
30
181
  'Content-Type': 'text/event-stream',
31
182
  'Cache-Control': 'no-cache',
@@ -38,26 +189,48 @@ displayRouter.get('/display-stream', (req, res) => {
38
189
  // Tell EventSource to retry quickly on disconnect (3s instead of browser default ~5-10s)
39
190
  res.write('retry: 3000\n\n')
40
191
 
192
+ // DO NOT move this inside an `if (authorized)`. It looks unused on the ticketless
193
+ // path and is not: a ticketless subscriber never receives buffered events, but it
194
+ // MUST still be told when its cursor is unresumable (see replay_gap below), and
195
+ // that verdict is a function of the cursor. Hiding the cursor behind the
196
+ // authorization check would silently delete ticketless gap reporting.
41
197
  const headerCursor = String(req.headers['last-event-id'] ?? '')
42
198
  const [headerBootId, headerEventId] = headerCursor.includes(':')
43
199
  ? headerCursor.split(':', 2)
44
200
  : ['', headerCursor]
45
201
  const cursorBootId = String(req.query.bootId ?? headerBootId ?? '') || null
46
202
  const cursorEventId = Number(req.query.eventId ?? headerEventId ?? 0)
47
- const replay = replayDisplayEvents(cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0)
48
203
 
49
204
  // Ready is a transport handshake, not proof that replay was consumed. It
50
205
  // must precede application events so build 188 can finish admission first.
206
+ //
207
+ // `contentAuthorized` is what makes a degraded stream DETECTABLE. Without it the
208
+ // two handshakes are byte-identical, so a client whose ticket expired sets
209
+ // `displayBusConnected = true`, sees a healthy socket, and never re-mints — the
210
+ // stream stays silent forever with nothing anywhere reporting a fault. Old clients
211
+ // JSON.parse this frame and read only bootId/eventId, so the extra key is inert
212
+ // for them.
51
213
  const watermark = getDisplayWatermark()
52
- res.write(`event: ready\ndata: ${JSON.stringify(watermark)}\n\n`)
214
+ res.write(`event: ready\ndata: ${JSON.stringify({ ...watermark, contentAuthorized: authorized })}\n\n`)
215
+
216
+ const replay = replayDisplayEvents(cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0)
53
217
  if (replay.gap) {
218
+ // Ticketless-VISIBLE on purpose. The payload is transport metadata only —
219
+ // reason, the cursor the client itself sent, the watermark already in `ready`,
220
+ // and a buffer boundary — so it discloses nothing. Withholding it silently
221
+ // breaks the client's replay-reconciliation branch after a server restart: the
222
+ // client would sit on a dead cursor waiting for a resume that cannot come.
54
223
  res.write(`event: replay_gap\ndata: ${JSON.stringify({
55
224
  reason: replay.reason,
56
225
  requested: { bootId: cursorBootId, eventId: cursorEventId },
57
226
  watermark,
58
227
  oldestEventId: replay.oldestEventId,
59
228
  })}\n\n`)
60
- } else {
229
+ } else if (authorized) {
230
+ // The replay buffer holds up to REPLAY_BUFFER_SIZE past events, so serving it
231
+ // to a ticketless subscriber would be a retroactive transcript dump — a larger
232
+ // disclosure than the live subscription. Skipped entirely rather than filtered,
233
+ // so no future event type can leak through a per-item test here.
61
234
  for (const event of replay.events) writeEvent(res, event)
62
235
  if (replay.events.length > 0) {
63
236
  console.log(`[display-bus] Replayed ${replay.events.length} publish-owned events after ${cursorEventId}`)
@@ -70,13 +243,43 @@ displayRouter.get('/display-stream', (req, res) => {
70
243
  }, 15_000)
71
244
 
72
245
  const unsub = onDisplay((event) => {
73
- try { writeEvent(res, event) } catch { /* client gone */ }
246
+ // The projection runs INSIDE the try, not above it. This is a synchronous
247
+ // EventEmitter listener, so anything that throws here propagates back out
248
+ // through `bus.emit()` into whoever called `emitDisplay` — and one of those
249
+ // callers is the meeting-save path. A malformed event must never be able to
250
+ // reach a recording. 6.41.0 guarded the whole listener body; keep that.
251
+ try {
252
+ const outgoing = authorized ? event : projectForTicketless(event)
253
+ if (!outgoing) return
254
+ writeEvent(res, outgoing)
255
+ } catch { /* client gone, or an event this subscriber simply cannot render */ }
74
256
  })
75
257
 
76
258
  req.on('close', () => {
77
259
  clearInterval(ping)
78
260
  unsub()
79
261
  })
262
+ }
263
+
264
+ // Ticketless path. Stays public so installed clients keep a live transport; content
265
+ // is withheld above unless the caller sent a valid token header.
266
+ displayRouter.get('/display-stream', (req, res) => {
267
+ const authorized = headerAuthorized(req)
268
+ if (!authorized) noteTicketlessConnect(false)
269
+ serveDisplayStream(req, res, authorized)
270
+ })
271
+
272
+ // Ticketed path. `api-auth` admits it on SHAPE alone; the signature is verified
273
+ // here, where the API token lives. A bad or expired ticket is not rejected — it
274
+ // degrades to exactly the ticketless stream, so a client whose ticket expired
275
+ // mid-reconnect keeps its transport instead of entering a retry loop. It learns it
276
+ // is degraded from `contentAuthorized:false` in the `ready` frame, and re-mints.
277
+ displayRouter.get('/display-stream/:ticket', (req, res) => {
278
+ const apiToken = process.env.COS_API_TOKEN ?? ''
279
+ const ticketOk = verifyDisplayTicket(apiToken, req.params.ticket)
280
+ const authorized = ticketOk || headerAuthorized(req)
281
+ if (!authorized) noteTicketlessConnect(true)
282
+ serveDisplayStream(req, res, authorized)
80
283
  })
81
284
 
82
285
  // 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,33 @@ 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`, or
486
+ // when DISPLAY_TICKET_TTL_SECONDS has elapsed since the last mint — retrying if
487
+ // the fetch 503s. Nothing on the server can re-mint on the client's behalf.
488
+ ...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
450
489
  capabilities: {
451
490
  durableQueryJobs: {
452
491
  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) {