@gotcos/glasses-server 6.99.33 → 7.0.18

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.
Files changed (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -4,10 +4,19 @@
4
4
  * - GET /api/today/brief the cached summary; never generates.
5
5
  * - POST /api/today/brief an open Today's ask, with the rows it read (titles, states, how long, last lines,
6
6
  * what needs you). Generates only when the switch is on, the inputs changed, 10 minutes
7
- * passed and the day's 60 are not spent. The Work cards in progress come from the
7
+ * passed and the day's cap (Summaries per day, 7.0.5) is not spent. The Work cards in progress come from the
8
8
  * server's own shared board read, never the body.
9
+ * 7.0.1: `"force": true` in the body is the Today refresh button. It skips the
10
+ * 10-minute interval and the "inputs unchanged" check, at most once every 30 s,
11
+ * inside the same daily cap. A throttled one answers reason `refresh_throttled` and
12
+ * `retryAfterMs`.
13
+ * 7.0.5: `"reason": "reconnect"` says the client's read just recovered from a failed
14
+ * one: that start skips the 10-minute interval, at most once per 10 minutes. The
15
+ * "inputs unchanged" check, the breaker, the switch and the daily cap still apply.
9
16
  * - GET /api/today/brief/settings the switch and what set it.
10
17
  * - POST /api/today/brief/settings { enabled: boolean }, the Settings toggle. COS_TODAY_BRIEF_LLM, when set, still wins.
18
+ * - GET /api/today/brief/settings/cap 7.0.5 "Summaries per day": { dailyCap, min: 1, max: 100, default: 100, usedToday }.
19
+ * - PUT /api/today/brief/settings/cap { dailyCap: number }, clamped to 1 to 100 and saved; answers the same shape.
11
20
  * Auth is the parent /api gate (token or web session cookie). Every answer is private, no-store.
12
21
  */
13
22
  import { Router } from 'express'
@@ -16,52 +25,90 @@ import { dataPath } from '../lib/data-dir.js'
16
25
  import { runClaudeText } from '../lib/claude-text-runner.js'
17
26
  import { logTokenAudit } from '../lib/token-audit.js'
18
27
  import type { TaskBoardRow } from '../lib/task-store.js'
19
- import { agentSessionRoots, findAgentSessionFile } from '../lib/agent-session-store.js'
28
+ import type { TaskDateFields } from '../lib/task-dates.js'
29
+ import { agentSessionRoots, codexSessionRootsOf, findAgentSessionFile, listCodexJsonlFiles, loadClaudeDesktopAliases, loadClaudeTranscriptIndex, type AgentProvider } from '../lib/agent-session-store.js'
20
30
  import { readClaudeSubagents, summarize, type SessionSubagents } from '../lib/session-subagents.js'
21
31
  import { readCodexSubagentDetails } from '../lib/codex-subagent-activity.js'
32
+ import { readRecentActivity, type RecentActivity } from '../lib/agent-session-recent.js'
22
33
  import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
23
34
  import { isRecordingLive } from '../lib/stt-runtime.js'
24
35
  import {
25
36
  TODAY_BRIEF_BODY_MAX_BYTES, TODAY_BRIEF_EFFORT, TODAY_BRIEF_SETTINGS_FILE, TODAY_BRIEF_STATE_FILE, TODAY_BRIEF_TIMEOUT_MS,
26
- TodayBriefInputError, createTodayBrief, normalizeTodayBriefInputs, subagentLookups, todayBriefFingerprint, withSubagents, type BriefWork, type SubagentReading, type TodayBrief,
37
+ TodayBriefInputError, createTodayBrief, normalizeTodayBriefInputs, sessionContextLookups, todayBriefFingerprint, withCalendar, withRecentActivity, withSubagents, type BriefWork, type CalendarReading, type SubagentReading, type TodayBrief,
27
38
  } from '../lib/today-brief.js'
28
39
 
29
40
  export interface TodayBriefRouterDeps {
30
41
  brief: TodayBrief
31
- /** The Work board as the server already reads it (a shared, bounded read), or null when it cannot be read. */
32
- board: () => Promise<TaskBoardRow[] | null>
33
- /** Sub-agents for up to 8 sessions, by "provider|id". Missing keys mean "not read"; the page's counts stay. */
34
- subagents?: (keys: string[]) => Promise<Map<string, SubagentReading>>
42
+ /** The Work board, dated when it can be (2026-11-09: listBoardWithDates, so a card's last move is known), or null
43
+ * when it cannot be read. */
44
+ board: () => Promise<Array<TaskBoardRow & Partial<TaskDateFields>> | null>
45
+ /** Sub-agents and recent activity for up to 12 sessions, by "provider|id", written into `into` as each session is
46
+ * read, so an ask that stops waiting keeps what was read by then. Missing keys mean "not read": the page's counts
47
+ * and `line` stay, the line marked "may be old". */
48
+ context?: (keys: string[], into: SessionContext) => Promise<unknown>
49
+ /** Today round 2: today's calendar (lib/calendar-source.ts calendarToday). Read with the session reads, inside the
50
+ * same wait; a failed, slow or unconnected read means no calendar in the prompt. */
51
+ calendar?: () => Promise<CalendarReading | null>
35
52
  now?: () => number
36
53
  }
37
54
 
38
- /** How long the sub-agent reads may hold the ask before it goes on without them. */
55
+ /** How long the session reads may hold the ask before it goes on with what was read by then. */
39
56
  export const TODAY_BRIEF_SUBAGENT_WAIT_MS = 2_500
40
57
 
