@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.3
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/content-range.test.ts +0 -127
- package/core/src/content-range.ts +0 -100
- package/core/src/core.test.ts +4 -66
- package/core/src/expand.ts +3 -11
- package/core/src/mcp.ts +6 -577
- package/core/src/notes.ts +4 -201
- 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/types.ts +0 -6
- 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/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
|
@@ -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
|
-
}
|