@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.
- package/CHANGELOG.md +158 -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-data.ts +336 -26
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +9 -5
- 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
|
@@ -128,6 +128,7 @@ import {
|
|
|
128
128
|
import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
|
|
129
129
|
import { armMacLiveCues, type MacLiveCuesAnswer } from '../lib/live-cues-v2.js'
|
|
130
130
|
import { liveProvisional } from '../lib/live-provisional.js'
|
|
131
|
+
import { getTitleSteerStore, TITLE_STEER_CAPABILITY } from '../lib/meeting-title-steer.js'
|
|
131
132
|
import { chunkLevel, restoreChunkLevels, withChunkLevel, type ChunkLevel } from '../lib/chunk-levels.js'
|
|
132
133
|
import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
|
|
133
134
|
import {
|
|
@@ -792,6 +793,8 @@ function persistSessionRequired(sessionId: string, certified?:MacSpeakerLivePrep
|
|
|
792
793
|
startTime: session.startTime,
|
|
793
794
|
lastActivityAt: session.lastActivityAt,
|
|
794
795
|
title: session.title,
|
|
796
|
+
// 6.99.0: the name typed in COS Control while recording (lib/meeting-title-steer.ts). Control reads this file directly.
|
|
797
|
+
...titleSteerField(sessionId),
|
|
795
798
|
// `chunks` = legacy dense form, kept for backward compatibility with
|
|
796
799
|
// existing readers; `chunksIndexed` preserves original indices so gap
|
|
797
800
|
// detection survives recovery. Recovery prefers chunksIndexed.
|
|
@@ -818,6 +821,34 @@ function persistSessionRequired(sessionId: string, certified?:MacSpeakerLivePrep
|
|
|
818
821
|
}
|
|
819
822
|
}
|
|
820
823
|
|
|
824
|
+
/** `{ titleSteer }` for the active-session record: the live name, or nothing (the key is absent, never an empty string). */
|
|
825
|
+
function titleSteerField(sessionId: string): { titleSteer?: string } {
|
|
826
|
+
const title = getTitleSteerStore().title(sessionId)
|
|
827
|
+
return title ? { titleSteer: title } : {}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* 6.99.0: put a changed title steer into the live session's record NOW, so COS Control (which reads the active-session
|
|
832
|
+
* file, not a route) shows it without waiting for the next chunk. A targeted edit of one key, not persistSessionRequired:
|
|
833
|
+
* that rebuilds the whole record from memory and would re-judge the Mac speaker projection outside the chunk path. A
|
|
834
|
+
* session with no record yet gets the key at its first persist. Returns whether the file was updated; never throws.
|
|
835
|
+
*/
|
|
836
|
+
export function publishLiveTitleSteer(sessionId: string): boolean {
|
|
837
|
+
try {
|
|
838
|
+
if (!sessions.has(sessionId)) return false
|
|
839
|
+
const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
|
|
840
|
+
const stat = lstatSync(filePath)
|
|
841
|
+
if (!stat.isFile() || stat.size > 256 * 1024 * 1024) return false // lstat: a symlink is not a file here
|
|
842
|
+
const record = JSON.parse(readFileSync(filePath, 'utf-8')) as Record<string, unknown>
|
|
843
|
+
if (record.sessionId !== sessionId) return false
|
|
844
|
+
const { titleSteer, ...rest } = record
|
|
845
|
+
const field = titleSteerField(sessionId)
|
|
846
|
+
if (titleSteer === field.titleSteer) return true
|
|
847
|
+
durableAtomicWriteFileSync(filePath, JSON.stringify({ ...rest, ...field }), { mode: 0o600 })
|
|
848
|
+
return true
|
|
849
|
+
} catch { return false }
|
|
850
|
+
}
|
|
851
|
+
|
|
821
852
|
function persistSessionBestEffort(sessionId: string): void {
|
|
822
853
|
try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
|
|
823
854
|
}
|
|
@@ -2945,7 +2976,7 @@ export function assertMacCaptureComplete(sessionId: string, expectedChunkCount:
|
|
|
2945
2976
|
transcribeStreamRouter.get('/mac-recording/capabilities', (_req, res) => {
|
|
2946
2977
|
// 6.83.2: `discard` advertises POST /api/meeting/sessions/:id/discard for a stranded no-sound capture. Older
|
|
2947
2978
|
// servers omit it, and COS Control then discards only its own local copy and says so.
|
|
2948
|
-
res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId(), discard: true })
|
|
2979
|
+
res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId(), discard: true, titleSteer: TITLE_STEER_CAPABILITY })
|
|
2949
2980
|
})
|
|
2950
2981
|
function requireMacPin(req: { get(name: string): string | undefined }): string {
|
|
2951
2982
|
const pin = req.get('X-COS-Server-Instance')?.trim()
|
|
@@ -2969,12 +3000,12 @@ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
|
|
|
2969
3000
|
if (!session.chunks.some(Boolean)) session.startTime = recording.startTime
|
|
2970
3001
|
persistSessionRequired(recording.sessionId)
|
|
2971
3002
|
// Arming happens only after the recording is admitted, and its answer never fails the start (armMacLiveCues returns a
|
|
2972
|
-
// reason code instead of throwing). Without `liveCues: true` the answer is
|
|
3003
|
+
// reason code instead of throwing). Without `liveCues: true` the answer is what 6.85.0 sent plus the additive `titleSteer` (6.99.0).
|
|
2973
3004
|
let liveCues: MacLiveCuesAnswer | undefined
|
|
2974
3005
|
if (req.body.liveCues === true) {
|
|
2975
3006
|
try { liveCues = armMacLiveCues(recording.sessionId) } catch { liveCues = { armed: false, reason: 'unavailable' } }
|
|
2976
3007
|
}
|
|
2977
|
-
res.json({ ...MAC_AUDIO, ...recording, replayed, serverInstanceId, ...(liveCues ? { liveCues } : {}) })
|
|
3008
|
+
res.json({ ...MAC_AUDIO, ...recording, replayed, serverInstanceId, ...(liveCues ? { liveCues } : {}), titleSteer: getTitleSteerStore().title(recording.sessionId) })
|
|
2978
3009
|
} catch (error) { sendStreamError(res, error) } finally { lease?.release() }
|
|
2979
3010
|
})
|
|
2980
3011
|
const macChunkLocks = new Set<string>()
|
|
@@ -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
|
-
|
|
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
|
}
|