@openparachute/vault 0.7.3-rc.5 → 0.7.3-rc.7
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/core/src/attachment/policy.test.ts +66 -0
- package/core/src/attachment/policy.ts +131 -0
- package/core/src/attachment/tickets.test.ts +45 -0
- package/core/src/attachment/tickets.ts +117 -0
- package/core/src/attachment-tickets-tool.test.ts +286 -0
- package/core/src/display-title.test.ts +149 -0
- package/core/src/mcp.ts +245 -4
- package/core/src/notes.ts +111 -4
- package/core/src/search-fts-v25.test.ts +9 -1
- package/core/src/search-query.test.ts +42 -0
- package/core/src/search-query.ts +27 -0
- package/core/src/search-title-boost.test.ts +107 -0
- package/core/src/types.ts +6 -0
- package/core/src/vault-projection.ts +48 -0
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/routes.ts +12 -91
- package/src/routing.ts +17 -0
- package/src/vault.test.ts +35 -11
|
@@ -0,0 +1,264 @@
|
|
|
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
|
+
class InProcessAttachmentTicketProvider implements AttachmentTicketProvider {
|
|
41
|
+
private readonly tickets = new Map<string, AttachmentTicket>();
|
|
42
|
+
|
|
43
|
+
async put(ticket: AttachmentTicket): Promise<void> {
|
|
44
|
+
this.tickets.set(ticket.id, ticket);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async take(id: string): Promise<AttachmentTicket | null> {
|
|
48
|
+
const ticket = this.tickets.get(id);
|
|
49
|
+
if (!ticket) return null;
|
|
50
|
+
this.tickets.delete(id);
|
|
51
|
+
return ticket;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let sharedProvider: InProcessAttachmentTicketProvider | undefined;
|
|
56
|
+
|
|
57
|
+
/** The one shared ticket provider for this process (see class doc comment above). */
|
|
58
|
+
export function getSharedAttachmentTicketProvider(): AttachmentTicketProvider {
|
|
59
|
+
if (!sharedProvider) sharedProvider = new InProcessAttachmentTicketProvider();
|
|
60
|
+
return sharedProvider;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Test-only: force a fresh provider so ticket state doesn't leak between test files. */
|
|
64
|
+
export function resetSharedAttachmentTicketProviderForTests(): void {
|
|
65
|
+
sharedProvider = undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Take + validate a ticket against the URL's vault name and the spend
|
|
70
|
+
* route's expected kind (a GET must not spend an upload ticket, etc.).
|
|
71
|
+
* Collapses spent / expired / unknown / wrong-vault / wrong-kind into the
|
|
72
|
+
* SAME `null` — the uniform-404 no-oracle posture the design calls for
|
|
73
|
+
* (an adversary probing an unguessable URL learns nothing about WHY it
|
|
74
|
+
* failed).
|
|
75
|
+
*/
|
|
76
|
+
async function takeValidTicket(
|
|
77
|
+
provider: AttachmentTicketProvider,
|
|
78
|
+
id: string,
|
|
79
|
+
vaultName: string,
|
|
80
|
+
kind: AttachmentTicket["kind"],
|
|
81
|
+
): Promise<AttachmentTicket | null> {
|
|
82
|
+
const ticket = await provider.take(id);
|
|
83
|
+
if (!ticket) return null;
|
|
84
|
+
if (ticket.kind !== kind || ticket.vaultName !== vaultName || ticket.expiresAt < Date.now()) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return ticket;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ticketNotFound(): Response {
|
|
91
|
+
return json(
|
|
92
|
+
{
|
|
93
|
+
error: "Not found",
|
|
94
|
+
error_type: "not_found",
|
|
95
|
+
how_to:
|
|
96
|
+
"tickets are single-use and short-lived — mint a new one with request-attachment-upload / request-attachment-download",
|
|
97
|
+
},
|
|
98
|
+
404,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Dispatch a ticket spend request for `/vault/<name>/tickets/<id>`. PUT or
|
|
104
|
+
* POST spends an upload ticket (the mint tool advertises PUT; POST is
|
|
105
|
+
* accepted too for runtimes/proxies that rewrite methods); GET spends a
|
|
106
|
+
* download ticket. Any other method, or a method that doesn't match the
|
|
107
|
+
* ticket's own kind, collapses to the same 404 `takeValidTicket` uses —
|
|
108
|
+
* no oracle on which kind a given id was minted for.
|
|
109
|
+
*/
|
|
110
|
+
export async function handleTicketSpend(
|
|
111
|
+
req: Request,
|
|
112
|
+
ticketId: string,
|
|
113
|
+
vaultName: string,
|
|
114
|
+
store: Store,
|
|
115
|
+
): Promise<Response> {
|
|
116
|
+
const provider = getSharedAttachmentTicketProvider();
|
|
117
|
+
|
|
118
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
119
|
+
return handleUploadSpend(req, ticketId, vaultName, store, provider);
|
|
120
|
+
}
|
|
121
|
+
if (req.method === "GET") {
|
|
122
|
+
return handleDownloadSpend(ticketId, vaultName, store, provider);
|
|
123
|
+
}
|
|
124
|
+
return ticketNotFound();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function handleUploadSpend(
|
|
128
|
+
req: Request,
|
|
129
|
+
ticketId: string,
|
|
130
|
+
vaultName: string,
|
|
131
|
+
store: Store,
|
|
132
|
+
provider: AttachmentTicketProvider,
|
|
133
|
+
): Promise<Response> {
|
|
134
|
+
const ticket = await takeValidTicket(provider, ticketId, vaultName, "upload");
|
|
135
|
+
if (!ticket || !ticket.noteId || !ticket.filename || !ticket.mimeType) return ticketNotFound();
|
|
136
|
+
|
|
137
|
+
// Declared size at mint IS the enforced cap here — tighter than the flat
|
|
138
|
+
// REST ceiling, per the ticket's own promise (`max_bytes` in the mint
|
|
139
|
+
// response). A ticket somehow minted with no declared size fails closed
|
|
140
|
+
// (0 bytes allowed) rather than falling back to the flat 100 MiB cap.
|
|
141
|
+
const declaredMax = ticket.sizeBytes ?? 0;
|
|
142
|
+
|
|
143
|
+
const contentLengthHeader = req.headers.get("content-length");
|
|
144
|
+
if (contentLengthHeader) {
|
|
145
|
+
const declared = Number(contentLengthHeader);
|
|
146
|
+
if (Number.isFinite(declared) && declared > declaredMax) {
|
|
147
|
+
return json(
|
|
148
|
+
{
|
|
149
|
+
error: `Upload exceeds the ticket's declared size (${declaredMax} bytes)`,
|
|
150
|
+
error_type: "file_too_large",
|
|
151
|
+
limit: declaredMax,
|
|
152
|
+
got: declared,
|
|
153
|
+
how_to: "mint a new ticket with an accurate size_bytes",
|
|
154
|
+
},
|
|
155
|
+
413,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const buffer = Buffer.from(await req.arrayBuffer());
|
|
161
|
+
if (buffer.length > declaredMax) {
|
|
162
|
+
return json(
|
|
163
|
+
{
|
|
164
|
+
error: `Upload exceeds the ticket's declared size (${declaredMax} bytes)`,
|
|
165
|
+
error_type: "file_too_large",
|
|
166
|
+
limit: declaredMax,
|
|
167
|
+
got: buffer.length,
|
|
168
|
+
how_to: "mint a new ticket with an accurate size_bytes",
|
|
169
|
+
},
|
|
170
|
+
413,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Same on-disk write discipline as REST's POST /storage/upload
|
|
175
|
+
// (src/routes.ts) — server-generated path, never caller-controlled.
|
|
176
|
+
const ext = sanitizeAttachmentExtension(ticket.filename);
|
|
177
|
+
const date = new Date().toISOString().split("T")[0]!;
|
|
178
|
+
const dir = join(assetsDir(vaultName), date);
|
|
179
|
+
mkdirSync(dir, { recursive: true });
|
|
180
|
+
const filename = `${Date.now()}-${crypto.randomUUID()}${ext}`;
|
|
181
|
+
writeFileSync(join(dir, filename), buffer);
|
|
182
|
+
const relativePath = `${date}/${filename}`;
|
|
183
|
+
|
|
184
|
+
invalidateUsageCache(vaultName);
|
|
185
|
+
|
|
186
|
+
// Mirrors the REST POST /notes/:id/attachments transcribe decision
|
|
187
|
+
// exactly (src/routes.ts): explicit opt-in (here, `transcribe: true` at
|
|
188
|
+
// mint) wins; otherwise infer from mime-type + the owning vault's
|
|
189
|
+
// auto-transcribe toggle.
|
|
190
|
+
const explicitOptIn = ticket.transcribe === true;
|
|
191
|
+
const perVaultEnabled = readVaultConfig(vaultName)?.auto_transcribe?.enabled;
|
|
192
|
+
const autoOptIn = !explicitOptIn && shouldAutoTranscribe(ticket.mimeType, { perVaultEnabled });
|
|
193
|
+
const attMeta: Record<string, unknown> = {
|
|
194
|
+
original_name: ticket.filename,
|
|
195
|
+
size: buffer.length,
|
|
196
|
+
};
|
|
197
|
+
if (explicitOptIn || autoOptIn) {
|
|
198
|
+
attMeta.transcribe_status = "pending";
|
|
199
|
+
attMeta.transcribe_requested_at = new Date().toISOString();
|
|
200
|
+
attMeta.transcribe_origin = explicitOptIn ? "legacy" : "auto";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const attachment = await store.addAttachment(ticket.noteId, relativePath, ticket.mimeType, attMeta);
|
|
204
|
+
|
|
205
|
+
if (explicitOptIn) {
|
|
206
|
+
const note = await store.getNote(ticket.noteId);
|
|
207
|
+
if (note) {
|
|
208
|
+
const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
|
|
209
|
+
if (noteMeta.transcribe_stub !== true) {
|
|
210
|
+
await store.updateNote(ticket.noteId, {
|
|
211
|
+
metadata: { ...noteMeta, transcribe_stub: true },
|
|
212
|
+
skipUpdatedAt: true,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Ticket mint + register in one shot (vs REST's two-step upload-then-
|
|
219
|
+
// attach) — the spend response is the attachment row itself, so the
|
|
220
|
+
// runtime can hand the id straight back to the model.
|
|
221
|
+
return json(attachment, 201);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function handleDownloadSpend(
|
|
225
|
+
ticketId: string,
|
|
226
|
+
vaultName: string,
|
|
227
|
+
store: Store,
|
|
228
|
+
provider: AttachmentTicketProvider,
|
|
229
|
+
): Promise<Response> {
|
|
230
|
+
const ticket = await takeValidTicket(provider, ticketId, vaultName, "download");
|
|
231
|
+
if (!ticket || !ticket.attachmentId) return ticketNotFound();
|
|
232
|
+
|
|
233
|
+
// The row can vanish between mint and spend (rare — a delete racing a
|
|
234
|
+
// slow curl); fold that into the same uniform 404 rather than a 500.
|
|
235
|
+
const attachment = await store.getAttachment(ticket.attachmentId);
|
|
236
|
+
if (!attachment) return ticketNotFound();
|
|
237
|
+
|
|
238
|
+
const assets = assetsDir(vaultName);
|
|
239
|
+
const filePath = normalize(join(assets, attachment.path));
|
|
240
|
+
if (!filePath.startsWith(normalize(assets)) || !existsSync(filePath)) {
|
|
241
|
+
return json(
|
|
242
|
+
{
|
|
243
|
+
error: "Not found",
|
|
244
|
+
error_type: "attachment_binary_missing",
|
|
245
|
+
how_to: "the attachment row exists but its bytes are gone — this ticket can't be re-spent",
|
|
246
|
+
},
|
|
247
|
+
404,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const stat = statSync(filePath);
|
|
252
|
+
const fileBuffer = readFileSync(filePath);
|
|
253
|
+
return new Response(fileBuffer, {
|
|
254
|
+
status: 200,
|
|
255
|
+
headers: {
|
|
256
|
+
"Content-Type": attachment.mimeType || "application/octet-stream",
|
|
257
|
+
"Content-Length": String(stat.size),
|
|
258
|
+
// Same defense-in-depth as the existing GET /storage/<path> byte-serve
|
|
259
|
+
// (src/routes.ts) — never let a browser MIME-sniff a stored asset into
|
|
260
|
+
// an active type.
|
|
261
|
+
"X-Content-Type-Options": "nosniff",
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
package/src/mcp-http.ts
CHANGED
|
@@ -109,7 +109,7 @@ export async function handleScopedMcp(
|
|
|
109
109
|
const instruction = await getServerInstruction(vaultName, auth);
|
|
110
110
|
return handleMcp(
|
|
111
111
|
req,
|
|
112
|
-
() => generateScopedMcpTools(vaultName, auth, callerBearer ?? null),
|
|
112
|
+
() => generateScopedMcpTools(vaultName, auth, callerBearer ?? null, req),
|
|
113
113
|
`parachute-vault/${vaultName}`,
|
|
114
114
|
vaultName,
|
|
115
115
|
auth,
|
|
@@ -229,6 +229,8 @@ export async function handleMcp(
|
|
|
229
229
|
tag?: string;
|
|
230
230
|
cycle?: unknown;
|
|
231
231
|
referencing_tags?: unknown;
|
|
232
|
+
/** Attachment-tickets design (§2c "errors as JIT docs") — a short, imperative next-step, distinct from the older free-form `hint`. */
|
|
233
|
+
how_to?: string;
|
|
232
234
|
};
|
|
233
235
|
// Honest-queries validation errors (vault#550) — `limit`/`offset`/date
|
|
234
236
|
// values that are structurally invalid rather than merely "no
|
|
@@ -399,6 +401,16 @@ export async function handleMcp(
|
|
|
399
401
|
error_type: e.error_type,
|
|
400
402
|
field: e.field,
|
|
401
403
|
hint: e.hint,
|
|
404
|
+
// Forwarded when present (undefined keys are fine — JSON-RPC
|
|
405
|
+
// `data` just omits them) so a `structuredError()` call site that
|
|
406
|
+
// stamps extra fields — `limit`/`got`/`extension` (size/type
|
|
407
|
+
// refusals) or `how_to` (attachment-tickets' JIT-docs field, §2c)
|
|
408
|
+
// — isn't silently truncated down to error_type/field/hint by
|
|
409
|
+
// this backstop.
|
|
410
|
+
...(e.limit !== undefined ? { limit: e.limit } : {}),
|
|
411
|
+
...(e.got !== undefined ? { got: e.got } : {}),
|
|
412
|
+
...(e.extension !== undefined ? { extension: e.extension } : {}),
|
|
413
|
+
...(e.how_to !== undefined ? { how_to: e.how_to } : {}),
|
|
402
414
|
});
|
|
403
415
|
}
|
|
404
416
|
return {
|
package/src/mcp-tools.ts
CHANGED
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
import { chooseHubOrigin, mintHubJwt, revokeHubJwt } from "./mcp-install.ts";
|
|
44
44
|
import { looksLikeJwt } from "./hub-jwt.ts";
|
|
45
45
|
import { readGlobalConfig, DEFAULT_PORT } from "./config.ts";
|
|
46
|
+
import { getBaseUrl } from "./oauth-discovery.ts";
|
|
47
|
+
import { getSharedAttachmentTicketProvider } from "./attachment-tickets.ts";
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
50
|
* Filter a vault projection to entries an in-scope tag contributes to.
|
|
@@ -110,6 +112,10 @@ export async function getServerInstruction(
|
|
|
110
112
|
description: config?.description ?? null,
|
|
111
113
|
projection,
|
|
112
114
|
coordinates: resolveVaultCoordinates(),
|
|
115
|
+
// Bun always wires an in-process AttachmentTicketProvider (see
|
|
116
|
+
// `generateScopedMcpTools` below) — the connect-time brief can
|
|
117
|
+
// unconditionally teach the ticket tools on this door.
|
|
118
|
+
attachments: { ticketsEnabled: true },
|
|
113
119
|
});
|
|
114
120
|
}
|
|
115
121
|
|
|
@@ -143,6 +149,16 @@ export function generateScopedMcpTools(
|
|
|
143
149
|
vaultName: string,
|
|
144
150
|
auth?: AuthResult,
|
|
145
151
|
callerBearer?: string | null,
|
|
152
|
+
/**
|
|
153
|
+
* The incoming MCP request, when available (omitted by the many
|
|
154
|
+
* test-only call sites that construct tools without a live request).
|
|
155
|
+
* Threaded through ONLY so the attachment-ticket tools can resolve a
|
|
156
|
+
* request-accurate `urlBase` (honoring `X-Forwarded-Host`/proto, same as
|
|
157
|
+
* `getBaseUrl`) — falls back to `resolveVaultCoordinates()`'s configured/
|
|
158
|
+
* expose-state origin when omitted, so ticket tools are still present
|
|
159
|
+
* (just less precisely origined) in that case.
|
|
160
|
+
*/
|
|
161
|
+
req?: Request,
|
|
146
162
|
): McpToolDef[] {
|
|
147
163
|
const store = getVaultStore(vaultName);
|
|
148
164
|
|
|
@@ -225,20 +241,39 @@ export function generateScopedMcpTools(
|
|
|
225
241
|
? (info) => logStrictBypass(info)
|
|
226
242
|
: undefined;
|
|
227
243
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
244
|
+
// Attachment tickets (Wave 1): bun always wires the in-process provider
|
|
245
|
+
// (see src/attachment-tickets.ts) — unlike the tag-scope predicates
|
|
246
|
+
// above, this isn't conditional on `scoped` because the tools should be
|
|
247
|
+
// listed for every session, scoped or not. Tag-scope confidentiality is
|
|
248
|
+
// still enforced (`noteVisible`, awaited inline inside the ticket tools'
|
|
249
|
+
// own async execute — see its doc comment in generateMcpTools for why
|
|
250
|
+
// this doesn't need the shared allowedHolder machinery the OTHER
|
|
251
|
+
// predicates above do).
|
|
252
|
+
const ticketNoteVisible = scoped
|
|
253
|
+
? async (note: Note) => {
|
|
254
|
+
const allowed = await expandTokenTagScope(store, rawTags);
|
|
255
|
+
return noteWithinTagScope(note, allowed, rawTags);
|
|
256
|
+
}
|
|
257
|
+
: undefined;
|
|
258
|
+
const ticketUrlBase = req
|
|
259
|
+
? `${getBaseUrl(req).replace(/\/$/, "")}/vault/${vaultName}`
|
|
260
|
+
: `${resolveVaultCoordinates().hubOrigin.replace(/\/$/, "")}/vault/${vaultName}`;
|
|
261
|
+
|
|
262
|
+
const tools = generateMcpTools(store, {
|
|
263
|
+
...(expandVisibility ? { expandVisibility } : {}),
|
|
264
|
+
...(nearTraversable ? { nearTraversable } : {}),
|
|
265
|
+
...(ifExistsVisible ? { ifExistsVisible } : {}),
|
|
266
|
+
...(aggregateVisibility ? { aggregateVisibility } : {}),
|
|
267
|
+
...(writeContext ? { writeContext } : {}),
|
|
268
|
+
...(strictBypass ? { strictBypass } : {}),
|
|
269
|
+
...(onStrictBypass ? { onStrictBypass } : {}),
|
|
270
|
+
attachmentTickets: {
|
|
271
|
+
provider: getSharedAttachmentTicketProvider(),
|
|
272
|
+
vaultName,
|
|
273
|
+
urlBase: ticketUrlBase,
|
|
274
|
+
...(ticketNoteVisible ? { noteVisible: ticketNoteVisible } : {}),
|
|
275
|
+
},
|
|
276
|
+
});
|
|
242
277
|
|
|
243
278
|
overrideVaultInfo(tools, vaultName, auth);
|
|
244
279
|
applyTagDependencyGuards(tools, vaultName);
|
package/src/routes.ts
CHANGED
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
type ContentRange,
|
|
43
43
|
} from "../core/src/content-range.ts";
|
|
44
44
|
import { attachValidationStatus, enforceStrictWrite, applySchemaDefaults } from "../core/src/mcp.ts";
|
|
45
|
+
import {
|
|
46
|
+
BLOCKED_ATTACHMENT_EXTENSIONS,
|
|
47
|
+
ATTACHMENT_MIME_TYPES,
|
|
48
|
+
} from "../core/src/attachment/policy.ts";
|
|
45
49
|
import type { ValidationWarning } from "../core/src/schema-defaults.ts";
|
|
46
50
|
import { logStrictBypass } from "./scopes.ts";
|
|
47
51
|
import * as linkOps from "../core/src/links.ts";
|
|
@@ -4440,97 +4444,14 @@ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
|
|
4440
4444
|
*/
|
|
4441
4445
|
export const MAX_REQUEST_BODY_BYTES = MAX_UPLOAD_BYTES + 20 * 1024 * 1024; // 120MB
|
|
4442
4446
|
|
|
4443
|
-
// Storage upload policy
|
|
4444
|
-
//
|
|
4445
|
-
//
|
|
4446
|
-
//
|
|
4447
|
-
//
|
|
4448
|
-
//
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
// .html/.htm/.xhtml/.shtml/.xht HTML — embeds <script>
|
|
4452
|
-
// .svg XML image — embeds <script>
|
|
4453
|
-
// .xml can carry XSLT / be parsed as XHTML
|
|
4454
|
-
// .js/.mjs/.cjs JavaScript
|
|
4455
|
-
// .css style-injection / UI-redress vector
|
|
4456
|
-
//
|
|
4457
|
-
// Two independent guards keep every STORED file inert when served:
|
|
4458
|
-
// 1. Only the curated MIME_TYPES below map to a real (always passive) type;
|
|
4459
|
-
// every other extension serves as application/octet-stream — a download,
|
|
4460
|
-
// never rendered.
|
|
4461
|
-
// 2. The GET byte-serve response pins `X-Content-Type-Options: nosniff`, so
|
|
4462
|
-
// a browser can't sniff an octet-stream body into an executable type.
|
|
4463
|
-
// The blocklist is belt-and-suspenders on top of those: even if a future MIME
|
|
4464
|
-
// entry or an upstream proxy weakened (1) or (2), these extensions still never
|
|
4465
|
-
// land on disk. If a future use case needs SVG, sanitize on read (strip
|
|
4466
|
-
// <script>/<foreignObject>) and revisit.
|
|
4467
|
-
const BLOCKED_EXTENSIONS = new Set([
|
|
4468
|
-
".html", ".htm", ".xhtml", ".shtml", ".xht",
|
|
4469
|
-
".svg",
|
|
4470
|
-
".xml",
|
|
4471
|
-
".js", ".mjs", ".cjs",
|
|
4472
|
-
".css",
|
|
4473
|
-
]);
|
|
4474
|
-
|
|
4475
|
-
// Explicit MIME types for the commonly-previewed formats. Anything accepted
|
|
4476
|
-
// but absent here serves as application/octet-stream — a download, never
|
|
4477
|
-
// rendered (e.g. .pages/.key/.numbers/.azw3/.exe/arbitrary binaries). None of
|
|
4478
|
-
// these map to an active type (text/html, image/svg+xml), so a served asset
|
|
4479
|
-
// can't execute script; `nosniff` on the GET response makes that ironclad.
|
|
4480
|
-
//
|
|
4481
|
-
// INVARIANT: never add an entry that maps to a browser-active type —
|
|
4482
|
-
// text/html, image/svg+xml, application/xhtml+xml, text/javascript,
|
|
4483
|
-
// application/wasm, text/css. Doing so re-enables same-origin execution for
|
|
4484
|
-
// that extension (and would mean it must also join BLOCKED_EXTENSIONS).
|
|
4485
|
-
const MIME_TYPES: Record<string, string> = {
|
|
4486
|
-
// Audio
|
|
4487
|
-
".wav": "audio/wav",
|
|
4488
|
-
".mp3": "audio/mpeg",
|
|
4489
|
-
".m4a": "audio/mp4",
|
|
4490
|
-
".ogg": "audio/ogg",
|
|
4491
|
-
".oga": "audio/ogg",
|
|
4492
|
-
".opus": "audio/opus",
|
|
4493
|
-
".aac": "audio/aac",
|
|
4494
|
-
".flac": "audio/flac",
|
|
4495
|
-
".webm": "audio/webm",
|
|
4496
|
-
// Image
|
|
4497
|
-
".png": "image/png",
|
|
4498
|
-
".jpg": "image/jpeg",
|
|
4499
|
-
".jpeg": "image/jpeg",
|
|
4500
|
-
".gif": "image/gif",
|
|
4501
|
-
".webp": "image/webp",
|
|
4502
|
-
".bmp": "image/bmp",
|
|
4503
|
-
".tiff": "image/tiff",
|
|
4504
|
-
".tif": "image/tiff",
|
|
4505
|
-
".heic": "image/heic",
|
|
4506
|
-
".heif": "image/heif",
|
|
4507
|
-
".avif": "image/avif",
|
|
4508
|
-
// Video
|
|
4509
|
-
".mp4": "video/mp4",
|
|
4510
|
-
".m4v": "video/x-m4v",
|
|
4511
|
-
".mov": "video/quicktime",
|
|
4512
|
-
// Documents / ebooks / data
|
|
4513
|
-
".pdf": "application/pdf",
|
|
4514
|
-
".epub": "application/epub+zip",
|
|
4515
|
-
".mobi": "application/x-mobipocket-ebook",
|
|
4516
|
-
".txt": "text/plain; charset=utf-8",
|
|
4517
|
-
".md": "text/markdown; charset=utf-8",
|
|
4518
|
-
".markdown": "text/markdown; charset=utf-8",
|
|
4519
|
-
".rtf": "application/rtf",
|
|
4520
|
-
".csv": "text/csv; charset=utf-8",
|
|
4521
|
-
".tsv": "text/tab-separated-values; charset=utf-8",
|
|
4522
|
-
".json": "application/json; charset=utf-8",
|
|
4523
|
-
".doc": "application/msword",
|
|
4524
|
-
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
4525
|
-
".ppt": "application/vnd.ms-powerpoint",
|
|
4526
|
-
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
4527
|
-
".xls": "application/vnd.ms-excel",
|
|
4528
|
-
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
4529
|
-
".odt": "application/vnd.oasis.opendocument.text",
|
|
4530
|
-
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
4531
|
-
".odp": "application/vnd.oasis.opendocument.presentation",
|
|
4532
|
-
".zip": "application/zip",
|
|
4533
|
-
};
|
|
4447
|
+
// Storage upload policy (extension blocklist + MIME lookup) now lives in
|
|
4448
|
+
// core/src/attachment/policy.ts — the canonical source shared with the
|
|
4449
|
+
// attachment-ticket mint/spend path (vault attachment-tickets design,
|
|
4450
|
+
// "shared BLOCKED_EXTENSIONS"), so a blocked extension can never diverge
|
|
4451
|
+
// between REST and tickets. See that module's doc comment for the full
|
|
4452
|
+
// same-origin-XSS rationale and the MIME curation invariant.
|
|
4453
|
+
const BLOCKED_EXTENSIONS = BLOCKED_ATTACHMENT_EXTENSIONS;
|
|
4454
|
+
const MIME_TYPES = ATTACHMENT_MIME_TYPES;
|
|
4534
4455
|
|
|
4535
4456
|
export async function handleStorage(
|
|
4536
4457
|
req: Request,
|
package/src/routing.ts
CHANGED
|
@@ -103,6 +103,7 @@ import {
|
|
|
103
103
|
} from "./mirror-routes.ts";
|
|
104
104
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
105
105
|
import { buildUsageReport } from "./usage.ts";
|
|
106
|
+
import { handleTicketSpend } from "./attachment-tickets.ts";
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
109
|
* Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
|
|
@@ -490,6 +491,22 @@ export async function route(
|
|
|
490
491
|
return handleAuthorizationServer(req, vaultName);
|
|
491
492
|
}
|
|
492
493
|
|
|
494
|
+
// Attachment ticket spend (attachment-tickets design, Wave 1) — no auth
|
|
495
|
+
// beyond the ticket itself; the ticket IS the credential (single-use,
|
|
496
|
+
// short TTL, scoped to exactly one upload slot or one attachment's
|
|
497
|
+
// bytes). Deliberately placed BEFORE `authenticateVaultRequest` below —
|
|
498
|
+
// a bearer-less runtime (a bare `curl`) must be able to spend a ticket
|
|
499
|
+
// without ever holding this vault's API key. The path segment
|
|
500
|
+
// (`/tickets/<id>`) is outside the authed `/api` tree and outside
|
|
501
|
+
// `/mcp` on purpose — see `request-attachment-upload`/`-download`
|
|
502
|
+
// (core/src/mcp.ts) for where tickets are minted.
|
|
503
|
+
const vaultTicketMatch = subpath.match(/^\/tickets\/([^/]+)$/);
|
|
504
|
+
if (vaultTicketMatch) {
|
|
505
|
+
const ticketId = decodeURIComponent(vaultTicketMatch[1]!);
|
|
506
|
+
const store = getVaultStore(vaultName);
|
|
507
|
+
return handleTicketSpend(req, ticketId, vaultName, store);
|
|
508
|
+
}
|
|
509
|
+
|
|
493
510
|
// ---------------------------------------------------------------------
|
|
494
511
|
// Authenticated surface
|
|
495
512
|
// ---------------------------------------------------------------------
|
package/src/vault.test.ts
CHANGED
|
@@ -2139,6 +2139,22 @@ describe("HTTP /notes", async () => {
|
|
|
2139
2139
|
expect(body[0]).not.toHaveProperty("content");
|
|
2140
2140
|
expect(body[0]).toHaveProperty("byteSize");
|
|
2141
2141
|
expect(body[0]).toHaveProperty("preview");
|
|
2142
|
+
expect(body[0]).toHaveProperty("displayTitle");
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
// Title axis (ratified 2026-07-17) — displayTitle on the REST lean shape.
|
|
2146
|
+
test("GET /notes lean shape carries the computed displayTitle", async () => {
|
|
2147
|
+
await store.createNote("# Meeting Notes\nagenda: budget", { path: "meeting" });
|
|
2148
|
+
const res = await handleNotes(mkReq("GET", "/notes"), store, "");
|
|
2149
|
+
const body = await res.json() as any[];
|
|
2150
|
+
expect(body[0].displayTitle).toBe("Meeting Notes");
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
test("GET /notes lean shape reports null displayTitle for an empty note", async () => {
|
|
2154
|
+
await store.createNote("", { path: "empty" });
|
|
2155
|
+
const res = await handleNotes(mkReq("GET", "/notes"), store, "");
|
|
2156
|
+
const body = await res.json() as any[];
|
|
2157
|
+
expect(body[0].displayTitle).toBeNull();
|
|
2142
2158
|
});
|
|
2143
2159
|
|
|
2144
2160
|
test("GET /notes?include_content=true returns full notes", async () => {
|
|
@@ -6609,8 +6625,12 @@ describe("stateless MCP transport", async () => {
|
|
|
6609
6625
|
expect(toolNames).not.toContain("merge-tags");
|
|
6610
6626
|
// Admin tools (vault#376) are hidden too
|
|
6611
6627
|
expect(toolNames).not.toContain("manage-token");
|
|
6612
|
-
//
|
|
6613
|
-
expect(toolNames
|
|
6628
|
+
// request-attachment-download is read-tier (upload is write-tier).
|
|
6629
|
+
expect(toolNames).toContain("request-attachment-download");
|
|
6630
|
+
expect(toolNames).not.toContain("request-attachment-upload");
|
|
6631
|
+
// Read tier is exactly 6 tools (doctor added by the re-tier;
|
|
6632
|
+
// request-attachment-download added by the attachment-tickets design).
|
|
6633
|
+
expect(toolNames.length).toBe(6);
|
|
6614
6634
|
|
|
6615
6635
|
closeAllStores();
|
|
6616
6636
|
});
|
|
@@ -6891,15 +6911,15 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6891
6911
|
return names;
|
|
6892
6912
|
}
|
|
6893
6913
|
|
|
6894
|
-
test("vault:read sees exactly the
|
|
6914
|
+
test("vault:read sees exactly the 6 read tools (doctor moved admin → read; request-attachment-download is read-tier)", async () => {
|
|
6895
6915
|
const names = await listToolNames(["vault:read"]);
|
|
6896
6916
|
expect(new Set(names)).toEqual(
|
|
6897
|
-
new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor"]),
|
|
6917
|
+
new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor", "request-attachment-download"]),
|
|
6898
6918
|
);
|
|
6899
|
-
expect(names.length).toBe(
|
|
6919
|
+
expect(names.length).toBe(6);
|
|
6900
6920
|
});
|
|
6901
6921
|
|
|
6902
|
-
test("vault:read + vault:write sees the
|
|
6922
|
+
test("vault:read + vault:write sees the 10 read+write tools (tag-schema tools moved write → admin; request-attachment-upload is write-tier)", async () => {
|
|
6903
6923
|
const names = await listToolNames(["vault:read", "vault:write"]);
|
|
6904
6924
|
expect(new Set(names)).toEqual(
|
|
6905
6925
|
new Set([
|
|
@@ -6911,9 +6931,11 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6911
6931
|
"create-note",
|
|
6912
6932
|
"update-note",
|
|
6913
6933
|
"delete-note",
|
|
6934
|
+
"request-attachment-upload",
|
|
6935
|
+
"request-attachment-download",
|
|
6914
6936
|
]),
|
|
6915
6937
|
);
|
|
6916
|
-
expect(names.length).toBe(
|
|
6938
|
+
expect(names.length).toBe(10);
|
|
6917
6939
|
expect(names).not.toContain("manage-token");
|
|
6918
6940
|
// Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
|
|
6919
6941
|
// admin-tier — structure/taxonomy curation, not content authorship.
|
|
@@ -6925,7 +6947,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6925
6947
|
expect(names).toContain("delete-note");
|
|
6926
6948
|
});
|
|
6927
6949
|
|
|
6928
|
-
test("vault:admin sees all
|
|
6950
|
+
test("vault:admin sees all 16 tools including manage-token + prune-schema + the tag-schema tools + both attachment-ticket tools", async () => {
|
|
6929
6951
|
const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
|
|
6930
6952
|
expect(names).toContain("manage-token");
|
|
6931
6953
|
expect(names).toContain("prune-schema");
|
|
@@ -6934,10 +6956,12 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6934
6956
|
expect(names).toContain("delete-tag");
|
|
6935
6957
|
expect(names).toContain("rename-tag");
|
|
6936
6958
|
expect(names).toContain("merge-tags");
|
|
6937
|
-
expect(names
|
|
6959
|
+
expect(names).toContain("request-attachment-upload");
|
|
6960
|
+
expect(names).toContain("request-attachment-download");
|
|
6961
|
+
expect(names.length).toBe(16);
|
|
6938
6962
|
});
|
|
6939
6963
|
|
|
6940
|
-
test("legacy-derived full token sees all
|
|
6964
|
+
test("legacy-derived full token sees all 16 tools (back-compat)", async () => {
|
|
6941
6965
|
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
6942
6966
|
const { writeVaultConfig } = await import("./config.ts");
|
|
6943
6967
|
const { closeAllStores } = await import("./vault-store.ts");
|
|
@@ -6970,7 +6994,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6970
6994
|
} as any);
|
|
6971
6995
|
const body = await res.json() as any;
|
|
6972
6996
|
const names: string[] = body.result.tools.map((t: any) => t.name);
|
|
6973
|
-
expect(names.length).toBe(
|
|
6997
|
+
expect(names.length).toBe(16);
|
|
6974
6998
|
expect(names).toContain("manage-token");
|
|
6975
6999
|
expect(names).toContain("prune-schema");
|
|
6976
7000
|
expect(names).toContain("doctor");
|