58
+ /** What the server read per session: its sub-agents and its recent activity, by "provider|id". */
59
+ export interface SessionContext { agents: Map<string, SubagentReading>; recent: Map<string, RecentActivity> }
60
+ export const emptySessionContext = (): SessionContext => ({ agents: new Map(), recent: new Map() })
61
+
41
62
  /**
42
- * The server's own sub-agent reads: Claude from the session's transcript tail (lib/session-subagents.ts), Codex from
43
- * the spawn-edge snapshot (readCodexSubagentDetails, server home only). Cursor has none. Sequential, bounded, read-only.
63
+ * 2026-11-09: readSubagentsFor became this. For each key, most important first: the sub-agents (Claude from the
64
+ * transcript tail, Codex from the spawn-edge snapshot) and the recent activity (lib/agent-session-recent.ts). Claude
65
+ * and Cursor read one after another; Codex reads in parallel with them, because a Codex rollout's first scan is the
66
+ * slow one (32 GB files) and must not starve the rest. Results land in `into` as they arrive. Read-only.
44
67
  */
45
- export async function readSubagentsFor(keys: string[], now = Date.now()): Promise<Map<string, SubagentReading>> {
46
- const out = new Map<string, SubagentReading>()
68
+ export async function readSessionContext(keys: string[], into: SessionContext = emptySessionContext(), now = Date.now()): Promise<SessionContext> {
47
69
  const roots = agentSessionRoots()
48
- const codex = keys.filter(k => k.startsWith('codex|')).map(k => k.slice(6))
49
- for (const key of keys.filter(k => k.startsWith('claude|'))) {
50
- const file = await findAgentSessionFile('claude', key.slice(7), roots).catch(() => null)
51
- const read: SessionSubagents | null = file ? await readClaudeSubagents(file) : null
52
- if (read && read.total) out.set(key, read)
53
- }
54
- if (codex.length) {
55
- const details = await readCodexSubagentDetails(codex, roots.codexSessions, now)
70
+ const split = (k: string) => { const i = k.indexOf('|'); return { provider: k.slice(0, i) as AgentProvider, id: k.slice(i + 1) } }
71
+ const codexKeys = keys.filter(k => k.startsWith('codex|'))
72
+ const localKeys = keys.filter(k => k.startsWith('claude|') || k.startsWith('cursor|'))
73
+ const local = (async () => {
74
+ // One index for every Claude id in this ask, not one per call.
75
+ const claudeFiles = localKeys.some(k => k.startsWith('claude|')) ? await loadClaudeTranscriptIndex(roots).catch(() => undefined) : undefined
76
+ const aliases = claudeFiles ? await loadClaudeDesktopAliases(roots.claudeCodeSessions).catch(() => undefined) : undefined
77
+ for (const key of localKeys) {
78
+ const { provider, id } = split(key)
79
+ const file = await findAgentSessionFile(provider, id, roots, new Date(now), aliases, claudeFiles ? { claudeFiles } : undefined).catch(() => null)
80
+ if (!file) continue
81
+ if (provider === 'claude') {
82
+ const read: SessionSubagents | null = await readClaudeSubagents(file)
83
+ if (read && read.total) into.agents.set(key, read)
84
+ }
85
+ const recent = await readRecentActivity(provider, file, now)
86
+ if (recent) into.recent.set(key, recent)
87
+ }
88
+ })()
89
+ const codex = (async () => {
90
+ if (!codexKeys.length) return
91
+ const ids = codexKeys.map(k => k.slice(6))
92
+ const details = await readCodexSubagentDetails(ids, roots.codexSessions, now).catch(() => new Map())
56
93
  for (const [id, list] of details) {
57
- const rows = list.map(d => ({ name: d.name, task: d.task, status: d.state === 'active' ? 'running' as const : d.state === 'completed' ? 'done' as const : 'unknown' as const, at: d.at }))
58
- if (rows.length) out.set('codex|' + id, summarize(rows, false))
94
+ const rows = (list as Array<{ name: string; task: string; state: string; at: number }>).map(d => ({ name: d.name, task: d.task, status: d.state === 'active' ? 'running' as const : d.state === 'completed' ? 'done' as const : 'unknown' as const, at: d.at }))
95
+ // Every row, not six: the brief keeps the live and recent ones and counts the rest as history.
96
+ if (rows.length) into.agents.set('codex|' + id, summarize(rows, false, rows.length))
59
97
  }
60
- }
61
- return out
98
+ const lists = await Promise.all(codexSessionRootsOf(roots).map(root => listCodexJsonlFiles(root).catch(() => [] as string[])))
99
+ const codexFiles = lists.flat()
100
+ for (const key of codexKeys) {
101
+ const file = await findAgentSessionFile('codex', key.slice(6), roots, new Date(now), undefined, { codexFiles }).catch(() => null)
102
+ if (!file) continue
103
+ const recent = await readRecentActivity('codex', file, now)
104
+ if (recent) into.recent.set(key, recent)
105
+ }
106
+ })()
107
+ await Promise.all([local, codex])
108
+ return into
62
109
  }
63
110
 
64
- /** The server's own brief: the data folder, Haiku through the isolated text runner, one audit row per start. */
111
+ /** The server's own brief: the data folder, Sonnet (7.0.1) through the isolated text runner, one audit row per start. */
65
112
  export function defaultTodayBrief(): TodayBrief {
66
113
  return createTodayBrief({
67
114
  stateFile: dataPath(TODAY_BRIEF_STATE_FILE),
@@ -81,8 +128,12 @@ export function defaultTodayBrief(): TodayBrief {
81
128
  }
82
129
 
83
130
  const IN_PROGRESS = new Set(['draft', 'built', 'qa'])
84
- /** Work cards in progress, by title and stage: open, not delegated, drafting to QA, or with an agent on it. */
85
- export function workInProgress(rows: TaskBoardRow[] | null): BriefWork[] {
131
+ /**
132
+ * Work cards in progress, by title and stage: open, not delegated, drafting to QA, or with an agent on it. 2026-11-09:
133
+ * with when each last moved (the dated board read: the line's git change time, else the day it was created; null when
134
+ * neither is known), running first, then the most recently moved, so the twelve kept are the live ones.
135
+ */
136
+ export function workInProgress(rows: Array<TaskBoardRow & Partial<TaskDateFields>> | null, now = Date.now()): BriefWork[] {
86
137
  if (!rows) return []
87
138
  const out: BriefWork[] = []
88
139
  for (const row of rows) {
@@ -90,9 +141,23 @@ export function workInProgress(rows: TaskBoardRow[] | null): BriefWork[] {
90
141
  const stage = row.workStage ?? (row.stage === 'active' ? 'draft' : row.stage === 'review' ? 'qa' : 'planned')
91
142
  const running = row.agentState === 'running'
92
143
  if (!IN_PROGRESS.has(stage) && !running) continue
93
- out.push({ title: row.title || row.text || '', stage, running })
144
+ const at = Date.parse(row.lineChangedAt ?? '') || Date.parse(row.createdOn ?? '') || 0
145
+ out.push({ title: row.title || row.text || '', stage, running, movedMinutes: at > 0 ? Math.max(0, Math.round((now - at) / 60000)) : null })
94
146
  }
95
- return out.sort((a, b) => Number(b.running) - Number(a.running)).slice(0, 12)
147
+ const age = (w: BriefWork) => (w.movedMinutes == null ? Number.MAX_SAFE_INTEGER : w.movedMinutes)
148
+ return out.sort((a, b) => Number(b.running) - Number(a.running) || age(a) - age(b)).slice(0, 12)
149
+ }
150
+
151
+ /**
152
+ * The body without `force` and `reason`, and what they asked. Anything but a boolean `force` is a bad request; 7.0.5
153
+ * `reason` may only be "reconnect".
154
+ */
155
+ export function splitForce(raw: unknown): { body: unknown; force: boolean; reconnect: boolean } {
156
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('force' in raw || 'reason' in raw)) return { body: raw, force: false, reconnect: false }
157
+ const { force, reason, ...rest } = raw as Record<string, unknown>
158
+ if (force !== undefined && typeof force !== 'boolean') throw new TodayBriefInputError('invalid_today_brief')
159
+ if (reason !== undefined && reason !== 'reconnect') throw new TodayBriefInputError('invalid_today_brief')
160
+ return { body: rest, force: force === true, reconnect: reason === 'reconnect' }
96
161
  }
97
162
 
98
163
  export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
@@ -108,26 +173,40 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
108
173
  if (Buffer.byteLength(JSON.stringify(req.body ?? null)) > TODAY_BRIEF_BODY_MAX_BYTES) {
109
174
  return res.status(413).json({ error: { code: 'today_brief_too_large', message: 'Too much for the Today summary.' } })
110
175
  }
176
+ // 7.0.1: `force` is the refresh button's flag, taken off before the rows are read (the rows reject unknown keys).
177
+ const { body, force, reconnect } = splitForce(req.body)
111
178
  // The switch first: off reads nothing, not even the board.
112
179
  if (!deps.brief.settings().enabled) {
113
- normalizeTodayBriefInputs(req.body)
180
+ normalizeTodayBriefInputs(body)
114
181
  return res.json(deps.brief.peek())
115
182
  }
116
- // Every gate that needs no reads goes first (switch, recording, drain, breaker, daily cap, interval): a gated ask
117
- // answers from the page's rows alone and reads neither the board nor any transcript.
118
- const pageInputs = normalizeTodayBriefInputs(req.body)
119
- const closed = deps.brief.gate()
183
+ // Every gate that needs no reads goes first (switch, recording, drain, breaker, daily cap, interval or, forced,
184
+ // the 30 s refresh floor): a gated ask answers from the page's rows alone and reads neither the board nor any
185
+ // transcript.
186
+ const pageInputs = normalizeTodayBriefInputs(body)
187
+ const closed = deps.brief.gate({ force, reconnect })
120
188
  if (closed) return res.json(deps.brief.askGated(pageInputs, closed))
121
- const work = workInProgress(await deps.board().catch(() => null))
122
- let inputs = normalizeTodayBriefInputs(req.body, work)
123
- const keys = subagentLookups(inputs)
124
- if (keys.length && deps.subagents) {
125
- let timer: ReturnType<typeof setTimeout> | undefined
126
- const late = new Promise<Map<string, SubagentReading>>(resolve => { timer = setTimeout(() => resolve(new Map()), TODAY_BRIEF_SUBAGENT_WAIT_MS) })
127
- const readings = await Promise.race([deps.subagents(keys).catch(() => new Map<string, SubagentReading>()), late]).finally(() => clearTimeout(timer))
128
- inputs = withSubagents(inputs, readings, (deps.now ?? Date.now)())
129
- }
130
- res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs)))
189
+ const now = (deps.now ?? Date.now)()
190
+ // The board and the session reads run together; the reads stop being waited for after 2.5 s and the ask goes on
191
+ // with whatever they wrote by then (a Codex first scan that runs on still fills its memo for the next ask).
192
+ const keys = sessionContextLookups(normalizeTodayBriefInputs(body))
193
+ const context = emptySessionContext()
194
+ // Today round 2: the calendar reads alongside the sessions, inside the same wait; whatever has not answered by
195
+ // then is left out (a slow bridge never holds the ask).
196
+ const cal: { value: CalendarReading | null } = { value: null }
197
+ const work: Array<Promise<unknown>> = []
198
+ if (keys.length && deps.context) work.push(Promise.resolve().then(() => deps.context!(keys, context)).catch(() => undefined))
199
+ if (deps.calendar) work.push(Promise.resolve().then(() => deps.calendar!()).then(c => { cal.value = c }, () => undefined))
200
+ let timer: ReturnType<typeof setTimeout> | undefined
201
+ const reads = work.length
202
+ ? Promise.race([Promise.all(work), new Promise<void>(resolve => { timer = setTimeout(resolve, TODAY_BRIEF_SUBAGENT_WAIT_MS) })]).finally(() => clearTimeout(timer))
203
+ : Promise.resolve()
204
+ const [rows] = await Promise.all([deps.board().catch(() => null), reads])
205
+ // A snapshot: a read still running after the wait cannot change the inputs under the call.
206
+ const agents = new Map(context.agents), recent = new Map(context.recent), calendar = cal.value
207
+ let inputs = normalizeTodayBriefInputs(body, workInProgress(rows, now))
208
+ inputs = withCalendar(withRecentActivity(withSubagents(inputs, agents, now), recent, now), calendar, now)
209
+ res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs), { force, reconnect }))
131
210
  } catch (error) {
132
211
  if (error instanceof TodayBriefInputError) return res.status(400).json({ error: { code: error.code, message: 'That is not a Today summary request.' } })
133
212
  console.warn(`[today-brief] ask failed (${error instanceof Error ? error.name : 'error'})`)
@@ -144,5 +223,16 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
144
223
  }
