@gotcos/glasses-server 7.0.7 → 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 +19 -0
- package/CHANGELOG.md +170 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +126 -23
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +3 -3
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- 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-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- 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/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -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/today-brief.ts +522 -58
- 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-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-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +11 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +16 -9
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -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 +36 -0
- package/server/web-control/app.js +953 -57
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +14 -1
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +75 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +178 -35
- 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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// 7.0.18 (X1, Miles 2026-11-10 07:14, "fold this in ... so I can respond from glasses, web app and desktop"):
|
|
2
|
+
// a session whose turn ENDS by asking a question in the Claude desktop app's next-step box.
|
|
3
|
+
//
|
|
4
|
+
// WHAT HAPPENS: the session calls `mcp__ccd_turn__wrap_up` with `ask` and reply buttons (`actions[].label`),
|
|
5
|
+
// then its turn stops. There is no AskUserQuestion and no PermissionRequest, so the permission broker never
|
|
6
|
+
// sees it, and the row read idle: the glasses said nothing was waiting (Miles, 07:02).
|
|
7
|
+
//
|
|
8
|
+
// WHAT COS RECEIVES (canaries C15 on the CLI with a stand-in ccd_turn server, C17 in a real Claude desktop
|
|
9
|
+
// Code tab, 2026-11-10): the COS hook spools PostToolUse for every tool, and that payload carries
|
|
10
|
+
// `tool_input.ask` and `tool_input.actions` verbatim, plus `prompt_id`; the Stop follows (about 5 s in the
|
|
11
|
+
// canary; the PostToolUse hook is async, so either order). PreToolUse is not spooled for this tool.
|
|
12
|
+
//
|
|
13
|
+
// WHAT THIS FILE DOES: keeps, per session, the newest qualifying ask (only an ask with at least one reply
|
|
14
|
+
// button, or `blocked`), marks it asked once the turn has stopped, shows it only after the Mac's desk has
|
|
15
|
+
// been idle for the broker's threshold (then latched, so the frozen glasses stay quiet while Miles sits at
|
|
16
|
+
// the Mac), and drops it on any reply, a new turn, the session ending, or 8 hours. The words (the ask, 80
|
|
17
|
+
// characters; reply labels, 40 each, at most 2) follow COS_WAITING_QUESTION_WORDS; with that at 0 the row
|
|
18
|
+
// still says a question waits, with no words. App-action buttons (commit, create PR) are never choices.
|
|
19
|
+
//
|
|
20
|
+
// THE SWITCH: `COS_TURN_END_ASKS`, read once (the first read, at startup), default in TURN_END_ASKS_DEFAULT.
|
|
21
|
+
// Off: nothing here runs, nothing is projected into the ledger, and every row is 7.0.17's.
|
|
22
|
+
//
|
|
23
|
+
// THE ANSWER is the existing Continue (contract v1, COORD 07:18), with text that never reads as typed at the
|
|
24
|
+
// desk. Memory and counters only; nothing here logs a question's words.
|
|
25
|
+
|
|
26
|
+
import { readSwitch } from './env-switch.js'
|
|
27
|
+
import { questionRowText } from './full-hold.js'
|
|
28
|
+
import type { HookEnvelope } from './session-hook-events.js'
|
|
29
|
+
|
|
30
|
+
/** The default when COS_TURN_END_ASKS is unset. One constant, per the lead (Miles chooses the value). */
|
|
31
|
+
export const TURN_END_ASKS_DEFAULT = true
|
|
32
|
+
|
|
33
|
+
/** The Claude desktop app's next-step tool (its `ccd_turn` MCP server). */
|
|
34
|
+
export const WRAP_UP_TOOL = 'mcp__ccd_turn__wrap_up'
|
|
35
|
+
export const TURN_ASK_MAX_CHARS = 80
|
|
36
|
+
export const TURN_ASK_CHOICE_MAX_CHARS = 40
|
|
37
|
+
export const TURN_ASK_MAX_CHOICES = 2
|
|
38
|
+
/** A question nobody answered in 8 hours is not news the next morning; it stays in the transcript. */
|
|
39
|
+
export const TURN_ASK_MAX_AGE_MS = 8 * 60 * 60_000
|
|
40
|
+
/** The shortest gap between two desk reads while an ask waits for the latch. */
|
|
41
|
+
export const TURN_ASK_DESK_POLL_MS = 3_000
|
|
42
|
+
/** When the desk cannot be read, the next try. */
|
|
43
|
+
export const TURN_ASK_UNREADABLE_RETRY_MS = 60_000
|
|
44
|
+
/** The latch never fires for a desk idle less than this, whatever the setting says (the broker's floor). */
|
|
45
|
+
export const TURN_ASK_DESK_IDLE_MIN_S = 30
|
|
46
|
+
/** Sessions tracked at once; past it the oldest go. */
|
|
47
|
+
export const MAX_TURN_ASKS = 128
|
|
48
|
+
/** The registry saying busy this long after the stop means the session runs again. */
|
|
49
|
+
export const TURN_ASK_BUSY_GRACE_MS = 5_000
|
|
50
|
+
|
|
51
|
+
let enabledAtStartup: boolean | null = null
|
|
52
|
+
|
|
53
|
+
/** `COS_TURN_END_ASKS`, read on the first call (startup) and kept: a change needs a restart. */
|
|
54
|
+
export function turnEndAsksEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
55
|
+
if (enabledAtStartup === null) enabledAtStartup = readSwitch(env.COS_TURN_END_ASKS, TURN_END_ASKS_DEFAULT)
|
|
56
|
+
return enabledAtStartup
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Tests only: set the switch, or null to read it again on the next call. */
|
|
60
|
+
export function __setTurnEndAsksForTests(value: boolean | null): void {
|
|
61
|
+
enabledAtStartup = value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rowText(value: unknown, max: number): string {
|
|
69
|
+
const text = questionRowText(value).replace(/\s+/g, ' ').trim()
|
|
70
|
+
return text.length > max ? text.slice(0, max - 1) + '…' : text
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What is kept of one ask: already cleaned and bounded; empty words when the words switch is off. */
|
|
74
|
+
export interface TurnAskCapture { ask: string; choices: string[]; blocked: boolean }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A `wrap_up` input read strictly. Null unless it carries a non-blank `ask` AND (at least one reply button
|
|
78
|
+
* or `blocked: true`): an ask with no buttons is the session's suggestion, not a decision it stopped on. A
|
|
79
|
+
* reply button has a `label` and no `action`; a button with an `action` (commit, create PR) is the app's own
|
|
80
|
+
* and never a choice. `words` false keeps the fact and drops every word.
|
|
81
|
+
*/
|
|
82
|
+
export function captureTurnAsk(toolInput: unknown, words: boolean): TurnAskCapture | null {
|
|
83
|
+
if (!isRecord(toolInput)) return null
|
|
84
|
+
const ask = rowText(toolInput.ask, TURN_ASK_MAX_CHARS)
|
|
85
|
+
if (!ask) return null
|
|
86
|
+
const labels: string[] = []
|
|
87
|
+
if (Array.isArray(toolInput.actions)) {
|
|
88
|
+
for (const action of toolInput.actions) {
|
|
89
|
+
if (!isRecord(action)) continue
|
|
90
|
+
if (action.action !== undefined && action.action !== null && action.action !== '') continue
|
|
91
|
+
const label = rowText(action.label, TURN_ASK_CHOICE_MAX_CHARS)
|
|
92
|
+
if (label) labels.push(label)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const blocked = toolInput.blocked === true
|
|
96
|
+
if (labels.length === 0 && !blocked) return null
|
|
97
|
+
return { ask: words ? ask : '', choices: words ? labels.slice(0, TURN_ASK_MAX_CHOICES) : [], blocked }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The ledger's copy (`projectHookPayload`): the capture as stored, so a restart replays it. */
|
|
101
|
+
export function projectedTurnAsk(value: unknown): TurnAskCapture | null {
|
|
102
|
+
if (!isRecord(value) || typeof value.ask !== 'string' || !Array.isArray(value.choices)) return null
|
|
103
|
+
const choices = value.choices.filter((c): c is string => typeof c === 'string').slice(0, TURN_ASK_MAX_CHOICES).map(c => rowText(c, TURN_ASK_CHOICE_MAX_CHARS)).filter(Boolean)
|
|
104
|
+
return { ask: rowText(value.ask, TURN_ASK_MAX_CHARS), choices, blocked: value.blocked === true }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What a row shows (`deriveSessionState` input). */
|
|
108
|
+
export interface VisibleTurnAsk { ask: string; choices: string[]; stoppedAt: number }
|
|
109
|
+
|
|
110
|
+
interface AskState {
|
|
111
|
+
sessionId: string
|
|
112
|
+
promptId: string | null
|
|
113
|
+
capture: TurnAskCapture
|
|
114
|
+
armedAt: number
|
|
115
|
+
stoppedAt: number | null
|
|
116
|
+
visibleAt: number | null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface TurnEndAskTrackerDeps {
|
|
120
|
+
now(): number
|
|
121
|
+
enabled(): boolean
|
|
122
|
+
/** COS_WAITING_QUESTION_WORDS (default on): the words, or the fact alone. */
|
|
123
|
+
words(): boolean
|
|
124
|
+
readDeskIdleSeconds(): Promise<number | null>
|
|
125
|
+
deskIdleSeconds(): number
|
|
126
|
+
pollMs?: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type TurnEndAskCounters = { armed: number; shown: number; clearedByReply: number; clearedByTurn: number; agedOut: number }
|
|
130
|
+
|
|
131
|
+
/** Events that end a session's present: whatever it asked is moot. */
|
|
132
|
+
const SESSION_RESETS: ReadonlySet<string> = new Set(['SessionEnd', 'Interrupt', 'StopFailure'])
|
|
133
|
+
|
|
134
|
+
function str(value: unknown): string | null {
|
|
135
|
+
return typeof value === 'string' && value.length > 0 ? value : null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class TurnEndAskTracker {
|
|
139
|
+
private readonly deps: TurnEndAskTrackerDeps
|
|
140
|
+
private readonly asks = new Map<string, AskState>()
|
|
141
|
+
/** The newest Stop per session, for a wrap_up whose (async) PostToolUse lands after it. */
|
|
142
|
+
private readonly lastStop = new Map<string, { at: number; promptId: string | null }>()
|
|
143
|
+
private poll: ReturnType<typeof setTimeout> | null = null
|
|
144
|
+
private pollInFlight = false
|
|
145
|
+
private stopped = false
|
|
146
|
+
readonly counters: TurnEndAskCounters = { armed: 0, shown: 0, clearedByReply: 0, clearedByTurn: 0, agedOut: 0 }
|
|
147
|
+
|
|
148
|
+
constructor(deps: TurnEndAskTrackerDeps) {
|
|
149
|
+
this.deps = deps
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One applied hook event (the signal store's subscriber: the raw envelope live, the ledger's projection
|
|
154
|
+
* on replay). `signal` is the store's signal after the event; only its registry `entrypoint` is read.
|
|
155
|
+
*/
|
|
156
|
+
observe(env: HookEnvelope, child: boolean, signal?: { entrypoint: string | null }): void {
|
|
157
|
+
if (!this.deps.enabled()) return
|
|
158
|
+
const p = env.payload
|
|
159
|
+
// A sub-agent's events are never the session asking you, and never its reply.
|
|
160
|
+
if (str(p.agent_id)) return
|
|
161
|
+
const sessionId = env.sessionId.toLowerCase()
|
|
162
|
+
const current = this.asks.get(sessionId)
|
|
163
|
+
// Any prompt, from the desk or a COS-spawned Continue child, is a reply: the question is answered.
|
|
164
|
+
if (env.event === 'UserPromptSubmit') {
|
|
165
|
+
if (current) { this.asks.delete(sessionId); this.counters.clearedByReply++ }
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
// A COS-spawned child's other events are not the session's own turn.
|
|
169
|
+
if (child) return
|
|
170
|
+
if (SESSION_RESETS.has(env.event) || (env.event === 'SessionStart' && p.source !== 'compact')) {
|
|
171
|
+
if (current) { this.asks.delete(sessionId); this.counters.clearedByTurn++ }
|
|
172
|
+
this.lastStop.delete(sessionId)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (env.event === 'Stop') {
|
|
176
|
+
const promptId = str(p.prompt_id)
|
|
177
|
+
this.rememberStop(sessionId, env.ts, promptId)
|
|
178
|
+
if (current && current.stoppedAt === null && env.ts >= current.armedAt && (current.promptId === null || promptId === null || current.promptId === promptId)) {
|
|
179
|
+
current.stoppedAt = env.ts
|
|
180
|
+
this.ensurePolling()
|
|
181
|
+
}
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const toolName = typeof p.tool_name === 'string' ? p.tool_name : ''
|
|
185
|
+
if (env.event === 'PostToolUse' && toolName === WRAP_UP_TOOL) {
|
|
186
|
+
// A print run never asks anyone (the broker's own rule: registry entrypoint sdk-cli).
|
|
187
|
+
if (signal?.entrypoint === 'sdk-cli') return
|
|
188
|
+
const capture = p.turn_ask !== undefined ? projectedTurnAsk(p.turn_ask) : captureTurnAsk(p.tool_input, this.deps.words())
|
|
189
|
+
const promptId = str(p.prompt_id)
|
|
190
|
+
if (!capture) {
|
|
191
|
+
// A later wrap_up of the same turn without a qualifying ask disarms the earlier one.
|
|
192
|
+
if (current && current.stoppedAt === null) this.asks.delete(sessionId)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
const stop = this.lastStop.get(sessionId)
|
|
196
|
+
const stoppedAlready = !!stop && env.ts <= stop.at && (stop.promptId === null || promptId === null || stop.promptId === promptId)
|
|
197
|
+
this.asks.set(sessionId, { sessionId, promptId, capture, armedAt: env.ts, stoppedAt: stoppedAlready ? stop!.at : null, visibleAt: null })
|
|
198
|
+
this.counters.armed++
|
|
199
|
+
this.prune()
|
|
200
|
+
if (stoppedAlready) this.ensurePolling()
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
// Any other tool step after the stop, from another prompt, is a new turn running: the ask is moot.
|
|
204
|
+
if (current && current.stoppedAt !== null && env.ts > current.stoppedAt && (env.event === 'PreToolUse' || env.event === 'PostToolUse' || env.event === 'PostToolUseFailure' || env.event === 'PermissionRequest')) {
|
|
205
|
+
const promptId = str(p.prompt_id)
|
|
206
|
+
if (promptId === null || current.promptId === null || promptId !== current.promptId) {
|
|
207
|
+
this.asks.delete(sessionId)
|
|
208
|
+
this.counters.clearedByTurn++
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private rememberStop(sessionId: string, at: number, promptId: string | null): void {
|
|
214
|
+
this.lastStop.delete(sessionId)
|
|
215
|
+
this.lastStop.set(sessionId, { at, promptId })
|
|
216
|
+
if (this.lastStop.size > MAX_TURN_ASKS) {
|
|
217
|
+
const oldest = this.lastStop.keys().next().value
|
|
218
|
+
if (oldest !== undefined) this.lastStop.delete(oldest)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The ask a row shows now: stopped, latched visible, not older than the age-out. */
|
|
223
|
+
visibleAsk(sessionId: string, now = this.deps.now()): VisibleTurnAsk | null {
|
|
224
|
+
if (!this.deps.enabled()) return null
|
|
225
|
+
const key = sessionId.toLowerCase()
|
|
226
|
+
const state = this.asks.get(key) ?? (key.length < 36 ? [...this.asks.values()].find(s => s.sessionId.startsWith(key)) : undefined)
|
|
227
|
+
if (!state || state.stoppedAt === null) return null
|
|
228
|
+
if (now - state.stoppedAt > TURN_ASK_MAX_AGE_MS) {
|
|
229
|
+
this.asks.delete(state.sessionId)
|
|
230
|
+
this.counters.agedOut++
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
if (state.visibleAt === null) return null
|
|
234
|
+
return { ask: state.capture.ask, choices: [...state.capture.choices], stoppedAt: state.stoppedAt }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The session's row ended: nothing it asked can still be answered. */
|
|
238
|
+
sessionRowEnded(sessionId: string): void {
|
|
239
|
+
const key = sessionId.toLowerCase()
|
|
240
|
+
for (const state of [...this.asks.values()]) {
|
|
241
|
+
if (state.sessionId === key || (key.length < 36 && state.sessionId.startsWith(key))) { this.asks.delete(state.sessionId); this.counters.clearedByTurn++ }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private prune(): void {
|
|
246
|
+
const now = this.deps.now()
|
|
247
|
+
for (const state of [...this.asks.values()]) {
|
|
248
|
+
if (state.stoppedAt !== null && now - state.stoppedAt > TURN_ASK_MAX_AGE_MS) { this.asks.delete(state.sessionId); this.counters.agedOut++ }
|
|
249
|
+
}
|
|
250
|
+
while (this.asks.size > MAX_TURN_ASKS) {
|
|
251
|
+
const oldest = [...this.asks.values()].sort((a, b) => a.armedAt - b.armedAt)[0]
|
|
252
|
+
if (!oldest) break
|
|
253
|
+
this.asks.delete(oldest.sessionId)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private waitingForLatch(): boolean {
|
|
258
|
+
for (const state of this.asks.values()) if (state.stoppedAt !== null && state.visibleAt === null) return true
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private ensurePolling(): void {
|
|
263
|
+
if (this.poll || this.stopped || !this.waitingForLatch()) return
|
|
264
|
+
this.schedule(this.deps.pollMs ?? TURN_ASK_DESK_POLL_MS)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private schedule(delayMs: number): void {
|
|
268
|
+
this.stopPolling()
|
|
269
|
+
if (this.stopped) return
|
|
270
|
+
this.poll = setTimeout(() => {
|
|
271
|
+
this.poll = null
|
|
272
|
+
void this.tick().then(next => { if (next !== null) this.schedule(next) })
|
|
273
|
+
}, delayMs)
|
|
274
|
+
this.poll.unref?.()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private stopPolling(): void {
|
|
278
|
+
if (this.poll) clearTimeout(this.poll)
|
|
279
|
+
this.poll = null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private thresholdS(): number {
|
|
283
|
+
const configured = Number(this.deps.deskIdleSeconds())
|
|
284
|
+
return Math.max(TURN_ASK_DESK_IDLE_MIN_S, Number.isFinite(configured) ? configured : TURN_ASK_DESK_IDLE_MIN_S)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* One desk read while an ask waits: idle at or past the threshold latches every stopped ask visible.
|
|
289
|
+
* Returns when to read again (ms), or null when nothing waits. The desk cannot reach the threshold
|
|
290
|
+
* sooner than (threshold - idle) seconds, so a busy desk is read about once per threshold, not every
|
|
291
|
+
* poll: an unanswered ask on a Mac in use costs one `ioreg` a minute or so, never one every 3 s.
|
|
292
|
+
*/
|
|
293
|
+
async tick(): Promise<number | null> {
|
|
294
|
+
this.prune()
|
|
295
|
+
if (!this.waitingForLatch()) return null
|
|
296
|
+
const pollMs = this.deps.pollMs ?? TURN_ASK_DESK_POLL_MS
|
|
297
|
+
if (this.pollInFlight) return pollMs
|
|
298
|
+
this.pollInFlight = true
|
|
299
|
+
let idle: number | null = null
|
|
300
|
+
try { idle = await this.deps.readDeskIdleSeconds() } catch { idle = null } finally { this.pollInFlight = false }
|
|
301
|
+
// Unreadable: nothing is shown (it never guesses the desk is empty); read again later.
|
|
302
|
+
if (idle === null || !Number.isFinite(idle)) return Math.max(pollMs, TURN_ASK_UNREADABLE_RETRY_MS)
|
|
303
|
+
const threshold = this.thresholdS()
|
|
304
|
+
if (idle < threshold) return Math.max(pollMs, Math.ceil((threshold - idle) * 1000))
|
|
305
|
+
const now = this.deps.now()
|
|
306
|
+
for (const state of this.asks.values()) {
|
|
307
|
+
if (state.stoppedAt !== null && state.visibleAt === null) { state.visibleAt = now; this.counters.shown++ }
|
|
308
|
+
}
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
stats(): TurnEndAskCounters & { tracked: number } {
|
|
313
|
+
return { ...this.counters, tracked: this.asks.size }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
stop(): void {
|
|
317
|
+
this.stopped = true
|
|
318
|
+
this.stopPolling()
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// message that landed in the wrong session does not verify there. An unknown provider keeps the
|
|
12
12
|
// `<claude|codex|cursor>` placeholder rather than naming a wrong one.
|
|
13
13
|
//
|
|
14
|
-
// The COS-WEB tag is
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// The COS-WEB tag is for a web turn signed by an enrolled phone key the API token cannot produce
|
|
15
|
+
// (6.99.31, behind COS_WEB_KEY_AUTHORITY, default off: lib/web-key-authority.ts). Off, as in 6.99.29,
|
|
16
|
+
// no turn is footed COS-WEB: a web turn is recorded, never vouched. The readers take either tag off.
|
|
17
17
|
//
|
|
18
18
|
// The verifier (MU-Chief-Staff operations/scripts/verify_g2_turn.py) mirrors G2_TURN_FOOTER_RE.
|
|
19
19
|
//
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Served-asset integrity for COS Control Web (6.99.31, plan §4.8 / Phase 6). OFF by default:
|
|
2
|
+
// on with COS_WEB_CONTROL_INTEGRITY=1, and by default whenever COS_WEB_KEY_AUTHORITY=1 (signing
|
|
3
|
+
// is worthless if the page that holds the key can be rewritten on disk); COS_WEB_CONTROL_INTEGRITY=0
|
|
4
|
+
// turns it off even then. Read once at boot.
|
|
5
|
+
//
|
|
6
|
+
// WHY. The managed generation is user-writable (plan canary C7), and the static layer re-read the
|
|
7
|
+
// tree every second (C8), so any process running as Miles could rewrite app.js and have the phone
|
|
8
|
+
// run it within a second. With integrity on:
|
|
9
|
+
// - the pack step writes `web-control/.manifest.json` (sha256 of every shipped file; the script is
|
|
10
|
+
// server/scripts/write-web-control-manifest.mjs, mirrored by computeWebControlManifest below)
|
|
11
|
+
// - at boot the manifest is read ONCE and kept in memory; every file is read once, hashed and
|
|
12
|
+
// compared; only bytes that matched are ever served, from memory (the 1 s disk re-read is off)
|
|
13
|
+
// - nothing outside the manifest is served (express.static is not mounted behind it)
|
|
14
|
+
// - the tree is re-checked every `recheckMs` (60 s); a mismatch flips /control to a plain
|
|
15
|
+
// integrity-error page (503) and logs which files; a later passing check serves again
|
|
16
|
+
//
|
|
17
|
+
// WHAT IT CANNOT DO. The manifest sits in the same writable directory: an edit of both an asset
|
|
18
|
+
// and the manifest BEFORE a boot passes here. Only COS Control (signed, App Management protected)
|
|
19
|
+
// holding the manifest hash itself catches that (plan §4.8 Control audit, Phase 2). `manifestSha256`
|
|
20
|
+
// is reported for that audit. A process that patches the server code and restarts it is out of reach.
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { readSwitch } from './env-switch.js'
|
|
26
|
+
|
|
27
|
+
export const WEB_CONTROL_MANIFEST_FILE = '.manifest.json'
|
|
28
|
+
export const WEB_CONTROL_INTEGRITY_RECHECK_MS = 60_000
|
|
29
|
+
|
|
30
|
+
export function webControlIntegrityEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
31
|
+
return readSwitch(env.COS_WEB_CONTROL_INTEGRITY, readSwitch(env.COS_WEB_KEY_AUTHORITY, false))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WebControlManifest { v: 1; algorithm: 'sha256'; files: Record<string, string> }
|
|
35
|
+
|
|
36
|
+
/** The files the manifest covers: every regular, non-dot file, except tests (never shipped). */
|
|
37
|
+
export function manifestFiles(dir: string, base = ''): Array<{ urlPath: string; file: string }> {
|
|
38
|
+
const out: Array<{ urlPath: string; file: string }> = []
|
|
39
|
+
let names: string[]
|
|
40
|
+
try { names = readdirSync(dir) } catch { return out }
|
|
41
|
+
for (const name of names.sort()) {
|
|
42
|
+
if (name.startsWith('.')) continue
|
|
43
|
+
const file = join(dir, name)
|
|
44
|
+
let st
|
|
45
|
+
try { st = statSync(file) } catch { continue }
|
|
46
|
+
const urlPath = `${base}/${name}`
|
|
47
|
+
if (st.isDirectory()) out.push(...manifestFiles(file, urlPath))
|
|
48
|
+
else if (st.isFile() && !name.endsWith('.test.ts')) out.push({ urlPath, file })
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What the pack step writes (and the .mjs script must produce byte for byte). */
|
|
54
|
+
export function computeWebControlManifest(dir: string): WebControlManifest {
|
|
55
|
+
const files: Record<string, string> = {}
|
|
56
|
+
for (const f of manifestFiles(dir)) files[f.urlPath] = createHash('sha256').update(readFileSync(f.file)).digest('hex')
|
|
57
|
+
return { v: 1, algorithm: 'sha256', files }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type IntegrityMismatch = { path: string; problem: 'changed' | 'missing' | 'unexpected' | 'unreadable' }
|
|
61
|
+
|
|
62
|
+
export interface IntegrityState {
|
|
63
|
+
ok: boolean
|
|
64
|
+
/** Why it failed, when it did: a manifest problem or the files that differ. */
|
|
65
|
+
error: 'manifest_missing' | 'manifest_invalid' | 'files_mismatch' | null
|
|
66
|
+
mismatches: IntegrityMismatch[]
|
|
67
|
+
checkedAt: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class WebControlIntegrity {
|
|
71
|
+
private manifest: WebControlManifest | null = null
|
|
72
|
+
private manifestError: 'manifest_missing' | 'manifest_invalid' | null = null
|
|
73
|
+
readonly manifestSha256: string | null
|
|
74
|
+
private snapshotBytes: Map<string, Buffer> | null = null
|
|
75
|
+
/** Bumps each time a passing check follows a failed one (or the first pass): the static layer rebuilds then. */
|
|
76
|
+
generation = 0
|
|
77
|
+
state: IntegrityState
|
|
78
|
+
|
|
79
|
+
constructor(readonly dir: string, private readonly opts: { now?: () => number; log?: (line: string) => void } = {}) {
|
|
80
|
+
let raw: Buffer | null = null
|
|
81
|
+
try { raw = readFileSync(join(dir, WEB_CONTROL_MANIFEST_FILE)) } catch { this.manifestError = 'manifest_missing' }
|
|
82
|
+
this.manifestSha256 = raw ? createHash('sha256').update(raw).digest('hex') : null
|
|
83
|
+
if (raw) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(raw.toString('utf8')) as WebControlManifest
|
|
86
|
+
const files = parsed?.files
|
|
87
|
+
if (parsed?.v === 1 && parsed.algorithm === 'sha256' && files && typeof files === 'object'
|
|
88
|
+
&& Object.entries(files).every(([k, v]) => k.startsWith('/') && !k.includes('..') && typeof v === 'string' && /^[0-9a-f]{64}$/.test(v))) {
|
|
89
|
+
this.manifest = { v: 1, algorithm: 'sha256', files: { ...files } }
|
|
90
|
+
} else this.manifestError = 'manifest_invalid'
|
|
91
|
+
} catch { this.manifestError = 'manifest_invalid' }
|
|
92
|
+
}
|
|
93
|
+
this.state = { ok: false, error: this.manifestError ?? 'files_mismatch', mismatches: [], checkedAt: 0 }
|
|
94
|
+
this.check()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private now(): number { return (this.opts.now ?? Date.now)() }
|
|
98
|
+
private log(line: string): void { (this.opts.log ?? ((l: string) => console.error(l)))(line) }
|
|
99
|
+
|
|
100
|
+
/** The verified bytes (urlPath to bytes), or null while failing. Never re-read from disk by a server. */
|
|
101
|
+
snapshot(): Map<string, Buffer> | null {
|
|
102
|
+
return this.state.ok ? this.snapshotBytes : null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read every file once, hash it, compare with the boot manifest. The snapshot is built from the
|
|
107
|
+
* very bytes that were hashed, so nothing read after the comparison is ever served.
|
|
108
|
+
*/
|
|
109
|
+
check(): IntegrityState {
|
|
110
|
+
const wasOk = this.state.ok
|
|
111
|
+
const first = this.state.checkedAt === 0
|
|
112
|
+
const at = this.now()
|
|
113
|
+
if (!this.manifest) {
|
|
114
|
+
this.state = { ok: false, error: this.manifestError ?? 'manifest_missing', mismatches: [], checkedAt: at }
|
|
115
|
+
if (first) this.log(`[web-control-integrity] ${this.state.error}: /control is refused`)
|
|
116
|
+
this.snapshotBytes = null
|
|
117
|
+
return this.state
|
|
118
|
+
}
|
|
119
|
+
const expected = this.manifest.files
|
|
120
|
+
const seen = new Set<string>()
|
|
121
|
+
const bytes = new Map<string, Buffer>()
|
|
122
|
+
const mismatches: IntegrityMismatch[] = []
|
|
123
|
+
for (const f of manifestFiles(this.dir)) {
|
|
124
|
+
seen.add(f.urlPath)
|
|
125
|
+
const want = expected[f.urlPath]
|
|
126
|
+
if (!want) { mismatches.push({ path: f.urlPath, problem: 'unexpected' }); continue }
|
|
127
|
+
let body: Buffer
|
|
128
|
+
try { body = readFileSync(f.file) } catch { mismatches.push({ path: f.urlPath, problem: 'unreadable' }); continue }
|
|
129
|
+
if (createHash('sha256').update(body).digest('hex') !== want) { mismatches.push({ path: f.urlPath, problem: 'changed' }); continue }
|
|
130
|
+
bytes.set(f.urlPath, body)
|
|
131
|
+
}
|
|
132
|
+
for (const p of Object.keys(expected)) if (!seen.has(p)) mismatches.push({ path: p, problem: 'missing' })
|
|
133
|
+
const ok = mismatches.length === 0
|
|
134
|
+
this.state = { ok, error: ok ? null : 'files_mismatch', mismatches, checkedAt: at }
|
|
135
|
+
if (ok) {
|
|
136
|
+
if (!wasOk) { this.snapshotBytes = bytes; this.generation += 1 }
|
|
137
|
+
} else {
|
|
138
|
+
this.snapshotBytes = null
|
|
139
|
+
const list = mismatches.slice(0, 20).map(m => `${m.problem}:${m.path}`).join(', ')
|
|
140
|
+
this.log(`[web-control-integrity] ${mismatches.length} file(s) do not match the manifest; /control is refused: ${list}`)
|
|
141
|
+
}
|
|
142
|
+
if (ok && !wasOk && this.generation > 1) this.log('[web-control-integrity] files match the manifest again; /control is served')
|
|
143
|
+
return this.state
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
status(): Record<string, unknown> {
|
|
147
|
+
return {
|
|
148
|
+
enabled: true,
|
|
149
|
+
ok: this.state.ok,
|
|
150
|
+
error: this.state.error,
|
|
151
|
+
mismatches: this.state.mismatches.slice(0, 50),
|
|
152
|
+
checkedAt: this.state.checkedAt ? new Date(this.state.checkedAt).toISOString() : null,
|
|
153
|
+
manifestSha256: this.manifestSha256,
|
|
154
|
+
files: this.manifest ? Object.keys(this.manifest.files).length : 0,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The page served instead of /control while integrity fails. Plain, no script, no references. */
|
|
160
|
+
export const INTEGRITY_ERROR_PAGE = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>COS Control Web: integrity check failed</title></head><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem;line-height:1.5"><h1 style="font-size:1.25rem">COS Control Web is paused</h1><p>The files this Mac would send your phone do not match the files that shipped with this version, so they are not being served. Nothing was signed.</p><p>Open COS Control on the Mac to repair or reinstall the server.</p></body></html>'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W1 (audit 02 F1): what a browser sees when it opens /control from outside Tailscale (home Wi-Fi, a coffee shop).
|
|
3
|
+
*
|
|
4
|
+
* The answer stays 403. Only the body changes: a page that says why and what to do, in place of one line of plain text.
|
|
5
|
+
* It is fixed text with no script, no link, no name, no address and no version, so it tells a stranger on the same network
|
|
6
|
+
* nothing the old text did not ("available on this Mac and over Tailscale"). It never offers a LAN way in: COS Control Web
|
|
7
|
+
* is Tailscale only unless the owner sets COS_WEB_CONTROL_LAN=1 on the Mac (lib/web-session-auth.ts isWebControlSocketAllowed).
|
|
8
|
+
*
|
|
9
|
+
* A request that does not ask for HTML (an asset, a fetch) keeps the plain text line, so nothing that parses it changes.
|
|
10
|
+
*/
|
|
11
|
+
export const WEB_CONTROL_OFFNET_TEXT = 'COS Control Web is available on this Mac and over Tailscale.'
|
|
12
|
+
|
|
13
|
+
/** Its own policy: no script, no outside request, inline style only. Replaces nothing for pages that pass the socket check. */
|
|
14
|
+
export const WEB_CONTROL_OFFNET_CSP = "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
15
|
+
|
|
16
|
+
export const WEB_CONTROL_OFFNET_HTML = `<!doctype html>
|
|
17
|
+
<html lang="en">
|
|
18
|
+
<head>
|
|
19
|
+
<meta charset="utf-8">
|
|
20
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
21
|
+
<meta name="referrer" content="no-referrer">
|
|
22
|
+
<meta name="robots" content="noindex">
|
|
23
|
+
<title>Open COS over Tailscale</title>
|
|
24
|
+
<style>
|
|
25
|
+
:root { color-scheme: light dark; --bg: #f6f5f2; --ink: #1d1c1a; --sub: #5d5a54; --card: #ffffff; --line: #e3e0da; }
|
|
26
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #151514; --ink: #f1efea; --sub: #a9a59d; --card: #1f1e1c; --line: #34322e; } }
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
29
|
+
main { max-width: 520px; margin: 0 auto; padding: max(28px, env(safe-area-inset-top)) 16px 40px; }
|
|
30
|
+
h1 { font-size: 24px; line-height: 1.25; margin: 0 0 12px; }
|
|
31
|
+
p { margin: 0 0 14px; color: var(--sub); }
|
|
32
|
+
ol { margin: 0 0 18px; padding: 16px 16px 16px 36px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; }
|
|
33
|
+
li { margin: 0 0 10px; }
|
|
34
|
+
li:last-child { margin-bottom: 0; }
|
|
35
|
+
strong { color: var(--ink); }
|
|
36
|
+
</style>
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
<main>
|
|
40
|
+
<h1>Open COS over Tailscale</h1>
|
|
41
|
+
<p>This is COS Control Web. It only opens over Tailscale, so it stays hidden from everyone else on this network.</p>
|
|
42
|
+
<ol>
|
|
43
|
+
<li>Install <strong>Tailscale</strong> on this phone or computer, and sign in with the same account your Mac uses.</li>
|
|
44
|
+
<li>Turn Tailscale on.</li>
|
|
45
|
+
<li>Open the COS link again. To get a new one, open COS Control Settings on your Mac and choose <strong>Open COS on your phone</strong>.</li>
|
|
46
|
+
</ol>
|
|
47
|
+
<p>Nothing was sent and you are not signed in.</p>
|
|
48
|
+
</main>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
|
51
|
+
`
|
|
52
|
+
|
|
53
|
+
/** The 403 body for a request outside Tailscale: the page for a browser, the old line for anything else. */
|
|
54
|
+
export function webControlOffnetResponse(acceptsHtml: boolean): { type: 'html' | 'text'; body: string; csp: string | null } {
|
|
55
|
+
return acceptsHtml
|
|
56
|
+
? { type: 'html', body: WEB_CONTROL_OFFNET_HTML, csp: WEB_CONTROL_OFFNET_CSP }
|
|
57
|
+
: { type: 'text', body: WEB_CONTROL_OFFNET_TEXT, csp: null }
|
|
58
|
+
}
|
|
@@ -25,6 +25,7 @@ import fs from 'fs'
|
|
|
25
25
|
import path from 'path'
|
|
26
26
|
import zlib from 'zlib'
|
|
27
27
|
import type { NextFunction, Request, RequestHandler, Response } from 'express'
|
|
28
|
+
import { INTEGRITY_ERROR_PAGE, WEB_CONTROL_INTEGRITY_RECHECK_MS, type WebControlIntegrity } from './web-control-integrity.js'
|
|
28
29
|
|
|
29
30
|
/** A versioned URL's lifetime. Safe because the hash changes whenever the bytes do. */
|
|
30
31
|
export const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
|
|
@@ -114,11 +115,18 @@ export function rewriteReferences(text: string, mount: string, hashes: Map<strin
|
|
|
114
115
|
* them and hashed, then HTML is rewritten against everything. So a font change gives its CSS a
|
|
115
116
|
* new hash, and that gives index.html new references.
|
|
116
117
|
*/
|
|
117
|
-
export function buildWebAssetTable(dir: string, mount = '/control'): WebAssetTable {
|
|
118
|
-
|
|
118
|
+
export function buildWebAssetTable(dir: string, mount = '/control', source?: ReadonlyMap<string, Buffer>): WebAssetTable {
|
|
119
|
+
// 6.99.31: with `source` (integrity mode), the table is built ONLY from those verified bytes; the
|
|
120
|
+
// disk is not read at all.
|
|
121
|
+
const files = source
|
|
122
|
+
? [...source.keys()].sort().filter(urlPath => TYPES[path.extname(urlPath).toLowerCase()] && !urlPath.split('/').some(part => part.startsWith('.'))).map(urlPath => ({ urlPath, file: urlPath }))
|
|
123
|
+
: walk(dir)
|
|
119
124
|
const hashes = new Map<string, string>()
|
|
120
125
|
const table: WebAssetTable = new Map()
|
|
121
|
-
const read = (file: string): Buffer | null => {
|
|
126
|
+
const read = (file: string): Buffer | null => {
|
|
127
|
+
if (source) return source.get(file) ?? null
|
|
128
|
+
try { return fs.readFileSync(file) } catch { return null }
|
|
129
|
+
}
|
|
122
130
|
const add = (urlPath: string, ext: string, body: Buffer): void => {
|
|
123
131
|
const hash = sha(body)
|
|
124
132
|
hashes.set(urlPath, hash)
|
|
@@ -168,20 +176,45 @@ function compressed(asset: WebAsset, encoding: 'br' | 'gzip'): Promise<Buffer> {
|
|
|
168
176
|
|
|
169
177
|
export interface WebControlStaticOptions {
|
|
170
178
|
mount?: string
|
|
171
|
-
/** How often the tree is re-checked for edits. 0 checks on every request. */
|
|
179
|
+
/** How often the tree is re-checked for edits. 0 checks on every request. Ignored in integrity mode. */
|
|
172
180
|
recheckMs?: number
|
|
173
181
|
now?: () => number
|
|
182
|
+
/**
|
|
183
|
+
* 6.99.31 (Phase 6): serve only bytes that matched the pack-time manifest, from memory. The disk is
|
|
184
|
+
* never re-read per request; the integrity check re-runs every `integrityRecheckMs` (default 60 s,
|
|
185
|
+
* 0: never on a timer, tests call `integrity.check()`). While it fails, every GET or HEAD under the
|
|
186
|
+
* mount answers the plain integrity-error page (503), and nothing falls through to a disk server.
|
|
187
|
+
*/
|
|
188
|
+
integrity?: WebControlIntegrity
|
|
189
|
+
integrityRecheckMs?: number
|
|
174
190
|
}
|
|
175
191
|
|
|
176
|
-
export function createWebControlStatic(dir: string, opts: WebControlStaticOptions = {}): RequestHandler & { table: () => WebAssetTable } {
|
|
192
|
+
export function createWebControlStatic(dir: string, opts: WebControlStaticOptions = {}): RequestHandler & { table: () => WebAssetTable; stop: () => void } {
|
|
177
193
|
const mount = opts.mount ?? '/control'
|
|
178
194
|
const recheckMs = opts.recheckMs ?? 1000
|
|
179
195
|
const now = opts.now ?? Date.now
|
|
180
|
-
|
|
181
|
-
let
|
|
196
|
+
const integrity = opts.integrity ?? null
|
|
197
|
+
let signature = integrity ? '' : treeSignature(dir)
|
|
198
|
+
let table: WebAssetTable = integrity ? new Map() : buildWebAssetTable(dir, mount)
|
|
199
|
+
let builtFrom = -1
|
|
182
200
|
let checkedAt = now()
|
|
201
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
202
|
+
if (integrity) {
|
|
203
|
+
const every = opts.integrityRecheckMs ?? WEB_CONTROL_INTEGRITY_RECHECK_MS
|
|
204
|
+
if (every > 0) { timer = setInterval(() => { try { integrity.check() } catch { /* the next tick retries */ } }, every); timer.unref?.() }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Integrity mode: the table of verified bytes, or null while the check fails. */
|
|
208
|
+
const verified = (): WebAssetTable | null => {
|
|
209
|
+
if (!integrity) return null
|
|
210
|
+
const snap = integrity.snapshot()
|
|
211
|
+
if (!snap) return null
|
|
212
|
+
if (integrity.generation !== builtFrom) { table = buildWebAssetTable(dir, mount, snap); builtFrom = integrity.generation }
|
|
213
|
+
return table
|
|
214
|
+
}
|
|
183
215
|
|
|
184
216
|
const current = (): WebAssetTable => {
|
|
217
|
+
if (integrity) return verified() ?? new Map()
|
|
185
218
|
if (now() - checkedAt >= recheckMs) {
|
|
186
219
|
checkedAt = now()
|
|
187
220
|
const next = treeSignature(dir)
|
|
@@ -194,8 +227,19 @@ export function createWebControlStatic(dir: string, opts: WebControlStaticOption
|
|
|
194
227
|
if (req.method !== 'GET' && req.method !== 'HEAD') { next(); return }
|
|
195
228
|
let urlPath = req.path || '/'
|
|
196
229
|
if (urlPath.endsWith('/')) urlPath += 'index.html'
|
|
230
|
+
if (integrity && verified() === null) {
|
|
231
|
+
res.status(503)
|
|
232
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
233
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
234
|
+
if (req.method === 'HEAD') res.end(); else res.end(INTEGRITY_ERROR_PAGE)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
197
237
|
const asset = current().get(urlPath)
|
|
198
|
-
if (!asset) {
|
|
238
|
+
if (!asset) {
|
|
239
|
+
// Integrity mode serves nothing it did not verify: no fall-through to a disk server.
|
|
240
|
+
if (integrity) { res.status(404).type('text').send('Not found'); return }
|
|
241
|
+
next(); return
|
|
242
|
+
}
|
|
199
243
|
|
|
200
244
|
const versioned = VERSIONABLE.has(asset.ext) && req.query.v === asset.hash
|
|
201
245
|
const encoding = COMPRESSIBLE.has(asset.ext) ? pickEncoding(req.headers['accept-encoding'] as string | undefined) : null
|
|
@@ -222,5 +266,5 @@ export function createWebControlStatic(dir: string, opts: WebControlStaticOption
|
|
|
222
266
|
if (req.method === 'HEAD') res.end(); else res.end(asset.body)
|
|
223
267
|
})
|
|
224
268
|
}
|
|
225
|
-
return Object.assign(handler, { table: current })
|
|
269
|
+
return Object.assign(handler, { table: current, stop: () => { if (timer) clearInterval(timer); timer = null } })
|
|
226
270
|
}
|