@gotcos/glasses-server 6.42.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/CHANGELOG.md CHANGED
@@ -1,3 +1,50 @@
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
+
1
48
  ## 6.42.0
2
49
 
3
50
  The display stream stops broadcasting your meetings to the local network.
@@ -10,7 +57,7 @@ the whole bus. A subscriber that does not gets a live transport, the handshake,
10
57
  the keepalive, replay-gap notices, and one projected lifecycle marker. It never
11
58
  receives a transcript, an answer, a coaching cue, a tool status or an error.
12
59
 
13
- **COS Glasses app 6.8.441 is required to restore full content delivery.** Older
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
14
61
  builds keep working — that is the entire reason the connection is not rejected —
15
62
  but they connect without a capability, so they will see the content-suppressed
16
63
  stream: no live transcript on the lens and no streamed answers, while meeting
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.42.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,12 +13,15 @@ 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
 
19
22
  // EventSource has the identical constraint, so the display stream reuses the same
20
23
  // 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
24
+ // no query-token fallback (same reasoning as above — this is not log hygiene). Admission here is SHAPE ONLY — the signature is verified
22
25
  // in the route, which is the only place that holds the API token.
23
26
  //
24
27
  // `/display-stream` itself STAYS PUBLIC, deliberately. Removing it would make a
