@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.
- package/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- 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/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- package/web/vite.config.ts +15 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Multi-vault HTTP server using Bun.serve().
|
|
4
|
+
*
|
|
5
|
+
* Routes:
|
|
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
|
+
* GET /vaults — list vaults
|
|
10
|
+
* * /vaults/{name}/api/... — per-vault REST API
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey } from "./config.ts";
|
|
14
|
+
import { authenticateVaultRequest, authenticateGlobalRequest, isMethodAllowed, extractApiKey } from "./auth.ts";
|
|
15
|
+
import type { AuthResult } from "./auth.ts";
|
|
16
|
+
import type { VaultConfig } from "./config.ts";
|
|
17
|
+
import { migrateVaultKeys } from "./token-store.ts";
|
|
18
|
+
import { getVaultStore } from "./vault-store.ts";
|
|
19
|
+
import { handleUnifiedMcp, handleScopedMcp } from "./mcp-http.ts";
|
|
20
|
+
import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks, handleStorage, handleViewNote } from "./routes.ts";
|
|
21
|
+
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
22
|
+
import { registerTriggers } from "./triggers.ts";
|
|
23
|
+
import { handleProtectedResource, handleAuthorizationServer, handleRegister, handleAuthorizeGet, handleAuthorizePost, handleToken } from "./oauth.ts";
|
|
24
|
+
|
|
25
|
+
// Register webhook triggers from global config. Replaces the old hardcoded
|
|
26
|
+
// tts-hook and transcription-hook with config-driven webhooks.
|
|
27
|
+
function registerConfiguredTriggers(): void {
|
|
28
|
+
const config = readGlobalConfig();
|
|
29
|
+
if (!config.triggers?.length) {
|
|
30
|
+
console.log("[triggers] no triggers configured in config.yaml");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
registerTriggers(defaultHookRegistry, config.triggers);
|
|
34
|
+
console.log(`[triggers] registered ${config.triggers.length} trigger(s)`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
registerConfiguredTriggers();
|
|
38
|
+
|
|
39
|
+
ensureConfigDirSync();
|
|
40
|
+
loadEnvFile();
|
|
41
|
+
|
|
42
|
+
// Auto-init: create a default vault if none exist (first run in Docker)
|
|
43
|
+
if (listVaults().length === 0) {
|
|
44
|
+
const globalConfig = readGlobalConfig();
|
|
45
|
+
if (!globalConfig.default_vault) {
|
|
46
|
+
const { fullKey, keyId } = generateApiKey();
|
|
47
|
+
writeVaultConfig({
|
|
48
|
+
name: "default",
|
|
49
|
+
api_keys: [{
|
|
50
|
+
id: keyId,
|
|
51
|
+
label: "default",
|
|
52
|
+
scope: "write",
|
|
53
|
+
key_hash: hashKey(fullKey),
|
|
54
|
+
created_at: new Date().toISOString(),
|
|
55
|
+
}],
|
|
56
|
+
created_at: new Date().toISOString(),
|
|
57
|
+
});
|
|
58
|
+
globalConfig.default_vault = "default";
|
|
59
|
+
if (!globalConfig.api_keys?.length) {
|
|
60
|
+
globalConfig.api_keys = [{
|
|
61
|
+
id: keyId,
|
|
62
|
+
label: "default",
|
|
63
|
+
scope: "write",
|
|
64
|
+
key_hash: hashKey(fullKey),
|
|
65
|
+
created_at: new Date().toISOString(),
|
|
66
|
+
}];
|
|
67
|
+
}
|
|
68
|
+
writeGlobalConfig(globalConfig);
|
|
69
|
+
console.log(`Auto-created default vault (API key: ${fullKey})`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Migrate tag schemas from vault.yaml → DB for each vault.
|
|
74
|
+
// Only inserts schemas that don't already exist in the DB (safe across restarts).
|
|
75
|
+
for (const vaultName of listVaults()) {
|
|
76
|
+
const vaultConfig = readVaultConfig(vaultName);
|
|
77
|
+
if (vaultConfig?.tag_schemas && Object.keys(vaultConfig.tag_schemas).length > 0) {
|
|
78
|
+
const store = getVaultStore(vaultName);
|
|
79
|
+
const existingTags = new Set(store.listTagSchemas().map((s) => s.tag));
|
|
80
|
+
let migrated = 0;
|
|
81
|
+
for (const [tag, schema] of Object.entries(vaultConfig.tag_schemas)) {
|
|
82
|
+
if (!existingTags.has(tag)) {
|
|
83
|
+
store.upsertTagSchema(tag, schema);
|
|
84
|
+
migrated++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (migrated > 0) {
|
|
88
|
+
console.log(`[migration] migrated ${migrated} tag schema(s) from vault.yaml to DB for vault "${vaultName}"`);
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`[migration] vault "${vaultName}" has tag_schemas in vault.yaml (already in DB — vault.yaml section can be removed)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Migrate existing API keys from config.yaml → per-vault token tables (idempotent)
|
|
96
|
+
{
|
|
97
|
+
const globalCfg = readGlobalConfig();
|
|
98
|
+
for (const vaultName of listVaults()) {
|
|
99
|
+
try {
|
|
100
|
+
const vc = readVaultConfig(vaultName);
|
|
101
|
+
if (!vc) continue;
|
|
102
|
+
const store = getVaultStore(vaultName);
|
|
103
|
+
const migrated = migrateVaultKeys(store.db, vc.api_keys, globalCfg.api_keys);
|
|
104
|
+
if (migrated > 0) {
|
|
105
|
+
console.log(`[tokens] migrated ${migrated} API key(s) into vault "${vaultName}"`);
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error(`[tokens] migration error for vault "${vaultName}":`, err);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const globalConfig = readGlobalConfig();
|
|
114
|
+
const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PORT;
|
|
115
|
+
|
|
116
|
+
const server = Bun.serve({
|
|
117
|
+
port,
|
|
118
|
+
hostname: "0.0.0.0",
|
|
119
|
+
idleTimeout: 120, // seconds — webhook triggers can take a while
|
|
120
|
+
async fetch(req, server) {
|
|
121
|
+
const url = new URL(req.url);
|
|
122
|
+
const path = url.pathname;
|
|
123
|
+
|
|
124
|
+
const corsHeaders = {
|
|
125
|
+
"Access-Control-Allow-Origin": "*",
|
|
126
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
127
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key, Mcp-Session-Id",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (req.method === "OPTIONS") {
|
|
131
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Derive client IP. Default: socket IP only (safe for direct-internet
|
|
135
|
+
// deployments). If TRUST_PROXY=1 is set, honor X-Forwarded-For — use
|
|
136
|
+
// this only when a reverse proxy (Cloudflare Tunnel, nginx, etc.) is
|
|
137
|
+
// terminating the connection, otherwise attackers can spoof the header
|
|
138
|
+
// to evade per-IP rate limiting.
|
|
139
|
+
const trustProxy = process.env.TRUST_PROXY === "1" || process.env.TRUST_PROXY === "true";
|
|
140
|
+
const forwardedFor = trustProxy ? req.headers.get("x-forwarded-for") : null;
|
|
141
|
+
const clientIp = forwardedFor
|
|
142
|
+
? forwardedFor.split(",")[0].trim()
|
|
143
|
+
: server.requestIP(req)?.address;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const start = Date.now();
|
|
147
|
+
const response = await route(req, path, clientIp);
|
|
148
|
+
const ms = Date.now() - start;
|
|
149
|
+
console.log(`${req.method} ${path} ${response.status} ${ms}ms`);
|
|
150
|
+
for (const [k, v] of Object.entries(corsHeaders)) {
|
|
151
|
+
response.headers.set(k, v);
|
|
152
|
+
}
|
|
153
|
+
return response;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(`[${req.method} ${path}]`, err);
|
|
156
|
+
return Response.json(
|
|
157
|
+
{ error: "Internal server error" },
|
|
158
|
+
{ status: 500, headers: corsHeaders },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
console.log(`Parachute Vault server listening on http://0.0.0.0:${server.port}`);
|
|
165
|
+
|
|
166
|
+
// Graceful shutdown — best-effort drain of in-flight note-mutation hooks.
|
|
167
|
+
async function shutdown(signal: string): Promise<void> {
|
|
168
|
+
console.log(`\n[${signal}] shutting down; in-flight hooks: ${defaultHookRegistry.inFlightCount}`);
|
|
169
|
+
try {
|
|
170
|
+
await Promise.race([
|
|
171
|
+
defaultHookRegistry.drain(),
|
|
172
|
+
new Promise<void>((resolve) => setTimeout(resolve, 5000)),
|
|
173
|
+
]);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error("[shutdown] hook drain error:", err);
|
|
176
|
+
}
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
180
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if a /view request has a valid API key (header or ?key= query param).
|
|
184
|
+
* Returns true if authenticated, false if not. Never rejects — unauthenticated
|
|
185
|
+
* requests still get public notes.
|
|
186
|
+
*/
|
|
187
|
+
function isViewAuthenticated(req: Request, vaultConfig: VaultConfig | null, vaultDb?: import("bun:sqlite").Database): boolean {
|
|
188
|
+
if (!vaultConfig) return false;
|
|
189
|
+
// extractApiKey now checks headers AND ?key= query param
|
|
190
|
+
const key = extractApiKey(req);
|
|
191
|
+
if (!key) return false;
|
|
192
|
+
const auth = authenticateVaultRequest(req, vaultConfig, vaultDb);
|
|
193
|
+
return !("error" in auth);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function route(req: Request, path: string, clientIp?: string): Promise<Response> {
|
|
197
|
+
// OAuth discovery endpoints (no auth required)
|
|
198
|
+
if (path === "/.well-known/oauth-protected-resource") {
|
|
199
|
+
return handleProtectedResource(req);
|
|
200
|
+
}
|
|
201
|
+
if (path === "/.well-known/oauth-authorization-server") {
|
|
202
|
+
return handleAuthorizationServer(req);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// OAuth flow endpoints (no auth — these ARE the auth)
|
|
206
|
+
if (path === "/oauth/register" || path === "/oauth/authorize" || path === "/oauth/token") {
|
|
207
|
+
const defaultVault = readGlobalConfig().default_vault ?? "default";
|
|
208
|
+
const vaultConfig = readVaultConfig(defaultVault);
|
|
209
|
+
if (!vaultConfig) {
|
|
210
|
+
return Response.json({ error: "server_error", error_description: "Default vault not configured" }, { status: 500 });
|
|
211
|
+
}
|
|
212
|
+
const store = getVaultStore(defaultVault);
|
|
213
|
+
|
|
214
|
+
if (path === "/oauth/register") {
|
|
215
|
+
return handleRegister(req, store.db);
|
|
216
|
+
}
|
|
217
|
+
if (path === "/oauth/authorize") {
|
|
218
|
+
const gc = readGlobalConfig();
|
|
219
|
+
const ownerPasswordHash = gc.owner_password_hash ?? null;
|
|
220
|
+
const totpSecret = gc.totp_secret ?? null;
|
|
221
|
+
const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
|
|
222
|
+
if (req.method === "GET") {
|
|
223
|
+
return handleAuthorizeGet(req, store.db, vaultConfig.name, ownerPasswordHash, totpEnrolled);
|
|
224
|
+
}
|
|
225
|
+
if (req.method === "POST") {
|
|
226
|
+
return handleAuthorizePost(req, store.db, {
|
|
227
|
+
vaultName: vaultConfig.name,
|
|
228
|
+
clientIp,
|
|
229
|
+
ownerPasswordHash,
|
|
230
|
+
totpSecret,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
234
|
+
}
|
|
235
|
+
if (path === "/oauth/token") {
|
|
236
|
+
return handleToken(req, store.db);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Health check — vault names only for authenticated requests
|
|
241
|
+
if (path === "/health") {
|
|
242
|
+
const auth = authenticateGlobalRequest(req);
|
|
243
|
+
if ("error" in auth) {
|
|
244
|
+
return Response.json({ status: "ok" });
|
|
245
|
+
}
|
|
246
|
+
return Response.json({ status: "ok", vaults: listVaults() });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Unified MCP (all vaults, global auth)
|
|
250
|
+
if (path === "/mcp" || path.startsWith("/mcp/")) {
|
|
251
|
+
const auth = authenticateGlobalRequest(req);
|
|
252
|
+
if ("error" in auth) return auth.error;
|
|
253
|
+
return handleUnifiedMcp(req, auth);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// View endpoint — serves notes as HTML (auth-aware, supports ID or path)
|
|
257
|
+
const viewMatch = path.match(/^\/view\/(.+)$/);
|
|
258
|
+
if (viewMatch && req.method === "GET") {
|
|
259
|
+
const defaultVault = readGlobalConfig().default_vault ?? "default";
|
|
260
|
+
const vaultConfig = readVaultConfig(defaultVault);
|
|
261
|
+
if (!vaultConfig) {
|
|
262
|
+
return Response.json({ error: "Default vault not found" }, { status: 404 });
|
|
263
|
+
}
|
|
264
|
+
const store = getVaultStore(defaultVault);
|
|
265
|
+
const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
|
|
266
|
+
return handleViewNote(store, decodeURIComponent(viewMatch[1]), {
|
|
267
|
+
authenticated,
|
|
268
|
+
publishedTag: vaultConfig.published_tag,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Backward compat: /public/:noteId → /view/:noteId (preserving query params)
|
|
273
|
+
const publicMatch = path.match(/^\/public\/([^/]+)$/);
|
|
274
|
+
if (publicMatch && req.method === "GET") {
|
|
275
|
+
const dest = new URL(`/view/${publicMatch[1]}`, req.url);
|
|
276
|
+
dest.search = new URL(req.url).search;
|
|
277
|
+
return Response.redirect(dest.toString(), 301);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
// List vaults — requires auth
|
|
282
|
+
if (path === "/vaults" && req.method === "GET") {
|
|
283
|
+
const auth = authenticateGlobalRequest(req);
|
|
284
|
+
if ("error" in auth) return auth.error;
|
|
285
|
+
const names = listVaults();
|
|
286
|
+
const vaults = names.map((name) => {
|
|
287
|
+
const config = readVaultConfig(name);
|
|
288
|
+
return {
|
|
289
|
+
name,
|
|
290
|
+
description: config?.description,
|
|
291
|
+
created_at: config?.created_at,
|
|
292
|
+
};
|
|
293
|
+
});
|
|
294
|
+
return Response.json({ vaults });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Backward-compatible: /api/* routes to default vault
|
|
298
|
+
if (path.startsWith("/api/")) {
|
|
299
|
+
const defaultVault = readGlobalConfig().default_vault ?? "default";
|
|
300
|
+
const vaultConfig = readVaultConfig(defaultVault);
|
|
301
|
+
if (!vaultConfig) {
|
|
302
|
+
return Response.json({ error: "Default vault not found" }, { status: 404 });
|
|
303
|
+
}
|
|
304
|
+
const store = getVaultStore(defaultVault);
|
|
305
|
+
const auth = authenticateVaultRequest(req, vaultConfig, store.db);
|
|
306
|
+
if ("error" in auth) return auth.error;
|
|
307
|
+
if (!isMethodAllowed(req.method, auth.permission)) {
|
|
308
|
+
return Response.json({ error: "Forbidden", message: "Insufficient permissions" }, { status: 403 });
|
|
309
|
+
}
|
|
310
|
+
const apiPath = path.slice(4); // strip "/api"
|
|
311
|
+
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6));
|
|
312
|
+
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5));
|
|
313
|
+
if (apiPath === "/find-path") return handleFindPath(req, store);
|
|
314
|
+
if (apiPath === "/vault") return handleVault(req, store, vaultConfig, (desc) => {
|
|
315
|
+
vaultConfig.description = desc;
|
|
316
|
+
writeVaultConfig(vaultConfig);
|
|
317
|
+
});
|
|
318
|
+
if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
|
|
319
|
+
if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), defaultVault);
|
|
320
|
+
if (apiPath === "/health") return Response.json({ status: "ok", vault: defaultVault });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Vault-scoped routes: /vaults/{name}/...
|
|
324
|
+
const vaultMatch = path.match(/^\/vaults\/([^/]+)(\/.*)?$/);
|
|
325
|
+
if (!vaultMatch) {
|
|
326
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const vaultName = vaultMatch[1];
|
|
330
|
+
const subpath = vaultMatch[2] ?? "";
|
|
331
|
+
|
|
332
|
+
const vaultConfig = readVaultConfig(vaultName);
|
|
333
|
+
if (!vaultConfig) {
|
|
334
|
+
return Response.json(
|
|
335
|
+
{ error: "Vault not found", vault: vaultName },
|
|
336
|
+
{ status: 404 },
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Backward compat: /vaults/{name}/public/:noteId → /view/:noteId
|
|
341
|
+
const vaultPublicMatch = subpath.match(/^\/public\/([^/]+)$/);
|
|
342
|
+
if (vaultPublicMatch && req.method === "GET") {
|
|
343
|
+
const dest = new URL(`/vaults/${vaultName}/view/${vaultPublicMatch[1]}`, req.url);
|
|
344
|
+
dest.search = new URL(req.url).search;
|
|
345
|
+
return Response.redirect(dest.toString(), 301);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// View endpoint — serves notes as HTML (auth-aware, vault-scoped, supports ID or path)
|
|
349
|
+
const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
|
|
350
|
+
if (vaultViewMatch && req.method === "GET") {
|
|
351
|
+
const store = getVaultStore(vaultName);
|
|
352
|
+
const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
|
|
353
|
+
return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]), {
|
|
354
|
+
authenticated,
|
|
355
|
+
publishedTag: vaultConfig.published_tag,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Vault-scoped OAuth endpoints (no auth — these ARE the auth)
|
|
360
|
+
if (subpath === "/oauth/register" || subpath === "/oauth/authorize" || subpath === "/oauth/token") {
|
|
361
|
+
const store = getVaultStore(vaultName);
|
|
362
|
+
if (subpath === "/oauth/register") return handleRegister(req, store.db);
|
|
363
|
+
if (subpath === "/oauth/authorize") {
|
|
364
|
+
const gc = readGlobalConfig();
|
|
365
|
+
const ownerPasswordHash = gc.owner_password_hash ?? null;
|
|
366
|
+
const totpSecret = gc.totp_secret ?? null;
|
|
367
|
+
const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
|
|
368
|
+
if (req.method === "GET") return handleAuthorizeGet(req, store.db, vaultConfig.name, ownerPasswordHash, totpEnrolled);
|
|
369
|
+
if (req.method === "POST") return handleAuthorizePost(req, store.db, {
|
|
370
|
+
vaultName: vaultConfig.name,
|
|
371
|
+
clientIp,
|
|
372
|
+
ownerPasswordHash,
|
|
373
|
+
totpSecret,
|
|
374
|
+
});
|
|
375
|
+
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
376
|
+
}
|
|
377
|
+
if (subpath === "/oauth/token") return handleToken(req, store.db);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Vault-scoped discovery endpoints
|
|
381
|
+
if (subpath === "/.well-known/oauth-protected-resource") return handleProtectedResource(req, `/vaults/${vaultName}/mcp`);
|
|
382
|
+
if (subpath === "/.well-known/oauth-authorization-server") return handleAuthorizationServer(req);
|
|
383
|
+
|
|
384
|
+
// Auth: per-vault key OR global key
|
|
385
|
+
const store = getVaultStore(vaultName);
|
|
386
|
+
const auth = authenticateVaultRequest(req, vaultConfig, store.db);
|
|
387
|
+
if ("error" in auth) return auth.error;
|
|
388
|
+
|
|
389
|
+
// Per-vault scoped MCP
|
|
390
|
+
if (subpath === "/mcp" || subpath.startsWith("/mcp/")) {
|
|
391
|
+
return handleScopedMcp(req, vaultName, auth);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Bare /vaults/{name} — single-vault root. Returns name, description,
|
|
395
|
+
// createdAt, and stats. One round trip for a viz landing page.
|
|
396
|
+
if (subpath === "" || subpath === "/") {
|
|
397
|
+
if (req.method !== "GET") {
|
|
398
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
399
|
+
}
|
|
400
|
+
const stats = store.getVaultStats();
|
|
401
|
+
return Response.json({
|
|
402
|
+
name: vaultName,
|
|
403
|
+
description: vaultConfig.description,
|
|
404
|
+
createdAt: vaultConfig.created_at,
|
|
405
|
+
stats,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// REST API — enforce permission level
|
|
410
|
+
if (!isMethodAllowed(req.method, auth.permission)) {
|
|
411
|
+
return Response.json(
|
|
412
|
+
{ error: "Forbidden", message: "Insufficient permissions" },
|
|
413
|
+
{ status: 403 },
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const apiMatch = subpath.match(/^\/api(\/.*)?$/);
|
|
418
|
+
if (!apiMatch) {
|
|
419
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const apiPath = apiMatch[1] ?? "";
|
|
423
|
+
|
|
424
|
+
if (apiPath.startsWith("/notes")) {
|
|
425
|
+
return handleNotes(req, store, apiPath.slice(6));
|
|
426
|
+
}
|
|
427
|
+
if (apiPath.startsWith("/tags")) {
|
|
428
|
+
return handleTags(req, store, apiPath.slice(5));
|
|
429
|
+
}
|
|
430
|
+
if (apiPath === "/find-path") {
|
|
431
|
+
return handleFindPath(req, store);
|
|
432
|
+
}
|
|
433
|
+
if (apiPath === "/vault") {
|
|
434
|
+
return handleVault(req, store, vaultConfig, (desc) => {
|
|
435
|
+
vaultConfig.description = desc;
|
|
436
|
+
writeVaultConfig(vaultConfig);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (apiPath === "/unresolved-wikilinks") {
|
|
440
|
+
return handleUnresolvedWikilinks(req, store);
|
|
441
|
+
}
|
|
442
|
+
if (apiPath.startsWith("/storage")) {
|
|
443
|
+
return handleStorage(req, apiPath.slice(8), vaultName);
|
|
444
|
+
}
|
|
445
|
+
if (apiPath === "/health") {
|
|
446
|
+
return Response.json({ status: "ok", vault: vaultName });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
450
|
+
}
|
package/src/systemd.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux systemd service management for the vault daemon.
|
|
3
|
+
*
|
|
4
|
+
* Installs a user-level systemd service (~/.config/systemd/user/).
|
|
5
|
+
* Uses EnvironmentFile to load ~/.parachute/.env.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { join, resolve } from "path";
|
|
10
|
+
import { writeFile, mkdir, unlink } from "fs/promises";
|
|
11
|
+
import { existsSync } from "fs";
|
|
12
|
+
import { $ } from "bun";
|
|
13
|
+
import { CONFIG_DIR, ENV_PATH, LOG_PATH, ERR_PATH } from "./config.ts";
|
|
14
|
+
|
|
15
|
+
const SERVICE_NAME = "parachute-vault";
|
|
16
|
+
const SERVICE_DIR = join(homedir(), ".config", "systemd", "user");
|
|
17
|
+
const SERVICE_PATH = join(SERVICE_DIR, `${SERVICE_NAME}.service`);
|
|
18
|
+
|
|
19
|
+
function generateUnit(serverPath: string, bunPath: string): string {
|
|
20
|
+
return `[Unit]
|
|
21
|
+
Description=Parachute Vault
|
|
22
|
+
After=network.target
|
|
23
|
+
|
|
24
|
+
[Service]
|
|
25
|
+
Type=simple
|
|
26
|
+
WorkingDirectory=${resolve(serverPath, "..")}
|
|
27
|
+
ExecStart=${bunPath} ${serverPath}
|
|
28
|
+
Restart=on-failure
|
|
29
|
+
RestartSec=5
|
|
30
|
+
EnvironmentFile=${ENV_PATH}
|
|
31
|
+
StandardOutput=append:${LOG_PATH}
|
|
32
|
+
StandardError=append:${ERR_PATH}
|
|
33
|
+
|
|
34
|
+
[Install]
|
|
35
|
+
WantedBy=default.target
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function installSystemdService(): Promise<void> {
|
|
40
|
+
const serverPath = resolve(import.meta.dir, "server.ts");
|
|
41
|
+
const bunPath = (await $`which bun`.text()).trim();
|
|
42
|
+
|
|
43
|
+
await mkdir(SERVICE_DIR, { recursive: true });
|
|
44
|
+
await writeFile(SERVICE_PATH, generateUnit(serverPath, bunPath));
|
|
45
|
+
|
|
46
|
+
// Enable lingering so user services run without login session
|
|
47
|
+
try {
|
|
48
|
+
await $`loginctl enable-linger ${process.env.USER}`.quiet();
|
|
49
|
+
} catch {
|
|
50
|
+
// May fail if not supported, that's ok
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await $`systemctl --user daemon-reload`.quiet();
|
|
54
|
+
await $`systemctl --user enable ${SERVICE_NAME}`.quiet();
|
|
55
|
+
await $`systemctl --user start ${SERVICE_NAME}`.quiet();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function uninstallSystemdService(): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
await $`systemctl --user stop ${SERVICE_NAME}`.quiet();
|
|
61
|
+
await $`systemctl --user disable ${SERVICE_NAME}`.quiet();
|
|
62
|
+
} catch {}
|
|
63
|
+
try {
|
|
64
|
+
await unlink(SERVICE_PATH);
|
|
65
|
+
await $`systemctl --user daemon-reload`.quiet();
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function restartSystemdService(): Promise<void> {
|
|
70
|
+
await $`systemctl --user restart ${SERVICE_NAME}`.quiet();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isSystemdAvailable(): boolean {
|
|
74
|
+
return existsSync("/run/systemd/system") || existsSync("/sys/fs/cgroup/systemd");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function isServiceActive(): Promise<boolean> {
|
|
78
|
+
try {
|
|
79
|
+
const result = await $`systemctl --user is-active ${SERVICE_NAME}`.quiet().text();
|
|
80
|
+
return result.trim() === "active";
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|