@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.
Files changed (53) hide show
  1. package/README.md +3 -5
  2. package/core/src/conformance.ts +1 -2
  3. package/core/src/content-range.test.ts +0 -127
  4. package/core/src/content-range.ts +0 -100
  5. package/core/src/contract-typed-index.test.ts +3 -4
  6. package/core/src/core.test.ts +4 -521
  7. package/core/src/expand.ts +3 -11
  8. package/core/src/indexed-fields.test.ts +3 -9
  9. package/core/src/indexed-fields.ts +1 -9
  10. package/core/src/mcp.ts +10 -601
  11. package/core/src/notes.ts +4 -201
  12. package/core/src/schema-defaults.ts +1 -85
  13. package/core/src/search-fts-v25.test.ts +1 -9
  14. package/core/src/search-query.test.ts +0 -42
  15. package/core/src/search-query.ts +0 -27
  16. package/core/src/seed-packs.test.ts +1 -117
  17. package/core/src/seed-packs.ts +1 -217
  18. package/core/src/store.ts +3 -59
  19. package/core/src/tag-schemas.ts +12 -27
  20. package/core/src/types.ts +1 -7
  21. package/core/src/vault-projection.ts +0 -55
  22. package/package.json +1 -1
  23. package/src/add-pack.test.ts +0 -32
  24. package/src/auth-hub-jwt.test.ts +1 -118
  25. package/src/auth.ts +0 -64
  26. package/src/cli.ts +1 -5
  27. package/src/contract-errors.test.ts +1 -2
  28. package/src/mcp-http.ts +4 -33
  29. package/src/mcp-tools.ts +14 -61
  30. package/src/oauth-discovery.ts +0 -31
  31. package/src/onboarding-seed.test.ts +0 -64
  32. package/src/routes.ts +95 -92
  33. package/src/routing.test.ts +4 -229
  34. package/src/routing.ts +23 -152
  35. package/src/scopes.ts +0 -22
  36. package/src/server.ts +0 -7
  37. package/src/storage.test.ts +1 -200
  38. package/src/transcription-worker.test.ts +0 -151
  39. package/src/transcription-worker.ts +52 -113
  40. package/src/vault.test.ts +11 -48
  41. package/core/src/attachment/bytes-provider.ts +0 -65
  42. package/core/src/attachment/policy.test.ts +0 -66
  43. package/core/src/attachment/policy.ts +0 -131
  44. package/core/src/attachment/tickets.test.ts +0 -45
  45. package/core/src/attachment/tickets.ts +0 -117
  46. package/core/src/attachment-tickets-tool.test.ts +0 -286
  47. package/core/src/display-title.test.ts +0 -190
  48. package/core/src/lede.test.ts +0 -96
  49. package/core/src/search-title-boost.test.ts +0 -125
  50. package/src/attachment-bytes.ts +0 -68
  51. package/src/attachment-tickets.test.ts +0 -475
  52. package/src/attachment-tickets.ts +0 -340
  53. package/src/read-attachment.test.ts +0 -436
