@gotcos/glasses-server 6.42.0 → 6.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ // Morning brief — the scheduler.
2
+ //
3
+ // One 30-second tick, unref'd so it never keeps a shutting-down process alive.
4
+ // Each tick asks the pure decision function whether the scheduled slot has
5
+ // arrived for the configured zone, then fires by submitting a durable query
6
+ // job to the same coordinator that every phone-originated prompt uses. From
7
+ // that point on the brief is an ordinary job: it survives the phone being
8
+ // asleep, it is projected into the conversation store with a message number
9
+ // when it completes, and the companion's history hydration picks it up like
10
+ // any other reply.
11
+ //
12
+ // HOW IT STOPS (the question every new provider caller must answer):
13
+ // - one scheduled fire per local calendar day, remembered in a ledger that is
14
+ // written BEFORE the job is admitted;
15
+ // - a failed admission retries at most `scheduledAttemptsPerDay` times, two
16
+ // minutes apart, then the day is given up;
17
+ // - a crash between the ledger write and the admission is RESUMED by client
18
+ // identity (deterministic per day), never re-run;
19
+ // - "Run now" is capped per day and refused while a brief is already running;
20
+ // - the whole thing is inert when durable jobs are off (COS Control's
21
+ // "Background jobs" switch) or maintenance admissions are closed.
22
+
23
+ import { createHash, randomUUID } from 'node:crypto'
24
+ import {
25
+ MORNING_BRIEF_LIMITS,
26
+ applyMorningBriefPatch,
27
+ loadMorningBriefConfig,
28
+ loadMorningBriefLedger,
29
+ saveMorningBriefConfig,
30
+ saveMorningBriefLedger,
31
+ type MorningBriefConfig,
32
+ type MorningBriefLedger,
33
+ type MorningBriefRun,
34
+ type MorningBriefStorePaths,
35
+ type MorningBriefTrigger,
36
+ serverTimezone,
37
+ } from './morning-brief-config.js'
38
+ import { composeMorningBriefPrompt } from './morning-brief-prompt.js'
39
+ import { decideScheduledFire, localClock, nextScheduledFire } from './morning-brief-schedule.js'
40
+ import type { QueryJobSnapshot } from './query-job-types.js'
41
+ import { isTerminalQueryJobStatus } from './query-job-types.js'
42
+
43
+ export interface MorningBriefSubmission {
44
+ job: Pick<QueryJobSnapshot, 'jobId' | 'clientJobId' | 'generation' | 'status' | 'sessionId'>
45
+ created: boolean
46
+ }
47
+
48
+ export interface MorningBriefSchedulerDeps {
49
+ paths: MorningBriefStorePaths
50
+ submit: (request: Record<string, unknown>) => Promise<MorningBriefSubmission>
51
+ findByClientGeneration: (clientJobId: string, generation: number) => Promise<QueryJobSnapshot | undefined>
52
+ getSnapshot: (jobId: string) => Promise<QueryJobSnapshot>
53
+ createSession: () => string
54
+ currentMessageEra: () => string
55
+ /** Highest stamped number in the active era across live sessions and archives. */
56
+ currentMessageMax: () => number
57
+ ownerName: () => string
58
+ durableJobsEnabled: () => boolean
59
+ admissionsOpen: () => boolean
60
+ now?: () => number
61
+ tickMs?: number
62
+ log?: (line: string) => void
63
+ }
64
+
65
+ export class MorningBriefRunError extends Error {
66
+ constructor(readonly status: number, readonly code: string, message: string) {
67
+ super(message)
68
+ this.name = 'MorningBriefRunError'
69
+ }
70
+ }
71
+
72
+ export interface MorningBriefRunView extends MorningBriefRun {
73
+ status: string
74
+ completedAt?: string
75
+ error?: { code: string; message: string }
76
+ /** First ~120 chars of the answer, for a list row. Never the whole brief. */
77
+ preview?: string
78
+ }
79
+
80
+ export interface MorningBriefStatus {
81
+ protocolVersion: 1
82
+ enabled: boolean
83
+ time: string
84
+ timezone: string
85
+ serverTimezone: string
86
+ days: number[]
87
+ nextRunAt: string | null
88
+ lastRun: MorningBriefRunView | null
89
+ /** Why the scheduler would not fire right now, for a status line. */
90
+ gate: 'ready' | 'durable_jobs_off' | 'admissions_closed' | 'disabled'
91
+ }
92
+
93
+ export type TickResult =
94
+ | { fired: true; run: MorningBriefRun }
95
+ | { fired: false; reason: string }
96
+
97
+ /** Deterministic v4-shaped client id for one local day, so a retry after a
98
+ * crashed submission admits as the SAME job. The store dedupes on it. */
99
+ export function scheduledClientJobId(day: string, timezone: string): string {
100
+ const digest = createHash('sha256').update(`morning-brief|${timezone}|${day}`).digest()
101
+ const bytes = Buffer.from(digest.subarray(0, 16))
102
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
103
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
104
+ const hex = bytes.toString('hex')
105
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
106
+ }
107
+
108
+ function errorParts(error: unknown): { code: string; message: string } {
109
+ const code = typeof (error as { code?: unknown })?.code === 'string' ? (error as { code: string }).code : 'submit_failed'
110
+ const message = error instanceof Error ? error.message : String(error)
111
+ return { code: code.slice(0, 64), message: message.slice(0, 300) }
112
+ }
113
+
114
+ export class MorningBriefScheduler {
115
+ private config: MorningBriefConfig
116
+ private ledger: MorningBriefLedger
117
+ private timer: ReturnType<typeof setInterval> | null = null
118
+ private tickInFlight: Promise<TickResult> | null = null
119
+ private readonly now: () => number
120
+ private readonly log: (line: string) => void
121
+ readonly quarantinedConfig?: string
122
+
123
+ constructor(private readonly deps: MorningBriefSchedulerDeps) {
124
+ this.now = deps.now ?? Date.now
125
+ this.log = deps.log ?? ((line) => console.log(`[morning-brief] ${line}`))
126
+ const loaded = loadMorningBriefConfig(deps.paths, new Date(this.now()))
127
+ this.config = loaded.config
128
+ this.quarantinedConfig = loaded.quarantinedAs
129
+ if (loaded.fresh) {
130
+ // Persist the defaults so every surface reads one file from the start
131
+ // and so `updatedAt` is a real first-seen time.
132
+ try { saveMorningBriefConfig(deps.paths, this.config) } catch (error) {
133
+ this.log(`could not persist default config: ${(error as Error).message}`)
134
+ }
135
+ }
136
+ this.ledger = loadMorningBriefLedger(deps.paths)
137
+ }
138
+
139
+ start(): void {
140
+ if (this.timer) return
141
+ const tickMs = Math.max(1_000, this.deps.tickMs ?? 30_000)
142
+ this.timer = setInterval(() => { void this.tick() }, tickMs)
143
+ this.timer.unref?.()
144
+ this.log(`scheduled ${this.config.enabled ? `daily at ${this.config.time} ${this.config.timezone}` : 'off'} · next ${nextScheduledFire(this.config, this.now()) ?? 'none'}`)
145
+ }
146
+
147
+ stop(): void {
148
+ if (this.timer) clearInterval(this.timer)
149
+ this.timer = null
150
+ }
151
+
152
+ getConfig(): MorningBriefConfig {
153
+ return structuredClone(this.config)
154
+ }
155
+
156
+ updateConfig(patch: unknown): MorningBriefConfig {
157
+ const next = applyMorningBriefPatch(this.config, patch, new Date(this.now()))
158
+ saveMorningBriefConfig(this.deps.paths, next)
159
+ this.config = next
160
+ this.log(`config updated · ${next.enabled ? `daily at ${next.time} ${next.timezone}` : 'off'} · next ${nextScheduledFire(next, this.now()) ?? 'none'}`)
161
+ return structuredClone(next)
162
+ }
163
+
164
+ previewPrompt(trigger: MorningBriefTrigger = 'scheduled'): string {
165
+ const clock = localClock(this.now(), this.config.timezone)
166
+ return composeMorningBriefPrompt({ config: this.config, day: clock.day, ownerName: this.deps.ownerName(), trigger })
167
+ }
168
+
169
+ /** One scheduler pass. Serialised: a slow submission never overlaps the next tick. */
170
+ tick(): Promise<TickResult> {
171
+ if (this.tickInFlight) return this.tickInFlight
172
+ this.tickInFlight = this.runTick().finally(() => { this.tickInFlight = null })
173
+ return this.tickInFlight
174
+ }
175
+
176
+ private async runTick(): Promise<TickResult> {
177
+ if (!this.deps.durableJobsEnabled()) return { fired: false, reason: 'durable_jobs_off' }
178
+ if (!this.deps.admissionsOpen()) return { fired: false, reason: 'admissions_closed' }
179
+ const decision = decideScheduledFire(this.config, this.ledger.runs, this.now())
180
+ if (!decision.fire) return { fired: false, reason: decision.reason }
181
+ const run = await this.fire('scheduled', decision.day, decision.attempt, decision.resume)
182
+ return { fired: true, run }
183
+ }
184
+
185
+ /** "Run now" from a settings surface. Bounded per day; refused while a brief is live. */
186
+ async runNow(): Promise<MorningBriefRun> {
187
+ if (!this.deps.durableJobsEnabled()) {
188
+ throw new MorningBriefRunError(409, 'durable_jobs_off', 'Turn on Background jobs in COS Control to run the brief.')
189
+ }
190
+ if (!this.deps.admissionsOpen()) {
191
+ throw new MorningBriefRunError(503, 'admissions_closed', 'The server is in maintenance. Try again in a moment.')
192
+ }
193
+ const clock = localClock(this.now(), this.config.timezone)
194
+ const today = this.ledger.runs.filter(run => run.day === clock.day)
195
+ for (const run of today) {
196
+ if (!run.jobId) continue
197
+ const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
198
+ if (snapshot && !isTerminalQueryJobStatus(snapshot.status)) {
199
+ throw new MorningBriefRunError(409, 'brief_in_progress', 'A brief is already running. It will land in the inbox when it finishes.')
200
+ }
201
+ }
202
+ const manual = today.filter(run => run.trigger === 'manual' && run.jobId).length
203
+ if (manual >= MORNING_BRIEF_LIMITS.manualRunsPerDay) {
204
+ throw new MorningBriefRunError(429, 'manual_runs_exhausted', `Run now is limited to ${MORNING_BRIEF_LIMITS.manualRunsPerDay} briefs a day.`)
205
+ }
206
+ const run = await this.fire('manual', clock.day, manual + 1)
207
+ if (run.submitError) {
208
+ throw new MorningBriefRunError(503, run.submitError.code, run.submitError.message)
209
+ }
210
+ return run
211
+ }
212
+
213
+ private async fire(trigger: MorningBriefTrigger, day: string, attempt: number, resume?: MorningBriefRun): Promise<MorningBriefRun> {
214
+ const clientJobId = trigger === 'scheduled'
215
+ ? scheduledClientJobId(day, this.config.timezone)
216
+ : randomUUID()
217
+
218
+ // Resume: a ledger row with no job id. Adopt an admission the coordinator
219
+ // may already hold for this identity before considering a fresh submit.
220
+ if (resume) {
221
+ const existing = await this.deps.findByClientGeneration(clientJobId, 1).catch(() => undefined)
222
+ if (existing) {
223
+ const adopted: MorningBriefRun = { ...resume, jobId: existing.jobId, lastKnownStatus: existing.status }
224
+ this.replaceRun(adopted)
225
+ this.log(`adopted ${trigger} brief for ${day} as job ${existing.jobId}`)
226
+ return adopted
227
+ }
228
+ }
229
+
230
+ const sessionId = resume?.sessionId ?? this.deps.createSession()
231
+ const messageEra = this.deps.currentMessageEra()
232
+ const globalMsgNum = resume?.globalMsgNum ?? this.deps.currentMessageMax() + 1
233
+ const run: MorningBriefRun = {
234
+ id: resume?.id ?? randomUUID(),
235
+ day,
236
+ trigger,
237
+ attempt,
238
+ firedAt: new Date(this.now()).toISOString(),
239
+ clientJobId,
240
+ generation: 1,
241
+ sessionId,
242
+ messageEra,
243
+ globalMsgNum,
244
+ }
245
+ // Ledger first. A crash after this line is a resume, not a second brief.
246
+ this.replaceRun(run)
247
+
248
+ const prompt = composeMorningBriefPrompt({ config: this.config, day, ownerName: this.deps.ownerName(), trigger })
249
+ try {
250
+ const admission = await this.deps.submit({
251
+ clientJobId,
252
+ generation: 1,
253
+ query: prompt,
254
+ sessionId,
255
+ messageEra,
256
+ globalMsgNum,
257
+ ...(this.config.model ? { model: this.config.model } : {}),
258
+ ...(this.config.effort ? { effort: this.config.effort } : {}),
259
+ activityToolMode: 'status',
260
+ attachmentIds: [],
261
+ attachmentRefs: [],
262
+ })
263
+ const accepted: MorningBriefRun = { ...run, jobId: admission.job.jobId, lastKnownStatus: admission.job.status }
264
+ this.replaceRun(accepted)
265
+ this.log(`${trigger} brief for ${day} admitted as job ${admission.job.jobId} (#${globalMsgNum}${admission.created ? '' : ', already held'})`)
266
+ return accepted
267
+ } catch (error) {
268
+ const failed: MorningBriefRun = { ...run, submitError: errorParts(error) }
269
+ this.replaceRun(failed)
270
+ this.log(`${trigger} brief for ${day} attempt ${attempt} failed to submit: ${failed.submitError!.code}`)
271
+ return failed
272
+ }
273
+ }
274
+
275
+ private replaceRun(run: MorningBriefRun): void {
276
+ const index = this.ledger.runs.findIndex(existing => existing.id === run.id)
277
+ if (index >= 0) this.ledger.runs[index] = run
278
+ else this.ledger.runs.push(run)
279
+ if (this.ledger.runs.length > MORNING_BRIEF_LIMITS.retainedRuns) {
280
+ this.ledger.runs = this.ledger.runs.slice(-MORNING_BRIEF_LIMITS.retainedRuns)
281
+ }
282
+ try {
283
+ saveMorningBriefLedger(this.deps.paths, this.ledger)
284
+ } catch (error) {
285
+ this.log(`ledger write failed: ${(error as Error).message}`)
286
+ }
287
+ }
288
+
289
+ /** Ledger rows newest first, each with the job's live status folded in. */
290
+ async listRuns(limit = 14): Promise<MorningBriefRunView[]> {
291
+ const rows = this.ledger.runs.slice(-Math.max(1, Math.min(limit, MORNING_BRIEF_LIMITS.retainedRuns))).reverse()
292
+ const views: MorningBriefRunView[] = []
293
+ for (const run of rows) {
294
+ if (!run.jobId) {
295
+ views.push({ ...run, status: run.submitError ? 'submit_failed' : 'submitting', ...(run.submitError ? { error: run.submitError } : {}) })
296
+ continue
297
+ }
298
+ const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
299
+ if (!snapshot) {
300
+ views.push({ ...run, status: run.lastKnownStatus ?? 'unknown' })
301
+ continue
302
+ }
303
+ if (snapshot.status !== run.lastKnownStatus && isTerminalQueryJobStatus(snapshot.status)) {
304
+ this.replaceRun({ ...run, lastKnownStatus: snapshot.status })
305
+ }
306
+ const answer = snapshot.response ?? snapshot.partialText ?? ''
307
+ views.push({
308
+ ...run,
309
+ status: snapshot.status,
310
+ ...(snapshot.completedAt ? { completedAt: snapshot.completedAt } : {}),
311
+ ...(snapshot.error ? { error: { code: snapshot.error.code, message: snapshot.error.message } } : {}),
312
+ ...(answer ? { preview: answer.replace(/\s+/g, ' ').trim().slice(0, 120) } : {}),
313
+ })
314
+ }
315
+ return views
316
+ }
317
+
318
+ async status(): Promise<MorningBriefStatus> {
319
+ const [lastRun] = await this.listRuns(1)
320
+ const gate: MorningBriefStatus['gate'] = !this.deps.durableJobsEnabled()
321
+ ? 'durable_jobs_off'
322
+ : !this.deps.admissionsOpen()
323
+ ? 'admissions_closed'
324
+ : !this.config.enabled ? 'disabled' : 'ready'
325
+ return {
326
+ protocolVersion: 1,
327
+ enabled: this.config.enabled,
328
+ time: this.config.time,
329
+ timezone: this.config.timezone,
330
+ serverTimezone: serverTimezone(),
331
+ days: [...this.config.days],
332
+ nextRunAt: nextScheduledFire(this.config, this.now()),
333
+ lastRun: lastRun ?? null,
334
+ gate,
335
+ }
336
+ }
337
+
338
+ /** Public-health shape: no prompt, no session ids, no job ids. */
339
+ async capability(): Promise<{
340
+ protocolVersion: 1
341
+ enabled: boolean
342
+ time: string
343
+ timezone: string
344
+ nextRunAt: string | null
345
+ lastRunAt: string | null
346
+ lastRunStatus: string | null
347
+ gate: MorningBriefStatus['gate']
348
+ }> {
349
+ const status = await this.status()
350
+ return {
351
+ protocolVersion: 1,
352
+ enabled: status.enabled,
353
+ time: status.time,
354
+ timezone: status.timezone,
355
+ nextRunAt: status.nextRunAt,
356
+ lastRunAt: status.lastRun?.firedAt ?? null,
357
+ lastRunStatus: status.lastRun?.status ?? null,
358
+ gate: status.gate,
359
+ }
360
+ }
361
+ }
@@ -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
 
