Repository navigation
Expand file tree
/
Copy pathproxy-helpers.ts
More file actions
609 lines (573 loc) · 23.9 KB
/
Copy pathproxy-helpers.ts
File metadata and controls
609 lines (573 loc) · 23.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
// Pure helpers used by src/proxy.ts. Lives in a separate file so behavior
// tests can import them without paying the `next/server` ESM resolution
// cost that the proxy entrypoint pays.
//
// The proxy() function in proxy.ts re-exports these so consumers still
// have one canonical import path.
// Data only — contract.ts declares the client-v1 surface and imports nothing at
// runtime but the equally pure operation registry (operations.ts, whose own
// imports are type-only and therefore erased), so pulling it in here costs the
// proxy no runtime dependency.
import { CLIENT_V1_PUBLIC_ROUTES } from "./lib/server/client-v1/contract.ts";
import { canonicalClientV1Pathname } from "./lib/server/client-v1/canonical-path.ts";
import {
CLIENT_V1_HPKE_HEADERS,
CLIENT_V1_HPKE_MECHANISM,
} from "./lib/server/client-v1/authority-contract.ts";
export function timingSafeEqualString(a: string, b: string) {
const encoder = new TextEncoder();
const aBytes = encoder.encode(a);
const bBytes = encoder.encode(b);
if (aBytes.length !== bBytes.length) return false;
let diff = 0;
for (let i = 0; i < aBytes.length; i += 1) {
diff |= aBytes[i] ^ bBytes[i];
}
return diff === 0;
}
export function isLoopbackHost(host: string | null) {
if (!host) return false;
const hostname = host.startsWith("[")
? host.slice(1, host.indexOf("]"))
: host.split(":")[0];
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
}
function hostnameFromHost(host: string | null) {
if (!host) return null;
return host.startsWith("[")
? host.slice(1, host.indexOf("]"))
: host.split(":")[0];
}
function isTailscaleIpHost(hostname: string) {
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
const parts = hostname.split(".").map((part) => Number(part));
return parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) &&
parts[0] === 100 &&
parts[1] >= 64 &&
parts[1] <= 127;
}
return hostname.toLowerCase().startsWith("fd7a:115c:a1e0:");
}
export function isTailscaleServeHost(host: string | null) {
const hostname = hostnameFromHost(host)?.toLowerCase();
return Boolean(hostname && (hostname.endsWith(".ts.net") || isTailscaleIpHost(hostname)));
}
export function isAllowedApiHost(
host: string | null,
mobileAccessAuthenticated = false,
tailnetTrusted = false,
) {
if (mobileAccessAuthenticated) return true;
return isLoopbackHost(host) || (tailnetTrusted && isTailscaleServeHost(host));
}
export function isTokenlessApiPeerAllowed(
trustedLocalPeer: boolean,
remoteIngress: boolean,
) {
return trustedLocalPeer || remoteIngress;
}
export function sameOrigin(value: string | null, expectedOrigin: string) {
if (!value) return true;
try {
const url = new URL(value);
if (url.origin === expectedOrigin) return true;
// Tauri's WKWebView on macOS occasionally normalizes the referer's
// loopback hostname (127.0.0.1 ↔ localhost ↔ [::1]) differently from the
// host the sidecar bound. The host gate above already requires a loopback
// request URL, so accept any same-scheme, same-port loopback referer.
const expected = new URL(expectedOrigin);
return (
url.protocol === expected.protocol &&
url.port === expected.port &&
isLoopbackHost(url.host) &&
isLoopbackHost(expected.host)
);
} catch {
return false;
}
}
export function isAllowedRequestSource(value: string | null, expectedOrigin: string) {
return sameOrigin(value, expectedOrigin);
}
/**
* The origins a genuinely first-party request may declare, in the order they
* should be tried.
*
* `req.nextUrl.origin` is pinned to the port Next was CONSTRUCTED with —
* server.ts passes the configured `PORT` to `next({ port })` before the
* listener runs, and `startListening()` then falls back to the next free port
* when the configured one is taken. So on a fallback the browser's real Origin
* (the port it actually connected to, carried by the Host header) never equals
* `nextUrl.origin` and every /api request 403s "forbidden origin" (cave-5sg).
*
* The request's own Host header carries the real authority for local fallback,
* but only loopback Hosts may extend the accepted-origin set. Tokenless
* tailnet-trust mode deliberately relaxes the Host gate for Tailscale Serve
* forwarding, so adding arbitrary non-loopback Hosts here would let a
* browser-supplied Origin that matches that Host satisfy the CSRF check.
* `nextUrl.origin` is kept first so the Tailscale-Serve / forwarded-host path
* (where Next trusts x-forwarded-host) is entirely unchanged.
*/
export function expectedRequestOrigins(
nextUrlOrigin: string,
protocol: string | null,
host: string | null,
): string[] {
const origins = [nextUrlOrigin];
if (isLoopbackHost(host)) {
const scheme = protocol && protocol.length > 0 ? protocol : "http:";
const derived = `${scheme}//${host}`;
if (derived !== nextUrlOrigin) origins.push(derived);
}
return origins;
}
/** True when `value` (an Origin/Referer) matches ANY accepted origin. An
* absent value passes (mirrors sameOrigin's null tolerance). */
export function isAllowedRequestSourceAny(value: string | null, expectedOrigins: string[]) {
return expectedOrigins.some((origin) => isAllowedRequestSource(value, origin));
}
export function shouldRequireMobileAccessCredential(
_host: string | null,
_hasSuppliedCredential: boolean,
trustedLocalPeer = false,
tailnetPeerVerified = false,
sidecarAuthenticated = false,
) {
// Forwarded tailnet identity is context for presence policy, not a bearer
// credential: a local process can forge proxy headers on a loopback socket.
void tailnetPeerVerified;
// Direct, unforwarded loopback is the no-prompt browser path. Remote ingress
// still needs the mobile credential, while Tauri may use its sidecar token.
return !trustedLocalPeer && !sidecarAuthenticated;
}
/**
* True when the request's LOCAL_PEER_HEADER value equals the per-boot
* local-peer secret minted by server.ts. The custom server deletes any
* client-supplied copy of the header before stamping its own, and the secret
* never leaves the process, so a match proves server.ts classified this
* request as a direct (unforwarded) loopback connection. An unset secret —
* Next running without server.ts in front — fails closed.
*/
export function isTrustedLocalPeer(headerValue: string | null, secret: string | undefined) {
if (!headerValue || !secret) return false;
return timingSafeEqualString(headerValue, secret);
}
export const CLIENT_V1_PUBLIC_INGRESS = "public";
export type ClientV1IngressKind =
| typeof CLIENT_V1_PUBLIC_INGRESS
| "authenticated";
/** Root of the whole Client v1 surface: public, admin, and every path under it
* that no route serves yet. */
export const CLIENT_V1_PATH_PREFIX = "/api/client/v1";
/**
* True for any pathname inside the Client v1 surface.
*
* Backslashes are folded to `/` for the prefix test only. Next answers a
* request-target such as `/api\client\v1\health` with a 308 to the slash form,
* so a `\` never reaches a handler — but the fold means the refusal below
* cannot be dodged by writing the prefix with the separator Next is about to
* rewrite. A `%` needs no equivalent treatment: Next matches STATIC segments
* against the raw bytes (`/api/client/v1/healt%68` is a 404, measured), so no
* escape can manufacture the literal prefix.
*/
export function isClientV1Path(pathname: string) {
const separatorNormalized = pathname.replace(/\\/g, "/");
return (
separatorNormalized === CLIENT_V1_PATH_PREFIX
|| separatorNormalized.startsWith(`${CLIENT_V1_PATH_PREFIX}/`)
);
}
/**
* True for the Client v1 administrative family — the credential list, the
* pairing-approval queue, and the decisions taken on it.
*/
export function isClientV1AdminPath(pathname: string) {
const separatorNormalized = pathname.replace(/\\/g, "/");
const adminPrefix = `${CLIENT_V1_PATH_PREFIX}/admin`;
return (
separatorNormalized === adminPrefix
|| separatorNormalized.startsWith(`${adminPrefix}/`)
);
}
const CLIENT_V1_PATH_PARAMETER = /^:[A-Za-z0-9_]+$/;
/** One contract route path (`/…/pairing/requests/:id`) as an exact matcher. */
function clientV1PathPattern(path: string) {
const segments = path.split("/").map((segment) =>
CLIENT_V1_PATH_PARAMETER.test(segment)
? "[^/]+"
: segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
return new RegExp(`^${segments.join("/")}$`);
}
/**
* Derived from the contract rather than restated from it. These are the paths
* a client may call before it holds a credential, and CLIENT_V1_PUBLIC_ROUTES
* is what the discovery fixture tells clients that set is — so a hand-kept
* second copy here could publish a route the proxy then answers with 403, with
* nothing in the suite able to see the disagreement (cave-d1sjz). Methods are
* dropped because ingress classification is method-blind; the routes
* themselves reject the methods they do not serve.
*/
const CLIENT_V1_PUBLIC_PATHS = CLIENT_V1_PUBLIC_ROUTES.map((route) =>
clientV1PathPattern(route.path),
);
/**
* Client-v1 paths whose OWN handler authenticates the bearer credential.
*
* Matching here is a DEMOTION, not a promotion: proxy() skips mobileAccessGate
* for a client-v1 ingress and returns before the sidecar-token block, so the
* only credential check left is the one the route performs for itself
* (requireScope, src/lib/server/client-v1/auth.ts). That trade is sound for a
* path that really does check — and a hole for a path that does not exist yet,
* because a handler landing later inherits the exemption without ever opting
* into it.
*
* The converse is not free either, so absence is a decision rather than a safe
* default. Both of the client-v1-only protections in proxy() are gated on the
* ingress classification this list feeds: the hard `403 forbidden peer: client
* v1 requires direct loopback` that rejects anything but a direct loopback
* peer, and clientV1RequestBodyError's 411/413 content-length gate with its
* 64 KB cap. Neither applies to a path that classifies null. So a future
* authenticated route that lands un-listed does not merely keep the sidecar
* gate: it gains a bearer requirement and loses loopback-only ingress and the
* body cap. Listing it once it checks its own credential restores both.
*
* So the invariant is: a pattern belongs here only once a route.ts on disk
* matches it AND that route calls requireScope. Both halves are asserted in
* src/app/api/api-contracts.test.ts. Phase 2 adds its entry in the same change
* that adds its handler; an unhandled client-v1 path stays off this list and
* falls through to the ordinary sidecar-token gate, which is where an
* unauthenticated loopback caller should land.
*
* It was NOT empty before cave-4841, and then it was: thirteen Phase 2 paths
* were listed against zero handlers, so the first Phase 2 route to land would
* have been reachable from any loopback process with no sidecar token at all.
* cave-4841 emptied it; the five entries below are the first to earn their
* place, and each arrives in the same change as the handler that serves it.
*
* All seven are canonical reads (cave-jfa9y; the two familiar detail reads
* joined in the Familiars integration, Stage 1), and each calls requireScope for
* `chat:read` AND re-checks the loopback stamp for itself, the way all three
* pairing routes now do. That second check began as cover for #4854 — the
* classifier below returns null for any pathname containing a percent sign, so
* a percent-encoded dynamic segment used to escape the direct-loopback branch
* entirely, and two of these five paths are dynamic-segmented. #4855 closed
* that by refusing such a target in proxy() before anything is classified. The
* in-route check stays as the second layer: a route that is DEMOTED by this
* list should not take its locality on trust from the thing that demoted it.
*
* Written through clientV1PathPattern rather than as hand-rolled regexes so
* these share the public set's anchoring and its one-segment `:id` scoping. A
* hand-written pattern that forgot a `^` would silently pre-authorize
* `/decoy/api/client/v1/projects`.
*/
export const CLIENT_V1_AUTHENTICATED_PATHS: RegExp[] = [
"/api/client/v1/familiars",
"/api/client/v1/familiars/:id/contract",
"/api/client/v1/familiars/:id/analytics",
"/api/client/v1/projects",
"/api/client/v1/conversations",
"/api/client/v1/conversations/:id",
"/api/client/v1/conversations/:id/messages",
].map(clientV1PathPattern);
/**
* True for a Client v1 request target proxy() must refuse outright.
*
* Pairing and admin IDs remain UUID-only. Conversation IDs may require one
* canonical encodeURIComponent-style pass, so only those authenticated route
* patterns accept validated escapes. Everything malformed, aliased, nested, or
* backslash-bearing still fails before ingress classification.
*/
export function isRefusedClientV1Path(pathname: string) {
if (!isClientV1Path(pathname)) return false;
if (pathname.includes("\\")) return true;
if (
CLIENT_V1_AUTHENTICATED_PATHS.some((pattern) => pattern.test(pathname))
) {
try {
canonicalClientV1Pathname(pathname);
return false;
} catch {
return true;
}
}
return pathname.includes("%");
}
/**
* The presented credential may be at most this many characters. An issued
* bearer is 43 base64url characters (randomBytes(32).toString("base64url"));
* the cap exists so an attacker-controlled header cannot push an unbounded
* value into the token68 regex below. It is deliberately looser than the
* route-level cap (read-guard MAX_BEARER_CHARACTERS) because this gate is
* presentation-only: the route still verifies the credential and applies its
* own bounds.
*/
const CLIENT_V1_BEARER_CHARACTERS = 4096;
const CLIENT_V1_BEARER_CREDENTIAL = /^[A-Za-z0-9._~+/-]+=*$/;
/**
* True when Authorization presents a syntactically well-formed bearer
* credential (RFC 6750 token68) for client-v1 resource ingress.
*
* Presented, not verified: deciding whether a credential is real means
* findByBearer, which reads the on-disk credential store, and this module is
* kept free of node builtins so the proxy's import graph stays
* client-bundlable (proxy-helpers.ts is reachable from a "use client"
* component via research-media-ticket.ts). Presentation is the half the proxy
* can enforce, and enforcing it is what keeps an anonymous loopback process
* off a resource route — the routes' requireScope calls are otherwise the only
* layer, and a route whose author forgets one has none at all (cave-q5mwb,
* revived from cave-d1sjz). The 12-byte fake "Bearer AAAA" satisfies this
* check and still lands on the route's own 401, so this gate never excuses
* requireScope.
*
* Fail-closed by construction: no header, a bare credential with no scheme,
* a non-"bearer" scheme, an empty credential, a credential over the cap, or a
* credential outside the token68 alphabet all return false.
*/
export function presentsClientV1Bearer(headerValue: string | null) {
if (!headerValue) return false;
const separator = headerValue.indexOf(" ");
if (separator <= 0) return false;
if (headerValue.slice(0, separator).toLowerCase() !== "bearer") return false;
const credential = headerValue.slice(separator + 1).trim();
return credential.length > 0
&& credential.length <= CLIENT_V1_BEARER_CHARACTERS
&& CLIENT_V1_BEARER_CREDENTIAL.test(credential);
}
/**
* True when the request presents the complete reviewed HPKE-bound header set.
*
* Like presentsClientV1Bearer, this checks presentation only. The authority
* runtime validates every value, opens the ciphertext, and rejects malformed,
* stale, replayed, or incorrectly keyed requests before the route runs.
*/
export function presentsClientV1BoundAuthority(
headers: Pick<Headers, "get">,
) {
if (
headers.get(CLIENT_V1_HPKE_HEADERS.mechanism)
!== CLIENT_V1_HPKE_MECHANISM
) {
return false;
}
return Object.values(CLIENT_V1_HPKE_HEADERS).every((name) => {
const value = headers.get(name);
return value !== null && value.length > 0;
});
}
export function clientV1IngressKind(pathname: string): ClientV1IngressKind | null {
if (isRefusedClientV1Path(pathname)) return null;
if (CLIENT_V1_PUBLIC_PATHS.some((pattern) => pattern.test(pathname))) {
return CLIENT_V1_PUBLIC_INGRESS;
}
if (CLIENT_V1_AUTHENTICATED_PATHS.some((pattern) => pattern.test(pathname))) {
return "authenticated";
}
return null;
}
/**
* The allowlisted Tailscale stable node ID behind this request, or null.
*
* Mirrors TAILNET_PEER_HEADER in server.ts, which is the only component that
* can mint this stamp: it sees the raw socket, deletes any client-supplied copy
* of the header, resolves the forwarded tailnet address against a
* `tailscale status` allowlist, and stamps `<perBootSecret>:<nodeId>`. The
* secret never leaves that process, so a match proves the peer is an
* allowlisted device rather than merely something claiming to be one. An unset
* secret — Next running without server.ts in front — fails closed.
*/
export function verifiedTailnetNode(headerValue: string | null, secret: string | undefined) {
if (!headerValue || !secret) return null;
const separator = headerValue.indexOf(":");
if (separator <= 0) return null;
const supplied = headerValue.slice(0, separator);
const nodeId = headerValue.slice(separator + 1);
if (!nodeId) return null;
return timingSafeEqualString(supplied, secret) ? nodeId : null;
}
export function bearerFromReferer(value: string | null, expectedOrigin: string) {
if (!value) return null;
try {
const url = new URL(value);
if (url.origin !== expectedOrigin) return null;
return url.searchParams.get(TOKEN_PARAM);
} catch {
return null;
}
}
/** bearerFromReferer against ANY accepted origin — so a referer carrying the
* real fallback port still yields its token when nextUrl.origin lags behind
* (see expectedRequestOrigins). Returns the first token found. */
export function bearerFromRefererAny(value: string | null, expectedOrigins: string[]) {
for (const origin of expectedOrigins) {
const token = bearerFromReferer(value, origin);
if (token) return token;
}
return null;
}
/**
* True when an unauthenticated request is a browser PAGE navigation (an
* HTML-accepting GET outside /api/). Only these get the HTML access-gate
* page; API routes, mutations, and non-browser clients (curl, fetch) keep
* the machine-readable JSON 401 envelope.
*/
export function isHtmlNavigationRequest(
method: string,
pathname: string,
accept: string | null,
) {
if (method !== "GET") return false;
if (pathname === "/api" || pathname.startsWith("/api/")) return false;
return Boolean(accept && accept.toLowerCase().includes("text/html"));
}
export function shouldBypassMobileAccessGate(
trustedLocalPeer: boolean,
method: string,
pathname: string,
accept: string | null,
) {
return (
trustedLocalPeer &&
isHtmlNavigationRequest(method, pathname, accept)
);
}
/**
* The access-gate page served (with a 401) to unauthenticated browser
* navigations when COVEN_CAVE_ACCESS_TOKEN is configured. Deliberately
* static — nothing from the request is interpolated — and script-free.
* The form submits the token as the existing ACCESS_TOKEN_QUERY_PARAM GET
* parameter, so verification and the cookie exchange reuse the audited
* query-token path in the proxy; this page adds no new auth logic.
*/
export function accessGatePage({ invalidToken = false }: { invalidToken?: boolean } = {}) {
const note = invalidToken
? '<p class="note" role="alert">That token didn’t verify — it may have expired. Mint a new pairing link and try again.</p>'
: "";
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Pair this device · Coven Cave</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
display: grid;
place-items: center;
min-height: 100vh;
background: oklch(0.24 0.006 291);
color: oklch(0.93 0.004 291);
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif;
}
main {
width: min(360px, calc(100vw - 48px));
padding: 28px;
border: 1px solid oklch(0.93 0.004 291 / 12%);
border-radius: 16px;
background: oklch(0.26 0.007 291);
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #9386d0;
box-shadow: 0 0 0 4px oklch(0.62 0.09 291 / 16%);
margin-bottom: 16px;
}
h1 { margin: 0 0 6px; font-size: 16px; font-weight: 650; }
p { margin: 0 0 16px; color: oklch(0.66 0.010 291); font-size: 13px; }
.note { color: oklch(0.72 0.14 78); }
form { display: flex; gap: 8px; }
input {
flex: 1;
min-width: 0;
padding: 8px 12px;
border: 1px solid oklch(0.93 0.004 291 / 22%);
border-radius: 999px;
background: oklch(0.22 0.006 291);
color: inherit;
font: inherit;
}
input:focus-visible, button:focus-visible {
outline: 2px solid oklch(0.62 0.09 291 / 55%);
outline-offset: 1px;
}
button {
padding: 8px 16px;
border: 1px solid oklch(0.93 0.004 291 / 22%);
border-radius: 999px;
background: oklch(0.29 0.008 291);
color: inherit;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:hover { background: oklch(0.32 0.009 291); }
</style>
</head>
<body>
<main>
<div class="dot" aria-hidden="true"></div>
<h1>Pair this device</h1>
<p>This Cave is protected. Open your pairing link, or paste an access token below.</p>
${note}
<form method="get" action="">
<input type="password" name="${ACCESS_TOKEN_QUERY_PARAM}" autocomplete="off" required aria-label="Access token" placeholder="Access token">
<button type="submit">Unlock</button>
</form>
</main>
</body>
</html>
`;
}
export const ACCESS_TOKEN_COOKIE = "coven_cave_access";
// Stamped by server.ts (with the per-boot COVEN_CAVE_LOCAL_PEER_SECRET) on
// requests whose TCP peer it verified as direct loopback. Mirrored in
// server.ts, which cannot import from src/.
export const LOCAL_PEER_HEADER = "x-coven-cave-local-peer";
export const TAILNET_PEER_HEADER = "x-coven-cave-tailnet-peer";
/**
* Whether this request must carry a proven biometric check (cave-brksh).
*
* Three deliberate exemptions:
*
* - Non-API paths. Page navigations have to load, or the surface that runs
* the WebAuthn ceremony can never render and the gate becomes a brick wall
* with no door.
* - `/api/passkey/*`. Obtaining presence cannot itself require presence. The
* sensitive members of that family — enrolling an ADDITIONAL credential and
* revoking one — police themselves at the route layer, where they can read
* the credential store that middleware cannot.
* - Local ingress. A direct loopback peer is someone at the machine; the
* phone is what this control is about.
*/
export function requiresPasskeyPresence(
pathname: string,
remoteIngress: boolean,
enabled: boolean,
): boolean {
if (!enabled || !remoteIngress) return false;
if (!pathname.startsWith("/api/")) return false;
return !(pathname === "/api/passkey" || pathname.startsWith("/api/passkey/"));
}
export const ACCESS_TOKEN_QUERY_PARAM = "coven_access_token";
export const LEGACY_ACCESS_PROMPT_QUERY_PARAM = "coven_access_prompt";
export const TOKEN_PARAM = "covenCaveToken";
export const TOKEN_HEADER = "x-coven-cave-token";
export const MOBILE_ACCESS_HEADER = "x-coven-cave-mobile-access";
export const CLIENT_V1_ADMIN_HEADER = "x-coven-cave-client-v1-admin";
export const VALIDATED_SIDECAR_QUERY_HEADER = "x-coven-cave-validated-sidecar-query";
export const SAFE_CONTENT_TYPES = [
"application/json",
"application/x-www-form-urlencoded",
"multipart/form-data",
// The local-only backdrop endpoint accepts raw, size-bounded raster bytes so
// it can reject an oversized upload while streaming instead of materialising
// multipart/base64 overhead. The route still verifies MIME + magic bytes;
// SVG remains deliberately unsupported.
"image/jpeg",
"image/png",
"image/webp",
];