@@ -1,340 +0,0 @@
1
- /**
2
- * Attachment tickets — bun's runtime-lane implementation (Wave 1).
3
- *
4
- * `InProcessAttachmentTicketProvider` is the process-wide (not per-vault)
5
- * ticket store — a plain `Map` keyed by the 256-bit ticket id, which is
6
- * already globally unguessable, so there's no need to shard by vault. A
7
- * daemon restart drops every outstanding ticket: acceptable given the
8
- * ≤30-minute TTL (a caller mid-upload across a restart just re-mints), and
9
- * simpler than persisting single-use, short-lived state to disk. Cloud's
10
- * future mirror persists to the vault's Durable Object instead — see
11
- * `AttachmentTicketProvider`'s doc comment (`core/src/attachment/tickets.ts`).
12
- *
13
- * `handleTicketSpend` is the bare (no auth beyond the ticket itself) HTTP
14
- * handler `src/routing.ts` dispatches to for `/vault/<name>/tickets/<id>`
15
- * — BEFORE `authenticateVaultRequest` runs, deliberately: the ticket IS
16
- * the credential, and a bearer-less shell (curl) must be able to spend it.
17
- */
18
-
19
- import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync } from "fs";
20
- import { join, normalize } from "path";
21
- import type { Store } from "../core/src/types.ts";
22
- import type { AttachmentTicket, AttachmentTicketProvider } from "../core/src/attachment/tickets.ts";
23
- import { sanitizeAttachmentExtension } from "../core/src/attachment/policy.ts";
24
- import { assetsDir, readVaultConfig } from "./config.ts";
25
- import { shouldAutoTranscribe } from "./auto-transcribe.ts";
26
- import { invalidateUsageCache } from "./usage.ts";
27
-
28
- function json(data: unknown, status = 200): Response {
29
- return Response.json(data, { status });
30
- }
31
-
32
- /**
33
- * Process-wide, in-memory ticket store (see module doc comment for the
34
- * restart-drops-tickets tradeoff). `take` deletes unconditionally on
35
- * lookup — single-use is enforced by the delete itself, not by a separate
36
- * expiry check; `takeValidTicket` below still checks `expiresAt` after
37
- * `take` returns, collapsing "expired" into the same null as "spent" /
38
- * "unknown" so the HTTP layer can give a uniform 404 (no oracle).
39
- */
40
- // Exported (alongside the shared-singleton accessors below) so
41
- // vault#612's sweep logic can be unit-tested against an ISOLATED instance
42
- // — no risk of a test's reset touching the one process-wide provider every
43
- // OTHER test file's ticket mint/spend flow also shares.
44
- export class InProcessAttachmentTicketProvider implements AttachmentTicketProvider {
45
- private readonly tickets = new Map<string, AttachmentTicket>();
46
-
47
- async put(ticket: AttachmentTicket): Promise<void> {
48
- this.tickets.set(ticket.id, ticket);
49
- }
50
-
51
- async take(id: string): Promise<AttachmentTicket | null> {
52
- const ticket = this.tickets.get(id);
53
- if (!ticket) return null;
54
- this.tickets.delete(id);
55
- return ticket;
56
- }
57
-
58
- /**
59
- * Drop every unspent ticket whose TTL has elapsed (vault#612). `take()`
60
- * already enforces expiry AT SPEND TIME — a caller can never successfully
61
- * spend a stale ticket — so this is purely a memory-hygiene backstop: an
62
- * agent that mints and then abandons the flow (network drop, a curl that
63
- * never runs) would otherwise leave its ticket in this Map forever. Returns
64
- * the count dropped, for test assertions / logging.
65
- */
66
- sweepExpired(now: number = Date.now()): number {
67
- let dropped = 0;
68
- for (const [id, ticket] of this.tickets) {
69
- if (ticket.expiresAt < now) {
70
- this.tickets.delete(id);
71
- dropped++;
72
- }
73
- }
74
- return dropped;
75
- }
76
-
77
- /** Test-only visibility into how many tickets are currently held. */
78
- size(): number {
79
- return this.tickets.size;
80
- }
81
- }
82
-
83
- let sharedProvider: InProcessAttachmentTicketProvider | undefined;
84
-
85
- /** The one shared ticket provider for this process (see class doc comment above). */
86
- export function getSharedAttachmentTicketProvider(): AttachmentTicketProvider {
87
- if (!sharedProvider) sharedProvider = new InProcessAttachmentTicketProvider();
88
- return sharedProvider;
89
- }
90
-
91
- /** Test-only: force a fresh provider so ticket state doesn't leak between test files. */
92
- export function resetSharedAttachmentTicketProviderForTests(): void {
93
- sharedProvider = undefined;
94
- }
95
-
96
- /** Test-only: how many tickets the shared provider currently holds (0 if never created). */
97
- export function sharedAttachmentTicketCountForTests(): number {
98
- return sharedProvider?.size() ?? 0;
99
- }
100
-
101
- /**
102
- * Drop every expired-unspent ticket from the shared provider (vault#612). A
103
- * no-op (returns 0) before the shared provider has ever been created — the
104
- * periodic sweep below calls this unconditionally, so this must tolerate
105
- * running before the first mint.
106
- */
107
- export function sweepExpiredAttachmentTickets(now: number = Date.now()): number {
108
- return sharedProvider?.sweepExpired(now) ?? 0;
109
- }
110
-
111
- /**
112
- * Sweep cadence (vault#612) — same cadence family as `EmbeddingWorker`'s
113
- * default sweep interval (`src/embedding-worker.ts`'s `DEFAULT_SWEEP_MS`).
114
- * Tickets are short-TTL (10-30 min, `computeTicketTtlMs`) and low-volume, so
115
- * a 30s sweep is comfortably frequent without being wasteful.
116
- */
117
- const TICKET_SWEEP_INTERVAL_MS = 30_000;
118
-
119
- let sweepTimer: ReturnType<typeof setInterval> | null = null;
120
-
121
- /**
122
- * Start the periodic expired-ticket sweep. No-op if already started. Mirrors
123
- * `EmbeddingWorker.start()`'s shape (`.unref()` so the timer never keeps the
124
- * process alive on its own — `server.ts`'s graceful-shutdown path still
125
- * calls `stopAttachmentTicketSweep()` explicitly for a clean stop, same as
126
- * `embeddingWorker.stop()`).
127
- */
128
- export function startAttachmentTicketSweep(intervalMs: number = TICKET_SWEEP_INTERVAL_MS): void {
129
- if (sweepTimer) return;
130
- sweepTimer = setInterval(() => {
131
- sweepExpiredAttachmentTickets();
132
- }, intervalMs);
133
- sweepTimer.unref?.();
134
- }
135
-
136
- /** Stop the periodic expired-ticket sweep. */
137
- export function stopAttachmentTicketSweep(): void {
138
- if (sweepTimer) {
139
- clearInterval(sweepTimer);
140
- sweepTimer = null;
141
- }
142
- }
143
-
144
- /**
145
- * Take + validate a ticket against the URL's vault name and the spend
146
- * route's expected kind (a GET must not spend an upload ticket, etc.).
147
- * Collapses spent / expired / unknown / wrong-vault / wrong-kind into the
148
- * SAME `null` — the uniform-404 no-oracle posture the design calls for
149
- * (an adversary probing an unguessable URL learns nothing about WHY it
150
- * failed).
151
- */
152
- async function takeValidTicket(
153
- provider: AttachmentTicketProvider,
154
- id: string,
155
- vaultName: string,
156
- kind: AttachmentTicket["kind"],
157
- ): Promise<AttachmentTicket | null> {
158
- const ticket = await provider.take(id);
159
- if (!ticket) return null;
160
- if (ticket.kind !== kind || ticket.vaultName !== vaultName || ticket.expiresAt < Date.now()) {
161
- return null;
162
- }
163
- return ticket;
164
- }
165
-
166
- function ticketNotFound(): Response {
167
- return json(
168
- {
169
- error: "Not found",
170
- error_type: "not_found",
171
- how_to:
172
- "tickets are single-use and short-lived — mint a new one with request-attachment-upload / request-attachment-download",
173
- },
174
- 404,
175
- );
176
- }
177
-
178
- /**
179
- * Dispatch a ticket spend request for `/vault/<name>/tickets/<id>`. PUT or
180
- * POST spends an upload ticket (the mint tool advertises PUT; POST is
181
- * accepted too for runtimes/proxies that rewrite methods); GET spends a
182
- * download ticket. Any other method, or a method that doesn't match the
183
- * ticket's own kind, collapses to the same 404 `takeValidTicket` uses —
184
- * no oracle on which kind a given id was minted for.
185
- */
186
- export async function handleTicketSpend(
187
- req: Request,
188
- ticketId: string,
189
- vaultName: string,
190
- store: Store,
191
- ): Promise<Response> {
192
- const provider = getSharedAttachmentTicketProvider();
193
-
194
- if (req.method === "PUT" || req.method === "POST") {
195
- return handleUploadSpend(req, ticketId, vaultName, store, provider);
196
- }
197
- if (req.method === "GET") {
198
- return handleDownloadSpend(ticketId, vaultName, store, provider);
199
- }
200
- return ticketNotFound();
201
- }
202
-
203
- async function handleUploadSpend(
204
- req: Request,
205
- ticketId: string,
206
- vaultName: string,
207
- store: Store,
208
- provider: AttachmentTicketProvider,
209
- ): Promise<Response> {
210
- const ticket = await takeValidTicket(provider, ticketId, vaultName, "upload");
211
- if (!ticket || !ticket.noteId || !ticket.filename || !ticket.mimeType) return ticketNotFound();
212
-
213
- // Declared size at mint IS the enforced cap here — tighter than the flat
214
- // REST ceiling, per the ticket's own promise (`max_bytes` in the mint
215
- // response). A ticket somehow minted with no declared size fails closed
216
- // (0 bytes allowed) rather than falling back to the flat 100 MiB cap.
217
- const declaredMax = ticket.sizeBytes ?? 0;
218
-
219
- const contentLengthHeader = req.headers.get("content-length");
220
- if (contentLengthHeader) {
221
- const declared = Number(contentLengthHeader);
222
- if (Number.isFinite(declared) && declared > declaredMax) {
223
- return json(
224
- {
225
- error: `Upload exceeds the ticket's declared size (${declaredMax} bytes)`,
226
- error_type: "file_too_large",
227
- limit: declaredMax,
228
- got: declared,
229
- how_to: "mint a new ticket with an accurate size_bytes",
230
- },
231
- 413,
232
- );
233
- }
234
- }
235
-
236
- const buffer = Buffer.from(await req.arrayBuffer());
237
- if (buffer.length > declaredMax) {
238
- return json(
239
- {
240
- error: `Upload exceeds the ticket's declared size (${declaredMax} bytes)`,
241
- error_type: "file_too_large",
242
- limit: declaredMax,
243
- got: buffer.length,
244
- how_to: "mint a new ticket with an accurate size_bytes",
245
- },
246
- 413,
247
- );
248
- }
249
-
250
- // Same on-disk write discipline as REST's POST /storage/upload
251
- // (src/routes.ts) — server-generated path, never caller-controlled.
252
- const ext = sanitizeAttachmentExtension(ticket.filename);
253
- const date = new Date().toISOString().split("T")[0]!;
254
- const dir = join(assetsDir(vaultName), date);
255
- mkdirSync(dir, { recursive: true });
256
- const filename = `${Date.now()}-${crypto.randomUUID()}${ext}`;
257
- writeFileSync(join(dir, filename), buffer);
258
- const relativePath = `${date}/${filename}`;
259
-
260
- invalidateUsageCache(vaultName);
261
-
262
- // Mirrors the REST POST /notes/:id/attachments transcribe decision
263
- // exactly (src/routes.ts): explicit opt-in (here, `transcribe: true` at
264
- // mint) wins; otherwise infer from mime-type + the owning vault's
265
- // auto-transcribe toggle.
266
- const explicitOptIn = ticket.transcribe === true;
267
- const perVaultEnabled = readVaultConfig(vaultName)?.auto_transcribe?.enabled;
268
- const autoOptIn = !explicitOptIn && shouldAutoTranscribe(ticket.mimeType, { perVaultEnabled });
269
- const attMeta: Record<string, unknown> = {
270
- original_name: ticket.filename,
271
- size: buffer.length,
272
- };
273
- if (explicitOptIn || autoOptIn) {
274
- attMeta.transcribe_status = "pending";
275
- attMeta.transcribe_requested_at = new Date().toISOString();
276
- attMeta.transcribe_origin = explicitOptIn ? "legacy" : "auto";
277
- }
278
-
279
- const attachment = await store.addAttachment(ticket.noteId, relativePath, ticket.mimeType, attMeta);
280
-
281
- if (explicitOptIn) {
282
- const note = await store.getNote(ticket.noteId);
283
- if (note) {
284
- const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
285
- if (noteMeta.transcribe_stub !== true) {
286
- await store.updateNote(ticket.noteId, {
287
- metadata: { ...noteMeta, transcribe_stub: true },
288
- skipUpdatedAt: true,
289
- });
290
- }
291
- }
292
- }
293
-
294
- // Ticket mint + register in one shot (vs REST's two-step upload-then-
295
- // attach) — the spend response is the attachment row itself, so the
296
- // runtime can hand the id straight back to the model.
297
- return json(attachment, 201);
298
- }
299
-
300
- async function handleDownloadSpend(
301
- ticketId: string,
302
- vaultName: string,
303
- store: Store,
304
- provider: AttachmentTicketProvider,
305
- ): Promise<Response> {
306
- const ticket = await takeValidTicket(provider, ticketId, vaultName, "download");
307
- if (!ticket || !ticket.attachmentId) return ticketNotFound();
308
-
309
- // The row can vanish between mint and spend (rare — a delete racing a
310
- // slow curl); fold that into the same uniform 404 rather than a 500.
311
- const attachment = await store.getAttachment(ticket.attachmentId);
312
- if (!attachment) return ticketNotFound();
313
-
314
- const assets = assetsDir(vaultName);
315
- const filePath = normalize(join(assets, attachment.path));
316
- if (!filePath.startsWith(normalize(assets)) || !existsSync(filePath)) {
317
- return json(
318
- {
319
- error: "Not found",
320
- error_type: "attachment_binary_missing",
321
- how_to: "the attachment row exists but its bytes are gone — this ticket can't be re-spent",
322
- },
323
- 404,
324
- );
325
- }
326
-
327
- const stat = statSync(filePath);
328
- const fileBuffer = readFileSync(filePath);
329
- return new Response(fileBuffer, {
330
- status: 200,
331
- headers: {
332
- "Content-Type": attachment.mimeType || "application/octet-stream",
333
- "Content-Length": String(stat.size),
334
- // Same defense-in-depth as the existing GET /storage/<path> byte-serve
335
- // (src/routes.ts) — never let a browser MIME-sniff a stored asset into
336
- // an active type.
337
- "X-Content-Type-Options": "nosniff",
338
- },
339
- });
340
- }