@openparachute/vault 0.2.4 → 0.3.0-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.
Files changed (98) hide show
  1. package/.claude/settings.local.json +2 -25
  2. package/CHANGELOG.md +64 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +591 -19
  6. package/core/src/indexed-fields.test.ts +285 -0
  7. package/core/src/indexed-fields.ts +238 -0
  8. package/core/src/mcp.ts +127 -6
  9. package/core/src/notes.ts +153 -11
  10. package/core/src/query-operators.ts +174 -0
  11. package/core/src/schema.ts +69 -2
  12. package/core/src/store.ts +92 -0
  13. package/core/src/tag-schemas.ts +5 -0
  14. package/core/src/types.ts +28 -1
  15. package/docs/HTTP_API.md +105 -1
  16. package/package/package.json +32 -0
  17. package/package.json +2 -2
  18. package/src/auth.test.ts +83 -114
  19. package/src/auth.ts +68 -6
  20. package/src/backup-launchd.ts +1 -1
  21. package/src/backup.test.ts +1 -1
  22. package/src/backup.ts +18 -17
  23. package/src/cli.ts +179 -121
  24. package/src/config-triggers.test.ts +49 -0
  25. package/src/config.test.ts +317 -2
  26. package/src/config.ts +420 -40
  27. package/src/context.test.ts +136 -0
  28. package/src/context.ts +115 -0
  29. package/src/daemon.ts +17 -16
  30. package/src/doctor.test.ts +9 -7
  31. package/src/launchd.test.ts +1 -1
  32. package/src/launchd.ts +6 -6
  33. package/src/mcp-http.ts +75 -21
  34. package/src/mcp-install.test.ts +125 -0
  35. package/src/mcp-install.ts +60 -0
  36. package/src/mcp-tools.ts +34 -96
  37. package/src/module-config.ts +109 -0
  38. package/src/oauth.test.ts +345 -57
  39. package/src/oauth.ts +155 -35
  40. package/src/published.test.ts +2 -2
  41. package/src/routes.ts +209 -33
  42. package/src/routing.test.ts +817 -300
  43. package/src/routing.ts +204 -202
  44. package/src/scopes.test.ts +136 -0
  45. package/src/scopes.ts +105 -0
  46. package/src/scribe-env.test.ts +49 -0
  47. package/src/scribe-env.ts +33 -0
  48. package/src/server.ts +57 -5
  49. package/src/services-manifest.test.ts +140 -0
  50. package/src/services-manifest.ts +99 -0
  51. package/src/systemd.ts +3 -3
  52. package/src/token-store.ts +42 -9
  53. package/src/transcription-worker.test.ts +583 -0
  54. package/src/transcription-worker.ts +346 -0
  55. package/src/triggers.test.ts +191 -1
  56. package/src/triggers.ts +17 -2
  57. package/src/vault.test.ts +693 -77
  58. package/src/version.test.ts +1 -1
  59. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  60. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  61. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  62. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  63. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  64. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  65. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  66. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  67. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  68. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  69. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  70. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  71. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  72. package/religions-abrahamic-filter.png +0 -0
  73. package/religions-buddhism-v2.png +0 -0
  74. package/religions-buddhism.png +0 -0
  75. package/religions-final.png +0 -0
  76. package/religions-v1.png +0 -0
  77. package/religions-v2.png +0 -0
  78. package/religions-zen.png +0 -0
  79. package/web/README.md +0 -73
  80. package/web/bun.lock +0 -827
  81. package/web/eslint.config.js +0 -23
  82. package/web/index.html +0 -15
  83. package/web/package.json +0 -36
  84. package/web/public/favicon.svg +0 -1
  85. package/web/public/icons.svg +0 -24
  86. package/web/src/App.tsx +0 -149
  87. package/web/src/Graph.tsx +0 -200
  88. package/web/src/NoteView.tsx +0 -155
  89. package/web/src/Sidebar.tsx +0 -186
  90. package/web/src/api.ts +0 -21
  91. package/web/src/index.css +0 -50
  92. package/web/src/main.tsx +0 -10
  93. package/web/src/types.ts +0 -37
  94. package/web/src/utils.ts +0 -107
  95. package/web/tsconfig.app.json +0 -25
  96. package/web/tsconfig.json +0 -7
  97. package/web/tsconfig.node.json +0 -24
  98. package/web/vite.config.ts +0 -16
