@gotcos/glasses-server 6.86.0 → 6.88.0

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 CHANGED
@@ -1,3 +1,93 @@
1
+ ## 6.88.0 - LAN first: a pre-token route proof and the Mac's addresses for the phone
2
+
3
+ The server half of the glasses "LAN first" reconnect plan (plan of 2026-10-22, Miles's decisions of 2026-10-23: "Go
4
+ with your picks on the reconnect plan. No regressions or loss of existing functionality."). After the 10/22 Mac restart
5
+ the phone stayed on a stale Tailscale tunnel while the Mac answered fine on the same Wi-Fi. These two additions let a
6
+ phone build reach the Mac over the home network first, without ever sending its token to the wrong machine. Contract
7
+ for the phone side: `docs/lan-first.md`.
8
+
9
+ - **`GET /api/route-proof?nonce=<22 to 64 base64url characters>`, public.** Answers `{ v: 1, serverInstanceId, proof }`,
10
+ where `proof` is lowercase hex HMAC-SHA256(token, "cos-route-proof:v1:" + nonce + ":" + serverInstanceId). The phone
11
+ computes the same HMAC with its stored token and sends `X-Cos-Token` to that address only on a match. The token is never
12
+ in the response, the nonce is never echoed or logged, and the caller chooses only the nonce, so the route is not a
13
+ general HMAC oracle; the fixed prefix also keeps a proof from ever being a display-stream ticket. Exact public door,
14
+ GET only (`server/lib/api-auth.ts`); every other verb and path stays behind the token. Errors: 400 `bad_nonce`,
15
+ 404 `disabled`, 405 `method_not_allowed`, 429 `rate_limited` (with `Retry-After`), 503 `not_ready`.
16
+ - **Rate limited.** 20 a minute per socket address and 240 a minute in all, in memory; bad nonces spend the same budget.
17
+ - **Only for a strong token.** A proof lets anyone who reaches the port test guesses offline, so it is served (and
18
+ advertised) only when the token is 32 characters or more. Tokens made by COS Control (64) and by the server (44) qualify;
19
+ a short hand-set legacy token keeps today's behaviour.
20
+ - **`routes` on authenticated `/api/models` only.** `{ v: 1, serverInstanceId, learnedAt, lan: [{ip, port, scheme}],
21
+ tailscale: [{ip, port, scheme}] }`: RFC1918 IPv4 on real interfaces (tunnels, Internet Sharing and VM bridges, Docker,
22
+ AirDrop and link-local left out) and 100.64/10 on Tailscale's own interface, plain http on `PORT` (3141). Re-read at
23
+ most every 10 s, so a new DHCP lease shows up; a failed interface read omits the field and leaves the rest of the
24
+ response alone. Never on public `/api/health`. The MagicDNS name is left out (reading it needs a `tailscale`
25
+ process per request).
26
+ - **Capability flags.** `capabilities.routeProof: { protocol: 1 }` and `capabilities.lanRoutes: { protocol: 1 }` on
27
+ `/api/health` and `/api/models`, from one helper. An older server omits both, and a phone keeps today's single URL.
28
+ - **Switch.** `COS_LAN_FIRST=0` turns both pieces off, read per request; the responses are then the 6.87.0 shape exactly.
29
+ - **No regression.** Every existing field of `/api/health` and `/api/models` is unchanged, checked path for path against
30
+ a golden captured from the unmodified 6.87.0 base, and byte for byte against the same response with the switch off.
31
+ - **Not built:** Phase 3's loopback `/api/glasses/link` for COS Control's banner. It needs new tracking of the phone's
32
+ last path and check-in, so it is not a purely additive change.
33
+ - **No spend.** No LLM, no new caller, nothing written to disk.
34
+ - **Checks (6.88.0 branch).** Full suite, serial (`--maxWorkers=1`), HOME, COS_DATA_DIR and COS_PROFILE_PATH in a scratch folder,
35
+ COS_SCRIPTS_DIR unset. Unmodified 6.87.0 base (601d807): 584 files (583 passed, 1 skipped), 8815 tests, 8801 passed,
36
+ 14 skipped, 0 failed. This branch: 588 files (587 passed, 1 skipped), 8911 tests, 8897 passed, 14 skipped, 0 failed.
37
+ The 96 new tests are the only change per file (`lan-first.test.ts` 58, `route-proof.test.ts` 18, `health-lan-first.test.ts`
38
+ 9, `lan-first-wiring.test.ts` 2, `api-auth.test.ts` 40 to 49); no existing test was changed. Mutation gate
39
+ `lan-first-mutation-gate.mjs` against a green baseline (136 tests in 5 files): 56 of 56 killed, each by a named test,
40
+ every target landing exactly once and restored byte for byte. Its first run left two survivors (the IPv4-only filter and
41
+ the de-duplication), each now covered by its own test and killed. `tsc --noEmit` clean. Pack dry run 549 files
42
+ (6.87.0: 546; the three new files are `server/lib/lan-first.ts`, `server/routes/route-proof.ts` and `docs/lan-first.md`).
43
+
44
+ ### Also in 6.88.0: transfer integrity counts what the phone captured, and recovery saves wait for it
45
+
46
+ For glasses 6.10.652 (meeting t2xs9u, 2026-10-23 07:59): a relaunched COS window saved a meeting from the Mac's 74 chunks
47
+ while the window still recording it had captured through index 120, and the file said "Transfer integrity 100%". One new
48
+ file (`server/lib/transfer-integrity.ts`) and two edits in `performSave` (`server/routes/meeting.ts`).
49
+
50
+ - **Integrity counts the phone's final index.** When the phone has said how far it captured (the Stop intent's
51
+ `finalChunkIndex`, or the save's own new optional `finalChunkIndex`), expected is that index plus one and every index past
52
+ the Mac's highest chunk is missing. 74 of 121 now reads 61.1% with 47 chunks not received, in the saved markdown, the
53
+ sidecar and the receipt. A final index more than 20,000 past the Mac's highest chunk is capped there; a malformed one is
54
+ ignored.
55
+ - **A recovery save waits.** A save with `recovery: 'origin_wipe'` (glasses 6.10.652) is refused with 409
56
+ `chunks_outstanding` (`missingCount`, `finalChunkIndex`) while the phone's final index says the Mac lacks chunks, and
57
+ nothing is saved or closed. `acceptMissing: true` (the phone waited its 10 minutes) saves what the Mac has, honestly counted.
58
+ - **Unchanged:** a save without `recovery` is never refused here; a save with no final index from anywhere writes exactly
59
+ what 6.87.0 wrote; an already saved meeting replays its receipt first; the autonomous save, the stop route and the status
60
+ route are untouched. No new log line, no LLM, no new disk write. Contract: `docs/meeting-autonomous-save.md`, section 6.88.0.
61
+ - **Checks (integrity, on top of c593b91).** Full suite, serial, the same scratch folder rules: 590 files, 8926 tests, 8912
62
+ passed, 14 skipped, 0 failed (the LAN-first record above is 8911 tests; the 15 new ones are `transfer-integrity.test.ts` 5
63
+ and `meeting-save-integrity.test.ts` 10; no existing test was changed). Mutation gate `integrity-6880-mutation-gate.mjs`
64
+ against a green baseline (15 tests in 2 files): 15 of 15 killed, each by a named test. `tsc --noEmit` clean. Pack dry run
65
+ 551 files (the LAN-first 549 plus `server/lib/transfer-integrity.ts` and the gate script).
66
+
67
+ ## 6.87.0 - A rough level per chunk for COS Control's glasses meeting strip
68
+
69
+ The server half of the level strip COS Control 0.5.339 draws in a glasses meeting's bottom bar (plan of 2026-10-23,
70
+ approved 09:25). The quiet gate already measured every live chunk (RMS, and Silero speech seconds) and only printed the
71
+ numbers on the `[perf]` line. Now each chunk keeps them.
72
+
73
+ - **Two numbers per chunk on the active-session record.** `chunkLevels[{i, level, speechSec}]` in
74
+ `active-sessions/<id>.json`: `level` is the chunk's RMS on a dBFS scale, 0 at -60 dBFS and under, 1 at -10 dBFS and
75
+ over, three decimals; `speechSec` is the Silero speech seconds (two decimals), or null when the gate fell back to
76
+ RMS. Every chunk the quiet gate sees gets one, quiet chunks included; a replayed index keeps its first answer, like
77
+ the chunk itself. Bounded to the newest 120 chunks (about 12 minutes of 6 s chunks) and restored, validated, after a
78
+ restart. It lives only as long as the record (deleted at save). `server/lib/chunk-levels.ts`.
79
+ - **Control reads it from the file it already reads.** The helper's `live-transcript` read opens this same file, so
80
+ there is no new route. `capabilities.liveMeeting.chunkLevels: true` on `/api/health` and `/api/models` tells Control
81
+ the record carries levels; an older server omits it and Control keeps its still glyph.
82
+ - **Not text, never logged.** Nothing new is logged, and no text is stored: the entries hold an index and two numbers.
83
+ `transcribe-stream-chunk-levels.test.ts` spies every console level and checks the record holds numbers only.
84
+ - **No spend.** No LLM, no new caller, no new disk write: the numbers ride on the durable write each chunk already makes.
85
+ - **Checks (6.87.0 branch).** Full suite, serial (`--maxWorkers=1`), HOME, COS_DATA_DIR and COS_PROFILE_PATH in a scratch folder,
86
+ COS_SCRIPTS_DIR unset: 584 files (583 passed, 1 skipped), 8815 tests, 8801 passed, 14 skipped, 0 failed. Mutation gate `live-meeting-6870-mutation-gate.mjs` against a green
87
+ baseline (32 tests in 4 files): 25 of 25 killed, each by a named
88
+ test, every target landing exactly once and restored byte for byte. `tsc --noEmit` clean. Pack dry run 546 files (6.86.0:
89
+ 545; the one new file is `server/lib/chunk-levels.ts`).
90
+
1
91
  ## 6.86.0 - Decision, promise and follow-up cues, live cues on Mac recordings, and the grey provisional line
