@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 +48 -1
- package/SECURITY.md +32 -0
- package/package.json +4 -2
- package/server/index.ts +4 -3
- package/server/lib/api-auth.ts +5 -2
- package/server/lib/display-bus.ts +7 -2
- package/server/lib/display-ticket.ts +28 -8
- package/server/routes/display.ts +43 -11
- package/server/routes/health.ts +12 -3
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
|
|
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.
|
|
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
|
-
//
|
|
185
|
-
// authenticated /tts/prepare
|
|
186
|
-
//
|
|
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
|
package/server/lib/api-auth.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
-
):
|
|
87
|
-
if (typeof ticket !== 'string' || !apiToken) return
|
|
96
|
+
): DisplayTicketVerdict {
|
|
97
|
+
if (typeof ticket !== 'string' || !apiToken) return 'malformed'
|
|
88
98
|
const separator = ticket.indexOf('.')
|
|
89
|
-
if (separator <= 0) return
|
|
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
|
|
102
|
+
if (!/^\d{1,15}$/.test(expRaw) || !/^[0-9a-f]{64}$/.test(provided)) return 'malformed'
|
|
93
103
|
const exp = Number(expRaw)
|
|
94
|
-
|
|
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
|
|
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
|
}
|
package/server/routes/display.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type DisplayEvent,
|
|
12
12
|
type PublishedDisplayEvent,
|
|
13
13
|
} from '../lib/display-bus.js'
|
|
14
|
-
import {
|
|
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
|
-
|
|
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(
|
|
150
|
+
function noteTicketlessConnect(reason: Exclude<DisplayTicketVerdict, 'ok'> | 'none'): void {
|
|
139
151
|
ticketlessConnects++
|
|
140
|
-
if (
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
280
|
-
const authorized =
|
|
281
|
-
if (!authorized) noteTicketlessConnect(
|
|
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
|
|
package/server/routes/health.ts
CHANGED
|
@@ -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`,
|
|
486
|
-
//
|
|
487
|
-
//
|
|
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: {
|