@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.
- package/.env.example +37 -0
- package/CHANGELOG.md +587 -0
- package/README.md +3 -1
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +12 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +155 -30
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +27 -14
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +43 -1
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +536 -0
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +31 -5
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +83 -0
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +640 -85
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +29 -1
- package/server/routes/agent-session-stream.ts +23 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +46 -1
- package/server/routes/live-events.ts +334 -0
- package/server/routes/permission-broker.ts +95 -5
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +223 -12
- package/server/routes/today-brief.ts +134 -44
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +28 -4
- package/server/routes/work-board.ts +9 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +76 -1
- package/server/web-control/app.js +2247 -98
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +21 -3
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +94 -9
- package/server/web-control/panes/cos-chat.js +628 -44
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +162 -21
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +97 -6
- package/server/web-control/panes/today-actions.css +46 -0
- package/server/web-control/panes/today-actions.js +119 -17
- package/server/web-control/panes/work.css +13 -0
- package/server/web-control/panes/work.js +188 -37
- package/server/web-control/shell.js +17 -14
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- 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
|
|
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 {
|
|
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,
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
*
|
|
43
|
-
* the spawn-edge snapshot
|
|
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
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
117
|
-
// answers from the page's rows alone and reads neither the board nor any
|
|
118
|
-
|
|
119
|
-
const
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
+
}
|