@gotcos/glasses-server 6.65.0 → 6.67.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.
@@ -0,0 +1,121 @@
1
+ // Glasses pairing routes (6.67.0). Contract: docs/pairing-contract.md.
2
+ //
3
+ // Mounted under /api AFTER requireApiToken and BEFORE the mutation-lease catch-all and
4
+ // the global 10 MB JSON parser:
5
+ // - POST /pairing/claim and GET /pairing/claim/:nonce are public (exact matches in
6
+ // isPublicApiRequest); everything else here needs the token AND a loopback socket.
7
+ // - The routes write no files, so they never take the mutation lease. During a drain or
8
+ // shutdown, minting, claiming, status and decisions answer 503 `draining`. The poll is
9
+ // still served: it writes nothing, and a token the user already allowed must still
10
+ // reach the phone if the update gate closes between Allow and the next poll.
11
+ // - Each route parses its own body with a 1 KB cap.
12
+ //
13
+ // Every address decision reads `req.socket.remoteAddress`, never `req.ip` or
14
+ // X-Forwarded-For. Codes, nonces, tokens and QR text are never logged (lib/glasses-pairing.ts
15
+ // logs the IP, the result and a 2-character hash of the code).
16
+
17
+ import express, { Router, type ErrorRequestHandler, type Request, type RequestHandler, type Response } from 'express'
18
+ import { isLoopbackSocket } from '../lib/network-policy.js'
19
+ import {
20
+ PAIRING_NONCE_PATTERN,
21
+ PAIRING_REASON_MESSAGE,
22
+ PAIRING_REASON_STATUS,
23
+ PairingError,
24
+ type PairingReason,
25
+ type PairingState,
26
+ } from '../lib/glasses-pairing.js'
27
+
28
+ export interface PairingRouterOptions {
29
+ /** Read per request so a restart created after mount is honoured in tests. */
30
+ state: () => PairingState
31
+ /** True while the server is shutting down or the maintenance gate is closed. */
32
+ isDraining: () => boolean
33
+ }
34
+
35
+ function sendReason(res: Response, reason: PairingReason, retryAfterSeconds?: number, status?: number): void {
36
+ if (retryAfterSeconds != null) res.setHeader('Retry-After', String(retryAfterSeconds))
37
+ res.status(status ?? PAIRING_REASON_STATUS[reason]).json({ reason, message: PAIRING_REASON_MESSAGE[reason] })
38
+ }
39
+
40
+ function sendError(res: Response, error: unknown): void {
41
+ if (error instanceof PairingError) return sendReason(res, error.reason, error.retryAfterSeconds)
42
+ console.error('[pairing] internal error:', error instanceof Error ? error.message : 'unknown')
43
+ res.status(500).json({ reason: 'internal', message: 'Pairing failed on the Mac. Try again.' })
44
+ }
45
+
46
+ function socketIp(req: Request): string {
47
+ return req.socket?.remoteAddress ?? ''
48
+ }
49
+
50
+ export function createPairingRouter(options: PairingRouterOptions): Router {
51
+ const router = Router()
52
+ const smallJson = express.json({ limit: '1kb' })
53
+
54
+ const notDraining: RequestHandler = (_req, res, next) => {
55
+ if (options.isDraining()) return sendReason(res, 'draining', 30)
56
+ next()
57
+ }
58
+ const loopbackOnly: RequestHandler = (req, res, next) => {
59
+ if (!isLoopbackSocket(req)) return sendReason(res, 'not_loopback')
60
+ next()
61
+ }
62
+ const body = (req: Request): Record<string, unknown> =>
63
+ req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body as Record<string, unknown> : {}
64
+
65
+ router.post('/pairing/code', notDraining, loopbackOnly, smallJson, (req, res) => {
66
+ const input = body(req)
67
+ if (input.allowLan !== undefined && typeof input.allowLan !== 'boolean') return sendReason(res, 'bad_request')
68
+ try {
69
+ res.json(options.state().mint({ allowLan: input.allowLan === true }))
70
+ } catch (error) { sendError(res, error) }
71
+ })
72
+
73
+ router.post('/pairing/claim', notDraining, smallJson, (req, res) => {
74
+ const input = body(req)
75
+ try {
76
+ options.state().claim({ code: input.code, nonce: input.nonce }, socketIp(req))
77
+ res.status(202).json({ pending: true })
78
+ } catch (error) { sendError(res, error) }
79
+ })
80
+
81
+ router.get('/pairing/claim/:nonce', (req, res) => {
82
+ // Express routes HEAD to a GET handler. Only a real GET may read (and so spend) the
83
+ // token; api-auth.ts already keeps HEAD behind the token, this holds it here too.
84
+ if (req.method !== 'GET') {
85
+ res.setHeader('Allow', 'GET')
86
+ return sendReason(res, 'bad_request', undefined, 405)
87
+ }
88
+ const nonce = req.params.nonce
89
+ if (typeof nonce !== 'string' || !PAIRING_NONCE_PATTERN.test(nonce)) return sendReason(res, 'bad_request')
90
+ try {
91
+ res.setHeader('Cache-Control', 'no-store')
92
+ res.json(options.state().poll(nonce, socketIp(req)))
93
+ } catch (error) { sendError(res, error) }
94
+ })
95
+
96
+ router.get('/pairing/status', notDraining, loopbackOnly, (_req, res) => {
97
+ try {
98
+ res.setHeader('Cache-Control', 'no-store')
99
+ res.json(options.state().status())
100
+ } catch (error) { sendError(res, error) }
101
+ })
102
+
103
+ router.post('/pairing/decision', notDraining, loopbackOnly, smallJson, (req, res) => {
104
+ const input = body(req)
105
+ try {
106
+ options.state().decide(input.nonce, input.allow)
107
+ res.json({ ok: true })
108
+ } catch (error) { sendError(res, error) }
109
+ })
110
+
111
+ // A body over 1 KB or one that is not JSON answers in the contract's shape, not
112
+ // Express's HTML error page. Scoped to this router's own parsers.
113
+ const parseError: ErrorRequestHandler = (error, req, res, next) => {
114
+ if (!req.path.startsWith('/pairing/')) return next(error)
115
+ if (res.headersSent) return next(error)
116
+ sendReason(res, 'bad_request')
117
+ }
118
+ router.use(parseError)
119
+
120
+ return router
121
+ }
@@ -136,7 +136,7 @@ async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { s
136
136
  console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
137
137
  }
138
138
  if (!(opts.enabled ?? autoCleanDefaultEnabled())) return cleaned
139
- const model = opts.model ?? 'sonnet'
139
+ const model = opts.model ?? 'haiku'
140
140
  const autoCleanBreaker = autoCleanBreakers[model]
141
141
  if (cleaned.length > AUTOCLEAN_MAX_CHARS || autoCleanBreaker.isOpen() || autoCleanCountToday() >= autoCleanDailyCap()) return cleaned
142
142
  const startedAt = Date.now()