@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.
Files changed (62) hide show
  1. package/README.md +51 -54
  2. package/core/src/core.test.ts +4 -1
  3. package/core/src/indexed-fields.test.ts +151 -0
  4. package/core/src/indexed-fields.ts +98 -0
  5. package/core/src/mcp.ts +66 -43
  6. package/core/src/notes.ts +26 -2
  7. package/core/src/portable-md.test.ts +52 -0
  8. package/core/src/portable-md.ts +48 -0
  9. package/core/src/schema.ts +87 -14
  10. package/core/src/store.ts +117 -0
  11. package/core/src/types.ts +28 -0
  12. package/package.json +2 -2
  13. package/src/auth-hub-jwt.test.ts +191 -11
  14. package/src/auth-status.ts +12 -5
  15. package/src/auth.test.ts +135 -219
  16. package/src/auth.ts +158 -107
  17. package/src/cli.ts +306 -224
  18. package/src/config.ts +12 -4
  19. package/src/export-watch.test.ts +23 -0
  20. package/src/export-watch.ts +14 -0
  21. package/src/git-preflight.test.ts +70 -0
  22. package/src/git-preflight.ts +68 -0
  23. package/src/hub-jwt.test.ts +27 -2
  24. package/src/hub-jwt.ts +10 -0
  25. package/src/init-summary.test.ts +4 -4
  26. package/src/init-summary.ts +36 -10
  27. package/src/mcp-config.test.ts +4 -2
  28. package/src/mcp-http.ts +24 -3
  29. package/src/mcp-install-interactive.test.ts +33 -71
  30. package/src/mcp-install-interactive.ts +23 -76
  31. package/src/mcp-install.test.ts +156 -55
  32. package/src/mcp-install.ts +109 -3
  33. package/src/mcp-tools.ts +249 -74
  34. package/src/mirror-config.test.ts +107 -0
  35. package/src/mirror-config.ts +275 -9
  36. package/src/mirror-credentials.test.ts +168 -17
  37. package/src/mirror-credentials.ts +155 -32
  38. package/src/mirror-deps.ts +25 -16
  39. package/src/mirror-import.test.ts +122 -16
  40. package/src/mirror-import.ts +50 -16
  41. package/src/mirror-manager.test.ts +51 -0
  42. package/src/mirror-manager.ts +116 -22
  43. package/src/mirror-per-vault.test.ts +519 -0
  44. package/src/mirror-registry.ts +91 -14
  45. package/src/mirror-routes.test.ts +81 -21
  46. package/src/mirror-routes.ts +90 -16
  47. package/src/routes.ts +39 -2
  48. package/src/routing.test.ts +203 -118
  49. package/src/routing.ts +46 -59
  50. package/src/scopes.test.ts +0 -86
  51. package/src/scopes.ts +9 -97
  52. package/src/server.ts +102 -34
  53. package/src/storage.test.ts +132 -7
  54. package/src/token-store.test.ts +88 -169
  55. package/src/token-store.ts +123 -249
  56. package/src/vault-create.test.ts +12 -4
  57. package/src/vault.test.ts +408 -103
  58. package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
  59. package/web/ui/dist/index.html +1 -1
  60. package/src/tokens-routes.test.ts +0 -727
  61. package/src/tokens-routes.ts +0 -392
  62. 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: {
@@ -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
- const config = manager.getConfig();
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
- const shape = validateMirrorConfigShape(body);
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
- const pathCheck = await validateExternalPath(config.external_path);
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 existing = readCredentials() ?? emptyCredentials();
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.6.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(req: Request, path: string, vault: string): Promise<Response> {
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";