@schlessera/brain-ui-server 0.4.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.
Files changed (188) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/agent/backend.d.ts +30 -0
  4. package/dist/agent/backend.d.ts.map +1 -0
  5. package/dist/agent/backend.js +226 -0
  6. package/dist/agent/backend.js.map +1 -0
  7. package/dist/app.d.ts +27 -0
  8. package/dist/app.d.ts.map +1 -0
  9. package/dist/app.js +116 -0
  10. package/dist/app.js.map +1 -0
  11. package/dist/brain/client.d.ts +43 -0
  12. package/dist/brain/client.d.ts.map +1 -0
  13. package/dist/brain/client.js +154 -0
  14. package/dist/brain/client.js.map +1 -0
  15. package/dist/brain/types.d.ts +41 -0
  16. package/dist/brain/types.d.ts.map +1 -0
  17. package/dist/brain/types.js +2 -0
  18. package/dist/brain/types.js.map +1 -0
  19. package/dist/cron/scheduler.d.ts +9 -0
  20. package/dist/cron/scheduler.d.ts.map +1 -0
  21. package/dist/cron/scheduler.js +58 -0
  22. package/dist/cron/scheduler.js.map +1 -0
  23. package/dist/db/client.d.ts +10 -0
  24. package/dist/db/client.d.ts.map +1 -0
  25. package/dist/db/client.js +73 -0
  26. package/dist/db/client.js.map +1 -0
  27. package/dist/files/walker.d.ts +63 -0
  28. package/dist/files/walker.d.ts.map +1 -0
  29. package/dist/files/walker.js +416 -0
  30. package/dist/files/walker.js.map +1 -0
  31. package/dist/index.d.ts +18 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +25 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/middleware/auth.d.ts +59 -0
  36. package/dist/middleware/auth.d.ts.map +1 -0
  37. package/dist/middleware/auth.js +246 -0
  38. package/dist/middleware/auth.js.map +1 -0
  39. package/dist/middleware/passkeys.d.ts +36 -0
  40. package/dist/middleware/passkeys.d.ts.map +1 -0
  41. package/dist/middleware/passkeys.js +427 -0
  42. package/dist/middleware/passkeys.js.map +1 -0
  43. package/dist/middleware/tailscale.d.ts +31 -0
  44. package/dist/middleware/tailscale.d.ts.map +1 -0
  45. package/dist/middleware/tailscale.js +64 -0
  46. package/dist/middleware/tailscale.js.map +1 -0
  47. package/dist/render/template.d.ts +7 -0
  48. package/dist/render/template.d.ts.map +1 -0
  49. package/dist/render/template.js +112 -0
  50. package/dist/render/template.js.map +1 -0
  51. package/dist/routes/brain.d.ts +155 -0
  52. package/dist/routes/brain.d.ts.map +1 -0
  53. package/dist/routes/brain.js +250 -0
  54. package/dist/routes/brain.js.map +1 -0
  55. package/dist/routes/files.d.ts +89 -0
  56. package/dist/routes/files.d.ts.map +1 -0
  57. package/dist/routes/files.js +91 -0
  58. package/dist/routes/files.js.map +1 -0
  59. package/dist/routes/health.d.ts +37 -0
  60. package/dist/routes/health.d.ts.map +1 -0
  61. package/dist/routes/health.js +27 -0
  62. package/dist/routes/health.js.map +1 -0
  63. package/dist/routes/providers.d.ts +33 -0
  64. package/dist/routes/providers.d.ts.map +1 -0
  65. package/dist/routes/providers.js +9 -0
  66. package/dist/routes/providers.js.map +1 -0
  67. package/dist/routes/render.d.ts +25 -0
  68. package/dist/routes/render.d.ts.map +1 -0
  69. package/dist/routes/render.js +47 -0
  70. package/dist/routes/render.js.map +1 -0
  71. package/dist/routes/sessions.d.ts +79 -0
  72. package/dist/routes/sessions.d.ts.map +1 -0
  73. package/dist/routes/sessions.js +37 -0
  74. package/dist/routes/sessions.js.map +1 -0
  75. package/dist/routes/voice.d.ts +92 -0
  76. package/dist/routes/voice.d.ts.map +1 -0
  77. package/dist/routes/voice.js +64 -0
  78. package/dist/routes/voice.js.map +1 -0
  79. package/dist/voice/deepgram-token.d.ts +5 -0
  80. package/dist/voice/deepgram-token.d.ts.map +1 -0
  81. package/dist/voice/deepgram-token.js +63 -0
  82. package/dist/voice/deepgram-token.js.map +1 -0
  83. package/dist/voice/keyterm-builder.d.ts +18 -0
  84. package/dist/voice/keyterm-builder.d.ts.map +1 -0
  85. package/dist/voice/keyterm-builder.js +391 -0
  86. package/dist/voice/keyterm-builder.js.map +1 -0
  87. package/dist/voice/speech-providers.d.ts +25 -0
  88. package/dist/voice/speech-providers.d.ts.map +1 -0
  89. package/dist/voice/speech-providers.js +104 -0
  90. package/dist/voice/speech-providers.js.map +1 -0
  91. package/dist/ws/attachments.d.ts +10 -0
  92. package/dist/ws/attachments.d.ts.map +1 -0
  93. package/dist/ws/attachments.js +63 -0
  94. package/dist/ws/attachments.js.map +1 -0
  95. package/dist/ws/bridge.d.ts +7 -0
  96. package/dist/ws/bridge.d.ts.map +1 -0
  97. package/dist/ws/bridge.js +86 -0
  98. package/dist/ws/bridge.js.map +1 -0
  99. package/dist/ws/clients.d.ts +20 -0
  100. package/dist/ws/clients.d.ts.map +1 -0
  101. package/dist/ws/clients.js +48 -0
  102. package/dist/ws/clients.js.map +1 -0
  103. package/dist/ws/connection.d.ts +8 -0
  104. package/dist/ws/connection.d.ts.map +1 -0
  105. package/dist/ws/connection.js +116 -0
  106. package/dist/ws/connection.js.map +1 -0
  107. package/dist/ws/dispatch.d.ts +5 -0
  108. package/dist/ws/dispatch.d.ts.map +1 -0
  109. package/dist/ws/dispatch.js +139 -0
  110. package/dist/ws/dispatch.js.map +1 -0
  111. package/dist/ws/frames.d.ts +17 -0
  112. package/dist/ws/frames.d.ts.map +1 -0
  113. package/dist/ws/frames.js +37 -0
  114. package/dist/ws/frames.js.map +1 -0
  115. package/dist/ws/handler.d.ts +34 -0
  116. package/dist/ws/handler.d.ts.map +1 -0
  117. package/dist/ws/handler.js +33 -0
  118. package/dist/ws/handler.js.map +1 -0
  119. package/dist/ws/history.d.ts +17 -0
  120. package/dist/ws/history.d.ts.map +1 -0
  121. package/dist/ws/history.js +45 -0
  122. package/dist/ws/history.js.map +1 -0
  123. package/dist/ws/host.d.ts +40 -0
  124. package/dist/ws/host.d.ts.map +1 -0
  125. package/dist/ws/host.js +49 -0
  126. package/dist/ws/host.js.map +1 -0
  127. package/dist/ws/routing.d.ts +12 -0
  128. package/dist/ws/routing.d.ts.map +1 -0
  129. package/dist/ws/routing.js +31 -0
  130. package/dist/ws/routing.js.map +1 -0
  131. package/dist/ws/run-session.d.ts +17 -0
  132. package/dist/ws/run-session.d.ts.map +1 -0
  133. package/dist/ws/run-session.js +160 -0
  134. package/dist/ws/run-session.js.map +1 -0
  135. package/dist/ws/session-catalog.d.ts +25 -0
  136. package/dist/ws/session-catalog.d.ts.map +1 -0
  137. package/dist/ws/session-catalog.js +45 -0
  138. package/dist/ws/session-catalog.js.map +1 -0
  139. package/dist/ws/shrink.d.ts +34 -0
  140. package/dist/ws/shrink.d.ts.map +1 -0
  141. package/dist/ws/shrink.js +91 -0
  142. package/dist/ws/shrink.js.map +1 -0
  143. package/dist/ws/turns.d.ts +77 -0
  144. package/dist/ws/turns.d.ts.map +1 -0
  145. package/dist/ws/turns.js +89 -0
  146. package/dist/ws/turns.js.map +1 -0
  147. package/migrations/001_initial.sql +29 -0
  148. package/migrations/002_session_provider.sql +1 -0
  149. package/migrations/003_session_backend.sql +5 -0
  150. package/migrations/004_drop_gemini_backend.sql +20 -0
  151. package/migrations/005_passkey_credentials.sql +18 -0
  152. package/package.json +72 -0
  153. package/src/agent/backend.ts +308 -0
  154. package/src/app.ts +159 -0
  155. package/src/brain/client.ts +186 -0
  156. package/src/brain/types.ts +44 -0
  157. package/src/cron/scheduler.ts +80 -0
  158. package/src/db/client.ts +85 -0
  159. package/src/files/walker.ts +418 -0
  160. package/src/index.ts +39 -0
  161. package/src/middleware/auth.ts +321 -0
  162. package/src/middleware/passkeys.ts +510 -0
  163. package/src/middleware/tailscale.ts +81 -0
  164. package/src/render/template.ts +124 -0
  165. package/src/routes/brain.ts +305 -0
  166. package/src/routes/files.ts +99 -0
  167. package/src/routes/health.ts +29 -0
  168. package/src/routes/providers.ts +9 -0
  169. package/src/routes/render.ts +68 -0
  170. package/src/routes/sessions.ts +49 -0
  171. package/src/routes/voice.ts +80 -0
  172. package/src/voice/deepgram-token.ts +80 -0
  173. package/src/voice/keyterm-builder.ts +426 -0
  174. package/src/voice/speech-providers.ts +111 -0
  175. package/src/ws/attachments.ts +86 -0
  176. package/src/ws/bridge.ts +139 -0
  177. package/src/ws/clients.ts +57 -0
  178. package/src/ws/connection.ts +132 -0
  179. package/src/ws/dispatch.ts +162 -0
  180. package/src/ws/frames.ts +42 -0
  181. package/src/ws/handler.ts +50 -0
  182. package/src/ws/history.ts +51 -0
  183. package/src/ws/host.ts +69 -0
  184. package/src/ws/routing.ts +43 -0
  185. package/src/ws/run-session.ts +192 -0
  186. package/src/ws/session-catalog.ts +97 -0
  187. package/src/ws/shrink.ts +95 -0
  188. package/src/ws/turns.ts +151 -0