package/src/oauth.ts CHANGED
@@ -4,10 +4,10 @@
4
4
  * Implements the subset of OAuth 2.1 needed for MCP clients (Claude Web,
5
5
  * Claude Desktop, etc.) to connect via the standard browser-based flow:
6
6
  *
7
- * 1. Dynamic Client Registration (RFC 7591) — POST /oauth/register
8
- * 2. Authorization endpoint (PKCE required) — GET/POST /oauth/authorize
9
- * 3. Token endpoint (code exchange) — POST /oauth/token
10
- * 4. Discovery endpoints — GET /.well-known/*
7
+ * 1. Dynamic Client Registration (RFC 7591) — POST /vault/<name>/oauth/register
8
+ * 2. Authorization endpoint (PKCE required) — GET/POST /vault/<name>/oauth/authorize
9
+ * 3. Token endpoint (code exchange) — POST /vault/<name>/oauth/token
10
+ * 4. Discovery endpoints — GET /vault/<name>/.well-known/*
11
11
  *
12
12
  * The flow produces a standard `pvt_` token stored in the vault's tokens table.
13
13
  * After the OAuth handshake, all requests use the same Bearer token auth path.
@@ -19,6 +19,8 @@ import { generateToken, createToken, resolveToken } from "./token-store.ts";
19
19
  import type { TokenPermission } from "./token-store.ts";
20
20
  import { verifyOwnerPassword, authorizeRateLimit, type RateLimiter } from "./owner-auth.ts";
21
21
  import { verifyTotpCode, verifyAndConsumeBackupCode } from "./two-factor.ts";
22
+ import { readManifest, ServicesManifestError } from "./services-manifest.ts";
23
+ import { legacyPermissionToScopes, SCOPE_READ, serializeScopes } from "./scopes.ts";
22
24
 
23
25
  /** Options for handleAuthorizePost. */
