@openparachute/vault 0.5.1-rc.2 → 0.5.2-rc.1
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/core.test.ts +183 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +77 -0
- package/core/src/mcp.ts +130 -22
- package/core/src/notes.ts +36 -0
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/schema.ts +7 -4
- package/core/src/store.ts +1 -1
- package/core/src/tag-schemas.ts +59 -44
- package/core/src/types.ts +31 -3
- package/package.json +1 -1
- package/src/auth.test.ts +37 -1
- package/src/auth.ts +29 -0
- package/src/cli.ts +133 -34
- package/src/config.test.ts +16 -0
- package/src/config.ts +39 -0
- package/src/mcp-tools.ts +60 -6
- package/src/routes.ts +486 -53
- package/src/routing.test.ts +185 -0
- package/src/routing.ts +32 -2
- package/src/server.ts +7 -0
- package/src/storage.test.ts +162 -0
- package/src/tag-scope.ts +68 -1
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +194 -2
- package/src/vault.test.ts +1064 -7
|
@@ -794,6 +794,37 @@ describe("importPortableVault", async () => {
|
|
|
794
794
|
expect(typed!.metadata).toEqual({ source: "git://x" });
|
|
795
795
|
});
|
|
796
796
|
|
|
797
|
+
it("preserves an opaque relationship-vocabulary map across export → import → re-export (vault#428)", async () => {
|
|
798
|
+
const vocab = {
|
|
799
|
+
"works-on": { from: "person", to: "project" },
|
|
800
|
+
"member-of": { from: "person", to: "organization" },
|
|
801
|
+
"partner-of": { from: "person", to: "person" },
|
|
802
|
+
"based-at": { from: "project", to: "place", note: "freeform" },
|
|
803
|
+
};
|
|
804
|
+
await store.upsertTagRecord("person", {
|
|
805
|
+
description: "a human",
|
|
806
|
+
relationships: vocab,
|
|
807
|
+
});
|
|
808
|
+
const outDir = join(tmpBase, "out");
|
|
809
|
+
await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
|
|
810
|
+
|
|
811
|
+
const target = new SqliteStore(new Database(":memory:"));
|
|
812
|
+
const stats = await importPortableVault(target, { inDir: outDir });
|
|
813
|
+
expect(stats.schemas_restored).toBe(1);
|
|
814
|
+
|
|
815
|
+
// Imported value matches the original verbatim.
|
|
816
|
+
const restored = await target.getTagRecord("person");
|
|
817
|
+
expect(restored?.relationships).toEqual(vocab);
|
|
818
|
+
|
|
819
|
+
// Re-export from the restored store and confirm the schema file is
|
|
820
|
+
// byte-identical to the first export (deep round-trip stability).
|
|
821
|
+
const outDir2 = join(tmpBase, "out2");
|
|
822
|
+
await exportVaultToDir(target, { outDir: outDir2, exportedAt: "2026-05-13T00:00:00.000Z" });
|
|
823
|
+
const schemaA = readFileSync(join(outDir, SIDECAR_DIR, "schemas", "person.yaml"), "utf-8");
|
|
824
|
+
const schemaB = readFileSync(join(outDir2, SIDECAR_DIR, "schemas", "person.yaml"), "utf-8");
|
|
825
|
+
expect(schemaB).toBe(schemaA);
|
|
826
|
+
});
|
|
827
|
+
|
|
797
828
|
it("skips typed links whose target is missing from the import set", async () => {
|
|
798
829
|
// Source note has a typed link to a target we don't include in
|
|
799
830
|
// the export (synthetic — write the .md file by hand).
|
|
@@ -862,6 +893,15 @@ describe("portable-md round-trip — byte-equivalent re-export after blow-away i
|
|
|
862
893
|
description: "A long-running effort",
|
|
863
894
|
fields: { status: { type: "string", enum: ["active", "done"] } },
|
|
864
895
|
});
|
|
896
|
+
// Opaque relationship-vocabulary map (vault#428) — exercises that the
|
|
897
|
+
// round-trip preserves an arbitrary app-defined relationships shape,
|
|
898
|
+
// not just the historical { target_tag, cardinality } one.
|
|
899
|
+
await store.upsertTagRecord("project", {
|
|
900
|
+
relationships: {
|
|
901
|
+
"works-on": { from: "person", to: "project" },
|
|
902
|
+
"based-at": { from: "project", to: "place", note: "freeform" },
|
|
903
|
+
},
|
|
904
|
+
});
|
|
865
905
|
const n1 = await store.createNote("alpha body", {
|
|
866
906
|
id: "01HX001",
|
|
867
907
|
path: "Inbox/alpha",
|
package/core/src/portable-md.ts
CHANGED
|
@@ -2091,11 +2091,29 @@ export function parseFrontmatter(raw: string): {
|
|
|
2091
2091
|
frontmatter: Record<string, unknown>;
|
|
2092
2092
|
content: string;
|
|
2093
2093
|
} {
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
const
|
|
2094
|
+
// 1. Strip a leading UTF-8 BOM (U+FEFF) if present. Without this an
|
|
2095
|
+
// Obsidian export saved with a BOM (`---\n…`) fails the open
|
|
2096
|
+
// test and the whole file — frontmatter included — falls into the
|
|
2097
|
+
// body, silently losing id/tags/timestamps (contract FX-FENCE-BOM).
|
|
2098
|
+
const src = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
2099
|
+
|
|
2100
|
+
// 2. Line-scan close-fence (CRLF-aware). The opening line must be
|
|
2101
|
+
// EXACTLY `---`; the closing fence is the FIRST subsequent line that
|
|
2102
|
+
// is EXACTLY `---` — not `----`, not `---text`, not ` ---`. An
|
|
2103
|
+
// unclosed block means the whole file is body (never swallow). This
|
|
2104
|
+
// replaces the old `indexOf("\n---")` which wrongly matched `\n----`
|
|
2105
|
+
// and `\n---more` (contract §1.1, FX-FENCE-FOURDASH-OPEN).
|
|
2106
|
+
const lines = src.split(/\r?\n/);
|
|
2107
|
+
if (lines[0] !== "---") return { frontmatter: {}, content: src };
|
|
2108
|
+
|
|
2109
|
+
let closeIdx = -1;
|
|
2110
|
+
for (let i = 1; i < lines.length; i++) {
|
|
2111
|
+
if (lines[i] === "---") { closeIdx = i; break; }
|
|
2112
|
+
}
|
|
2113
|
+
if (closeIdx === -1) return { frontmatter: {}, content: src };
|
|
2114
|
+
|
|
2115
|
+
const yamlBlock = lines.slice(1, closeIdx).join("\n");
|
|
2116
|
+
const content = lines.slice(closeIdx + 1).join("\n");
|
|
2099
2117
|
return { frontmatter: parseBlock(yamlBlock, 0).value, content };
|
|
2100
2118
|
}
|
|
2101
2119
|
|
|
@@ -2119,11 +2137,16 @@ function parseBlock(text: string, baseIndent: number): ParseResult {
|
|
|
2119
2137
|
while (i < lines.length) {
|
|
2120
2138
|
const line = lines[i]!;
|
|
2121
2139
|
if (line.trim() === "") { i++; continue; }
|
|
2140
|
+
// Skip `#`-comment lines (contract C3 / §1.2). A comment at the
|
|
2141
|
+
// block's base level is not a key line; the parser must step over it.
|
|
2142
|
+
if (line.trimStart().startsWith("#")) { i++; continue; }
|
|
2122
2143
|
const indent = countLeadingSpaces(line);
|
|
2123
2144
|
if (indent < baseIndent) break;
|
|
2124
2145
|
if (indent > baseIndent) { i++; continue; } // shouldn't happen at this level
|
|
2125
2146
|
|
|
2126
|
-
|
|
2147
|
+
// Key regex (contract C2 / §1.2): dots allowed in keys, optional
|
|
2148
|
+
// whitespace before the colon (`created.at:` / `key :` both parse).
|
|
2149
|
+
const kv = line.slice(baseIndent).match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
|
|
2127
2150
|
if (!kv) { i++; continue; }
|
|
2128
2151
|
const key = kv[1]!;
|
|
2129
2152
|
const valueText = kv[2]!.trim();
|
|
@@ -2199,7 +2222,8 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
|
|
|
2199
2222
|
// First content after `- `.
|
|
2200
2223
|
const after = line.slice(indent + 2).trim();
|
|
2201
2224
|
// Is this a scalar item (`- foo`) or an object item (`- key: value`)?
|
|
2202
|
-
|
|
2225
|
+
// Key regex matches parseBlock's (contract C2): dots + optional space.
|
|
2226
|
+
const objMatch = after.match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
|
|
2203
2227
|
if (!objMatch) {
|
|
2204
2228
|
result.push(parseScalarOrInline(after));
|
|
2205
2229
|
i++;
|
|
@@ -2251,7 +2275,7 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
|
|
|
2251
2275
|
const sibIndent = countLeadingSpaces(sib);
|
|
2252
2276
|
if (sibIndent !== itemIndent) break;
|
|
2253
2277
|
if (sib.slice(sibIndent).startsWith("- ")) break;
|
|
2254
|
-
const sibKv = sib.slice(sibIndent).match(/^([\w][\w
|
|
2278
|
+
const sibKv = sib.slice(sibIndent).match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
|
|
2255
2279
|
if (!sibKv) break;
|
|
2256
2280
|
const sibKey = sibKv[1]!;
|
|
2257
2281
|
const sibValue = sibKv[2]!.trim();
|
|
@@ -2287,6 +2311,36 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
|
|
|
2287
2311
|
return { value: result, consumed: i - start };
|
|
2288
2312
|
}
|
|
2289
2313
|
|
|
2314
|
+
/**
|
|
2315
|
+
* Quote-aware split of an inline-array body on top-level commas
|
|
2316
|
+
* (contract C4 / §1.2). A comma inside a single- or double-quoted string
|
|
2317
|
+
* does not separate items, so `"a, b", c` → `['"a, b"', 'c']`. Quote
|
|
2318
|
+
* chars are preserved in the parts; `parseScalarOrInline` strips them via
|
|
2319
|
+
* `unquote`. Mirrors the web parser's `splitInlineArray`.
|
|
2320
|
+
*/
|
|
2321
|
+
function splitInlineArray(inner: string): string[] {
|
|
2322
|
+
const parts: string[] = [];
|
|
2323
|
+
let buf = "";
|
|
2324
|
+
let quote: '"' | "'" | null = null;
|
|
2325
|
+
for (let i = 0; i < inner.length; i++) {
|
|
2326
|
+
const ch = inner[i]!;
|
|
2327
|
+
if (quote) {
|
|
2328
|
+
buf += ch;
|
|
2329
|
+
if (ch === quote) quote = null;
|
|
2330
|
+
} else if (ch === '"' || ch === "'") {
|
|
2331
|
+
quote = ch;
|
|
2332
|
+
buf += ch;
|
|
2333
|
+
} else if (ch === ",") {
|
|
2334
|
+
parts.push(buf);
|
|
2335
|
+
buf = "";
|
|
2336
|
+
} else {
|
|
2337
|
+
buf += ch;
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
parts.push(buf);
|
|
2341
|
+
return parts;
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2290
2344
|
/**
|
|
2291
2345
|
* Parse a scalar or inline form (`[a, b]`, `{ k: v }`). Used for the
|
|
2292
2346
|
* value portion of `key: value` lines.
|
|
@@ -2295,7 +2349,10 @@ function parseScalarOrInline(s: string): unknown {
|
|
|
2295
2349
|
if (s.startsWith("[") && s.endsWith("]")) {
|
|
2296
2350
|
const inner = s.slice(1, -1).trim();
|
|
2297
2351
|
if (inner === "") return [];
|
|
2298
|
-
|
|
2352
|
+
// Quote-aware split (contract C4 / §1.2): a comma inside a quoted
|
|
2353
|
+
// string is NOT an item separator, so `["a, b", c]` → 2 items, not 3.
|
|
2354
|
+
// Matches the web parser's `splitInlineArray`.
|
|
2355
|
+
return splitInlineArray(inner).map((part) => parseScalarOrInline(part.trim()));
|
|
2299
2356
|
}
|
|
2300
2357
|
if (s.startsWith("{") && s.endsWith("}")) {
|
|
2301
2358
|
const inner = s.slice(1, -1).trim();
|
|
@@ -2376,18 +2433,59 @@ function unquote(s: string): unknown {
|
|
|
2376
2433
|
// Directory walking — shared with obsidian.ts
|
|
2377
2434
|
// ---------------------------------------------------------------------------
|
|
2378
2435
|
|
|
2379
|
-
/**
|
|
2380
|
-
*
|
|
2436
|
+
/**
|
|
2437
|
+
* Markdown-file classification (contract §1.7). Case-insensitive `.md`
|
|
2438
|
+
* OR `.markdown`. `.mdx`, `.txt`, etc. are NOT markdown for the importer.
|
|
2439
|
+
* Identical to the web parser's classifier.
|
|
2440
|
+
*/
|
|
2441
|
+
export function isMarkdownExtension(path: string): boolean {
|
|
2442
|
+
return /\.(md|markdown)$/i.test(path);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* Named intake-excluded directory/entry segments (contract §1.9). The
|
|
2447
|
+
* generic `startsWith(".")` rule below subsumes the dot-prefixed ones;
|
|
2448
|
+
* the named set is kept explicit for readability + to cover
|
|
2449
|
+
* `__MACOSX`/`node_modules` (which do not start with ".").
|
|
2450
|
+
*/
|
|
2451
|
+
const EXCLUDED_SEGMENTS = new Set([
|
|
2452
|
+
".obsidian",
|
|
2453
|
+
".trash",
|
|
2454
|
+
".git",
|
|
2455
|
+
".parachute",
|
|
2456
|
+
"__MACOSX",
|
|
2457
|
+
"node_modules",
|
|
2458
|
+
]);
|
|
2459
|
+
|
|
2460
|
+
/**
|
|
2461
|
+
* Intake (file-selection) exclusion (contract §1.9). Excludes a source
|
|
2462
|
+
* path if ANY `/`-segment is a named-excluded entry OR starts with "."
|
|
2463
|
+
* (generic dotfile/dotdir). Applied identically by both parsers before
|
|
2464
|
+
* parsing. The generic dot rule means legit dot-prefixed user files
|
|
2465
|
+
* (`.daily-note.md`) ARE excluded — the chosen, consistent behavior.
|
|
2466
|
+
*/
|
|
2467
|
+
export function isExcludedPath(sourcePath: string): boolean {
|
|
2468
|
+
for (const segment of sourcePath.split("/")) {
|
|
2469
|
+
if (segment === "") continue;
|
|
2470
|
+
if (EXCLUDED_SEGMENTS.has(segment)) return true;
|
|
2471
|
+
if (segment.startsWith(".")) return true;
|
|
2472
|
+
}
|
|
2473
|
+
return false;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
/** Recursively list all .md / .markdown files in a directory, excluding
|
|
2477
|
+
* hidden + named-internal dirs per the canonical `isExcludedPath` rule
|
|
2478
|
+
* (`.parachute/`, `.obsidian/`, `node_modules`, …). Legacy import path. */
|
|
2381
2479
|
export function walkMarkdownFiles(dir: string): string[] {
|
|
2382
2480
|
const results: string[] = [];
|
|
2383
2481
|
function walk(current: string) {
|
|
2384
2482
|
for (const entry of readdirSync(current)) {
|
|
2385
|
-
|
|
2386
|
-
if (entry
|
|
2483
|
+
// Per-segment exclusion: the named set + generic dotfile rule.
|
|
2484
|
+
if (EXCLUDED_SEGMENTS.has(entry) || entry.startsWith(".")) continue;
|
|
2387
2485
|
const full = join(current, entry);
|
|
2388
2486
|
const stat = statSync(full);
|
|
2389
2487
|
if (stat.isDirectory()) walk(full);
|
|
2390
|
-
else if (stat.isFile() &&
|
|
2488
|
+
else if (stat.isFile() && isMarkdownExtension(entry)) results.push(full);
|
|
2391
2489
|
}
|
|
2392
2490
|
}
|
|
2393
2491
|
walk(dir);
|
|
@@ -2417,15 +2515,43 @@ export function walkContentFiles(dir: string): string[] {
|
|
|
2417
2515
|
return results.sort();
|
|
2418
2516
|
}
|
|
2419
2517
|
|
|
2518
|
+
/**
|
|
2519
|
+
* Canonical inline-tag regex (contract §1.3). `#` at line-start or after
|
|
2520
|
+
* whitespace; body chars `[A-Za-z0-9_/-]` (slash for hierarchy); the tag
|
|
2521
|
+
* MUST contain ≥1 non-numeric char (the middle `[A-Za-z_/-]`), so `#2024`
|
|
2522
|
+
* is NOT a tag but `#v2`, `#2024-plan`, `#area/sub` are. Identical to the
|
|
2523
|
+
* web parser's `INLINE_HASHTAG`.
|
|
2524
|
+
*/
|
|
2525
|
+
const INLINE_TAG_REGEX = /(?:^|\s)#([A-Za-z0-9_/-]*[A-Za-z_/-][A-Za-z0-9_/-]*)/g;
|
|
2526
|
+
|
|
2527
|
+
/**
|
|
2528
|
+
* Normalize + slug-validate a tag value (contract §1.4). Shared by inline
|
|
2529
|
+
* tag extraction and frontmatter tag extraction (obsidian.ts re-exports
|
|
2530
|
+
* this) so both surfaces validate identically. Returns null when the
|
|
2531
|
+
* value is unusable (non-string non-scalar, empty, or fails slug rules).
|
|
2532
|
+
*/
|
|
2533
|
+
export function normalizeTagValue(v: unknown): string | null {
|
|
2534
|
+
if (typeof v === "number" || typeof v === "boolean") {
|
|
2535
|
+
return String(v).toLowerCase();
|
|
2536
|
+
}
|
|
2537
|
+
if (typeof v !== "string") return null;
|
|
2538
|
+
const stripped = v.trim().replace(/^#/, "").toLowerCase();
|
|
2539
|
+
if (stripped === "") return null;
|
|
2540
|
+
// Slug-validate: lowercase alnum, underscore, hyphen, slash (hierarchy).
|
|
2541
|
+
if (!/^[a-z0-9_/-]+$/.test(stripped)) return null;
|
|
2542
|
+
return stripped;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2420
2545
|
/** Extract inline #tags from markdown content. Excludes tags in code blocks. */
|
|
2421
2546
|
export function extractInlineTags(content: string): string[] {
|
|
2422
2547
|
let stripped = content.replace(/```[\s\S]*?```/g, "");
|
|
2423
2548
|
stripped = stripped.replace(/`[^`\n]+`/g, "");
|
|
2424
2549
|
const tags = new Set<string>();
|
|
2425
|
-
const regex =
|
|
2550
|
+
const regex = new RegExp(INLINE_TAG_REGEX.source, INLINE_TAG_REGEX.flags);
|
|
2426
2551
|
let match: RegExpExecArray | null;
|
|
2427
2552
|
while ((match = regex.exec(stripped)) !== null) {
|
|
2428
|
-
|
|
2553
|
+
const tag = normalizeTagValue(match[1]!);
|
|
2554
|
+
if (tag) tags.add(tag);
|
|
2429
2555
|
}
|
|
2430
2556
|
return [...tags];
|
|
2431
2557
|
}
|
package/core/src/schema.ts
CHANGED
|
@@ -30,10 +30,13 @@ CREATE TABLE IF NOT EXISTS notes (
|
|
|
30
30
|
-- description — human-readable blurb (markdown).
|
|
31
31
|
-- fields — JSON: indexed metadata field declarations per
|
|
32
32
|
-- query-operators.md. Replaces v6-era tag_schemas.fields.
|
|
33
|
-
-- relationships — JSON:
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
--
|
|
33
|
+
-- relationships — JSON: opaque relationship-vocabulary map. Keys are
|
|
34
|
+
-- relationship names; values are arbitrary JSON the declaring
|
|
35
|
+
-- app interprets (e.g. the Weaver's { "works-on": { from, to } }).
|
|
36
|
+
-- Stored + returned verbatim; only the top level is validated
|
|
37
|
+
-- (must be a JSON object/map). The historical typed shape
|
|
38
|
+
-- { "rel": { target_tag, cardinality, description? } } remains a
|
|
39
|
+
-- valid value. Not enforced at write time. See vault#428.
|
|
37
40
|
-- parent_names — JSON array of parent tag names. Replaces the v6-era
|
|
38
41
|
-- _tags/NAME config-note hierarchy.
|
|
39
42
|
CREATE TABLE IF NOT EXISTS tags (
|
package/core/src/store.ts
CHANGED
|
@@ -572,7 +572,7 @@ export class BunSqliteStore implements Store {
|
|
|
572
572
|
patch: {
|
|
573
573
|
description?: string | null;
|
|
574
574
|
fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
|
|
575
|
-
relationships?:
|
|
575
|
+
relationships?: tagSchemaOps.TagRelationshipMap | null;
|
|
576
576
|
parent_names?: string[] | null;
|
|
577
577
|
},
|
|
578
578
|
) {
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -33,10 +33,13 @@ export interface TagFieldSchema {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Cardinality vocabulary for typed
|
|
37
|
-
* algebra so AI clients reading `list-tags` can reason
|
|
38
|
-
* directly.
|
|
39
|
-
*
|
|
36
|
+
* Cardinality vocabulary for the historical typed-relationship shape.
|
|
37
|
+
* Names rather than algebra so AI clients reading `list-tags` can reason
|
|
38
|
+
* about intent directly. Retained for callers that still want the typed
|
|
39
|
+
* `{ target_tag, cardinality }` declaration — but `relationships` is now an
|
|
40
|
+
* opaque vocabulary map (see `TagRelationshipMap` / `validateRelationships`),
|
|
41
|
+
* so this is one valid value shape among many, not a required one.
|
|
42
|
+
* See patterns/tag-data-model.md §Relationships.
|
|
40
43
|
*/
|
|
41
44
|
export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
|
|
42
45
|
|
|
@@ -47,12 +50,24 @@ export const TAG_REL_CARDINALITIES: readonly TagRelCardinality[] = [
|
|
|
47
50
|
"many-required",
|
|
48
51
|
] as const;
|
|
49
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The historical typed-relationship declaration. Still a valid opaque-map
|
|
55
|
+
* value — vault no longer enforces it. New apps (the Weaver / structural-link
|
|
56
|
+
* picker) declare their own freeform vocabulary instead.
|
|
57
|
+
*/
|
|
50
58
|
export interface TagRelationship {
|
|
51
59
|
target_tag: string;
|
|
52
60
|
cardinality: TagRelCardinality;
|
|
53
61
|
description?: string;
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
/**
|
|
65
|
+
* `relationships` is an opaque vocabulary map: relationship-name → arbitrary
|
|
66
|
+
* JSON value the declaring app interprets. Vault stores and returns the values
|
|
67
|
+
* verbatim and enforces only that the top-level value is a JSON object (a map).
|
|
68
|
+
*/
|
|
69
|
+
export type TagRelationshipMap = Record<string, unknown>;
|
|
70
|
+
|
|
56
71
|
/**
|
|
57
72
|
* Schema-only view of a tag — the historical shape. Backwards-compatible
|
|
58
73
|
* with v13-and-earlier callers.
|
|
@@ -67,7 +82,7 @@ export interface TagSchema {
|
|
|
67
82
|
* Full tag record — schema + typed relationships + hierarchy parents.
|
|
68
83
|
*/
|
|
69
84
|
export interface TagRecord extends TagSchema {
|
|
70
|
-
relationships?:
|
|
85
|
+
relationships?: TagRelationshipMap;
|
|
71
86
|
parent_names?: string[];
|
|
72
87
|
created_at?: string;
|
|
73
88
|
updated_at?: string;
|
|
@@ -115,7 +130,7 @@ export function upsertTagRecord(
|
|
|
115
130
|
patch: {
|
|
116
131
|
description?: string | null;
|
|
117
132
|
fields?: Record<string, TagFieldSchema> | null;
|
|
118
|
-
relationships?:
|
|
133
|
+
relationships?: TagRelationshipMap | null;
|
|
119
134
|
parent_names?: string[] | null;
|
|
120
135
|
},
|
|
121
136
|
): TagRecord {
|
|
@@ -226,56 +241,56 @@ export function deleteTagSchema(db: Database, tag: string): boolean {
|
|
|
226
241
|
}
|
|
227
242
|
|
|
228
243
|
// ---------------------------------------------------------------------------
|
|
229
|
-
// Validation —
|
|
244
|
+
// Validation — relationships (opaque vocabulary map)
|
|
230
245
|
// ---------------------------------------------------------------------------
|
|
231
246
|
|
|
232
247
|
/**
|
|
233
|
-
* Validate a `relationships` payload before persisting.
|
|
234
|
-
*
|
|
235
|
-
*
|
|
248
|
+
* Validate a `relationships` payload before persisting. `relationships` is
|
|
249
|
+
* an **opaque vocabulary map**: a JSON object whose keys are relationship
|
|
250
|
+
* names and whose values are arbitrary JSON the declaring app interprets
|
|
251
|
+
* (e.g. the Weaver / structural-link picker's `{ "works-on": { from, to } }`
|
|
252
|
+
* shape). Vault does not enforce any inner structure — it stores and returns
|
|
253
|
+
* the values verbatim.
|
|
254
|
+
*
|
|
255
|
+
* Rules (the only ones):
|
|
256
|
+
* - The top-level value must be a plain JSON object (a map). A top-level
|
|
257
|
+
* array or primitive is rejected — relationships is a map, not a list.
|
|
258
|
+
* - The payload must be JSON-serializable (no circular refs / functions /
|
|
259
|
+
* bigints), since it's persisted as a JSON column.
|
|
236
260
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
261
|
+
* Returns the value verbatim (round-trips through JSON.parse(JSON.stringify)
|
|
262
|
+
* to both prove serializability and strip anything non-serializable). The
|
|
263
|
+
* historical typed shape `{ target_tag, cardinality }` is a valid opaque map,
|
|
264
|
+
* so this is a backwards-compatible superset — existing typed declarations
|
|
265
|
+
* and callers keep working unchanged.
|
|
266
|
+
*
|
|
267
|
+
* Phase 1 was already informational ("declarations are not enforced at write
|
|
268
|
+
* time"); dropping the inner-shape gate is consistent with that intent.
|
|
241
269
|
*/
|
|
242
|
-
export function validateRelationships(
|
|
243
|
-
raw: unknown,
|
|
244
|
-
): Record<string, TagRelationship> {
|
|
270
|
+
export function validateRelationships(raw: unknown): Record<string, unknown> {
|
|
245
271
|
if (raw === null || raw === undefined) {
|
|
246
272
|
throw new Error("relationships: expected an object, got null/undefined");
|
|
247
273
|
}
|
|
248
274
|
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
249
|
-
throw new Error(
|
|
275
|
+
throw new Error(
|
|
276
|
+
"relationships: expected an object mapping relationship name → value (got an array or primitive)",
|
|
277
|
+
);
|
|
250
278
|
}
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
if (!rel || typeof rel !== "string") {
|
|
279
|
+
for (const rel of Object.keys(raw as Record<string, unknown>)) {
|
|
280
|
+
if (!rel) {
|
|
254
281
|
throw new Error("relationships: keys must be non-empty strings");
|
|
255
282
|
}
|
|
256
|
-
if (!decl || typeof decl !== "object" || Array.isArray(decl)) {
|
|
257
|
-
throw new Error(`relationships["${rel}"]: declaration must be an object`);
|
|
258
|
-
}
|
|
259
|
-
const d = decl as Record<string, unknown>;
|
|
260
|
-
if (typeof d.target_tag !== "string" || d.target_tag.length === 0) {
|
|
261
|
-
throw new Error(`relationships["${rel}"]: target_tag must be a non-empty string`);
|
|
262
|
-
}
|
|
263
|
-
const card = d.cardinality;
|
|
264
|
-
if (typeof card !== "string" || !TAG_REL_CARDINALITIES.includes(card as TagRelCardinality)) {
|
|
265
|
-
throw new Error(
|
|
266
|
-
`relationships["${rel}"]: cardinality must be one of ${TAG_REL_CARDINALITIES.join(" | ")}; got ${JSON.stringify(card)}`,
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
if (d.description !== undefined && typeof d.description !== "string") {
|
|
270
|
-
throw new Error(`relationships["${rel}"]: description must be a string when set`);
|
|
271
|
-
}
|
|
272
|
-
out[rel] = {
|
|
273
|
-
target_tag: d.target_tag,
|
|
274
|
-
cardinality: card as TagRelCardinality,
|
|
275
|
-
...(d.description !== undefined ? { description: d.description as string } : {}),
|
|
276
|
-
};
|
|
277
283
|
}
|
|
278
|
-
|
|
284
|
+
// Round-trip through JSON to (a) confirm the payload is serializable —
|
|
285
|
+
// the column is stored as JSON — and (b) return a clean, owned copy with
|
|
286
|
+
// no non-JSON values lingering. Throws on circular refs / bigint / etc.
|
|
287
|
+
let serialized: string;
|
|
288
|
+
try {
|
|
289
|
+
serialized = JSON.stringify(raw);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
throw new Error(`relationships: value must be JSON-serializable (${(err as Error).message})`);
|
|
292
|
+
}
|
|
293
|
+
return JSON.parse(serialized) as Record<string, unknown>;
|
|
279
294
|
}
|
|
280
295
|
|
|
281
296
|
// ---------------------------------------------------------------------------
|
|
@@ -287,7 +302,7 @@ function rowToRecord(row: TagRow): TagRecord {
|
|
|
287
302
|
tag: row.name,
|
|
288
303
|
description: row.description ?? undefined,
|
|
289
304
|
fields: parseJson<Record<string, TagFieldSchema>>(row.fields),
|
|
290
|
-
relationships: parseJson<
|
|
305
|
+
relationships: parseJson<TagRelationshipMap>(row.relationships),
|
|
291
306
|
parent_names: parseJson<string[]>(row.parent_names),
|
|
292
307
|
created_at: row.created_at ?? undefined,
|
|
293
308
|
updated_at: row.updated_at ?? undefined,
|
package/core/src/types.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
|
|
1
|
+
import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
2
2
|
import type { PrunedField } from "./indexed-fields.js";
|
|
3
3
|
|
|
4
4
|
// ---- Re-exports ----
|
|
5
5
|
|
|
6
|
-
export type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
|
|
6
|
+
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
7
7
|
export type { PrunedField } from "./indexed-fields.js";
|
|
8
8
|
|
|
9
9
|
// ---- Note ----
|
|
@@ -25,6 +25,14 @@ export interface Note {
|
|
|
25
25
|
updatedAt?: string;
|
|
26
26
|
tags?: string[];
|
|
27
27
|
links?: Link[];
|
|
28
|
+
/**
|
|
29
|
+
* Opt-in link degree (raw row count, both directions by default). Present
|
|
30
|
+
* only when the caller requests it via `include_link_count` (REST/MCP).
|
|
31
|
+
* Surfaced the same way `links`/`attachments` are — an extra key injected
|
|
32
|
+
* onto the response after the base shape. See `getLinkCounts` in links.ts
|
|
33
|
+
* for the exact degree semantics (self-loop = 2 under `both`).
|
|
34
|
+
*/
|
|
35
|
+
linkCount?: number;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
// ---- Link ----
|
|
@@ -59,6 +67,16 @@ export interface VaultStats {
|
|
|
59
67
|
tagCount: number;
|
|
60
68
|
attachmentCount: number;
|
|
61
69
|
linkCount: number;
|
|
70
|
+
/**
|
|
71
|
+
* Total bytes of all note content, computed as the sum of the UTF-8 byte
|
|
72
|
+
* length of every note's `content`. The SQL uses `LENGTH(CAST(content AS
|
|
73
|
+
* BLOB))` deliberately: SQLite's bare `LENGTH(text)` returns the number of
|
|
74
|
+
* *characters*, not bytes, so a note full of multibyte UTF-8 (emoji, CJK,
|
|
75
|
+
* accents) would undercount its true on-disk/on-wire footprint. Casting to
|
|
76
|
+
* BLOB forces `LENGTH` to count raw bytes. This is the logical content size,
|
|
77
|
+
* not the physical DB-file size (see `usage.ts:dbBytes` for the latter).
|
|
78
|
+
*/
|
|
79
|
+
contentBytes: number;
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
// ---- Query Options ----
|
|
@@ -115,6 +133,12 @@ export interface QueryOpts {
|
|
|
115
133
|
// declared `indexed: true`; errors loudly otherwise. Direction is taken
|
|
116
134
|
// from `sort` (default "asc") and `created_at` is appended as a stable
|
|
117
135
|
// tiebreaker.
|
|
136
|
+
//
|
|
137
|
+
// The pseudo-field `link_count` is special-cased (no indexed-field
|
|
138
|
+
// declaration needed): it sorts by link DEGREE — the both-directions
|
|
139
|
+
// raw row count — using the same directional-sum definition as the
|
|
140
|
+
// `linkCount` response field, so the sort key equals the field value for
|
|
141
|
+
// every note (self-loops included). See `queryNotes`/`getLinkCounts`.
|
|
118
142
|
orderBy?: string;
|
|
119
143
|
limit?: number;
|
|
120
144
|
offset?: number;
|
|
@@ -153,6 +177,8 @@ export interface NoteSummary {
|
|
|
153
177
|
createdAt: string;
|
|
154
178
|
updatedAt?: string;
|
|
155
179
|
tags?: string[];
|
|
180
|
+
/** Opt-in link degree (see `Note.linkCount`). */
|
|
181
|
+
linkCount?: number;
|
|
156
182
|
}
|
|
157
183
|
|
|
158
184
|
/**
|
|
@@ -169,6 +195,8 @@ export interface NoteIndex {
|
|
|
169
195
|
metadata?: Record<string, unknown>;
|
|
170
196
|
byteSize: number;
|
|
171
197
|
preview: string;
|
|
198
|
+
/** Opt-in link degree (see `Note.linkCount`). */
|
|
199
|
+
linkCount?: number;
|
|
172
200
|
}
|
|
173
201
|
|
|
174
202
|
/** Link with hydrated note summaries. */
|
|
@@ -313,7 +341,7 @@ export interface Store {
|
|
|
313
341
|
patch: {
|
|
314
342
|
description?: string | null;
|
|
315
343
|
fields?: Record<string, TagFieldSchema> | null;
|
|
316
|
-
relationships?:
|
|
344
|
+
relationships?: TagRelationshipMap | null;
|
|
317
345
|
parent_names?: string[] | null;
|
|
318
346
|
},
|
|
319
347
|
): Promise<TagRecord>;
|
package/package.json
CHANGED
package/src/auth.test.ts
CHANGED
|
@@ -26,7 +26,8 @@ import {
|
|
|
26
26
|
hashKey,
|
|
27
27
|
} from "./config.ts";
|
|
28
28
|
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
29
|
-
import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
|
|
29
|
+
import { authenticateVaultRequest, authenticateGlobalRequest, warnLegacyGlobalApiKeys } from "./auth.ts";
|
|
30
|
+
import type { StoredKey } from "./config.ts";
|
|
30
31
|
|
|
31
32
|
let tmpHome: string;
|
|
32
33
|
let prevHome: string | undefined;
|
|
@@ -442,3 +443,38 @@ describe("auth — VAULT_AUTH_TOKEN server-wide operator bearer", () => {
|
|
|
442
443
|
expect("error" in result).toBe(true);
|
|
443
444
|
});
|
|
444
445
|
});
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Legacy GLOBAL api_keys boot warning (security review — multi-user
|
|
449
|
+
// hardening). Cross-vault credentials in config.yaml must be surfaced loudly
|
|
450
|
+
// at boot, but never altered. Pure-function unit tests (no server boot).
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
describe("warnLegacyGlobalApiKeys (legacy cross-vault key boot warning)", () => {
|
|
453
|
+
function key(id: string): StoredKey {
|
|
454
|
+
return {
|
|
455
|
+
id,
|
|
456
|
+
label: id,
|
|
457
|
+
key_hash: `sha256:${id}`,
|
|
458
|
+
scope: "full",
|
|
459
|
+
created_at: new Date().toISOString(),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
test("warns when global api_keys are present", () => {
|
|
464
|
+
const msgs: string[] = [];
|
|
465
|
+
const count = warnLegacyGlobalApiKeys([key("a"), key("b")], (m) => msgs.push(m));
|
|
466
|
+
expect(count).toBe(2);
|
|
467
|
+
expect(msgs).toHaveLength(1);
|
|
468
|
+
expect(msgs[0]).toContain("legacy GLOBAL api_key");
|
|
469
|
+
expect(msgs[0]).toContain("CROSS-VAULT");
|
|
470
|
+
// Heads-up only — must signal it does NOT alter the keys.
|
|
471
|
+
expect(msgs[0]).toContain("remain active");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("silent when there are no global api_keys", () => {
|
|
475
|
+
const msgs: string[] = [];
|
|
476
|
+
expect(warnLegacyGlobalApiKeys([], (m) => msgs.push(m))).toBe(0);
|
|
477
|
+
expect(warnLegacyGlobalApiKeys(undefined, (m) => msgs.push(m))).toBe(0);
|
|
478
|
+
expect(msgs).toHaveLength(0);
|
|
479
|
+
});
|
|
480
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -171,6 +171,35 @@ export function warnLegacyOnce(cacheKey: string, context: string): void {
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Boot-time warning for legacy GLOBAL `api_keys` in `config.yaml` (security
|
|
176
|
+
* review — multi-user hardening). Those keys are CROSS-VAULT credentials: a
|
|
177
|
+
* single key authenticates against EVERY vault on this server (see the global
|
|
178
|
+
* `api_keys` branch in `authenticate` ~L283). That predates per-vault keys +
|
|
179
|
+
* tag-scoped hub JWTs and is a confidentiality hazard once a server hosts
|
|
180
|
+
* multiple users' vaults — one user's global key reads another's vault.
|
|
181
|
+
*
|
|
182
|
+
* WARNING ONLY — never touches the keys (the operator owns them). The
|
|
183
|
+
* verification flagged 6 such keys on the live box; this surfaces them at
|
|
184
|
+
* boot so they're rotated/removed before multi-user sharing. Returns the
|
|
185
|
+
* count it warned about (0 = silent) so callers / tests can assert.
|
|
186
|
+
*/
|
|
187
|
+
export function warnLegacyGlobalApiKeys(
|
|
188
|
+
globalApiKeys: StoredKey[] | undefined,
|
|
189
|
+
warn: (msg: string) => void = console.warn,
|
|
190
|
+
): number {
|
|
191
|
+
const count = globalApiKeys?.length ?? 0;
|
|
192
|
+
if (count === 0) return 0;
|
|
193
|
+
warn(
|
|
194
|
+
`[auth] WARNING: ${count} legacy GLOBAL api_key(s) found in config.yaml. ` +
|
|
195
|
+
"These are CROSS-VAULT credentials (each grants access to every vault on this server) " +
|
|
196
|
+
"and predate per-vault keys + tag-scoped hub JWTs. Before multi-user sharing, ROTATE or " +
|
|
197
|
+
"REMOVE them — a global key leaks one user's vault to another. They remain active (the " +
|
|
198
|
+
"operator owns them); this is a heads-up, not an automatic change.",
|
|
199
|
+
);
|
|
200
|
+
return count;
|
|
201
|
+
}
|
|
202
|
+
|
|
174
203
|
/** Read-only tools (the only tools allowed for "read" permission). */
|
|
175
204
|
const READ_TOOLS = new Set([
|
|
176
205
|
"query-notes",
|