@openparachute/vault 0.2.3 → 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 (58) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/CHANGELOG.md +70 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +603 -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 +157 -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 +29 -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
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Unit tests for scope primitives — parse, match, inheritance, legacy
3
+ * permission fallback. Integration tests for scope enforcement at the
4
+ * HTTP boundary live in routing.test.ts + vault.test.ts.
5
+ */
6
+
7
+ import { describe, test, expect } from "bun:test";
8
+ import {
9
+ SCOPE_READ,
10
+ SCOPE_WRITE,
11
+ SCOPE_ADMIN,
12
+ parseScopes,
13
+ hasScope,
14
+ scopeForMethod,
15
+ legacyPermissionToScopes,
16
+ serializeScopes,
17
+ } from "./scopes.ts";
18
+
19
+ describe("parseScopes", () => {
20
+ test("returns [] for null or empty input", () => {
21
+ expect(parseScopes(null)).toEqual([]);
22
+ expect(parseScopes(undefined)).toEqual([]);
23
+ expect(parseScopes("")).toEqual([]);
24
+ expect(parseScopes(" ")).toEqual([]);
25
+ });
26
+
27
+ test("splits on whitespace and trims", () => {
28
+ expect(parseScopes("vault:read vault:write")).toEqual([SCOPE_READ, SCOPE_WRITE]);
29
+ expect(parseScopes(" vault:read vault:write ")).toEqual([SCOPE_READ, SCOPE_WRITE]);
30
+ expect(parseScopes("vault:read\tvault:write\nvault:admin")).toEqual([
31
+ SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN,
32
+ ]);
33
+ });
34
+
35
+ test("collapses vault:<name>:<verb> synonym to vault:<verb>", () => {
36
+ expect(parseScopes("vault:journal:read")).toEqual([SCOPE_READ]);
37
+ expect(parseScopes("vault:journal:write vault:work:admin")).toEqual([
38
+ SCOPE_WRITE, SCOPE_ADMIN,
39
+ ]);
40
+ });
41
+
42
+ test("preserves unrecognized scopes verbatim", () => {
43
+ expect(parseScopes("profile email")).toEqual(["profile", "email"]);
44
+ expect(parseScopes("vault:unknown:frob")).toEqual(["vault:unknown:frob"]);
45
+ });
46
+
47
+ test("empty name segment does NOT collapse (vault::read stays literal)", () => {
48
+ // Guard against a hand-crafted DB row with `vault::read` satisfying a
49
+ // `vault:read` check by accident. Only reachable via direct DB write,
50
+ // not API input, but the parser stays honest.
51
+ expect(parseScopes("vault::read")).toEqual(["vault::read"]);
52
+ expect(hasScope(parseScopes("vault::read"), SCOPE_READ)).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe("hasScope — inheritance admin ⊇ write ⊇ read", () => {
57
+ test("exact match succeeds", () => {
58
+ expect(hasScope([SCOPE_READ], SCOPE_READ)).toBe(true);
59
+ expect(hasScope([SCOPE_WRITE], SCOPE_WRITE)).toBe(true);
60
+ expect(hasScope([SCOPE_ADMIN], SCOPE_ADMIN)).toBe(true);
61
+ });
62
+
63
+ test("vault:write satisfies vault:read", () => {
64
+ expect(hasScope([SCOPE_WRITE], SCOPE_READ)).toBe(true);
65
+ });
66
+
67
+ test("vault:admin satisfies vault:read and vault:write", () => {
68
+ expect(hasScope([SCOPE_ADMIN], SCOPE_READ)).toBe(true);
69
+ expect(hasScope([SCOPE_ADMIN], SCOPE_WRITE)).toBe(true);
70
+ });
71
+
72
+ test("vault:read does NOT satisfy vault:write or vault:admin", () => {
73
+ expect(hasScope([SCOPE_READ], SCOPE_WRITE)).toBe(false);
74
+ expect(hasScope([SCOPE_READ], SCOPE_ADMIN)).toBe(false);
75
+ });
76
+
77
+ test("vault:write does NOT satisfy vault:admin", () => {
78
+ expect(hasScope([SCOPE_WRITE], SCOPE_ADMIN)).toBe(false);
79
+ });
80
+
81
+ test("empty granted list fails", () => {
82
+ expect(hasScope([], SCOPE_READ)).toBe(false);
83
+ expect(hasScope([], SCOPE_WRITE)).toBe(false);
84
+ expect(hasScope([], SCOPE_ADMIN)).toBe(false);
85
+ });
86
+
87
+ test("non-vault scopes require exact match — no inheritance", () => {
88
+ expect(hasScope(["profile"], "profile")).toBe(true);
89
+ expect(hasScope(["profile"], "email")).toBe(false);
90
+ expect(hasScope([SCOPE_ADMIN], "profile")).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe("scopeForMethod", () => {
95
+ test("read methods → vault:read", () => {
96
+ expect(scopeForMethod("GET")).toBe(SCOPE_READ);
97
+ expect(scopeForMethod("HEAD")).toBe(SCOPE_READ);
98
+ expect(scopeForMethod("OPTIONS")).toBe(SCOPE_READ);
99
+ expect(scopeForMethod("get")).toBe(SCOPE_READ); // case-insensitive
100
+ });
101
+
102
+ test("write methods → vault:write", () => {
103
+ expect(scopeForMethod("POST")).toBe(SCOPE_WRITE);
104
+ expect(scopeForMethod("PATCH")).toBe(SCOPE_WRITE);
105
+ expect(scopeForMethod("PUT")).toBe(SCOPE_WRITE);
106
+ expect(scopeForMethod("DELETE")).toBe(SCOPE_WRITE);
107
+ });
108
+
109
+ test("unknown method falls back to vault:write (default-deny)", () => {
110
+ expect(scopeForMethod("TRACE")).toBe(SCOPE_WRITE);
111
+ });
112
+ });
113
+
114
+ describe("legacyPermissionToScopes", () => {
115
+ test("'read' → [vault:read]", () => {
116
+ expect(legacyPermissionToScopes("read")).toEqual([SCOPE_READ]);
117
+ });
118
+
119
+ test("'full' and anything else → [read, write, admin]", () => {
120
+ expect(legacyPermissionToScopes("full")).toEqual([SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN]);
121
+ expect(legacyPermissionToScopes("admin")).toEqual([SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN]);
122
+ expect(legacyPermissionToScopes("write")).toEqual([SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN]);
123
+ });
124
+ });
125
+
126
+ describe("serializeScopes — round-trips with parseScopes", () => {
127
+ test("joins with spaces", () => {
128
+ expect(serializeScopes([SCOPE_READ, SCOPE_WRITE])).toBe("vault:read vault:write");
129
+ expect(serializeScopes([])).toBe("");
130
+ });
131
+
132
+ test("serialize then parse is the identity (for known scopes)", () => {
133
+ const scopes = [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN];
134
+ expect(parseScopes(serializeScopes(scopes))).toEqual(scopes);
135
+ });
136
+ });
package/src/scopes.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Scope primitives for Phase 2 enforcement.
3
+ *
4
+ * Tokens carry OAuth-standard whitespace-separated scopes. This module parses,
5
+ * normalizes, and matches them — including the `admin ⊇ write ⊇ read`
6
+ * inheritance rule and the `vault:<name>:<verb>` future-shape synonym
7
+ * (narrowed per-vault scopes are Phase 2+; today we treat them as equivalent
8
+ * to `vault:<verb>`).
9
+ *
10
+ * Legacy back-compat: tokens without any `vault:*` scope — but with a
11
+ * 0.2.x-era `permission = "full" | "read"` — are mapped to the appropriate
12
+ * scope set on the fly. `legacyPermissionToScopes` is marked deprecated and
13
+ * should be removed one release after enforcement lands.
14
+ */
15
+
16
+ export const SCOPE_READ = "vault:read" as const;
17
+ export const SCOPE_WRITE = "vault:write" as const;
18
+ export const SCOPE_ADMIN = "vault:admin" as const;
19
+
20
+ /** All first-class vault scopes in inheritance order (lowest → highest). */
21
+ export const VAULT_SCOPES = [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN] as const;
22
+ export type VaultScope = (typeof VAULT_SCOPES)[number];
23
+
24
+ /**
25
+ * Parse a whitespace-separated scope string into a normalized scope list.
26
+ *
27
+ * Normalization:
28
+ * - Empty / null → []
29
+ * - Trim + split on any whitespace
30
+ * - `vault:<name>:<verb>` collapses to `vault:<verb>` (per-vault narrowing
31
+ * is Phase 2+; today it's treated as a synonym)
32
+ * - Unrecognized scopes are preserved as-is (they just won't match anything)
33
+ */
34
+ export function parseScopes(raw: string | null | undefined): string[] {
35
+ if (!raw) return [];
36
+ return raw
37
+ .split(/\s+/)
38
+ .map((s) => s.trim())
39
+ .filter(Boolean)
40
+ .map((s) => normalizeScope(s));
41
+ }
42
+
43
+ function normalizeScope(scope: string): string {
44
+ // `vault:<name>:<verb>` → `vault:<verb>` (synonym collapse). Reject an empty
45
+ // name segment (`vault::read`) — preserve it as-is so it can't accidentally
46
+ // satisfy a `vault:read` check. Only reachable via direct DB write, but the
47
+ // one-liner keeps the parser honest.
48
+ const parts = scope.split(":");
49
+ if (parts.length === 3 && parts[0] === "vault" && parts[1].length > 0) {
50
+ const verb = parts[2];
51
+ if (verb === "read" || verb === "write" || verb === "admin") {
52
+ return `vault:${verb}`;
53
+ }
54
+ }
55
+ return scope;
56
+ }
57
+
58
+ /**
59
+ * Return true iff `granted` satisfies `required` under the inheritance rule
60
+ * `admin ⊇ write ⊇ read`. Exact-match required for non-vault scopes.
61
+ */
62
+ export function hasScope(granted: string[], required: string): boolean {
63
+ if (granted.includes(required)) return true;
64
+
65
+ // Inheritance: admin ⊇ write ⊇ read
66
+ if (required === SCOPE_READ) {
67
+ return granted.includes(SCOPE_WRITE) || granted.includes(SCOPE_ADMIN);
68
+ }
69
+ if (required === SCOPE_WRITE) {
70
+ return granted.includes(SCOPE_ADMIN);
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Pick the required scope for a given API request.
77
+ * - GET/HEAD/OPTIONS → read
78
+ * - POST/PATCH/PUT/DELETE → write
79
+ *
80
+ * Admin-gated endpoints (like `/.parachute/config`) don't go through this
81
+ * helper — they call `hasScope(auth.scopes, SCOPE_ADMIN)` directly.
82
+ */
83
+ export function scopeForMethod(method: string): VaultScope {
84
+ const m = method.toUpperCase();
85
+ if (m === "GET" || m === "HEAD" || m === "OPTIONS") return SCOPE_READ;
86
+ return SCOPE_WRITE;
87
+ }
88
+
89
+ /**
90
+ * Map a 0.2.x legacy `permission` column value to scopes. Kept for back-compat
91
+ * during the one-release-cycle deprecation window — after that, every token
92
+ * row will carry an explicit `scopes` column and this can go.
93
+ *
94
+ * @deprecated Remove one release after v0.4 scope enforcement lands.
95
+ */
96
+ export function legacyPermissionToScopes(permission: string): string[] {
97
+ // "full", "admin", "write" all historically meant unrestricted access
98
+ if (permission === "read") return [SCOPE_READ];
99
+ return [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN];
100
+ }
101
+
102
+ /** Serialize a scope list to an OAuth-standard whitespace-separated string. */
103
+ export function serializeScopes(scopes: string[]): string {
104
+ return scopes.join(" ");
105
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { resolveScribeAuthToken } from "./scribe-env.ts";
3
+
4
+ function captureWarn() {
5
+ const calls: unknown[][] = [];
6
+ return { logger: { warn: (...args: unknown[]) => calls.push(args) }, calls };
7
+ }
8
+
9
+ describe("resolveScribeAuthToken", () => {
10
+ test("returns SCRIBE_AUTH_TOKEN when set (canonical)", () => {
11
+ const { logger, calls } = captureWarn();
12
+ const token = resolveScribeAuthToken(
13
+ { SCRIBE_AUTH_TOKEN: "canonical-v1" } as NodeJS.ProcessEnv,
14
+ logger,
15
+ );
16
+ expect(token).toBe("canonical-v1");
17
+ // Canonical path is silent — no deprecation warning.
18
+ expect(calls.length).toBe(0);
19
+ });
20
+
21
+ test("prefers canonical over legacy when both set", () => {
22
+ const { logger, calls } = captureWarn();
23
+ const token = resolveScribeAuthToken(
24
+ { SCRIBE_AUTH_TOKEN: "new", SCRIBE_TOKEN: "old" } as NodeJS.ProcessEnv,
25
+ logger,
26
+ );
27
+ expect(token).toBe("new");
28
+ expect(calls.length).toBe(0);
29
+ });
30
+
31
+ test("falls back to SCRIBE_TOKEN with deprecation warning", () => {
32
+ const { logger, calls } = captureWarn();
33
+ const token = resolveScribeAuthToken(
34
+ { SCRIBE_TOKEN: "legacy-v0" } as NodeJS.ProcessEnv,
35
+ logger,
36
+ );
37
+ expect(token).toBe("legacy-v0");
38
+ expect(calls.length).toBe(1);
39
+ expect(String(calls[0][0])).toContain("SCRIBE_TOKEN is deprecated");
40
+ expect(String(calls[0][0])).toContain("SCRIBE_AUTH_TOKEN");
41
+ });
42
+
43
+ test("returns undefined when neither is set (loopback back-compat)", () => {
44
+ const { logger, calls } = captureWarn();
45
+ const token = resolveScribeAuthToken({} as NodeJS.ProcessEnv, logger);
46
+ expect(token).toBeUndefined();
47
+ expect(calls.length).toBe(0);
48
+ });
49
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Env-var plumbing for the scribe integration (transcription worker + triggers).
3
+ *
4
+ * Lives in its own module so the boot-time token resolution in server.ts is
5
+ * testable without running the rest of server.ts (which has side effects:
6
+ * triggers, auto-init, Bun.serve). Keep this module pure and dependency-free.
7
+ */
8
+
9
+ /**
10
+ * Resolve the scribe auth token. `SCRIBE_AUTH_TOKEN` is the canonical name
11
+ * (matches the CLI's install-time auto-wire); `SCRIBE_TOKEN` is a legacy alias
12
+ * kept for one release — when only the legacy name is set, we warn once so
13
+ * users notice and rename.
14
+ *
15
+ * Returns `undefined` when neither is set; callers must treat that as "no
16
+ * Authorization header" (back-compat with loopback-trust deployments).
17
+ */
18
+ export function resolveScribeAuthToken(
19
+ env: NodeJS.ProcessEnv = process.env,
20
+ logger: { warn: (...args: unknown[]) => void } = console,
21
+ ): string | undefined {
22
+ const canonical = env.SCRIBE_AUTH_TOKEN;
23
+ if (canonical) return canonical;
24
+ const legacy = env.SCRIBE_TOKEN;
25
+ if (legacy) {
26
+ logger.warn(
27
+ "[transcribe] SCRIBE_TOKEN is deprecated; rename to SCRIBE_AUTH_TOKEN. " +
28
+ "The legacy name will stop being read in the next release.",
29
+ );
30
+ return legacy;
31
+ }
32
+ return undefined;
33
+ }
package/src/server.ts CHANGED
@@ -4,11 +4,13 @@
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
  */
@@ -19,6 +21,9 @@ import { getVaultStore } 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, type TranscriptionWorker } from "./transcription-worker.ts";
25
+ import { assetsDir } from "./routes.ts";
26
+ import { resolveScribeAuthToken } from "./scribe-env.ts";
22
27
 
23
28
  // Register webhook triggers from global config. Replaces the old hardcoded
24
29
  // tts-hook and transcription-hook with config-driven webhooks.
@@ -30,10 +35,54 @@ function registerConfiguredTriggers(): void {
30
35
  }
31
36
  registerTriggers(defaultHookRegistry, config.triggers);
32
37
  console.log(`[triggers] registered ${config.triggers.length} trigger(s)`);
38
+
39
+ // Soft-deprecation warning: if the dedicated transcription worker is
40
+ // enabled AND a trigger points at what looks like the same scribe endpoint,
41
+ // both will process the same attachments. The trigger's `missing_metadata`
42
+ // guard keeps it idempotent once the worker marks `transcript` on the
43
+ // attachment, but the noise is worth flagging.
44
+ if (process.env.SCRIBE_URL) {
45
+ const scribeHost = safeHost(process.env.SCRIBE_URL);
46
+ for (const t of config.triggers) {
47
+ if (t.action.send !== "attachment") continue;
48
+ if (scribeHost && safeHost(t.action.webhook) === scribeHost) {
49
+ console.warn(
50
+ `[triggers] "${t.name}" points at scribe (${t.action.webhook}) and the dedicated worker is also enabled; ` +
51
+ `these may double-fire. Prefer the dedicated worker for /v1/audio/transcriptions and remove this trigger.`,
52
+ );
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ function safeHost(url: string): string | null {
59
+ try { return new URL(url).host; } catch { return null; }
33
60
  }
34
61
 
35
62
  registerConfiguredTriggers();
36
63
 
64
+ /**
65
+ * Start the transcription worker if SCRIBE_URL is configured. The worker
66
+ * polls every vault for attachments with `metadata.transcribe_status = "pending"`
67
+ * and sends the audio to scribe. Absent SCRIBE_URL, the worker stays off
68
+ * — `{transcribe: true}` uploads still enqueue, they just wait.
69
+ */
70
+ let transcriptionWorker: TranscriptionWorker | null = null;
71
+ if (process.env.SCRIBE_URL) {
72
+ transcriptionWorker = startTranscriptionWorker({
73
+ vaultList: () => listVaults(),
74
+ getStore: (name) => getVaultStore(name),
75
+ scribeUrl: process.env.SCRIBE_URL,
76
+ scribeToken: resolveScribeAuthToken(),
77
+ resolveAssetsDir: (vault) => assetsDir(vault),
78
+ getAudioRetention: (vault) => readVaultConfig(vault)?.audio_retention ?? "keep",
79
+ getContextPredicates: (vault) => readVaultConfig(vault)?.transcription?.context,
80
+ });
81
+ console.log(`[transcribe] worker started → ${process.env.SCRIBE_URL}`);
82
+ } else {
83
+ console.log("[transcribe] worker disabled (set SCRIBE_URL to enable)");
84
+ }
85
+
37
86
  ensureConfigDirSync();
38
87
  loadEnvFile();
39
88
 
@@ -166,11 +215,14 @@ async function shutdown(signal: string): Promise<void> {
166
215
  console.log(`\n[${signal}] shutting down; in-flight hooks: ${defaultHookRegistry.inFlightCount}`);
167
216
  try {
168
217
  await Promise.race([
169
- defaultHookRegistry.drain(),
218
+ Promise.all([
219
+ defaultHookRegistry.drain(),
220
+ transcriptionWorker?.stop() ?? Promise.resolve(),
221
+ ]),
170
222
  new Promise<void>((resolve) => setTimeout(resolve, 5000)),
171
223
  ]);
172
224
  } catch (err) {
173
- console.error("[shutdown] hook drain error:", err);
225
+ console.error("[shutdown] drain error:", err);
174
226
  }
175
227
  process.exit(0);
176
228
  }
@@ -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