@gotcos/glasses-server 6.98.0 → 6.99.12

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 (89) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +147 -9
  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/display-bus.ts +3 -0
  8. package/server/lib/drop-destinations.ts +134 -0
  9. package/server/lib/drop-history-search.ts +97 -0
  10. package/server/lib/drop-p2-boot.ts +60 -0
  11. package/server/lib/drop-p2-runtime.ts +237 -0
  12. package/server/lib/drop-p2-settings.ts +174 -0
  13. package/server/lib/drop-resolution.ts +168 -0
  14. package/server/lib/drop-router.ts +325 -0
  15. package/server/lib/drop-service.ts +739 -0
  16. package/server/lib/drop-settings.ts +45 -0
  17. package/server/lib/drop-store.ts +428 -0
  18. package/server/lib/drop-when.ts +265 -0
  19. package/server/lib/glasses-pairing.ts +15 -0
  20. package/server/lib/learning-needs-you.ts +111 -0
  21. package/server/lib/live-cues-v2-data.ts +336 -26
  22. package/server/lib/live-cues-v2-settings.ts +2 -1
  23. package/server/lib/live-cues-v2.ts +9 -5
  24. package/server/lib/meeting-consolidation-run.ts +169 -0
  25. package/server/lib/meeting-store.ts +61 -0
  26. package/server/lib/meeting-title-steer.ts +200 -0
  27. package/server/lib/network-policy.ts +84 -6
  28. package/server/lib/permission-broker.ts +11 -0
  29. package/server/lib/ping-budget.ts +149 -0
  30. package/server/lib/ping-engine.ts +245 -0
  31. package/server/lib/ping-feed-sink.ts +33 -0
  32. package/server/lib/ping-sources.ts +192 -0
  33. package/server/lib/query-job-coordinator.ts +5 -2
  34. package/server/lib/query-job-runtime.ts +45 -4
  35. package/server/lib/query-job-types.ts +10 -1
  36. package/server/lib/reminder-scheduler.ts +153 -0
  37. package/server/lib/tailnet-self-name.ts +103 -0
  38. package/server/lib/tailscale-nudge.ts +40 -2
  39. package/server/lib/task-store.ts +32 -0
  40. package/server/lib/web-ask-admission.ts +56 -0
  41. package/server/lib/web-live-chunks.ts +273 -0
  42. package/server/lib/web-memories-bridge.ts +323 -0
  43. package/server/lib/web-session-auth.ts +492 -0
  44. package/server/lib/web-usage-bridge.ts +154 -0
  45. package/server/lib/work-search.ts +14 -1
  46. package/server/lib/your-move-count.ts +58 -0
  47. package/server/routes/display.ts +11 -1
  48. package/server/routes/drops.ts +260 -0
  49. package/server/routes/health.ts +16 -0
  50. package/server/routes/live-cues.ts +5 -4
  51. package/server/routes/meeting.ts +61 -5
  52. package/server/routes/permission-broker.ts +23 -3
  53. package/server/routes/transcribe-stream.ts +34 -3
  54. package/server/routes/web-control.ts +569 -0
  55. package/server/routes/welcome-context.ts +2 -1
  56. package/server/web-control/app.css +519 -0
  57. package/server/web-control/app.js +3643 -0
  58. package/server/web-control/control.css +312 -0
  59. package/server/web-control/fonts/DMSans.ttf +0 -0
  60. package/server/web-control/fonts/Fraunces.ttf +0 -0
  61. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  62. package/server/web-control/index.html +275 -0
  63. package/server/web-control/mark.svg +1 -0
  64. package/server/web-control/memories/d3.min.js +2 -0
  65. package/server/web-control/memories/graph-explorer.css +200 -0
  66. package/server/web-control/memories/graph-explorer.js +748 -0
  67. package/server/web-control/memories/memories-app.js +1561 -0
  68. package/server/web-control/memories/memories-theme.css +73 -0
  69. package/server/web-control/memories/memories.html +72 -0
  70. package/server/web-control/memories/memory-stewardship.js +63 -0
  71. package/server/web-control/memories/memory-workspace.js +216 -0
  72. package/server/web-control/panes/cos-chat.css +157 -0
  73. package/server/web-control/panes/cos-chat.js +973 -0
  74. package/server/web-control/panes/dictation.js +173 -0
  75. package/server/web-control/panes/meetings.css +257 -0
  76. package/server/web-control/panes/meetings.js +2264 -0
  77. package/server/web-control/panes/messages.js +711 -0
  78. package/server/web-control/panes/questions.css +32 -0
  79. package/server/web-control/panes/questions.js +275 -0
  80. package/server/web-control/panes/sessions.css +452 -0
  81. package/server/web-control/panes/settings.js +292 -0
  82. package/server/web-control/panes/speakers.css +269 -0
  83. package/server/web-control/panes/speakers.js +2755 -0
  84. package/server/web-control/panes/threads.js +200 -0
  85. package/server/web-control/panes/work.css +261 -0
  86. package/server/web-control/panes/work.js +1667 -0
  87. package/server/web-control/shell.js +294 -0
  88. package/server/web-control/theme.js +25 -0
  89. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,492 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { mkdirSync, readFileSync } from 'node:fs'