145
224
  try { res.json(deps.brief.setEnabled(body.enabled)) } catch { failed(res) }
146
225
  })
226
+ // 7.0.5 "Summaries per day" (Control Web Settings and COS Control use the same route).
227
+ router.get('/today/brief/settings/cap', (_req, res) => {
228
+ try { res.json(deps.brief.cap()) } catch { failed(res) }
229
+ })
230
+ router.put('/today/brief/settings/cap', (req, res) => {
231
+ const body = req.body
232
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'dailyCap') || typeof body.dailyCap !== 'number' || !Number.isFinite(body.dailyCap)) {
233
+ return res.status(400).json({ error: { code: 'invalid_today_brief_cap', message: 'Send { dailyCap: a number from 1 to 100 }.' } })
234
+ }
235
+ try { res.json(deps.brief.setDailyCap(body.dailyCap)) } catch { failed(res) }
236
+ })
147
237
  return router
148
238
  }
@@ -24,6 +24,10 @@ import {
24
24
  type WebLoginFailure,
25
25
  WebGlassesCodeProbeBudget,
26
26
  WEB_SESSION_TTL_MS,
27
+ WEB_SESSION_COOKIE_NAME,
28
+ WEB_SESSION_MAX_LIFE_MS,
29
+ readWebSessionCookie,
30
+ webSessionCookieFrom,
27
31
  WebHandoffStore,
28
32
  WebLoginLimiter,
29
33
  WebDeviceCodeStore,
@@ -199,6 +203,18 @@ export function webControlCsp(_requestPath: string): string {
199
203
  ].join('; ')
200
204
  }
