@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2
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/README.md +51 -54
- package/core/src/core.test.ts +4 -1
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +66 -43
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +52 -0
- package/core/src/portable-md.ts +48 -0
- package/core/src/schema.ts +87 -14
- package/core/src/store.ts +117 -0
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +191 -11
- package/src/auth-status.ts +12 -5
- package/src/auth.test.ts +135 -219
- package/src/auth.ts +158 -107
- package/src/cli.ts +306 -224
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/init-summary.test.ts +4 -4
- package/src/init-summary.ts +36 -10
- package/src/mcp-config.test.ts +4 -2
- package/src/mcp-http.ts +24 -3
- package/src/mcp-install-interactive.test.ts +33 -71
- package/src/mcp-install-interactive.ts +23 -76
- package/src/mcp-install.test.ts +156 -55
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +249 -74
- package/src/mirror-config.test.ts +107 -0
- package/src/mirror-config.ts +275 -9
- package/src/mirror-credentials.test.ts +168 -17
- package/src/mirror-credentials.ts +155 -32
- package/src/mirror-deps.ts +25 -16
- package/src/mirror-import.test.ts +122 -16
- package/src/mirror-import.ts +50 -16
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +116 -22
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +81 -21
- package/src/mirror-routes.ts +90 -16
- package/src/routes.ts +39 -2
- package/src/routing.test.ts +203 -118
- package/src/routing.ts +46 -59
- package/src/scopes.test.ts +0 -86
- package/src/scopes.ts +9 -97
- package/src/server.ts +102 -34
- package/src/storage.test.ts +132 -7
- package/src/token-store.test.ts +88 -169
- package/src/token-store.ts +123 -249
- package/src/vault-create.test.ts +12 -4
- package/src/vault.test.ts +408 -103
- package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/tokens-routes.test.ts +0 -727
- package/src/tokens-routes.ts +0 -392
- package/web/ui/dist/assets/index-Degr8snN.js +0 -60
|
@@ -245,6 +245,37 @@ describe("handleMirrorPut", () => {
|
|
|
245
245
|
}
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
+
test("external + git not installed → 503 git_not_installed + actionable message", async () => {
|
|
249
|
+
// vault#415 nit — handleMirrorPut validates the external path via
|
|
250
|
+
// validateExternalPath, which shells `git`. On a git-less server it must
|
|
251
|
+
// return the friendly 503 (consistent with the import route), not let a
|
|
252
|
+
// raw "Executable not found" crash out. Force the preflight via the
|
|
253
|
+
// whichOverride seam against a REAL git repo so the only failure is the
|
|
254
|
+
// preflight.
|
|
255
|
+
home = tmp("mirror-put-nogit-installed-");
|
|
256
|
+
const { manager } = makeManager(home);
|
|
257
|
+
const external = tmp("mirror-put-nogit-target-");
|
|
258
|
+
initRepo(external);
|
|
259
|
+
try {
|
|
260
|
+
const req = new Request("http://x/admin/mirror", {
|
|
261
|
+
method: "PUT",
|
|
262
|
+
body: JSON.stringify({
|
|
263
|
+
enabled: true,
|
|
264
|
+
location: "external",
|
|
265
|
+
external_path: external,
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
const res = await handleMirrorPut(req, manager, () => null);
|
|
269
|
+
expect(res.status).toBe(503);
|
|
270
|
+
const body = (await res.json()) as { error_type: string; message: string };
|
|
271
|
+
expect(body.error_type).toBe("git_not_installed");
|
|
272
|
+
expect(body.message).toContain("git is required");
|
|
273
|
+
expect(body.message).toContain("dnf install git");
|
|
274
|
+
} finally {
|
|
275
|
+
fs.rmSync(external, { recursive: true, force: true });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
248
279
|
test("accepts a valid external config, persists, restarts watch", async () => {
|
|
249
280
|
home = tmp("mirror-put-happy-");
|
|
250
281
|
const external = tmp("mirror-put-ext-");
|
|
@@ -506,8 +537,8 @@ describe("auth credential routes — GET /auth", () => {
|
|
|
506
537
|
|
|
507
538
|
test("returns fully-null sanitized shape when no credentials stored", async () => {
|
|
508
539
|
home = tmp("mirror-auth-empty-");
|
|
509
|
-
makeManager(home);
|
|
510
|
-
const res = handleAuthGet();
|
|
540
|
+
const { manager } = makeManager(home);
|
|
541
|
+
const res = handleAuthGet(manager);
|
|
511
542
|
expect(res.status).toBe(200);
|
|
512
543
|
const body = (await res.json()) as { active_method: null; github_oauth: null; pat: null };
|
|
513
544
|
expect(body.active_method).toBeNull();
|
|
@@ -517,7 +548,7 @@ describe("auth credential routes — GET /auth", () => {
|
|
|
517
548
|
|
|
518
549
|
test("returns sanitized shape when github_oauth credentials present (no token leaks)", async () => {
|
|
519
550
|
home = tmp("mirror-auth-oauth-");
|
|
520
|
-
makeManager(home);
|
|
551
|
+
const { manager } = makeManager(home);
|
|
521
552
|
const creds: MirrorCredentials = {
|
|
522
553
|
active_method: "github_oauth",
|
|
523
554
|
github_oauth: {
|
|
@@ -529,8 +560,8 @@ describe("auth credential routes — GET /auth", () => {
|
|
|
529
560
|
},
|
|
530
561
|
pat: null,
|
|
531
562
|
};
|
|
532
|
-
writeCredentials(creds);
|
|
533
|
-
const res = handleAuthGet();
|
|
563
|
+
writeCredentials("default", creds);
|
|
564
|
+
const res = handleAuthGet(manager);
|
|
534
565
|
expect(res.status).toBe(200);
|
|
535
566
|
const text = await res.text();
|
|
536
567
|
expect(text).not.toContain("gho_secret");
|
|
@@ -550,7 +581,7 @@ describe("auth credential routes — DELETE /auth", () => {
|
|
|
550
581
|
test("wipes credentials from disk", async () => {
|
|
551
582
|
home = tmp("mirror-auth-delete-");
|
|
552
583
|
const { manager } = makeManager(home);
|
|
553
|
-
writeCredentials({
|
|
584
|
+
writeCredentials("default", {
|
|
554
585
|
active_method: "pat",
|
|
555
586
|
github_oauth: null,
|
|
556
587
|
pat: {
|
|
@@ -559,10 +590,10 @@ describe("auth credential routes — DELETE /auth", () => {
|
|
|
559
590
|
label: "test",
|
|
560
591
|
},
|
|
561
592
|
});
|
|
562
|
-
expect(fs.existsSync(mirrorCredentialsPath())).toBe(true);
|
|
593
|
+
expect(fs.existsSync(mirrorCredentialsPath("default"))).toBe(true);
|
|
563
594
|
const res = await handleAuthDelete(manager);
|
|
564
595
|
expect(res.status).toBe(200);
|
|
565
|
-
expect(fs.existsSync(mirrorCredentialsPath())).toBe(false);
|
|
596
|
+
expect(fs.existsSync(mirrorCredentialsPath("default"))).toBe(false);
|
|
566
597
|
});
|
|
567
598
|
|
|
568
599
|
test("idempotent — missing credentials file still 200", async () => {
|
|
@@ -686,7 +717,7 @@ describe("auth credential routes — device flow", () => {
|
|
|
686
717
|
expect(grantBody.user.id).toBe(12345);
|
|
687
718
|
// Credentials persisted; no token leak in response.
|
|
688
719
|
expect(JSON.stringify(grantBody)).not.toContain("gho_real");
|
|
689
|
-
const saved = readCredentials();
|
|
720
|
+
const saved = readCredentials("default");
|
|
690
721
|
expect(saved?.active_method).toBe("github_oauth");
|
|
691
722
|
expect(saved?.github_oauth?.access_token).toBe("gho_real1234567890");
|
|
692
723
|
expect(saved?.github_oauth?.user_login).toBe("aaron");
|
|
@@ -809,7 +840,7 @@ describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)",
|
|
|
809
840
|
// Seed github_oauth credentials. select-repo doesn't probe; it just
|
|
810
841
|
// sets origin to github.com/<owner>/<repo>.git. We then rewrite
|
|
811
842
|
// origin to our local bare repo so pushNow has somewhere to land.
|
|
812
|
-
writeCredentials({
|
|
843
|
+
writeCredentials("default", {
|
|
813
844
|
active_method: "github_oauth",
|
|
814
845
|
github_oauth: {
|
|
815
846
|
access_token: "gho_fake1234567890abcd",
|
|
@@ -865,7 +896,7 @@ describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)",
|
|
|
865
896
|
auto_push: false,
|
|
866
897
|
});
|
|
867
898
|
await manager.start();
|
|
868
|
-
writeCredentials({
|
|
899
|
+
writeCredentials("default", {
|
|
869
900
|
active_method: "github_oauth",
|
|
870
901
|
github_oauth: {
|
|
871
902
|
access_token: "gho_anothertoken12345",
|
|
@@ -943,15 +974,15 @@ describe("auth credential routes — github repos / create-repo", () => {
|
|
|
943
974
|
|
|
944
975
|
test("repos returns 400 when not connected to GitHub", async () => {
|
|
945
976
|
home = tmp("mirror-auth-repos-noauth-");
|
|
946
|
-
makeManager(home);
|
|
947
|
-
const res = await handleAuthGithubRepos();
|
|
977
|
+
const { manager } = makeManager(home);
|
|
978
|
+
const res = await handleAuthGithubRepos(manager);
|
|
948
979
|
expect(res.status).toBe(400);
|
|
949
980
|
});
|
|
950
981
|
|
|
951
982
|
test("repos returns list when authed", async () => {
|
|
952
983
|
home = tmp("mirror-auth-repos-ok-");
|
|
953
|
-
makeManager(home);
|
|
954
|
-
writeCredentials({
|
|
984
|
+
const { manager } = makeManager(home);
|
|
985
|
+
writeCredentials("default", {
|
|
955
986
|
active_method: "github_oauth",
|
|
956
987
|
github_oauth: {
|
|
957
988
|
access_token: "gho_test1234567890",
|
|
@@ -979,7 +1010,7 @@ describe("auth credential routes — github repos / create-repo", () => {
|
|
|
979
1010
|
],
|
|
980
1011
|
},
|
|
981
1012
|
]);
|
|
982
|
-
const res = await handleAuthGithubRepos(fetcher);
|
|
1013
|
+
const res = await handleAuthGithubRepos(manager, fetcher);
|
|
983
1014
|
expect(res.status).toBe(200);
|
|
984
1015
|
const body = (await res.json()) as { repos: Array<{ full_name: string }> };
|
|
985
1016
|
expect(body.repos).toHaveLength(1);
|
|
@@ -988,8 +1019,8 @@ describe("auth credential routes — github repos / create-repo", () => {
|
|
|
988
1019
|
|
|
989
1020
|
test("create-repo proxies through with mocked fetch", async () => {
|
|
990
1021
|
home = tmp("mirror-auth-create-repo-");
|
|
991
|
-
makeManager(home);
|
|
992
|
-
writeCredentials({
|
|
1022
|
+
const { manager } = makeManager(home);
|
|
1023
|
+
writeCredentials("default", {
|
|
993
1024
|
active_method: "github_oauth",
|
|
994
1025
|
github_oauth: {
|
|
995
1026
|
access_token: "gho_test1234567890",
|
|
@@ -1020,7 +1051,7 @@ describe("auth credential routes — github repos / create-repo", () => {
|
|
|
1020
1051
|
method: "POST",
|
|
1021
1052
|
body: JSON.stringify({ name: "new-vault" }),
|
|
1022
1053
|
});
|
|
1023
|
-
const res = await handleAuthGithubCreateRepo(req, fetcher);
|
|
1054
|
+
const res = await handleAuthGithubCreateRepo(req, manager, fetcher);
|
|
1024
1055
|
expect(res.status).toBe(200);
|
|
1025
1056
|
const body = (await res.json()) as { full_name: string };
|
|
1026
1057
|
expect(body.full_name).toBe("aaron/new-vault");
|
|
@@ -1258,12 +1289,41 @@ describe("handleMirrorImport", () => {
|
|
|
1258
1289
|
expect(body.message).toContain("vault.yaml");
|
|
1259
1290
|
});
|
|
1260
1291
|
|
|
1292
|
+
test("git not installed returns 503 + git_not_installed + actionable message", async () => {
|
|
1293
|
+
// vault#415 — live bug on a git-less Amazon Linux EC2 box. Force the
|
|
1294
|
+
// preflight (via the whichOverride seam) to see no git; the spawn seam
|
|
1295
|
+
// should never be reached.
|
|
1296
|
+
home = tmp("import-route-nogit-");
|
|
1297
|
+
await bootstrapVault(home);
|
|
1298
|
+
let spawnCalled = false;
|
|
1299
|
+
const spyingSpawn: GitSpawn = async () => {
|
|
1300
|
+
spawnCalled = true;
|
|
1301
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
1302
|
+
};
|
|
1303
|
+
const req = new Request("http://x/import", {
|
|
1304
|
+
method: "POST",
|
|
1305
|
+
body: JSON.stringify({
|
|
1306
|
+
remote_url: "https://github.com/a/b.git",
|
|
1307
|
+
mode: "merge",
|
|
1308
|
+
credentials: { kind: "none" },
|
|
1309
|
+
}),
|
|
1310
|
+
});
|
|
1311
|
+
const res = await handleMirrorImport(req, "default", spyingSpawn, () => null);
|
|
1312
|
+
expect(res.status).toBe(503);
|
|
1313
|
+
const body = (await res.json()) as { error_type: string; message: string };
|
|
1314
|
+
expect(body.error_type).toBe("git_not_installed");
|
|
1315
|
+
expect(body.message).toContain("git is required");
|
|
1316
|
+
expect(body.message).toContain("dnf install git");
|
|
1317
|
+
// Failed fast: the git spawn was never reached.
|
|
1318
|
+
expect(spawnCalled).toBe(false);
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1261
1321
|
test("uses stored credentials when credentials: null (credentialsFile path)", async () => {
|
|
1262
1322
|
home = tmp("import-route-stored-creds-");
|
|
1263
1323
|
await bootstrapVault(home);
|
|
1264
1324
|
fixture = await buildExportFixture();
|
|
1265
1325
|
// Write a stored PAT credential matching github.com.
|
|
1266
|
-
writeCredentials({
|
|
1326
|
+
writeCredentials("default", {
|
|
1267
1327
|
active_method: "pat",
|
|
1268
1328
|
github_oauth: null,
|
|
1269
1329
|
pat: {
|
|
@@ -1301,7 +1361,7 @@ describe("handleMirrorImport", () => {
|
|
|
1301
1361
|
await bootstrapVault(home);
|
|
1302
1362
|
fixture = await buildExportFixture();
|
|
1303
1363
|
// Stored credentials should be IGNORED when per-call PAT is supplied.
|
|
1304
|
-
writeCredentials({
|
|
1364
|
+
writeCredentials("default", {
|
|
1305
1365
|
active_method: "pat",
|
|
1306
1366
|
github_oauth: null,
|
|
1307
1367
|
pat: {
|
package/src/mirror-routes.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
type ImportResult,
|
|
63
63
|
} from "./mirror-import.ts";
|
|
64
64
|
import { redactToken } from "./export-watch.ts";
|
|
65
|
+
import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
|
|
65
66
|
import { getVaultStore } from "./vault-store.ts";
|
|
66
67
|
import { assetsDir } from "./routes.ts";
|
|
67
68
|
|
|
@@ -75,7 +76,11 @@ import { assetsDir } from "./routes.ts";
|
|
|
75
76
|
* any persistence has happened yet.
|
|
76
77
|
*/
|
|
77
78
|
export function handleMirrorGet(manager: MirrorManager): Response {
|
|
78
|
-
|
|
79
|
+
// Per-vault (vault#400): `getEffectiveConfig()` returns THIS vault's
|
|
80
|
+
// persisted config even when the lazily-built manager hasn't started yet,
|
|
81
|
+
// so a non-default vault's page shows ITS config — never the default
|
|
82
|
+
// vault's. That's the exact "same remote on every vault page" symptom.
|
|
83
|
+
const config = manager.getEffectiveConfig();
|
|
79
84
|
const status = manager.getStatus();
|
|
80
85
|
return Response.json(
|
|
81
86
|
{
|
|
@@ -116,6 +121,10 @@ export function handleMirrorGet(manager: MirrorManager): Response {
|
|
|
116
121
|
export async function handleMirrorPut(
|
|
117
122
|
req: Request,
|
|
118
123
|
manager: MirrorManager,
|
|
124
|
+
// Test seam for the external-path git-presence preflight (default
|
|
125
|
+
// `Bun.which` inside `validateExternalPath`). Inject a fn returning `null`
|
|
126
|
+
// to exercise the git_not_installed 503 path without uninstalling git.
|
|
127
|
+
whichOverride?: (cmd: string) => string | null,
|
|
119
128
|
): Promise<Response> {
|
|
120
129
|
let body: unknown;
|
|
121
130
|
try {
|
|
@@ -130,7 +139,9 @@ export async function handleMirrorPut(
|
|
|
130
139
|
);
|
|
131
140
|
}
|
|
132
141
|
|
|
133
|
-
|
|
142
|
+
// Per-vault (vault#399): bind the auto_push/internal credential gate to
|
|
143
|
+
// the mirror-owning vault so it reads that vault's own credentials file.
|
|
144
|
+
const shape = validateMirrorConfigShape(body, { vaultName: manager.getVaultName() });
|
|
134
145
|
if (!shape.ok) {
|
|
135
146
|
return Response.json(
|
|
136
147
|
{
|
|
@@ -148,7 +159,25 @@ export async function handleMirrorPut(
|
|
|
148
159
|
// to *do* something with an external path. Disabling the mirror by-
|
|
149
160
|
// flipping enabled to false shouldn't fail because the path went away.
|
|
150
161
|
if (config.enabled && config.location === "external" && config.external_path) {
|
|
151
|
-
|
|
162
|
+
let pathCheck;
|
|
163
|
+
try {
|
|
164
|
+
pathCheck = await validateExternalPath(config.external_path, whichOverride);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err instanceof GitNotInstalledError) {
|
|
167
|
+
// 503 git_not_installed — consistent with the import route. The
|
|
168
|
+
// server can't validate (or later sync) an external git mirror
|
|
169
|
+
// without git installed; the message tells the operator how to fix.
|
|
170
|
+
return Response.json(
|
|
171
|
+
{
|
|
172
|
+
error: "git not installed",
|
|
173
|
+
error_type: "git_not_installed",
|
|
174
|
+
message: err.message,
|
|
175
|
+
},
|
|
176
|
+
{ status: 503 },
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
152
181
|
if (!pathCheck.ok) {
|
|
153
182
|
return Response.json(
|
|
154
183
|
{
|
|
@@ -456,7 +485,8 @@ export async function handleAuthGithubPoll(
|
|
|
456
485
|
);
|
|
457
486
|
}
|
|
458
487
|
// Persist credentials. Keep any existing PAT — only swap active method.
|
|
459
|
-
const
|
|
488
|
+
const vaultName = manager.getVaultName();
|
|
489
|
+
const existing = readCredentials(vaultName) ?? emptyCredentials();
|
|
460
490
|
const next: MirrorCredentials = {
|
|
461
491
|
...existing,
|
|
462
492
|
active_method: "github_oauth",
|
|
@@ -469,7 +499,7 @@ export async function handleAuthGithubPoll(
|
|
|
469
499
|
},
|
|
470
500
|
};
|
|
471
501
|
try {
|
|
472
|
-
writeCredentials(next);
|
|
502
|
+
writeCredentials(vaultName, next);
|
|
473
503
|
} catch (err) {
|
|
474
504
|
return Response.json(
|
|
475
505
|
{
|
|
@@ -590,6 +620,26 @@ export async function handleAuthPat(
|
|
|
590
620
|
);
|
|
591
621
|
}
|
|
592
622
|
|
|
623
|
+
// Preflight: the validation probe (`git ls-remote`) and every later push
|
|
624
|
+
// shell `git`. On a git-less server, surface the friendly, actionable
|
|
625
|
+
// 503 instead of letting the probe's `Bun.spawn` throw a raw
|
|
626
|
+
// "Executable not found in $PATH: \"git\"".
|
|
627
|
+
try {
|
|
628
|
+
ensureGitAvailable();
|
|
629
|
+
} catch (err) {
|
|
630
|
+
if (err instanceof GitNotInstalledError) {
|
|
631
|
+
return Response.json(
|
|
632
|
+
{
|
|
633
|
+
error: "git not installed",
|
|
634
|
+
error_type: "git_not_installed",
|
|
635
|
+
message: err.message,
|
|
636
|
+
},
|
|
637
|
+
{ status: 503 },
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
throw err;
|
|
641
|
+
}
|
|
642
|
+
|
|
593
643
|
// Validate via `git ls-remote <embedded-auth-url>` — uses the same
|
|
594
644
|
// x-access-token shape we'd embed at push time so the probe exercises
|
|
595
645
|
// the actual auth path. If the operator pasted a URL that already has
|
|
@@ -615,9 +665,12 @@ export async function handleAuthPat(
|
|
|
615
665
|
}
|
|
616
666
|
|
|
617
667
|
// Save the userinfo'd URL — that's what gets embedded as `origin` so
|
|
618
|
-
// bare `git push` works without needing GIT_ASKPASS etc.
|
|
668
|
+
// bare `git push` works without needing GIT_ASKPASS etc. Per-vault
|
|
669
|
+
// (vault#399): the PAT + remote_url land in this vault's own file, not a
|
|
670
|
+
// server-wide one — so configuring vault B never reuses vault A's remote.
|
|
671
|
+
const vaultName = manager.getVaultName();
|
|
619
672
|
const next: MirrorCredentials = {
|
|
620
|
-
...(readCredentials() ?? emptyCredentials()),
|
|
673
|
+
...(readCredentials(vaultName) ?? emptyCredentials()),
|
|
621
674
|
active_method: "pat",
|
|
622
675
|
pat: {
|
|
623
676
|
token,
|
|
@@ -626,7 +679,7 @@ export async function handleAuthPat(
|
|
|
626
679
|
},
|
|
627
680
|
};
|
|
628
681
|
try {
|
|
629
|
-
writeCredentials(next);
|
|
682
|
+
writeCredentials(vaultName, next);
|
|
630
683
|
} catch (err) {
|
|
631
684
|
return Response.json(
|
|
632
685
|
{
|
|
@@ -721,10 +774,11 @@ async function probeGitLsRemote(
|
|
|
721
774
|
}
|
|
722
775
|
|
|
723
776
|
/**
|
|
724
|
-
* `GET /.parachute/mirror/auth` — connection status (no secrets).
|
|
777
|
+
* `GET /.parachute/mirror/auth` — connection status (no secrets). Reads the
|
|
778
|
+
* mirror-owning vault's per-vault credentials (vault#399).
|
|
725
779
|
*/
|
|
726
|
-
export function handleAuthGet(): Response {
|
|
727
|
-
const creds = readCredentials();
|
|
780
|
+
export function handleAuthGet(manager: MirrorManager): Response {
|
|
781
|
+
const creds = readCredentials(manager.getVaultName());
|
|
728
782
|
return Response.json(sanitizeCredentials(creds), {
|
|
729
783
|
headers: { "Access-Control-Allow-Origin": "*" },
|
|
730
784
|
});
|
|
@@ -735,7 +789,7 @@ export function handleAuthGet(): Response {
|
|
|
735
789
|
* remote URL on the mirror dir.
|
|
736
790
|
*/
|
|
737
791
|
export async function handleAuthDelete(manager: MirrorManager): Promise<Response> {
|
|
738
|
-
deleteCredentials();
|
|
792
|
+
deleteCredentials(manager.getVaultName());
|
|
739
793
|
// Strip origin from the mirror dir if one is set.
|
|
740
794
|
const status = manager.getStatus();
|
|
741
795
|
if (status.mirror_path) {
|
|
@@ -757,9 +811,10 @@ export async function handleAuthDelete(manager: MirrorManager): Promise<Response
|
|
|
757
811
|
* the stored OAuth token. Requires `active_method === "github_oauth"`.
|
|
758
812
|
*/
|
|
759
813
|
export async function handleAuthGithubRepos(
|
|
814
|
+
manager: MirrorManager,
|
|
760
815
|
fetchImpl?: FetchLike,
|
|
761
816
|
): Promise<Response> {
|
|
762
|
-
const creds = readCredentials();
|
|
817
|
+
const creds = readCredentials(manager.getVaultName());
|
|
763
818
|
if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
|
|
764
819
|
return Response.json(
|
|
765
820
|
{
|
|
@@ -794,9 +849,10 @@ export async function handleAuthGithubRepos(
|
|
|
794
849
|
*/
|
|
795
850
|
export async function handleAuthGithubCreateRepo(
|
|
796
851
|
req: Request,
|
|
852
|
+
manager: MirrorManager,
|
|
797
853
|
fetchImpl?: FetchLike,
|
|
798
854
|
): Promise<Response> {
|
|
799
|
-
const creds = readCredentials();
|
|
855
|
+
const creds = readCredentials(manager.getVaultName());
|
|
800
856
|
if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
|
|
801
857
|
return Response.json(
|
|
802
858
|
{
|
|
@@ -849,7 +905,7 @@ export async function handleAuthGithubSelectRepo(
|
|
|
849
905
|
req: Request,
|
|
850
906
|
manager: MirrorManager,
|
|
851
907
|
): Promise<Response> {
|
|
852
|
-
const creds = readCredentials();
|
|
908
|
+
const creds = readCredentials(manager.getVaultName());
|
|
853
909
|
if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
|
|
854
910
|
return Response.json(
|
|
855
911
|
{
|
|
@@ -1023,7 +1079,7 @@ export async function applyCredentialsToMirror(
|
|
|
1023
1079
|
): Promise<void> {
|
|
1024
1080
|
const status = manager.getStatus();
|
|
1025
1081
|
if (!status.mirror_path) return;
|
|
1026
|
-
const creds = readCredentials();
|
|
1082
|
+
const creds = readCredentials(manager.getVaultName());
|
|
1027
1083
|
if (!creds || !creds.active_method) {
|
|
1028
1084
|
await unsetGitRemote(status.mirror_path);
|
|
1029
1085
|
return;
|
|
@@ -1076,11 +1132,16 @@ export async function applyCredentialsToMirror(
|
|
|
1076
1132
|
*
|
|
1077
1133
|
* `spawnOverride` is a test seam: lets the test inject a fake git binary.
|
|
1078
1134
|
* Production callers omit it; `cloneAndImport` falls back to `defaultGitSpawn`.
|
|
1135
|
+
*
|
|
1136
|
+
* `whichOverride` is a test seam for the git-presence preflight (default
|
|
1137
|
+
* `Bun.which` inside `cloneAndImport`). Inject a fn returning `null` to
|
|
1138
|
+
* exercise the git_not_installed 503 path without uninstalling git.
|
|
1079
1139
|
*/
|
|
1080
1140
|
export async function handleMirrorImport(
|
|
1081
1141
|
req: Request,
|
|
1082
1142
|
vaultName: string,
|
|
1083
1143
|
spawnOverride?: GitSpawn,
|
|
1144
|
+
whichOverride?: (cmd: string) => string | null,
|
|
1084
1145
|
): Promise<Response> {
|
|
1085
1146
|
let body: {
|
|
1086
1147
|
remote_url?: unknown;
|
|
@@ -1195,8 +1256,21 @@ export async function handleMirrorImport(
|
|
|
1195
1256
|
store,
|
|
1196
1257
|
assetsDir: assets,
|
|
1197
1258
|
spawn: spawnOverride,
|
|
1259
|
+
which: whichOverride,
|
|
1198
1260
|
});
|
|
1199
1261
|
} catch (err) {
|
|
1262
|
+
if (err instanceof GitNotInstalledError) {
|
|
1263
|
+
// 503 Service Unavailable — the server isn't configured to do this
|
|
1264
|
+
// yet (git missing). The message tells the operator how to fix it.
|
|
1265
|
+
return Response.json(
|
|
1266
|
+
{
|
|
1267
|
+
error: "git not installed",
|
|
1268
|
+
error_type: "git_not_installed",
|
|
1269
|
+
message: err.message,
|
|
1270
|
+
},
|
|
1271
|
+
{ status: 503 },
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1200
1274
|
if (err instanceof ImportConflictError) {
|
|
1201
1275
|
return Response.json(
|
|
1202
1276
|
{
|
package/src/routes.ts
CHANGED
|
@@ -564,7 +564,7 @@ async function handleNotesInner(
|
|
|
564
564
|
//
|
|
565
565
|
// - **Flat date params** (DEPRECATED): `?date_field=created_at&
|
|
566
566
|
// date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
|
|
567
|
-
// Still functional through 0.5.x; planned removal in 0.
|
|
567
|
+
// Still functional through 0.5.x; planned removal in a later 0.x
|
|
568
568
|
// (vault#288). New consumers should use bracket-style.
|
|
569
569
|
//
|
|
570
570
|
// Precedence on overlap: bracket-style wins. If a caller passes both
|
|
@@ -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";
|