@@ -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,
@@ -79,21 +79,41 @@ export function mintDisplayTicket(
79
79
  * itself, so a forged expiry changes the signed message and fails the HMAC — the
80
80
  * claim cannot be edited without the key.
81
81
  */
82
- export function verifyDisplayTicket(
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(
83
93
  apiToken: string,
84
94
  ticket: unknown,
85
95
  nowMs: number = Date.now(),
86
- ): boolean {
87
- if (typeof ticket !== 'string' || !apiToken) return false
96
+ ): DisplayTicketVerdict {
97
+ if (typeof ticket !== 'string' || !apiToken) return 'malformed'
88
98
  const separator = ticket.indexOf('.')
89
- if (separator <= 0) return false
99
+ if (separator <= 0) return 'malformed'
90
100
  const expRaw = ticket.slice(0, separator)
91
101
  const provided = ticket.slice(separator + 1)
92
- if (!/^\d{1,15}$/.test(expRaw) || !/^[0-9a-f]{64}$/.test(provided)) return false
102
+ if (!/^\d{1,15}$/.test(expRaw) || !/^[0-9a-f]{64}$/.test(provided)) return 'malformed'
93
103
  const exp = Number(expRaw)
94
- if (!Number.isSafeInteger(exp)) return false
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'
95
107
  // Expiry is checked BEFORE the compare so a stale ticket cannot be probed for
96
108
  // 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))
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'
99
119
  }
@@ -11,7 +11,7 @@ import {
11
11
  type DisplayEvent,
12
12
  type PublishedDisplayEvent,
13
13
  } from '../lib/display-bus.js'
14
- import { verifyDisplayTicket } from '../lib/display-ticket.js'
14
+ import { type DisplayTicketVerdict, explainDisplayTicket } from '../lib/display-ticket.js'
15
15
  import { timingSafeTokenEqual } from '../lib/token-auth.js'
16
16
 
17
17
  export const displayRouter = Router()
@@ -93,7 +93,13 @@ const TICKETLESS_PROJECTIONS: {
93
93
  * Never returns the input event unchanged — the projection is always applied.
94
94
  */
95
95
  function projectForTicketless(event: PublishedDisplayEvent): PublishedDisplayEvent | null {
96
- const project = TICKETLESS_PROJECTIONS[event.type]
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
97
103
  if (!project) return null
98
104
  return { ...event, data: project(event.data) }
99
105
  }
@@ -134,25 +140,37 @@ const TICKETLESS_LOG_INTERVAL_MS = 60_000
134
140
  let ticketlessConnects = 0
135
141
  let ticketlessRejectedTickets = 0
136
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
+ }
137
149
 
138
- function noteTicketlessConnect(rejectedTicket: boolean): void {
150
+ function noteTicketlessConnect(reason: Exclude<DisplayTicketVerdict, 'ok'> | 'none'): void {
139
151
  ticketlessConnects++
140
- if (rejectedTicket) ticketlessRejectedTickets++
152
+ if (reason !== 'none') ticketlessRejectedTickets++
153
+ ticketlessByReason[reason]++
141
154
  const now = Date.now()
142
155
  if (ticketlessLoggedAt !== 0 && now - ticketlessLoggedAt < TICKETLESS_LOG_INTERVAL_MS) return
143
156
  ticketlessLoggedAt = now
157
+ const { expired, 'bad-signature': bad, malformed, none } = ticketlessByReason
144
158
  console.warn(
145
159
  `[display-bus] ${ticketlessConnects} ticketless subscriber(s)`
146
- + ` (${ticketlessRejectedTickets} with a rejected ticket) — content withheld, lifecycle only`,
160
+ + ` (${ticketlessRejectedTickets} with a rejected ticket:`
161
+ + ` ${expired} expired, ${bad} bad-signature, ${malformed} malformed; ${none} bare)`
162
+ + ` — content withheld, lifecycle only`,
147
163
  )
148
164
  ticketlessConnects = 0
149
165
  ticketlessRejectedTickets = 0
166
+ for (const k of Object.keys(ticketlessByReason) as Array<keyof typeof ticketlessByReason>) ticketlessByReason[k] = 0
150
167
  }
151
168
 
152
169
  export function __resetDisplayStreamLogForTests(): void {
153
170
  ticketlessConnects = 0
154
171
  ticketlessRejectedTickets = 0
155
172
  ticketlessLoggedAt = 0
173
+ for (const k of Object.keys(ticketlessByReason) as Array<keyof typeof ticketlessByReason>) ticketlessByReason[k] = 0
156
174
  }
157
175
 
158
176
  /**
@@ -213,7 +231,15 @@ function serveDisplayStream(req: Request, res: Response, authorized: boolean): v
213
231
  const watermark = getDisplayWatermark()
214
232
  res.write(`event: ready\ndata: ${JSON.stringify({ ...watermark, contentAuthorized: authorized })}\n\n`)
215
233
 
216
- const replay = replayDisplayEvents(cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0)
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
+ )
217
243
  if (replay.gap) {
218
244
  // Ticketless-VISIBLE on purpose. The payload is transport metadata only —
219
245
  // reason, the cursor the client itself sent, the watermark already in `ready`,
@@ -226,11 +252,17 @@ function serveDisplayStream(req: Request, res: Response, authorized: boolean): v
226
252
  watermark,
227
253
  oldestEventId: replay.oldestEventId,
228
254
  })}\n\n`)
229
- } else if (authorized) {
255
+ } else if (authorized && req.query.probe !== '1') {
230
256
  // The replay buffer holds up to REPLAY_BUFFER_SIZE past events, so serving it
231
257
  // to a ticketless subscriber would be a retroactive transcript dump — a larger
232
258
  // disclosure than the live subscription. Skipped entirely rather than filtered,
233
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.
234
266
  for (const event of replay.events) writeEvent(res, event)
235
267
  if (replay.events.length > 0) {
236
268
  console.log(`[display-bus] Replayed ${replay.events.length} publish-owned events after ${cursorEventId}`)
@@ -265,7 +297,7 @@ function serveDisplayStream(req: Request, res: Response, authorized: boolean): v
265
297
  // is withheld above unless the caller sent a valid token header.
266
298
  displayRouter.get('/display-stream', (req, res) => {
267
299
  const authorized = headerAuthorized(req)
268
- if (!authorized) noteTicketlessConnect(false)
300
+ if (!authorized) noteTicketlessConnect('none')
269
301
  serveDisplayStream(req, res, authorized)
270
302
  })
271
303
 
@@ -276,9 +308,9 @@ displayRouter.get('/display-stream', (req, res) => {
276
308
  // is degraded from `contentAuthorized:false` in the `ready` frame, and re-mints.
277
309
  displayRouter.get('/display-stream/:ticket', (req, res) => {
278
310
  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)
311
+ const verdict = explainDisplayTicket(apiToken, req.params.ticket)
312
+ const authorized = verdict === 'ok' || headerAuthorized(req)
313
+ if (!authorized) noteTicketlessConnect(verdict)
282
314
  serveDisplayStream(req, res, authorized)
283
315
  })
284
316
 
@@ -482,9 +482,18 @@ healthRouter.get('/models', async (req, res) => {
482
482
  // outlive one.)
483
483
  //
484
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.
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.
488
497
  ...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
489
498
  capabilities: {
490
499
  durableQueryJobs: {