24
26
  export interface AuthorizePostOptions {
@@ -63,6 +65,96 @@ export function getBaseUrl(req: Request): string {
63
65
  return url.origin;
64
66
  }
65
67
 
68
+ /**
69
+ * Public origin the client reached vault through. When `PARACHUTE_HUB_ORIGIN`
70
+ * is set AND matches the incoming request's base URL, returns the hub; else
71
+ * returns the request base. This is the RFC 8414 compliance hinge: discovery
72
+ * metadata's `issuer`, token `iss` claims, and the service catalog all stem
73
+ * from this, so the issuer view is always self-consistent with the origin the
74
+ * client is actually talking to.
75
+ */
76
+ function resolvePublicOrigin(req: Request): string {
77
+ const hub = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
78
+ const base = getBaseUrl(req);
79
+ return hub && base === hub ? hub : base;
80
+ }
81
+
82
+ /**
83
+ * OAuth endpoint coordinates. Hub-rooted when the request came in through the
84
+ * hub origin (`PARACHUTE_HUB_ORIGIN` set AND matches the incoming base URL),
85
+ * vault-path-rooted otherwise. The same vault exposes both views concurrently:
86
+ * a loopback client gets `issuer = http://127.0.0.1:<port>/vault/<name>`; a
87
+ * client reaching vault via the hub reverse proxy gets `issuer = <hub>`.
88
+ *
89
+ * This is how vault stays RFC 8414 compliant while a single process serves
90
+ * both origins — discovery always returns the issuer matching the client's
91
+ * origin.
92
+ */
93
+ export function resolveOAuthCoordinates(
94
+ req: Request,
95
+ vaultName: string,
96
+ ): {
97
+ issuer: string;
98
+ authorizationEndpoint: string;
99
+ tokenEndpoint: string;
100
+ registrationEndpoint: string;
101
+ } {
102
+ const origin = resolvePublicOrigin(req);
103
+ const hub = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
104
+ if (hub && origin === hub) {
105
+ return {
106
+ issuer: hub,
107
+ authorizationEndpoint: `${hub}/oauth/authorize`,
108
+ tokenEndpoint: `${hub}/oauth/token`,
109
+ registrationEndpoint: `${hub}/oauth/register`,
110
+ };
111
+ }
112
+ const prefix = `/vault/${vaultName}`;
113
+ return {
114
+ issuer: `${origin}${prefix}`,
115
+ authorizationEndpoint: `${origin}${prefix}/oauth/authorize`,
116
+ tokenEndpoint: `${origin}${prefix}/oauth/token`,
117
+ registrationEndpoint: `${origin}${prefix}/oauth/register`,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Ecosystem service catalog for the token response (Phase 1 of the
123
+ * hub-as-OAuth-issuer design). Reads `~/.parachute/services.json` — the same
124
+ * manifest the CLI maintains — and rewrites each entry's canonical path into
125
+ * an absolute URL rooted at the origin the client reached vault through. A
126
+ * client that came in via the hub gets hub-rooted URLs; a loopback client
127
+ * gets loopback URLs. Same vault, same manifest, origin-consistent.
128
+ *
129
+ * Failure to read the manifest is non-fatal: we log and return an empty
130
+ * catalog rather than refusing to issue the token. The token response shape
131
+ * is additive — clients that don't expect `services` ignore it.
132
+ */
133
+ export function buildServiceCatalog(
134
+ req: Request,
135
+ ): Record<string, { url: string; version: string }> {
136
+ let entries: ReturnType<typeof readManifest>["services"];
137
+ try {
138
+ entries = readManifest().services;
139
+ } catch (err) {
140
+ if (err instanceof ServicesManifestError) {
141
+ console.warn(`[parachute-vault] services.json unreadable: ${err.message}`);
142
+ return {};
143
+ }
144
+ throw err;
145
+ }
146
+ const origin = resolvePublicOrigin(req);
147
+ const catalog: Record<string, { url: string; version: string }> = {};
148
+ for (const entry of entries) {
149
+ const path = entry.paths[0] ?? "/";
150
+ catalog[entry.name] = {
151
+ url: `${origin}${path}`,
152
+ version: entry.version,
153
+ };
154
+ }
155
+ return catalog;
156
+ }
157
+
66
158
  function escapeHtml(s: string): string {
67
159
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
68
160
  }
@@ -74,49 +166,61 @@ function escapeHtml(s: string): string {
74
166
  /**
75
167
  * OAuth 2.0 Protected Resource Metadata (RFC 9728).
76
168
  *
77
- * @param mcpPath — the resource URL (e.g. `/mcp` or `/vaults/X/mcp`).
78
- * @param authServerPrefix path prefix for the authorization server issuer
79
- * (e.g. `""` for global, `/vaults/X` for scoped).
80
- * The client discovers the AS metadata at
81
- * `{base}{prefix}/.well-known/oauth-authorization-server`.
169
+ * @param vaultName — the vault whose MCP endpoint is the protected resource.
170
+ * The metadata advertises `resource: {base}/vault/{name}/mcp`
171
+ * and the vault's authorization server at
172
+ * `{base}/vault/{name}`. Clients discover the AS metadata
173
+ * at `{base}/vault/{name}/.well-known/oauth-authorization-server`.
82
174
  */
83
- export function handleProtectedResource(
84
- req: Request,
85
- mcpPath = "/mcp",
86
- authServerPrefix = "",
87
- ): Response {
175
+ export function handleProtectedResource(req: Request, vaultName: string): Response {
176
+ const { issuer } = resolveOAuthCoordinates(req, vaultName);
88
177
  const base = getBaseUrl(req);
178
+ const prefix = `/vault/${vaultName}`;
89
179
  return Response.json({
90
- resource: `${base}${mcpPath}`,
91
- authorization_servers: [`${base}${authServerPrefix}`],
92
- scopes_supported: ["full", "read"],
180
+ resource: `${base}${prefix}/mcp`,
181
+ // `authorization_servers` points clients at the AS metadata doc. When the
182
+ // hub is the issuer (Phase 0), the AS metadata still lives on the vault
183
+ // itself — it's the document that tells clients where the hub endpoints
184
+ // are. So we use the issuer as the AS locator when set, otherwise the
185
+ // vault origin.
186
+ authorization_servers: [issuer],
187
+ scopes_supported: SCOPES_SUPPORTED,
93
188
  bearer_methods_supported: ["header"],
94
189
  });
95
190
  }
96
191
 
97
192
  /**
98
- * OAuth 2.0 Authorization Server Metadata (RFC 8414).
99
- *
100
- * @param vaultName when provided, returns vault-scoped endpoints
101
- * (`/vaults/<name>/oauth/*`) and issuer. Tokens minted
102
- * via these endpoints are scoped to the named vault's DB.
193
+ * OAuth 2.0 Authorization Server Metadata (RFC 8414). Endpoint URLs and
194
+ * `issuer` honor `PARACHUTE_HUB_ORIGIN` when set — see
195
+ * `resolveOAuthCoordinates` for the hub-vs-standalone contract.
103
196
  */
104
- export function handleAuthorizationServer(req: Request, vaultName?: string): Response {
105
- const base = getBaseUrl(req);
106
- const prefix = vaultName ? `/vaults/${vaultName}` : "";
197
+ export function handleAuthorizationServer(req: Request, vaultName: string): Response {
198
+ const coord = resolveOAuthCoordinates(req, vaultName);
107
199
  return Response.json({
108
- issuer: `${base}${prefix}`,
109
- authorization_endpoint: `${base}${prefix}/oauth/authorize`,
110
- token_endpoint: `${base}${prefix}/oauth/token`,
111
- registration_endpoint: `${base}${prefix}/oauth/register`,
200
+ issuer: coord.issuer,
201
+ authorization_endpoint: coord.authorizationEndpoint,
202
+ token_endpoint: coord.tokenEndpoint,
203
+ registration_endpoint: coord.registrationEndpoint,
112
204
  response_types_supported: ["code"],
113
205
  code_challenge_methods_supported: ["S256"],
114
206
  grant_types_supported: ["authorization_code"],
115
207
  token_endpoint_auth_methods_supported: ["none"],
116
- scopes_supported: ["full", "read"],
208
+ scopes_supported: SCOPES_SUPPORTED,
117
209
  });
118
210
  }
119
211
 
212
+ /**
213
+ * Scopes published in OAuth discovery. Phase 2 enforces these at request time
214
+ * (`vault:admin` ⊇ `vault:write` ⊇ `vault:read`). `vault:<name>:*` refinements
215
+ * are documented as future shape; the scope parser accepts them as synonyms
216
+ * for `vault:*` today.
217
+ *
218
+ * Legacy `full`/`read` remain in the list for back-compat with 0.2.x clients
219
+ * that hardcoded those names — they're translated into `vault:*` scopes on the
220
+ * way in and out.
221
+ */
222
+ const SCOPES_SUPPORTED = ["vault:read", "vault:write", "vault:admin", "full", "read"];
223
+
120
224
  // ---------------------------------------------------------------------------
121
225
  // Dynamic Client Registration (RFC 7591)
122
226
  // ---------------------------------------------------------------------------
@@ -486,8 +590,8 @@ export async function handleToken(
486
590
  }
487
591
 
488
592
  // Validate the code was issued for the same vault this token endpoint
489
- // serves. Without this, a code issued under /vaults/A/oauth/authorize
490
- // could be presented to /vaults/B/oauth/token and the token would be
593
+ // serves. Without this, a code issued under /vault/A/oauth/authorize
594
+ // could be presented to /vault/B/oauth/token and the token would be
491
595
  // minted into B's DB — privilege escalation across vault boundaries.
492
596
  if (authCode.vault_name !== vaultName) {
493
597
  return Response.json({ error: "invalid_grant", error_description: "vault mismatch" }, { status: 400 });
@@ -506,19 +610,35 @@ export async function handleToken(
506
610
  // Mark code as used
507
611
  db.prepare("UPDATE oauth_codes SET used = 1 WHERE code = ?").run(code);
508
612
 
509
- // Create a real pvt_ token
613
+ // Translate the consent-time selected scope into both the legacy permission
614
+ // column and the OAuth-standard scope list we now persist on the token row.
615
+ // The consent page only offers read vs full today; full becomes the
616
+ // admin-inheriting scope set so hub admin operations keep working.
510
617
  const permission: TokenPermission = authCode.scope === "read" ? "read" : "full";
618
+ const scopes = legacyPermissionToScopes(permission);
619
+ const scopeString = serializeScopes(scopes);
620
+
511
621
  const { fullToken } = generateToken();
512
622
  createToken(db, fullToken, {
513
623
  label: `oauth:${clientId.slice(0, 8)}`,
514
624
  permission,
625
+ scopes,
515
626
  });
516
627
 
628
+ const { issuer } = resolveOAuthCoordinates(req, vaultName);
517
629
  return Response.json({
518
630
  access_token: fullToken,
519
631
  token_type: "bearer",
520
- scope: permission,
632
+ // RFC 6749 §5.1: scope is an OAuth-standard whitespace-separated string.
633
+ scope: scopeString,
521
634
  vault: vaultName,
635
+ // Phase 0: identify the issuer so tokens validated by downstream services
636
+ // can pin trust on the hub-origin URL, not vault's internal address.
637
+ iss: issuer,
638
+ // Phase 1: bundle the ecosystem service catalog so Notes/clients learn
639
+ // all sibling service URLs from the token response and don't need to
640
+ // prompt the user for each one. Additive field — older clients ignore.
641
+ services: buildServiceCatalog(req),
522
642
  });
523
643
  }
524
644
 
@@ -708,7 +828,7 @@ function renderConsentPage(p: ConsentParams): string {
708
828
  <div class="card">
709
829
  <h1>Authorize access</h1>
710
830
  <p><span class="client">${escapeHtml(p.clientName)}</span> wants to access your <strong>${escapeHtml(p.vaultName)}</strong> vault.</p>
711
- <form method="POST" action="/oauth/authorize">
831
+ <form method="POST" action="">
712
832
  <input type="hidden" name="client_id" value="${escapeHtml(p.clientId)}">
713
833
  <input type="hidden" name="redirect_uri" value="${escapeHtml(p.redirectUri)}">
714
834
  <input type="hidden" name="code_challenge" value="${escapeHtml(p.codeChallenge)}">
@@ -25,8 +25,8 @@ describe("/public → /view redirect", () => {
25
25
  });
26
26
 
27
27
  it("works for vault-scoped paths", () => {
28
- const url = buildRedirectUrl("http://localhost:1940/vaults/work/public/abc?key=pvk_x", "abc", "/vaults/work");
29
- expect(url).toBe("http://localhost:1940/vaults/work/view/abc?key=pvk_x");
28
+ const url = buildRedirectUrl("http://localhost:1940/vault/work/public/abc?key=pvk_x", "abc", "/vault/work");
29
+ expect(url).toBe("http://localhost:1940/vault/work/view/abc?key=pvk_x");
30
30
  });
31
31
  });
32
32
 
package/src/routes.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  type ExpandMode,
25
25
  } from "../core/src/expand.ts";
26
26
  import { join, extname, normalize } from "path";
27
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
27
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
28
28
  import { vaultDir } from "./config.ts";
29
29
 
30
30
  // ---------------------------------------------------------------------------
@@ -40,6 +40,13 @@ function parseBool(val: string | null, defaultVal: boolean): boolean {
40
40
  return val === "true" || val === "1";
41
41
  }
42
42
 
43
+ function parseBoolOrUndef(val: string | null): boolean | undefined {
44
+ if (val === null) return undefined;
45
+ if (val === "true" || val === "1") return true;
46
+ if (val === "false" || val === "0") return false;
47
+ return undefined;
48
+ }
49
+
43
50
  function parseQuery(url: URL, key: string): string | null {
44
51
  return url.searchParams.get(key);
45
52
  }
@@ -124,6 +131,7 @@ export async function handleNotes(
124
131
  req: Request,
125
132
  store: Store,
126
133
  subpath: string,
134
+ vault?: string,
127
135
  ): Promise<Response> {
128
136
  const url = new URL(req.url);
129
137
  const method = req.method;
@@ -182,19 +190,33 @@ export async function handleNotes(
182
190
 
183
191
  // Structured query
184
192
  const tags = parseQueryList(url, "tag");
185
- let results: Note[] = await store.queryNotes({
186
- tags,
187
- tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
188
- excludeTags: parseQueryList(url, "exclude_tag"),
189
- path: parseQuery(url, "path") ?? undefined,
190
- pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
191
- metadata: undefined, // metadata filter not practical in query params
192
- dateFrom: parseQuery(url, "date_from") ?? undefined,
193
- dateTo: parseQuery(url, "date_to") ?? undefined,
194
- sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
195
- limit: parseInt10(parseQuery(url, "limit")) ?? 50,
196
- offset: parseInt10(parseQuery(url, "offset")),
197
- });
193
+ let results: Note[];
194
+ try {
195
+ results = await store.queryNotes({
196
+ tags,
197
+ tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
198
+ excludeTags: parseQueryList(url, "exclude_tag"),
199
+ hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
200
+ hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
201
+ path: parseQuery(url, "path") ?? undefined,
202
+ pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
203
+ metadata: undefined, // metadata filter not practical in query params
204
+ dateFrom: parseQuery(url, "date_from") ?? undefined,
205
+ dateTo: parseQuery(url, "date_to") ?? undefined,
206
+ sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
207
+ orderBy: parseQuery(url, "order_by") ?? undefined,
208
+ limit: parseInt10(parseQuery(url, "limit")) ?? 50,
209
+ offset: parseInt10(parseQuery(url, "offset")),
210
+ });
211
+ } catch (e: any) {
212
+ // QueryError (non-indexed order_by, unknown operator, ...) surfaces
213
+ // here. Duck-type on `name` + `code` — core is a separate module, so
214
+ // `instanceof` is fragile across bundling boundaries.
215
+ if (e && e.name === "QueryError") {
216
+ return json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400);
217
+ }
218
+ throw e;
219
+ }
198
220
 
199
221
  // Near-scope filter (graph neighborhood)
200
222
  const nearNoteId = parseQuery(url, "near[note_id]");
@@ -309,9 +331,32 @@ export async function handleNotes(
309
331
  if (method === "POST") {
310
332
  const note = await resolveNote(store, idOrPath);
311
333
  if (!note) return json({ error: "Not found" }, 404);
312
- const body = await req.json() as { path: string; mimeType: string };
334
+ const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
313
335
  if (!body.path || !body.mimeType) return json({ error: "path and mimeType are required" }, 400);
314
- return json(await store.addAttachment(note.id, body.path, body.mimeType), 201);
336
+
337
+ // `transcribe: true` asks the transcription worker to read this audio
338
+ // file and replace the note's content with the transcript. The caller
339
+ // is declaring "overwrite my current content when the transcript lands"
340
+ // — we persist that as `transcribe_stub: true` on the note so a later
341
+ // user edit (which clears the marker) can opt out before the worker
342
+ // runs.
343
+ const attMeta = body.transcribe
344
+ ? { transcribe_status: "pending" as const, transcribe_requested_at: new Date().toISOString() }
345
+ : undefined;
346
+
347
+ const attachment = await store.addAttachment(note.id, body.path, body.mimeType, attMeta);
348
+
349
+ if (body.transcribe) {
350
+ const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
351
+ if (noteMeta.transcribe_stub !== true) {
352
+ await store.updateNote(note.id, {
353
+ metadata: { ...noteMeta, transcribe_stub: true },
354
+ skipUpdatedAt: true,
355
+ });
356
+ }
357
+ }
358
+
359
+ return json(attachment, 201);
315
360
  }
316
361
  if (method === "GET") {
317
362
  const note = await resolveNote(store, idOrPath);
@@ -321,6 +366,29 @@ export async function handleNotes(
321
366
  return json({ error: "Method not allowed" }, 405);
322
367
  }
323
368
 
369
+ const attMatch = sub.match(/^\/attachments\/([^/]+)$/);
370
+ if (attMatch) {
371
+ const attId = decodeURIComponent(attMatch[1]!);
372
+ if (method === "DELETE") {
373
+ const note = await resolveNote(store, idOrPath);
374
+ if (!note) return json({ error: "Not found" }, 404);
375
+ const result = await store.deleteAttachment(note.id, attId);
376
+ if (!result.deleted) return json({ error: "Not found" }, 404);
377
+ // Unlink the storage file only if no other attachment still references
378
+ // the same path. Best-effort: the row is already gone, so a missing
379
+ // file or unlink error should not flip the DELETE to an error.
380
+ if (vault && result.path && result.orphaned) {
381
+ const assets = assetsDir(vault);
382
+ const filePath = normalize(join(assets, result.path));
383
+ if (filePath.startsWith(normalize(assets)) && existsSync(filePath)) {
384
+ try { unlinkSync(filePath); } catch {}
385
+ }
386
+ }
387
+ return new Response(null, { status: 204 });
388
+ }
389
+ return json({ error: "Method not allowed" }, 405);
390
+ }
391
+
324
392
  if (sub !== "") return json({ error: "Not found" }, 404);
325
393
 
326
394
  // GET /notes/:idOrPath — single note
@@ -351,6 +419,24 @@ export async function handleNotes(
351
419
  if (!note) throw new NotFoundError(`Note not found: "${idOrPath}"`);
352
420
  const body = await req.json() as any;
353
421
 
422
+ // --- Safety-by-default: refuse mutations without a precondition ---
423
+ // Mirror the MCP tool: require `if_updated_at` unless the caller
424
+ // explicitly sets `force: true`. 428 Precondition Required is the
425
+ // RFC 6585 status for exactly this case.
426
+ if (body.if_updated_at === undefined && body.force !== true) {
427
+ return json(
428
+ {
429
+ error_type: "precondition_required",
430
+ error: "precondition_required",
431
+ message:
432
+ "update requires `if_updated_at` (the note's last-seen updated_at) or `force: true`.",
433
+ note_id: note.id,
434
+ path: note.path ?? null,
435
+ },
436
+ 428,
437
+ );
438
+ }
439
+
354
440
  // --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
355
441
  // The actual link deletions happen only after the core UPDATE succeeds,
356
442
  // so a conflict leaves the note untouched.
@@ -420,10 +506,16 @@ export async function handleNotes(
420
506
  if (e && e.code === "CONFLICT") {
421
507
  return json(
422
508
  {
423
- error: "conflict",
424
- message: e.message,
425
- note_id: e.note_id,
509
+ // New structured shape — what agents should key on.
510
+ error_type: "conflict",
426
511
  current_updated_at: e.current_updated_at ?? null,
512
+ your_updated_at: e.expected_updated_at,
513
+ path: e.note_path ?? null,
514
+ note_id: e.note_id,
515
+ message: e.message,
516
+ // Legacy fields — kept for the lens VaultConflictError shim and
517
+ // any other pre-launch callers. Safe to drop post-launch.
518
+ error: "conflict",
427
519
  expected_updated_at: e.expected_updated_at,
428
520
  },
429
521
  409,
@@ -445,7 +537,8 @@ export async function handleNotes(
445
537
  }
446
538
 
447
539
  // ---------------------------------------------------------------------------
448
- // Tags — GET/PUT/DELETE /api/tags[/:name]
540
+ // Tags — GET/PUT/DELETE /api/tags[/:name], POST /api/tags/merge,
541
+ // POST /api/tags/:name/rename
449
542
  // ---------------------------------------------------------------------------
450
543
 
451
544
  export async function handleTags(req: Request, store: Store, subpath = ""): Promise<Response> {
@@ -479,6 +572,54 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
479
572
  return json(tags);
480
573
  }
481
574
 
575
+ // POST /tags/merge — atomic multi-source merge into a target tag.
576
+ // Must come before the /:name matcher so "merge" isn't read as a tag name.
577
+ if (subpath === "/merge") {
578
+ if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
579
+ const body = (await req.json().catch(() => null)) as
580
+ | { sources?: unknown; target?: unknown }
581
+ | null;
582
+ if (!body) return json({ error: "Invalid JSON body" }, 400);
583
+ const sources = body.sources;
584
+ const target = body.target;
585
+ if (!Array.isArray(sources) || !sources.every((s) => typeof s === "string" && s.length > 0)) {
586
+ return json({ error: "sources must be a non-empty array of strings" }, 400);
587
+ }
588
+ if (typeof target !== "string" || target.length === 0) {
589
+ return json({ error: "target must be a non-empty string" }, 400);
590
+ }
591
+ const result = await store.mergeTags(sources, target);
592
+ return json(result);
593
+ }
594
+
595
+ // POST /tags/:name/rename — atomic rename across tags + note_tags + schema
596
+ const renameMatch = subpath.match(/^\/([^/]+)\/rename$/);
597
+ if (renameMatch) {
598
+ if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
599
+ const oldName = decodeURIComponent(renameMatch[1]);
600
+ const body = (await req.json().catch(() => null)) as { new_name?: unknown } | null;
601
+ if (!body) return json({ error: "Invalid JSON body" }, 400);
602
+ const newName = body.new_name;
603
+ if (typeof newName !== "string" || newName.length === 0) {
604
+ return json({ error: "new_name must be a non-empty string" }, 400);
605
+ }
606
+ const result = await store.renameTag(oldName, newName);
607
+ if ("error" in result) {
608
+ if (result.error === "not_found") return json({ error: "not_found", tag: oldName }, 404);
609
+ if (result.error === "target_exists") {
610
+ return json(
611
+ {
612
+ error: "target_exists",
613
+ target: newName,
614
+ message: "Target tag already exists; use POST /api/tags/merge to combine them.",
615
+ },
616
+ 409,
617
+ );
618
+ }
619
+ }
620
+ return json(result);
621
+ }
622
+
482
623
  // Routes with tag name
483
624
  const nameMatch = subpath.match(/^\/([^/]+)$/);
484
625
  if (!nameMatch) return json({ error: "Not found" }, 404);
@@ -550,19 +691,34 @@ export async function handleFindPath(req: Request, store: Store): Promise<Respon
550
691
  // Vault info — GET/PATCH /api/vault
551
692
  // ---------------------------------------------------------------------------
552
693
 
694
+ type VaultConfigLike = {
695
+ name: string;
696
+ description?: string;
697
+ audio_retention?: "keep" | "until_transcribed" | "never";
698
+ };
699
+
700
+ const VALID_AUDIO_RETENTION = ["keep", "until_transcribed", "never"] as const;
701
+
702
+ function vaultResponse(vaultConfig: VaultConfigLike): Record<string, unknown> {
703
+ return {
704
+ name: vaultConfig.name,
705
+ description: vaultConfig.description ?? null,
706
+ config: {
707
+ audio_retention: vaultConfig.audio_retention ?? "keep",
708
+ },
709
+ };
710
+ }
711
+
553
712
  export async function handleVault(
554
713
  req: Request,
555
714
  store: Store,
556
- vaultConfig: { name: string; description?: string },
557
- updateDescription?: (description: string) => void,
715
+ vaultConfig: VaultConfigLike,
716
+ persist?: () => void,
558
717
  ): Promise<Response> {
559
718
  const url = new URL(req.url);
560
719
 
561
720
  if (req.method === "GET") {
562
- const result: any = {
563
- name: vaultConfig.name,
564
- description: vaultConfig.description ?? null,
565
- };
721
+ const result: Record<string, unknown> = vaultResponse(vaultConfig);
566
722
  if (parseBool(parseQuery(url, "include_stats"), false)) {
567
723
  result.stats = await store.getVaultStats();
568
724
  }
@@ -570,14 +726,34 @@ export async function handleVault(
570
726
  }
571
727
 
572
728
  if (req.method === "PATCH") {
573
- const body = await req.json() as { description?: string };
574
- if (body.description !== undefined && updateDescription) {
575
- updateDescription(body.description);
729
+ const body = await req.json() as {
730
+ description?: string;
731
+ config?: { audio_retention?: string };
732
+ };
733
+ let dirty = false;
734
+
735
+ if (body.description !== undefined) {
736
+ vaultConfig.description = body.description;
737
+ dirty = true;
576
738
  }
577
- return json({
578
- name: vaultConfig.name,
579
- description: body.description ?? vaultConfig.description ?? null,
580
- });
739
+
740
+ if (body.config?.audio_retention !== undefined) {
741
+ const v = body.config.audio_retention;
742
+ if (!VALID_AUDIO_RETENTION.includes(v as typeof VALID_AUDIO_RETENTION[number])) {
743
+ return json(
744
+ {
745
+ error: "invalid_audio_retention",
746
+ message: `audio_retention must be one of: ${VALID_AUDIO_RETENTION.join(", ")}`,
747
+ },
748
+ 400,
749
+ );
750
+ }
751
+ vaultConfig.audio_retention = v as typeof VALID_AUDIO_RETENTION[number];
752
+ dirty = true;
753
+ }
754
+
755
+ if (dirty && persist) persist();
756
+ return json(vaultResponse(vaultConfig));
581
757
  }
582
758
 
583
759
  return json({ error: "Method not allowed" }, 405);