@gotcos/glasses-server 6.98.1 → 6.99.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +170 -11
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/cos-thread.ts +582 -0
  8. package/server/lib/display-bus.ts +3 -0
  9. package/server/lib/drop-attachments.ts +55 -0
  10. package/server/lib/drop-destinations.ts +134 -0
  11. package/server/lib/drop-history-search.ts +97 -0
  12. package/server/lib/drop-p2-boot.ts +60 -0
  13. package/server/lib/drop-p2-runtime.ts +237 -0
  14. package/server/lib/drop-p2-settings.ts +174 -0
  15. package/server/lib/drop-resolution.ts +168 -0
  16. package/server/lib/drop-router.ts +325 -0
  17. package/server/lib/drop-service.ts +830 -0
  18. package/server/lib/drop-settings.ts +56 -0
  19. package/server/lib/drop-store.ts +450 -0
  20. package/server/lib/drop-when.ts +265 -0
  21. package/server/lib/glasses-pairing.ts +15 -0
  22. package/server/lib/learning-needs-you.ts +111 -0
  23. package/server/lib/live-cues-v2-settings.ts +2 -1
  24. package/server/lib/live-cues-v2.ts +6 -4
  25. package/server/lib/media-store.ts +80 -0
  26. package/server/lib/meeting-batch-transcribe.ts +15 -2
  27. package/server/lib/meeting-consolidation-run.ts +169 -0
  28. package/server/lib/meeting-store.ts +61 -0
  29. package/server/lib/meeting-title-steer.ts +200 -0
  30. package/server/lib/network-policy.ts +84 -6
  31. package/server/lib/permission-broker.ts +13 -0
  32. package/server/lib/phone-recording.ts +1 -1
  33. package/server/lib/ping-budget.ts +149 -0
  34. package/server/lib/ping-engine.ts +245 -0
  35. package/server/lib/ping-feed-sink.ts +33 -0
  36. package/server/lib/ping-sources.ts +192 -0
  37. package/server/lib/query-job-coordinator.ts +5 -2
  38. package/server/lib/query-job-runtime.ts +45 -4
  39. package/server/lib/query-job-types.ts +10 -1
  40. package/server/lib/reminder-scheduler.ts +153 -0
  41. package/server/lib/tailnet-self-name.ts +103 -0
  42. package/server/lib/tailscale-nudge.ts +40 -2
  43. package/server/lib/task-store.ts +32 -0
  44. package/server/lib/web-ask-admission.ts +56 -0
  45. package/server/lib/web-live-chunks.ts +273 -0
  46. package/server/lib/web-memories-bridge.ts +323 -0
  47. package/server/lib/web-session-auth.ts +492 -0
  48. package/server/lib/web-usage-bridge.ts +154 -0
  49. package/server/lib/work-search.ts +14 -1
  50. package/server/lib/your-move-count.ts +58 -0
  51. package/server/routes/cos-thread.ts +79 -0
  52. package/server/routes/display.ts +11 -1
  53. package/server/routes/drops.ts +262 -0
  54. package/server/routes/health.ts +16 -0
  55. package/server/routes/live-cues.ts +5 -4
  56. package/server/routes/meeting.ts +61 -5
  57. package/server/routes/permission-broker.ts +23 -3
  58. package/server/routes/transcribe-stream.ts +43 -4
  59. package/server/routes/web-control.ts +569 -0
  60. package/server/routes/welcome-context.ts +2 -1
  61. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  62. package/server/scripts/web-live-cls/measure.mjs +80 -0
  63. package/server/scripts/web-live-cls/mock.mjs +97 -0
  64. package/server/scripts/web-live-cls/render.mjs +26 -0
  65. package/server/web-control/app.css +519 -0
  66. package/server/web-control/app.js +3643 -0
  67. package/server/web-control/control.css +325 -0
  68. package/server/web-control/fonts/DMSans.ttf +0 -0
  69. package/server/web-control/fonts/Fraunces.ttf +0 -0
  70. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  71. package/server/web-control/index.html +275 -0
  72. package/server/web-control/mark.svg +1 -0
  73. package/server/web-control/memories/d3.min.js +2 -0
  74. package/server/web-control/memories/graph-explorer.css +200 -0
  75. package/server/web-control/memories/graph-explorer.js +748 -0
  76. package/server/web-control/memories/memories-app.js +1561 -0
  77. package/server/web-control/memories/memories-theme.css +73 -0
  78. package/server/web-control/memories/memories.html +72 -0
  79. package/server/web-control/memories/memory-stewardship.js +63 -0
  80. package/server/web-control/memories/memory-workspace.js +216 -0
  81. package/server/web-control/panes/cos-chat.css +174 -0
  82. package/server/web-control/panes/cos-chat.js +1118 -0
  83. package/server/web-control/panes/dictation.js +173 -0
  84. package/server/web-control/panes/meetings.css +314 -0
  85. package/server/web-control/panes/meetings.js +2568 -0
  86. package/server/web-control/panes/messages.js +718 -0
  87. package/server/web-control/panes/questions.css +32 -0
  88. package/server/web-control/panes/questions.js +275 -0
  89. package/server/web-control/panes/sessions.css +452 -0
  90. package/server/web-control/panes/settings.js +292 -0
  91. package/server/web-control/panes/speakers.css +269 -0
  92. package/server/web-control/panes/speakers.js +2755 -0
  93. package/server/web-control/panes/threads.js +200 -0
  94. package/server/web-control/panes/work.css +261 -0
  95. package/server/web-control/panes/work.js +1667 -0
  96. package/server/web-control/shell.js +294 -0
  97. package/server/web-control/theme.js +25 -0
  98. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,569 @@
