@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/src/cli.ts CHANGED
@@ -102,6 +102,13 @@ import { listTokens, revokeToken, migrateVaultKeys } from "./token-store.ts";
102
102
  import { VAULT_SCOPES } from "./scopes.ts";
103
103
  import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
104
104
  import { getVaultStore } from "./vault-store.ts";
105
+ import {
106
+ defaultMirrorConfig,
107
+ resolveMirrorPath,
108
+ writeMirrorConfigForVault,
109
+ type MirrorConfig,
110
+ } from "./mirror-config.ts";
111
+ import { bootstrapInternalMirror } from "./mirror-manager.ts";
105
112
  import { selfRegister } from "./self-register.ts";
106
113
  import {
107
114
  hasOwnerPassword,
@@ -814,11 +821,15 @@ async function cmdCreate(args: string[]) {
814
821
  // POST /vaults shells out to this CLI and parses stdout). Errors still go
815
822
  // to stderr as plain text and exit nonzero — callers branch on exit code.
816
823
  const jsonMode = args.includes("--json");
824
+ // `--no-mirror` opts THIS create out of the default internal live mirror
825
+ // even when the server-wide `default_mirror` knob is `internal`. Parity for
826
+ // operators who want one bare vault without flipping the global default.
827
+ const noMirror = args.includes("--no-mirror");
817
828
  // Greedy strip of any `--*` token to recover the positional vault name.
818
- // Today only `--json` is recognized; any other `--foo` is silently dropped.
819
- // If a future flag (e.g. `--force`, `--dry-run`) is added, the parsing
820
- // here needs to whitelist it — otherwise an invalid flag becomes a silent
821
- // no-op rather than a usage error.
829
+ // `--json` and `--no-mirror` are recognized; any other `--foo` is silently
830
+ // dropped. If a future flag (e.g. `--force`, `--dry-run`) is added, the
831
+ // parsing here needs to whitelist it — otherwise an invalid flag becomes a
832
+ // silent no-op rather than a usage error.
822
833
  const positional = args.filter((a) => !a.startsWith("--"));
823
834
  const name = positional[0];
824
835
  if (!name) {
@@ -826,8 +837,14 @@ async function cmdCreate(args: string[]) {
826
837
  process.exit(1);
827
838
  }
828
839
 
829
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
830
- console.error("Vault name must contain only letters, numbers, hyphens, and underscores.");
840
+ // Lowercase-only (security review — multi-user hardening). An uppercase
841
+ // vault name flips the audience case (`vault.<Name>` vs `vault.<name>`)
842
+ // and drifts from hub-side / init-path lowercasing, breaking JWT
843
+ // audience matching. `init` already enforces lowercase via
844
+ // `validateVaultName`; mirror that rule here so uppercase can't enter
845
+ // through `create` either.
846
+ if (!/^[a-z0-9_-]+$/.test(name)) {
847
+ console.error("Vault name must be lowercase alphanumeric with hyphens or underscores (no uppercase).");
831
848
  process.exit(1);
832
849
  }
833
850
  if (name === "list") {
@@ -846,7 +863,7 @@ async function cmdCreate(args: string[]) {
846
863
 
847
864
  ensureConfigDirSync();
848
865
  const wasFirst = listVaults().length === 0;
849
- const credential = await createVault(name);
866
+ const credential = await createVault(name, noMirror ? { enableMirror: false } : {});
850
867
 
851
868
  // If this is the only vault now, make it the default so unscoped routes
852
869
  // (/mcp, /api/*, /oauth/*) target it. Avoids the "single vault named
@@ -2760,34 +2777,17 @@ async function cmdImport(args: string[]) {
2760
2777
  return;
2761
2778
  }
2762
2779
 
2763
- // Import into vault — use createNoteRaw to skip per-note wikilink sync,
2764
- // then do a single pass after all notes are imported (much faster for large vaults).
2780
+ // Import into vault — use the shared `importObsidianNotes` adapter
2781
+ // (obsidian.ts). It uses createNoteRaw to skip per-note wikilink sync,
2782
+ // id-aware upsert with a path-conflict guard, intra-batch collision
2783
+ // dedup, per-note error isolation, and timestamp preservation. The
2784
+ // single wikilink pass runs below, after all notes exist.
2785
+ const { importObsidianNotes } = await import("../core/src/obsidian.ts");
2765
2786
  const store = getVaultStore(vaultName);
2766
- let imported = 0;
2767
- let skipped = 0;
2768
-
2769
- for (const note of notes) {
2770
- // Skip if a note with this path already exists
2771
- const existing = await store.getNoteByPath(note.path);
2772
- if (existing) {
2773
- skipped++;
2774
- continue;
2775
- }
2776
-
2777
- // Build metadata from frontmatter (excluding tags, already extracted)
2778
- const metadata = Object.keys(note.frontmatter).length > 0 ? note.frontmatter : undefined;
2779
-
2780
- await store.createNoteRaw(note.content, {
2781
- path: note.path,
2782
- tags: note.tags.length > 0 ? note.tags : undefined,
2783
- metadata: metadata as Record<string, unknown>,
2784
- });
2785
- imported++;
2786
- }
2787
+ const { imported, skipped } = await importObsidianNotes(store, notes);
2787
2788
 
2788
- // Single-pass wikilink sync after all notes exist
2789
2789
  console.log(`\nImported ${imported} notes into vault "${vaultName}"`);
2790
- if (skipped > 0) console.log(`Skipped ${skipped} notes (path already exists)`);
2790
+ if (skipped > 0) console.log(`Skipped ${skipped} notes (path already exists or conflict)`);
2791
2791
 
2792
2792
  if (imported > 0) {
2793
2793
  const linkResult = await store.syncAllWikilinks();
@@ -3350,7 +3350,59 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
3350
3350
  * is reachable — vault#282 Stage 2). The DB is created lazily via
3351
3351
  * `getVaultStore` so migrations + schema run; we no longer write any pvt_* row.
3352
3352
  */
3353
- async function createVault(name: string): Promise<VaultCredential> {
3353
+ interface CreateVaultOptions {
3354
+ /**
3355
+ * Override the server-wide `default_mirror` knob for this one create.
3356
+ * `--no-mirror` on `parachute-vault create` sets this to `false` so the
3357
+ * vault is created with no mirror config even when the knob is `internal`.
3358
+ * Unset → fall back to the `default_mirror` global config knob (default
3359
+ * `internal`).
3360
+ */
3361
+ enableMirror?: boolean;
3362
+ /**
3363
+ * Test seam threaded straight into `bootstrapInternalMirror` (default
3364
+ * `Bun.which`). Inject a fn returning `null` to exercise the
3365
+ * git-not-installed best-effort path without uninstalling git from the
3366
+ * test host.
3367
+ */
3368
+ which?: (cmd: string) => string | null;
3369
+ }
3370
+
3371
+ /**
3372
+ * The History / "Live Mirror" preset, written at create time when the
3373
+ * `default_mirror` knob resolves to `internal`. Matches the History preset
3374
+ * the admin SPA's VaultMirror page applies:
3375
+ * `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
3376
+ * auto_push:false}`.
3377
+ * Built on top of `defaultMirrorConfig()` so the non-preset fields
3378
+ * (commit_template, safety_net_seconds) stay canonical.
3379
+ */
3380
+ function historyPresetMirrorConfig(): MirrorConfig {
3381
+ return {
3382
+ ...defaultMirrorConfig(),
3383
+ enabled: true,
3384
+ location: "internal",
3385
+ sync_mode: "events",
3386
+ auto_commit: true,
3387
+ auto_push: false,
3388
+ };
3389
+ }
3390
+
3391
+ /**
3392
+ * Resolve whether a freshly created vault should get the internal mirror.
3393
+ * Precedence: explicit per-create override (`--no-mirror`) → server-wide
3394
+ * `default_mirror` knob (default `internal`).
3395
+ */
3396
+ function shouldEnableCreateTimeMirror(opts: CreateVaultOptions): boolean {
3397
+ if (opts.enableMirror !== undefined) return opts.enableMirror;
3398
+ // Default to "internal" when the knob is unset — backup-on-by-default.
3399
+ return (readGlobalConfig().default_mirror ?? "internal") === "internal";
3400
+ }
3401
+
3402
+ async function createVault(
3403
+ name: string,
3404
+ opts: CreateVaultOptions = {},
3405
+ ): Promise<VaultCredential> {
3354
3406
  const config: VaultConfig = {
3355
3407
  name,
3356
3408
  api_keys: [],
@@ -3361,6 +3413,47 @@ async function createVault(name: string): Promise<VaultCredential> {
3361
3413
  // Touch the store so the vault's SQLite DB + schema are created. No token
3362
3414
  // row is written — vault is a pure hub resource-server post-0.5.0.
3363
3415
  getVaultStore(name);
3416
+
3417
+ // Default new vaults to an internal live mirror (local git backup of the
3418
+ // markdown projection). Backup-on-by-default; GitHub off-site backup is an
3419
+ // opt-in upgrade layered on top later. Opt out via the `default_mirror: off`
3420
+ // global knob (operators on git-less / disk-constrained / cloud boxes) or
3421
+ // the `--no-mirror` flag (this one create only).
3422
+ //
3423
+ // BEST-EFFORT, NON-FATAL: write the mirror config first (so the operator's
3424
+ // intent persists even if git is absent), then attempt the bootstrap. A
3425
+ // git-less box leaves the config written but inactive + logs an actionable
3426
+ // hint — it must NEVER fail the vault create. Create-time ONLY: existing
3427
+ // vaults are never retroactively migrated.
3428
+ if (shouldEnableCreateTimeMirror(opts)) {
3429
+ const mirrorConfig = historyPresetMirrorConfig();
3430
+ writeMirrorConfigForVault(name, mirrorConfig);
3431
+ const mirrorPath = resolveMirrorPath(vaultDir(name), mirrorConfig);
3432
+ if (mirrorPath) {
3433
+ try {
3434
+ const result = await bootstrapInternalMirror(mirrorPath, opts.which);
3435
+ if (!result.ok) {
3436
+ // git-not-installed (or refuse-to-clobber) — config stays written,
3437
+ // mirror just isn't active yet. Surface an actionable line; the
3438
+ // vault create succeeds regardless.
3439
+ console.error(
3440
+ `Note: local git backup configured but not yet active — ${result.error} ` +
3441
+ `Install git to activate; the backup turns on automatically on the next vault restart.`,
3442
+ );
3443
+ }
3444
+ } catch (err) {
3445
+ // Defense-in-depth: bootstrapInternalMirror already converts the
3446
+ // git-missing case into a non-throwing { ok:false } result, but a
3447
+ // truly unexpected throw must still not fail the create.
3448
+ console.error(
3449
+ `Note: local git backup configured but bootstrap hit an unexpected error ` +
3450
+ `(${(err as Error).message ?? err}). The vault was still created; ` +
3451
+ `the backup will retry on the next vault restart.`,
3452
+ );
3453
+ }
3454
+ }
3455
+ }
3456
+
3364
3457
  return mintBootstrapCredential(name);
3365
3458
  }
3366
3459
 
@@ -3465,7 +3558,13 @@ Setup:
3465
3558
  parachute --version Print the installed version (alias: -v, version)
3466
3559
 
3467
3560
  Vaults:
3468
- parachute-vault create <name> [--json] Create a new vault (--json: emit { name, token, paths, set_as_default })
3561
+ parachute-vault create <name> [--json] [--no-mirror]
3562
+ Create a new vault (--json: emit { name, token, paths, set_as_default }).
3563
+ New vaults default to an internal live mirror — a local git backup of
3564
+ the markdown projection (backup on by default; GitHub off-site is an
3565
+ opt-in upgrade). --no-mirror creates a bare vault with no mirror config.
3566
+ Operators can flip the server-wide default with 'default_mirror: off' in
3567
+ config.yaml (recommended for cloud / disk-constrained boxes).
3469
3568
  parachute-vault list List all vaults
3470
3569
  parachute-vault remove <name> [--yes] Remove a vault
3471
3570
  parachute-vault mcp-install [--mint|--token <t>]
@@ -250,6 +250,22 @@ describe("config", () => {
250
250
  writeGlobalConfig({ port: 1940, autostart: false });
251
251
  expect(readGlobalConfig().autostart).toBe(false);
252
252
  });
253
+
254
+ test("round-trips default_mirror: internal|off", () => {
255
+ // Absent: createVault falls back to the in-code default ("internal" —
256
+ // backup-on-by-default). The knob is only persisted when explicitly set.
257
+ writeGlobalConfig({ port: 1940 });
258
+ expect(readGlobalConfig().default_mirror).toBeUndefined();
259
+
260
+ // Explicit internal — new vaults get the History-preset local git mirror.
261
+ writeGlobalConfig({ port: 1940, default_mirror: "internal" });
262
+ expect(readGlobalConfig().default_mirror).toBe("internal");
263
+
264
+ // Explicit off — the opt-out operators set on git-less / disk-constrained
265
+ // / cloud boxes so new vaults are created with no mirror config.
266
+ writeGlobalConfig({ port: 1940, default_mirror: "off" });
267
+ expect(readGlobalConfig().default_mirror).toBe("off");
268
+ });
253
269
  });
254
270
 
255
271
  // ---------------------------------------------------------------------------
package/src/config.ts CHANGED
@@ -115,6 +115,17 @@ export function vaultConfigPath(name: string): string {
115
115
  return join(vaultDir(name), "vault.yaml");
116
116
  }
117
117
 
118
+ /**
119
+ * Per-vault attachments directory: `<vaultDir>/assets`, or the `ASSETS_DIR`
120
+ * env override when set (single-assets-root deployments). Lives here next to
121
+ * the other path helpers — neutral ground that both `routes.ts` (upload/serve)
122
+ * and `usage.ts` (footprint dir-walk) import without a cycle. `routes.ts`
123
+ * re-exports it for the existing callers (mirror-deps, server, triggers, …).
124
+ */
125
+ export function assetsDir(name: string): string {
126
+ return process.env.ASSETS_DIR ?? join(vaultDir(name), "assets");
127
+ }
128
+
118
129
  // ---------------------------------------------------------------------------
119
130
  // Types
120
131
  // ---------------------------------------------------------------------------
@@ -280,6 +291,29 @@ export interface GlobalConfig {
280
291
  * resolved path. See `./mirror-config.ts`.
281
292
  */
282
293
  mirror?: MirrorConfigType;
294
+ /**
295
+ * Server-wide DEFAULT for newly created vaults' backup posture. Decides
296
+ * whether `createVault` writes the History-preset internal mirror
297
+ * (local git backup of the markdown projection) at create time.
298
+ *
299
+ * - `"internal"` (default) — new vaults get a local git mirror enabled
300
+ * out of the box (backup-on-by-default). The History preset:
301
+ * `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
302
+ * auto_push:false}`. GitHub off-site backup remains an opt-in upgrade.
303
+ * - `"off"` — new vaults are created with no mirror config (the historical
304
+ * pre-default behavior). The escape hatch for git-less / disk-constrained
305
+ * boxes and cloud deploys, where doubling disk per vault is unwanted.
306
+ * Cloud / container deploys SHOULD set this to `off`.
307
+ *
308
+ * Create-time ONLY — this knob does NOT retroactively enable mirrors on
309
+ * already-created vaults (that would ~double disk across every existing
310
+ * vault). Existing-vault opt-in is a separate, deliberate follow-up.
311
+ *
312
+ * The container/cloud first-boot auto-create path in `server.ts` does NOT
313
+ * funnel through `createVault`, so it is unaffected by this knob and stays
314
+ * mirror-off regardless — matching the recommended cloud posture.
315
+ */
316
+ default_mirror?: "internal" | "off";
283
317
  /**
284
318
  * Auto-transcribe configuration for the vault↔scribe handoff (vault#353,
285
319
  * design 2026-05-21 Part 2). When `enabled: true` AND scribe is discoverable
@@ -1162,6 +1196,7 @@ export function readGlobalConfig(): GlobalConfig {
1162
1196
  const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
1163
1197
  const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
1164
1198
  const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
1199
+ const defaultMirrorMatch = yaml.match(/^default_mirror:\s*(internal|off)/m);
1165
1200
  // auto_transcribe block — currently single boolean `enabled` (vault#353).
1166
1201
  // Parsed as a nested 2-space-indent block so future fields can grow under
1167
1202
  // it without breaking the regex; only `enabled` is read for v0.6.
@@ -1190,6 +1225,9 @@ export function readGlobalConfig(): GlobalConfig {
1190
1225
  if (autostartMatch) {
1191
1226
  config.autostart = autostartMatch[1]! === "true";
1192
1227
  }
1228
+ if (defaultMirrorMatch) {
1229
+ config.default_mirror = defaultMirrorMatch[1]! as "internal" | "off";
1230
+ }
1193
1231
  if (autoTranscribeEnabled !== undefined) {
1194
1232
  config.auto_transcribe = { enabled: autoTranscribeEnabled };
1195
1233
  }
@@ -1259,6 +1297,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1259
1297
  if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
1260
1298
  if (config.discovery) lines.push(`discovery: ${config.discovery}`);
1261
1299
  if (config.autostart !== undefined) lines.push(`autostart: ${config.autostart}`);
1300
+ if (config.default_mirror) lines.push(`default_mirror: ${config.default_mirror}`);
1262
1301
  if (config.owner_password_hash) {
1263
1302
  lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
1264
1303
  }
package/src/mcp-tools.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { generateMcpTools } from "../core/src/mcp.ts";
9
9
  import type { McpToolDef } from "../core/src/mcp.ts";
10
+ import type { Note } from "../core/src/types.ts";
10
11
  import {
11
12
  buildVaultProjection,
12
13
  projectionToMarkdown,
@@ -18,6 +19,7 @@ import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts
18
19
  import type { AuthResult } from "./auth.ts";
19
20
  import {
20
21
  expandTokenTagScope,
22
+ filterHydratedLinksByTagScope,
21
23
  noteWithinTagScope,
22
24
  tagsWithinScope,
23
25
  } from "./tag-scope.ts";
@@ -117,11 +119,30 @@ export function generateScopedMcpTools(
117
119
  callerBearer?: string | null,
118
120
  ): McpToolDef[] {
119
121
  const store = getVaultStore(vaultName);
120
- const tools = generateMcpTools(store);
122
+
123
+ // Tag-scope confidentiality (security review): when the session is
124
+ // tag-scoped, build an expand-visibility predicate so `query-notes`'s
125
+ // `expand_links` inlining can't embed out-of-scope note content. The
126
+ // predicate reads from a SHARED holder that `applyTagScopeWrappers`
127
+ // populates with the resolved allowlist before core's execute runs the
128
+ // (synchronous) expansion — so by the time core calls `isVisible(note)`
129
+ // the allowlist is ready. Core stays scope-unaware: it only receives the
130
+ // plain closure. Unscoped sessions pass no predicate (unchanged path).
131
+ const scoped = Boolean(auth?.scoped_tags && auth.scoped_tags.length > 0);
132
+ const allowedHolder: { value: Set<string> | null } = { value: null };
133
+ const rawTags = scoped ? auth!.scoped_tags : null;
134
+ const expandVisibility = scoped
135
+ ? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
136
+ : undefined;
137
+
138
+ const tools = generateMcpTools(
139
+ store,
140
+ expandVisibility ? { expandVisibility } : undefined,
141
+ );
121
142
 
122
143
  overrideVaultInfo(tools, vaultName, auth);
123
144
  applyTagDependencyGuards(tools, vaultName);
124
- applyTagScopeWrappers(tools, vaultName, auth);
145
+ applyTagScopeWrappers(tools, vaultName, auth, allowedHolder);
125
146
 
126
147
  // manage-token is server-only (needs token-store + auth context), so it
127
148
  // lives here rather than in core. Always appended to the surface; the
@@ -181,6 +202,7 @@ function applyTagScopeWrappers(
181
202
  tools: McpToolDef[],
182
203
  vaultName: string,
183
204
  auth: AuthResult | undefined,
205
+ allowedHolder?: { value: Set<string> | null },
184
206
  ): void {
185
207
  if (!auth || !auth.scoped_tags || auth.scoped_tags.length === 0) return;
186
208
  const store = getVaultStore(vaultName);
@@ -188,12 +210,40 @@ function applyTagScopeWrappers(
188
210
  let allowedPromise: Promise<Set<string> | null> | null = null;
189
211
  const getAllowed = (): Promise<Set<string> | null> => {
190
212
  if (!allowedPromise) {
191
- allowedPromise = expandTokenTagScope(store, auth.scoped_tags);
213
+ allowedPromise = expandTokenTagScope(store, auth.scoped_tags).then((a) => {
214
+ // Publish the resolved allowlist into the shared holder so the
215
+ // expand-visibility predicate (wired in generateScopedMcpTools and
216
+ // baked into the query-notes expand context) sees the same set.
217
+ // The query-notes wrapper awaits getAllowed() before calling the
218
+ // core execute that runs expansion, so the holder is populated in
219
+ // time. Security review: closes the expand_links content leak.
220
+ if (allowedHolder) allowedHolder.value = a;
221
+ return a;
222
+ });
192
223
  }
193
224
  return allowedPromise;
194
225
  };
195
226
  const rawTags = auth.scoped_tags;
196
227
 
228
+ // Scrub a returned note's hydrated `links` array (present when the caller
229
+ // set `include_links`) so out-of-scope NEIGHBOR summaries (id/path/tags)
230
+ // don't leak — symmetric with the REST `include_links` fix. Mutates in
231
+ // place and returns the note for chaining. No-op when `links` is absent.
232
+ //
233
+ // Ordering invariant: reading `allowedHolder.value` here is safe ONLY
234
+ // because every wrapper that calls scrubNoteLinks first does
235
+ // `await getAllowed()` (which populates the holder) before `orig(params)`
236
+ // and before this scrub runs. So by the time we read `holder.value` it is
237
+ // the resolved allowlist, never the initial `null`. The `?? null` fallback
238
+ // is the unscoped/holder-absent path; `filterHydratedLinksByTagScope` then
239
+ // keys off `rawTags` (non-null here) for the actual scope check.
240
+ const scrubNoteLinks = (n: any): any => {
241
+ if (n && Array.isArray(n.links)) {
242
+ n.links = filterHydratedLinksByTagScope(n.links, allowedHolder?.value ?? null, rawTags);
243
+ }
244
+ return n;
245
+ };
246
+
197
247
  wrapReadTool(tools, "query-notes", async (orig, params) => {
198
248
  const allowed = await getAllowed();
199
249
  const result = await orig(params);
@@ -203,7 +253,9 @@ function applyTagScopeWrappers(
203
253
  // - `{notes, next_cursor}` (cursor mode, vault#313)
204
254
  // - `{...note}` with `id`+`tags` (single-note by id)
205
255
  if (Array.isArray(result)) {
206
- return result.filter((n: any) => noteWithinTagScope(n, allowed, rawTags));
256
+ return result
257
+ .filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
258
+ .map(scrubNoteLinks);
207
259
  }
208
260
  if (
209
261
  result &&
@@ -214,13 +266,15 @@ function applyTagScopeWrappers(
214
266
  ) {
215
267
  const r = result as { notes: any[]; next_cursor: string | null };
216
268
  return {
217
- notes: r.notes.filter((n: any) => noteWithinTagScope(n, allowed, rawTags)),
269
+ notes: r.notes
270
+ .filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
271
+ .map(scrubNoteLinks),
218
272
  next_cursor: r.next_cursor,
219
273
  };
220
274
  }
221
275
  if (result && typeof result === "object" && "id" in result && "tags" in result) {
222
276
  return noteWithinTagScope(result as any, allowed, rawTags)
223
- ? result
277
+ ? scrubNoteLinks(result)
224
278
  : { error: "Note not found", id: (result as any).id };
225
279
  }
226
280
  return result;