@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.
- package/.env.example +5 -0
- package/CHANGELOG.md +24 -0
- package/README.md +32 -6
- package/SECURITY.md +16 -0
- package/bin/cli.cjs +20 -29
- package/bin/whisper-runtime.cjs +17 -0
- package/bin/whisper-runtime.d.cts +1 -0
- package/docs/pairing-contract.md +118 -0
- package/docs/pairing-fixture.json +94 -0
- package/package.json +7 -2
- package/server/index.ts +31 -16
- package/server/lib/api-auth.ts +27 -2
- package/server/lib/dictation-clean-models.ts +3 -3
- package/server/lib/dictation-clean.ts +1 -1
- package/server/lib/glasses-pairing.ts +444 -0
- package/server/lib/health-static-probes.ts +4 -2
- package/server/lib/network-policy.ts +64 -0
- package/server/lib/whisper-local.ts +4 -11
- package/server/lib/whisper-preview.ts +2 -2
- package/server/routes/health.ts +5 -0
- package/server/routes/pairing.ts +121 -0
- package/server/routes/prompt-drafts.ts +1 -1
|
@@ -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 ?? '
|
|
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()
|