1
+ import express, { Router, type Request, type Response } from 'express'
2
+ import { hostname, networkInterfaces } from 'node:os'
3
+ import {
4
+ hasProxyForwardedHeaders,
5
+ isAllowedNetworkIp,
6
+ isAllowedNetworkOrigin,
7
+ isLoopbackAddress,
8
+ isLoopbackSocket,
9
+ normalizeRemoteIp,
10
+ normalizeTailscaleDnsName,
11
+ reachableIpv4Addresses,
12
+ } from '../lib/network-policy.js'
13
+ import { timingSafeTokenEqual } from '../lib/token-auth.js'
14
+ import { getTailscaleNudger } from '../lib/tailscale-nudge.js'
15
+ import {
16
+ formatClearWebSessionCookie,
17
+ formatSetWebSessionCookie,
18
+ isWebSessionAuthorized,
19
+ mintWebSessionCookieValue,
20
+ classifyWebLogin,
21
+ type WebLoginFailure,
22
+ WebGlassesCodeProbeBudget,
23
+ WEB_SESSION_TTL_MS,
24
+ WebHandoffStore,
25
+ WebLoginLimiter,
26
+ WebDeviceCodeStore,
27
+ bumpWebSessionEpoch,
28
+ isWebControlSocketAllowed,
29
+ } from '../lib/web-session-auth.js'
30
+ import { acquireMaintenanceWork, MaintenanceLifecycleError, maintenanceErrorPayload } from '../lib/maintenance-lifecycle.js'
31
+ import { getOwnerName } from '../lib/profile.js'
32
+ import { readSwitch } from '../lib/env-switch.js'
33
+ import { managedServerVersion } from '../lib/managed-runtime.js'
34
+ import {
35
+ dispatchMemoriesWebOp,
36
+ isMemoryWebOpReadOnly,
37
+ MUTATING_MEMORY_WEB_OPS,
38
+ READ_ONLY_MEMORY_WEB_OPS,
39
+ } from '../lib/web-memories-bridge.js'
40
+ import { listLiveTranscriptSessions, readLiveTranscriptSession } from '../lib/web-live-chunks.js'
41
+ import { getWebUsageSummary } from '../lib/web-usage-bridge.js'
42
+
43
+ export { isMemoryWebOpReadOnly, MUTATING_MEMORY_WEB_OPS, READ_ONLY_MEMORY_WEB_OPS }
44
+
45
+ export interface WebControlRouterDeps {
46
+ apiToken: () => string
47
+ port: () => number
48
+ publicPort?: () => number
49
+ httpsPort?: () => number | null
50
+ handoffStore?: WebHandoffStore
51
+ loginLimiter?: WebLoginLimiter
52
+ deviceCodeStore?: WebDeviceCodeStore
53
+ serverVersion?: () => string
54
+ selfDnsName?: () => string | null
55
+ baseDataDir?: () => string | undefined
56
+ queryTailscaleStatus?: () => Promise<{ backendState: string; selfIps: string[]; selfDnsName?: string | null; certDomains?: string[] } | null>
57
+ /** Read-only: is this normalised code the live glasses pairing code? (PairingState.isLiveCode) */
58
+ glassesCodeIsLive?: (code: string) => boolean
59
+ /** Names the live glasses code (its expiry), or null when none is showing. Scopes the probe budget. */
60
+ liveGlassesCodeKey?: () => string | null
61
+ glassesProbeBudget?: WebGlassesCodeProbeBudget
62
+ /** This Mac's Tailscale IPv4, for the phone link in a handoff. Defaults to the interfaces. */
63
+ tailnetIpv4?: () => string | null
64
+ /** Where sign-in failures are logged. Kind, length and socket IP only, never the value. */
65
+ log?: (line: string) => void
66
+ /** Caps POST /web-session/phone-link (5 a minute). Injected by tests. */
67
+ phoneLinkLimiter?: WebPhoneLinkLimiter
68
+ }
69
+
70
+ /** What the sign-in page shows for each failure. Names the fix, never the value typed. */
71
+ export const WEB_LOGIN_FAILURE_MESSAGE: Record<WebLoginFailure, string> = {
72
+ wrong_token: 'That token did not match. Copy the pairing token again and paste it within a minute, since the copy clears after 60 seconds. Or choose Open COS on your phone in COS Control and scan its QR.',
73
+ wrong_code: 'That code did not match or has expired. A code lasts 5 minutes and works once. Get a new one.',
74
+ glasses_code: 'That is the glasses pairing code. It only pairs the COS Glasses app. To sign in here, choose Open COS on your phone in COS Control, or paste the pairing token.',
75
+ handoff_expired: 'That sign-in link expired or was already used. Get a new one from COS Control on your Mac, or Open COS on the web in the COS Glasses app.',
76
+ missing: 'Enter a sign-in code or paste the pairing token.',
77
+ }
78
+
79
+ const WEB_LOGIN_LOCKOUT_CAUSE: Record<WebLoginFailure, string> = {
80
+ wrong_token: 'The last try was a wrong token.',
81
+ wrong_code: 'The last try was a wrong or expired code.',
82
+ glasses_code: 'The last try was the glasses pairing code, which only works in the COS Glasses app.',
83
+ handoff_expired: 'The last try was an expired sign-in link.',
84
+ missing: 'The last try had no code or token.',
85
+ }
86
+
87
+ /** The 429 text: how long to wait, and what kept failing, so the next try is the right one. */
88
+ export function webLoginLockoutMessage(retryAfterS: number, last: WebLoginFailure | null): string {
89
+ const wait = `Too many failed sign-ins from this device. Try again in ${Math.max(1, Math.ceil(retryAfterS / 60))} min.`
90
+ return last ? `${wait} ${WEB_LOGIN_LOCKOUT_CAUSE[last]}` : wait
91
+ }
92
+
93
+ /** What the CORS gate needs from the Tailscale nudger (the real one, or a fake in tests). */
94
+ export interface SelfDnsSource {
95
+ currentSelfDnsName(): string | null
96
+ queryStatus(forceRefresh?: boolean): Promise<unknown>
97
+ }
98
+
99
+ /**
100
+ * The global CORS origin check. A phone on the tailnet opens
101
+ * `http://<mac>.<tailnet>.ts.net:3141/control` and every POST it sends (sign-in first) carries
102
+ * that Origin. The nudger learns the Mac's own Self.DNSName only on its own tick, and never when
103
+ * COS_TAILSCALE_NUDGE=0, so a `*.ts.net` origin it does not recognise yet triggers one status
104
+ * read (single-flight, cached 15 s) before the verdict. Any other unknown origin is refused
105
+ * without a read. Never a suffix match: only this Mac's exact name passes.
106
+ */
107
+ export async function isAllowedCorsOrigin(origin: string, dns: SelfDnsSource): Promise<boolean> {
108
+ if (isAllowedNetworkOrigin(origin, { selfDnsName: dns.currentSelfDnsName() })) return true
109
+ let host = ''
110
+ try { host = new URL(origin).hostname.toLowerCase().replace(/\.$/, '') } catch { return false }
111
+ if (!host.endsWith('.ts.net')) return false
112
+ try { await dns.queryStatus() } catch { return false }
113
+ return isAllowedNetworkOrigin(origin, { selfDnsName: dns.currentSelfDnsName() })
114
+ }
115
+
116
+ /**
117
+ * Open COS on the web, from the COS Glasses app (phone-link). The companion reads
118
+ * `capabilities.webPhoneLink` on /api/health and shows its row only when this is present,
119
+ * so an older server simply omits it.
120
+ */
121
+ export const WEB_PHONE_LINK_CAPABILITY = 1
122
+ export const WEB_PHONE_LINK_MAX_PER_WINDOW = 5
123
+ export const WEB_PHONE_LINK_WINDOW_MS = 60_000
124
+
125
+ /**
126
+ * At most 5 phone links a minute for the whole server. One person holds the pairing token,
127
+ * so a per-IP split would only let a leaked token mint faster from more addresses.
128
+ */
129
+ export class WebPhoneLinkLimiter {
130
+ private readonly stamps: number[] = []
131
+ constructor(
132
+ private readonly now: () => number = () => Date.now(),
133
+ private readonly max = WEB_PHONE_LINK_MAX_PER_WINDOW,
134
+ private readonly windowMs = WEB_PHONE_LINK_WINDOW_MS,
135
+ ) {}
136
+
137
+ /** Seconds until the next link may be minted; 0 means it was admitted and counted now. */
138
+ take(): number {
139
+ const at = this.now()
140
+ while (this.stamps.length && at - this.stamps[0] >= this.windowMs) this.stamps.shift()
141
+ if (this.stamps.length >= this.max) {
142
+ return Math.max(1, Math.ceil((this.stamps[0] + this.windowMs - at) / 1000))
143
+ }
144
+ this.stamps.push(at)
145
+ return 0
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Who may ask for a phone link: this Mac, a Tailscale peer, or a home-network address,
151
+ * the same places the glasses' token already reaches. Never a request carrying proxy
152
+ * forwarding headers (`tailscale serve`, Funnel, a local proxy), whose real caller is unknown.
153
+ */
154
+ export function isPhoneLinkSocketAllowed(
155
+ remoteAddress: string | undefined | null,
156
+ headers?: Record<string, unknown> | null,
157
+ ): boolean {
158
+ if (hasProxyForwardedHeaders(headers)) return false
159
+ const addr = normalizeRemoteIp(remoteAddress ?? '')
160
+ if (!addr) return false
161
+ return isLoopbackAddress(addr) || isAllowedNetworkIp(addr)
162
+ }
163
+
164
+ export function webControlEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
165
+ return readSwitch(env.COS_WEB_CONTROL_ENABLED, true)
166
+ }
167
+
168
+ /**
169
+ * CSP for `/control/*`. The shell (index.html, app.js, theme.js) has no inline script, so it
170
+ * gets `script-src 'self'`: an injected `<img onerror>` in rendered session or meeting text
171
+ * cannot run. The hosted Memories page is the native bundle as shipped and still uses inline
172
+ * `onclick` handlers, so only `/control/memories/*` keeps `'unsafe-inline'`.
173
+ * `frame-ancestors 'self'` stops another site framing the signed-in page.
174
+ */
175
+ export function webControlCsp(requestPath: string): string {
176
+ const inlineScripts = requestPath.startsWith('/memories/')
177
+ return [
178
+ "default-src 'self'",
179
+ inlineScripts ? "script-src 'self' 'unsafe-inline'" : "script-src 'self'",
180
+ "style-src 'self' 'unsafe-inline'",
181
+ "font-src 'self' data:",
182
+ "img-src 'self' data: blob:",
183
+ "media-src 'self' blob:",
184
+ "connect-src 'self'",
185
+ "frame-src 'self'",
186
+ "frame-ancestors 'self'",
187
+ "base-uri 'none'",
188
+ "form-action 'self'",
189
+ ].join('; ')
190
+ }
191
+
192
+ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
193
+ const router = Router()
194
+ const handoffStore = deps.handoffStore ?? new WebHandoffStore()
195
+ const loginLimiter = deps.loginLimiter ?? new WebLoginLimiter()
196
+ const deviceCodes = deps.deviceCodeStore ?? new WebDeviceCodeStore()
197
+ const smallJson = express.json({ limit: '64kb' })
198
+ const log = deps.log ?? ((line: string) => console.log(line))
199
+ const glassesBudget = deps.glassesProbeBudget ?? new WebGlassesCodeProbeBudget()
200
+ const phoneLinkLimiter = deps.phoneLinkLimiter ?? new WebPhoneLinkLimiter()
201
+ const isLiveGlassesCode = deps.glassesCodeIsLive
202
+ const glassesCheck = isLiveGlassesCode
203
+ ? (code: string): boolean => glassesBudget.check(deps.liveGlassesCodeKey ? deps.liveGlassesCodeKey() : 'live', () => isLiveGlassesCode(code))
204
+ : undefined
205
+
206
+ const allowedPorts = (): number[] => {
207
+ const ports = [deps.port()]
208
+ const pub = deps.publicPort?.()
209
+ if (typeof pub === 'number' && pub > 0 && !ports.includes(pub)) ports.push(pub)
210
+ const hp = deps.httpsPort?.()
211
+ if (typeof hp === 'number' && hp > 0) ports.push(hp)
212
+ return ports
213
+ }
214
+
215
+ const selfDns = (): string | null => {
216
+ if (deps.selfDnsName) return normalizeTailscaleDnsName(deps.selfDnsName())
217
+ return getTailscaleNudger().currentSelfDnsName()
218
+ }
219
+
220
+ /** This Mac's tailnet name (when known) and Tailscale IPv4, for links a phone opens. */
221
+ async function tailnetAddress(): Promise<{ name: string | null; ip: string | null }> {
222
+ let name = selfDns()
223
+ if (!name && deps.queryTailscaleStatus) {
224
+ const status = await deps.queryTailscaleStatus().catch(() => null)
225
+ name = normalizeTailscaleDnsName(status?.selfDnsName ?? null)
226
+ }
227
+ let ip: string | null = null
228
+ try {
229
+ ip = deps.tailnetIpv4
230
+ ? deps.tailnetIpv4()
231
+ : reachableIpv4Addresses(networkInterfaces()).find(a => a.kind === 'tailscale')?.address ?? null
232
+ } catch {
233
+ ip = null
234
+ }
235
+ return { name, ip }
236
+ }
237
+
238
+ async function loopbackFetch(
239
+ path: string,
240
+ init: { method?: string; body?: unknown; timeoutMs?: number } = {},
241
+ ): Promise<{ status: number; body: Record<string, any> }> {
242
+ const controller = new AbortController()
243
+ const timer = setTimeout(() => controller.abort(), init.timeoutMs ?? 25_000)
244
+ timer.unref?.()
245
+ try {
246
+ const res = await fetch(`http://127.0.0.1:${deps.port()}${path}`, {
247
+ method: init.method ?? 'GET',
248
+ headers: {
249
+ 'X-COS-Token': deps.apiToken(),
250
+ ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
251
+ },
252
+ ...(init.body !== undefined ? { body: typeof init.body === 'string' ? init.body : JSON.stringify(init.body) } : {}),
253
+ signal: controller.signal,
254
+ })
255
+ let parsed: any = {}
256
+ try { parsed = await res.json() } catch { parsed = {} }
257
+ if (Array.isArray(parsed)) return { status: res.status, body: { items: parsed } }
258
+ return { status: res.status, body: parsed && typeof parsed === 'object' ? parsed : {} }
259
+ } finally {
260
+ clearTimeout(timer)
261
+ }
262
+ }
263
+
264
+ router.get('/web-session/status', (req: Request, res: Response) => {
265
+ res.set('Cache-Control', 'private, no-store')
266
+ const token = deps.apiToken()
267
+ const cookieOk = isWebSessionAuthorized(req, token, {
268
+ selfDnsName: selfDns(),
269
+ allowedPorts: allowedPorts(),
270
+ })
271
+ const headerOk = timingSafeTokenEqual(req.headers['x-cos-token'], token)
272
+ // Public door: an unauthenticated caller learns only that it is signed out, never the
273
+ // Mac's name or its owner's. `network: 'tailscale_only'` tells the sign-in page why a
274
+ // home-Wi-Fi browser cannot sign in (review W2), so it can say "use Tailscale".
275
+ if (!cookieOk && !headerOk) {
276
+ return res.json(isWebControlSocketAllowed(req.socket?.remoteAddress, process.env, req.headers)
277
+ ? { authenticated: false, mode: 'none' }
278
+ : { authenticated: false, mode: 'none', network: 'tailscale_only' })
279
+ }
280
+ res.json({
281
+ authenticated: true,
282
+ mode: headerOk ? 'header' : 'cookie',
283
+ serverName: hostname().replace(/\.local$/i, ''),
284
+ ownerName: getOwnerName(),
285
+ })
286
+ })
287
+
288
+ router.post('/web-session/login', smallJson, (req: Request, res: Response) => {
289
+ res.set('Cache-Control', 'private, no-store')
290
+ if (!isWebControlSocketAllowed(req.socket?.remoteAddress, process.env, req.headers)) {
291
+ return res.status(403).json({
292
+ ok: false,
293
+ error: 'tailscale_only',
294
+ message: 'Open COS Control Web over Tailscale. Home Wi-Fi is off unless COS_WEB_CONTROL_LAN=1 is set on the Mac.',
295
+ })
296
+ }
297
+ const ip = normalizeRemoteIp(req.socket?.remoteAddress ?? '')
298
+ const retryAfterS = loginLimiter.blockedFor(ip)
299
+ if (retryAfterS > 0) {
300
+ const last = loginLimiter.lastFailure(ip)
301
+ res.setHeader('Retry-After', String(retryAfterS))
302
+ return res.status(429).json({
303
+ ok: false,
304
+ error: 'too_many_attempts',
305
+ ...(last ? { reason: last } : {}),
306
+ message: webLoginLockoutMessage(retryAfterS, last),
307
+ })
308
+ }
309
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
310
+ ? req.body as { token?: unknown; handoff?: unknown; code?: unknown }
311
+ : {}
312
+ const token = deps.apiToken()
313
+ const result = classifyWebLogin(body, token, handoffStore, deviceCodes, glassesCheck)
314
+ if (!result.ok) {
315
+ // Every failure counts, the glasses-code one included, so that check cannot be used to
316
+ // probe codes faster than the limiter allows.
317
+ loginLimiter.fail(ip, result.reason)
318
+ log(`[web-login] fail kind=${result.reason} len=${result.length} ip=${ip}`)
319
+ return res.status(401).json({
320
+ ok: false,
321
+ error: 'invalid_credentials',
322
+ reason: result.reason,
323
+ message: WEB_LOGIN_FAILURE_MESSAGE[result.reason],
324
+ })
325
+ }
326
+ log(`[web-login] ok kind=${result.kind} ip=${ip}`)
327
+ loginLimiter.succeed(ip)
328
+ const minted = mintWebSessionCookieValue(token, Date.now(), WEB_SESSION_TTL_MS)
329
+ res.setHeader('Set-Cookie', formatSetWebSessionCookie(minted.value, WEB_SESSION_TTL_MS / 1000, req.secure))
330
+ return res.json({
331
+ ok: true,
332
+ expiresAt: new Date(minted.expiresAtMs).toISOString(),
333
+ serverName: hostname().replace(/\.local$/i, ''),
334
+ ownerName: getOwnerName(),
335
+ })
336
+ })
337
+
338
+ router.post('/web-session/logout', (req: Request, res: Response) => {
339
+ res.set('Cache-Control', 'private, no-store')
340
+ res.setHeader('Set-Cookie', formatClearWebSessionCookie(req.secure))
341
+ res.json({ ok: true })
342
+ })
343
+
344
+ // Sign out every browser at once (review W1): bumps the session epoch, so every cookie ever
345
+ // issued stops working, copies included. The pairing token, the glasses and the phone app
346
+ // are untouched. Behind the gate, so a cookie caller also needs X-COS-Web and its own Origin.
347
+ router.post('/web-session/revoke-all', (req: Request, res: Response) => {
348
+ res.set('Cache-Control', 'private, no-store')
349
+ const epoch = bumpWebSessionEpoch()
350
+ res.setHeader('Set-Cookie', formatClearWebSessionCookie(req.secure))
351
+ res.json({ ok: true, epoch })
352
+ })
353
+
354
+ // A signed-in browser (or the Mac app) asks for a short code to type on another device.
355
+ // Behind the gate: a cookie caller also needs X-COS-Web and its own Origin (CSRF).
356
+ router.post('/web-session/device-code', (_req: Request, res: Response) => {
357
+ res.set('Cache-Control', 'private, no-store')
358
+ const minted = deviceCodes.mint()
359
+ res.json({ ok: true, code: minted.display, expiresAt: minted.expiresAt })
360
+ })
361
+
362
+ // COS Control mints a ticket here and shows `phoneUrl` as a QR: the phone camera opens Safari
363
+ // on the tailnet address and the page signs in with the ticket (one use, 2 minutes). The
364
+ // cookie belongs to the origin, so Control shows exactly one URL, the name when this Mac
365
+ // knows it. `url` stays the loopback link the Mac's own browser opens.
366
+ router.post('/web-session/handoff', async (req: Request, res: Response) => {
367
+ res.set('Cache-Control', 'private, no-store')
368
+ if (!isLoopbackSocket(req)) {
369
+ return res.status(403).json({ error: 'not_loopback', message: 'Web handoff tickets are minted only on this Mac.' })
370
+ }
371
+ const pubPort = deps.publicPort?.() ?? deps.port()
372
+ const { name, ip } = await tailnetAddress()
373
+ const { ticket, expiresAt } = handoffStore.mint()
374
+ const controlPath = `/control?handoff=${encodeURIComponent(ticket)}`
375
+ const nameUrl = name ? `http://${name}:${pubPort}${controlPath}` : null
376
+ const ipUrl = ip ? `http://${ip}:${pubPort}${controlPath}` : null
377
+ return res.json({
378
+ ok: true,
379
+ ticket,
380
+ expiresAt,
381
+ url: `http://127.0.0.1:${pubPort}${controlPath}`,
382
+ phoneUrl: nameUrl ?? ipUrl,
383
+ tailnet: { ipUrl, nameUrl },
384
+ })
385
+ })
386
+
387
+ // Open COS on the web, from the COS Glasses app. The phone asks with its pairing token,
388
+ // copies `url` and pastes it into Safari: the link signs in once, within 2 minutes, and the
389
+ // page drops the ticket from the address bar (web-control/app.js boot). `code` is the
390
+ // fallback when the clipboard refuses: typed at `codeAddress`, once, within 5 minutes.
391
+ // The Even app keeps every link inside its own WebView, which is why this copies a link
392
+ // rather than opening one. Never logs the ticket, the link or the code.
393
+ router.post('/web-session/phone-link', async (req: Request, res: Response) => {
394
+ res.set('Cache-Control', 'private, no-store')
395
+ // Only the glasses' header token. A browser signed in with the cookie already has COS
396
+ // on the web, and must not be able to mint links that sign other browsers in.
397
+ if (!timingSafeTokenEqual(req.headers['x-cos-token'], deps.apiToken())) {
398
+ return res.status(401).json({
399
+ ok: false,
400
+ error: 'unauthorized',
401
+ reason: 'pairing_token_required',
402
+ message: 'Only the paired COS Glasses app can ask for a web link.',
403
+ })
404
+ }
405
+ if (!isPhoneLinkSocketAllowed(req.socket?.remoteAddress, req.headers)) {
406
+ return res.status(403).json({
407
+ ok: false,
408
+ error: 'network_refused',
409
+ message: 'Ask for a web link from this Mac, over Tailscale or on your home network.',
410
+ })
411
+ }
412
+ const ip = normalizeRemoteIp(req.socket?.remoteAddress ?? '')
413
+ const retryAfterS = phoneLinkLimiter.take()
414
+ if (retryAfterS > 0) {
415
+ res.setHeader('Retry-After', String(retryAfterS))
416
+ log(`[web-phone-link] refused kind=rate_limited ip=${ip}`)
417
+ return res.status(429).json({
418
+ ok: false,
419
+ error: 'too_many_links',
420
+ retryAfterS,
421
+ message: `That is 5 links in a minute. Try again in ${retryAfterS} s.`,
422
+ })
423
+ }
424
+ const pubPort = deps.publicPort?.() ?? deps.port()
425
+ const { name, ip: tailIp } = await tailnetAddress()
426
+ const host = name ?? tailIp
427
+ if (!host) {
428
+ log(`[web-phone-link] refused kind=no_tailnet ip=${ip}`)
429
+ return res.status(503).json({
430
+ ok: false,
431
+ error: 'no_tailnet_address',
432
+ message: 'Your Mac has no Tailscale address right now. Turn Tailscale on, on the Mac.',
433
+ })
434
+ }
435
+ const { ticket, expiresAt } = handoffStore.mint()
436
+ const code = deviceCodes.mint()
437
+ const base = `http://${host}:${pubPort}/control`
438
+ log(`[web-phone-link] minted via=${name ? 'name' : 'ip'} ip=${ip}`)
439
+ return res.json({
440
+ ok: true,
441
+ url: `${base}?handoff=${encodeURIComponent(ticket)}`,
442
+ expiresAt,
443
+ code: code.display,
444
+ codeAddress: base,
445
+ codeExpiresAt: code.expiresAt,
446
+ })
447
+ })
448
+
449
+ router.get('/tailscale/web-url', async (_req: Request, res: Response) => {
450
+ res.set('Cache-Control', 'private, no-store')
451
+ const queryFn = deps.queryTailscaleStatus ?? (() => getTailscaleNudger().queryStatus())
452
+ const status = await queryFn().catch(() => null)
453
+ const dns = normalizeTailscaleDnsName(status?.selfDnsName ?? selfDns())
454
+ const addrs = reachableIpv4Addresses(networkInterfaces())
455
+ const tailscaleIp = status?.selfIps?.[0] ?? addrs.find(a => a.kind === 'tailscale')?.address ?? null
456
+ const lanIp = addrs.find(a => a.kind === 'lan')?.address ?? null
457
+ const port = deps.publicPort?.() ?? deps.port()
458
+ const httpsPort = deps.httpsPort?.() ?? null
459
+ const nodeName = dns ? dns.split('.')[0] : hostname().replace(/\.local$/i, '')
460
+
461
+ res.json({
462
+ available: !!(dns || tailscaleIp),
463
+ backendState: status?.backendState ?? (tailscaleIp ? 'Running' : 'Unknown'),
464
+ nodeName,
465
+ selfDnsName: dns,
466
+ tailscaleIpv4: tailscaleIp,
467
+ lanIpv4: lanIp,
468
+ port,
469
+ httpsPort,
470
+ magicDnsUrl: dns ? `http://${dns}:${port}/control` : null,
471
+ magicDnsHttpsUrl: dns && httpsPort ? `https://${dns}:${httpsPort}/control` : null,
472
+ ipv4Url: tailscaleIp ? `http://${tailscaleIp}:${port}/control` : null,
473
+ loopbackUrl: `http://127.0.0.1:${port}/control`,
474
+ })
475
+ })
476
+
477
+ // Bridge 1: Memories & Knowledge Graph Web Bridge (POST /web-bridge/memories)
478
+ const handleMemoriesBridge = async (req: Request, res: Response) => {
479
+ res.set('Cache-Control', 'private, no-store')
480
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
481
+ ? req.body as { op?: unknown; args?: unknown }
482
+ : {}
483
+ const op = typeof body.op === 'string' ? body.op.trim() : ''
484
+ const args = body.args && typeof body.args === 'object' && !Array.isArray(body.args)
485
+ ? body.args as Record<string, unknown>
486
+ : {}
487
+ if (!op) return res.status(400).json({ ok: false, message: 'Missing op.', details: {} })
488
+
489
+ const readOnly = isMemoryWebOpReadOnly(op, args)
490
+ let lease: ReturnType<typeof acquireMaintenanceWork> | null = null
491
+ if (!readOnly && op !== 'memory.guardrails.run') {
492
+ if (!MUTATING_MEMORY_WEB_OPS.has(op) && op !== 'workspace.request') {
493
+ return res.status(400).json({ ok: false, message: `This page cannot ask for ${op}.`, details: {} })
494
+ }
495
+ try {
496
+ lease = acquireMaintenanceWork('api_mutation')
497
+ } catch (error) {
498
+ if (error instanceof MaintenanceLifecycleError) {
499
+ return res.status(error.status).json({ ok: false, message: maintenanceErrorPayload(error).message || 'Server is updating.', details: {} })
500
+ }
501
+ return res.status(503).json({ ok: false, message: 'Server unavailable.', details: {} })
502
+ }
503
+ }
504
+ try {
505
+ const out = await dispatchMemoriesWebOp(op, args, loopbackFetch, deps.serverVersion?.() ?? managedServerVersion() ?? undefined)
506
+ res.json(out)
507
+ } catch (error) {
508
+ res.json({ ok: false, message: error instanceof Error ? error.message : 'Bridge request failed', details: {} })
509
+ } finally {
510
+ lease?.release()
511
+ }
512
+ }
513
+ router.post('/web-bridge/memories', handleMemoriesBridge)
514
+
515
+ // Bridge 2: Live Meeting Transcript Chunks (`active-sessions/<id>.json`)
516
+ router.get('/meetings/live-chunks', (req: Request, res: Response) => {
517
+ res.set('Cache-Control', 'private, no-store')
518
+ const baseDir = deps.baseDataDir?.()
519
+ const sessions = listLiveTranscriptSessions(baseDir)
520
+ const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId.trim() : ''
521
+ if (!sessionId) {
522
+ return res.json({ sessions })
523
+ }
524
+ const sinceRaw = typeof req.query.since === 'string' ? Number.parseInt(req.query.since, 10) : -1
525
+ const since = Number.isInteger(sinceRaw) ? sinceRaw : -1
526
+ const knownStamp = typeof req.query.stamp === 'string' && req.query.stamp ? req.query.stamp : null
527
+ const revRaw = typeof req.query.speakerRevision === 'string' ? Number.parseInt(req.query.speakerRevision, 10) : null
528
+ const knownSpeakerRevision = revRaw !== null && Number.isInteger(revRaw) && revRaw >= 0 ? revRaw : null
529
+ try {
530
+ const read = readLiveTranscriptSession({
531
+ baseDataDir: baseDir,
532
+ sessionId,
533
+ since,
534
+ knownStamp,
535
+ knownSpeakerRevision,
536
+ })
537
+ return res.json({ sessions, read })
538
+ } catch (err) {
539
+ const reason = err instanceof Error ? err.message : 'unreadable'
540
+ const status = reason.startsWith('invalid_') ? 400 : 503
541
+ return res.status(status).json({ error: 'live_transcript_unavailable', reason })
542
+ }
543
+ })
544
+
545
+ // Bridge 3: Usage & COS Cost Summary (`/api/usage/summary`, `/api/usage/sessions`, `/api/usage/cos-cost`)
546
+ router.get(['/usage/summary', '/usage/sessions', '/usage/cos-cost'], async (req: Request, res: Response) => {
547
+ res.set('Cache-Control', 'private, no-store')
548
+ const forceRefresh = req.query.refresh === '1' || req.query.refresh === 'true'
549
+ const summary = await getWebUsageSummary({ forceRefresh })
550
+ res.json(summary)
551
+ })
552
+
553
+ // Today / Work "Your Move" compatibility route
554
+ router.get('/work-board/your-move', async (_req: Request, res: Response) => {
555
+ res.set('Cache-Control', 'private, no-store')
556
+ const open = await loopbackFetch('/api/work-board/activity/open', { timeoutMs: 8_000 }).catch(() => null)
557
+ if (!open || open.status !== 200) {
558
+ return res.json({ available: false, total: 0, items: [] })
559
+ }
560
+ const rawItems = Array.isArray(open.body.items) ? open.body.items : []
561
+ return res.json({
562
+ available: open.body.available !== false,
563
+ total: typeof open.body.total === 'number' ? open.body.total : rawItems.length,
564
+ items: rawItems,
565
+ })
566
+ })
567
+
568
+ return router
569
+ }
@@ -296,7 +296,8 @@ export function calendarAnswerWasRead(bridgeReady: boolean, answer: unknown): bo
296
296
  return !('error' in answer)
