@gotcos/glasses-server 6.98.1 → 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.
- package/CHANGELOG.md +149 -0
- package/package.json +2 -1
- package/server/index.ts +147 -9
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +739 -0
- package/server/lib/drop-settings.ts +45 -0
- package/server/lib/drop-store.ts +428 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +6 -4
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +11 -0
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +260 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +34 -3
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +312 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +157 -0
- package/server/web-control/panes/cos-chat.js +973 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +257 -0
- package/server/web-control/panes/meetings.js +2264 -0
- package/server/web-control/panes/messages.js +711 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- 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' && (
|
|
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). */
|