201
205
 
206
+ /**
207
+ * The expiry (ISO) of the cos_web_session cookie on a request, read from the SAME value the verifier reads (Q1 S1:
208
+ * `parseCookies` keeps the last of several; another app on this host name can plant an earlier one). Read only after
209
+ * verification. Shapes: `<exp>.<epoch>.<sig>`, or a renewed `<exp>.<epoch>.<orig>.<sig>`.
210
+ */
211
+ export function webSessionCookieExpiry(cookieHeader: unknown): string | null {
212
+ const value = webSessionCookieFrom(cookieHeader)
213
+ const exp = typeof value === 'string' ? /^(\d{1,15})\./.exec(value) : null
214
+ const ms = exp ? Number(exp[1]) * 1000 : NaN
215
+ return Number.isSafeInteger(ms) && ms > 0 ? new Date(ms).toISOString() : null
216
+ }
217
+
202
218
  export function createWebControlRouter(deps: WebControlRouterDeps): Router {
203
219
  const router = Router()
204
220
  const handoffStore = deps.handoffStore ?? new WebHandoffStore()
@@ -287,11 +303,20 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
287
303
  ? { authenticated: false, mode: 'none' }
288
304
  : { authenticated: false, mode: 'none', network: 'tailscale_only' })
289
305
  }
306
+ // W1 (audit 02 F2): a signed-in browser learns when ITS OWN cookie ends, so the page can say so before it does.
307
+ // Only after the cookie verified (its signature covers the expiry), only for cookie mode, and nothing a caller without
308
+ // that cookie can read. The 7-day lifetime itself is unchanged.
309
+ const expiresAt = cookieOk && !headerOk ? webSessionCookieExpiry(req.headers.cookie) : null
310
+ // Renewal (Miles 2026-11-09): the latest this sign-in can last while in use, 30 days from its first sign-in.
311
+ const read = cookieOk && !headerOk ? readWebSessionCookie(webSessionCookieFrom(req.headers.cookie), token) : null
312
+ const renewUntil = read ? new Date((read.origUnixSec + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000)) * 1000).toISOString() : null
290
313
  res.json({
291
314
  authenticated: true,
292
315
  mode: headerOk ? 'header' : 'cookie',
293
316
  serverName: hostname().replace(/\.local$/i, ''),
294
317
  ownerName: getOwnerName(),
318
+ ...(expiresAt ? { expiresAt } : {}),
319
+ ...(renewUntil ? { renewUntil } : {}),
295
320
  })