297
297
  }
298
298
 
299
- async function fetchNextEvent(): Promise<{ event: NextEventPayload | null; read: boolean }> {
299
+ /** Exported for Drop it P2's prep pings (lib/drop-p2-runtime.ts); same cache, same bridge read. */
300
+ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null; read: boolean }> {
300
301
  if (cachedNextEvent && Date.now() - calendarFetchedAt < CALENDAR_TTL_MS) {
301
302
  return { event: cachedNextEvent, read: cachedCalendarRead }
302
303
  }
@@ -0,0 +1,57 @@
1
+ // Minimal CDP driver over the Playwright-cached headless Chromium. No dependencies (Node 24 has WebSocket).
2
+ import { spawn } from 'node:child_process'
3
+ import { mkdtempSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ export const CHROME = process.env.CHROME || '/Users/ukaoma/Library/Caches/ms-playwright/chromium_headless_shell-1194/chrome-mac/headless_shell'
8
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
9
+
10
+ export async function launch(port = 9333) {
11
+ const dir = mkdtempSync(join(process.env.SCRATCH || tmpdir(), 'chr-'))
12
+ const proc = spawn(CHROME, [`--remote-debugging-port=${port}`, `--user-data-dir=${dir}`, '--no-first-run', '--hide-scrollbars', '--font-render-hinting=none', 'about:blank'], { stdio: 'ignore' })
13
+ let ver = null
14
+ for (let i = 0; i < 50 && !ver; i++) { await sleep(150); try { ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json() } catch {} }
15
+ if (!ver) throw new Error('chromium did not start')
16
+ const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
17
+ const page = list.find(t => t.type === 'page')
18
+ const ws = new WebSocket(page.webSocketDebuggerUrl)
19
+ await new Promise((r, j) => { ws.onopen = r; ws.onerror = j })
20
+ let id = 0
21
+ const waiting = new Map()
22
+ const listeners = []
23
+ ws.onmessage = ev => {
24
+ const m = JSON.parse(ev.data)
25
+ if (m.id && waiting.has(m.id)) { const w = waiting.get(m.id); waiting.delete(m.id); m.error ? w.j(new Error(m.error.message)) : w.r(m.result) }
26
+ else if (m.method) listeners.forEach(l => l(m))
27
+ }
28
+ const send = (method, params = {}) => new Promise((r, j) => { const i = ++id; waiting.set(i, { r, j }); ws.send(JSON.stringify({ id: i, method, params })) })
29
+ const evaluate = async (expr) => {
30
+ const out = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })
31
+ if (out.exceptionDetails) throw new Error('page: ' + (out.exceptionDetails.exception?.description || out.exceptionDetails.text))
32
+ return out.result.value
33
+ }
34
+ await send('Page.enable'); await send('Runtime.enable')
35
+ const logs = []
36
+ listeners.push(m => { if (m.method === 'Runtime.consoleAPICalled') logs.push(m.params.type + ': ' + m.params.args.map(a => a.value ?? a.description).join(' ')); if (m.method === 'Runtime.exceptionThrown') logs.push('EXC: ' + (m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text)) })
37
+ return {
38
+ send, evaluate, logs,
39
+ async viewport(width, height, scheme) {
40
+ await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 2, mobile: width < 500 })
41
+ await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }, { name: 'prefers-reduced-motion', value: 'no-preference' }] })
42
+ },
43
+ async goto(url) {
44
+ // A fresh document every time: the same URL with a hash would otherwise be a same-document navigation.
45
+ await send('Page.navigate', { url: 'about:blank' }); await sleep(150)
46
+ await send('Page.navigate', { url })
47
+ for (let i = 0; i < 60; i++) { await sleep(100); if (await evaluate('document.readyState') === 'complete') break }
48
+ },
49
+ async shot(path) {
50
+ const { data } = await send('Page.captureScreenshot', { format: 'png' })
51
+ const { writeFileSync } = await import('node:fs')
52
+ writeFileSync(path, Buffer.from(data, 'base64'))
53
+ },
54
+ async close() { try { ws.close() } catch {} proc.kill('SIGTERM') },
55
+ }
56
+ }
57
+ export { sleep }