@openparachute/vault 0.4.8 → 0.4.9-rc.11
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/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/src/module-config.ts
CHANGED
|
@@ -19,8 +19,11 @@
|
|
|
19
19
|
* - port: GlobalConfig.port, exposed read-only.
|
|
20
20
|
* - autoTranscribe.*: vault↔scribe handoff (vault#353, design 2026-05-21
|
|
21
21
|
* Part 2). Three nested fields per design Q4:
|
|
22
|
-
* - enabled: boolean toggle, default
|
|
23
|
-
*
|
|
22
|
+
* - enabled: boolean toggle, default true when scribe is
|
|
23
|
+
* reachable (persisted in
|
|
24
|
+
* GlobalConfig.auto_transcribe.enabled). Default
|
|
25
|
+
* flipped from off → on so installing scribe is
|
|
26
|
+
* the only opt-in signal needed.
|
|
24
27
|
* - scribeUrl: readOnly — resolved per-process from
|
|
25
28
|
* `~/.parachute/services.json` via
|
|
26
29
|
* `scribe-discovery.ts`. Operators can't point at an
|
|
@@ -69,10 +72,10 @@ export function buildConfigSchema(): ModuleConfigSchema {
|
|
|
69
72
|
properties: {
|
|
70
73
|
enabled: {
|
|
71
74
|
type: "boolean",
|
|
72
|
-
default:
|
|
75
|
+
default: true,
|
|
73
76
|
title: "Enable auto-transcription",
|
|
74
77
|
description:
|
|
75
|
-
"Master toggle.
|
|
78
|
+
"Master toggle. Default on — audio uploads transcribe automatically when scribe is reachable. Set to false to disable. Global — persisted in `GlobalConfig.auto_transcribe.enabled` and applies to every vault on this server. Per-vault control is a future enhancement when multi-vault deployments need it.",
|
|
76
79
|
},
|
|
77
80
|
scribeUrl: {
|
|
78
81
|
type: "string",
|
|
@@ -139,7 +142,10 @@ export function buildConfigValues(
|
|
|
139
142
|
return {
|
|
140
143
|
audio_retention: vaultConfig.audio_retention ?? "keep",
|
|
141
144
|
autoTranscribe: {
|
|
142
|
-
|
|
145
|
+
// Match shouldAutoTranscribe's `?? true` so the admin SPA displays
|
|
146
|
+
// the same value runtime uses. An unset config row shows `true`
|
|
147
|
+
// because that's what vault will actually do on the next audio upload.
|
|
148
|
+
enabled: globalConfig.auto_transcribe?.enabled ?? true,
|
|
143
149
|
scribeUrl,
|
|
144
150
|
},
|
|
145
151
|
// Legacy alias mirrors `autoTranscribe.scribeUrl` so hubs reading the
|
package/src/routes.ts
CHANGED
|
@@ -2077,7 +2077,13 @@ const MIME_TYPES: Record<string, string> = {
|
|
|
2077
2077
|
".mp4": "video/mp4",
|
|
2078
2078
|
};
|
|
2079
2079
|
|
|
2080
|
-
export async function handleStorage(
|
|
2080
|
+
export async function handleStorage(
|
|
2081
|
+
req: Request,
|
|
2082
|
+
path: string,
|
|
2083
|
+
vault: string,
|
|
2084
|
+
store: Store,
|
|
2085
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
2086
|
+
): Promise<Response> {
|
|
2081
2087
|
const assets = assetsDir(vault);
|
|
2082
2088
|
|
|
2083
2089
|
if (req.method === "POST" && path === "/upload") {
|
|
@@ -2121,6 +2127,37 @@ export async function handleStorage(req: Request, path: string, vault: string):
|
|
|
2121
2127
|
return json({ error: "Not found" }, 404);
|
|
2122
2128
|
}
|
|
2123
2129
|
|
|
2130
|
+
// Tag-scope gate (C0 adversarial-audit finding). The note-keyed
|
|
2131
|
+
// attachment surfaces (GET /notes/:id?include_attachments, GET
|
|
2132
|
+
// /notes/:id/attachments, query results) are all gated behind
|
|
2133
|
+
// `noteWithinTagScope`, but this raw byte-serve endpoint historically
|
|
2134
|
+
// shipped bytes purely by filesystem path with only a path-traversal
|
|
2135
|
+
// guard — so a tag-scoped token (scoped to e.g. ["work"]) could fetch
|
|
2136
|
+
// an out-of-scope note's attachment bytes directly if it learned the
|
|
2137
|
+
// storage path. Path secrecy (the 122-bit UUID in the filename) is NOT
|
|
2138
|
+
// an access control. When the token is tag-scoped, reverse-lookup the
|
|
2139
|
+
// requested path → owning attachment row(s) → note_id, and serve only
|
|
2140
|
+
// if at least one owning note is within scope. Unscoped tokens
|
|
2141
|
+
// (tagScope.raw === null) keep the prior behavior verbatim.
|
|
2142
|
+
//
|
|
2143
|
+
// 404 (not 403) on out-of-scope / no-owning-row, matching the
|
|
2144
|
+
// note-level surfaces — don't create an existence oracle that confirms
|
|
2145
|
+
// "this path exists but you can't see it."
|
|
2146
|
+
if (tagScope.raw !== null) {
|
|
2147
|
+
const rows = await store.getAttachmentsByPath(reqPath);
|
|
2148
|
+
let allowed = false;
|
|
2149
|
+
for (const att of rows) {
|
|
2150
|
+
const note = await store.getNote(att.noteId);
|
|
2151
|
+
if (note && noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
2152
|
+
allowed = true;
|
|
2153
|
+
break;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
if (!allowed) {
|
|
2157
|
+
return json({ error: "Not found" }, 404);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2124
2161
|
const stat = statSync(filePath);
|
|
2125
2162
|
const ext = extname(filePath).toLowerCase();
|
|
2126
2163
|
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
package/src/routing.test.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* never touch ~/.parachute.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
|
|
20
|
+
import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test";
|
|
21
21
|
import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
|
|
22
22
|
import { join } from "path";
|
|
23
23
|
import { tmpdir } from "os";
|
|
@@ -606,6 +606,24 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
|
|
|
606
606
|
|
|
607
607
|
const HUB_ORIGIN = "http://127.0.0.1:1939";
|
|
608
608
|
|
|
609
|
+
// Process-env isolation: sibling test files (tokens-routes.test.ts,
|
|
610
|
+
// auth-hub-jwt.test.ts) set PARACHUTE_HUB_ORIGIN in their own beforeAll
|
|
611
|
+
// hooks. Bun's test runner shares a single process across test files,
|
|
612
|
+
// and when file-ordering puts those before this one, their hook-set
|
|
613
|
+
// value can still be live when our tests run. Restore the default
|
|
614
|
+
// (unset) here so we test against `DEFAULT_HUB_LOOPBACK`. Caught when
|
|
615
|
+
// vault rc.1 release CI failed with "Received: http://127.0.0.1:34295"
|
|
616
|
+
// — a leaked ephemeral port from another test's fixture.
|
|
617
|
+
let _prevHubOriginRouting: string | undefined;
|
|
618
|
+
beforeAll(() => {
|
|
619
|
+
_prevHubOriginRouting = process.env.PARACHUTE_HUB_ORIGIN;
|
|
620
|
+
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
621
|
+
});
|
|
622
|
+
afterAll(() => {
|
|
623
|
+
if (_prevHubOriginRouting === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
624
|
+
else process.env.PARACHUTE_HUB_ORIGIN = _prevHubOriginRouting;
|
|
625
|
+
});
|
|
626
|
+
|
|
609
627
|
describe("per-vault OAuth discovery (hub-rooted after workstream E)", () => {
|
|
610
628
|
test("AS metadata names the hub as issuer + endpoints", async () => {
|
|
611
629
|
createVault("journal");
|
|
@@ -1804,3 +1822,76 @@ describe("/vault/<name>/.parachute/mirror — auth + dispatch", () => {
|
|
|
1804
1822
|
expect([405, 503]).toContain(res.status);
|
|
1805
1823
|
});
|
|
1806
1824
|
});
|
|
1825
|
+
|
|
1826
|
+
// ---------------------------------------------------------------------------
|
|
1827
|
+
// /vault/<name>/.parachute/mirror/run-now — manual-trigger endpoint added
|
|
1828
|
+
// alongside the SPA UI. Tests pin the auth gate matches the parent
|
|
1829
|
+
// endpoint; handler-shape coverage lives in mirror-routes.test.ts.
|
|
1830
|
+
// ---------------------------------------------------------------------------
|
|
1831
|
+
|
|
1832
|
+
describe("/vault/<name>/.parachute/mirror/run-now — auth + dispatch", () => {
|
|
1833
|
+
test("unauthenticated → 401", async () => {
|
|
1834
|
+
createVault("journal");
|
|
1835
|
+
const p = "/vault/journal/.parachute/mirror/run-now";
|
|
1836
|
+
const res = await route(
|
|
1837
|
+
new Request(`http://localhost:1940${p}`, { method: "POST" }),
|
|
1838
|
+
p,
|
|
1839
|
+
);
|
|
1840
|
+
expect(res.status).toBe(401);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
test("vault:read token → 403 insufficient_scope", async () => {
|
|
1844
|
+
createVault("journal");
|
|
1845
|
+
const store = getVaultStore("journal");
|
|
1846
|
+
const { fullToken } = generateToken();
|
|
1847
|
+
createToken(store.db, fullToken, {
|
|
1848
|
+
label: "reader",
|
|
1849
|
+
permission: "read",
|
|
1850
|
+
scopes: ["vault:read"],
|
|
1851
|
+
});
|
|
1852
|
+
const p = "/vault/journal/.parachute/mirror/run-now";
|
|
1853
|
+
const res = await route(
|
|
1854
|
+
new Request(`http://localhost:1940${p}`, {
|
|
1855
|
+
method: "POST",
|
|
1856
|
+
headers: { authorization: `Bearer ${fullToken}` },
|
|
1857
|
+
}),
|
|
1858
|
+
p,
|
|
1859
|
+
);
|
|
1860
|
+
expect(res.status).toBe(403);
|
|
1861
|
+
const body = (await res.json()) as { error_type?: string; required_scope?: string };
|
|
1862
|
+
expect(body.error_type).toBe("insufficient_scope");
|
|
1863
|
+
expect(body.required_scope).toBe("vault:admin");
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
test("admin token reaches the handler — 503 when manager not wired, 400 when wired+disabled", async () => {
|
|
1867
|
+
// Mirrors the parent endpoint's harness behavior: test ordering
|
|
1868
|
+
// determines whether a previous test wired a manager. Either way
|
|
1869
|
+
// the auth gate passed, which is what this routing-level test pins.
|
|
1870
|
+
createVault("journal");
|
|
1871
|
+
const token = createAdminToken("journal");
|
|
1872
|
+
const p = "/vault/journal/.parachute/mirror/run-now";
|
|
1873
|
+
const res = await route(
|
|
1874
|
+
new Request(`http://localhost:1940${p}`, {
|
|
1875
|
+
method: "POST",
|
|
1876
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1877
|
+
}),
|
|
1878
|
+
p,
|
|
1879
|
+
);
|
|
1880
|
+
expect([400, 503]).toContain(res.status);
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
test("non-POST methods return 405 when manager is wired", async () => {
|
|
1884
|
+
createVault("journal");
|
|
1885
|
+
const token = createAdminToken("journal");
|
|
1886
|
+
const p = "/vault/journal/.parachute/mirror/run-now";
|
|
1887
|
+
const res = await route(
|
|
1888
|
+
new Request(`http://localhost:1940${p}`, {
|
|
1889
|
+
method: "GET",
|
|
1890
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1891
|
+
}),
|
|
1892
|
+
p,
|
|
1893
|
+
);
|
|
1894
|
+
// 503 short-circuits the method check when no manager is wired.
|
|
1895
|
+
expect([405, 503]).toContain(res.status);
|
|
1896
|
+
});
|
|
1897
|
+
});
|
package/src/routing.ts
CHANGED
|
@@ -72,7 +72,21 @@ import {
|
|
|
72
72
|
} from "./oauth-discovery.ts";
|
|
73
73
|
import { handleConfigSchema, handleConfig } from "./module-config.ts";
|
|
74
74
|
import { buildAuthStatus } from "./auth-status.ts";
|
|
75
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
handleAuthDelete,
|
|
77
|
+
handleAuthGet,
|
|
78
|
+
handleAuthGithubCreateRepo,
|
|
79
|
+
handleAuthGithubDeviceCode,
|
|
80
|
+
handleAuthGithubPoll,
|
|
81
|
+
handleAuthGithubRepos,
|
|
82
|
+
handleAuthGithubSelectRepo,
|
|
83
|
+
handleAuthPat,
|
|
84
|
+
handleMirrorGet,
|
|
85
|
+
handleMirrorImport,
|
|
86
|
+
handleMirrorPushNow,
|
|
87
|
+
handleMirrorPut,
|
|
88
|
+
handleMirrorRunNow,
|
|
89
|
+
} from "./mirror-routes.ts";
|
|
76
90
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
77
91
|
|
|
78
92
|
/**
|
|
@@ -424,7 +438,15 @@ export async function route(
|
|
|
424
438
|
|
|
425
439
|
// MCP (per-vault, single-vault session).
|
|
426
440
|
if (isScopedMcp) {
|
|
427
|
-
|
|
441
|
+
// Thread the RAW caller bearer (the exact credential the session
|
|
442
|
+
// presented) into the MCP layer so the manage-token tool can forward it
|
|
443
|
+
// to hub's mint-token attenuation proxy (vault#403, MGT). Only the raw
|
|
444
|
+
// validated bearer — never a fabricated one. extractApiKey returns the
|
|
445
|
+
// same value `authenticateVaultRequest` validated above; non-forwardable
|
|
446
|
+
// credentials (env-var secret, legacy pvt_*) are handled by manage-token
|
|
447
|
+
// itself (it only forwards JWT-shaped bearers).
|
|
448
|
+
const callerBearer = extractApiKey(req);
|
|
449
|
+
return handleScopedMcp(req, vaultName, auth, callerBearer);
|
|
428
450
|
}
|
|
429
451
|
|
|
430
452
|
// Bare `/vault/<name>` — single-vault root. Returns name, description,
|
|
@@ -467,15 +489,13 @@ export async function route(
|
|
|
467
489
|
return handleTokens(req, store, vaultName, auth.scopes, auth.scoped_tags, tokensMatch[1] ?? "");
|
|
468
490
|
}
|
|
469
491
|
|
|
470
|
-
// /.parachute/mirror —
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
// A1 these endpoints unblock direct API callers and the by-hand
|
|
478
|
-
// config workflow.
|
|
492
|
+
// /.parachute/mirror — Admin-gated read+write of THIS vault's persistent
|
|
493
|
+
// mirror config + runtime status. Per-vault (vault#400): the manager is
|
|
494
|
+
// resolved by the URL's vault name, so each vault's mirror page reflects
|
|
495
|
+
// its own config + git remote. Lives under `.parachute/` (alongside
|
|
496
|
+
// info/icon/config) rather than `admin/` because `/vault/<name>/admin/*`
|
|
497
|
+
// is reserved for the admin SPA's static-file mount; the API surface goes
|
|
498
|
+
// under `.parachute/` by the module-protocol convention.
|
|
479
499
|
if (subpath === "/.parachute/mirror") {
|
|
480
500
|
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
481
501
|
return Response.json(
|
|
@@ -489,19 +509,22 @@ export async function route(
|
|
|
489
509
|
{ status: 403 },
|
|
490
510
|
);
|
|
491
511
|
}
|
|
492
|
-
|
|
512
|
+
// vault#400: resolve the manager for THIS vault (from the URL). The
|
|
513
|
+
// registry lazily builds one (via the boot-installed factory) for any
|
|
514
|
+
// existing vault — including a non-default vault or one configured at
|
|
515
|
+
// runtime — so the handler operates on the right vault's config + status
|
|
516
|
+
// + git remote, never the default vault's.
|
|
517
|
+
const manager = getMirrorManager(vaultName);
|
|
493
518
|
if (!manager) {
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
// SPA know it's a service-state issue, not a misconfig on their
|
|
499
|
-
// end.
|
|
519
|
+
// Null only when boot hasn't installed the factory yet (startup race
|
|
520
|
+
// or boot failure). Surface a clear 503 rather than a JSON null so the
|
|
521
|
+
// operator + the hub SPA know it's a service-state issue, not a
|
|
522
|
+
// misconfig on their end.
|
|
500
523
|
return Response.json(
|
|
501
524
|
{
|
|
502
525
|
error: "Mirror manager not initialized",
|
|
503
526
|
message:
|
|
504
|
-
"The vault server hasn't wired
|
|
527
|
+
"The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
|
|
505
528
|
},
|
|
506
529
|
{ status: 503 },
|
|
507
530
|
);
|
|
@@ -511,6 +534,156 @@ export async function route(
|
|
|
511
534
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
512
535
|
}
|
|
513
536
|
|
|
537
|
+
// /.parachute/mirror/run-now — fire a one-shot export+commit+push pass.
|
|
538
|
+
// Same admin gate as the GET/PUT above; same manager presence check.
|
|
539
|
+
// POST-only — a GET would imply "read the result of running" which
|
|
540
|
+
// isn't the verb (the rolling status is already available on the
|
|
541
|
+
// parent GET endpoint).
|
|
542
|
+
if (subpath === "/.parachute/mirror/run-now") {
|
|
543
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
544
|
+
return Response.json(
|
|
545
|
+
{
|
|
546
|
+
error: "Forbidden",
|
|
547
|
+
error_type: "insufficient_scope",
|
|
548
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
549
|
+
required_scope: SCOPE_ADMIN,
|
|
550
|
+
granted_scopes: auth.scopes,
|
|
551
|
+
},
|
|
552
|
+
{ status: 403 },
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
const manager = getMirrorManager(vaultName);
|
|
556
|
+
if (!manager) {
|
|
557
|
+
return Response.json(
|
|
558
|
+
{
|
|
559
|
+
error: "Mirror manager not initialized",
|
|
560
|
+
message:
|
|
561
|
+
"The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
|
|
562
|
+
},
|
|
563
|
+
{ status: 503 },
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if (req.method === "POST") return handleMirrorRunNow(manager);
|
|
567
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// /.parachute/mirror/push-now — fire `git push` against committed state.
|
|
571
|
+
// Cut 6 of vault#392. Same admin gate + manager check as run-now;
|
|
572
|
+
// POST-only. Distinguished from /run-now in that this skips export +
|
|
573
|
+
// commit, only pushes — for "did my credentials actually work?" flow.
|
|
574
|
+
if (subpath === "/.parachute/mirror/push-now") {
|
|
575
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
576
|
+
return Response.json(
|
|
577
|
+
{
|
|
578
|
+
error: "Forbidden",
|
|
579
|
+
error_type: "insufficient_scope",
|
|
580
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
581
|
+
required_scope: SCOPE_ADMIN,
|
|
582
|
+
granted_scopes: auth.scopes,
|
|
583
|
+
},
|
|
584
|
+
{ status: 403 },
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
const manager = getMirrorManager(vaultName);
|
|
588
|
+
if (!manager) {
|
|
589
|
+
return Response.json(
|
|
590
|
+
{
|
|
591
|
+
error: "Mirror manager not initialized",
|
|
592
|
+
message:
|
|
593
|
+
"The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
|
|
594
|
+
},
|
|
595
|
+
{ status: 503 },
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
if (req.method === "POST") return handleMirrorPushNow(manager);
|
|
599
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// /.parachute/mirror/import — clone a vault export from git + import.
|
|
603
|
+
// Admin-gated. POST-only. Synchronous (imports finish in <30s for
|
|
604
|
+
// typical vaults). See mirror-routes.ts:handleMirrorImport for the
|
|
605
|
+
// request/response shape + error map. Symmetric counterpart to the
|
|
606
|
+
// export-to-git flow vault#382 + vault#384 shipped.
|
|
607
|
+
if (subpath === "/.parachute/mirror/import") {
|
|
608
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
609
|
+
return Response.json(
|
|
610
|
+
{
|
|
611
|
+
error: "Forbidden",
|
|
612
|
+
error_type: "insufficient_scope",
|
|
613
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
614
|
+
required_scope: SCOPE_ADMIN,
|
|
615
|
+
granted_scopes: auth.scopes,
|
|
616
|
+
},
|
|
617
|
+
{ status: 403 },
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (req.method !== "POST") {
|
|
621
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
622
|
+
}
|
|
623
|
+
return handleMirrorImport(req, vaultName);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// /.parachute/mirror/auth/* — UI-configurable git push credentials.
|
|
627
|
+
// GitHub OAuth Device Flow + PAT fallback. All admin-gated; the
|
|
628
|
+
// routes themselves don't carry secrets in their responses
|
|
629
|
+
// (mirror-routes.ts redacts via sanitizeCredentials).
|
|
630
|
+
if (subpath.startsWith("/.parachute/mirror/auth")) {
|
|
631
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
632
|
+
return Response.json(
|
|
633
|
+
{
|
|
634
|
+
error: "Forbidden",
|
|
635
|
+
error_type: "insufficient_scope",
|
|
636
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
637
|
+
required_scope: SCOPE_ADMIN,
|
|
638
|
+
granted_scopes: auth.scopes,
|
|
639
|
+
},
|
|
640
|
+
{ status: 403 },
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
const manager = getMirrorManager(vaultName);
|
|
644
|
+
if (!manager) {
|
|
645
|
+
return Response.json(
|
|
646
|
+
{
|
|
647
|
+
error: "Mirror manager not initialized",
|
|
648
|
+
message:
|
|
649
|
+
"The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
|
|
650
|
+
},
|
|
651
|
+
{ status: 503 },
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (subpath === "/.parachute/mirror/auth") {
|
|
656
|
+
if (req.method === "GET") return handleAuthGet(manager);
|
|
657
|
+
if (req.method === "DELETE") return handleAuthDelete(manager);
|
|
658
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
659
|
+
}
|
|
660
|
+
if (subpath === "/.parachute/mirror/auth/github/device-code") {
|
|
661
|
+
if (req.method === "POST") return handleAuthGithubDeviceCode();
|
|
662
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
663
|
+
}
|
|
664
|
+
if (subpath === "/.parachute/mirror/auth/github/poll") {
|
|
665
|
+
if (req.method === "POST") return handleAuthGithubPoll(req, manager);
|
|
666
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
667
|
+
}
|
|
668
|
+
if (subpath === "/.parachute/mirror/auth/github/repos") {
|
|
669
|
+
if (req.method === "GET") return handleAuthGithubRepos(manager);
|
|
670
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
671
|
+
}
|
|
672
|
+
if (subpath === "/.parachute/mirror/auth/github/create-repo") {
|
|
673
|
+
if (req.method === "POST") return handleAuthGithubCreateRepo(req, manager);
|
|
674
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
675
|
+
}
|
|
676
|
+
if (subpath === "/.parachute/mirror/auth/github/select-repo") {
|
|
677
|
+
if (req.method === "POST") return handleAuthGithubSelectRepo(req, manager);
|
|
678
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
679
|
+
}
|
|
680
|
+
if (subpath === "/.parachute/mirror/auth/pat") {
|
|
681
|
+
if (req.method === "POST") return handleAuthPat(req, manager);
|
|
682
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
683
|
+
}
|
|
684
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
685
|
+
}
|
|
686
|
+
|
|
514
687
|
const apiMatch = subpath.match(/^\/api(\/.*)?$/);
|
|
515
688
|
if (!apiMatch) {
|
|
516
689
|
return Response.json({ error: "Not found" }, { status: 404 });
|
|
@@ -558,7 +731,7 @@ export async function route(
|
|
|
558
731
|
});
|
|
559
732
|
}
|
|
560
733
|
if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
|
|
561
|
-
if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName);
|
|
734
|
+
if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName, store, tagScope);
|
|
562
735
|
if (apiPath === "/health") return Response.json({ status: "ok", vault: vaultName });
|
|
563
736
|
|
|
564
737
|
return Response.json({ error: "Not found" }, { status: 404 });
|
package/src/server.ts
CHANGED
|
@@ -31,8 +31,19 @@ import { getCachedScribeUrl } from "./scribe-discovery.ts";
|
|
|
31
31
|
import { readEnvFile, setEnvVar } from "./config.ts";
|
|
32
32
|
import { resolveBindHostname } from "./bind.ts";
|
|
33
33
|
import { MirrorManager } from "./mirror-manager.ts";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
setMirrorManager,
|
|
36
|
+
setMirrorManagerFactory,
|
|
37
|
+
listMirrorManagers,
|
|
38
|
+
} from "./mirror-registry.ts";
|
|
35
39
|
import { buildMirrorDeps, resolveMirrorVaultName } from "./mirror-deps.ts";
|
|
40
|
+
import { migrateLegacyServerWideCredentials } from "./mirror-credentials.ts";
|
|
41
|
+
import {
|
|
42
|
+
commentOutLegacyMirrorBlockFile,
|
|
43
|
+
migrateLegacyServerWideConfig,
|
|
44
|
+
readMirrorConfigForVault,
|
|
45
|
+
} from "./mirror-config.ts";
|
|
46
|
+
import { GLOBAL_CONFIG_PATH } from "./config.ts";
|
|
36
47
|
import { selfRegister } from "./self-register.ts";
|
|
37
48
|
import pkg from "../package.json" with { type: "json" };
|
|
38
49
|
|
|
@@ -231,47 +242,96 @@ const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PO
|
|
|
231
242
|
const hostname = resolveBindHostname();
|
|
232
243
|
|
|
233
244
|
// ---------------------------------------------------------------------------
|
|
234
|
-
// Mirror lifecycle (vault
|
|
245
|
+
// Mirror lifecycle — per-vault (vault#400).
|
|
235
246
|
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
247
|
+
// Before vault#400 the server stood up a SINGLE mirror manager bound to the
|
|
248
|
+
// mirror-owning vault (default/first), and every mirror route resolved to it
|
|
249
|
+
// — so every vault's mirror page showed the default vault's config + git
|
|
250
|
+
// remote. Now each vault gets its OWN config (`data/<vault>/mirror-config.yaml`)
|
|
251
|
+
// + its OWN manager. Boot:
|
|
241
252
|
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
253
|
+
// 1. Migrate the legacy SERVER-WIDE credentials file (vault#399) + the
|
|
254
|
+
// legacy server-wide `mirror:` config block (vault#400) to the
|
|
255
|
+
// mirror-owning vault (default/first). Other vaults start unconfigured.
|
|
256
|
+
// 2. Install the lazy-build factory so routes can stand up a manager for
|
|
257
|
+
// any vault on first request (handles a vault configured at runtime
|
|
258
|
+
// without a restart).
|
|
259
|
+
// 3. Iterate ALL vaults; for each whose per-vault config has
|
|
260
|
+
// `enabled: true`, build + register + start its manager. Per-vault
|
|
261
|
+
// failure is logged + isolated — one vault's mirror error never breaks
|
|
262
|
+
// another vault's mirror or the vault server's serving path.
|
|
244
263
|
// ---------------------------------------------------------------------------
|
|
245
|
-
let mirrorManager: MirrorManager | null = null;
|
|
246
264
|
{
|
|
247
|
-
// Canonicalized in `mirror-deps.ts:resolveMirrorVaultName`
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// question 2) only has to touch one site.
|
|
265
|
+
// Canonicalized in `mirror-deps.ts:resolveMirrorVaultName` — the binding
|
|
266
|
+
// rule (default_vault → first listed vault → null) lives in exactly one
|
|
267
|
+
// place. Post-vault#400 it's only used for migration attribution.
|
|
251
268
|
const mirrorVaultName = resolveMirrorVaultName(listVaults);
|
|
252
|
-
|
|
269
|
+
|
|
270
|
+
// vault#399: migrate the legacy SERVER-WIDE credentials file to the
|
|
271
|
+
// per-vault layout, attributed to the mirror-owning vault. Idempotent +
|
|
272
|
+
// safe (no-op when no legacy file / already migrated; legacy file renamed
|
|
273
|
+
// `.bak`, never deleted). Runs even with no vaults (no-op).
|
|
274
|
+
try {
|
|
275
|
+
migrateLegacyServerWideCredentials(mirrorVaultName);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.warn(
|
|
278
|
+
`[mirror] legacy credential migration failed (non-fatal): ${(err as Error).message ?? err}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// vault#400: migrate the legacy server-wide `mirror:` CONFIG block from
|
|
283
|
+
// config.yaml to the mirror-owning vault's per-vault config file. The
|
|
284
|
+
// legacy block is commented out in place (preserved for reference), never
|
|
285
|
+
// silently dropped. Idempotent: no-op when no block / target already
|
|
286
|
+
// configured. (#399 already moved credentials — we do NOT re-migrate them.)
|
|
287
|
+
try {
|
|
288
|
+
migrateLegacyServerWideConfig(
|
|
289
|
+
readGlobalConfig().mirror,
|
|
290
|
+
mirrorVaultName,
|
|
291
|
+
() => commentOutLegacyMirrorBlockFile(GLOBAL_CONFIG_PATH),
|
|
292
|
+
);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.warn(
|
|
295
|
+
`[mirror] legacy config migration failed (non-fatal): ${(err as Error).message ?? err}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Install the lazy-build factory so routes can stand up a per-vault manager
|
|
300
|
+
// on demand (a vault configured at runtime works without a restart).
|
|
301
|
+
setMirrorManagerFactory((name) => new MirrorManager(buildMirrorDeps(name)));
|
|
302
|
+
|
|
303
|
+
// Stand up every vault whose per-vault config is enabled. Don't block
|
|
304
|
+
// server startup on slow initial exports — kick each off in the background.
|
|
305
|
+
const vaults = listVaults();
|
|
306
|
+
if (vaults.length === 0) {
|
|
307
|
+
console.log("[mirror] no vaults yet — managers stand up on next restart after a vault exists");
|
|
308
|
+
}
|
|
309
|
+
for (const name of vaults) {
|
|
310
|
+
let cfg;
|
|
311
|
+
try {
|
|
312
|
+
cfg = readMirrorConfigForVault(name);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
console.warn(`[mirror] could not read config for vault "${name}" (skipping): ${(err as Error).message ?? err}`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!cfg?.enabled) continue; // unconfigured / disabled → no manager work
|
|
253
318
|
try {
|
|
254
|
-
|
|
255
|
-
setMirrorManager(
|
|
256
|
-
|
|
257
|
-
// in the background, log the outcome. The HTTP server comes up
|
|
258
|
-
// immediately so OAuth + REST aren't blocked behind a multi-second
|
|
259
|
-
// export pass.
|
|
260
|
-
void mirrorManager
|
|
319
|
+
const mgr = new MirrorManager(buildMirrorDeps(name));
|
|
320
|
+
setMirrorManager(name, mgr);
|
|
321
|
+
void mgr
|
|
261
322
|
.start()
|
|
262
323
|
.then((status) => {
|
|
263
324
|
if (!status.enabled && status.last_error) {
|
|
264
|
-
console.warn(`[mirror] startup error: ${status.last_error}`);
|
|
325
|
+
console.warn(`[mirror] vault "${name}" startup error: ${status.last_error}`);
|
|
265
326
|
}
|
|
266
327
|
})
|
|
267
328
|
.catch((err) => {
|
|
268
|
-
console.warn(`[mirror] startup threw: ${(err as Error).message ?? err}`);
|
|
329
|
+
console.warn(`[mirror] vault "${name}" startup threw: ${(err as Error).message ?? err}`);
|
|
269
330
|
});
|
|
270
331
|
} catch (err) {
|
|
271
|
-
|
|
332
|
+
// Isolated per-vault: one vault's failure never touches others.
|
|
333
|
+
console.warn(`[mirror] vault "${name}" manager construction failed: ${(err as Error).message ?? err}`);
|
|
272
334
|
}
|
|
273
|
-
} else {
|
|
274
|
-
console.log("[mirror] no vaults yet — manager will be initialized on next restart after a vault exists");
|
|
275
335
|
}
|
|
276
336
|
}
|
|
277
337
|
|
|
@@ -315,18 +375,39 @@ const server = Bun.serve({
|
|
|
315
375
|
console.log(`Parachute Vault server listening on http://${hostname}:${server.port}`);
|
|
316
376
|
|
|
317
377
|
// Graceful shutdown — best-effort drain of in-flight note-mutation hooks.
|
|
378
|
+
//
|
|
379
|
+
// Order matters under the event-driven mirror shape (vault#382):
|
|
380
|
+
// 1. Every MirrorManager.stop() runs FIRST (vault#400: drain ALL per-vault
|
|
381
|
+
// managers, not just one) so they unsubscribe from hooks cleanly +
|
|
382
|
+
// cancel their debounce timers. Otherwise the registry drain below
|
|
383
|
+
// would wait on a manager's hook handler that just queued itself after
|
|
384
|
+
// a final mutation.
|
|
385
|
+
// 2. Then drain hooks + stop the transcription worker in parallel —
|
|
386
|
+
// neither depends on the other.
|
|
387
|
+
//
|
|
388
|
+
// The whole thing races a 5s timeout so a stuck handler doesn't hang
|
|
389
|
+
// shutdown indefinitely.
|
|
318
390
|
async function shutdown(signal: string): Promise<void> {
|
|
319
391
|
console.log(`\n[${signal}] shutting down; in-flight hooks: ${defaultHookRegistry.inFlightCount}`);
|
|
320
392
|
try {
|
|
321
393
|
await Promise.race([
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
394
|
+
(async () => {
|
|
395
|
+
// Mirror stop first — unsubscribes + cancels each debounce. Each
|
|
396
|
+
// manager's internal soft-settle timeout (250ms) bounds the wait;
|
|
397
|
+
// drain them in parallel so total shutdown stays bounded.
|
|
398
|
+
await Promise.all(
|
|
399
|
+
listMirrorManagers().map((m) =>
|
|
400
|
+
m.stop().catch((err) =>
|
|
401
|
+
console.warn(`[mirror] stop threw during shutdown (non-fatal): ${(err as Error).message ?? err}`),
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
// Then drain hooks + stop the transcription worker in parallel.
|
|
406
|
+
await Promise.all([
|
|
407
|
+
defaultHookRegistry.drain(),
|
|
408
|
+
transcriptionWorker?.stop() ?? Promise.resolve(),
|
|
409
|
+
]);
|
|
410
|
+
})(),
|
|
330
411
|
new Promise<void>((resolve) => setTimeout(resolve, 5000)),
|
|
331
412
|
]);
|
|
332
413
|
} catch (err) {
|