@openparachute/vault 0.2.4 → 0.3.0

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 (102) 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/hooks.ts +111 -3
  7. package/core/src/indexed-fields.test.ts +285 -0
  8. package/core/src/indexed-fields.ts +238 -0
  9. package/core/src/mcp.ts +127 -6
  10. package/core/src/notes.ts +153 -11
  11. package/core/src/query-operators.ts +174 -0
  12. package/core/src/schema.ts +69 -2
  13. package/core/src/store.ts +95 -1
  14. package/core/src/tag-schemas.ts +5 -0
  15. package/core/src/types.ts +28 -1
  16. package/docs/HTTP_API.md +105 -1
  17. package/docs/auth-model.md +340 -0
  18. package/package/package.json +32 -0
  19. package/package.json +2 -2
  20. package/src/auth.test.ts +83 -114
  21. package/src/auth.ts +68 -6
  22. package/src/backup-launchd.ts +1 -1
  23. package/src/backup.test.ts +1 -1
  24. package/src/backup.ts +18 -17
  25. package/src/bind.test.ts +28 -0
  26. package/src/bind.ts +19 -0
  27. package/src/cli.ts +228 -133
  28. package/src/config-triggers.test.ts +49 -0
  29. package/src/config.test.ts +317 -2
  30. package/src/config.ts +420 -40
  31. package/src/context.test.ts +136 -0
  32. package/src/context.ts +115 -0
  33. package/src/daemon.ts +17 -16
  34. package/src/doctor.test.ts +9 -7
  35. package/src/launchd.test.ts +1 -1
  36. package/src/launchd.ts +6 -6
  37. package/src/mcp-http.ts +75 -21
  38. package/src/mcp-install.test.ts +125 -0
  39. package/src/mcp-install.ts +60 -0
  40. package/src/mcp-tools.ts +34 -96
  41. package/src/module-config.ts +109 -0
  42. package/src/oauth.test.ts +345 -57
  43. package/src/oauth.ts +155 -35
  44. package/src/published.test.ts +2 -2
  45. package/src/routes.ts +209 -33
  46. package/src/routing.test.ts +817 -300
  47. package/src/routing.ts +204 -202
  48. package/src/scopes.test.ts +294 -0
  49. package/src/scopes.ts +253 -0
  50. package/src/scribe-env.test.ts +49 -0
  51. package/src/scribe-env.ts +33 -0
  52. package/src/server.ts +73 -9
  53. package/src/services-manifest.test.ts +140 -0
  54. package/src/services-manifest.ts +99 -0
  55. package/src/systemd.ts +3 -3
  56. package/src/token-store.ts +42 -9
  57. package/src/transcription-worker.test.ts +864 -0
  58. package/src/transcription-worker.ts +501 -0
  59. package/src/triggers.test.ts +191 -1
  60. package/src/triggers.ts +17 -2
  61. package/src/vault.test.ts +693 -77
  62. package/src/version.test.ts +1 -1
  63. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  64. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  65. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  66. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  67. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  68. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  69. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  70. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  71. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  72. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  73. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  74. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  75. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  76. package/religions-abrahamic-filter.png +0 -0
  77. package/religions-buddhism-v2.png +0 -0
  78. package/religions-buddhism.png +0 -0
  79. package/religions-final.png +0 -0
  80. package/religions-v1.png +0 -0
  81. package/religions-v2.png +0 -0
  82. package/religions-zen.png +0 -0
  83. package/web/README.md +0 -73
  84. package/web/bun.lock +0 -827
  85. package/web/eslint.config.js +0 -23
  86. package/web/index.html +0 -15
  87. package/web/package.json +0 -36
  88. package/web/public/favicon.svg +0 -1
  89. package/web/public/icons.svg +0 -24
  90. package/web/src/App.tsx +0 -149
  91. package/web/src/Graph.tsx +0 -200
  92. package/web/src/NoteView.tsx +0 -155
  93. package/web/src/Sidebar.tsx +0 -186
  94. package/web/src/api.ts +0 -21
  95. package/web/src/index.css +0 -50
  96. package/web/src/main.tsx +0 -10
  97. package/web/src/types.ts +0 -37
  98. package/web/src/utils.ts +0 -107
  99. package/web/tsconfig.app.json +0 -25
  100. package/web/tsconfig.json +0 -7
  101. package/web/tsconfig.node.json +0 -24
  102. package/web/vite.config.ts +0 -16