296
321
  })
297
322
 
@@ -322,10 +347,13 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
322
347
  const token = deps.apiToken()
323
348
  const result = classifyWebLogin(body, token, handoffStore, deviceCodes, glassesCheck)
324
349
  if (!result.ok) {
325
- // Every failure counts, the glasses-code one included, so that check cannot be used to
326
- // probe codes faster than the limiter allows.
327
- loginLimiter.fail(ip, result.reason)
328
- log(`[web-login] fail kind=${result.reason} len=${result.length} ip=${ip}`)
350
+ // Every failure counts toward the per-IP limit, the glasses-code one included, so that check cannot be used to
351
+ // probe codes faster than the limiter allows. Except a failed sign-in LINK (Miles 2026-11-09 20:00, "Yes, stop
352
+ // counting stale links"): a ticket is 192 random bits, so guessing one is not a risk, and a person reopening an old
353
+ // QR or link should not lock themselves out of typing a code. It still fails with the same message.
354
+ if (result.reason !== 'handoff_expired') loginLimiter.fail(ip, result.reason)
355
+ // S1 item 7: why a handoff link failed (expired, used, evicted, unknown). The log only; the message is unchanged.
356
+ log(`[web-login] fail kind=${result.reason}${result.handoff ? ` sub=${result.handoff}` : ''} len=${result.length} ip=${ip}`)
329
357
  return res.status(401).json({
330
358
  ok: false,
331
359
  error: 'invalid_credentials',
@@ -340,6 +368,8 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
340
368
  return res.json({
341
369
  ok: true,
342
370
  expiresAt: new Date(minted.expiresAtMs).toISOString(),
371
+ // Renewal (Miles 2026-11-09): while in use this sign-in renews up to 30 days from now (lib/web-session-auth.ts).
372
+ renewUntil: new Date((Math.floor(minted.expiresAtMs / 1000) - Math.floor(WEB_SESSION_TTL_MS / 1000) + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000)) * 1000).toISOString(),
343
373
  serverName: hostname().replace(/\.local$/i, ''),
344
374
  ownerName: getOwnerName(),
345
375
  })
@@ -357,6 +387,9 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
357
387
  router.post('/web-session/revoke-all', (req: Request, res: Response) => {
358
388
  res.set('Cache-Control', 'private, no-store')
359
389
  const epoch = bumpWebSessionEpoch()
390
+ // Final review V7: a sign-in link or code made before the sign-out no longer signs a browser in after it.
391
+ handoffStore.revokeAll()
392
+ deviceCodes.revokeAll()
360
393
  res.setHeader('Set-Cookie', formatClearWebSessionCookie(req.secure))
361
394
  res.json({ ok: true, epoch })
362
395
  })
@@ -0,0 +1,284 @@
1
+ // /api/web-key/*: the enrolled phone key registry and enrollment ceremony (6.99.31, plan §4.3,
2
+ // §4.7). Behind COS_WEB_KEY_AUTHORITY (default off). Off, none of these is a route (each falls
3
+ // through to next(), exactly as on 6.99.29).
4
+ //
5
+ // WHO MAY CALL WHAT:
6
+ // - The phone's steps (enroll start, prove, status, nonce) need the WEB path: the signed cookie,
7
+ // no X-Cos-Token header, and a socket that is not this Mac (the same judgment as a web turn).
8
+ // A token-bearing, loopback or own-address request is refused: an agent on this Mac cannot
9
+ // start an enrollment for a key it made.
10
+ // - COS Control's steps (pending, approve) need X-Cos-Token, and approve ALSO needs the anchor's
11
+ // Secure Enclave signature over the frozen approval (Touch ID). The token alone approves nothing.
12
+ // - Revoke, revoke-all and the key list take either the token or the web path. Revocation never
13
+ // needs the anchor, so a lost phone can always be revoked (by a token holder too: denial of
14
+ // service only, logged in web-key-audit.jsonl).
15
+ // - Anchor registration (review B1) is COS Control's bootstrap: loopback socket only, no proxy
16
+ // headers, X-Cos-Token, and the anchor's own signature over its `cos-web-anchor/1` statement. A
17
+ // new anchor vouches nothing for 24 hours; the turn answer exposes its fingerprint and age so
18
+ // the verifier compares it with Miles's committed pin.
19
+ // - Approval packets name their creating session from the request context (`x-cos-provider`,
20
+ // `x-cos-session-id`), never from body text (review B4).
21
+ // - Phase 3: the phone's key steps (enroll start and prove, nonce) answer only on the 3144 signer
22
+ // listener when `isSignerRequest` is wired (index.ts), so a page on 3143 cannot make a key.
23
+ // - Phase 3 handoff: the composer creates one on the web path (the destination comes from the
24
+ // binding, never the body); only the signer page claims it, once, on 3144, naming its origin.
25
+ // - 7.0.9 anchor bootstrap (COS_WEB_KEY_ANCHOR_BOOTSTRAP on, COS_WEB_KEY_AUTHORITY off): ONLY
26
+ // GET status (X-Cos-Token only, reply `enabled: false, mode: 'bootstrap'`) and POST anchor (as
27
+ // above) answer. Every other route falls through exactly as with both switches off.
28
+
29
+ import { Router, type Request, type Response, type NextFunction } from 'express'
30
+ import { timingSafeTokenEqual } from '../lib/token-auth.js'
31
+ import { hasProxyForwardedHeaders, isLoopbackAddress, normalizeRemoteIp } from '../lib/network-policy.js'
32
+ import { WEB_KEY_ENABLED_APPROVE_ACTIONS, type WebKeyAuthority } from '../lib/web-key-authority.js'
33
+ import { WEB_ANCHOR_MATURITY_MS, type WebKeyEnrollments } from '../lib/web-key-registry.js'
34
+ import { parseHandoffRequest, type WebKeySignHandoffs } from '../lib/web-key-handoff.js'
35
+ import type { WebApprovalPacket, WebTurnScope } from '../../shared/web-turn-envelope.js'
36
+
37
+ export const ANCHOR_REGISTRATION_LOOPBACK_COPY = 'The COS Control anchor key can only be registered by COS Control on this Mac.'
38
+
39
+ export interface WebKeyRouterDeps {
40
+ authority: WebKeyAuthority
41
+ enrollments: WebKeyEnrollments
42
+ /** The web path in full: cookie, no token, a socket that is not this Mac (G2TurnProvenance.originOf). */
43
+ isWebRequest: (req: Request) => boolean
44
+ apiToken: () => string
45
+ selfDnsName: () => string | null
46
+ expectedOrigin: () => string | null
47
+ /** Served-asset integrity (Phase 6) for Control's audit; absent: not reported. */
48
+ integrityStatus?: () => Record<string, unknown>
49
+ /**
50
+ * Phase 3: whether this request reached the 3144 signer listener (index.ts: the socket's local
51
+ * port). Given, the phone's key steps (enroll start and prove, nonce) and the handoff claim answer
52
+ * only there, so a page on 3143 can neither make a key nor fetch a handoff. Absent (tests that
53
+ * predate the signer): not checked, exactly as before.
54
+ */
55
+ isSignerRequest?: (req: Request) => boolean
56
+ /** Phase 3: the 3143 to 3144 handoff store. Absent: the handoff routes answer 404 `handoff_unavailable`. */
57
+ handoffs?: WebKeySignHandoffs
58
+ /** Phase 3: the binding's real destination, as the turn route will resolve it. Null: no such binding. */
59
+ resolveBinding?: (bindingId: string) => { provider: string; sessionId: string; epoch: number; targetKey: string } | null
60
+ }
61
+
62
+ export const SIGNER_ORIGIN_REQUIRED = 'signer_origin_required'
63
+
64
+ export function createWebKeyRouter(deps: WebKeyRouterDeps): Router {
65
+ const router = Router()
66
+
67
+ const on = (req: Request, res: Response, next: NextFunction): void => {
68
+ if (!deps.authority.enabled()) { next('router'); return }
69
+ res.set('Cache-Control', 'private, no-store')
70
+ next()
71
+ }
72
+ const hasToken = (req: Request): boolean => {
73
+ let expected = ''
74
+ try { expected = deps.apiToken() } catch { expected = '' }
75
+ return expected.length > 0 && timingSafeTokenEqual(req.headers['x-cos-token'], expected)
76
+ }
77
+ const web = (req: Request): boolean => {
78
+ try { return deps.isWebRequest(req) === true } catch { return false }
79
+ }
80
+ const refuse = (res: Response, status: number, error: string, extra: Record<string, unknown> = {}): void => {
81
+ res.status(status).json({ ok: false, error, ...extra })
82
+ }
83
+ const by = (req: Request): string => (hasToken(req) ? 'token' : 'web')
84
+ const body = (req: Request): Record<string, unknown> => (req.body && typeof req.body === 'object' ? req.body as Record<string, unknown> : {})
85
+ /** On the 3144 listener, when the deployment can tell (Phase 3). Never throws. */
86
+ const onSigner = (req: Request): boolean => {
87
+ if (!deps.isSignerRequest) return true
88
+ try { return deps.isSignerRequest(req) === true } catch { return false }
89
+ }
90
+
91
+ // 7.0.9: status and anchor registration answer in either mode; with both switches off they fall
92
+ // through like every other route. They sit BEFORE `on`, which still gates everything else on the
93
+ // authority alone.
94
+ const anchorRoutes = (req: Request, res: Response, next: NextFunction): void => {
95
+ if (deps.authority.mode() === 'off') { next('router'); return }
96
+ res.set('Cache-Control', 'private, no-store')
97
+ next()
98
+ }
99
+
100
+ router.get('/web-key/status', anchorRoutes, (req, res) => {
101
+ const registry = deps.authority.registry
102
+ const selfDns = deps.selfDnsName()
103
+ if (deps.authority.mode() !== 'authority') {
104
+ // Bootstrap only: COS Control's anchor state, nothing that suggests approvals. Token only:
105
+ // no web page needs it while no phone can enroll. `enabled: false` keeps older Controls hidden.
106
+ if (!hasToken(req)) return refuse(res, 401, 'unauthorized')
107
+ const anchor = registry.anchor()
108
+ return void res.json({
109
+ ok: true,
110
+ enabled: false,
111
+ mode: 'bootstrap',
112
+ anchorRegistered: anchor !== null,
113
+ anchorMature: registry.anchorMature(),
114
+ anchorSetAt: anchor ? anchor.setAt : null,
115
+ aud: registry.aud(selfDns),
116
+ origin: deps.expectedOrigin(),
117
+ epoch: registry.epoch(),
118
+ })
119
+ }
120
+ if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
121
+ res.json({
122
+ ok: true,
123
+ enabled: true,
124
+ anchorRegistered: registry.anchor() !== null,
125
+ anchorRefusal: registry.anchorRefusal(),
126
+ anchorMature: registry.anchorMature(),
127
+ registryReadable: !registry.unreadable,
128
+ nonceStoreReadable: !deps.authority.nonces.unavailable,
129
+ aud: registry.aud(selfDns),
130
+ origin: deps.expectedOrigin(),
131
+ epoch: registry.epoch(),
132
+ activeKeys: registry.activeCount(selfDns),
133
+ ...(deps.integrityStatus ? { integrity: deps.integrityStatus() } : {}),
134
+ })
135
+ })
136
+
137
+ router.post('/web-key/anchor', anchorRoutes, (req, res) => {
138
+ // Loopback socket, no forwarding headers (a local proxy makes every caller loopback), and the token.
139
+ const addr = normalizeRemoteIp(req.socket?.remoteAddress ?? '')
140
+ if (!addr || !isLoopbackAddress(addr) || hasProxyForwardedHeaders(req.headers)) return refuse(res, 403, 'loopback_required', { reasonCopy: ANCHOR_REGISTRATION_LOOPBACK_COPY })
141
+ if (!hasToken(req)) return refuse(res, 401, 'token_required')
142
+ const b = body(req)
143
+ const result = deps.authority.registry.registerAnchor({ anchorPub: b.anchorPub, statement: b.statement, signature: b.signature }, 'control')
144
+ if (!result.ok) return refuse(res, result.reason === 'registry_write_failed' || result.reason === 'registry_unreadable' ? 503 : result.reason === 'anchor_signature_invalid' ? 403 : 400, result.reason)
145
+ res.status(201).json({ ...result, maturesAt: result.setAt + WEB_ANCHOR_MATURITY_MS })
146
+ })
147
+
148
+ // Everything below: the authority only (bootstrap mode falls through here, as with both off).
149
+ router.use('/web-key', on)
150
+
151
+ router.post('/web-key/enroll/start', (req, res) => {
152
+ if (!web(req)) return refuse(res, 403, 'web_origin_required')
153
+ if (!onSigner(req)) return refuse(res, 403, SIGNER_ORIGIN_REQUIRED)
154
+ const b = body(req)
155
+ const result = deps.enrollments.start({
156
+ spki: b.spki,
157
+ label: b.label,
158
+ rotates: b.rotates,
159
+ sasCommit: b.sasCommit,
160
+ userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '',
161
+ remote: req.socket?.remoteAddress ?? 'unknown',
162
+ }, { selfDnsName: deps.selfDnsName(), origin: deps.expectedOrigin() })
163
+ if (!result.ok) return refuse(res, result.reason === 'device_not_allowed' ? 403 : result.reason === 'enrollment_pending' || result.reason === 'key_cap_reached' || result.reason === 'key_already_enrolled' ? 409 : 400, result.reason)
164
+ res.status(201).json(result)
165
+ })
166
+
167
+ router.post('/web-key/enroll/prove', (req, res) => {
168
+ if (!web(req)) return refuse(res, 403, 'web_origin_required')
169
+ if (!onSigner(req)) return refuse(res, 403, SIGNER_ORIGIN_REQUIRED)
170
+ const b = body(req)
171
+ const result = deps.enrollments.prove({ enrollId: b.enrollId, signature: b.signature, phoneNonce: b.phoneNonce, passkey: b.passkey })
172
+ if (!result.ok) return refuse(res, result.reason === 'enroll_unknown' ? 404 : 400, result.reason)
173
+ res.json(result)
174
+ })
175
+
176
+ router.get('/web-key/enroll/status', (req, res) => {
177
+ if (!web(req) && !hasToken(req)) return refuse(res, 401, 'unauthorized')
178
+ res.json({ ok: true, status: deps.enrollments.status(req.query.enrollId) })
179
+ })
180
+
181
+ router.get('/web-key/enroll/pending', (req, res) => {
182
+ if (!hasToken(req)) return refuse(res, 401, 'token_required')
183
+ res.json({ ok: true, pending: deps.enrollments.pendingView() })
184
+ })
185
+
186
+ router.post('/web-key/enroll/approve', (req, res) => {
187
+ if (!hasToken(req)) return refuse(res, 401, 'token_required')
188
+ const b = body(req)
189
+ const result = deps.enrollments.approve(b.enrollId, b.anchorSig, deps.selfDnsName())
190
+ if (!result.ok) return refuse(res, result.reason === 'enroll_unknown' ? 404 : result.reason === 'anchor_signature_invalid' ? 403 : 409, result.reason)
191
+ res.json(result)
192
+ })
193
+
194
+ router.get('/web-key/keys', (req, res) => {
195
+ if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
196
+ res.json({ ok: true, epoch: deps.authority.registry.epoch(), keys: deps.authority.registry.list(deps.selfDnsName()) })
197
+ })
198
+
199
+ router.post('/web-key/revoke', (req, res) => {
200
+ if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
201
+ const b = body(req)
202
+ if (typeof b.kid !== 'string') return refuse(res, 400, 'invalid_request')
203
+ const reason = typeof b.reason === 'string' && b.reason.trim() ? b.reason.trim() : 'revoked'
204
+ if (!deps.authority.registry.revoke(b.kid, reason, by(req))) return refuse(res, 404, 'key_unknown')
205
+ res.json({ ok: true, kid: b.kid, revoked: true })
206
+ })
207
+
208
+ router.post('/web-key/revoke-all', (req, res) => {
209
+ if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
210
+ res.json({ ok: true, epoch: deps.authority.registry.revokeAll(by(req)) })
211
+ })
212
+
213
+ router.post('/web-key/nonce', (req, res) => {
214
+ if (!web(req)) return refuse(res, 403, 'web_origin_required')
215
+ if (!onSigner(req)) return refuse(res, 403, SIGNER_ORIGIN_REQUIRED)
216
+ const issued = deps.authority.nonces.issue()
217
+ if (!issued.ok) return refuse(res, 503, issued.reason)
218
+ res.status(201).json({ ok: true, nonce: issued.nonce, exp: issued.exp, aud: deps.authority.registry.aud(deps.selfDnsName()), epoch: deps.authority.registry.epoch() })
219
+ })
220
+
221
+ router.post('/web-key/packets', (req, res) => {
222
+ // A packet is only a request (a receiving session asks Miles to approve one exact action).
223
+ if (!hasToken(req)) return refuse(res, 401, 'token_required')
224
+ // The creating session comes from the request context, never the body (review B4).
225
+ const header = (name: string): string | null => { const v = req.headers[name]; return typeof v === 'string' && v.trim() ? v.trim() : null }
226
+ const created = deps.authority.packets.create(body(req), { provider: header('x-cos-provider'), sessionId: header('x-cos-session-id') })
227
+ if (!created.ok) return refuse(res, created.reason === 'packet_cap_reached' ? 409 : created.reason === 'creator_unknown' ? 401 : 400, created.reason)
228
+ res.status(201).json({ ok: true, packet: created.packet, packetSha256: created.packetSha256 })
229
+ })
230
+
231
+ router.get('/web-key/packets', (req, res) => {
232
+ if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
233
+ const provider = req.query.provider, sessionId = req.query.sessionId
234
+ if ((provider !== undefined || sessionId !== undefined) && (typeof provider !== 'string' || !['claude', 'codex', 'cursor'].includes(provider) || typeof sessionId !== 'string' || !sessionId || sessionId.length > 128)) return refuse(res, 400, 'invalid_target')
235
+ const packets = deps.authority.packets.list().filter(row => WEB_KEY_ENABLED_APPROVE_ACTIONS.has(row.packet.action)
236
+ && (provider === undefined || (row.packet.provider === provider && row.packet.sessionId === sessionId)))
237
+ res.json({ ok: true, packets })
238
+ })
239
+
240
+ // ── Phase 3: the handoff from the Control Web composer (3143) to the signer page (3144) ──
241
+ // Create: the composer, on the web path. The destination is the binding's, never the body's.
242
+ router.post('/web-key/handoff', (req, res) => {
243
+ if (!web(req)) return refuse(res, 403, 'web_origin_required')
244
+ if (!deps.handoffs || !deps.resolveBinding) return refuse(res, 404, 'handoff_unavailable')
245
+ const signerOrigin = deps.expectedOrigin()
246
+ if (!signerOrigin) return refuse(res, 503, 'origin_unavailable')
247
+ if (deps.authority.registry.activeCount(deps.selfDnsName()) < 1) return refuse(res, 409, 'no_phone_key')
248
+ const parsed = parseHandoffRequest(req.body)
249
+ if (!parsed.ok) return refuse(res, 400, parsed.reason)
250
+ const { request } = parsed
251
+ let target: ReturnType<NonNullable<WebKeyRouterDeps['resolveBinding']>> = null
252
+ try { target = deps.resolveBinding(request.turn.bindingId) } catch { target = null }
253
+ if (!target) return refuse(res, 404, 'unknown_binding')
254
+ if (target.epoch !== request.turn.epoch || target.targetKey !== request.turn.targetKey) return refuse(res, 409, 'binding_changed')
255
+ let scope: WebTurnScope = { kind: 'message' }
256
+ let approvalPacket: WebApprovalPacket | null = null
257
+ if (request.packetSha256) {
258
+ const packet = deps.authority.packets.get(request.packetSha256)
259
+ if (!packet) return refuse(res, 404, 'packet_unknown')
260
+ if (!WEB_KEY_ENABLED_APPROVE_ACTIONS.has(packet.action)) return refuse(res, 400, 'approve_action_not_enabled')
261
+ // A packet is signable only for the session that asked for it (review B4).
262
+ if (packet.provider !== target.provider || packet.sessionId !== target.sessionId) return refuse(res, 409, 'packet_session_mismatch')
263
+ scope = { kind: 'approve', action: packet.action, artifact: packet.artifact, version: packet.version, digest: packet.digest, channel: packet.channel, packetSha256: request.packetSha256 }
264
+ approvalPacket = packet
265
+ }
266
+ const created = deps.handoffs.create(request, { provider: target.provider, sessionId: target.sessionId }, scope, approvalPacket)
267
+ if (!created.ok) return refuse(res, created.reason === 'handoff_cap_reached' ? 409 : 400, created.reason)
268
+ // The id rides in the fragment: never sent to a server, never in a Referer. The words are not in it.
269
+ res.status(201).json({ ok: true, handoffId: created.handoffId, exp: created.exp, signerUrl: `${signerOrigin}/#h=${created.handoffId}` })
270
+ })
271
+
272
+ // Claim: the signer page, once, on its own listener and naming its own origin.
273
+ router.post('/web-key/handoff/claim', (req, res) => {
274
+ if (!web(req)) return refuse(res, 403, 'web_origin_required')
275
+ if (!deps.handoffs) return refuse(res, 404, 'handoff_unavailable')
276
+ const signerOrigin = deps.expectedOrigin()
277
+ if (!deps.isSignerRequest || !onSigner(req) || !signerOrigin || req.headers.origin !== signerOrigin) return refuse(res, 403, SIGNER_ORIGIN_REQUIRED)
278
+ const claimed = deps.handoffs.claim(body(req).handoffId)
279
+ if (!claimed.ok) return refuse(res, claimed.reason === 'handoff_unknown' ? 404 : claimed.reason === 'handoff_expired' ? 410 : 409, claimed.reason)
280
+ res.json({ ok: true, handoff: claimed.handoff })
281
+ })
282
+
283
+ return router
284
+ }