@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.
- package/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +70 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +603 -19
- 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 +157 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +92 -0
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +29 -1
- package/docs/HTTP_API.md +105 -1
- 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/cli.ts +179 -121
- 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 +136 -0
- package/src/scopes.ts +105 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +57 -5
- 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 +583 -0
- package/src/transcription-worker.ts +346 -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
|
@@ -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
|
-
* * /
|
|
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
|
-
|
|
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]
|
|
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 {
|
|
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
|