package/src/server.ts CHANGED
@@ -4,21 +4,27 @@
4
4
  *
5
5
  * Routes:
6
6
  * GET /health — health check
7
- * * /mcp — unified MCP (all vaults, vault param)
8
- * * /vaults/{name}/mcp — scoped MCP (single vault, no vault param)
9
7
  * GET /vaults — list vaults with metadata (authenticated)
10
8
  * GET /vaults/list — list vault names (public; disable via config.discovery)
11
- * * /vaults/{name}/api/... — per-vault REST API
9
+ * * /vault/{name}/mcp scoped MCP (per-vault session)
10
+ * * /vault/{name}/oauth/... — per-vault OAuth flow
11
+ * * /vault/{name}/.well-known/... — per-vault OAuth discovery
12
+ * * /vault/{name}/view/... — auth-aware HTML note view
13
+ * * /vault/{name}/api/... — per-vault REST API
12
14
  *
13
15
  * The request pipeline lives in ./routing.ts (exported for unit testing).
14
16
  */
15
17
 
16
18
  import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey } from "./config.ts";
17
19
  import { migrateVaultKeys } from "./token-store.ts";
18
- import { getVaultStore } from "./vault-store.ts";
20
+ import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
19
21
  import { defaultHookRegistry } from "../core/src/hooks.ts";
20
22
  import { registerTriggers } from "./triggers.ts";
21
23
  import { route } from "./routing.ts";
24
+ import { startTranscriptionWorker, registerTranscriptionHook, type TranscriptionWorker } from "./transcription-worker.ts";
25
+ import { assetsDir } from "./routes.ts";
26
+ import { resolveScribeAuthToken } from "./scribe-env.ts";
27
+ import { resolveBindHostname } from "./bind.ts";
22
28
 
23
29
  // Register webhook triggers from global config. Replaces the old hardcoded
24
30
  // tts-hook and transcription-hook with config-driven webhooks.
@@ -30,13 +36,67 @@ function registerConfiguredTriggers(): void {
30
36
  }
31
37
  registerTriggers(defaultHookRegistry, config.triggers);
32
38
  console.log(`[triggers] registered ${config.triggers.length} trigger(s)`);
39
+
40
+ // Soft-deprecation warning: if the dedicated transcription worker is
41
+ // enabled AND a trigger points at what looks like the same scribe endpoint,
42
+ // both will process the same attachments. The trigger's `missing_metadata`
43
+ // guard keeps it idempotent once the worker marks `transcript` on the
44
+ // attachment, but the noise is worth flagging.
45
+ if (process.env.SCRIBE_URL) {
46
+ const scribeHost = safeHost(process.env.SCRIBE_URL);
47
+ for (const t of config.triggers) {
48
+ if (t.action.send !== "attachment") continue;
49
+ if (scribeHost && safeHost(t.action.webhook) === scribeHost) {
50
+ console.warn(
51
+ `[triggers] "${t.name}" points at scribe (${t.action.webhook}) and the dedicated worker is also enabled; ` +
52
+ `these may double-fire. Prefer the dedicated worker for /v1/audio/transcriptions and remove this trigger.`,
53
+ );
54
+ }
55
+ }
56
+ }
33
57
  }
34
58
 
35
- registerConfiguredTriggers();
59
+ function safeHost(url: string): string | null {
60
+ try { return new URL(url).host; } catch { return null; }
61
+ }
36
62
 
63
+ // Load .env before anything reads process.env — otherwise SCRIBE_URL and
64
+ // friends configured in ~/.parachute/vault/.env are invisible to the
65
+ // transcription-worker check and the trigger double-fire warning below.
37
66
  ensureConfigDirSync();
38
67
  loadEnvFile();
39
68
 
