@gotcos/glasses-server 6.88.0 → 6.89.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/docs/lan-first.md DELETED
@@ -1,158 +0,0 @@
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.
@@ -1,236 +0,0 @@
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
- }
@@ -1,83 +0,0 @@
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
- }