@openparachute/vault 0.1.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 (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Tests for the token store — scoped tokens with permissions.
3
+ * Tokens now live inside each vault's SQLite database (schema v7).
4
+ */
5
+
6
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
7
+ import { Database } from "bun:sqlite";
8
+ import { initSchema } from "../core/src/schema.ts";
9
+ import {
10
+ generateToken,
11
+ createToken,
12
+ resolveToken,
13
+ listTokens,
14
+ revokeToken,
15
+ } from "./token-store.ts";
16
+
17
+ let db: Database;
18
+
19
+ beforeEach(() => {
20
+ db = new Database(":memory:");
21
+ initSchema(db);
22
+ });
23
+
24
+ afterEach(() => {
25
+ db.close();
26
+ });
27
+
28
+ describe("token CRUD", () => {
29
+ test("create and resolve a full-access token", () => {
30
+ const { fullToken } = generateToken();
31
+ createToken(db, fullToken, { label: "test-token", permission: "full" });
32
+
33
+ const resolved = resolveToken(db, fullToken);
34
+ expect(resolved).not.toBeNull();
35
+ expect(resolved!.permission).toBe("full");
36
+ });
37
+
38
+ test("token with read permission", () => {
39
+ const { fullToken } = generateToken();
40
+ createToken(db, fullToken, {
41
+ label: "reader",
42
+ permission: "read",
43
+ });
44
+
45
+ const resolved = resolveToken(db, fullToken);
46
+ expect(resolved!.permission).toBe("read");
47
+ });
48
+
49
+ test("default permission is full", () => {
50
+ const { fullToken } = generateToken();
51
+ createToken(db, fullToken, { label: "default-perm" });
52
+
53
+ const resolved = resolveToken(db, fullToken);
54
+ expect(resolved!.permission).toBe("full");
55
+ });
56
+
57
+ test("legacy admin permission normalizes to full", () => {
58
+ const { fullToken } = generateToken();
59
+ // Simulate a legacy token by writing "admin" directly to DB
60
+ const hash = require("./config.ts").hashKey(fullToken);
61
+ db.prepare("INSERT INTO tokens (token_hash, label, permission, created_at) VALUES (?, ?, ?, ?)")
62
+ .run(hash, "legacy-admin", "admin", new Date().toISOString());
63
+
64
+ const resolved = resolveToken(db, fullToken);
65
+ expect(resolved!.permission).toBe("full");
66
+ });
67
+
68
+ test("legacy write permission normalizes to full", () => {
69
+ const { fullToken } = generateToken();
70
+ const hash = require("./config.ts").hashKey(fullToken);
71
+ db.prepare("INSERT INTO tokens (token_hash, label, permission, created_at) VALUES (?, ?, ?, ?)")
72
+ .run(hash, "legacy-write", "write", new Date().toISOString());
73
+
74
+ const resolved = resolveToken(db, fullToken);
75
+ expect(resolved!.permission).toBe("full");
76
+ });
77
+
78
+ test("expired token is rejected", () => {
79
+ const { fullToken } = generateToken();
80
+ createToken(db, fullToken, {
81
+ label: "expired",
82
+ permission: "full",
83
+ expires_at: "2020-01-01T00:00:00.000Z", // in the past
84
+ });
85
+
86
+ const resolved = resolveToken(db, fullToken);
87
+ expect(resolved).toBeNull();
88
+ });
89
+
90
+ test("non-expired token is accepted", () => {
91
+ const { fullToken } = generateToken();
92
+ const future = new Date(Date.now() + 86400000).toISOString(); // +1 day
93
+ createToken(db, fullToken, {
94
+ label: "valid",
95
+ permission: "read",
96
+ expires_at: future,
97
+ });
98
+
99
+ const resolved = resolveToken(db, fullToken);
100
+ expect(resolved).not.toBeNull();
101
+ expect(resolved!.permission).toBe("read");
102
+ });
103
+
104
+ test("invalid token returns null", () => {
105
+ const resolved = resolveToken(db, "pvt_does_not_exist");
106
+ expect(resolved).toBeNull();
107
+ });
108
+
109
+ test("list tokens shows all tokens", () => {
110
+ const { fullToken: t1 } = generateToken();
111
+ const { fullToken: t2 } = generateToken();
112
+ createToken(db, t1, { label: "first", permission: "full" });
113
+ createToken(db, t2, { label: "second", permission: "read" });
114
+
115
+ const tokens = listTokens(db);
116
+ expect(tokens.length).toBe(2);
117
+ expect(tokens.some((t) => t.label === "first")).toBe(true);
118
+ expect(tokens.some((t) => t.label === "second")).toBe(true);
119
+ // Each token should have a display ID
120
+ expect(tokens.every((t) => t.id.startsWith("t_"))).toBe(true);
121
+ });
122
+
123
+ test("revoke token by display ID", () => {
124
+ const { fullToken } = generateToken();
125
+ createToken(db, fullToken, { label: "to-revoke" });
126
+
127
+ const tokens = listTokens(db);
128
+ expect(tokens.length).toBe(1);
129
+
130
+ const revoked = revokeToken(db, tokens[0].id);
131
+ expect(revoked).toBe(true);
132
+
133
+ const after = listTokens(db);
134
+ expect(after.length).toBe(0);
135
+
136
+ // Token should no longer resolve
137
+ expect(resolveToken(db, fullToken)).toBeNull();
138
+ });
139
+
140
+ test("revoke non-existent token returns false", () => {
141
+ expect(revokeToken(db, "t_doesnotexist")).toBe(false);
142
+ });
143
+
144
+ test("resolve updates last_used_at", () => {
145
+ const { fullToken } = generateToken();
146
+ createToken(db, fullToken, { label: "usage-tracking" });
147
+
148
+ // Before first use
149
+ const before = listTokens(db);
150
+ expect(before[0].last_used_at).toBeNull();
151
+
152
+ // Resolve (which should update last_used_at)
153
+ resolveToken(db, fullToken);
154
+
155
+ const after = listTokens(db);
156
+ expect(after[0].last_used_at).not.toBeNull();
157
+ });
158
+ });
159
+
160
+ describe("token generation", () => {
161
+ test("generated tokens have pvt_ prefix", () => {
162
+ const { fullToken, tokenHash } = generateToken();
163
+ expect(fullToken.startsWith("pvt_")).toBe(true);
164
+ expect(tokenHash.startsWith("sha256:")).toBe(true);
165
+ });
166
+
167
+ test("generated tokens are unique", () => {
168
+ const t1 = generateToken();
169
+ const t2 = generateToken();
170
+ expect(t1.fullToken).not.toBe(t2.fullToken);
171
+ expect(t1.tokenHash).not.toBe(t2.tokenHash);
172
+ });
173
+ });
174
+
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Token operations for per-vault token management.
3
+ *
4
+ * Tokens live in each vault's SQLite database (the `tokens` table is part of
5
+ * the vault schema as of v7). All functions take a Database parameter — the
6
+ * vault's own DB connection.
7
+ *
8
+ * Two permission levels:
9
+ * - "full" — unrestricted access (CRUD, delete, token management)
10
+ * - "read" — query-only (no mutations)
11
+ *
12
+ * Legacy "admin" and "write" values in the DB are normalized to "full" at
13
+ * read time for backward compatibility.
14
+ */
15
+
16
+ import { Database } from "bun:sqlite";
17
+ import crypto from "node:crypto";
18
+ import { hashKey } from "./config.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type TokenPermission = "full" | "read";
25
+
26
+ /**
27
+ * Normalize legacy permission values ("admin", "write") to the current
28
+ * two-tier model. Existing DB rows may contain the old values.
29
+ */
30
+ export function normalizePermission(p: string): TokenPermission {
31
+ if (p === "read") return "read";
32
+ return "full"; // "admin", "write", or anything else → full
33
+ }
34
+
35
+ export interface Token {
36
+ token_hash: string;
37
+ label: string;
38
+ permission: TokenPermission;
39
+ /** @deprecated Scope columns exist in DB but are not enforced at runtime. */
40
+ scope_tag: string | null;
41
+ /** @deprecated Scope columns exist in DB but are not enforced at runtime. */
42
+ scope_path_prefix: string | null;
43
+ expires_at: string | null;
44
+ created_at: string;
45
+ last_used_at: string | null;
46
+ }
47
+
48
+ export interface ResolvedToken {
49
+ permission: TokenPermission;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Token operations
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export function generateToken(): { fullToken: string; tokenHash: string } {
57
+ const random = crypto.randomBytes(32).toString("base64url").slice(0, 32);
58
+ const fullToken = `pvt_${random}`;
59
+ return { fullToken, tokenHash: hashKey(fullToken) };
60
+ }
61
+
62
+ export function createToken(
63
+ db: Database,
64
+ fullToken: string,
65
+ opts: {
66
+ label: string;
67
+ permission?: TokenPermission;
68
+ /** @deprecated Written to DB but not enforced at runtime. */
69
+ scope_tag?: string | null;
70
+ /** @deprecated Written to DB but not enforced at runtime. */
71
+ scope_path_prefix?: string | null;
72
+ expires_at?: string | null;
73
+ },
74
+ ): Token {
75
+ const tokenHash = hashKey(fullToken);
76
+ const now = new Date().toISOString();
77
+ const permission = opts.permission ?? "full";
78
+
79
+ db.prepare(`
80
+ INSERT INTO tokens (token_hash, label, permission, scope_tag, scope_path_prefix, expires_at, created_at)
81
+ VALUES (?, ?, ?, ?, ?, ?, ?)
82
+ `).run(
83
+ tokenHash,
84
+ opts.label,
85
+ permission,
86
+ opts.scope_tag ?? null,
87
+ opts.scope_path_prefix ?? null,
88
+ opts.expires_at ?? null,
89
+ now,
90
+ );
91
+
92
+ return {
93
+ token_hash: tokenHash,
94
+ label: opts.label,
95
+ permission,
96
+ scope_tag: opts.scope_tag ?? null,
97
+ scope_path_prefix: opts.scope_path_prefix ?? null,
98
+ expires_at: opts.expires_at ?? null,
99
+ created_at: now,
100
+ last_used_at: null,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Resolve a bearer token. Returns the token info if valid, null if not found or expired.
106
+ * Updates last_used_at on successful resolution.
107
+ */
108
+ export function resolveToken(db: Database, providedToken: string): ResolvedToken | null {
109
+ // Hash-then-lookup: the SQL = comparison on SHA-256 output is not timing-safe,
110
+ // but this is acceptable — the attacker would need to guess a valid SHA-256
111
+ // preimage, which is computationally infeasible regardless of timing leaks.
112
+ const candidateHash = hashKey(providedToken);
113
+
114
+ const row = db.prepare(`
115
+ SELECT token_hash, permission, expires_at
116
+ FROM tokens WHERE token_hash = ?
117
+ `).get(candidateHash) as {
118
+ token_hash: string;
119
+ permission: string;
120
+ expires_at: string | null;
121
+ } | null;
122
+
123
+ if (!row) return null;
124
+
125
+ // Check expiry
126
+ if (row.expires_at && new Date(row.expires_at) < new Date()) {
127
+ return null;
128
+ }
129
+
130
+ // Update last_used_at
131
+ db.prepare("UPDATE tokens SET last_used_at = ? WHERE token_hash = ?")
132
+ .run(new Date().toISOString(), row.token_hash);
133
+
134
+ return { permission: normalizePermission(row.permission) };
135
+ }
136
+
137
+ /**
138
+ * List all tokens (for CLI display). Never exposes the hash directly —
139
+ * shows a truncated prefix for identification.
140
+ */
141
+ export function listTokens(db: Database): (Token & { id: string })[] {
142
+ const rows = db.prepare(`
143
+ SELECT token_hash, label, permission, scope_tag, scope_path_prefix,
144
+ expires_at, created_at, last_used_at
145
+ FROM tokens ORDER BY created_at DESC
146
+ `).all() as Token[];
147
+
148
+ return rows.map((r) => ({
149
+ ...r,
150
+ permission: normalizePermission(r.permission),
151
+ // Derive a short display ID from the hash (first 12 chars after "sha256:")
152
+ id: `t_${r.token_hash.slice(7, 19)}`,
153
+ }));
154
+ }
155
+
156
+ /**
157
+ * Revoke (delete) a token by its display ID or full hash.
158
+ * Returns true if exactly one token was deleted.
159
+ * If a display ID prefix matches multiple tokens, returns false (ambiguous).
160
+ */
161
+ export function revokeToken(db: Database, idOrHash: string): boolean {
162
+ // Try matching by display ID prefix
163
+ if (idOrHash.startsWith("t_")) {
164
+ const hashPrefix = idOrHash.slice(2);
165
+ const rows = db.prepare(
166
+ "SELECT token_hash FROM tokens WHERE token_hash LIKE ?"
167
+ ).all(`sha256:${hashPrefix}%`) as { token_hash: string }[];
168
+ if (rows.length === 1) {
169
+ db.prepare("DELETE FROM tokens WHERE token_hash = ?").run(rows[0].token_hash);
170
+ return true;
171
+ }
172
+ if (rows.length > 1) {
173
+ // Ambiguous prefix — refuse to revoke
174
+ return false;
175
+ }
176
+ }
177
+
178
+ // Try matching by full hash
179
+ const result = db.prepare("DELETE FROM tokens WHERE token_hash = ?").run(idOrHash);
180
+ return result.changes > 0;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Migration: import existing API keys from config.yaml into a vault's DB
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Import existing API keys for a specific vault from config.yaml into its DB.
189
+ * Idempotent — skips keys whose hash already exists.
190
+ *
191
+ * Imports:
192
+ * - Per-vault keys from vault.yaml (direct match)
193
+ * - Global keys from config.yaml (they become full-access tokens in every vault)
194
+ */
195
+ export function migrateVaultKeys(
196
+ db: Database,
197
+ vaultKeys: { key_hash: string; label: string; scope?: string; created_at: string; last_used_at?: string }[],
198
+ globalKeys?: { key_hash: string; label: string; scope?: string; created_at: string; last_used_at?: string }[],
199
+ ): number {
200
+ let migrated = 0;
201
+
202
+ // Import per-vault keys
203
+ for (const key of vaultKeys) {
204
+ const exists = db.prepare("SELECT 1 FROM tokens WHERE token_hash = ?").get(key.key_hash);
205
+ if (!exists) {
206
+ db.prepare(`
207
+ INSERT INTO tokens (token_hash, label, permission, created_at, last_used_at)
208
+ VALUES (?, ?, ?, ?, ?)
209
+ `).run(
210
+ key.key_hash,
211
+ key.label,
212
+ key.scope === "read" ? "read" : "full",
213
+ key.created_at,
214
+ key.last_used_at ?? null,
215
+ );
216
+ migrated++;
217
+ }
218
+ }
219
+
220
+ // Import global keys as full-access tokens
221
+ if (globalKeys) {
222
+ for (const key of globalKeys) {
223
+ const exists = db.prepare("SELECT 1 FROM tokens WHERE token_hash = ?").get(key.key_hash);
224
+ if (!exists) {
225
+ db.prepare(`
226
+ INSERT INTO tokens (token_hash, label, permission, created_at, last_used_at)
227
+ VALUES (?, ?, ?, ?, ?)
228
+ `).run(
229
+ key.key_hash,
230
+ key.label,
231
+ "full",
232
+ key.created_at,
233
+ key.last_used_at ?? null,
234
+ );
235
+ migrated++;
236
+ }
237
+ }
238
+ }
239
+
240
+ return migrated;
241
+ }