3
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
4
+ import { dirname } from 'node:path'
5
+ import {
6
+ hasProxyForwardedHeaders,
7
+ isAllowedNetworkIp,
8
+ isAllowedNetworkOrigin,
9
+ isLoopbackAddress,
10
+ isTailscaleIpv4,
11
+ isTailscaleIpv6,
12
+ normalizeRemoteIp,
13
+ type AllowedOriginOptions,
14
+ } from './network-policy.js'
15
+ import { readSwitch } from './env-switch.js'
16
+ import { timingSafeTokenEqual } from './token-auth.js'
17
+ import { CROCKFORD_ALPHABET, PAIRING_CODE_ATTEMPT_LIMIT, PAIRING_CODE_LENGTH } from './glasses-pairing.js'
18
+
19
+ export const WEB_SESSION_COOKIE_NAME = 'cos_web_session'
20
+ export const WEB_CSRF_HEADER = 'x-cos-web'
21
+ export const WEB_SESSION_TTL_MS = 7 * 24 * 60 * 60_000
22
+ export const WEB_HANDOFF_TTL_MS = 2 * 60_000
23
+ export const WEB_HANDOFF_MAX_ACTIVE = 16
24
+
25
+ const HANDOFF_TICKET_PATTERN = /^[A-Za-z0-9_-]{24,64}$/
26
+
27
+ function parseCookies(cookieHeader: string | undefined): Record<string, string> {
28
+ const out: Record<string, string> = {}
29
+ if (typeof cookieHeader !== 'string' || !cookieHeader) return out
30
+ for (const part of cookieHeader.split(';')) {
31
+ const eq = part.indexOf('=')
32
+ if (eq <= 0) continue
33
+ const key = part.slice(0, eq).trim()
34
+ const val = part.slice(eq + 1).trim()
35
+ if (key) out[key] = val
36
+ }
37
+ return out
38
+ }
39
+
40
+ function signSession(apiToken: string, expUnixSec: number, epoch: number): string {
41
+ return createHmac('sha256', `cos_web_session_v2:${apiToken}`)
42
+ .update(`${expUnixSec}:${epoch}`)
43
+ .digest('hex')
44
+ }
45
+
46
+ // ── Revocation (review W1) ───────────────────────────────────────────────────
47
+ // The cookie signs a session EPOCH kept on the Mac. "Sign out every browser" bumps it, so
48
+ // every copy of every cookie stops working at once, without rotating the pairing token
49
+ // (which would also unpair the glasses). The file holds only a number.
50
+ let epochFile: string | null = null
51
+ let epochCache: number | null = null
52
+
53
+ /** Where the epoch lives. index.ts sets it to dataPath('web-session.json'); tests pass a temp file. */
54
+ export function setWebSessionEpochFile(path: string | null): void {
55
+ epochFile = path
56
+ epochCache = null
57
+ }
58
+
59
+ /**
60
+ * The epoch NEVER moves backwards (review W5). A missing file (first run, or deleted) and an
61
+ * unreadable or corrupt one both start a new epoch from the clock in whole seconds. Each "Sign
62
+ * out every browser" adds 1 to the epoch the file was created with, so the clock is above every
63
+ * earlier epoch unless the file is lost within k seconds of its creation after k bumps (in use
64
+ * the file is created on first run, long before). So no cookie from before can come back to
65
+ * life; the cost is one more sign-in after such an event. Tests pin Date.now for this reason.
66
+ */
67
+ export function currentWebSessionEpoch(): number {
68
+ if (epochCache !== null) return epochCache
69
+ if (!epochFile) {
70
+ epochCache = 0
71
+ return 0
72
+ }
73
+ let value: number | null = null
74
+ try {
75
+ const parsed = JSON.parse(readFileSync(epochFile, 'utf8')) as { epoch?: unknown }
76
+ if (typeof parsed.epoch === 'number' && Number.isSafeInteger(parsed.epoch) && parsed.epoch >= 0) value = parsed.epoch
77
+ } catch {
78
+ value = null
79
+ }
80
+ if (value === null) {
81
+ value = Math.floor(Date.now() / 1000)
82
+ try { writeEpoch(value) } catch { /* still forward in memory; the next read tries again */ }
83
+ }
84
+ epochCache = value
85
+ return value
86
+ }
87
+
88
+ // The server's durable writer: fsync of the bytes, rename, then fsync of the directory, through
89
+ // a guarded (O_NOFOLLOW) exclusive temp file.
90
+ function writeEpoch(value: number): void {
91
+ if (!epochFile) return
92
+ mkdirSync(dirname(epochFile), { recursive: true })
93
+ durableAtomicWriteFileSync(epochFile, JSON.stringify({ epoch: value }) + '\n', { mode: 0o600 })
94
+ }
95
+
96
+ /** Invalidates every browser session. Returns the new epoch. Written atomically and fsynced. */
97
+ export function bumpWebSessionEpoch(): number {
98
+ const next = currentWebSessionEpoch() + 1
99
+ writeEpoch(next)
100
+ epochCache = next
101
+ return next
102
+ }
103
+
104
+ export function mintWebSessionCookieValue(apiToken: string, nowMs = Date.now(), ttlMs = WEB_SESSION_TTL_MS, epoch = currentWebSessionEpoch()): {
105
+ value: string
106
+ expiresAtMs: number
107
+ } {
108
+ const expiresAtMs = nowMs + ttlMs
109
+ const expUnixSec = Math.floor(expiresAtMs / 1000)
110
+ const sig = signSession(apiToken, expUnixSec, epoch)
111
+ return { value: `${expUnixSec}.${epoch}.${sig}`, expiresAtMs }
112
+ }
113
+
114
+ export function verifyWebSessionCookieValue(
115
+ cookieValue: string | undefined,
116
+ apiToken: string,
117
+ nowMs = Date.now(),
118
+ epoch = currentWebSessionEpoch(),
119
+ ): boolean {
120
+ if (typeof cookieValue !== 'string' || !apiToken) return false
121
+ const parts = cookieValue.split('.')
122
+ if (parts.length !== 3) return false
123
+ const [expPart, epochPart, sigPart] = parts
124
+ if (!/^\d{1,15}$/.test(expPart) || !/^\d{1,12}$/.test(epochPart) || !/^[0-9a-f]{64}$/.test(sigPart)) return false
125
+ const expUnixSec = Number(expPart)
126
+ if (!Number.isSafeInteger(expUnixSec) || expUnixSec * 1000 <= nowMs) return false
127
+ // The signature covers the CURRENT epoch, so a cookie from before "Sign out every browser"
128
+ // (or one whose epoch digits were edited) fails here; epochPart is only shape-checked.
129
+ const expected = signSession(apiToken, expUnixSec, epoch)
130
+ const left = Buffer.from(sigPart, 'utf8')
131
+ const right = Buffer.from(expected, 'utf8')
132
+ return left.length === right.length && timingSafeEqual(left, right)
133
+ }
134
+
135
+ export function formatSetWebSessionCookie(value: string, maxAgeSec: number, secure = false): string {
136
+ const parts = [
137
+ `${WEB_SESSION_COOKIE_NAME}=${value}`,
138
+ 'Path=/',
139
+ 'HttpOnly',
140
+ 'SameSite=Strict',
141
+ `Max-Age=${Math.max(0, Math.floor(maxAgeSec))}`,
142
+ ]
143
+ if (secure) parts.push('Secure')
144
+ return parts.join('; ')
145
+ }
146
+
147
+ export function formatClearWebSessionCookie(secure = false): string {
148
+ return formatSetWebSessionCookie('', 0, secure)
149
+ }
150
+
151
+ // ── Which sockets may use the browser surface (review W2) ────────────────────
152
+ /**
153
+ * COS Control Web is for this Mac and its tailnet: loopback, Tailscale IPv4 (100.64/10) and
154
+ * Tailscale IPv6 (fd7a:115c:a1e0::/48). Home Wi-Fi and other LAN addresses are refused
155
+ * unless COS_WEB_CONTROL_LAN=1, because there the page and its sign-in travel as plain HTTP
156
+ * and anyone on the network can watch. The glasses' X-Cos-Token path is unaffected.
157
+ */
158
+ export function isWebControlSocketAllowed(
159
+ remoteAddress: string | undefined | null,
160
+ env: NodeJS.ProcessEnv = process.env,
161
+ headers?: Record<string, unknown> | null,
162
+ ): boolean {
163
+ // Review W4: behind `tailscale serve`, Funnel or any local proxy every caller arrives on
164
+ // loopback. Such a request carries forwarding headers, and is never treated as this Mac or
165
+ // the tailnet (the same rule isLoopbackSocket applies to the Mac-only routes).
166
+ if (hasProxyForwardedHeaders(headers)) return false
167
+ const addr = normalizeRemoteIp(remoteAddress ?? '')
168
+ if (!addr) return false
169
+ if (isLoopbackAddress(addr) || isTailscaleIpv4(addr) || isTailscaleIpv6(addr)) return true
170
+ return readSwitch(env.COS_WEB_CONTROL_LAN, false) && isAllowedNetworkIp(addr)
171
+ }
172
+
173
+ export interface WebSessionAuthRequest {
174
+ method?: string
175
+ headers?: Record<string, string | string[] | undefined>
176
+ socket?: { remoteAddress?: string | undefined } | null
177
+ }
178
+
179
+ export interface WebSessionVerifyOptions extends AllowedOriginOptions {
180
+ nowMs?: number
181
+ env?: NodeJS.ProcessEnv
182
+ }
183
+
184
+ /** The origin a browser stamped, as `host[:port]`, lowercased; null when it is not a URL. */
185
+ function originHostPort(origin: string): string | null {
186
+ try {
187
+ return new URL(origin).host.toLowerCase()
188
+ } catch {
189
+ return null
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Verify whether `req` carries a valid `cos_web_session` cookie AND satisfies the CSRF and
195
+ * origin rules on writes.
196
+ *
197
+ * - Any method: the socket must be loopback or a Tailscale peer (or LAN, opted in), the cookie
198
+ * signature valid, unexpired and of the current session epoch.
199
+ * - GET/HEAD/OPTIONS: if an `Origin` header is present it must pass `isAllowedNetworkOrigin`.
200
+ * - POST/PUT/PATCH/DELETE: `X-COS-Web: 1`, plus an `Origin` (or `Referer`) that passes the
201
+ * allowlist AND names the very host and port the browser connected to (the `Host` header).
202
+ * The allowlist alone admits any LAN or tailnet address on 3141/3143 (review W3); equality
203
+ * with Host is what makes it "this server". A DNS-rebinding page matches Host but fails the
204
+ * allowlist; another machine's page passes the allowlist but fails Host.
205
+ */
206
+ export function isWebSessionAuthorized(
207
+ req: WebSessionAuthRequest,
208
+ apiToken: string,
209
+ options: WebSessionVerifyOptions = {},
210
+ ): boolean {
211
+ if (!isWebControlSocketAllowed(req.socket?.remoteAddress, options.env, req.headers)) return false
212
+ const headers = req.headers ?? {}
213
+ const cookieHeader = typeof headers.cookie === 'string' ? headers.cookie : undefined
214
+ const cookies = parseCookies(cookieHeader)
215
+ const rawCookie = cookies[WEB_SESSION_COOKIE_NAME]
216
+ if (!verifyWebSessionCookieValue(rawCookie, apiToken, options.nowMs ?? Date.now())) {
217
+ return false
218
+ }
219
+
220
+ const method = (req.method ?? 'GET').toUpperCase()
221
+ const originHeader = typeof headers.origin === 'string' ? headers.origin : undefined
222
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
223
+ if (originHeader && originHeader !== 'null') {
224
+ return isAllowedNetworkOrigin(originHeader, options)
225
+ }
226
+ return true
227
+ }
228
+
229
+ const csrfVal = headers[WEB_CSRF_HEADER]
230
+ const csrfOk = csrfVal === '1' || (Array.isArray(csrfVal) && csrfVal[0] === '1')
231
+ if (!csrfOk) return false
232
+
233
+ const candidateOrigin = originHeader && originHeader !== 'null'
234
+ ? originHeader
235
+ : (typeof headers.referer === 'string' ? headers.referer : '')
236
+ if (!candidateOrigin) return false
237
+ if (!isAllowedNetworkOrigin(candidateOrigin, options)) return false
238
+ const host = typeof headers.host === 'string' ? headers.host.trim().toLowerCase() : ''
239
+ return host !== '' && originHostPort(candidateOrigin) === host
240
+ }
241
+
242
+ interface HandoffRecord {
243
+ ticket: string
244
+ expiresAtMs: number
245
+ }
246
+
247
+ /**
248
+ * Short-lived, single-use loopback handoff tickets so native `COS Control.app` can
249
+ * open the user's browser directly into an authenticated session without putting
250
+ * the standing `COS_API_TOKEN` in a URL or touching `PairingState`.
251
+ */
252
+ export class WebHandoffStore {
253
+ private readonly tickets = new Map<string, HandoffRecord>()
254
+ constructor(private readonly now: () => number = () => Date.now()) {}
255
+
256
+ private sweep(at: number): void {
257
+ for (const [k, rec] of this.tickets) {
258
+ if (at >= rec.expiresAtMs) this.tickets.delete(k)
259
+ }
260
+ }
261
+
262
+ mint(ttlMs = WEB_HANDOFF_TTL_MS): { ticket: string; expiresAt: string } {
263
+ const at = this.now()
264
+ this.sweep(at)
265
+ while (this.tickets.size >= WEB_HANDOFF_MAX_ACTIVE) {
266
+ const oldest = this.tickets.keys().next().value
267
+ if (!oldest) break
268
+ this.tickets.delete(oldest)
269
+ }
270
+ const ticket = randomBytes(24).toString('base64url')
271
+ const expiresAtMs = at + ttlMs
272
+ this.tickets.set(ticket, { ticket, expiresAtMs })
273
+ return { ticket, expiresAt: new Date(expiresAtMs).toISOString() }
274
+ }
275
+
276
+ consume(ticket: unknown): boolean {
277
+ const at = this.now()
278
+ this.sweep(at)
279
+ if (typeof ticket !== 'string' || !HANDOFF_TICKET_PATTERN.test(ticket)) return false
280
+ const existing = this.tickets.get(ticket)
281
+ if (!existing) return false
282
+ this.tickets.delete(ticket)
283
+ return at < existing.expiresAtMs
284
+ }
285
+ }
286
+
287
+ /** Why a browser sign-in failed. Logged and shown to the person; never carries the value. */
288
+ export type WebLoginFailure = 'wrong_token' | 'wrong_code' | 'glasses_code' | 'handoff_expired' | 'missing'
289
+
290
+ export type WebLoginResult =
291
+ | { ok: true; kind: 'token' | 'handoff' | 'code' }
292
+ | { ok: false; reason: WebLoginFailure; length: number }
293
+
294
+ // What a phone paste drags along with a token: spaces and line breaks (a copy out of a
295
+ // terminal or a note), zero-width and no-break characters, and the quotes around a value
296
+ // copied out of an .env line. None of them can be part of a pairing token.
297
+ const PASTE_INVISIBLES = /[\s\u200B-\u200D\u2060\uFEFF\u00A0]/g
298
+ const PASTE_WRAPPING_QUOTES = /^["'`\u2018\u2019\u201C\u201D]+|["'`\u2018\u2019\u201C\u201D]+$/g
299
+
300
+ /** The token as pasted, with paste debris removed (see PASTE_INVISIBLES). */
301
+ export function normalizePastedToken(input: unknown): string {
302
+ if (typeof input !== 'string') return ''
303
+ return input.replace(PASTE_INVISIBLES, '').replace(PASTE_WRAPPING_QUOTES, '')
304
+ }
305
+
306
+ /**
307
+ * Check one sign-in and say why it failed. Exactly one credential is tried, in this order:
308
+ * token, handoff ticket, code. `isGlassesCode` answers whether a code that is NOT a web code
309
+ * is the live glasses pairing code; it must be read only (never consume or count an attempt),
310
+ * because that code belongs to the glasses app and the person typed it here by mistake.
311
+ */
312
+ export function classifyWebLogin(
313
+ input: { token?: unknown; handoff?: unknown; code?: unknown },
314
+ apiToken: string,
315
+ handoffStore?: WebHandoffStore,
316
+ codeStore?: WebDeviceCodeStore,
317
+ isGlassesCode?: (code: string) => boolean,
318
+ ): WebLoginResult {
319
+ const raw = typeof input.token === 'string' ? input.token.trim() : ''
320
+ const token = normalizePastedToken(input.token)
321
+ if (token.length > 0 || raw.length > 0) {
322
+ // The value as typed first (a provisioned token is never second-guessed), then the
323
+ // paste-cleaned one. Still one sign-in attempt either way.
324
+ if (raw.length > 0 && timingSafeTokenEqual(raw, apiToken)) return { ok: true, kind: 'token' }
325
+ if (token.length > 0 && token !== raw && timingSafeTokenEqual(token, apiToken)) return { ok: true, kind: 'token' }
326
+ return { ok: false, reason: 'wrong_token', length: token.length || raw.length }
327
+ }
328
+ if (handoffStore && typeof input.handoff === 'string' && input.handoff.trim().length > 0) {
329
+ const ticket = input.handoff.trim()
330
+ return handoffStore.consume(ticket)
331
+ ? { ok: true, kind: 'handoff' }
332
+ : { ok: false, reason: 'handoff_expired', length: ticket.length }
333
+ }
334
+ if (codeStore && typeof input.code === 'string' && input.code.trim().length > 0) {
335
+ if (codeStore.consume(input.code)) return { ok: true, kind: 'code' }
336
+ const code = normalizeDeviceCode(input.code)
337
+ let glasses = false
338
+ if (code && isGlassesCode) {
339
+ try { glasses = isGlassesCode(code) === true } catch { glasses = false }
340
+ }
341
+ return { ok: false, reason: glasses ? 'glasses_code' : 'wrong_code', length: code?.length ?? input.code.trim().length }
342
+ }
343
+ return { ok: false, reason: 'missing', length: 0 }
344
+ }
345
+
346
+ /**
347
+ * How many times web sign-in may ask "is this the glasses code?" about one live glasses code,
348
+ * across every device (review W1). The glasses claim route allows PAIRING_CODE_ATTEMPT_LIMIT
349
+ * guesses per code, but the web limiter counts per IP, so without this cap many tailnet
350
+ * addresses would each get their own answers about the same code. Past the budget a typed code
351
+ * reads as a plain wrong code, which says nothing. Pairing state is never touched: the key is
352
+ * read from it, the count lives here, and a newly shown glasses code starts a fresh budget.
353
+ */
354
+ export class WebGlassesCodeProbeBudget {
355
+ private key: string | null = null
356
+ private count = 0
357
+ constructor(private readonly limit = PAIRING_CODE_ATTEMPT_LIMIT) {}
358
+
359
+ /** `key` names the live glasses code (null when none is showing); `isLive` is the read-only check. */
360
+ check(key: string | null, isLive: () => boolean): boolean {
361
+ if (key === null) return false
362
+ if (key !== this.key) {
363
+ this.key = key
364
+ this.count = 0
365
+ }
366
+ this.count += 1
367
+ if (this.count > this.limit) return false
368
+ return isLive()
369
+ }
370
+ }
371
+
372
+ export function verifyLoginTokenOrHandoff(
373
+ input: { token?: unknown; handoff?: unknown; code?: unknown },
374
+ apiToken: string,
375
+ handoffStore?: WebHandoffStore,
376
+ codeStore?: WebDeviceCodeStore,
377
+ ): boolean {
378
+ return classifyWebLogin(input, apiToken, handoffStore, codeStore).ok
379
+ }
380
+
381
+ export const WEB_DEVICE_CODE_TTL_MS = 5 * 60_000
382
+ export const WEB_DEVICE_CODE_MAX_ACTIVE = 4
383
+ const DEVICE_CODE_ALPHABET = CROCKFORD_ALPHABET
384
+ const DEVICE_CODE_LENGTH = PAIRING_CODE_LENGTH
385
+
386
+ /**
387
+ * Normalise what a person typed: Crockford base32 reads O as 0 and I/L as 1, and ignores
388
+ * case, spaces and dashes, so "k7p3-qx9m" and "K7P3QX9M" are the same code.
389
+ */
390
+ export function normalizeDeviceCode(input: unknown): string | null {
391
+ if (typeof input !== 'string') return null
392
+ const clean = input.toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1')
393
+ if (clean.length !== DEVICE_CODE_LENGTH) return null
394
+ for (const ch of clean) if (!DEVICE_CODE_ALPHABET.includes(ch)) return null
395
+ return clean
396
+ }
397
+
398
+ /**
399
+ * Short sign-in codes for another device (the phone), minted by a browser or app that is
400
+ * already signed in. Same shape as glasses pairing: 8 Crockford characters (40 bits), five
401
+ * minutes, single use, at most four live at once. Guessing is also capped per IP by the
402
+ * route's sign-in limiter, so a code cannot be found inside its lifetime.
403
+ */
404
+ export class WebDeviceCodeStore {
405
+ private readonly codes = new Map<string, number>()
406
+ constructor(private readonly now: () => number = () => Date.now()) {}
407
+
408
+ private sweep(at: number): void {
409
+ for (const [code, exp] of this.codes) if (at >= exp) this.codes.delete(code)
410
+ }
411
+
412
+ mint(ttlMs = WEB_DEVICE_CODE_TTL_MS): { code: string; display: string; expiresAt: string } {
413
+ const at = this.now()
414
+ this.sweep(at)
415
+ while (this.codes.size >= WEB_DEVICE_CODE_MAX_ACTIVE) {
416
+ const oldest = this.codes.keys().next().value
417
+ if (oldest === undefined) break
418
+ this.codes.delete(oldest)
419
+ }
420
+ let code = ''
421
+ do {
422
+ code = ''
423
+ const bytes = randomBytes(DEVICE_CODE_LENGTH)
424
+ for (const b of bytes) code += DEVICE_CODE_ALPHABET[b & 31]
425
+ } while (this.codes.has(code))
426
+ this.codes.set(code, at + ttlMs)
427
+ return { code, display: `${code.slice(0, 4)}-${code.slice(4)}`, expiresAt: new Date(at + ttlMs).toISOString() }
428
+ }
429
+
430
+ consume(input: unknown): boolean {
431
+ const at = this.now()
432
+ this.sweep(at)
433
+ const code = normalizeDeviceCode(input)
434
+ if (!code) return false
435
+ // The sweep above has already dropped every expired code, so presence means still valid.
436
+ if (!this.codes.has(code)) return false
437
+ this.codes.delete(code)
438
+ return true
439
+ }
440
+ }
441
+
442
+ export const WEB_LOGIN_MAX_FAILURES = 8
443
+ export const WEB_LOGIN_WINDOW_MS = 10 * 60_000
444
+
445
+ /**
446
+ * Failed browser sign-ins per socket IP. After WEB_LOGIN_MAX_FAILURES wrong tokens inside
447
+ * WEB_LOGIN_WINDOW_MS the IP is refused (429) until the window that began with its first
448
+ * failure ends. A success clears the IP. Bounded: at most 512 tracked IPs, oldest evicted.
449
+ */
450
+ export class WebLoginLimiter {
451
+ private readonly failures = new Map<string, { count: number; firstAt: number; last?: WebLoginFailure }>()
452
+ constructor(
453
+ private readonly now: () => number = () => Date.now(),
454
+ private readonly maxFailures = WEB_LOGIN_MAX_FAILURES,
455
+ private readonly windowMs = WEB_LOGIN_WINDOW_MS,
456
+ ) {}
457
+
458
+ /** Seconds until this IP may try again; 0 when it may try now. */
459
+ blockedFor(ip: string): number {
460
+ const rec = this.failures.get(ip)
461
+ if (!rec) return 0
462
+ const left = rec.firstAt + this.windowMs - this.now()
463
+ if (left <= 0) { this.failures.delete(ip); return 0 }
464
+ return rec.count >= this.maxFailures ? Math.ceil(left / 1000) : 0
465
+ }
466
+
467
+ fail(ip: string, reason?: WebLoginFailure): void {
468
+ const at = this.now()
469
+ const rec = this.failures.get(ip)
470
+ if (!rec || at - rec.firstAt >= this.windowMs) {
471
+ this.failures.delete(ip)
472
+ this.failures.set(ip, { count: 1, firstAt: at, last: reason })
473
+ while (this.failures.size > 512) {
474
+ const oldest = this.failures.keys().next().value
475
+ if (oldest === undefined) break
476
+ this.failures.delete(oldest)
477
+ }
478
+ return
479
+ }
480
+ rec.count += 1
481
+ if (reason) rec.last = reason
482
+ }
483
+
484
+ /** The kind of this IP's most recent failure inside its window, for the lockout message. */
485
+ lastFailure(ip: string): WebLoginFailure | null {
486
+ return this.failures.get(ip)?.last ?? null
487
+ }
488
+
489
+ succeed(ip: string): void {
490
+ this.failures.delete(ip)
491
+ }
492
+ }
@@ -0,0 +1,154 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { accessSync, constants as fsConstants, existsSync, readFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ const CACHE_TTL_MS = 30_000
7
+ const HELPER_TIMEOUT_MS = 15_000
8
+
9
+ export interface UsageSummaryPayload {
10
+ available: boolean
11
+ source: 'helper' | 'ledger_fallback'
12
+ fetchedAt: string
13
+ sessionUsage: Record<string, unknown> | null
14
+ cosCost: Record<string, unknown> | null
15
+ }
16
+
17
+ function candidateHelperPaths(home = homedir()): string[] {
18
+ return [
19
+ join(home, 'Library', 'Application Support', 'COS Control', 'bin', 'cos-control-helper'),
20
+ '/Applications/COS Control.app/Contents/Resources/cos-control-helper',
21
+ '/Applications/COS Control.app/Contents/MacOS/cos-control-helper',
22
+ ]
23
+ }
24
+
25
+ export function resolveCosControlHelperBin(home = homedir()): string | null {
26
+ for (const p of candidateHelperPaths(home)) {
27
+ try {
28
+ accessSync(p, fsConstants.X_OK)
29
+ return p
30
+ } catch {
31
+ continue
32
+ }
33
+ }
34
+ return null
35
+ }
36
+
37
+ function runHelperReadOnly(bin: string, verb: 'session-usage' | 'cos-usage'): Promise<Record<string, unknown> | null> {
38
+ return new Promise(resolve => {
39
+ execFile(
40
+ bin,
41
+ [verb, '--read-only'],
42
+ { timeout: HELPER_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, encoding: 'utf8' },
43
+ (err, stdout) => {
44
+ if (err || !stdout) return resolve(null)
45
+ try {
46
+ const parsed = JSON.parse(stdout) as { ok?: boolean; details?: Record<string, unknown> }
47
+ if (parsed && parsed.ok && parsed.details && typeof parsed.details === 'object') {
48
+ return resolve(parsed.details)
49
+ }
50
+ resolve(null)
51
+ } catch {
52
+ resolve(null)
53
+ }
54
+ },
55
+ )
56
+ })
57
+ }
58
+
59
+ export function readFallbackTokenAudit(home = homedir()): Record<string, unknown> {
60
+ const auditPath = join(home, '.cos-glasses', 'token-audit.jsonl')
61
+ if (!existsSync(auditPath)) {
62
+ return { auditFound: false, rows: [], totalCalls: 0, totalTokens: 0 }
63
+ }
64
+ try {
65
+ const raw = readFileSync(auditPath, 'utf8')
66
+ const lines = raw.split('\n').filter(Boolean).slice(-2000)
67
+ const byCategory = new Map<string, { calls: number; tokens: number; usd: number }>()
68
+ for (const line of lines) {
69
+ try {
70
+ const row = JSON.parse(line) as Record<string, unknown>
71
+ const cat = typeof row.caller === 'string'
72
+ ? row.caller
73
+ : typeof row.category === 'string'
74
+ ? row.category
75
+ : typeof row.route === 'string'
76
+ ? row.route
77
+ : 'other'
78
+ const tokens = (typeof row.inputTokens === 'number' ? row.inputTokens : 0)
79
+ + (typeof row.outputTokens === 'number' ? row.outputTokens : 0)
80
+ + (typeof row.tokens === 'number' ? row.tokens : 0)
81
+ const usd = typeof row.costUsd === 'number' ? row.costUsd : typeof row.usd === 'number' ? row.usd : 0
82
+ const cur = byCategory.get(cat) ?? { calls: 0, tokens: 0, usd: 0 }
83
+ cur.calls += 1
84
+ cur.tokens += tokens
85
+ cur.usd += usd
86
+ byCategory.set(cat, cur)
87
+ } catch {
88
+ continue
89
+ }
90
+ }
91
+ const rows = [...byCategory.entries()].map(([category, v]) => ({
92
+ category,
93
+ calls: v.calls,
94
+ estimatedTokens: v.tokens,
95
+ estimatedUSD: Number(v.usd.toFixed(4)),
96
+ }))
97
+ return {
98
+ auditFound: true,
99
+ rows,
100
+ updatedAt: new Date().toISOString(),
101
+ }
102
+ } catch {
103
+ return { auditFound: false, rows: [] }
104
+ }
105
+ }
106
+
107
+ let cachedSummary: { at: number; value: UsageSummaryPayload } | null = null
108
+ let inFlightSummary: Promise<UsageSummaryPayload> | null = null
109
+
110
+ export async function getWebUsageSummary(options: {
111
+ forceRefresh?: boolean
112
+ helperBin?: string | null
113
+ home?: string
114
+ } = {}): Promise<UsageSummaryPayload> {
115
+ const now = Date.now()
116
+ if (!options.forceRefresh && cachedSummary && now - cachedSummary.at < CACHE_TTL_MS) {
117
+ return cachedSummary.value
118
+ }
119
+ if (inFlightSummary) return inFlightSummary
120
+
121
+ inFlightSummary = (async (): Promise<UsageSummaryPayload> => {
122
+ const bin = options.helperBin !== undefined ? options.helperBin : resolveCosControlHelperBin(options.home)
123
+ if (bin) {
124
+ const [sessionUsage, cosCost] = await Promise.all([
125
+ runHelperReadOnly(bin, 'session-usage'),
126
+ runHelperReadOnly(bin, 'cos-usage'),
127
+ ])
128
+ if (sessionUsage || cosCost) {
129
+ const payload: UsageSummaryPayload = {
130
+ available: true,
131
+ source: 'helper',
132
+ fetchedAt: new Date().toISOString(),
133
+ sessionUsage,
134
+ cosCost: cosCost ?? readFallbackTokenAudit(options.home),
135
+ }
136
+ cachedSummary = { at: Date.now(), value: payload }
137
+ return payload
138
+ }
139
+ }
140
+ const fallback: UsageSummaryPayload = {
141
+ available: true,
142
+ source: 'ledger_fallback',
143
+ fetchedAt: new Date().toISOString(),
144
+ sessionUsage: null,
145
+ cosCost: readFallbackTokenAudit(options.home),
146
+ }
147
+ cachedSummary = { at: Date.now(), value: fallback }
148
+ return fallback
149
+ })().finally(() => {
150
+ inFlightSummary = null
151
+ })
152
+
153
+ return inFlightSummary
154
+ }
@@ -135,7 +135,20 @@ export function searchOptionText(text: string): string {
135
135
  * drain answers its own `refused` state rather than a 503, and a running job stops at its next unit when a drain starts.
136
136
  */
137
137
  export function isReadOnlyApiPost(method: string, path: string): boolean {
138
- return method === 'POST' && (path === '/work/search' || path === '/meetings/live-ask' || /^\/meeting\/[A-Za-z0-9:_-]{3,96}\/voice-suggestions$/.test(path))
138
+ return method === 'POST' && (
139
+ path === '/work/search'
140
+ // Drop it P2 History search: reads the drop store and asks Jev one question, writing only its token ledger.
141
+ || path === '/drops/search'
142
+ || path === '/meetings/live-ask'
143
+ || path === '/web-session/login'
144
+ || path === '/web-session/logout'
145
+ || path === '/web-session/handoff'
146
+ || path === '/web-session/phone-link'
147
+ || path === '/web-session/device-code'
148
+ || path === '/web-session/revoke-all'
149
+ || path === '/web-bridge/memories'
150
+ || /^\/meeting\/[A-Za-z0-9:_-]{3,96}\/voice-suggestions$/.test(path)
151
+ )
139
152
  }
140
153
 
141
154
  /** A query's length as people count it: code points, not UTF-16 units, so 150 emoji are 150 (Control counts characters). */