@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.
- package/.claude/settings.local.json +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/hooks.ts +111 -3
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +95 -1
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/docs/auth-model.md +340 -0
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/bind.test.ts +28 -0
- package/src/bind.ts +19 -0
- package/src/cli.ts +228 -133
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +294 -0
- package/src/scopes.ts +253 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +73 -9
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +864 -0
- package/src/transcription-worker.ts +501 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- 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
|
-
* * /
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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]
|
|
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 {
|
|
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=${
|
|
32
|
+
WorkingDirectory=${VAULT_HOME}
|
|
33
33
|
ExecStart=/bin/bash ${WRAPPER_PATH}
|
|
34
34
|
Restart=on-failure
|
|
35
35
|
RestartSec=5
|
package/src/token-store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
);
|