@@ -0,0 +1,510 @@
1
+ import { Hono } from "hono";
2
+ import type { Context } from "hono";
3
+ import {
4
+ generateAuthenticationOptions,
5
+ generateRegistrationOptions,
6
+ verifyAuthenticationResponse as realVerifyAuthentication,
7
+ verifyRegistrationResponse as realVerifyRegistration,
8
+ } from "@simplewebauthn/server";
9
+ import type {
10
+ AuthenticationResponseJSON,
11
+ RegistrationResponseJSON,
12
+ WebAuthnCredential,
13
+ } from "@simplewebauthn/server";
14
+ import {
15
+ type AuthMode,
16
+ issueSessionCookie,
17
+ consumeLoginToken,
18
+ LOGIN_RATE_LIMIT,
19
+ GLOBAL_LOGIN_RATE_LIMIT,
20
+ } from "./auth.js";
21
+ import { clientIp } from "./tailscale.js";
22
+ import { getDb } from "../db/client.js";
23
+
24
+ /**
25
+ * WebAuthn passkeys as an extension of `password` mode: the password bootstraps
26
+ * the first registration (and stays as recovery), passkeys are the day-to-day
27
+ * login. A successful assertion mints the same session cookie as password login
28
+ * (issueSessionCookie), so authGuard / isWsAuthorized / TTL are untouched.
29
+ *
30
+ * Self-contained on purpose — this module (plus its auth.ts imports) is slated
31
+ * to move wholesale into @schlessera/ui-server.
32
+ *
33
+ * Credentials are RP-scoped: a credential registered on localhost does not
34
+ * exist for the production domain and vice versa. Rows carry rp_id so all
35
+ * environments share the table.
36
+ */
37
+
38
+ const CHALLENGE_TTL_MS = 120_000; // > the 60s ceremony timeout
39
+ // Pending challenges are unauthenticated state; cap and evict oldest-first so
40
+ // login-options spam can't grow memory. Losing one costs a retried ceremony.
41
+ const MAX_PENDING_CHALLENGES = 100;
42
+ const OPTIONS_RATE_LIMIT = 10; // login-options per IP per minute (own bucket)
43
+ // Branding/identity are config-injected with the historical values as
44
+ // defaults, so existing deployments keep their credentials.
45
+ function rpName(): string {
46
+ return process.env.WEBAUTHN_RP_NAME || "Brain UI";
47
+ }
48
+ // The single "user" every passkey belongs to. Stable across registrations so
49
+ // an authenticator overwrites its existing entry instead of stacking
50
+ // duplicates for the same site.
51
+ //
52
+ // WARNING: the user handle is part of the WebAuthn wire contract — it is
53
+ // burned into every resident credential at registration. Changing
54
+ // WEBAUTHN_USER_ID on a deployment that already has passkeys orphans them
55
+ // (authenticators will present a handle the server no longer recognizes as
56
+ // the same user and re-registration stacks a second entry). Set it before the
57
+ // first passkey is registered and never change it.
58
+ function userName(): string {
59
+ return process.env.WEBAUTHN_USER_NAME || "owner";
60
+ }
61
+ /** WebAuthn caps `user.id` at 64 bytes; browsers reject anything longer. */
62
+ const MAX_USER_ID_BYTES = 64;
63
+
64
+ function userId(): Uint8Array<ArrayBuffer> {
65
+ return new TextEncoder().encode(
66
+ process.env.WEBAUTHN_USER_ID || "brain-ui-owner"
67
+ ) as Uint8Array<ArrayBuffer>;
68
+ }
69
+
70
+ /**
71
+ * Refuse to boot on a WebAuthn user handle the browser would reject. Called
72
+ * from createApp(): an oversized handle otherwise produces registration
73
+ * options that look fine server-side and fail silently in every browser.
74
+ */
75
+ export function assertPasskeyConfig(): void {
76
+ const bytes = userId().byteLength;
77
+ if (bytes > MAX_USER_ID_BYTES) {
78
+ throw new Error(
79
+ `WEBAUTHN_USER_ID is ${bytes} bytes; WebAuthn allows at most ${MAX_USER_ID_BYTES}. ` +
80
+ "Pick a shorter stable identifier (it is burned into every credential)."
81
+ );
82
+ }
83
+ }
84
+ // ES256 + RS256 only: every mainstream authenticator supports ES256, and it
85
+ // keeps Ed25519 (whose WebCrypto verify path on Bun is unproven) out of the
86
+ // database. Widen once Bun's Ed25519 verify is validated.
87
+ const SUPPORTED_ALGORITHM_IDS = [-7, -257];
88
+
89
+ type ChallengeType = "registration" | "authentication";
90
+
91
+ /** Injectable seams for tests; production callers pass nothing. */
92
+ export interface PasskeyDeps {
93
+ verifyRegistrationResponse?: typeof realVerifyRegistration;
94
+ verifyAuthenticationResponse?: typeof realVerifyAuthentication;
95
+ now?: () => number;
96
+ }
97
+
98
+ // --- challenge store (in-memory: single-process server; move to a shared
99
+ // store if this ever runs multi-process) ---
100
+
101
+ const pendingChallenges = new Map<string, { type: ChallengeType; expiresAt: number }>();
102
+
103
+ function putChallenge(challenge: string, type: ChallengeType, now: number): void {
104
+ for (const [key, entry] of pendingChallenges) {
105
+ if (entry.expiresAt <= now) pendingChallenges.delete(key);
106
+ }
107
+ while (pendingChallenges.size >= MAX_PENDING_CHALLENGES) {
108
+ const oldest = pendingChallenges.keys().next().value;
109
+ if (oldest === undefined) break;
110
+ pendingChallenges.delete(oldest);
111
+ }
112
+ pendingChallenges.set(challenge, { type, expiresAt: now + CHALLENGE_TTL_MS });
113
+ }
114
+
115
+ /** Single-use: deleted before verification, so a failed verify still burns it. */
116
+ function consumeChallenge(challenge: string, type: ChallengeType, now: number): boolean {
117
+ const entry = pendingChallenges.get(challenge);
118
+ if (!entry) return false;
119
+ pendingChallenges.delete(challenge);
120
+ return entry.type === type && entry.expiresAt > now;
121
+ }
122
+
123
+ /** The challenge the browser actually signed, from clientDataJSON. */
124
+ function challengeFromResponse(response: {
125
+ response: { clientDataJSON: string };
126
+ }): string | null {
127
+ try {
128
+ const json = JSON.parse(
129
+ Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8")
130
+ ) as { challenge?: unknown };
131
+ return typeof json.challenge === "string" ? json.challenge : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ // --- RP / origin resolution ---
138
+
139
+ function envList(name: string): string[] {
140
+ return (process.env[name] ?? "")
141
+ .split(",")
142
+ .map((s) => s.trim())
143
+ .filter(Boolean);
144
+ }
145
+
146
+ function isLoopbackHostname(hostname: string): boolean {
147
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
148
+ }
149
+
150
+ /**
151
+ * Derive the relying party from the request instead of demanding new env:
152
+ * same-origin deployments, split topology (ALLOWED_ORIGINS), and dev all work
153
+ * unconfigured. WEBAUTHN_ORIGINS / WEBAUTHN_RP_ID remain as overrides for
154
+ * proxies that rewrite Host.
155
+ */
156
+ function resolveRp(c: Context): { rpID: string; origin: string } | null {
157
+ const origin = c.req.header("origin");
158
+ if (!origin) return null;
159
+
160
+ let url: URL;
161
+ try {
162
+ url = new URL(origin);
163
+ } catch {
164
+ return null;
165
+ }
166
+
167
+ const allowed =
168
+ envList("WEBAUTHN_ORIGINS").includes(origin) ||
169
+ envList("ALLOWED_ORIGINS").includes(origin) ||
170
+ url.host === c.req.header("host") ||
171
+ // Dev escape hatch: the vite proxy sets changeOrigin, so the server sees
172
+ // Host localhost:3000 but Origin http://localhost:5173. Opt-in via an
173
+ // explicit flag — NOT NODE_ENV, whose absence must never widen the RP.
174
+ (process.env.BRAIN_UI_ALLOW_LOOPBACK_ORIGIN === "1" &&
175
+ isLoopbackHostname(url.hostname));
176
+ if (!allowed) return null;
177
+
178
+ return { rpID: process.env.WEBAUTHN_RP_ID ?? url.hostname, origin };
179
+ }
180
+
181
+ /** rpID for endpoints where same-origin GETs legitimately omit Origin. */
182
+ function rpIdForRead(c: Context): string {
183
+ if (process.env.WEBAUTHN_RP_ID) return process.env.WEBAUTHN_RP_ID;
184
+ const origin = c.req.header("origin");
185
+ if (origin) {
186
+ try {
187
+ return new URL(origin).hostname;
188
+ } catch {
189
+ // fall through to Host
190
+ }
191
+ }
192
+ const host = c.req.header("host") ?? "localhost";
193
+ return host.replace(/:\d+$/, "");
194
+ }
195
+
196
+ // --- persistence ---
197
+
198
+ interface CredentialRow {
199
+ id: string;
200
+ public_key: Uint8Array;
201
+ counter: number;
202
+ transports: string | null;
203
+ rp_id: string;
204
+ aaguid: string | null;
205
+ device_type: string | null;
206
+ backed_up: number;
207
+ label: string;
208
+ created_at: number;
209
+ last_used_at: number | null;
210
+ }
211
+
212
+ /** Wire shape shared with the client (shared/protocol.ts PasskeySummary). */
213
+ function toSummary(row: CredentialRow) {
214
+ return {
215
+ id: row.id,
216
+ label: row.label,
217
+ rpId: row.rp_id,
218
+ createdAt: row.created_at,
219
+ lastUsedAt: row.last_used_at,
220
+ backedUp: row.backed_up === 1,
221
+ deviceType: row.device_type,
222
+ transports: row.transports ? (JSON.parse(row.transports) as string[]) : null,
223
+ aaguid: row.aaguid,
224
+ };
225
+ }
226
+
227
+ function credentialsForRp(rpID: string): CredentialRow[] {
228
+ return getDb()
229
+ .query("SELECT * FROM passkey_credentials WHERE rp_id = ?")
230
+ .all(rpID) as CredentialRow[];
231
+ }
232
+
233
+ function credentialById(id: string): CredentialRow | null {
234
+ return getDb()
235
+ .query("SELECT * FROM passkey_credentials WHERE id = ?")
236
+ .get(id) as CredentialRow | null;
237
+ }
238
+
239
+ function toWebAuthnCredential(row: CredentialRow): WebAuthnCredential {
240
+ return {
241
+ id: row.id,
242
+ publicKey: new Uint8Array(row.public_key),
243
+ counter: row.counter,
244
+ transports: row.transports
245
+ ? (JSON.parse(row.transports) as WebAuthnCredential["transports"])
246
+ : undefined,
247
+ };
248
+ }
249
+
250
+ function sanitizeLabel(label: unknown): string {
251
+ return typeof label === "string" ? label.trim().slice(0, 64) : "";
252
+ }
253
+
254
+ function notEnabled(c: Context) {
255
+ return c.json({ error: "Passkey login is not enabled" }, 400);
256
+ }
257
+
258
+ /**
259
+ * Once a passkey exists for the request's RP, password login is disabled —
260
+ * the password becomes registration-bootstrap and break-glass recovery only
261
+ * (set BRAIN_UI_ALLOW_PASSWORD=1 to re-enable it, e.g. after losing every
262
+ * authenticator). Scoped per RP so e.g. localhost dev keeps password login
263
+ * until it has its own credential. Host-derived rpID is fine here: a forged
264
+ * Host can at most re-enable password auth, which still requires the password.
265
+ */
266
+ export function passwordLoginDisabled(c: Context): boolean {
267
+ if (process.env.BRAIN_UI_ALLOW_PASSWORD === "1") return false;
268
+ const row = getDb()
269
+ .query("SELECT COUNT(*) AS n FROM passkey_credentials WHERE rp_id = ?")
270
+ .get(rpIdForRead(c)) as { n: number };
271
+ return row.n > 0;
272
+ }
273
+
274
+ /**
275
+ * Unauthenticated passkey routes. Mount BEFORE the auth guard, next to
276
+ * authRoutes.
277
+ */
278
+ export function passkeyPublicRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hono {
279
+ const verifyAuthentication = deps.verifyAuthenticationResponse ?? realVerifyAuthentication;
280
+ const now = deps.now ?? Date.now;
281
+ const app = new Hono();
282
+
283
+ // Capability hint for the login screen: which methods can it offer?
284
+ app.get("/auth/methods", (c) => {
285
+ c.header("Cache-Control", "no-store");
286
+ if (mode !== "password") {
287
+ return c.json({ password: false, passkey: false });
288
+ }
289
+ const row = getDb()
290
+ .query("SELECT COUNT(*) AS n FROM passkey_credentials WHERE rp_id = ?")
291
+ .get(rpIdForRead(c)) as { n: number };
292
+ return c.json({ password: !passwordLoginDisabled(c), passkey: row.n > 0 });
293
+ });
294
+
295
+ app.post("/auth/passkey/login-options", async (c) => {
296
+ if (mode !== "password") return notEnabled(c);
297
+ const ip = clientIp(c, process.env.TRUST_PROXY === "1") || "unknown";
298
+ // Own bucket: conditional-UI mounts fire this on every login-screen load
299
+ // and must not starve real verify attempts of their shared budget.
300
+ if (!consumeLoginToken(`pk-opt:${ip}`, OPTIONS_RATE_LIMIT)) {
301
+ return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
302
+ }
303
+ const rp = resolveRp(c);
304
+ if (!rp) return c.json({ error: "Origin not allowed" }, 400);
305
+
306
+ // Empty allowCredentials + discoverable credentials = usernameless login.
307
+ const options = await generateAuthenticationOptions({
308
+ rpID: rp.rpID,
309
+ allowCredentials: [],
310
+ userVerification: "required",
311
+ });
312
+ putChallenge(options.challenge, "authentication", now());
313
+ return c.json(options);
314
+ });
315
+
316
+ app.post("/auth/passkey/login-verify", async (c) => {
317
+ if (mode !== "password") return notEnabled(c);
318
+ const ip = clientIp(c, process.env.TRUST_PROXY === "1") || "unknown";
319
+ // Same buckets as password login: one combined online-guess budget.
320
+ const perIpOk = consumeLoginToken(`ip:${ip}`, LOGIN_RATE_LIMIT);
321
+ const globalOk = consumeLoginToken("global", GLOBAL_LOGIN_RATE_LIMIT);
322
+ if (!perIpOk || !globalOk) {
323
+ return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
324
+ }
325
+ const rp = resolveRp(c);
326
+ if (!rp) return c.json({ error: "Origin not allowed" }, 400);
327
+
328
+ let response: AuthenticationResponseJSON;
329
+ try {
330
+ response = await c.req.json();
331
+ } catch {
332
+ return c.json({ error: "Invalid request body" }, 400);
333
+ }
334
+
335
+ const fail = () => c.json({ error: "Passkey verification failed" }, 401);
336
+
337
+ const row = typeof response?.id === "string" ? credentialById(response.id) : null;
338
+ if (!row || row.rp_id !== rp.rpID) return fail();
339
+
340
+ const challenge = challengeFromResponse(response);
341
+ if (!challenge || !consumeChallenge(challenge, "authentication", now())) {
342
+ return fail();
343
+ }
344
+
345
+ try {
346
+ const result = await verifyAuthentication({
347
+ response,
348
+ expectedChallenge: challenge,
349
+ expectedOrigin: rp.origin,
350
+ expectedRPID: rp.rpID,
351
+ credential: toWebAuthnCredential(row),
352
+ });
353
+ if (!result.verified) return fail();
354
+ getDb()
355
+ .prepare(
356
+ "UPDATE passkey_credentials SET counter = ?, last_used_at = ? WHERE id = ?"
357
+ )
358
+ .run(result.authenticationInfo.newCounter, now(), row.id);
359
+ } catch (err) {
360
+ const message = err instanceof Error ? err.message : String(err);
361
+ // The library throws on a counter regression — possible cloned
362
+ // credential. Cloud passkeys legitimately sit at 0, so warn, don't
363
+ // revoke.
364
+ if (/counter/i.test(message)) {
365
+ console.warn(
366
+ `[passkeys] counter regression for credential ${row.id} — possible cloned credential`
367
+ );
368
+ } else {
369
+ console.warn(`[passkeys] authentication failed: ${message}`);
370
+ }
371
+ return fail();
372
+ }
373
+
374
+ await issueSessionCookie(c);
375
+ return c.json({ ok: true });
376
+ });
377
+
378
+ return app;
379
+ }
380
+
381
+ /**
382
+ * Session-gated passkey routes. Mount AFTER the auth guard — gating comes from
383
+ * mount position, not per-route checks.
384
+ */
385
+ export function passkeyManagementRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hono {
386
+ const verifyRegistration = deps.verifyRegistrationResponse ?? realVerifyRegistration;
387
+ const now = deps.now ?? Date.now;
388
+ const app = new Hono();
389
+
390
+ app.post("/auth/passkey/register-options", async (c) => {
391
+ if (mode !== "password") return notEnabled(c);
392
+ const rp = resolveRp(c);
393
+ if (!rp) return c.json({ error: "Origin not allowed" }, 400);
394
+
395
+ const options = await generateRegistrationOptions({
396
+ rpName: rpName(),
397
+ rpID: rp.rpID,
398
+ userName: userName(),
399
+ userID: userId(),
400
+ userDisplayName: rpName(),
401
+ attestationType: "none",
402
+ excludeCredentials: credentialsForRp(rp.rpID).map((row) => ({
403
+ id: row.id,
404
+ transports: toWebAuthnCredential(row).transports,
405
+ })),
406
+ authenticatorSelection: {
407
+ residentKey: "required",
408
+ userVerification: "required",
409
+ },
410
+ supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
411
+ });
412
+ putChallenge(options.challenge, "registration", now());
413
+ return c.json(options);
414
+ });
415
+
416
+ app.post("/auth/passkey/register-verify", async (c) => {
417
+ if (mode !== "password") return notEnabled(c);
418
+ const rp = resolveRp(c);
419
+ if (!rp) return c.json({ error: "Origin not allowed" }, 400);
420
+
421
+ let body: { response?: RegistrationResponseJSON; label?: unknown };
422
+ try {
423
+ body = await c.req.json();
424
+ } catch {
425
+ return c.json({ error: "Invalid request body" }, 400);
426
+ }
427
+ if (!body.response) return c.json({ error: "Invalid request body" }, 400);
428
+
429
+ const challenge = challengeFromResponse(body.response);
430
+ if (!challenge || !consumeChallenge(challenge, "registration", now())) {
431
+ return c.json({ error: "Passkey registration failed" }, 400);
432
+ }
433
+
434
+ try {
435
+ const result = await verifyRegistration({
436
+ response: body.response,
437
+ expectedChallenge: challenge,
438
+ expectedOrigin: rp.origin,
439
+ expectedRPID: rp.rpID,
440
+ });
441
+ if (!result.verified || !result.registrationInfo) {
442
+ return c.json({ error: "Passkey registration failed" }, 400);
443
+ }
444
+ const { credential, aaguid, credentialDeviceType, credentialBackedUp } =
445
+ result.registrationInfo;
446
+ const createdAt = now();
447
+ getDb()
448
+ .prepare(
449
+ `INSERT INTO passkey_credentials
450
+ (id, public_key, counter, transports, rp_id, aaguid, device_type,
451
+ backed_up, label, created_at)
452
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
453
+ )
454
+ .run(
455
+ credential.id,
456
+ credential.publicKey,
457
+ credential.counter,
458
+ credential.transports ? JSON.stringify(credential.transports) : null,
459
+ rp.rpID,
460
+ aaguid || null,
461
+ credentialDeviceType,
462
+ credentialBackedUp ? 1 : 0,
463
+ sanitizeLabel(body.label),
464
+ createdAt
465
+ );
466
+ const row = credentialById(credential.id);
467
+ return c.json({ ok: true, credential: row ? toSummary(row) : null });
468
+ } catch (err) {
469
+ const message = err instanceof Error ? err.message : String(err);
470
+ console.warn(`[passkeys] registration failed: ${message}`);
471
+ return c.json({ error: "Passkey registration failed" }, 400);
472
+ }
473
+ });
474
+
475
+ // All rows, not rpID-filtered: the prod panel must be able to show and
476
+ // delete stale localhost/dev credentials. The client badges foreign RPs.
477
+ app.get("/auth/passkey/list", (c) => {
478
+ if (mode !== "password") return notEnabled(c);
479
+ const rows = getDb()
480
+ .query("SELECT * FROM passkey_credentials ORDER BY created_at DESC")
481
+ .all() as CredentialRow[];
482
+ return c.json({ credentials: rows.map(toSummary) });
483
+ });
484
+
485
+ app.put("/auth/passkey/:id", async (c) => {
486
+ if (mode !== "password") return notEnabled(c);
487
+ let body: { label?: unknown };
488
+ try {
489
+ body = await c.req.json();
490
+ } catch {
491
+ return c.json({ error: "Invalid request body" }, 400);
492
+ }
493
+ const result = getDb()
494
+ .prepare("UPDATE passkey_credentials SET label = ? WHERE id = ?")
495
+ .run(sanitizeLabel(body.label), c.req.param("id"));
496
+ if (result.changes === 0) return c.json({ error: "Unknown passkey" }, 404);
497
+ return c.json({ ok: true });
498
+ });
499
+
500
+ app.delete("/auth/passkey/:id", (c) => {
501
+ if (mode !== "password") return notEnabled(c);
502
+ const result = getDb()
503
+ .prepare("DELETE FROM passkey_credentials WHERE id = ?")
504
+ .run(c.req.param("id"));
505
+ if (result.changes === 0) return c.json({ error: "Unknown passkey" }, 404);
506
+ return c.json({ ok: true });
507
+ });
508
+
509
+ return app;
510
+ }
@@ -0,0 +1,81 @@
1
+ import type { Context, MiddlewareHandler } from "hono";
2
+ import { getConnInfo } from "hono/bun";
3
+
4
+ /**
5
+ * Restrict access to Tailscale VPN clients (CGNAT range 100.64.0.0/10) or
6
+ * localhost.
7
+ *
8
+ * SECURITY: header trust is OFF by default. An exposed port lets anyone spoof
9
+ * `x-forwarded-for`, so the real socket address is used unless `TRUST_PROXY=1`
10
+ * — set that ONLY when a trusted reverse proxy in front of brain-ui sets the
11
+ * forwarding header.
12
+ */
13
+ export interface TailscaleGuardOptions {
14
+ /** Trust x-forwarded-for / x-real-ip. Defaults to `TRUST_PROXY === "1"`. */
15
+ trustProxy?: boolean;
16
+ }
17
+
18
+ export function tailscaleGuard(
19
+ options: TailscaleGuardOptions = {}
20
+ ): MiddlewareHandler {
21
+ const trustProxy = options.trustProxy ?? process.env.TRUST_PROXY === "1";
22
+ return async (c, next) => {
23
+ if (isTailscaleAllowed(c, trustProxy)) {
24
+ await next();
25
+ } else {
26
+ return c.json({ error: "VPN access required" }, 403);
27
+ }
28
+ };
29
+ }
30
+
31
+ /** True when the request's client IP is a Tailscale or loopback address. */
32
+ export function isTailscaleAllowed(c: Context, trustProxy: boolean): boolean {
33
+ const ip = clientIp(c, trustProxy);
34
+ return isTailscaleIp(ip) || isLocalIp(ip);
35
+ }
36
+
37
+ /**
38
+ * The client IP. With `trustProxy`, read the forwarding header a trusted proxy
39
+ * set; otherwise use the real socket address (unspoofable). Returns "" when the
40
+ * source is unavailable (e.g. Hono's `app.request()` has no socket) — callers
41
+ * treat "" as not-allowed.
42
+ *
43
+ * X-Forwarded-For is parsed RIGHT-TO-LEFT: each proxy appends the address it saw,
44
+ * so the rightmost entries are the trusted hops we control and the leftmost is
45
+ * client-controllable. `TRUST_PROXY_HOPS` (default 1) is how many proxies front
46
+ * this app; the client IP is the entry just before them. Taking the leftmost
47
+ * entry (the old behaviour) let a client spoof its address by pre-seeding XFF.
48
+ */
49
+ export function clientIp(c: Context, trustProxy: boolean): string {
50
+ if (trustProxy) {
51
+ const forwarded = c.req.header("x-forwarded-for");
52
+ if (forwarded) {
53
+ const parts = forwarded
54
+ .split(",")
55
+ .map((part) => part.trim())
56
+ .filter(Boolean);
57
+ if (parts.length > 0) {
58
+ const hops = Math.max(1, Number(process.env.TRUST_PROXY_HOPS) || 1);
59
+ const ip = parts[Math.max(0, parts.length - hops)];
60
+ if (ip) return ip;
61
+ }
62
+ }
63
+ return c.req.header("x-real-ip") ?? "";
64
+ }
65
+ try {
66
+ return getConnInfo(c).remote.address ?? "";
67
+ } catch {
68
+ return "";
69
+ }
70
+ }
71
+
72
+ function isTailscaleIp(ip: string): boolean {
73
+ // Tailscale CGNAT range: 100.64.0.0/10 (100.64.0.0 - 100.127.255.255)
74
+ if (!ip.startsWith("100.")) return false;
75
+ const second = parseInt(ip.split(".")[1], 10);
76
+ return second >= 64 && second <= 127;
77
+ }
78
+
79
+ function isLocalIp(ip: string): boolean {
80
+ return ip === "127.0.0.1" || ip === "::1" || ip === "localhost";
81
+ }