2
92
 
3
93
  The server half of COS Control's live meeting view (plan of 2026-10-22, decisions of 15:21). Three lanes: three more cue
package/SECURITY.md CHANGED
@@ -43,6 +43,16 @@ reproduction you have. Do not include real transcripts.
43
43
  turns itself off after 10 minutes. Over Tailscale the traffic is encrypted.
44
44
  - Behind `tailscale serve` or `funnel`, requests arrive from a loopback proxy, so
45
45
  every claim is refused (`not_allowed_network`): pairing fails closed there.
46
+ - `GET /api/route-proof` (6.88.0, `docs/lan-first.md`) is public by design. It returns
47
+ the server instance id and HMAC-SHA256(token, "cos-route-proof:v1:" + nonce + ":" +
48
+ instance id), never the token, so the phone can check which Mac answers at a home
49
+ Wi-Fi address before sending the token there. The caller picks only a 22 to 64
50
+ character base64url nonce. It is served only for a token of 32 characters or more,
51
+ because a proof lets anyone who reaches the port test guesses offline. Limits: 20 a
52
+ minute per connection address, 240 a minute in all. It does not stop an active
53
+ attacker on the same Wi-Fi who relays the request to the real Mac: the LAN path is
54
+ plain HTTP, as above. `COS_LAN_FIRST=0` turns it off. The Mac's addresses are
55
+ published only on authenticated `/api/models`, never on `/api/health`.
46
56
  - `GET /api/display-stream` is public by design and returns lifecycle events
47
57
  only. Content requires a ticket or the token header. See
48
58
  `server/routes/display.ts` for the allowlist.