69
+ registerConfiguredTriggers();
70
+
71
+ /**
72
+ * Start the transcription worker if SCRIBE_URL is configured. The worker
73
+ * polls every vault for attachments with `metadata.transcribe_status = "pending"`
74
+ * and sends the audio to scribe. Absent SCRIBE_URL, the worker stays off
75
+ * — `{transcribe: true}` uploads still enqueue, they just wait.
76
+ */
77
+ let transcriptionWorker: TranscriptionWorker | null = null;
78
+ if (process.env.SCRIBE_URL) {
79
+ transcriptionWorker = startTranscriptionWorker({
80
+ vaultList: () => listVaults(),
81
+ getStore: (name) => getVaultStore(name),
82
+ scribeUrl: process.env.SCRIBE_URL,
83
+ scribeToken: resolveScribeAuthToken(),
84
+ resolveAssetsDir: (vault) => assetsDir(vault),
85
+ getAudioRetention: (vault) => readVaultConfig(vault)?.audio_retention ?? "keep",
86
+ getContextPredicates: (vault) => readVaultConfig(vault)?.transcription?.context,
87
+ });
88
+ // Event-driven hot path — the `attachment:created` hook fires the worker
89
+ // in a microtask instead of waiting for the 30s sweep.
90
+ registerTranscriptionHook(
91
+ defaultHookRegistry,
92
+ transcriptionWorker,
93
+ (store) => getVaultNameForStore(store as never),
94
+ );
95
+ console.log(`[transcribe] worker started → ${process.env.SCRIBE_URL}`);
96
+ } else {
97
+ console.log("[transcribe] worker disabled (set SCRIBE_URL to enable)");
98
+ }
99
+
40
100
  // Auto-init: create a default vault if none exist (first run in Docker)
