@openparachute/vault 0.3.3 → 0.4.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/.parachute/module.json +15 -0
- package/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +727 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +1626 -183
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/routes.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* REST API route handlers for the multi-vault server.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors the
|
|
4
|
+
* Mirrors the MCP tools:
|
|
5
5
|
* /api/notes — query-notes, create-note, update-note, delete-note
|
|
6
6
|
* /api/tags — list-tags, update-tag, delete-tag
|
|
7
7
|
* /api/find-path — find-path
|
|
@@ -13,9 +13,27 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Store, Note } from "../core/src/types.ts";
|
|
15
15
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
16
|
-
import { toNoteIndex, filterMetadata } from "../core/src/notes.ts";
|
|
16
|
+
import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
|
|
17
17
|
import * as linkOps from "../core/src/links.ts";
|
|
18
18
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
19
|
+
import {
|
|
20
|
+
filterNotesByTagScope,
|
|
21
|
+
noteWithinTagScope,
|
|
22
|
+
tagScopeForbidden,
|
|
23
|
+
tagsWithinScope,
|
|
24
|
+
} from "./tag-scope.ts";
|
|
25
|
+
import { findTokensReferencingTag } from "./token-store.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tag-scope context threaded through handlers. `allowed` is the
|
|
29
|
+
* pre-expanded set of permitted tags (root + descendants), `raw` is the
|
|
30
|
+
* original allowlist for error messages. Both null when the token is
|
|
31
|
+
* unscoped — handlers fast-path on `allowed === null` and behave
|
|
32
|
+
* identically to the pre-tag-scope code path.
|
|
33
|
+
*/
|
|
34
|
+
export type TagScopeCtx = { allowed: Set<string> | null; raw: string[] | null };
|
|
35
|
+
|
|
36
|
+
const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
|
|
19
37
|
import {
|
|
20
38
|
expandContent,
|
|
21
39
|
DEFAULT_EXPAND_DEPTH,
|
|
@@ -62,6 +80,258 @@ function parseInt10(val: string | null): number | undefined {
|
|
|
62
80
|
return isNaN(n) ? undefined : n;
|
|
63
81
|
}
|
|
64
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Parse bracket-style metadata filters (vault#285 friction point 1.3).
|
|
85
|
+
*
|
|
86
|
+
* Maps `?meta[field][op]=value` (Stripe / JSON:API / Strapi convention) to
|
|
87
|
+
* the engine `metadata` filter shape at `core/src/notes.ts:494-509`. Recognised
|
|
88
|
+
* forms:
|
|
89
|
+
*
|
|
90
|
+
* - `?meta[field]=value` — shorthand equality (JSON-scan;
|
|
91
|
+
* no indexed-field declaration
|
|
92
|
+
* required)
|
|
93
|
+
* - `?meta[field][op]=value` — operator query (routes through
|
|
94
|
+
* the indexed generated column;
|
|
95
|
+
* engine raises FIELD_NOT_INDEXED
|
|
96
|
+
* if the field isn't declared)
|
|
97
|
+
* - `?meta[field][in][]=v1&[in][]=v2` — array form for in/not_in
|
|
98
|
+
* - `?meta[field][in]=v1,v2` — comma-separated form for in/not_in
|
|
99
|
+
*
|
|
100
|
+
* Supported operators mirror the engine: `eq`, `ne`, `gt`, `gte`, `lt`,
|
|
101
|
+
* `lte`, `in`, `not_in`, `exists`. `exists` requires `"true"` or `"false"`;
|
|
102
|
+
* other values reject with INVALID_OPERATOR_VALUE.
|
|
103
|
+
*
|
|
104
|
+
* Compound filters AND together: multiple `meta[a][gte]=1&meta[a][lt]=5`
|
|
105
|
+
* on the same field merge into one operator object; filters on different
|
|
106
|
+
* fields stack as independent AND clauses (engine semantics).
|
|
107
|
+
*
|
|
108
|
+
* **Bridge for the real `n.created_at` / `n.updated_at` columns** — these
|
|
109
|
+
* route through `dateFilter` (the existing engine path that exempts them
|
|
110
|
+
* from the indexed-field gate), not through the metadata-filter path. Only
|
|
111
|
+
* `gte` (→ inclusive `from`) and `lt` (→ exclusive `to`) are accepted on
|
|
112
|
+
* these fields, matching the dateFilter contract exactly. Other operators
|
|
113
|
+
* reject with INVALID_QUERY so callers don't think `meta[created_at][eq]=…`
|
|
114
|
+
* works.
|
|
115
|
+
*
|
|
116
|
+
* Returns `{ metadata?, dateFilter?, error? }`. When `error` is set the
|
|
117
|
+
* caller should return it directly (already shaped as a 400 with
|
|
118
|
+
* `error` + `code`).
|
|
119
|
+
*/
|
|
120
|
+
function parseMetaBrackets(url: URL): {
|
|
121
|
+
metadata?: Record<string, unknown>;
|
|
122
|
+
dateFilter?: { field: string; from?: string; to?: string };
|
|
123
|
+
error?: Response;
|
|
124
|
+
} {
|
|
125
|
+
// Real columns on `notes` — exempt from the indexed-field gate, routed
|
|
126
|
+
// through `dateFilter` instead of `metadata`.
|
|
127
|
+
const REAL_DATE_COLUMNS = new Set(["created_at", "updated_at"]);
|
|
128
|
+
// Operators that take an array value. Used for parser-level rejection of
|
|
129
|
+
// `[]`-array syntax on the wrong operator (e.g. `meta[field][eq][]=value`).
|
|
130
|
+
const ARRAY_OPS = new Set(["in", "not_in"]);
|
|
131
|
+
// `meta[FIELD]` or `meta[FIELD][OP]` or `meta[FIELD][OP][]`. Field names are
|
|
132
|
+
// bounded by `FIELD_NAME_RE` at the engine layer; the parser is liberal here
|
|
133
|
+
// and lets the engine raise the loud error on bad names.
|
|
134
|
+
const META_RE = /^meta\[([^\]]+)\](?:\[([^\]]+)\](\[\])?)?$/;
|
|
135
|
+
|
|
136
|
+
// `metadata[field]` is either a primitive (shorthand `eq` via json_extract)
|
|
137
|
+
// or a sub-object of operator clauses. The two are *mutually exclusive per
|
|
138
|
+
// field per request*: mixing them is a silent-data-loss footgun (op set,
|
|
139
|
+
// then shorthand stomps; or shorthand set, then op stomps) so we reject
|
|
140
|
+
// loudly. Track each field's chosen form here.
|
|
141
|
+
const metadata: Record<string, unknown> = {};
|
|
142
|
+
const shorthandFields = new Set<string>();
|
|
143
|
+
const opBucketsByField = new Map<string, Map<string, string[]>>(); // field → op → values (array form)
|
|
144
|
+
const opObjectByField = new Map<string, Record<string, unknown>>(); // field → built op object (single-value ops)
|
|
145
|
+
|
|
146
|
+
// dateFilter accumulates `from` (gte) and `to` (lt) bounds on a single
|
|
147
|
+
// column. Spanning both `created_at` AND `updated_at` in one request is
|
|
148
|
+
// not expressible (the engine takes one `field`), so we reject early
|
|
149
|
+
// rather than silently corrupting one bound. See vault#289 review F1.
|
|
150
|
+
let dateField: "created_at" | "updated_at" | null = null;
|
|
151
|
+
let dateFrom: string | undefined;
|
|
152
|
+
let dateTo: string | undefined;
|
|
153
|
+
|
|
154
|
+
function rejectMixedForms(field: string): Response {
|
|
155
|
+
return json(
|
|
156
|
+
{
|
|
157
|
+
error: `bracket-meta filter: cannot mix shorthand and operator forms for the same field — \`meta[${field}]=…\` and \`meta[${field}][<op>]=…\` are mutually exclusive in one request. Pick one form.`,
|
|
158
|
+
code: "INVALID_QUERY",
|
|
159
|
+
},
|
|
160
|
+
400,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getOpObject(field: string): Record<string, unknown> {
|
|
165
|
+
let bucket = opObjectByField.get(field);
|
|
166
|
+
if (!bucket) {
|
|
167
|
+
bucket = {};
|
|
168
|
+
opObjectByField.set(field, bucket);
|
|
169
|
+
}
|
|
170
|
+
return bucket;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
174
|
+
const m = META_RE.exec(key);
|
|
175
|
+
if (!m) continue;
|
|
176
|
+
const field = m[1]!;
|
|
177
|
+
const op = m[2];
|
|
178
|
+
const isArray = m[3] === "[]";
|
|
179
|
+
|
|
180
|
+
// Bridge: real date columns route to dateFilter, not metadata.
|
|
181
|
+
if (REAL_DATE_COLUMNS.has(field)) {
|
|
182
|
+
if (!op) {
|
|
183
|
+
return {
|
|
184
|
+
error: json(
|
|
185
|
+
{
|
|
186
|
+
error: `bracket-date filter on \`${field}\` requires an operator: meta[${field}][gte]=… (lower bound) or meta[${field}][lt]=… (upper bound, exclusive).`,
|
|
187
|
+
code: "INVALID_QUERY",
|
|
188
|
+
},
|
|
189
|
+
400,
|
|
190
|
+
),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (op !== "gte" && op !== "lt") {
|
|
194
|
+
return {
|
|
195
|
+
error: json(
|
|
196
|
+
{
|
|
197
|
+
error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract uses these two ops because the equivalent flat shape (\`date_field=${field}&date_from=…&date_to=…\`) is half-open by design.`,
|
|
198
|
+
code: "INVALID_QUERY",
|
|
199
|
+
},
|
|
200
|
+
400,
|
|
201
|
+
),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// F1: dateFilter takes a single column. Reject the cross-column
|
|
205
|
+
// case before assigning — otherwise the second column's
|
|
206
|
+
// assignment would silently override the first.
|
|
207
|
+
if (dateField !== null && dateField !== field) {
|
|
208
|
+
return {
|
|
209
|
+
error: json(
|
|
210
|
+
{
|
|
211
|
+
error: `bracket-date filter cannot span both \`created_at\` and \`updated_at\` in one request — issue two queries or use one column per request.`,
|
|
212
|
+
code: "INVALID_QUERY",
|
|
213
|
+
},
|
|
214
|
+
400,
|
|
215
|
+
),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
dateField = field as "created_at" | "updated_at";
|
|
219
|
+
if (op === "gte") dateFrom = value;
|
|
220
|
+
else dateTo = value;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Regular metadata field.
|
|
225
|
+
if (!op) {
|
|
226
|
+
// Shorthand: `?meta[field]=value` → primitive (engine routes through
|
|
227
|
+
// json_extract; no indexed declaration required).
|
|
228
|
+
// F2: reject if any operator form already wrote a bucket for this
|
|
229
|
+
// field — the two shapes don't compose and the silent stomp would
|
|
230
|
+
// drop one form's intent. Mirror check for the reverse order below.
|
|
231
|
+
if (opObjectByField.has(field) || opBucketsByField.has(field)) {
|
|
232
|
+
return { error: rejectMixedForms(field) };
|
|
233
|
+
}
|
|
234
|
+
shorthandFields.add(field);
|
|
235
|
+
metadata[field] = value;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// F2: reject if shorthand already wrote a primitive for this field.
|
|
239
|
+
if (shorthandFields.has(field)) {
|
|
240
|
+
return { error: rejectMixedForms(field) };
|
|
241
|
+
}
|
|
242
|
+
// F4: `[]`-array syntax only makes sense for `in` / `not_in`. Other ops
|
|
243
|
+
// (eq, gt, exists, …) take a scalar; `meta[field][eq][]=v` is a
|
|
244
|
+
// shape error — surface it at the parser layer with a clear message
|
|
245
|
+
// instead of letting the engine raise a generic INVALID_OPERATOR_VALUE
|
|
246
|
+
// downstream.
|
|
247
|
+
if (isArray && !ARRAY_OPS.has(op)) {
|
|
248
|
+
return {
|
|
249
|
+
error: json(
|
|
250
|
+
{
|
|
251
|
+
error: `bracket-meta filter: array form \`meta[${field}][${op}][]=…\` is only valid for \`in\` and \`not_in\`. \`${op}\` takes a single value — use \`meta[${field}][${op}]=value\` instead.`,
|
|
252
|
+
code: "INVALID_OPERATOR_VALUE",
|
|
253
|
+
},
|
|
254
|
+
400,
|
|
255
|
+
),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (isArray) {
|
|
259
|
+
// `meta[field][in][]=v1&meta[field][in][]=v2`. Nested map keeps
|
|
260
|
+
// field and op as separate dimensions — no string-concat ambiguity
|
|
261
|
+
// for field names that contain (or might one day be allowed to
|
|
262
|
+
// contain) the delimiter character. See vault#289 review F5.
|
|
263
|
+
let fieldBucket = opBucketsByField.get(field);
|
|
264
|
+
if (!fieldBucket) {
|
|
265
|
+
fieldBucket = new Map<string, string[]>();
|
|
266
|
+
opBucketsByField.set(field, fieldBucket);
|
|
267
|
+
}
|
|
268
|
+
let values = fieldBucket.get(op);
|
|
269
|
+
if (!values) {
|
|
270
|
+
values = [];
|
|
271
|
+
fieldBucket.set(op, values);
|
|
272
|
+
}
|
|
273
|
+
values.push(value);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (op === "in" || op === "not_in") {
|
|
277
|
+
// Comma form: `meta[field][in]=v1,v2`. Mutually exclusive with the
|
|
278
|
+
// `[]` array form per field+op — last write wins if both are
|
|
279
|
+
// supplied; we don't reject because the resulting array is well-
|
|
280
|
+
// defined regardless of which form a caller picked.
|
|
281
|
+
const arr = value.includes(",")
|
|
282
|
+
? value.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
|
|
283
|
+
: [value];
|
|
284
|
+
getOpObject(field)[op] = arr;
|
|
285
|
+
} else if (op === "exists") {
|
|
286
|
+
const bool = value === "true" ? true : value === "false" ? false : null;
|
|
287
|
+
if (bool === null) {
|
|
288
|
+
return {
|
|
289
|
+
error: json(
|
|
290
|
+
{
|
|
291
|
+
error: `bracket-meta filter: \`exists\` on \`${field}\` requires "true" or "false", got "${value}"`,
|
|
292
|
+
code: "INVALID_OPERATOR_VALUE",
|
|
293
|
+
},
|
|
294
|
+
400,
|
|
295
|
+
),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
getOpObject(field)[op] = bool;
|
|
299
|
+
} else {
|
|
300
|
+
// eq / ne / gt / gte / lt / lte — all primitive, single value. Type
|
|
301
|
+
// coercion is left to SQLite affinity rules: indexed columns are
|
|
302
|
+
// declared TEXT or INTEGER, and SQLite will compare a string-shaped
|
|
303
|
+
// numeric correctly against an INTEGER column. The engine raises
|
|
304
|
+
// UNKNOWN_OPERATOR if `op` isn't in SUPPORTED_OPS.
|
|
305
|
+
getOpObject(field)[op] = value;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Roll up `[]`-array buckets onto their op-objects. Done after the main
|
|
310
|
+
// loop so `in`/`not_in` array-form and comma-form on the same field
|
|
311
|
+
// collapse into one merged op-object cleanly.
|
|
312
|
+
for (const [field, opMap] of opBucketsByField) {
|
|
313
|
+
for (const [op, values] of opMap) {
|
|
314
|
+
getOpObject(field)[op] = values;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Roll up op-objects onto the metadata payload.
|
|
318
|
+
for (const [field, opObj] of opObjectByField) {
|
|
319
|
+
metadata[field] = opObj;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const result: {
|
|
323
|
+
metadata?: Record<string, unknown>;
|
|
324
|
+
dateFilter?: { field: string; from?: string; to?: string };
|
|
325
|
+
} = {};
|
|
326
|
+
if (Object.keys(metadata).length > 0) result.metadata = metadata;
|
|
327
|
+
if (dateField) {
|
|
328
|
+
result.dateFilter = { field: dateField };
|
|
329
|
+
if (dateFrom !== undefined) result.dateFilter.from = dateFrom;
|
|
330
|
+
if (dateTo !== undefined) result.dateFilter.to = dateTo;
|
|
331
|
+
}
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
|
|
65
335
|
/**
|
|
66
336
|
* Parse include_metadata query param.
|
|
67
337
|
* - absent/null → undefined (all metadata, default)
|
|
@@ -132,6 +402,7 @@ export async function handleNotes(
|
|
|
132
402
|
store: Store,
|
|
133
403
|
subpath: string,
|
|
134
404
|
vault?: string,
|
|
405
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
135
406
|
): Promise<Response> {
|
|
136
407
|
const url = new URL(req.url);
|
|
137
408
|
const method = req.method;
|
|
@@ -148,6 +419,12 @@ export async function handleNotes(
|
|
|
148
419
|
if (id) {
|
|
149
420
|
const note = await resolveNote(store, id);
|
|
150
421
|
if (!note) return json({ error: "Note not found", id }, 404);
|
|
422
|
+
// Tag-scope: a token can't see what its allowlist excludes. Surface
|
|
423
|
+
// as 404 (not 403) — the existence of the note is itself information
|
|
424
|
+
// we shouldn't leak across the scope boundary.
|
|
425
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
426
|
+
return json({ error: "Note not found", id }, 404);
|
|
427
|
+
}
|
|
151
428
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
152
429
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
153
430
|
const expand = parseExpandParams(url, db);
|
|
@@ -169,7 +446,11 @@ export async function handleNotes(
|
|
|
169
446
|
if (search) {
|
|
170
447
|
const searchTags = parseQueryList(url, "tag");
|
|
171
448
|
const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
|
|
172
|
-
const
|
|
449
|
+
const rawResults = await store.searchNotes(search, { tags: searchTags, limit });
|
|
450
|
+
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
451
|
+
// happens after the store query so an empty post-filter list still
|
|
452
|
+
// returns 200 [] (consistent with "no matches"), not 403.
|
|
453
|
+
const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
|
|
173
454
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
174
455
|
const inclMeta = parseIncludeMetadata(url);
|
|
175
456
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
|
|
@@ -189,7 +470,33 @@ export async function handleNotes(
|
|
|
189
470
|
}
|
|
190
471
|
|
|
191
472
|
// Structured query
|
|
473
|
+
//
|
|
474
|
+
// Two filter syntaxes coexist on this endpoint:
|
|
475
|
+
//
|
|
476
|
+
// - **Bracket-style** (canonical, vault#285 friction point 1.3):
|
|
477
|
+
// `?meta[field][op]=value` / `?meta[created_at][gte]=…`. Exposes
|
|
478
|
+
// the full engine `metadata` filter (eq/ne/gt/gte/lt/lte/in/
|
|
479
|
+
// not_in/exists) and the dateFilter bridge through one consistent
|
|
480
|
+
// shape. See `parseMetaBrackets` for the grammar.
|
|
481
|
+
//
|
|
482
|
+
// - **Flat date params** (DEPRECATED): `?date_field=created_at&
|
|
483
|
+
// date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
|
|
484
|
+
// Still functional through 0.5.x; planned removal in 0.6.0
|
|
485
|
+
// (vault#288). New consumers should use bracket-style.
|
|
486
|
+
//
|
|
487
|
+
// Precedence on overlap: bracket-style wins. If a caller passes both
|
|
488
|
+
// `meta[created_at][gte]=X` and `date_field=created_at&date_from=Y`,
|
|
489
|
+
// the bracket form is the dateFilter the engine sees; the flat
|
|
490
|
+
// params are silently dropped. We don't error — the bracket form is
|
|
491
|
+
// documented as canonical, and rejecting the overlap would block a
|
|
492
|
+
// realistic migration path where a caller half-converted their code.
|
|
493
|
+
//
|
|
494
|
+
// Surface asymmetry: REST flattens to a query string; MCP takes a
|
|
495
|
+
// nested `date_filter: { field, from, to }` object directly. Both
|
|
496
|
+
// lower to the same store-level `dateFilter` shape.
|
|
192
497
|
const tags = parseQueryList(url, "tag");
|
|
498
|
+
const bracket = parseMetaBrackets(url);
|
|
499
|
+
if (bracket.error) return bracket.error;
|
|
193
500
|
let results: Note[];
|
|
194
501
|
try {
|
|
195
502
|
results = await store.queryNotes({
|
|
@@ -200,9 +507,28 @@ export async function handleNotes(
|
|
|
200
507
|
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
201
508
|
path: parseQuery(url, "path") ?? undefined,
|
|
202
509
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
203
|
-
metadata:
|
|
204
|
-
|
|
205
|
-
|
|
510
|
+
metadata: bracket.metadata,
|
|
511
|
+
// Date-range precedence chain (highest to lowest):
|
|
512
|
+
// 1. Bracket-style `meta[created_at][gte]=…` (canonical).
|
|
513
|
+
// 2. Flat `date_field=…&date_from=…&date_to=…` (deprecated).
|
|
514
|
+
// 3. Legacy `date_from=…&date_to=…` (no date_field, deprecated)
|
|
515
|
+
// — filters on `n.created_at` by definition.
|
|
516
|
+
// The engine rejects combinations of `dateFilter` with the legacy
|
|
517
|
+
// `dateFrom`/`dateTo`, so we never set both shapes simultaneously.
|
|
518
|
+
...(bracket.dateFilter
|
|
519
|
+
? { dateFilter: bracket.dateFilter }
|
|
520
|
+
: parseQuery(url, "date_field")
|
|
521
|
+
? {
|
|
522
|
+
dateFilter: {
|
|
523
|
+
field: parseQuery(url, "date_field")!,
|
|
524
|
+
from: parseQuery(url, "date_from") ?? undefined,
|
|
525
|
+
to: parseQuery(url, "date_to") ?? undefined,
|
|
526
|
+
},
|
|
527
|
+
}
|
|
528
|
+
: {
|
|
529
|
+
dateFrom: parseQuery(url, "date_from") ?? undefined,
|
|
530
|
+
dateTo: parseQuery(url, "date_to") ?? undefined,
|
|
531
|
+
}),
|
|
206
532
|
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
207
533
|
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
208
534
|
limit: parseInt10(parseQuery(url, "limit")) ?? 50,
|
|
@@ -223,6 +549,10 @@ export async function handleNotes(
|
|
|
223
549
|
if (nearNoteId) {
|
|
224
550
|
const anchor = await resolveNote(store, nearNoteId);
|
|
225
551
|
if (!anchor) return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
|
|
552
|
+
// Tag-scope: anchor must itself be visible to this token.
|
|
553
|
+
if (!noteWithinTagScope(anchor, tagScope.allowed, tagScope.raw)) {
|
|
554
|
+
return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
|
|
555
|
+
}
|
|
226
556
|
const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
|
|
227
557
|
const relationship = parseQuery(url, "near[relationship]") ?? undefined;
|
|
228
558
|
const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship });
|
|
@@ -230,6 +560,11 @@ export async function handleNotes(
|
|
|
230
560
|
results = results.filter((n) => nearScope.has(n.id));
|
|
231
561
|
}
|
|
232
562
|
|
|
563
|
+
// Tag-scope: drop any result outside the allowlist before shaping
|
|
564
|
+
// output. Same semantics as the search path — empty result is 200 [],
|
|
565
|
+
// not 403.
|
|
566
|
+
results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
|
|
567
|
+
|
|
233
568
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
234
569
|
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
235
570
|
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
@@ -285,25 +620,110 @@ export async function handleNotes(
|
|
|
285
620
|
const body = await req.json() as any;
|
|
286
621
|
const items: any[] = body.notes ?? [body];
|
|
287
622
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
623
|
+
// Batch cap (#213): refuse oversized batches before doing any work. 500
|
|
624
|
+
// is the cap (Benjamin's number) — tighter blast radius than 1000 for
|
|
625
|
+
// the runaway-client case that flooded a deployment with 7,453 notes.
|
|
626
|
+
if (items.length > MAX_BATCH_SIZE) {
|
|
627
|
+
return json(
|
|
628
|
+
{
|
|
629
|
+
error_type: "batch_too_large",
|
|
630
|
+
error: "BatchTooLarge",
|
|
631
|
+
message: `max ${MAX_BATCH_SIZE} notes per request, got ${items.length}`,
|
|
632
|
+
limit: MAX_BATCH_SIZE,
|
|
633
|
+
},
|
|
634
|
+
413,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
297
637
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
638
|
+
// Empty-note pre-validation (#213): walk the batch first and reject the
|
|
639
|
+
// whole request if any item would be content+path empty. This makes
|
|
640
|
+
// mixed batches atomic for the empty-note case — no caller gets a
|
|
641
|
+
// half-applied batch where the prefix landed and the empty entry
|
|
642
|
+
// surfaced the 400. Mirrors the Store-level invariant exactly.
|
|
643
|
+
for (let i = 0; i < items.length; i++) {
|
|
644
|
+
const item = items[i];
|
|
645
|
+
const content = (item?.content ?? "").toString();
|
|
646
|
+
const rawPath = item?.path;
|
|
647
|
+
const pathEmpty = rawPath === undefined || rawPath === null
|
|
648
|
+
|| (typeof rawPath === "string" && rawPath.trim() === "");
|
|
649
|
+
if (!content.trim() && pathEmpty) {
|
|
650
|
+
return json(
|
|
651
|
+
{
|
|
652
|
+
error_type: "empty_note",
|
|
653
|
+
error: "EmptyNoteError",
|
|
654
|
+
message: `empty_note: a note must have either content or a path (item index ${i})`,
|
|
655
|
+
item_index: i,
|
|
656
|
+
},
|
|
657
|
+
400,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Tag-scope pre-validation: every new note in the batch must carry at
|
|
663
|
+
// least one tag inside the token's allowlist. Same atomic-batch
|
|
664
|
+
// discipline as the empty-note check — reject the whole request before
|
|
665
|
+
// any DB write so a tag-scoped token can't accidentally land a partial
|
|
666
|
+
// batch with an in-scope prefix.
|
|
667
|
+
if (tagScope.allowed) {
|
|
668
|
+
for (let i = 0; i < items.length; i++) {
|
|
669
|
+
if (!tagsWithinScope(items[i]?.tags, tagScope.allowed, tagScope.raw)) {
|
|
670
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
303
671
|
}
|
|
304
672
|
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const created: Note[] = [];
|
|
676
|
+
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
677
|
+
// failure (path conflict, etc.) rolls back every prior insert. Without
|
|
678
|
+
// this, callers got half-applied batches where the prefix landed and
|
|
679
|
+
// the offending entry surfaced the 409 — see #236. Single-item posts
|
|
680
|
+
// are already atomic at the store layer and skip the wrap so they
|
|
681
|
+
// don't collide with concurrent single-item callers on the shared
|
|
682
|
+
// bun:sqlite connection.
|
|
683
|
+
const batched = items.length > 1;
|
|
684
|
+
if (batched) db.exec("BEGIN");
|
|
685
|
+
try {
|
|
686
|
+
for (const item of items) {
|
|
687
|
+
const note = await store.createNote(item.content ?? "", {
|
|
688
|
+
id: item.id,
|
|
689
|
+
path: item.path,
|
|
690
|
+
tags: item.tags,
|
|
691
|
+
metadata: item.metadata,
|
|
692
|
+
created_at: item.createdAt ?? item.created_at,
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Create explicit links
|
|
696
|
+
if (item.links) {
|
|
697
|
+
for (const link of item.links as { target: string; relationship: string }[]) {
|
|
698
|
+
const target = await resolveNote(store, link.target);
|
|
699
|
+
if (target) await store.createLink(note.id, target.id, link.relationship);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
305
702
|
|
|
306
|
-
|
|
703
|
+
created.push((await store.getNote(note.id)) ?? note);
|
|
704
|
+
}
|
|
705
|
+
if (batched) db.exec("COMMIT");
|
|
706
|
+
} catch (e: any) {
|
|
707
|
+
if (batched) db.exec("ROLLBACK");
|
|
708
|
+
// Duck-type for module-boundary robustness (matches the PATCH branch).
|
|
709
|
+
if (e && e.code === "PATH_CONFLICT") {
|
|
710
|
+
return json(
|
|
711
|
+
{ error_type: "path_conflict", error: "path_conflict", path: e.path, message: e.message },
|
|
712
|
+
409,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (e && e.code === "EMPTY_NOTE") {
|
|
716
|
+
return json(
|
|
717
|
+
{
|
|
718
|
+
error_type: "empty_note",
|
|
719
|
+
error: "EmptyNoteError",
|
|
720
|
+
message: e.message,
|
|
721
|
+
item_index: e.item_index ?? null,
|
|
722
|
+
},
|
|
723
|
+
400,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
throw e;
|
|
307
727
|
}
|
|
308
728
|
|
|
309
729
|
// Apply tag schema defaults
|
|
@@ -323,7 +743,7 @@ export async function handleNotes(
|
|
|
323
743
|
const idMatch = subpath.match(/^\/([^/]+)(\/.*)?$/);
|
|
324
744
|
if (!idMatch) return json({ error: "Not found" }, 404);
|
|
325
745
|
|
|
326
|
-
const idOrPath = decodeURIComponent(idMatch[1]);
|
|
746
|
+
const idOrPath = decodeURIComponent(idMatch[1]!);
|
|
327
747
|
const sub = idMatch[2] ?? "";
|
|
328
748
|
|
|
329
749
|
// Attachments sub-routes (keep as-is — Daily needs them)
|
|
@@ -331,6 +751,9 @@ export async function handleNotes(
|
|
|
331
751
|
if (method === "POST") {
|
|
332
752
|
const note = await resolveNote(store, idOrPath);
|
|
333
753
|
if (!note) return json({ error: "Not found" }, 404);
|
|
754
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
755
|
+
return json({ error: "Not found" }, 404);
|
|
756
|
+
}
|
|
334
757
|
const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
|
|
335
758
|
if (!body.path || !body.mimeType) return json({ error: "path and mimeType are required" }, 400);
|
|
336
759
|
|
|
@@ -361,6 +784,9 @@ export async function handleNotes(
|
|
|
361
784
|
if (method === "GET") {
|
|
362
785
|
const note = await resolveNote(store, idOrPath);
|
|
363
786
|
if (!note) return json({ error: "Not found" }, 404);
|
|
787
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
788
|
+
return json({ error: "Not found" }, 404);
|
|
789
|
+
}
|
|
364
790
|
return json(await store.getAttachments(note.id));
|
|
365
791
|
}
|
|
366
792
|
return json({ error: "Method not allowed" }, 405);
|
|
@@ -372,6 +798,9 @@ export async function handleNotes(
|
|
|
372
798
|
if (method === "DELETE") {
|
|
373
799
|
const note = await resolveNote(store, idOrPath);
|
|
374
800
|
if (!note) return json({ error: "Not found" }, 404);
|
|
801
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
802
|
+
return json({ error: "Not found" }, 404);
|
|
803
|
+
}
|
|
375
804
|
const result = await store.deleteAttachment(note.id, attId);
|
|
376
805
|
if (!result.deleted) return json({ error: "Not found" }, 404);
|
|
377
806
|
// Unlink the storage file only if no other attachment still references
|
|
@@ -395,6 +824,9 @@ export async function handleNotes(
|
|
|
395
824
|
if (method === "GET") {
|
|
396
825
|
const note = await resolveNote(store, idOrPath);
|
|
397
826
|
if (!note) return json({ error: "Not found" }, 404);
|
|
827
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
828
|
+
return json({ error: "Not found" }, 404);
|
|
829
|
+
}
|
|
398
830
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
399
831
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
400
832
|
const expand = parseExpandParams(url, db);
|
|
@@ -417,13 +849,60 @@ export async function handleNotes(
|
|
|
417
849
|
try {
|
|
418
850
|
const note = await resolveNote(store, idOrPath);
|
|
419
851
|
if (!note) throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
852
|
+
// Tag-scope: existing note must be in scope. Mirror the read-side
|
|
853
|
+
// 404-not-403 stance — a token can't see (and therefore can't
|
|
854
|
+
// discover-then-modify) notes outside its allowlist.
|
|
855
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
856
|
+
throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
857
|
+
}
|
|
420
858
|
const body = await req.json() as any;
|
|
859
|
+
// Tag-scope: post-update tag set must still satisfy scope. Compute
|
|
860
|
+
// the prospective tag set (existing − removed + added) and reject
|
|
861
|
+
// before any write if it would drift outside the allowlist. This
|
|
862
|
+
// covers the bot-untags-its-only-allowlisted-tag escape route.
|
|
863
|
+
if (tagScope.allowed) {
|
|
864
|
+
const removed = new Set<string>((body.tags?.remove as string[] | undefined) ?? []);
|
|
865
|
+
const projected = new Set<string>((note.tags ?? []).filter((t) => !removed.has(t)));
|
|
866
|
+
for (const t of (body.tags?.add as string[] | undefined) ?? []) projected.add(t);
|
|
867
|
+
if (!tagsWithinScope([...projected], tagScope.allowed, tagScope.raw)) {
|
|
868
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// --- Validate mutual exclusion of content modes ---
|
|
873
|
+
const hasContent = body.content !== undefined;
|
|
874
|
+
const hasAppendPrepend = body.append !== undefined || body.prepend !== undefined;
|
|
875
|
+
const hasContentEdit = body.content_edit !== undefined;
|
|
876
|
+
const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
|
|
877
|
+
if (contentModes > 1) {
|
|
878
|
+
return json(
|
|
879
|
+
{
|
|
880
|
+
error: "mutually_exclusive",
|
|
881
|
+
message: "`content`, `append`/`prepend`, and `content_edit` are mutually exclusive — pick one mode of content update.",
|
|
882
|
+
},
|
|
883
|
+
400,
|
|
884
|
+
);
|
|
885
|
+
}
|
|
421
886
|
|
|
422
887
|
// --- Safety-by-default: refuse mutations without a precondition ---
|
|
423
888
|
// Mirror the MCP tool: require `if_updated_at` unless the caller
|
|
424
889
|
// explicitly sets `force: true`. 428 Precondition Required is the
|
|
425
890
|
// RFC 6585 status for exactly this case.
|
|
426
|
-
|
|
891
|
+
//
|
|
892
|
+
// Append/prepend-only updates are exempt — SQL-atomic concatenation
|
|
893
|
+
// is no-conflict-by-design. Tag/link mutations are *not* exempt
|
|
894
|
+
// (#201): they're idempotent set-ops, but still represent a
|
|
895
|
+
// non-content change the caller should observe before re-asserting.
|
|
896
|
+
const isAppendOnly = hasAppendPrepend
|
|
897
|
+
&& !hasContent
|
|
898
|
+
&& !hasContentEdit
|
|
899
|
+
&& body.path === undefined
|
|
900
|
+
&& body.metadata === undefined
|
|
901
|
+
&& body.created_at === undefined
|
|
902
|
+
&& body.createdAt === undefined
|
|
903
|
+
&& body.tags === undefined
|
|
904
|
+
&& body.links === undefined;
|
|
905
|
+
if (!isAppendOnly && body.if_updated_at === undefined && body.force !== true) {
|
|
427
906
|
return json(
|
|
428
907
|
{
|
|
429
908
|
error_type: "precondition_required",
|
|
@@ -437,10 +916,40 @@ export async function handleNotes(
|
|
|
437
916
|
);
|
|
438
917
|
}
|
|
439
918
|
|
|
919
|
+
// --- Resolve content_edit into a full content string ---
|
|
920
|
+
let contentOverride = body.content as string | undefined;
|
|
921
|
+
if (hasContentEdit) {
|
|
922
|
+
const ce = body.content_edit as { old_text?: unknown; new_text?: unknown };
|
|
923
|
+
if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
|
|
924
|
+
return json(
|
|
925
|
+
{ error: "bad_request", message: "`content_edit` requires { old_text: string, new_text: string }." },
|
|
926
|
+
400,
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
const idx = note.content.indexOf(ce.old_text);
|
|
930
|
+
if (idx < 0) {
|
|
931
|
+
// 422 Unprocessable Entity, not 404: the note exists, the request is
|
|
932
|
+
// syntactically valid, but the search string can't be applied to the
|
|
933
|
+
// current content. Returning 404 implied "note doesn't exist" and
|
|
934
|
+
// confused operators chasing a missing record (#202).
|
|
935
|
+
return json(
|
|
936
|
+
{ error: "unprocessable_content", message: `content_edit: \`old_text\` not found in note "${note.id}". Re-read and retry.` },
|
|
937
|
+
422,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
const second = note.content.indexOf(ce.old_text, idx + 1);
|
|
941
|
+
if (second >= 0) {
|
|
942
|
+
return json(
|
|
943
|
+
{ error: "ambiguous", message: `content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context.` },
|
|
944
|
+
409,
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
|
|
948
|
+
}
|
|
949
|
+
|
|
440
950
|
// --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
|
|
441
951
|
// The actual link deletions happen only after the core UPDATE succeeds,
|
|
442
952
|
// so a conflict leaves the note untouched.
|
|
443
|
-
let contentOverride = body.content as string | undefined;
|
|
444
953
|
const linksRemove = body.links?.remove as { target: string; relationship: string }[] | undefined;
|
|
445
954
|
const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
|
|
446
955
|
if (linksRemove) {
|
|
@@ -449,7 +958,12 @@ export async function handleNotes(
|
|
|
449
958
|
if (!target) continue;
|
|
450
959
|
resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
|
|
451
960
|
if (link.relationship === "wikilink" && target.path) {
|
|
452
|
-
|
|
961
|
+
// Materialize the prospective content for append/prepend callers
|
|
962
|
+
// so we don't fight the SQL-atomic path with a JS-level rewrite.
|
|
963
|
+
const current = contentOverride
|
|
964
|
+
?? (hasAppendPrepend
|
|
965
|
+
? (body.prepend as string ?? "") + note.content + (body.append as string ?? "")
|
|
966
|
+
: note.content);
|
|
453
967
|
const cleaned = removeWikilinkBrackets(current, target.path);
|
|
454
968
|
if (cleaned !== current) contentOverride = cleaned;
|
|
455
969
|
}
|
|
@@ -458,7 +972,12 @@ export async function handleNotes(
|
|
|
458
972
|
|
|
459
973
|
// --- Core update (runs the if_updated_at check atomically) ---
|
|
460
974
|
const updates: any = {};
|
|
461
|
-
if (contentOverride !== undefined)
|
|
975
|
+
if (contentOverride !== undefined) {
|
|
976
|
+
updates.content = contentOverride;
|
|
977
|
+
} else if (hasAppendPrepend) {
|
|
978
|
+
if (body.append !== undefined) updates.append = body.append;
|
|
979
|
+
if (body.prepend !== undefined) updates.prepend = body.prepend;
|
|
980
|
+
}
|
|
462
981
|
if (body.path !== undefined) updates.path = body.path;
|
|
463
982
|
if (body.metadata !== undefined) {
|
|
464
983
|
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
@@ -497,7 +1016,14 @@ export async function handleNotes(
|
|
|
497
1016
|
}
|
|
498
1017
|
}
|
|
499
1018
|
|
|
500
|
-
|
|
1019
|
+
// Response shape: full Note (back-compat default) or lean NoteIndex
|
|
1020
|
+
// (vault#285 friction point 2.response — opt-out for callers making
|
|
1021
|
+
// frequent small edits to large notes). Mirror the MCP `update-note`
|
|
1022
|
+
// `include_content` knob exactly.
|
|
1023
|
+
const updatedNote = await store.getNote(note.id);
|
|
1024
|
+
if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
|
|
1025
|
+
const includeContentResp = body.include_content !== false;
|
|
1026
|
+
return json(includeContentResp ? updatedNote : toNoteIndex(updatedNote));
|
|
501
1027
|
} catch (e: any) {
|
|
502
1028
|
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
503
1029
|
// Duck-type on `code` rather than `instanceof ConflictError`: this
|
|
@@ -521,6 +1047,27 @@ export async function handleNotes(
|
|
|
521
1047
|
409,
|
|
522
1048
|
);
|
|
523
1049
|
}
|
|
1050
|
+
// Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
|
|
1051
|
+
if (e && e.code === "PATH_CONFLICT") {
|
|
1052
|
+
return json(
|
|
1053
|
+
{ error_type: "path_conflict", error: "path_conflict", path: e.path, message: e.message },
|
|
1054
|
+
409,
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
// Empty-note guard from the Store boundary (#213) — the proposed update
|
|
1058
|
+
// would clear both content AND path. Surface as 400 so callers can fix
|
|
1059
|
+
// the request without retrying.
|
|
1060
|
+
if (e && e.code === "EMPTY_NOTE") {
|
|
1061
|
+
return json(
|
|
1062
|
+
{
|
|
1063
|
+
error_type: "empty_note",
|
|
1064
|
+
error: "EmptyNoteError",
|
|
1065
|
+
message: e.message,
|
|
1066
|
+
note_id: e.note_id ?? null,
|
|
1067
|
+
},
|
|
1068
|
+
400,
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
524
1071
|
throw e;
|
|
525
1072
|
}
|
|
526
1073
|
}
|
|
@@ -529,6 +1076,11 @@ export async function handleNotes(
|
|
|
529
1076
|
if (method === "DELETE") {
|
|
530
1077
|
const note = await resolveNote(store, idOrPath);
|
|
531
1078
|
if (!note) return json({ error: "Not found" }, 404);
|
|
1079
|
+
// Tag-scope: can't delete what you can't read. 404 (not 403) for the
|
|
1080
|
+
// same no-leak reason as the read paths.
|
|
1081
|
+
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1082
|
+
return json({ error: "Not found" }, 404);
|
|
1083
|
+
}
|
|
532
1084
|
await store.deleteNote(note.id);
|
|
533
1085
|
return json({ deleted: true, id: note.id });
|
|
534
1086
|
}
|
|
@@ -541,7 +1093,12 @@ export async function handleNotes(
|
|
|
541
1093
|
// POST /api/tags/:name/rename
|
|
542
1094
|
// ---------------------------------------------------------------------------
|
|
543
1095
|
|
|
544
|
-
export async function handleTags(
|
|
1096
|
+
export async function handleTags(
|
|
1097
|
+
req: Request,
|
|
1098
|
+
store: Store,
|
|
1099
|
+
subpath = "",
|
|
1100
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
1101
|
+
): Promise<Response> {
|
|
545
1102
|
const url = new URL(req.url);
|
|
546
1103
|
|
|
547
1104
|
// GET /tags — list all, or get single tag detail
|
|
@@ -549,27 +1106,49 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
549
1106
|
const singleTag = parseQuery(url, "tag");
|
|
550
1107
|
|
|
551
1108
|
if (singleTag) {
|
|
1109
|
+
// Tag-scope: a tag-scoped token can only see tags reachable from its
|
|
1110
|
+
// allowlist (root + descendants per the parent_names hierarchy).
|
|
1111
|
+
// Anything else 404s — same "no leak" stance as note reads.
|
|
1112
|
+
if (tagScope.allowed && !tagScope.allowed.has(singleTag)) {
|
|
1113
|
+
return json({ error: "Tag not found", tag: singleTag }, 404);
|
|
1114
|
+
}
|
|
552
1115
|
const allTags = await store.listTags();
|
|
553
1116
|
const found = allTags.find((t) => t.name === singleTag);
|
|
554
|
-
const
|
|
1117
|
+
const record = await store.getTagRecord(singleTag);
|
|
555
1118
|
return json({
|
|
556
1119
|
name: singleTag,
|
|
557
1120
|
count: found?.count ?? 0,
|
|
558
|
-
description:
|
|
559
|
-
fields:
|
|
1121
|
+
description: record?.description ?? null,
|
|
1122
|
+
fields: record?.fields ?? null,
|
|
1123
|
+
relationships: record?.relationships ?? null,
|
|
1124
|
+
parent_names: record?.parent_names ?? null,
|
|
1125
|
+
created_at: record?.created_at ?? null,
|
|
1126
|
+
updated_at: record?.updated_at ?? null,
|
|
560
1127
|
});
|
|
561
1128
|
}
|
|
562
1129
|
|
|
563
1130
|
const tags = await store.listTags();
|
|
1131
|
+
const filtered = tagScope.allowed
|
|
1132
|
+
? tags.filter((t) => tagScope.allowed!.has(t.name))
|
|
1133
|
+
: tags;
|
|
564
1134
|
if (parseBool(parseQuery(url, "include_schema"), false)) {
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
1135
|
+
const records = new Map(
|
|
1136
|
+
(await store.listTagRecords()).map((r) => [r.tag, r] as const),
|
|
1137
|
+
);
|
|
1138
|
+
return json(filtered.map((t) => {
|
|
1139
|
+
const r = records.get(t.name);
|
|
1140
|
+
return {
|
|
1141
|
+
...t,
|
|
1142
|
+
description: r?.description ?? null,
|
|
1143
|
+
fields: r?.fields ?? null,
|
|
1144
|
+
relationships: r?.relationships ?? null,
|
|
1145
|
+
parent_names: r?.parent_names ?? null,
|
|
1146
|
+
created_at: r?.created_at ?? null,
|
|
1147
|
+
updated_at: r?.updated_at ?? null,
|
|
1148
|
+
};
|
|
1149
|
+
}));
|
|
571
1150
|
}
|
|
572
|
-
return json(
|
|
1151
|
+
return json(filtered);
|
|
573
1152
|
}
|
|
574
1153
|
|
|
575
1154
|
// POST /tags/merge — atomic multi-source merge into a target tag.
|
|
@@ -588,6 +1167,37 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
588
1167
|
if (typeof target !== "string" || target.length === 0) {
|
|
589
1168
|
return json({ error: "target must be a non-empty string" }, 400);
|
|
590
1169
|
}
|
|
1170
|
+
// Tag-scope: every source AND the target must be inside the allowlist.
|
|
1171
|
+
// A merge that pulls notes out of a token's scope (or pushes notes into
|
|
1172
|
+
// it) is a privilege escalation; refuse the whole op.
|
|
1173
|
+
if (tagScope.allowed) {
|
|
1174
|
+
for (const t of [...sources, target]) {
|
|
1175
|
+
if (!tagScope.allowed.has(t)) {
|
|
1176
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// Same dependency check as DELETE /tags/:name — merging consumes every
|
|
1181
|
+
// source tag, so a source referenced by a tag-scoped token would orphan
|
|
1182
|
+
// that token's allowlist. Aggregate matches across sources for a single
|
|
1183
|
+
// 409 envelope.
|
|
1184
|
+
const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
|
|
1185
|
+
const db = (store as any).db;
|
|
1186
|
+
for (const src of sources) {
|
|
1187
|
+
const tokens = findTokensReferencingTag(db, src as string);
|
|
1188
|
+
if (tokens.length > 0) referenced.push({ source: src as string, tokens });
|
|
1189
|
+
}
|
|
1190
|
+
if (referenced.length > 0) {
|
|
1191
|
+
return json(
|
|
1192
|
+
{
|
|
1193
|
+
error: "TagInUseByTokens",
|
|
1194
|
+
error_type: "tag_in_use_by_tokens",
|
|
1195
|
+
message: `Cannot merge: ${referenced.length} source tag(s) referenced by tag-scoped token(s); revoke or re-mint them first.`,
|
|
1196
|
+
referenced_by: referenced,
|
|
1197
|
+
},
|
|
1198
|
+
409,
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
591
1201
|
const result = await store.mergeTags(sources, target);
|
|
592
1202
|
return json(result);
|
|
593
1203
|
}
|
|
@@ -596,13 +1206,20 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
596
1206
|
const renameMatch = subpath.match(/^\/([^/]+)\/rename$/);
|
|
597
1207
|
if (renameMatch) {
|
|
598
1208
|
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
599
|
-
const oldName = decodeURIComponent(renameMatch[1]);
|
|
1209
|
+
const oldName = decodeURIComponent(renameMatch[1]!);
|
|
600
1210
|
const body = (await req.json().catch(() => null)) as { new_name?: unknown } | null;
|
|
601
1211
|
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
602
1212
|
const newName = body.new_name;
|
|
603
1213
|
if (typeof newName !== "string" || newName.length === 0) {
|
|
604
1214
|
return json({ error: "new_name must be a non-empty string" }, 400);
|
|
605
1215
|
}
|
|
1216
|
+
if (tagScope.allowed && (!tagScope.allowed.has(oldName) || !tagScope.allowed.has(newName))) {
|
|
1217
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
1218
|
+
}
|
|
1219
|
+
// Vault#240: rename now cascades to tokens.scoped_tags (and every
|
|
1220
|
+
// other surface). The old fail-closed token-reference check has been
|
|
1221
|
+
// removed; the cascade rewrites the JSON allowlist atomically. See
|
|
1222
|
+
// notes.ts:renameTag for the surfaces touched.
|
|
606
1223
|
const result = await store.renameTag(oldName, newName);
|
|
607
1224
|
if ("error" in result) {
|
|
608
1225
|
if (result.error === "not_found") return json({ error: "not_found", tag: oldName }, 404);
|
|
@@ -611,7 +1228,8 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
611
1228
|
{
|
|
612
1229
|
error: "target_exists",
|
|
613
1230
|
target: newName,
|
|
614
|
-
|
|
1231
|
+
conflicting: result.conflicting,
|
|
1232
|
+
message: "Target tag (or one of its sub-tags) already exists; use POST /api/tags/merge to combine them.",
|
|
615
1233
|
},
|
|
616
1234
|
409,
|
|
617
1235
|
);
|
|
@@ -623,36 +1241,122 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
623
1241
|
// Routes with tag name
|
|
624
1242
|
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
625
1243
|
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
626
|
-
const tagName = decodeURIComponent(nameMatch[1]);
|
|
1244
|
+
const tagName = decodeURIComponent(nameMatch[1]!);
|
|
627
1245
|
|
|
628
|
-
// GET /tags/:name — single tag detail
|
|
1246
|
+
// GET /tags/:name — single tag detail (full record)
|
|
629
1247
|
if (req.method === "GET") {
|
|
1248
|
+
if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
|
|
1249
|
+
return json({ error: "Tag not found", tag: tagName }, 404);
|
|
1250
|
+
}
|
|
630
1251
|
const allTags = await store.listTags();
|
|
631
1252
|
const found = allTags.find((t) => t.name === tagName);
|
|
632
|
-
const
|
|
1253
|
+
const record = await store.getTagRecord(tagName);
|
|
633
1254
|
return json({
|
|
634
1255
|
name: tagName,
|
|
635
1256
|
count: found?.count ?? 0,
|
|
636
|
-
description:
|
|
637
|
-
fields:
|
|
1257
|
+
description: record?.description ?? null,
|
|
1258
|
+
fields: record?.fields ?? null,
|
|
1259
|
+
relationships: record?.relationships ?? null,
|
|
1260
|
+
parent_names: record?.parent_names ?? null,
|
|
1261
|
+
created_at: record?.created_at ?? null,
|
|
1262
|
+
updated_at: record?.updated_at ?? null,
|
|
638
1263
|
});
|
|
639
1264
|
}
|
|
640
1265
|
|
|
641
|
-
// PUT /tags/:name — upsert tag
|
|
1266
|
+
// PUT /tags/:name — upsert tag identity row. Body accepts any combination
|
|
1267
|
+
// of { description, fields, relationships, parent_names }; omitted keys
|
|
1268
|
+
// are preserved, explicit null clears. See patterns/tag-data-model.md.
|
|
642
1269
|
if (req.method === "PUT") {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
description
|
|
648
|
-
fields
|
|
1270
|
+
if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
|
|
1271
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
1272
|
+
}
|
|
1273
|
+
const body = (await req.json()) as {
|
|
1274
|
+
description?: string | null;
|
|
1275
|
+
fields?: Record<string, unknown> | null;
|
|
1276
|
+
relationships?: Record<string, unknown> | null;
|
|
1277
|
+
parent_names?: unknown;
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
// Validate relationships shape + cardinality vocabulary up front so
|
|
1281
|
+
// a bad payload returns 400, not a thrown 500.
|
|
1282
|
+
let relationshipsPatch:
|
|
1283
|
+
| Record<string, tagSchemaOps.TagRelationship>
|
|
1284
|
+
| null
|
|
1285
|
+
| undefined;
|
|
1286
|
+
if (body.relationships === null) {
|
|
1287
|
+
relationshipsPatch = null;
|
|
1288
|
+
} else if (body.relationships !== undefined) {
|
|
1289
|
+
try {
|
|
1290
|
+
relationshipsPatch = tagSchemaOps.validateRelationships(body.relationships);
|
|
1291
|
+
} catch (err) {
|
|
1292
|
+
return json(
|
|
1293
|
+
{ error: (err as Error).message, error_type: "invalid_relationships" },
|
|
1294
|
+
400,
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
let parentNamesPatch: string[] | null | undefined;
|
|
1300
|
+
if (body.parent_names === null) {
|
|
1301
|
+
parentNamesPatch = null;
|
|
1302
|
+
} else if (body.parent_names !== undefined) {
|
|
1303
|
+
if (!Array.isArray(body.parent_names)) {
|
|
1304
|
+
return json({ error: "parent_names must be an array of tag names" }, 400);
|
|
1305
|
+
}
|
|
1306
|
+
const cleaned = (body.parent_names as unknown[]).filter(
|
|
1307
|
+
(p): p is string => typeof p === "string" && p.length > 0,
|
|
1308
|
+
);
|
|
1309
|
+
parentNamesPatch = cleaned.length > 0 ? cleaned : null;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Field merge mirrors MCP update-tag — preserves prior keys when the
|
|
1313
|
+
// payload only declares new ones.
|
|
1314
|
+
let fieldsPatch:
|
|
1315
|
+
| Record<string, tagSchemaOps.TagFieldSchema>
|
|
1316
|
+
| null
|
|
1317
|
+
| undefined;
|
|
1318
|
+
if (body.fields === null) {
|
|
1319
|
+
fieldsPatch = null;
|
|
1320
|
+
} else if (body.fields !== undefined) {
|
|
1321
|
+
const existing = await store.getTagSchema(tagName);
|
|
1322
|
+
const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
|
|
1323
|
+
...(existing?.fields ?? {}),
|
|
1324
|
+
...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
|
|
1325
|
+
};
|
|
1326
|
+
fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const result = await store.upsertTagRecord(tagName, {
|
|
1330
|
+
...(body.description !== undefined ? { description: body.description } : {}),
|
|
1331
|
+
...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
|
|
1332
|
+
...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
|
|
1333
|
+
...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
|
|
649
1334
|
});
|
|
650
|
-
return json(
|
|
1335
|
+
return json(result);
|
|
651
1336
|
}
|
|
652
1337
|
|
|
653
|
-
// DELETE /tags/:name — delete tag +
|
|
1338
|
+
// DELETE /tags/:name — delete tag + identity row + remove from all notes
|
|
654
1339
|
if (req.method === "DELETE") {
|
|
655
|
-
|
|
1340
|
+
if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
|
|
1341
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
1342
|
+
}
|
|
1343
|
+
// Tag-scoped tokens reference root tags by name; deleting a referenced
|
|
1344
|
+
// tag would silently orphan the token's allowlist. Fail closed (409)
|
|
1345
|
+
// and name the offending tokens so the operator can revoke or re-mint
|
|
1346
|
+
// before retrying. patterns/tag-scoped-tokens.md §Dependencies.
|
|
1347
|
+
const referenced_by = findTokensReferencingTag((store as any).db, tagName);
|
|
1348
|
+
if (referenced_by.length > 0) {
|
|
1349
|
+
return json(
|
|
1350
|
+
{
|
|
1351
|
+
error: "TagInUseByTokens",
|
|
1352
|
+
error_type: "tag_in_use_by_tokens",
|
|
1353
|
+
message: `Tag "${tagName}" is referenced by ${referenced_by.length} tag-scoped token(s); revoke or re-mint them before deleting.`,
|
|
1354
|
+
tag: tagName,
|
|
1355
|
+
referenced_by,
|
|
1356
|
+
},
|
|
1357
|
+
409,
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
656
1360
|
return json(await store.deleteTag(tagName));
|
|
657
1361
|
}
|
|
658
1362
|
|
|
@@ -663,7 +1367,11 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
663
1367
|
// Find-path — GET /api/find-path?source=...&target=...
|
|
664
1368
|
// ---------------------------------------------------------------------------
|
|
665
1369
|
|
|
666
|
-
export async function handleFindPath(
|
|
1370
|
+
export async function handleFindPath(
|
|
1371
|
+
req: Request,
|
|
1372
|
+
store: Store,
|
|
1373
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
1374
|
+
): Promise<Response> {
|
|
667
1375
|
if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
|
|
668
1376
|
|
|
669
1377
|
const url = new URL(req.url);
|
|
@@ -675,11 +1383,28 @@ export async function handleFindPath(req: Request, store: Store): Promise<Respon
|
|
|
675
1383
|
try {
|
|
676
1384
|
const sourceNote = await resolveNote(store, source);
|
|
677
1385
|
if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
|
|
1386
|
+
if (!noteWithinTagScope(sourceNote, tagScope.allowed, tagScope.raw)) {
|
|
1387
|
+
return json({ error: `Note not found: "${source}"` }, 404);
|
|
1388
|
+
}
|
|
678
1389
|
const targetNote = await resolveNote(store, target);
|
|
679
1390
|
if (!targetNote) return json({ error: `Note not found: "${target}"` }, 404);
|
|
1391
|
+
if (!noteWithinTagScope(targetNote, tagScope.allowed, tagScope.raw)) {
|
|
1392
|
+
return json({ error: `Note not found: "${target}"` }, 404);
|
|
1393
|
+
}
|
|
680
1394
|
const maxDepth = Math.min(parseInt10(parseQuery(url, "max_depth")) ?? 5, 10);
|
|
681
1395
|
|
|
1396
|
+
// Tag-scope on the *path* itself: every intermediate hop must also be
|
|
1397
|
+
// within scope. A reachable target via an out-of-scope hop is not a
|
|
1398
|
+
// permitted answer — surface as "no path" (null).
|
|
682
1399
|
const result = linkOps.findPath(db, sourceNote.id, targetNote.id, { max_depth: maxDepth });
|
|
1400
|
+
if (result && tagScope.allowed) {
|
|
1401
|
+
for (const id of result.path) {
|
|
1402
|
+
const hop = await store.getNote(id);
|
|
1403
|
+
if (!hop || !noteWithinTagScope(hop, tagScope.allowed, tagScope.raw)) {
|
|
1404
|
+
return json(null);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
683
1408
|
return json(result);
|
|
684
1409
|
} catch (e: any) {
|
|
685
1410
|
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
@@ -789,7 +1514,7 @@ function renderMarkdown(md: string): string {
|
|
|
789
1514
|
let inCodeBlock = false;
|
|
790
1515
|
|
|
791
1516
|
for (let i = 0; i < lines.length; i++) {
|
|
792
|
-
const line = lines[i]
|
|
1517
|
+
const line = lines[i]!;
|
|
793
1518
|
|
|
794
1519
|
if (line.trimStart().startsWith("```")) {
|
|
795
1520
|
if (inCodeBlock) {
|
|
@@ -815,15 +1540,15 @@ function renderMarkdown(md: string): string {
|
|
|
815
1540
|
|
|
816
1541
|
const headerMatch = trimmed.match(/^(#{1,6})\s+(.+)/);
|
|
817
1542
|
if (headerMatch) {
|
|
818
|
-
const level = headerMatch[1]
|
|
819
|
-
out.push(`<h${level}>${inlineMarkdown(escapeHtml(headerMatch[2]))}</h${level}>`);
|
|
1543
|
+
const level = headerMatch[1]!.length;
|
|
1544
|
+
out.push(`<h${level}>${inlineMarkdown(escapeHtml(headerMatch[2]!))}</h${level}>`);
|
|
820
1545
|
continue;
|
|
821
1546
|
}
|
|
822
1547
|
|
|
823
1548
|
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
|
824
1549
|
const items: string[] = [trimmed.slice(2)];
|
|
825
1550
|
while (i + 1 < lines.length) {
|
|
826
|
-
const next = lines[i + 1]
|
|
1551
|
+
const next = lines[i + 1]!.trim();
|
|
827
1552
|
if (next.startsWith("- ") || next.startsWith("* ")) {
|
|
828
1553
|
items.push(next.slice(2));
|
|
829
1554
|
i++;
|
|
@@ -949,9 +1674,17 @@ export function assetsDir(vault: string): string {
|
|
|
949
1674
|
}
|
|
950
1675
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
|
951
1676
|
|
|
1677
|
+
// Storage allowlist policy:
|
|
1678
|
+
// - audio + image + .pdf (knowledge-vault content: papers, scans, receipts)
|
|
1679
|
+
// + .mp4 (mobile capture default; iOS records mp4, not webm).
|
|
1680
|
+
// - .svg and .html are deliberately excluded — both can embed `<script>`
|
|
1681
|
+
// tags, which would turn an upload into a same-origin XSS vector when
|
|
1682
|
+
// the asset is served back from /storage/. If a future use case needs
|
|
1683
|
+
// SVG, sanitize on read (strip <script>/<foreignObject>) and revisit.
|
|
952
1684
|
const ALLOWED_EXTENSIONS = new Set([
|
|
953
1685
|
".wav", ".mp3", ".m4a", ".ogg", ".webm",
|
|
954
1686
|
".png", ".jpg", ".jpeg", ".gif", ".webp",
|
|
1687
|
+
".pdf", ".mp4",
|
|
955
1688
|
]);
|
|
956
1689
|
|
|
957
1690
|
const MIME_TYPES: Record<string, string> = {
|
|
@@ -965,6 +1698,8 @@ const MIME_TYPES: Record<string, string> = {
|
|
|
965
1698
|
".jpeg": "image/jpeg",
|
|
966
1699
|
".gif": "image/gif",
|
|
967
1700
|
".webp": "image/webp",
|
|
1701
|
+
".pdf": "application/pdf",
|
|
1702
|
+
".mp4": "video/mp4",
|
|
968
1703
|
};
|
|
969
1704
|
|
|
970
1705
|
export async function handleStorage(req: Request, path: string, vault: string): Promise<Response> {
|
|
@@ -984,7 +1719,7 @@ export async function handleStorage(req: Request, path: string, vault: string):
|
|
|
984
1719
|
return json({ error: `File type ${ext} not allowed` }, 400);
|
|
985
1720
|
}
|
|
986
1721
|
|
|
987
|
-
const date = new Date().toISOString().split("T")[0]
|
|
1722
|
+
const date = new Date().toISOString().split("T")[0]!;
|
|
988
1723
|
const dir = join(assets, date);
|
|
989
1724
|
mkdirSync(dir, { recursive: true });
|
|
990
1725
|
|