@@ -0,0 +1,158 @@
1
+ # LAN first: the server contract (6.88.0)
2
+
3
+ The server half of the glasses "LAN first" reconnect plan (2026-10-22, decisions of 2026-10-23). The phone
4
+ reaches the Mac over the home network first and over Tailscale second. The server adds two things, both
5
+ additive and both capability gated:
6
+
7
+ 1. `GET /api/route-proof`, a public identity proof, so the phone can check WHICH Mac answers at an address
8
+ before it sends `X-Cos-Token` there.
9
+ 2. `routes` on authenticated `GET /api/models`: the addresses this Mac answers on, re-learned after a DHCP change.
10
+
11
+ An older server has neither capability flag. A phone must then keep today's single stored URL and change
12
+ nothing. `COS_LAN_FIRST=0` in the server environment turns both pieces off (read per request).
13
+
14
+ ## Capability flags
15
+
16
+ Published on `GET /api/health` (public) and `GET /api/models` (token) under `capabilities`, from one helper,
17
+ so the two surfaces always agree:
18
+
19
+ ```json
20
+ "routeProof": { "protocol": 1 },
21
+ "lanRoutes": { "protocol": 1 }
22
+ ```
23
+
24
+ | Flag | Present when | Absent means |
25
+ |---|---|---|
26
+ | `routeProof` | the switch is on, the server instance id is minted, and the token is at least 32 characters | do not call `/api/route-proof`; never send the token to an unproven address |
27
+ | `lanRoutes` | the switch is on | `/api/models` will not carry `routes` |
28
+
29
+ Public health carries only these flags. It never carries an address.
30
+
31
+ ## `GET /api/route-proof?nonce=<nonce>`
32
+
33
+ Public: no `X-Cos-Token`. GET only.
34
+
35
+ ### Request
36
+
37
+ | Part | Rule |
38
+ |---|---|
39
+ | `nonce` | exactly one query parameter, 22 to 64 characters of base64url (`A-Z a-z 0-9 - _`), no padding. Fresh random per request; 16 random bytes encode to 22 characters. |
40
+ | Headers | none needed. Do not send `X-Cos-Token` to an address you have not proven. |
41
+ | Timeout | the plan's 1.5 s for a LAN proof. |
42
+
43
+ ### Success: `200`
44
+
45
+ ```json
46
+ { "v": 1, "serverInstanceId": "<uuid v4>", "proof": "<64 lowercase hex>" }
47
+ ```
48
+
49
+ `Cache-Control: no-store`.
50
+
51
+ ### The HMAC
52
+
53
+ ```
54
+ message = "cos-route-proof:v1:" + nonce + ":" + serverInstanceId (UTF-8)
55
+ proof = lowercase hex( HMAC-SHA256( key = token (UTF-8 bytes), message ) )
56
+ ```
57
+
58
+ `token` is the exact pairing token string the phone stores (the `X-Cos-Token` value). `serverInstanceId` is the
59
+ value in the response, which must also equal the phone's stored `active.serverInstanceId`.
60
+
61
+ Known-answer vector:
62
+
63
+ | Input | Value |
64
+ |---|---|
65
+ | token | `0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` |
66
+ | nonce | `AAAAAAAAAAAAAAAAAAAAAA` |
67
+ | serverInstanceId | `00000000-0000-4000-8000-000000000000` |
68
+ | message | `cos-route-proof:v1:AAAAAAAAAAAAAAAAAAAAAA:00000000-0000-4000-8000-000000000000` |
69
+ | proof | `445fc7d579849fa3fdfb8c2831cf0ace04c6db74e2817b75e87139cc9a3cd207` |
70
+
71
+ ### Phone-side check (the whole point)
72
+
73
+ The route counts only if ALL hold, and only then may the phone send `X-Cos-Token` to that address:
74
+
75
+ 1. Status 200 and `v === 1`.
76
+ 2. `serverInstanceId` equals the stored `active.serverInstanceId`.
77
+ 3. `proof` equals the phone's own HMAC over the nonce IT generated for this request and the STORED id.
78
+
79
+ Anything else (timeout, any other status, a body that does not parse, a mismatch) means "not my Mac": drop that
80
+ address for this cycle and never send the token there. Compare in constant time where the platform offers it;
81
+ the comparison happens on the phone, so there is no remote timing oracle either way.
82
+
83
+ ### Errors
84
+
85
+ Every error body is `{ "v": 1, "reason": "<code>" }` with `Cache-Control: no-store`.
86
+
87
+ | Status | `reason` | When | Phone should |
88
+ |---|---|---|---|
89
+ | 400 | `bad_nonce` | nonce missing, repeated, wrong length or charset | fix the client; treat as not proven |
90
+ | 401 | (the standard `pairing_token_rejected` body) | any verb other than GET without a token, or any path other than exactly `/api/route-proof` | treat as not proven |
91
+ | 404 | `disabled` | `COS_LAN_FIRST=0` | stop LAN proofs until the capability returns |
92
+ | 404 | (no body contract) | a server older than 6.88.0 | stop LAN proofs; it has no `routeProof` flag anyway |
93
+ | 405 | `method_not_allowed` | a non-GET verb that carried a valid token. `Allow: GET` | fix the client |
94
+ | 429 | `rate_limited` | over the limit below. `Retry-After` in seconds | wait `Retry-After`, keep the current route |
95
+ | 503 | `not_ready` | the instance id is not minted yet (still binding after a restart), or the token is too short to expose. `Retry-After: 30` | keep the current route, retry later |
96
+ | 403 | (network filter) | the caller is not on loopback, Tailscale or a private network | treat as not proven |
97
+
98
+ ### Rate limit
99
+
100
+ Per socket address (never `X-Forwarded-For`): 20 requests in any 60 s window. Global: 240 in any 60 s window
101
+ across all addresses. Bad-nonce requests spend the same budget. The plan's phone schedule (boot, foreground,
102
+ reconnect, then one every 60 s while on Tailscale) stays far below it.
103
+
104
+ ## `routes` on `GET /api/models` (token)
105
+
106
+ ```json
107
+ "routes": {
108
+ "v": 1,
109
+ "serverInstanceId": "<uuid v4, same as top-level serverInstanceId>",
110
+ "learnedAt": "<ISO time the interfaces were read>",
111
+ "lan": [ { "ip": "192.168.1.204", "port": 3141, "scheme": "http" } ],
112
+ "tailscale": [ { "ip": "100.108.149.114", "port": 3141, "scheme": "http" } ]
113
+ }
114
+ ```
115
+
116
+ | Field | Rule |
117
+ |---|---|
118
+ | `lan` | RFC1918 IPv4 (10/8, 172.16/12, 192.168/16) on a non-internal interface that is not a tunnel, bridge or VM network (`utun`, `tun`, `tap`, `ipsec`, `ppp`, `bridge`, `awdl`, `llw`, `vmnet`, `vboxnet`, `docker`, `br-`, `veth`, `virbr`, `lxc`, `lxd`, `cni`, `flannel`, `podman`, `cali`, `vnic`, `tailscale` are left out). Link-local 169.254/16 and public addresses never appear. |
119
+ | `tailscale` | 100.64/10 on Tailscale's own interface (`utun*` on macOS, `tailscale*` elsewhere). A carrier-grade-NAT address on Wi-Fi is not Tailscale and is left out. |
120
+ | `port` | the plain-http port (`PORT`, default 3141) for both kinds. HTTPS 3143 is never advertised: its certificate does not cover these addresses. |
121
+ | `scheme` | always `http` in protocol 1. |
122
+ | MagicDNS name | not published. Reading it means running `tailscale status --json` per request, which is not cheap; a later protocol may add `name`. |
123
+ | order | interface order as the OS reports it; at most 8 per list; no duplicates. |
124
+ | freshness | read from the interfaces at most every 10 s, so a new DHCP lease shows up on the next read after that. |
125
+ | absent | the switch is off, the instance id is not minted, or the interface read failed. The rest of `/api/models` is unchanged in every case. |
126
+
127
+ Node does not expose interface up/down flags; an address the OS still lists can appear. That is why every
128
+ address must pass the route proof before the token goes there.
129
+
130
+ Replace the stored LAN list each time `/api/models` is read on either path. Never hardcode an address.
131
+
132
+ ## What a stranger can learn
133
+
134
+ | Surface | Without the token |
135
+ |---|---|
136
+ | `/api/route-proof` | the instance id (already public on `/api/health` as `server_instance_id`), an HMAC of a message it cannot choose, and whether the token is long enough to be proven (it gets `not_ready` otherwise) |
137
+ | `/api/health` | the two protocol flags |
138
+ | `/api/models` | the standard 401 |
139
+
140
+ ## Security decisions
141
+
142
+ - **No token oracle.** The caller chooses only the nonce, its charset has no `:`, and the message always starts with
143
+ the fixed prefix and ends with the server's own id. The only other HMAC keyed by the token signs
144
+ `display-stream:<exp>` (display tickets), which can never start with the prefix, so a proof is never a ticket.
145
+ - **Offline guessing.** A proof lets anyone who can reach the port test token guesses offline. Server- and
146
+ Control-generated tokens (44 and 64 characters of random) make that hopeless; a token under 32 characters gets no
147
+ proof and no `routeProof` flag, so that install keeps today's behaviour.
148
+ - **What the proof does and does not stop.** It stops the token going to a DIFFERENT machine at a remembered
149
+ address (someone else's Wi-Fi, a reused DHCP lease). It does not stop an active attacker on the same Wi-Fi who relays
150
+ the proof request to the real Mac: the LAN path is plain HTTP, so the token crosses the home network unencrypted, as
151
+ a pasted token and "Allow pairing on this Wi-Fi" already do. Over Tailscale the traffic is encrypted.
152
+ - **Nothing logged.** The route never logs or echoes the nonce or the proof.
153
+ - **No state on disk.** The rate limiter is in memory; a restart clears it.
154
+
155
+ ## Not in this release
156
+
157
+ Phase 3 of the plan (a loopback `GET /api/glasses/link` for COS Control's banner) is not built: it needs new
158
+ tracking of the client's last path and check-in time, so it is not a trivially additive change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.86.0",
3
+ "version": "6.88.0",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -135,6 +135,10 @@
135
135
  "docs/test-plan-6.71.7.md",
136
136
  "!server/scripts/question-hold-mutation-gate.mjs",
137
137
  "!server/scripts/live-meeting-6860-mutation-gate.mjs",
138
+ "!server/scripts/live-meeting-6870-mutation-gate.mjs",
139
+ "!server/scripts/lan-first-mutation-gate.mjs",
140
+ "!server/scripts/integrity-6880-mutation-gate.mjs",
141
+ "docs/lan-first.md",
138
142
  "docs/test-plan-6.71.8.md",
139
143
  "docs/test-plan-6.71.9.md",
140
144
  "docs/test-plan-6.71.10.md",
package/server/index.ts CHANGED
@@ -147,7 +147,7 @@ import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversati
147
147
  import { getMediaStore } from './lib/media-store.js'
148
148
  import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
149
149
  import { serverMetrics } from './lib/server-metrics.js'
150
- import { initializeServerInstanceId } from './lib/server-instance-id.js'
150
+ import { getServerInstanceId, initializeServerInstanceId } from './lib/server-instance-id.js'
151
151
  import { appendPrivateEnvBlock, UnsafeUserConfigPathError } from './lib/secure-user-config.js'
152
152
  import { getTranscriptionProfileStatus } from './lib/profile.js'
153
153
  import { createQueryJobsRouter } from './routes/query-jobs.js'
@@ -191,6 +191,7 @@ import {
191
191
  import { requireApiToken } from './lib/api-auth.js'
192
192
  import { PairingState, pairingHostsFrom } from './lib/glasses-pairing.js'
193
193
  import { createPairingRouter } from './routes/pairing.js'
194
+ import { createRouteProofRouter } from './routes/route-proof.js'
194
195
  import { isManagedRuntime } from './lib/managed-runtime.js'
195
196
  import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
196
197
  import {
@@ -368,6 +369,14 @@ app.use('/api', createPairingRouter({
368
369
  state: () => pairingState,
369
370
  isDraining: () => gracefulShutdownStarted || !maintenanceAdmissionsOpen(),
370
371
  }))
372
+ // 6.88.0 LAN first (docs/lan-first.md): the public pre-token identity proof. Writes nothing,
373
+ // so it sits beside pairing, ahead of the lease catch-all and the global parser, and is
374
+ // served during a drain. The instance id is read per request: it is minted only after
375
+ // every listener binds, and until then the route answers 503 `not_ready`.
376
+ app.use('/api', createRouteProofRouter({
377
+ token: () => API_TOKEN,
378
+ serverInstanceId: getServerInstanceId,
379
+ }))
371
380
 
372
381
  // Fail-closed catch-all for mutation routes that do not own a more specific
373
382
  // lifecycle lease below. This closes the admission/drain race for secondary
@@ -43,10 +43,17 @@ const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64
43
43
  // Minting, status and decisions stay behind the token AND a loopback socket.
44
44
  const PAIRING_POLL_PATH = new RegExp(`^/pairing/claim/${PAIRING_NONCE_BODY}$`)
45
45
 
46
+ // 6.88.0 LAN first (docs/lan-first.md): GET /route-proof is public because it exists to
47
+ // prove which Mac answers at an address BEFORE the phone sends its token there. Exact path,
48
+ // GET only (HEAD and every other verb stay behind the token). It returns an HMAC keyed by
49
+ // the token over a fixed, domain-separated message, never the token (routes/route-proof.ts).
50
+ const ROUTE_PROOF_PATH = '/route-proof'
51
+
46
52
  export function isPublicApiRequest(method: string, path: string): boolean {
47
53
  if (PUBLIC_API_PATHS.has(path)) return true
48
54
  if (method === 'POST' && path === '/pairing/claim') return true
49
55
  if (method === 'GET' && PAIRING_POLL_PATH.test(path)) return true
56
+ if (method === 'GET' && path === ROUTE_PROOF_PATH) return true
50
57
  if (method !== 'GET' && method !== 'HEAD') return false
51
58
  return TTS_PLAYBACK_CAPABILITY_PATH.test(path)
52
59
  || DISPLAY_STREAM_CAPABILITY_PATH.test(path)
@@ -0,0 +1,64 @@
1
+ // 6.87.0: a rough level per live chunk, for COS Control's level strip on glasses meetings.
2
+ //
3
+ // The quiet gate already measures every chunk (stt-live.ts decideQuiet: RMS and, with Silero, speech seconds). Until
4
+ // 6.87.0 those numbers only reached the [perf] line. Now each chunk keeps two numbers on the active session record:
5
+ // `level`, 0 to 1 from the chunk's RMS on a dBFS scale, and `speechSec`, the Silero speech seconds (null when the gate
6
+ // fell back to RMS). Control reads them from the same active-session file its live transcript reads, so there is no
7
+ // new route and no new log line.
8
+ //
9
+ // NOT TEXT. Two numbers per chunk, bounded to the newest CHUNK_LEVELS_LIMIT chunks, living only as long as the
10
+ // active-session record (the 4-hour crash-recovery file, deleted at save). Nothing here logs.
11
+
12
+ /** The newest chunks kept: about 12 minutes of 6 s chunks, far more than the strip draws. */
13
+ export const CHUNK_LEVELS_LIMIT = 120
14
+ /** The dBFS that reads as 0: under the quiet gate's RMS 150 (about -47 dBFS) and the room floor of a G2 mic. */
15
+ export const LEVEL_FLOOR_DBFS = -60
16
+ /** The dBFS that reads as 1: loud close speech. */
17
+ export const LEVEL_CEILING_DBFS = -10
18
+
19
+ export interface ChunkLevel {
20
+ /** The chunk's own index. */
21
+ i: number
22
+ /** 0 to 1, three decimals. */
23
+ level: number
24
+ /** Silero speech seconds, two decimals; null when the quiet gate used RMS. */
25
+ speechSec: number | null
26
+ }
27
+
28
+ /** A chunk's RMS (PCM16 units) as 0 to 1: LEVEL_FLOOR_DBFS and under is 0, LEVEL_CEILING_DBFS and over is 1. */
29
+ export function normalizedLevel(rms: number): number {
30
+ if (!Number.isFinite(rms) || rms <= 0) return 0
31
+ const dbfs = 20 * Math.log10(rms / 32768)
32
+ const level = (dbfs - LEVEL_FLOOR_DBFS) / (LEVEL_CEILING_DBFS - LEVEL_FLOOR_DBFS)
33
+ return Math.round(Math.min(1, Math.max(0, level)) * 1000) / 1000
34
+ }
35
+
36
+ /** The stored entry for one chunk's quiet decision. */
37
+ export function chunkLevel(chunkIndex: number, rms: number, speechSec: number | null): ChunkLevel {
38
+ const speech = speechSec !== null && Number.isFinite(speechSec) ? Math.round(Math.max(0, speechSec) * 100) / 100 : null
39
+ return { i: chunkIndex, level: normalizedLevel(rms), speechSec: speech }
40
+ }
41
+
42
+ /** `levels` with `entry` in index order (a second entry for an index replaces the first), the newest
43
+ * CHUNK_LEVELS_LIMIT kept. Never mutates its input. */
44
+ export function withChunkLevel(levels: readonly ChunkLevel[] | undefined, entry: ChunkLevel): ChunkLevel[] {
45
+ const next = (levels ?? []).filter(l => l.i !== entry.i)
46
+ next.push(entry)
47
+ next.sort((a, b) => a.i - b.i)
48
+ return next.length > CHUNK_LEVELS_LIMIT ? next.slice(next.length - CHUNK_LEVELS_LIMIT) : next
49
+ }
50
+
51
+ /** The levels from a recovered session file: only well-formed entries, one per index, in order, bounded. */
52
+ export function restoreChunkLevels(value: unknown): ChunkLevel[] {
53
+ if (!Array.isArray(value)) return []
54
+ let levels: ChunkLevel[] = []
55
+ for (const raw of value) {
56
+ if (!raw || typeof raw !== 'object') continue
57
+ const { i, level, speechSec } = raw as Record<string, unknown>
58
+ if (!Number.isInteger(i) || (i as number) < 0) continue
59
+ if (typeof level !== 'number' || !Number.isFinite(level) || level < 0 || level > 1) continue
60
+ if (speechSec !== null && (typeof speechSec !== 'number' || !Number.isFinite(speechSec) || speechSec < 0)) continue
61
+ levels = withChunkLevel(levels, { i: i as number, level, speechSec: speechSec as number | null })
62
+ }
63
+ return levels
64
+ }
@@ -0,0 +1,236 @@
1
+ // 6.88.0 LAN first (docs/lan-first.md). Two additive pieces for the phone:
2
+ //
3
+ // 1. A pre-token identity proof. `GET /api/route-proof?nonce=<phone nonce>` is public, so a
4
+ // phone can ask an address it has not used before "are you the Mac I paired with?"
5
+ // BEFORE it sends X-Cos-Token there. The answer is an HMAC keyed by the token over a
6
+ // fixed, domain-separated message; the phone computes the same HMAC with its stored
7
+ // token and sends the token only on a match. A machine without the token cannot make a
8
+ // matching proof, and the response never carries the token.
9
+ // 2. The addresses this Mac answers on, published ONLY on authenticated /api/models (never
10
+ // on public /api/health), so the phone can learn its LAN route after a DHCP change.
11
+ //
12
+ // This module is pure apart from the cached interface read. Clock, interfaces, token and
13
+ // instance id are injected so every rule is testable.
14
+
15
+ import { createHmac } from 'node:crypto'
16
+ import { networkInterfaces as osNetworkInterfaces } from 'node:os'
17
+ import { isRfc1918Ipv4, isTailscaleIpv4, normalizeRemoteIp } from './network-policy.js'
18
+
19
+ export const ROUTE_PROOF_PROTOCOL = 1
20
+ export const LAN_ROUTES_PROTOCOL = 1
21
+
22
+ /**
23
+ * The fixed domain-separation prefix. The caller chooses only the nonce, never the whole
24
+ * message, and the nonce charset has no `:`, so the message always parses one way. The only
25
+ * other HMAC keyed by the token signs `display-stream:<exp>` (lib/display-ticket.ts), which
26
+ * can never begin with this prefix, so a proof is never a usable display ticket.
27
+ */
28
+ export const ROUTE_PROOF_PREFIX = 'cos-route-proof:v1:'
29
+ /** base64url alphabet, 22 to 64 characters. 22 base64url characters carry 132 bits. */
30
+ export const ROUTE_PROOF_NONCE_PATTERN = /^[A-Za-z0-9_-]{22,64}$/
31
+ /**
32
+ * A proof lets anyone on the network check token guesses offline, so it is served only for
33
+ * a token long enough that guessing is hopeless. COS Control writes 64 hex characters and
34
+ * the server's own token is 44 base64url characters; a short hand-set legacy token gets no
35
+ * proof (the phone keeps today's single-route behaviour).
36
+ */
37
+ export const ROUTE_PROOF_MIN_TOKEN_LENGTH = 32
38
+ export const ROUTE_PROOF_IP_LIMIT = 20
39
+ export const ROUTE_PROOF_GLOBAL_LIMIT = 240
40
+ export const ROUTE_PROOF_WINDOW_MS = 60_000
41
+ /** Past this many tracked addresses, idle ones are dropped. Live ones are bounded by the global ceiling. */
42
+ export const ROUTE_PROOF_MAX_TRACKED_IPS = 1024
43
+
44
+ export const LAN_ROUTES_CACHE_MS = 10_000
45
+ export const LAN_ROUTES_MAX_PER_KIND = 8
46
+
47
+ /** `COS_LAN_FIRST=0` turns off both pieces (read per request). Anything else leaves them on. */
48
+ export function lanFirstEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
49
+ return env.COS_LAN_FIRST !== '0'
50
+ }
51
+
52
+ /** The plain-http port the phone uses on both paths (server/index.ts reads the same `PORT`). */
53
+ export function lanFirstHttpPort(env: NodeJS.ProcessEnv = process.env): number {
54
+ const port = parseInt(env.PORT ?? '3141', 10)
55
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3141
56
+ }
57
+
58
+ export function isValidRouteProofNonce(value: unknown): value is string {
59
+ return typeof value === 'string' && ROUTE_PROOF_NONCE_PATTERN.test(value)
60
+ }
61
+
62
+ export function routeProofTokenUsable(token: unknown): token is string {
63
+ return typeof token === 'string' && token.length >= ROUTE_PROOF_MIN_TOKEN_LENGTH
64
+ }
65
+
66
+ export function routeProofMessage(nonce: string, serverInstanceId: string): string {
67
+ return `${ROUTE_PROOF_PREFIX}${nonce}:${serverInstanceId}`
68
+ }
69
+
70
+ /** Lowercase hex HMAC-SHA256(token, "cos-route-proof:v1:" + nonce + ":" + serverInstanceId). */
71
+ export function computeRouteProof(token: string, nonce: string, serverInstanceId: string): string {
72
+ return createHmac('sha256', token).update(routeProofMessage(nonce, serverInstanceId), 'utf8').digest('hex')
73
+ }
74
+
75
+ /** True when the proof is servable right now: switch on, instance id minted, token strong. */
76
+ export function routeProofAvailable(input: { token: unknown; serverInstanceId: string | null; env?: NodeJS.ProcessEnv }): boolean {
77
+ return lanFirstEnabled(input.env) && !!input.serverInstanceId && routeProofTokenUsable(input.token)
78
+ }
79
+
80
+ /** `capabilities.routeProof` / `capabilities.lanRoutes`, or nothing. Absent means "do not try". */
81
+ export function lanFirstCapabilities(input: { token: unknown; serverInstanceId: string | null; env?: NodeJS.ProcessEnv }): {
82
+ routeProof?: { protocol: number }
83
+ lanRoutes?: { protocol: number }
84
+ } {
85
+ if (!lanFirstEnabled(input.env)) return {}
86
+ return {
87
+ ...(routeProofAvailable(input) ? { routeProof: { protocol: ROUTE_PROOF_PROTOCOL } } : {}),
88
+ lanRoutes: { protocol: LAN_ROUTES_PROTOCOL },
89
+ }
90
+ }
91
+
92
+ // ── Rate limit ───────────────────────────────────────────────────────────────
93
+
94
+ export type RouteProofAdmission = { ok: true } | { ok: false; retryAfterSeconds: number }
95
+
96
+ /** Sliding one-minute window per remote address, plus a global ceiling. In memory only. */
97
+ export class RouteProofRateLimiter {
98
+ private readonly perIp = new Map<string, number[]>()
99
+ private global: number[] = []
100
+
101
+ constructor(private readonly now: () => number = () => Date.now()) {}
102
+
103
+ admit(remoteAddress: string | undefined | null): RouteProofAdmission {
104
+ const at = this.now()
105
+ const ip = normalizeRemoteIp(remoteAddress ?? '') || 'unknown'
106
+ const live = (times: number[]) => times.filter(t => at - t < ROUTE_PROOF_WINDOW_MS)
107
+ const retry = (oldest: number) => Math.max(1, Math.ceil((ROUTE_PROOF_WINDOW_MS - (at - oldest)) / 1000))
108
+
109
+ this.global = live(this.global)
110
+ const recent = live(this.perIp.get(ip) ?? [])
111
+ if (recent.length >= ROUTE_PROOF_IP_LIMIT) {
112
+ this.perIp.set(ip, recent)
113
+ return { ok: false, retryAfterSeconds: retry(recent[0]) }
114
+ }
115
+ if (this.global.length >= ROUTE_PROOF_GLOBAL_LIMIT) {
116
+ // Nothing is stored for a refused address, so a flood of new addresses grows nothing.
117
+ if (recent.length) this.perIp.set(ip, recent)
118
+ return { ok: false, retryAfterSeconds: retry(this.global[0]) }
119
+ }
120
+ recent.push(at)
121
+ this.global.push(at)
122
+ this.perIp.set(ip, recent)
123
+ // Live entries are bounded by the global ceiling, so dropping the idle ones is enough.
124
+ if (this.perIp.size > ROUTE_PROOF_MAX_TRACKED_IPS) {
125
+ for (const [key, times] of this.perIp) if (live(times).length === 0) this.perIp.delete(key)
126
+ }
127
+ return { ok: true }
128
+ }
129
+
130
+ /** For tests: how many addresses the limiter is holding. */
131
+ trackedAddressCount(): number {
132
+ return this.perIp.size
133
+ }
134
+ }
135
+
136
+ // ── Routes ───────────────────────────────────────────────────────────────────
137
+
138
+ export interface RouteEntry {
139
+ ip: string
140
+ port: number
141
+ scheme: 'http'
142
+ }
143
+
144
+ export interface LanRoutes {
145
+ v: number
146
+ serverInstanceId: string
147
+ learnedAt: string
148
+ lan: RouteEntry[]
149
+ tailscale: RouteEntry[]
150
+ }
151
+
152
+ type InterfaceInfo = { address: string; family: string | number; internal: boolean }
153
+ export type InterfaceTable = Record<string, InterfaceInfo[] | undefined>
154
+
155
+ /**
156
+ * Interfaces a phone on the home Wi-Fi can never reach through: VPN tunnels (utun is checked
157
+ * separately for Tailscale), Internet Sharing and VM bridges, AirDrop (awdl), low-latency WLAN
158
+ * (llw), VM host-only networks, and Linux container bridges and veth pairs.
159
+ */
160
+ const NOT_LAN_INTERFACE = /^(utun|tun|tap|ipsec|ppp|bridge|awdl|llw|vmnet|vboxnet|docker|br-|veth|virbr|lxc|lxd|cni|flannel|podman|cali|vnic|tailscale)/i
161
+ /** Tailscale's own interface: utunN on macOS, tailscale0 on Linux, "Tailscale" on Windows. */
162
+ const TAILSCALE_INTERFACE = /^(utun|tailscale)/i
163
+
164
+ function ipv4(info: InterfaceInfo): boolean {
165
+ const family = typeof info.family === 'number' ? (info.family === 4 ? 'IPv4' : 'IPv6') : info.family
166
+ return family === 'IPv4' && !info.internal
167
+ }
168
+
169
+ /**
170
+ * Pure. LAN = RFC1918 IPv4 on a non-internal interface that is not a tunnel, bridge or VM
171
+ * network. Tailscale = 100.64/10 on Tailscale's own interface (a carrier-grade-NAT address
172
+ * on en0 is not Tailscale and is left out). Link-local 169.254/16 is neither. Each list keeps
173
+ * interface order, is de-duplicated, and is capped.
174
+ */
175
+ export function buildRouteLists(nets: InterfaceTable, port: number): { lan: RouteEntry[]; tailscale: RouteEntry[] } {
176
+ const lan: RouteEntry[] = []
177
+ const tailscale: RouteEntry[] = []
178
+ const seen = new Set<string>()
179
+ for (const [name, infos] of Object.entries(nets)) {
180
+ for (const info of infos ?? []) {
181
+ if (!info || typeof info.address !== 'string' || !ipv4(info)) continue
182
+ const ip = info.address
183
+ if (seen.has(ip)) continue
184
+ if (isTailscaleIpv4(ip)) {
185
+ if (TAILSCALE_INTERFACE.test(name) && tailscale.length < LAN_ROUTES_MAX_PER_KIND) {
186
+ seen.add(ip)
187
+ tailscale.push({ ip, port, scheme: 'http' })
188
+ }
189
+ continue
190
+ }
191
+ if (isRfc1918Ipv4(ip) && !NOT_LAN_INTERFACE.test(name) && lan.length < LAN_ROUTES_MAX_PER_KIND) {
192
+ seen.add(ip)
193
+ lan.push({ ip, port, scheme: 'http' })
194
+ }
195
+ }
196
+ }
197
+ return { lan, tailscale }
198
+ }
199
+
200
+ let readInterfaces: () => InterfaceTable = () => osNetworkInterfaces() as InterfaceTable
201
+ let cache: { at: number; port: number; lists: { lan: RouteEntry[]; tailscale: RouteEntry[] } } | null = null
202
+
203
+ /**
204
+ * The `routes` field for authenticated /api/models, or null to omit it. Re-read from the
205
+ * interfaces at most every 10 s, so a DHCP change shows up within one cache period. Never
206
+ * throws: a failed interface read omits the field and leaves the rest of the response alone.
207
+ */
208
+ export function lanRoutesField(input: {
209
+ port: number
210
+ serverInstanceId: string | null
211
+ env?: NodeJS.ProcessEnv
212
+ now?: () => number
213
+ }): LanRoutes | null {
214
+ try {
215
+ if (!lanFirstEnabled(input.env) || !input.serverInstanceId) return null
216
+ const at = (input.now ?? Date.now)()
217
+ if (!cache || cache.port !== input.port || at - cache.at >= LAN_ROUTES_CACHE_MS || at < cache.at) {
218
+ cache = { at, port: input.port, lists: buildRouteLists(readInterfaces(), input.port) }
219
+ }
220
+ return {
221
+ v: LAN_ROUTES_PROTOCOL,
222
+ serverInstanceId: input.serverInstanceId,
223
+ learnedAt: new Date(cache.at).toISOString(),
224
+ lan: cache.lists.lan.map(r => ({ ...r })),
225
+ tailscale: cache.lists.tailscale.map(r => ({ ...r })),
226
+ }
227
+ } catch {
228
+ cache = null
229
+ return null
230
+ }
231
+ }
232
+
233
+ export function __setInterfaceReaderForTests(reader: (() => InterfaceTable) | null): void {
234
+ readInterfaces = reader ?? (() => osNetworkInterfaces() as InterfaceTable)
235
+ cache = null
236
+ }
@@ -80,7 +80,8 @@ export function liveProvisional(): LiveProvisionalStore { return (store ??= new
80
80
  /** 6.86.0: what /api/health and /api/models publish as `capabilities.liveMeeting`, so COS Control gates the live meeting
81
81
  * view by capability, never by a status code. Constants only (health is public). An older server omits the key.
82
82
  * - macLiveCues: POST /api/mac-recording/start takes `liveCues: true` and answers `liveCues: {armed, ...}`.
83
- * - provisional: GET <route>?sessionId= serves the newest preview of a live meeting. */
84
- export function liveMeetingCapability(): { protocol: 1; macLiveCues: true; provisional: { route: string; maxChars: number; ttlMs: number } } {
85
- return { protocol: 1, macLiveCues: true, provisional: { route: '/api/transcribe-stream/provisional', maxChars: LIVE_PROVISIONAL_LIMITS.maxChars, ttlMs: LIVE_PROVISIONAL_LIMITS.ttlMs } }
83
+ * - provisional: GET <route>?sessionId= serves the newest preview of a live meeting.
84
+ * - chunkLevels (6.87.0): the active-session record keeps `chunkLevels[{i, level, speechSec}]` (lib/chunk-levels.ts). */
85
+ export function liveMeetingCapability(): { protocol: 1; macLiveCues: true; provisional: { route: string; maxChars: number; ttlMs: number }; chunkLevels: true } {
86
+ return { protocol: 1, macLiveCues: true, provisional: { route: '/api/transcribe-stream/provisional', maxChars: LIVE_PROVISIONAL_LIMITS.maxChars, ttlMs: LIVE_PROVISIONAL_LIMITS.ttlMs }, chunkLevels: true }
86
87
  }
@@ -0,0 +1,59 @@
1
+ // 6.88.0: transfer integrity counts what the phone captured, not only what the Mac heard of.
2
+ //
3
+ // WHAT HAPPENED. 2026-10-23 07:59, meeting t2xs9u (glasses 6.10.651). A relaunched COS window saved
4
+ // the meeting from the 74 chunks the Mac held while the window still recording it had captured
5
+ // through index 120. The saved file said "Transfer integrity 100%": the gap report only looks for
6
+ // holes in [0, highest index received], so 47 chunks the Mac never heard of did not count.
7
+ //
8
+ // THE RULE. When the phone has said how far it captured (the Stop's `finalChunkIndex`, or a save's
9
+ // own `finalChunkIndex`, glasses 6.10.652), the expected count is that index plus one, and every
10
+ // index from the Mac's highest received chunk to it is missing. 74 of 121 reads 61.1%.
11
+ //
12
+ // And a phone's ORIGIN-WIPE RECOVERY save (`recovery: 'origin_wipe'`, glasses 6.10.652) is refused
13
+ // with 409 `chunks_outstanding` while the phone has said it captured chunks the Mac does not hold,
14
+ // unless it says it waited them out (`acceptMissing: true`). The phone checks the same thing before
15
+ // it asks; this closes the moment between its status read and its save (a Stop landing in between).
16
+ // A save without `recovery` is never refused here: every existing caller behaves as before.
17
+
18
+ import type { TranscriptGapReport } from '../routes/transcribe-stream.js'
19
+
20
+ /** The stop route's own bound on a final index. */
21
+ export const PHONE_FINAL_INDEX_LIMIT = 1_000_000
22
+ /** A phone-said index this far past the Mac's highest chunk is capped (about 30 hours of 6 s chunks),
23
+ * so one bad number cannot write a huge missing list into the sidecar. */
24
+ export const PHONE_FINAL_INDEX_MAX_AHEAD = 20_000
25
+
26
+ /** A final index as the stop route accepts it (a whole number, -1 or more), else null. */
27
+ export function parsePhoneFinalChunkIndex(value: unknown): number | null {
28
+ return typeof value === 'number' && Number.isInteger(value) && value >= -1 && value <= PHONE_FINAL_INDEX_LIMIT ? value : null
29
+ }
30
+
31
+ /** The highest index any phone window said it captured, or null when none said. */
32
+ export function phoneFinalChunkIndex(...said: Array<number | null | undefined>): number | null {
33
+ const known = said.filter((v): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0)
34
+ return known.length > 0 ? Math.max(...known) : null
35
+ }
36
+
37
+ /** The gap report with the phone's final index counted. Unchanged when the phone said nothing new. */
38
+ export function withPhoneFinalIndex(report: TranscriptGapReport | null, phoneFinal: number | null): TranscriptGapReport | null {
39
+ if (!report || phoneFinal === null || phoneFinal <= report.maxIndex) return report
40
+ const final = Math.min(phoneFinal, Math.max(report.maxIndex, 0) + PHONE_FINAL_INDEX_MAX_AHEAD)
41
+ const missingIndices = [...report.missingIndices]
42
+ for (let index = report.maxIndex + 1; index <= final; index++) missingIndices.push(index)
43
+ const expected = final + 1
44
+ return {
45
+ ...report,
46
+ maxIndex: final,
47
+ expected,
48
+ missingIndices,
49
+ completeness: expected > 0 ? report.received / expected : 1,
50
+ }
51
+ }
52
+
53
+ /** Chunks in [0, phoneFinal] the Mac does not hold. */
54
+ export function chunksOutstanding(report: TranscriptGapReport | null, phoneFinal: number | null): number {
55
+ if (phoneFinal === null) return 0
56
+ const counted = withPhoneFinalIndex(report, phoneFinal)
57
+ if (!counted) return phoneFinal + 1
58
+ return counted.missingIndices.filter(index => index <= phoneFinal).length
59
+ }
@@ -75,6 +75,7 @@ import { liveCuesCapability } from '../lib/live-cues-capability.js'
75
75
  import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
76
76
  import { liveMeetingCapability } from '../lib/live-provisional.js'
77
77
  import { CUE_KINDS } from '../../shared/live-cues-v2.js'
78
+ import { lanFirstCapabilities, lanFirstHttpPort, lanRoutesField } from '../lib/lan-first.js'
78
79
 
79
80
  /** 6.86.0: `capabilities.liveCues.kinds`, the cue kinds this server can write (additive; an older server omits it, which
80
81
  * is how a client knows to expect only reinforce and challenge). Which kinds a given meeting gets is in its arm answer. */
@@ -547,6 +548,9 @@ healthRouter.get('/health', async (_req, res) => {
547
548
  tasks: {
548
549
  gate: taskBridgeAvailable() ? 'ready' : 'disabled',
549
550
  },
551
+ // 6.88.0 LAN first (docs/lan-first.md): protocol flags only. This route is public, so
552
+ // the addresses themselves are published only on authenticated /api/models.
553
+ ...lanFirstCapabilities({ token: process.env.COS_API_TOKEN ?? '', serverInstanceId: getServerInstanceId() }),
550
554
  },
551
555
  // /api/health is intentionally unauthenticated for setup diagnostics.
552
556
  // Publish capability only; job counts, retention identities, subscriber
@@ -638,6 +642,10 @@ healthRouter.get('/models', async (req, res) => {
638
642
  // Key lanes (plan v4 3.1): `keyProfiles` only while a profile is active. Absent when off.
639
643
  ...keyProfilesModelsField(),
640
644
  serverInstanceId: getServerInstanceId(),
645
+ // 6.88.0 LAN first (docs/lan-first.md): the addresses this Mac answers on, behind the
646
+ // token. Absent when the switch is off, the instance id is not minted yet, or the
647
+ // interface read failed; never a reason for this response to fail.
648
+ ...lanRoutesModelsField(),
641
649
  // Minted here rather than on a route of its own: this response is already
642
650
  // authenticated, and the mint is stateless, so it costs nothing and stores
643
651
  // nothing.
@@ -733,9 +741,16 @@ healthRouter.get('/models', async (req, res) => {
733
741
  tasks: {
734
742
  gate: taskBridgeAvailable() ? 'ready' : 'disabled',
735
743
  },
744
+ // 6.88.0 LAN first: the same helper as /api/health, so the two surfaces cannot disagree.
745
+ ...lanFirstCapabilities({ token: apiToken, serverInstanceId: getServerInstanceId() }),
736
746
  },
737
747
  })
738
748
  })
749
+ /** 6.88.0: `{ routes }` for /api/models, or nothing at all. lanRoutesField never throws. */
750
+ function lanRoutesModelsField(): { routes?: NonNullable<ReturnType<typeof lanRoutesField>> } {
751
+ const routes = lanRoutesField({ port: lanFirstHttpPort(), serverInstanceId: getServerInstanceId() })
752
+ return routes ? { routes } : {}
753
+ }
739
754
  /** 6.78.0: `{ steer }` for /api/models `durableQueryJobs`, or nothing at all. */
740
755
  export function steerCapabilityField(jobsEnabled: boolean): { steer?: { protocolVersion: 1; providers: ['claude'] } } {
741
756
  const steer = messagesSteerCapability({ jobsEnabled, cliSupported: claudeSteerSupportKnown() })
@@ -12,6 +12,7 @@ import { Router } from 'express'
12
12
  import { emitDisplay } from '../lib/display-bus.js'
13
13
  import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
14
14
  import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
15
+ import { chunksOutstanding, parsePhoneFinalChunkIndex, phoneFinalChunkIndex, withPhoneFinalIndex } from '../lib/transfer-integrity.js'
15
16
  import {
16
17
  AUTONOMOUS_FINALIZE_POLL_MS,
17
18
  AUTONOMOUS_FINALIZE_SETTLE_MS,
@@ -1128,6 +1129,24 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1128
1129
  if (MAC_CAPTURE_ID.test(sessionId)) {
1129
1130
  requireMacRecordingForSave(sessionId, body?.expectedChunkCount)
1130
1131
  }
1132
+ // 6.88.0 (lib/transfer-integrity.ts): how far a phone window said it captured, from its Stop or
1133
+ // from this save (glasses 6.10.652). The saved file's integrity counts against it.
1134
+ const phoneFinal = phoneFinalChunkIndex(stopIntents.get(sessionId)?.finalChunkIndex, parsePhoneFinalChunkIndex(body?.finalChunkIndex))
1135
+ // An origin-wipe recovery never finalizes a meeting a phone window still holds chunks for, unless
1136
+ // it says it waited them out. A save without `recovery` is never refused here.
1137
+ if (body?.recovery === 'origin_wipe' && body?.acceptMissing !== true) {
1138
+ const report = sessions.getIntegrity(sessionId)
1139
+ const outstanding = report ? chunksOutstanding(report, phoneFinal) : 0
1140
+ if (outstanding > 0) {
1141
+ respond(409, {
1142
+ error: 'A phone window captured chunks this Mac does not hold yet',
1143
+ reason: 'chunks_outstanding',
1144
+ missingCount: outstanding,
1145
+ finalChunkIndex: phoneFinal,
1146
+ })
1147
+ return null
1148
+ }
1149
+ }
1131
1150
  if (savingSessions.has(sessionId)) {
1132
1151
  respond(409, { error: 'Meeting save already in progress', reason: 'save_in_progress' })
1133
1152
  return null
@@ -1166,7 +1185,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1166
1185
  const durationMs = durationFromTimeline > 0
1167
1186
  ? durationFromTimeline
1168
1187
  : Math.max(0, Date.now() - startTime)
1169
- const integrity = sessions.getIntegrity(sessionId)
1188
+ const integrity = withPhoneFinalIndex(sessions.getIntegrity(sessionId), phoneFinal)
1170
1189
  const needsOperations = cosOpsPipelineConfigured() && !macRecording
1171
1190
  // Standalone saves need a finalization pass too, for summary enrichment.
1172
1191
  // Before 6.37 a standalone save with no audio nulled the job below and
@@ -0,0 +1,83 @@
1
+ // 6.88.0 LAN first: the pre-token identity proof (docs/lan-first.md).
2
+ //
3
+ // GET /api/route-proof?nonce=<22-64 base64url chars> is PUBLIC (an exact GET door in
4
+ // api-auth.ts), because it exists to prove which Mac answers at an address BEFORE the phone
5
+ // sends its token there. Rules, each with its reason:
6
+ // - The nonce is validated strictly (charset and length) and never echoed or logged.
7
+ // - The message is fixed: "cos-route-proof:v1:" + nonce + ":" + serverInstanceId. The caller
8
+ // picks only the nonce, so this is never a general HMAC oracle, and the prefix keeps it
9
+ // apart from the display-stream ticket, the only other HMAC keyed by the token.
10
+ // - The response carries the instance id (already on public /api/health as
11
+ // server_instance_id) and the HMAC. Never the token, never an address, never a version.
12
+ // - Served only while lib/lan-first.ts `routeProofAvailable` holds: switch on, instance id
13
+ // minted, token at least 32 characters (a proof would let a stranger test guesses of a
14
+ // short token offline).
15
+ // - Rate limited per socket address (never req.ip or X-Forwarded-For) plus a global ceiling.
16
+ // - Writes nothing, so it takes no lease and is served during a drain.
17
+
18
+ import { Router, type Request, type Response } from 'express'
19
+ import {
20
+ ROUTE_PROOF_PROTOCOL,
21
+ RouteProofRateLimiter,
22
+ computeRouteProof,
23
+ isValidRouteProofNonce,
24
+ lanFirstEnabled,
25
+ routeProofTokenUsable,
26
+ } from '../lib/lan-first.js'
27
+
28
+ export type RouteProofReason = 'bad_nonce' | 'rate_limited' | 'not_ready' | 'disabled' | 'method_not_allowed'
29
+
30
+ export const ROUTE_PROOF_REASON_STATUS: Record<RouteProofReason, number> = {
31
+ bad_nonce: 400,
32
+ disabled: 404,
33
+ method_not_allowed: 405,
34
+ rate_limited: 429,
35
+ not_ready: 503,
36
+ }
37
+
38
+ export interface RouteProofRouterOptions {
39
+ /** Read per request, so a token change is honoured without a remount. */
40
+ token: () => string
41
+ serverInstanceId: () => string | null
42
+ env?: () => NodeJS.ProcessEnv
43
+ limiter?: RouteProofRateLimiter
44
+ }
45
+
46
+ function send(res: Response, reason: RouteProofReason, retryAfterSeconds?: number): void {
47
+ if (retryAfterSeconds != null) res.setHeader('Retry-After', String(retryAfterSeconds))
48
+ res.setHeader('Cache-Control', 'no-store')
49
+ res.status(ROUTE_PROOF_REASON_STATUS[reason]).json({ v: ROUTE_PROOF_PROTOCOL, reason })
50
+ }
51
+
52
+ export function createRouteProofRouter(options: RouteProofRouterOptions): Router {
53
+ const router = Router()
54
+ const limiter = options.limiter ?? new RouteProofRateLimiter()
55
+ const env = options.env ?? (() => process.env)
56
+
57
+ router.all('/route-proof', (req: Request, res: Response) => {
58
+ // The public door in api-auth.ts is GET only, so any other method has already been
59
+ // refused without a token. With a token it is still refused here: one shape, one verb.
60
+ if (req.method !== 'GET') {
61
+ res.setHeader('Allow', 'GET')
62
+ return send(res, 'method_not_allowed')
63
+ }
64
+ if (!lanFirstEnabled(env())) return send(res, 'disabled')
65
+ // Counted before the nonce check, so malformed requests spend the same budget.
66
+ const admission = limiter.admit(req.socket?.remoteAddress)
67
+ if (!admission.ok) return send(res, 'rate_limited', admission.retryAfterSeconds)
68
+ const nonce = req.query.nonce
69
+ if (!isValidRouteProofNonce(nonce)) return send(res, 'bad_nonce')
70
+ let token = ''
71
+ try { token = options.token() } catch { token = '' }
72
+ const serverInstanceId = options.serverInstanceId()
73
+ if (!serverInstanceId || !routeProofTokenUsable(token)) return send(res, 'not_ready', 30)
74
+ res.setHeader('Cache-Control', 'no-store')
75
+ res.json({
76
+ v: ROUTE_PROOF_PROTOCOL,
77
+ serverInstanceId,
78
+ proof: computeRouteProof(token, nonce, serverInstanceId),
79
+ })
80
+ })
81
+
82
+ return router
83
+ }
@@ -117,6 +117,7 @@ import {
117
117
  import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
118
118
  import { armMacLiveCues, type MacLiveCuesAnswer } from '../lib/live-cues-v2.js'
119
119
  import { liveProvisional } from '../lib/live-provisional.js'
120
+ import { chunkLevel, restoreChunkLevels, withChunkLevel, type ChunkLevel } from '../lib/chunk-levels.js'
120
121
  import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
121
122
  import {
122
123
  scheduleProgressiveHqCheckpoint,
@@ -348,6 +349,8 @@ interface TranscriptSession {
348
349
  /** Exact replay records for silent/filtered ASR completions. */
349
350
  emptyCompletions?: Record<string, EmptyTranscriptCompletion>
350
351
  maxChunkIndex?: number
352
+ /** 6.87.0: two numbers per chunk (level 0 to 1, Silero speech seconds) for Control's level strip. Never text. */
353
+ chunkLevels?: ChunkLevel[]
351
354
  /** Persisted idle-retention clock. Meeting date/duration still use startTime. */
352
355
  lastActivityAt: number
353
356
  // Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
@@ -713,6 +716,7 @@ function persistSessionRequired(sessionId: string): void {
713
716
  emptyCompletions: session.emptyCompletions ?? {},
714
717
  maxChunkIndex: session.maxChunkIndex ?? -1,
715
718
  providerCandidates: session.providerCandidates ?? {},
719
+ chunkLevels: session.chunkLevels ?? [],
716
720
  })
717
721
  try {
718
722
  durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
@@ -901,6 +905,7 @@ function recoverSessions(): void {
901
905
  providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
902
906
  ? data.providerCandidates
903
907
  : {},
908
+ chunkLevels: restoreChunkLevels(data.chunkLevels),
904
909
  }
905
910
  // Retroactively strip inline hallucinations from recovered chunks.
906
911
  // Two-pass: (1) scan all chunks to build frequency/blocklist,
@@ -2439,6 +2444,8 @@ async function processStreamChunk(opts: {
2439
2444
  const quiet = decideQuiet(audioBuffer, stt.quietGate)
2440
2445
  const rms = quiet.rms
2441
2446
  const isQuiet = quiet.isQuiet
2447
+ // 6.87.0: the level strip's two numbers, on the record every terminal path below persists. Not text, never logged.
2448
+ session.chunkLevels = withChunkLevel(session.chunkLevels, chunkLevel(chunkIndex, rms, quiet.speechSec))
2442
2449
  const whisperAudio = isQuiet ? audioBuffer : await asrFrontEnd(audioBuffer, stt.frontEnd)
2443
2450
  const whisperContext = recentWhisperContext(sessionId, session)
2444
2451
  console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)