@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.2
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/README.md +3 -5
- package/core/src/conformance.ts +1 -2
- package/core/src/content-range.test.ts +0 -127
- package/core/src/content-range.ts +0 -100
- package/core/src/contract-typed-index.test.ts +3 -4
- package/core/src/core.test.ts +4 -521
- package/core/src/expand.ts +3 -11
- package/core/src/indexed-fields.test.ts +3 -9
- package/core/src/indexed-fields.ts +1 -9
- package/core/src/mcp.ts +10 -601
- package/core/src/notes.ts +4 -201
- package/core/src/schema-defaults.ts +1 -85
- package/core/src/search-fts-v25.test.ts +1 -9
- package/core/src/search-query.test.ts +0 -42
- package/core/src/search-query.ts +0 -27
- package/core/src/seed-packs.test.ts +1 -117
- package/core/src/seed-packs.ts +1 -217
- package/core/src/store.ts +3 -59
- package/core/src/tag-schemas.ts +12 -27
- package/core/src/types.ts +1 -7
- package/core/src/vault-projection.ts +0 -55
- package/package.json +1 -1
- package/src/add-pack.test.ts +0 -32
- package/src/auth-hub-jwt.test.ts +1 -118
- package/src/auth.ts +0 -64
- package/src/cli.ts +1 -5
- package/src/contract-errors.test.ts +1 -2
- package/src/mcp-http.ts +4 -33
- package/src/mcp-tools.ts +14 -61
- package/src/oauth-discovery.ts +0 -31
- package/src/onboarding-seed.test.ts +0 -64
- package/src/routes.ts +95 -92
- package/src/routing.test.ts +4 -229
- package/src/routing.ts +23 -152
- package/src/scopes.ts +0 -22
- package/src/server.ts +0 -7
- package/src/storage.test.ts +1 -200
- package/src/transcription-worker.test.ts +0 -151
- package/src/transcription-worker.ts +52 -113
- package/src/vault.test.ts +11 -48
- package/core/src/attachment/bytes-provider.ts +0 -65
- package/core/src/attachment/policy.test.ts +0 -66
- package/core/src/attachment/policy.ts +0 -131
- package/core/src/attachment/tickets.test.ts +0 -45
- package/core/src/attachment/tickets.ts +0 -117
- package/core/src/attachment-tickets-tool.test.ts +0 -286
- package/core/src/display-title.test.ts +0 -190
- package/core/src/lede.test.ts +0 -96
- package/core/src/search-title-boost.test.ts +0 -125
- package/src/attachment-bytes.ts +0 -68
- package/src/attachment-tickets.test.ts +0 -475
- package/src/attachment-tickets.ts +0 -340
- package/src/read-attachment.test.ts +0 -436
package/src/routing.ts
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP request router for the multi-vault server.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `/vault/default/...` is the baseline URL for single-vault deployments.
|
|
4
|
+
* All per-vault resources live under `/vault/<name>/...`. There is no
|
|
5
|
+
* unscoped fallback — a request must name the vault it targets. A fresh
|
|
6
|
+
* install creates a vault named `default`, so `/vault/default/...` is the
|
|
7
|
+
* baseline URL for single-vault deployments.
|
|
9
8
|
*
|
|
10
9
|
* Dispatch shape:
|
|
11
10
|
*
|
|
12
11
|
* /.well-known/parachute.json — NOT served here (CLI owns it at
|
|
13
12
|
* origin root; vault never handles it)
|
|
14
|
-
* /.well-known/oauth-protected-resource/mcp
|
|
15
|
-
* — root-endpoint PRM (RFC 9728) for the
|
|
16
|
-
* canonical `/mcp` below (U1)
|
|
17
|
-
* /mcp[/*] — canonical root MCP endpoint; the vault
|
|
18
|
-
* is DERIVED FROM THE TOKEN, then the
|
|
19
|
-
* request re-dispatches through the same
|
|
20
|
-
* per-vault machinery (U1)
|
|
21
13
|
* /health — liveness ping, vault names leaked
|
|
22
14
|
* only to authenticated callers
|
|
23
15
|
* /vaults/list — public vault-name discovery (can be
|
|
@@ -29,20 +21,15 @@
|
|
|
29
21
|
* /vault/<name>/.well-known/oauth-* — discovery forwarder; metadata names
|
|
30
22
|
* the hub as the authorization server
|
|
31
23
|
* (vault is resource-server only)
|
|
32
|
-
* /vault/<name>/mcp[/*] — MCP endpoint (Bearer auth)
|
|
33
|
-
* URL-addressed twin of root `/mcp`,
|
|
34
|
-
* untouched and permanent
|
|
24
|
+
* /vault/<name>/mcp[/*] — MCP endpoint (Bearer auth)
|
|
35
25
|
* /vault/<name>/view/<idOrPath> — auth-aware HTML view
|
|
36
26
|
* /vault/<name>/public/<noteId> — legacy alias → /view redirect
|
|
37
27
|
* /vault/<name> — vault metadata + stats (auth)
|
|
38
28
|
* /vault/<name>/api/... — REST surface (auth)
|
|
39
29
|
*
|
|
40
|
-
*
|
|
41
|
-
* `/
|
|
42
|
-
*
|
|
43
|
-
* the client having to know the vault's URL name. There is still deliberately
|
|
44
|
-
* no compat for the old `/api/*`, `/oauth/*`, `/view/*`, or `/vaults/<name>/*`
|
|
45
|
-
* prefixes; clients using those must re-authenticate and point at the new URLs.
|
|
30
|
+
* There is deliberately no compat for the old `/api/*`, `/mcp`, `/oauth/*`,
|
|
31
|
+
* `/view/*`, or `/vaults/<name>/*` prefixes. Clients must re-authenticate
|
|
32
|
+
* after the upgrade and point at the new URLs.
|
|
46
33
|
*
|
|
47
34
|
* **No standalone OAuth issuer.** vault does not mint OAuth tokens or
|
|
48
35
|
* render a consent UI. Hub is the issuer; vault validates hub-signed
|
|
@@ -63,7 +50,6 @@ import {
|
|
|
63
50
|
import {
|
|
64
51
|
authenticateVaultRequest,
|
|
65
52
|
authenticateGlobalRequest,
|
|
66
|
-
deriveVaultFromToken,
|
|
67
53
|
extractApiKey,
|
|
68
54
|
} from "./auth.ts";
|
|
69
55
|
import { hasScopeForVault, hasMigrateScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
|
|
@@ -92,7 +78,6 @@ import { handleTriggers } from "./triggers-api.ts";
|
|
|
92
78
|
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
93
79
|
import {
|
|
94
80
|
handleProtectedResource,
|
|
95
|
-
handleRootProtectedResource,
|
|
96
81
|
handleAuthorizationServer,
|
|
97
82
|
getBaseUrl,
|
|
98
83
|
} from "./oauth-discovery.ts";
|
|
@@ -118,7 +103,6 @@ import {
|
|
|
118
103
|
} from "./mirror-routes.ts";
|
|
119
104
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
120
105
|
import { buildUsageReport } from "./usage.ts";
|
|
121
|
-
import { handleTicketSpend } from "./attachment-tickets.ts";
|
|
122
106
|
|
|
123
107
|
/**
|
|
124
108
|
* Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
|
|
@@ -150,58 +134,6 @@ async function withMcpChallenge(
|
|
|
150
134
|
return new Response(body, { status: 401, headers });
|
|
151
135
|
}
|
|
152
136
|
|
|
153
|
-
/**
|
|
154
|
-
* 401 + RFC 9728 challenge for the canonical ROOT `/mcp` endpoint (U1), when
|
|
155
|
-
* the token is absent / invalid / names no single vault. Mirrors
|
|
156
|
-
* `withMcpChallenge` but points at the ROOT protected-resource metadata
|
|
157
|
-
* (`/.well-known/oauth-protected-resource/mcp`) rather than a vault-scoped one,
|
|
158
|
-
* since the root resource is vault-agnostic. A spec-following MCP client
|
|
159
|
-
* follows this pointer to the root PRM, discovers the hub, and requests the
|
|
160
|
-
* un-narrowed scopes the hub's consent picker narrows to a chosen vault.
|
|
161
|
-
*/
|
|
162
|
-
function rootMcpChallenge(req: Request): Response {
|
|
163
|
-
const base = getBaseUrl(req);
|
|
164
|
-
return Response.json(
|
|
165
|
-
{ error: "Unauthorized", message: "API key required" },
|
|
166
|
-
{
|
|
167
|
-
status: 401,
|
|
168
|
-
headers: {
|
|
169
|
-
"WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`,
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Shared scoped-MCP dispatch tail used by BOTH the URL-addressed per-vault
|
|
177
|
-
* endpoint (`/vault/<name>/mcp`) and the token-derived root endpoint (`/mcp`,
|
|
178
|
-
* U1). Runs the FULL per-vault auth machinery via `authenticateVaultRequest`
|
|
179
|
-
* (audience strict-check, `vault_scope` pin, broad-scope rejection, tag-scope
|
|
180
|
-
* parse) and, on success, hands off to `handleScopedMcp`. A 401 is decorated
|
|
181
|
-
* with the per-vault RFC 9728 challenge. Because both entry points funnel
|
|
182
|
-
* through this one function, a root request behaves byte-identically to the
|
|
183
|
-
* same token at the per-vault URL — and, for the root, the re-validation here
|
|
184
|
-
* is the authoritative gate: a derivation that named the wrong vault fails the
|
|
185
|
-
* audience strict-check, it never bypasses it.
|
|
186
|
-
*/
|
|
187
|
-
async function dispatchScopedMcp(
|
|
188
|
-
req: Request,
|
|
189
|
-
vaultName: string,
|
|
190
|
-
vaultConfig: VaultConfig,
|
|
191
|
-
): Promise<Response> {
|
|
192
|
-
const auth = await authenticateVaultRequest(req, vaultConfig);
|
|
193
|
-
if ("error" in auth) return withMcpChallenge(auth.error, req, vaultName);
|
|
194
|
-
// Thread the RAW caller bearer (the exact credential the session presented)
|
|
195
|
-
// into the MCP layer so the manage-token tool can forward it to hub's
|
|
196
|
-
// mint-token attenuation proxy (vault#403, MGT). Only the raw validated
|
|
197
|
-
// bearer — never a fabricated one. extractApiKey returns the same value
|
|
198
|
-
// `authenticateVaultRequest` validated above; non-forwardable credentials
|
|
199
|
-
// (env-var secret, legacy pvt_*) are handled by manage-token itself (it only
|
|
200
|
-
// forwards JWT-shaped bearers).
|
|
201
|
-
const callerBearer = extractApiKey(req);
|
|
202
|
-
return handleScopedMcp(req, vaultName, auth, callerBearer);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
137
|
/**
|
|
206
138
|
* Check if a /view request has a valid API key (header or ?key= query param).
|
|
207
139
|
* Returns true if authenticated, false if not. Never rejects — unauthenticated
|
|
@@ -327,18 +259,6 @@ export async function route(
|
|
|
327
259
|
return handleAuthorizationServer(req, vaultName);
|
|
328
260
|
}
|
|
329
261
|
|
|
330
|
-
// Root-endpoint protected-resource metadata (RFC 9728 §3.1 path-insertion)
|
|
331
|
-
// for the canonical `/mcp` endpoint (U1). The root resource is vault-
|
|
332
|
-
// agnostic — the target vault is derived from the token, not the URL — so
|
|
333
|
-
// this document carries no vault name and advertises the un-narrowed
|
|
334
|
-
// `vault:read` / `vault:write` scopes; the hub's consent picker narrows them
|
|
335
|
-
// to a chosen vault at authorization time. Distinct from the per-vault
|
|
336
|
-
// insertion shapes above (which require a `/vault/<name>` segment), so there
|
|
337
|
-
// is no overlap. The `rootMcpChallenge` 401 points clients here.
|
|
338
|
-
if (path === "/.well-known/oauth-protected-resource/mcp") {
|
|
339
|
-
return handleRootProtectedResource(req);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
262
|
// ---------------------------------------------------------------------
|
|
343
263
|
// Cross-vault / origin-root endpoints
|
|
344
264
|
// ---------------------------------------------------------------------
|
|
@@ -447,43 +367,6 @@ export async function route(
|
|
|
447
367
|
return Response.json({ vaults });
|
|
448
368
|
}
|
|
449
369
|
|
|
450
|
-
// ---------------------------------------------------------------------
|
|
451
|
-
// Canonical root MCP endpoint — token-derived vault dispatch (U1).
|
|
452
|
-
//
|
|
453
|
-
// `POST /mcp` (plus the GET/DELETE the streamable-HTTP transport uses, and
|
|
454
|
-
// the `/mcp/*` subpaths) is the vault-AGNOSTIC entry point: the target vault
|
|
455
|
-
// is derived from the TOKEN, not the URL. We validate the bearer with the
|
|
456
|
-
// same scope-guard trust kernel the per-vault path uses, read the vault name
|
|
457
|
-
// from the token's claims, then RE-DISPATCH through the identical per-vault
|
|
458
|
-
// machinery (`dispatchScopedMcp` → `authenticateVaultRequest` →
|
|
459
|
-
// `handleScopedMcp`). The re-dispatch re-validates WITH the audience pin, so
|
|
460
|
-
// a bad derivation fails the inner check rather than bypassing it (defense in
|
|
461
|
-
// depth). The legacy URL-addressed `/vault/<name>/mcp` is untouched and lives
|
|
462
|
-
// forever.
|
|
463
|
-
//
|
|
464
|
-
// No / invalid / unnameable token → 401 whose `WWW-Authenticate` points at
|
|
465
|
-
// the ROOT PRM (`/.well-known/oauth-protected-resource/mcp`), so a spec-
|
|
466
|
-
// following MCP client discovers the hub and requests the un-narrowed scopes
|
|
467
|
-
// its consent picker narrows to a chosen vault.
|
|
468
|
-
if (path === "/mcp" || path.startsWith("/mcp/")) {
|
|
469
|
-
const derived = await deriveVaultFromToken(req);
|
|
470
|
-
if ("error" in derived) {
|
|
471
|
-
return rootMcpChallenge(req);
|
|
472
|
-
}
|
|
473
|
-
const vaultConfig = readVaultConfig(derived.vaultName);
|
|
474
|
-
if (!vaultConfig) {
|
|
475
|
-
// A validly-signed hub token naming a vault that isn't on THIS server.
|
|
476
|
-
// Mirror the per-vault 404 (`/vault/<name>/mcp` on a missing vault) — the
|
|
477
|
-
// holder knows which vault their token targets; nothing is leaked to an
|
|
478
|
-
// anonymous caller (they never validate a token to reach here).
|
|
479
|
-
return Response.json(
|
|
480
|
-
{ error: "Vault not found", vault: derived.vaultName },
|
|
481
|
-
{ status: 404 },
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
return dispatchScopedMcp(req, derived.vaultName, vaultConfig);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
370
|
// ---------------------------------------------------------------------
|
|
488
371
|
// Per-vault routing: /vault/<name>/...
|
|
489
372
|
// ---------------------------------------------------------------------
|
|
@@ -607,40 +490,28 @@ export async function route(
|
|
|
607
490
|
return handleAuthorizationServer(req, vaultName);
|
|
608
491
|
}
|
|
609
492
|
|
|
610
|
-
// Attachment ticket spend (attachment-tickets design, Wave 1) — no auth
|
|
611
|
-
// beyond the ticket itself; the ticket IS the credential (single-use,
|
|
612
|
-
// short TTL, scoped to exactly one upload slot or one attachment's
|
|
613
|
-
// bytes). Deliberately placed BEFORE `authenticateVaultRequest` below —
|
|
614
|
-
// a bearer-less runtime (a bare `curl`) must be able to spend a ticket
|
|
615
|
-
// without ever holding this vault's API key. The path segment
|
|
616
|
-
// (`/tickets/<id>`) is outside the authed `/api` tree and outside
|
|
617
|
-
// `/mcp` on purpose — see `request-attachment-upload`/`-download`
|
|
618
|
-
// (core/src/mcp.ts) for where tickets are minted.
|
|
619
|
-
const vaultTicketMatch = subpath.match(/^\/tickets\/([^/]+)$/);
|
|
620
|
-
if (vaultTicketMatch) {
|
|
621
|
-
const ticketId = decodeURIComponent(vaultTicketMatch[1]!);
|
|
622
|
-
const store = getVaultStore(vaultName);
|
|
623
|
-
return handleTicketSpend(req, ticketId, vaultName, store);
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// MCP (per-vault, single-vault session) — the URL names the vault. Shares
|
|
627
|
-
// the exact dispatch tail with the token-derived root `/mcp` (U1), so both
|
|
628
|
-
// behave identically. Handled BEFORE the shared authenticated-surface auth
|
|
629
|
-
// below because `dispatchScopedMcp` owns its own auth + 401 challenge; a
|
|
630
|
-
// non-MCP 401 further down never carries the RFC 9728 challenge (it's
|
|
631
|
-
// MCP-only).
|
|
632
|
-
if (subpath === "/mcp" || subpath.startsWith("/mcp/")) {
|
|
633
|
-
return dispatchScopedMcp(req, vaultName, vaultConfig);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
493
|
// ---------------------------------------------------------------------
|
|
637
494
|
// Authenticated surface
|
|
638
495
|
// ---------------------------------------------------------------------
|
|
639
496
|
|
|
640
497
|
const store = getVaultStore(vaultName);
|
|
641
498
|
const auth = await authenticateVaultRequest(req, vaultConfig);
|
|
499
|
+
const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
|
|
642
500
|
if ("error" in auth) {
|
|
643
|
-
return auth.error;
|
|
501
|
+
return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// MCP (per-vault, single-vault session).
|
|
505
|
+
if (isScopedMcp) {
|
|
506
|
+
// Thread the RAW caller bearer (the exact credential the session
|
|
507
|
+
// presented) into the MCP layer so the manage-token tool can forward it
|
|
508
|
+
// to hub's mint-token attenuation proxy (vault#403, MGT). Only the raw
|
|
509
|
+
// validated bearer — never a fabricated one. extractApiKey returns the
|
|
510
|
+
// same value `authenticateVaultRequest` validated above; non-forwardable
|
|
511
|
+
// credentials (env-var secret, legacy pvt_*) are handled by manage-token
|
|
512
|
+
// itself (it only forwards JWT-shaped bearers).
|
|
513
|
+
const callerBearer = extractApiKey(req);
|
|
514
|
+
return handleScopedMcp(req, vaultName, auth, callerBearer);
|
|
644
515
|
}
|
|
645
516
|
|
|
646
517
|
// Bare `/vault/<name>` — single-vault root. Returns name, description,
|
package/src/scopes.ts
CHANGED
|
@@ -263,28 +263,6 @@ export function validateMintedScopes(
|
|
|
263
263
|
return { ok: true };
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
/**
|
|
267
|
-
* Distinct vault names NAMED by narrowed `vault:<name>:<verb>` scopes in the
|
|
268
|
-
* granted list. Broad `vault:<verb>` scopes name no vault (a legacy/operator
|
|
269
|
-
* shape) and are skipped; the `migrate` axis isn't a read/write/admin verb so
|
|
270
|
-
* `decomposeVaultScope` returns null for it and it too is skipped.
|
|
271
|
-
*
|
|
272
|
-
* Used by the canonical root `/mcp` endpoint (U1) to read the target vault
|
|
273
|
-
* from a token's scope claim — one of three agreeing sources (scope / `aud` /
|
|
274
|
-
* single-element `vault_scope`) that `deriveVaultFromToken` cross-checks. A
|
|
275
|
-
* hub-minted token carries scopes for exactly one vault, so this returns a
|
|
276
|
-
* single-element list in practice; a return of length ≠ 1 signals a
|
|
277
|
-
* malformed/multi-vault scope set the derivation treats as a disagreement.
|
|
278
|
-
*/
|
|
279
|
-
export function narrowedVaultNames(granted: string[]): string[] {
|
|
280
|
-
const names = new Set<string>();
|
|
281
|
-
for (const s of granted) {
|
|
282
|
-
const d = decomposeVaultScope(s);
|
|
283
|
-
if (d && d.vault !== null) names.add(d.vault);
|
|
284
|
-
}
|
|
285
|
-
return [...names];
|
|
286
|
-
}
|
|
287
|
-
|
|
288
266
|
/**
|
|
289
267
|
* Detect a broad `vault:<verb>` scope in a granted list. Hub-issued JWTs
|
|
290
268
|
* must NOT carry broad vault scopes — the hub mints `vault:<name>:<verb>` so
|
package/src/server.ts
CHANGED
|
@@ -21,7 +21,6 @@ import { migrateVaultKeys } from "./token-store.ts";
|
|
|
21
21
|
import { resolveFirstBootVaultName, reservedNameSquatWarnings } from "./vault-name.ts";
|
|
22
22
|
import { getVaultStore, getVaultNameForStore, getSharedEmbeddingProvider } from "./vault-store.ts";
|
|
23
23
|
import { EmbeddingWorker, registerEmbeddingHook } from "./embedding-worker.ts";
|
|
24
|
-
import { startAttachmentTicketSweep, stopAttachmentTicketSweep } from "./attachment-tickets.ts";
|
|
25
24
|
import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
|
|
26
25
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
27
26
|
import { registerTriggers } from "./triggers.ts";
|
|
@@ -238,11 +237,6 @@ const embeddingWorker = new EmbeddingWorker({
|
|
|
238
237
|
registerEmbeddingHook(defaultHookRegistry, embeddingWorker, (store) => getVaultNameForStore(store as never));
|
|
239
238
|
embeddingWorker.start();
|
|
240
239
|
|
|
241
|
-
// Attachment-ticket sweep (vault#612) — drops expired-unspent tickets from
|
|
242
|
-
// the in-process store so an abandoned mint (an agent that never curls)
|
|
243
|
-
// doesn't sit in memory forever. See src/attachment-tickets.ts.
|
|
244
|
-
startAttachmentTicketSweep();
|
|
245
|
-
|
|
246
240
|
if (process.env.VAULT_AUTH_TOKEN?.trim()) {
|
|
247
241
|
console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
|
|
248
242
|
}
|
|
@@ -625,7 +619,6 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
625
619
|
// Then drain hooks + stop the transcription/embedding workers in
|
|
626
620
|
// parallel.
|
|
627
621
|
embeddingWorker.stop();
|
|
628
|
-
stopAttachmentTicketSweep();
|
|
629
622
|
await Promise.all([
|
|
630
623
|
defaultHookRegistry.drain(),
|
|
631
624
|
transcriptionWorker?.stop() ?? Promise.resolve(),
|
package/src/storage.test.ts
CHANGED
|
@@ -30,7 +30,7 @@ const testDir = join(
|
|
|
30
30
|
process.env.PARACHUTE_HOME = testDir;
|
|
31
31
|
process.env.ASSETS_DIR = join(testDir, "assets");
|
|
32
32
|
|
|
33
|
-
const { handleStorage, MAX_UPLOAD_BYTES, MAX_REQUEST_BODY_BYTES
|
|
33
|
+
const { handleStorage, MAX_UPLOAD_BYTES, MAX_REQUEST_BODY_BYTES } = await import("./routes.ts");
|
|
34
34
|
const { expandTokenTagScope } = await import("./tag-scope.ts");
|
|
35
35
|
|
|
36
36
|
// The upload-allowlist tests never touch the store (POST /upload writes to
|
|
@@ -564,202 +564,3 @@ describe("storage POST parity (finding D)", () => {
|
|
|
564
564
|
expect(res.status).toBe(404);
|
|
565
565
|
});
|
|
566
566
|
});
|
|
567
|
-
|
|
568
|
-
// ---------------------------------------------------------------------------
|
|
569
|
-
// REST Range (206) — vault attachments-for-agents design D9, the REST twin
|
|
570
|
-
// of MCP's content_offset/content_length.
|
|
571
|
-
// ---------------------------------------------------------------------------
|
|
572
|
-
|
|
573
|
-
describe("parseByteRangeHeader", () => {
|
|
574
|
-
test("no header → null (full response)", () => {
|
|
575
|
-
expect(parseByteRangeHeader(null, 100)).toBeNull();
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
test("bytes=0-99 on a 100-byte file → the whole file as one satisfiable range", () => {
|
|
579
|
-
expect(parseByteRangeHeader("bytes=0-99", 100)).toEqual({ start: 0, end: 99 });
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
test("bytes=10-19 → an inclusive mid-file window", () => {
|
|
583
|
-
expect(parseByteRangeHeader("bytes=10-19", 100)).toEqual({ start: 10, end: 19 });
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
test("bytes=90- (open-ended) → reads to EOF", () => {
|
|
587
|
-
expect(parseByteRangeHeader("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
test("bytes=-10 (suffix range) → the last 10 bytes", () => {
|
|
591
|
-
expect(parseByteRangeHeader("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
test("bytes=-1000 (suffix longer than the file) → clamps to the whole file", () => {
|
|
595
|
-
expect(parseByteRangeHeader("bytes=-1000", 100)).toEqual({ start: 0, end: 99 });
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
test("bytes=50-1000 (end past EOF) → clamps end to the last byte", () => {
|
|
599
|
-
expect(parseByteRangeHeader("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 });
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
test("start past EOF → null (this function's own contract; a real Bun.serve() socket overrides with a native 416 — see doc comment)", () => {
|
|
603
|
-
expect(parseByteRangeHeader("bytes=100-200", 100)).toBeNull();
|
|
604
|
-
expect(parseByteRangeHeader("bytes=1000-", 100)).toBeNull();
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
test("start > end → null (malformed)", () => {
|
|
608
|
-
expect(parseByteRangeHeader("bytes=50-10", 100)).toBeNull();
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
test("bytes=- (both empty) → null", () => {
|
|
612
|
-
expect(parseByteRangeHeader("bytes=-", 100)).toBeNull();
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
test("a multi-range list (bytes=0-10,20-30) → null (ignored per D9, not an error)", () => {
|
|
616
|
-
expect(parseByteRangeHeader("bytes=0-10,20-30", 100)).toBeNull();
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
test("an unrecognized unit → null", () => {
|
|
620
|
-
expect(parseByteRangeHeader("items=0-10", 100)).toBeNull();
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
test("garbage value → null, not a throw", () => {
|
|
624
|
-
expect(parseByteRangeHeader("bytes=abc-def", 100)).toBeNull();
|
|
625
|
-
expect(parseByteRangeHeader("nonsense", 100)).toBeNull();
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
test("a zero-byte file → null regardless of header (nothing to range over)", () => {
|
|
629
|
-
expect(parseByteRangeHeader("bytes=0-0", 0)).toBeNull();
|
|
630
|
-
});
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
describe("storage GET Range support (vault attachments-for-agents design D9)", () => {
|
|
634
|
-
const VAULT = "range-vault";
|
|
635
|
-
// 26 bytes, byte-identical to its own index — content[i] === i (0x00..0x19)
|
|
636
|
-
// — so any slice's bytes can be asserted exactly by index, not just length.
|
|
637
|
-
const CONTENT = Buffer.from(Array.from({ length: 26 }, (_, i) => i));
|
|
638
|
-
|
|
639
|
-
async function setup(): Promise<{ store: SqliteStore; assets: string; relPath: string }> {
|
|
640
|
-
const store = freshStore();
|
|
641
|
-
const assets = join(testDir, "assets", VAULT, "data");
|
|
642
|
-
mkdirSync(join(assets, "2026-05-28"), { recursive: true });
|
|
643
|
-
process.env.ASSETS_DIR = assets;
|
|
644
|
-
|
|
645
|
-
const relPath = "2026-05-28/ranged.bin";
|
|
646
|
-
writeFileSync(join(assets, relPath), CONTENT);
|
|
647
|
-
const note = await store.createNote("range note", { tags: ["misc"] });
|
|
648
|
-
await store.addAttachment(note.id, relPath, "application/octet-stream");
|
|
649
|
-
|
|
650
|
-
return { store, assets, relPath };
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
function getReq(reqPath: string, range?: string): Request {
|
|
654
|
-
const headers: Record<string, string> = {};
|
|
655
|
-
if (range !== undefined) headers.range = range;
|
|
656
|
-
return new Request(`http://localhost:1940/storage/${reqPath}`, { method: "GET", headers });
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
test("no Range header → 200, full body, Accept-Ranges advertised", async () => {
|
|
660
|
-
const { store, relPath } = await setup();
|
|
661
|
-
const res = await handleStorage(getReq(relPath), `/${relPath}`, VAULT, store);
|
|
662
|
-
expect(res.status).toBe(200);
|
|
663
|
-
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
664
|
-
expect(res.headers.get("Content-Length")).toBe("26");
|
|
665
|
-
expect(res.headers.has("Content-Range")).toBe(false);
|
|
666
|
-
const body = Buffer.from(await res.arrayBuffer());
|
|
667
|
-
expect(body.equals(CONTENT)).toBe(true);
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
test("Range: bytes=0-9 → 206, first 10 bytes, correct Content-Range", async () => {
|
|
671
|
-
const { store, relPath } = await setup();
|
|
672
|
-
const res = await handleStorage(getReq(relPath, "bytes=0-9"), `/${relPath}`, VAULT, store);
|
|
673
|
-
expect(res.status).toBe(206);
|
|
674
|
-
expect(res.headers.get("Content-Range")).toBe("bytes 0-9/26");
|
|
675
|
-
expect(res.headers.get("Content-Length")).toBe("10");
|
|
676
|
-
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
677
|
-
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
678
|
-
const body = Buffer.from(await res.arrayBuffer());
|
|
679
|
-
expect(body.equals(CONTENT.subarray(0, 10))).toBe(true);
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
test("Range: bytes=16-25 (to EOF) → 206, correct tail bytes", async () => {
|
|
683
|
-
const { store, relPath } = await setup();
|
|
684
|
-
const res = await handleStorage(getReq(relPath, "bytes=16-25"), `/${relPath}`, VAULT, store);
|
|
685
|
-
expect(res.status).toBe(206);
|
|
686
|
-
expect(res.headers.get("Content-Range")).toBe("bytes 16-25/26");
|
|
687
|
-
const body = Buffer.from(await res.arrayBuffer());
|
|
688
|
-
expect(body.equals(CONTENT.subarray(16, 26))).toBe(true);
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
test("Range: bytes=-5 (suffix) → 206, last 5 bytes", async () => {
|
|
692
|
-
const { store, relPath } = await setup();
|
|
693
|
-
const res = await handleStorage(getReq(relPath, "bytes=-5"), `/${relPath}`, VAULT, store);
|
|
694
|
-
expect(res.status).toBe(206);
|
|
695
|
-
expect(res.headers.get("Content-Range")).toBe("bytes 21-25/26");
|
|
696
|
-
const body = Buffer.from(await res.arrayBuffer());
|
|
697
|
-
expect(body.equals(CONTENT.subarray(21, 26))).toBe(true);
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
test("paging the whole file via sequential ranges reassembles byte-identical content", async () => {
|
|
701
|
-
const { store, relPath } = await setup();
|
|
702
|
-
const chunkSize = 7; // doesn't divide 26 evenly — exercises a short final chunk
|
|
703
|
-
const chunks: Buffer[] = [];
|
|
704
|
-
for (let start = 0; start < 26; start += chunkSize) {
|
|
705
|
-
const end = Math.min(start + chunkSize - 1, 25);
|
|
706
|
-
const res = await handleStorage(getReq(relPath, `bytes=${start}-${end}`), `/${relPath}`, VAULT, store);
|
|
707
|
-
expect(res.status).toBe(206);
|
|
708
|
-
chunks.push(Buffer.from(await res.arrayBuffer()));
|
|
709
|
-
}
|
|
710
|
-
expect(Buffer.concat(chunks).equals(CONTENT)).toBe(true);
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
test("a malformed Range header → 200 full response, not an error (D9: ignore, don't fail)", async () => {
|
|
714
|
-
const { store, relPath } = await setup();
|
|
715
|
-
const res = await handleStorage(getReq(relPath, "not-a-range"), `/${relPath}`, VAULT, store);
|
|
716
|
-
expect(res.status).toBe(200);
|
|
717
|
-
expect(res.headers.has("Content-Range")).toBe(false);
|
|
718
|
-
const body = Buffer.from(await res.arrayBuffer());
|
|
719
|
-
expect(body.equals(CONTENT)).toBe(true);
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
test("a multi-range Range header → 200 full response (multipart ranges unsupported, ignored per D9)", async () => {
|
|
723
|
-
const { store, relPath } = await setup();
|
|
724
|
-
const res = await handleStorage(getReq(relPath, "bytes=0-5,10-15"), `/${relPath}`, VAULT, store);
|
|
725
|
-
expect(res.status).toBe(200);
|
|
726
|
-
const body = Buffer.from(await res.arrayBuffer());
|
|
727
|
-
expect(body.equals(CONTENT)).toBe(true);
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
test("an unsatisfiable Range (start past EOF) → handleStorage's OWN logic returns 200 (parseByteRangeHeader's null contract)", async () => {
|
|
731
|
-
// IMPORTANT — this is NOT the whole live story. `handleStorage` is
|
|
732
|
-
// called here directly, in-process, so this only exercises OUR
|
|
733
|
-
// fallback logic (parseByteRangeHeader → null → the unranged 200
|
|
734
|
-
// branch). Live-verified against a real `Bun.serve()` socket (not
|
|
735
|
-
// this harness): Bun's own runtime independently reinterprets the
|
|
736
|
-
// incoming `Range` header on a `Bun.file()`-backed response body and
|
|
737
|
-
// overrides an out-of-bounds range with a native
|
|
738
|
-
// **416 Range Not Satisfiable** — RFC 7233-correct, and kept, not
|
|
739
|
-
// fought. This in-process harness has no socket for Bun's native
|
|
740
|
-
// layer to intercept, so it can only ever observe the 200 our own
|
|
741
|
-
// code returns — see `parseByteRangeHeader`'s doc comment
|
|
742
|
-
// (`src/routes.ts`) for the full explanation.
|
|
743
|
-
const { store, relPath } = await setup();
|
|
744
|
-
const res = await handleStorage(getReq(relPath, "bytes=1000-2000"), `/${relPath}`, VAULT, store);
|
|
745
|
-
expect(res.status).toBe(200);
|
|
746
|
-
const body = Buffer.from(await res.arrayBuffer());
|
|
747
|
-
expect(body.equals(CONTENT)).toBe(true);
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
test("Range is still honored for a tag-scoped in-scope request (206)", async () => {
|
|
751
|
-
const { store, relPath } = await setup();
|
|
752
|
-
const ctx = await tagScopeCtx(store, ["misc"]);
|
|
753
|
-
const res = await handleStorage(getReq(relPath, "bytes=0-3"), `/${relPath}`, VAULT, store, ctx);
|
|
754
|
-
expect(res.status).toBe(206);
|
|
755
|
-
const body = Buffer.from(await res.arrayBuffer());
|
|
756
|
-
expect(body.equals(CONTENT.subarray(0, 4))).toBe(true);
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
test("Range on an out-of-scope attachment still 404s (same confinement guard, byte-identical to the non-ranged path)", async () => {
|
|
760
|
-
const { store, relPath } = await setup();
|
|
761
|
-
const ctx = await tagScopeCtx(store, ["totally-unrelated-tag"]);
|
|
762
|
-
const res = await handleStorage(getReq(relPath, "bytes=0-3"), `/${relPath}`, VAULT, store, ctx);
|
|
763
|
-
expect(res.status).toBe(404);
|
|
764
|
-
});
|
|
765
|
-
});
|