41
101
  if (listVaults().length === 0) {
42
102
  const globalConfig = readGlobalConfig();
@@ -110,10 +170,11 @@ for (const vaultName of listVaults()) {
110
170
 
111
171
  const globalConfig = readGlobalConfig();
112
172
  const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PORT;
173
+ const hostname = resolveBindHostname();
113
174
 
114
175
  const server = Bun.serve({
115
176
  port,
116
- hostname: "0.0.0.0",
177
+ hostname,
117
178
  idleTimeout: 120, // seconds — webhook triggers can take a while
118
179
  async fetch(req, server) {
119
180
  const url = new URL(req.url);
@@ -159,18 +220,21 @@ const server = Bun.serve({
159
220
  },
160
221
  });
161
222
 
162
- console.log(`Parachute Vault server listening on http://0.0.0.0:${server.port}`);
223
+ console.log(`Parachute Vault server listening on http://${hostname}:${server.port}`);
163
224
 
164
225
  // Graceful shutdown — best-effort drain of in-flight note-mutation hooks.
165
226
  async function shutdown(signal: string): Promise<void> {
166
227
  console.log(`\n[${signal}] shutting down; in-flight hooks: ${defaultHookRegistry.inFlightCount}`);
167
228
  try {
168
229
  await Promise.race([
169
- defaultHookRegistry.drain(),
230
+ Promise.all([
231
+ defaultHookRegistry.drain(),
232
+ transcriptionWorker?.stop() ?? Promise.resolve(),
233
+ ]),
170
234
  new Promise<void>((resolve) => setTimeout(resolve, 5000)),
171
235
  ]);
172
236
  } catch (err) {
173
- console.error("[shutdown] hook drain error:", err);
237
+ console.error("[shutdown] drain error:", err);
174
238
  }
175
239
  process.exit(0);
176
240
  }
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ type ServiceEntry,
7
+ ServicesManifestError,
8
+ readManifest,
9
+ upsertService,
10
+ } from "./services-manifest.ts";
11
+
12
+ function tempPath(): { path: string; cleanup: () => void } {
13
+ const dir = mkdtempSync(join(tmpdir(), "pvault-manifest-"));
14
+ const path = join(dir, "services.json");
15
+ return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
16
+ }
17
+
18
+ const vault: ServiceEntry = {
19
+ name: "parachute-vault",
20
+ port: 1940,
21
+ paths: ["/"],
22
+ health: "/health",
23
+ version: "0.2.4",
24
+ };
25
+
26
+ const notes: ServiceEntry = {
27
+ name: "parachute-notes",
28
+ port: 5173,
29
+ paths: ["/notes"],
30
+ health: "/notes/health",
31
+ version: "0.0.1",
32
+ };
33
+
34
+ describe("services-manifest", () => {
35
+ test("readManifest returns empty when file missing", () => {
36
+ const { path, cleanup } = tempPath();
37
+ try {
38
+ expect(readManifest(path)).toEqual({ services: [] });
39
+ } finally {
40
+ cleanup();
41
+ }
42
+ });
43
+
44
+ test("upsertService creates the file if missing", () => {
45
+ const { path, cleanup } = tempPath();
46
+ try {
47
+ const m = upsertService(vault, path);
48
+ expect(m.services).toEqual([vault]);
49
+ expect(readManifest(path)).toEqual({ services: [vault] });
50
+ } finally {
51
+ cleanup();
52
+ }
53
+ });
54
+
55
+ test("upsertService updates by name and never duplicates", () => {
56
+ const { path, cleanup } = tempPath();
57
+ try {
58
+ upsertService(vault, path);
59
+ const updated = { ...vault, version: "0.3.0", port: 1941 };
60
+ upsertService(updated, path);
61
+ const m = readManifest(path);
62
+ expect(m.services).toHaveLength(1);
63
+ expect(m.services[0]).toEqual(updated);
64
+ } finally {
65
+ cleanup();
66
+ }
67
+ });
68
+
69
+ test("upsertService preserves entries written by other services", () => {
70
+ const { path, cleanup } = tempPath();
71
+ try {
72
+ writeFileSync(path, `${JSON.stringify({ services: [notes] }, null, 2)}\n`);
73
+ upsertService(vault, path);
74
+ const m = readManifest(path);
75
+ expect(m.services).toHaveLength(2);
76
+ expect(m.services.find((s) => s.name === "parachute-notes")).toEqual(notes);
77
+ expect(m.services.find((s) => s.name === "parachute-vault")).toEqual(vault);
78
+ } finally {
79
+ cleanup();
80
+ }
81
+ });
82
+
83
+ test("upsertService writes pretty-printed JSON with trailing newline", () => {
84
+ const { path, cleanup } = tempPath();
85
+ try {
86
+ upsertService(vault, path);
87
+ const raw = readFileSync(path, "utf8");
88
+ expect(raw).toBe(`${JSON.stringify({ services: [vault] }, null, 2)}\n`);
89
+ } finally {
90
+ cleanup();
91
+ }
92
+ });
93
+
94
+ test("readManifest throws ServicesManifestError on malformed JSON", () => {
95
+ const { path, cleanup } = tempPath();
96
+ try {
97
+ writeFileSync(path, "{ not json");
98
+ expect(() => readManifest(path)).toThrow(ServicesManifestError);
99
+ } finally {
100
+ cleanup();
101
+ }
102
+ });
103
+
104
+ test("readManifest throws ServicesManifestError on schema violation", () => {
105
+ const { path, cleanup } = tempPath();
106
+ try {
107
+ writeFileSync(path, JSON.stringify({ services: [{ name: "x" }] }));
108
+ expect(() => readManifest(path)).toThrow(ServicesManifestError);
109
+ } finally {
110
+ cleanup();
111
+ }
112
+ });
113
+
114
+ test("upsertService rejects invalid entry without touching the file", () => {
115
+ const { path, cleanup } = tempPath();
116
+ try {
117
+ writeFileSync(path, `${JSON.stringify({ services: [notes] }, null, 2)}\n`);
118
+ const bad = { ...vault, port: -1 };
119
+ expect(() => upsertService(bad as ServiceEntry, path)).toThrow(ServicesManifestError);
120
+ expect(readManifest(path)).toEqual({ services: [notes] });
121
+ } finally {
122
+ cleanup();
123
+ }
124
+ });
125
+
126
+ test("default path honors PARACHUTE_HOME set at runtime", () => {
127
+ const dir = mkdtempSync(join(tmpdir(), "pvault-home-"));
128
+ const prior = process.env.PARACHUTE_HOME;
129
+ process.env.PARACHUTE_HOME = dir;
130
+ try {
131
+ upsertService(vault);
132
+ expect(readManifest()).toEqual({ services: [vault] });
133
+ expect(readManifest(join(dir, "services.json"))).toEqual({ services: [vault] });
134
+ } finally {
135
+ if (prior === undefined) delete process.env.PARACHUTE_HOME;
136
+ else process.env.PARACHUTE_HOME = prior;
137
+ rmSync(dir, { recursive: true, force: true });
138
+ }
139
+ });
140
+ });
@@ -0,0 +1,99 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ // Resolve per-call so `PARACHUTE_HOME` set at runtime (Docker, tests) is
6
+ // honored, matching the pattern in `config.ts`.
7
+ function servicesManifestPath(): string {
8
+ const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
9
+ return join(root, "services.json");
10
+ }
11
+
12
+ export interface ServiceEntry {
13
+ name: string;
14
+ port: number;
15
+ paths: string[];
16
+ health: string;
17
+ version: string;
18
+ }
19
+
20
+ export interface ServicesManifest {
21
+ services: ServiceEntry[];
22
+ }
23
+
24
+ export class ServicesManifestError extends Error {
25
+ override name = "ServicesManifestError";
26
+ }
27
+
28
+ function validateEntry(raw: unknown, where: string): ServiceEntry {
29
+ if (!raw || typeof raw !== "object") {
30
+ throw new ServicesManifestError(`${where}: expected object, got ${typeof raw}`);
31
+ }
32
+ const e = raw as Record<string, unknown>;
33
+ const { name, port, paths, health, version } = e;
34
+ if (typeof name !== "string" || name.length === 0) {
35
+ throw new ServicesManifestError(`${where}: "name" must be a non-empty string`);
36
+ }
37
+ if (typeof port !== "number" || !Number.isInteger(port) || port <= 0 || port > 65535) {
38
+ throw new ServicesManifestError(`${where}: "port" must be an integer 1..65535`);
39
+ }
40
+ if (!Array.isArray(paths) || paths.some((p) => typeof p !== "string")) {
41
+ throw new ServicesManifestError(`${where}: "paths" must be an array of strings`);
42
+ }
43
+ if (typeof health !== "string" || !health.startsWith("/")) {
44
+ throw new ServicesManifestError(`${where}: "health" must be a path starting with "/"`);
45
+ }
46
+ if (typeof version !== "string") {
47
+ throw new ServicesManifestError(`${where}: "version" must be a string`);
48
+ }
49
+ return { name, port, paths: paths as string[], health, version };
50
+ }
51
+
52
+ function validateManifest(raw: unknown, where: string): ServicesManifest {
53
+ if (!raw || typeof raw !== "object") {
54
+ throw new ServicesManifestError(`${where}: root must be an object`);
55
+ }
56
+ const services = (raw as Record<string, unknown>).services;
57
+ if (!Array.isArray(services)) {
58
+ throw new ServicesManifestError(`${where}: "services" must be an array`);
59
+ }
60
+ return {
61
+ services: services.map((s, i) => validateEntry(s, `${where} services[${i}]`)),
62
+ };
63
+ }
64
+
65
+ export function readManifest(path: string = servicesManifestPath()): ServicesManifest {
66
+ if (!existsSync(path)) return { services: [] };
67
+ let raw: unknown;
68
+ try {
69
+ raw = JSON.parse(readFileSync(path, "utf8"));
70
+ } catch (err) {
71
+ throw new ServicesManifestError(
72
+ `failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
73
+ );
74
+ }
75
+ return validateManifest(raw, path);
76
+ }
77
+
78
+ function writeManifest(manifest: ServicesManifest, path: string): void {
79
+ mkdirSync(dirname(path), { recursive: true });
80
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
81
+ writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`);
82
+ renameSync(tmp, path);
83
+ }
84
+
85
+ export function upsertService(
86
+ entry: ServiceEntry,
87
+ path: string = servicesManifestPath(),
88
+ ): ServicesManifest {
89
+ validateEntry(entry, "entry");
90
+ const current = readManifest(path);
91
+ const idx = current.services.findIndex((s) => s.name === entry.name);
92
+ if (idx >= 0) {
93
+ current.services[idx] = entry;
94
+ } else {
95
+ current.services.push(entry);
96
+ }
97
+ writeManifest(current, path);
98
+ return current;
99
+ }
package/src/systemd.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Linux systemd service management for the vault daemon.
3
3
  *
4
4
  * Installs a user-level systemd service (~/.config/systemd/user/).
5
- * Uses EnvironmentFile to load ~/.parachute/.env.
5
+ * Uses EnvironmentFile to load ~/.parachute/vault/.env.
6
6
  */
7
7
 
8
8
  import { homedir } from "os";
@@ -10,7 +10,7 @@ import { join } from "path";
10
10
  import { writeFile, mkdir, unlink } from "fs/promises";
11
11
  import { existsSync } from "fs";
12
12
  import { $ } from "bun";
13
- import { CONFIG_DIR, LOG_PATH, ERR_PATH } from "./config.ts";
13
+ import { VAULT_HOME, LOG_PATH, ERR_PATH } from "./config.ts";
14
14
  import { WRAPPER_PATH, writeDaemonWrapper } from "./daemon.ts";
15
15
 
16
16
  const SERVICE_NAME = "parachute-vault";
@@ -29,7 +29,7 @@ After=network.target
29
29
 
30
30
  [Service]
31
31
  Type=simple
32
- WorkingDirectory=${CONFIG_DIR}
32
+ WorkingDirectory=${VAULT_HOME}
33
33
  ExecStart=/bin/bash ${WRAPPER_PATH}
34
34
  Restart=on-failure
35
35
  RestartSec=5
@@ -16,6 +16,11 @@
16
16
  import { Database } from "bun:sqlite";
17
17
  import crypto from "node:crypto";
18
18
  import { hashKey } from "./config.ts";
19
+ import { legacyPermissionToScopes, parseScopes, serializeScopes } from "./scopes.ts";
20
+
21
+ function scopesForMigratedPermission(permission: string): string {
22
+ return serializeScopes(legacyPermissionToScopes(permission));
23
+ }
19
24
 
20
25
  // ---------------------------------------------------------------------------
21
26
  // Types
@@ -47,6 +52,15 @@ export interface Token {
47
52
 
48
53
  export interface ResolvedToken {
49
54
  permission: TokenPermission;
55
+ /**
56
+ * Granted scopes, parsed from the token row's `scopes` column. Pre-v12
57
+ * tokens (where the column is NULL) fall back to the legacy permission
58
+ * → scopes mapping and `legacyDerived` is set true so callers can log
59
+ * a deprecation warning on first use.
60
+ */
61
+ scopes: string[];
62
+ /** True iff `scopes` was derived from the legacy `permission` column. */
63
+ legacyDerived: boolean;
50
64
  }
51
65
 
52
66
  // ---------------------------------------------------------------------------
@@ -65,6 +79,12 @@ export function createToken(
65
79
  opts: {
66
80
  label: string;
67
81
  permission?: TokenPermission;
82
+ /**
83
+ * Explicit OAuth-standard scopes to persist. If omitted, derived from
84
+ * `permission` (read → [vault:read], anything else → [vault:read,
85
+ * vault:write, vault:admin]). Written as a whitespace-separated string.
86
+ */
87
+ scopes?: string[];
68
88
  /** @deprecated Written to DB but not enforced at runtime. */
69
89
  scope_tag?: string | null;
70
90
  /** @deprecated Written to DB but not enforced at runtime. */
@@ -75,14 +95,17 @@ export function createToken(
75
95
  const tokenHash = hashKey(fullToken);
76
96
  const now = new Date().toISOString();
77
97
  const permission = opts.permission ?? "full";
98
+ const scopes = opts.scopes ?? legacyPermissionToScopes(permission);
99
+ const scopesStr = serializeScopes(scopes);
78
100
 
79
101
  db.prepare(`
80
- INSERT INTO tokens (token_hash, label, permission, scope_tag, scope_path_prefix, expires_at, created_at)
81
- VALUES (?, ?, ?, ?, ?, ?, ?)
102
+ INSERT INTO tokens (token_hash, label, permission, scopes, scope_tag, scope_path_prefix, expires_at, created_at)
103
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
82
104
  `).run(
83
105
  tokenHash,
84
106
  opts.label,
85
107
  permission,
108
+ scopesStr,
86
109
  opts.scope_tag ?? null,
87
110
  opts.scope_path_prefix ?? null,
88
111
  opts.expires_at ?? null,
@@ -112,11 +135,12 @@ export function resolveToken(db: Database, providedToken: string): ResolvedToken
112
135
  const candidateHash = hashKey(providedToken);
113
136
 
114
137
  const row = db.prepare(`
115
- SELECT token_hash, permission, expires_at
138
+ SELECT token_hash, permission, scopes, expires_at
116
139
  FROM tokens WHERE token_hash = ?
117
140
  `).get(candidateHash) as {
118
141
  token_hash: string;
119
142
  permission: string;
143
+ scopes: string | null;
120
144
  expires_at: string | null;
121
145
  } | null;
122
146
 
@@ -131,7 +155,13 @@ export function resolveToken(db: Database, providedToken: string): ResolvedToken
131
155
  db.prepare("UPDATE tokens SET last_used_at = ? WHERE token_hash = ?")
132
156
  .run(new Date().toISOString(), row.token_hash);
133
157
 
134
- return { permission: normalizePermission(row.permission) };
158
+ const permission = normalizePermission(row.permission);
159
+ const parsed = parseScopes(row.scopes);
160
+ const hasVaultScope = parsed.some((s) => s.startsWith("vault:"));
161
+ const scopes = hasVaultScope ? parsed : legacyPermissionToScopes(permission);
162
+ const legacyDerived = !hasVaultScope;
163
+
164
+ return { permission, scopes, legacyDerived };
135
165
  }
136
166
 
137
167
  /**
@@ -203,13 +233,15 @@ export function migrateVaultKeys(
203
233
  for (const key of vaultKeys) {
204
234
  const exists = db.prepare("SELECT 1 FROM tokens WHERE token_hash = ?").get(key.key_hash);
205
235
  if (!exists) {
236
+ const permission = key.scope === "read" ? "read" : "full";
206
237
  db.prepare(`
207
- INSERT INTO tokens (token_hash, label, permission, created_at, last_used_at)
208
- VALUES (?, ?, ?, ?, ?)
238
+ INSERT INTO tokens (token_hash, label, permission, scopes, created_at, last_used_at)
239
+ VALUES (?, ?, ?, ?, ?, ?)
209
240
  `).run(
210
241
  key.key_hash,
211
242
  key.label,
212
- key.scope === "read" ? "read" : "full",
243
+ permission,
244
+ scopesForMigratedPermission(permission),
213
245
  key.created_at,
214
246
  key.last_used_at ?? null,
215
247
  );
@@ -223,12 +255,13 @@ export function migrateVaultKeys(
223
255
  const exists = db.prepare("SELECT 1 FROM tokens WHERE token_hash = ?").get(key.key_hash);
224
256
  if (!exists) {
225
257
  db.prepare(`
226
- INSERT INTO tokens (token_hash, label, permission, created_at, last_used_at)
227
- VALUES (?, ?, ?, ?, ?)
258
+ INSERT INTO tokens (token_hash, label, permission, scopes, created_at, last_used_at)
259
+ VALUES (?, ?, ?, ?, ?, ?)
228
260
  `).run(
229
261
  key.key_hash,
230
262
  key.label,
231
263
  "full",
264
+ scopesForMigratedPermission("full"),
232
265
  key.created_at,
233
266
  key.last_used_at ?? null,
234
267
  );