@@ -51,6 +51,7 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
51
51
  import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
52
52
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
53
53
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
54
+ import { getMorningBriefScheduler } from '../lib/morning-brief-runtime.js'
54
55
  import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
55
56
  import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
56
57
  import { managedRuntimeCapability, managedServerVersion } from '../lib/managed-runtime.js'
@@ -110,6 +111,14 @@ function durableQueryJobStatus() {
110
111
  }
111
112
  }
112
113
 
114
+ async function morningBriefCapabilityOrNull() {
115
+ try {
116
+ return await getMorningBriefScheduler().capability()
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
113
122
  healthRouter.get('/health', async (_req, res) => {
114
123
  const [, staticProbes] = await Promise.all([
115
124
  refreshLocalTtsHealth(),
@@ -199,6 +208,13 @@ healthRouter.get('/health', async (_req, res) => {
199
208
  // diverged, so a constant in both is guaranteed to drift. Absent means an
200
209
  // older server, and the client falls back to single-shot.
201
210
  const mediaLimits = await getMediaLimits({ chunkedUploadEnabled: MEDIA_CHUNKED_UPLOAD_ENABLED })
211
+ // Public on purpose: a settings screen decides whether to show the Morning
212
+ // brief card from health, before it holds a token. Times and gate only —
213
+ // no prompt, no session or job ids. Health must never 500 because of the
214
+ // brief, so BOTH a synchronous construction failure and a rejected status
215
+ // read collapse to "capability absent" (health-query-jobs.test mocks the
216
+ // conversation store to one export, which is exactly such a failure).
217
+ const morningBrief = await morningBriefCapabilityOrNull()
202
218
  const features = {
203
219
  claude: claudeAvailable,
204
220
  codex: codexAvailable,
@@ -216,6 +232,9 @@ healthRouter.get('/health', async (_req, res) => {
216
232
  g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
217
233
  durableQueryJobs: durableJobs.enabled,
218
234
  durableQueryJobsProtocol: durableJobs.protocolVersion,
235
+ // Static: this build carries the scheduler. The live schedule is under
236
+ // capabilities.morningBrief and may be absent while the runtime boots.
237
+ morningBrief: true,
219
238
  localFirstMeetings: localFirstMeetings !== null,
220
239
  transcriptionPolicy: transcription.mode,
221
240
  liveCues: liveCues.available,
@@ -400,6 +419,7 @@ healthRouter.get('/health', async (_req, res) => {
400
419
  finalization: getMeetingFinalizationSnapshot(),
401
420
  },
402
421
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
422
+ ...(morningBrief ? { morningBrief } : {}),
403
423
  },
404
424
  // /api/health is intentionally unauthenticated for setup diagnostics.
405
425
  // Publish capability only; job counts, retention identities, subscriber
@@ -482,9 +502,18 @@ healthRouter.get('/models', async (req, res) => {
482
502
  // outlive one.)
483
503
  //
484
504
  // 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.
505
+ // when a display-stream `ready` frame reports `contentAuthorized: false`,
506
+ // retrying if the fetch 503s. Nothing on the server can re-mint on the
507
+ // client's behalf.
508
+ //
509
+ // AUTHORIZATION IS DECIDED ONCE PER SOCKET, at connect. A ticket that was
510
+ // valid when the EventSource opened keeps that socket authorized for its whole
511
+ // life — QA on 2026-09-01 held an 8-second ticket open for 32s and content
512
+ // kept flowing. That is deliberate: the shipped client re-mints only on
513
+ // RECONNECT, never on a timer, so a per-event re-check would blank every
514
+ // 6.8.441 lens fifteen minutes into a meeting. The TTL therefore bounds how
515
+ // long a LEAKED URL can open a new socket, not how long an open socket lives.
516
+ // Changing that is a breaking change that needs a client version gate first.
488
517
  ...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
489
518
  capabilities: {
490
519
  durableQueryJobs: {
@@ -0,0 +1,80 @@
1
+ // Morning brief — the settings and status surface.
2
+ //
3
+ // GET /api/morning-brief config + source catalog + status + recent runs
4
+ // PUT /api/morning-brief patch the config (validated; 400 on a bad field)
5
+ // POST /api/morning-brief/run fire a brief now (202; 409 while one runs; 429 past the daily cap)
6
+ // GET /api/morning-brief/runs recent runs with live job status and message numbers
7
+ // GET /api/morning-brief/preview the exact prompt today's brief would send
8
+ //
9
+ // Authenticated by the global /api middleware like every other settings route.
10
+ // Nothing here returns a brief's full text: the answer lives in the conversation
11
+ // store and the phone reads it through the same history route as any reply.
12
+
13
+ import { Router } from 'express'
14
+ import { MorningBriefConfigError, describeMorningBriefSources } from '../lib/morning-brief-config.js'
15
+ import { MorningBriefRunError, type MorningBriefScheduler } from '../lib/morning-brief-scheduler.js'
16
+
17
+ export function createMorningBriefRouter(scheduler: () => MorningBriefScheduler): Router {
18
+ const router = Router()
19
+
20
+ const describe = async () => {
21
+ const instance = scheduler()
22
+ return {
23
+ config: instance.getConfig(),
24
+ sources: describeMorningBriefSources(),
25
+ status: await instance.status(),
26
+ runs: await instance.listRuns(7),
27
+ }
28
+ }
29
+
30
+ router.get('/morning-brief', async (_req, res) => {
31
+ try {
32
+ res.json(await describe())
33
+ } catch (error) {
34
+ console.error('[morning-brief] describe failed:', error)
35
+ res.status(500).json({ error: { code: 'morning_brief_unavailable', message: 'Could not read the morning brief settings.' } })
36
+ }
37
+ })
38
+
39
+ router.put('/morning-brief', async (req, res) => {
40
+ try {
41
+ scheduler().updateConfig(req.body)
42
+ res.json(await describe())
43
+ } catch (error) {
44
+ if (error instanceof MorningBriefConfigError) {
45
+ return res.status(400).json({ error: { code: error.code, message: error.message } })
46
+ }
47
+ console.error('[morning-brief] update failed:', error)
48
+ res.status(500).json({ error: { code: 'morning_brief_save_failed', message: 'The settings could not be saved.' } })
49
+ }
50
+ })
51
+
52
+ router.post('/morning-brief/run', async (_req, res) => {
53
+ try {
54
+ const run = await scheduler().runNow()
55
+ res.status(202).json({ run })
56
+ } catch (error) {
57
+ if (error instanceof MorningBriefRunError) {
58
+ return res.status(error.status).json({ error: { code: error.code, message: error.message } })
59
+ }
60
+ console.error('[morning-brief] run now failed:', error)
61
+ res.status(500).json({ error: { code: 'morning_brief_run_failed', message: 'The brief could not be started.' } })
62
+ }
63
+ })
64
+
65
+ router.get('/morning-brief/runs', async (req, res) => {
66
+ const limit = Number.parseInt(String(req.query.limit ?? '14'), 10)
67
+ try {
68
+ res.json({ runs: await scheduler().listRuns(Number.isFinite(limit) ? limit : 14) })
69
+ } catch (error) {
70
+ console.error('[morning-brief] runs failed:', error)
71
+ res.status(500).json({ error: { code: 'morning_brief_unavailable', message: 'Could not read the morning brief runs.' } })
72
+ }
73
+ })
74
+
75
+ router.get('/morning-brief/preview', (_req, res) => {
76
+ res.type('text/plain').send(scheduler().previewPrompt('scheduled'))
77
+ })
78
+
79
+ return router
80
+ }