@openparachute/vault 0.6.3 → 0.6.4-rc.10
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 +46 -11
- package/core/src/attribution.test.ts +273 -0
- package/core/src/core.test.ts +126 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +235 -9
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +280 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +51 -19
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +35 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/admin-spa.test.ts +18 -5
- package/src/admin-spa.ts +24 -3
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/cli.ts +345 -9
- package/src/config.ts +11 -0
- package/src/first-boot-create.test.ts +155 -0
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/mirror-credentials.test.ts +47 -0
- package/src/mirror-credentials.ts +33 -0
- package/src/mirror-history.test.ts +426 -0
- package/src/mirror-manager.ts +202 -22
- package/src/mirror-routes.test.ts +78 -0
- package/src/mirror-routes.ts +195 -1
- package/src/module-config.ts +46 -80
- package/src/routes.ts +209 -41
- package/src/routing.test.ts +115 -25
- package/src/routing.ts +64 -2
- package/src/scale.bench.test.ts +82 -0
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/self-register.test.ts +5 -5
- package/src/self-register.ts +8 -3
- package/src/server.ts +58 -38
- package/src/subscribe.test.ts +23 -2
- package/src/test-support/spawn.ts +85 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
- package/src/vault-remove.test.ts +4 -5
- package/src/vault.test.ts +188 -17
package/src/mirror-manager.ts
CHANGED
|
@@ -369,6 +369,170 @@ async function readCurrentOrigin(repoDir: string): Promise<string | null> {
|
|
|
369
369
|
return url.length > 0 ? url : null;
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Git history read surface (vault#300)
|
|
374
|
+
//
|
|
375
|
+
// The vault is already git-backed via the mirror — one file per note, so
|
|
376
|
+
// `git log` IS a tamper-evident, time-travelable, diffable write history.
|
|
377
|
+
// These helpers SURFACE that history through the admin-gated REST + CLI
|
|
378
|
+
// read paths. No write-path change, no schema change, no new table — the
|
|
379
|
+
// history already exists on disk; we just read it.
|
|
380
|
+
//
|
|
381
|
+
// Same Bun.spawn-git shape as `readCommitsUnpushed` / `readCurrentOrigin`
|
|
382
|
+
// above (spawn array argv, capture stdout, parse) so REST + CLI share ONE
|
|
383
|
+
// implementation and there's no drift between them.
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
/** A single commit in the mirror's history. */
|
|
387
|
+
export interface HistoryEntry {
|
|
388
|
+
/** Full commit sha. */
|
|
389
|
+
sha: string;
|
|
390
|
+
/** ISO-8601 author date (`%aI`). */
|
|
391
|
+
date: string;
|
|
392
|
+
/** Commit subject line (`%s`). */
|
|
393
|
+
message: string;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Default cap on how many commits a history read returns. */
|
|
397
|
+
export const HISTORY_DEFAULT_LIMIT = 100;
|
|
398
|
+
/** Hard ceiling — even an explicit `?limit=` can't exceed this. */
|
|
399
|
+
export const HISTORY_MAX_LIMIT = 1000;
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Normalize + validate a note path for use as a `git log -- <path>.md`
|
|
403
|
+
* pathspec. Returns the `<path>.md` argv token, or null when the path is
|
|
404
|
+
* unsafe / empty.
|
|
405
|
+
*
|
|
406
|
+
* The path goes into an ARRAY-spawn argv (no shell), so this isn't guarding
|
|
407
|
+
* against shell injection — it's guarding against directory traversal
|
|
408
|
+
* (`..`) and absolute paths escaping the mirror dir, plus normalizing the
|
|
409
|
+
* leading-slash + trailing-`.md` shape so the pathspec matches the file the
|
|
410
|
+
* exporter actually wrote (`<note.path>.md`, see portable-md.ts).
|
|
411
|
+
*/
|
|
412
|
+
export function noteHistoryPathspec(notePath: string): string | null {
|
|
413
|
+
const trimmed = notePath.trim();
|
|
414
|
+
if (trimmed.length === 0) return null;
|
|
415
|
+
// Reject absolute paths and traversal — the pathspec must stay inside the
|
|
416
|
+
// mirror working tree. A `..` segment (or a leading `/`) could otherwise
|
|
417
|
+
// point `git log` at files outside the vault's note tree.
|
|
418
|
+
if (trimmed.startsWith("/")) return null;
|
|
419
|
+
const segments = trimmed.split("/");
|
|
420
|
+
if (segments.some((s) => s === "..")) return null;
|
|
421
|
+
// The exporter writes `<note.path>.md`. Accept a path the caller already
|
|
422
|
+
// suffixed with `.md` (idempotent) or bare; emit the `.md` form either way.
|
|
423
|
+
const base = trimmed.endsWith(".md") ? trimmed.slice(0, -3) : trimmed;
|
|
424
|
+
if (base.length === 0) return null;
|
|
425
|
+
return `${base}.md`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Read the mirror's commit history via `git log`. Optionally scoped to a
|
|
430
|
+
* single note's file (`opts.notePath` → `git log --follow -- <path>.md`).
|
|
431
|
+
*
|
|
432
|
+
* Returns an ordered (newest-first) array of `{ sha, date, message }`.
|
|
433
|
+
* Tolerant of the not-yet-a-repo / no-commits-yet cases — those return an
|
|
434
|
+
* empty array rather than throwing, so a freshly-bootstrapped (or never-
|
|
435
|
+
* bootstrapped) mirror reads as "no history yet", not a 500.
|
|
436
|
+
*
|
|
437
|
+
* `git log` field separators: we use `%H` (full sha), `%aI` (ISO author
|
|
438
|
+
* date), `%s` (subject). A literal record separator (`\x1f`) joins the
|
|
439
|
+
* three fields and a literal unit separator (`\x1e`) terminates each record
|
|
440
|
+
* so a multi-line-unfriendly subject (it isn't — `%s` is the subject line
|
|
441
|
+
* only) or a commit message containing our delimiters can't desync parsing.
|
|
442
|
+
*
|
|
443
|
+
* The git binary preflight is the CALLER's responsibility (REST + CLI both
|
|
444
|
+
* call `ensureGitAvailable` before this) — but a missing-repo `git log`
|
|
445
|
+
* exits non-zero, which we map to `[]`, so even an un-preflighted call
|
|
446
|
+
* degrades to "empty" rather than a crash.
|
|
447
|
+
*/
|
|
448
|
+
export async function readMirrorHistory(
|
|
449
|
+
repoDir: string,
|
|
450
|
+
opts: { notePath?: string; limit?: number } = {},
|
|
451
|
+
): Promise<HistoryEntry[]> {
|
|
452
|
+
const limit = Math.min(
|
|
453
|
+
Math.max(1, Math.floor(opts.limit ?? HISTORY_DEFAULT_LIMIT)),
|
|
454
|
+
HISTORY_MAX_LIMIT,
|
|
455
|
+
);
|
|
456
|
+
const FIELD_SEP = "\x1f";
|
|
457
|
+
const RECORD_SEP = "\x1e";
|
|
458
|
+
const format = `%H${FIELD_SEP}%aI${FIELD_SEP}%s${RECORD_SEP}`;
|
|
459
|
+
const args = [
|
|
460
|
+
"log",
|
|
461
|
+
`--max-count=${limit}`,
|
|
462
|
+
`--pretty=format:${format}`,
|
|
463
|
+
];
|
|
464
|
+
if (opts.notePath) {
|
|
465
|
+
const spec = noteHistoryPathspec(opts.notePath);
|
|
466
|
+
// An unsafe / empty path yields no history rather than an unscoped log.
|
|
467
|
+
if (spec === null) return [];
|
|
468
|
+
// `--follow` traces the file across renames (the vault renames note
|
|
469
|
+
// files when a note's path changes). `--` ends the option list so the
|
|
470
|
+
// pathspec is never mistaken for a flag.
|
|
471
|
+
args.push("--follow", "--", spec);
|
|
472
|
+
}
|
|
473
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
474
|
+
cwd: repoDir,
|
|
475
|
+
stdout: "pipe",
|
|
476
|
+
stderr: "pipe",
|
|
477
|
+
});
|
|
478
|
+
const exitCode = await proc.exited;
|
|
479
|
+
// Non-zero: not a git repo, no commits yet, or a path with no history.
|
|
480
|
+
// All map to "empty history" — never a thrown error.
|
|
481
|
+
if (exitCode !== 0) return [];
|
|
482
|
+
const out = new TextDecoder().decode(
|
|
483
|
+
await new Response(proc.stdout).arrayBuffer(),
|
|
484
|
+
);
|
|
485
|
+
const entries: HistoryEntry[] = [];
|
|
486
|
+
for (const record of out.split(RECORD_SEP)) {
|
|
487
|
+
const trimmed = record.trim();
|
|
488
|
+
if (trimmed.length === 0) continue;
|
|
489
|
+
const [sha, date, ...rest] = trimmed.split(FIELD_SEP);
|
|
490
|
+
if (!sha || !date) continue;
|
|
491
|
+
entries.push({
|
|
492
|
+
sha,
|
|
493
|
+
date,
|
|
494
|
+
// `%s` never contains FIELD_SEP, but rejoin defensively so a delimiter
|
|
495
|
+
// that somehow slipped into a subject doesn't truncate the message.
|
|
496
|
+
message: redactToken(rest.join(FIELD_SEP)),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return entries;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Read a single past revision of a note file: `git show <sha>:<path>.md`.
|
|
504
|
+
* Returns the file content at that commit, or null when the path is unsafe,
|
|
505
|
+
* the sha/path pair doesn't resolve, or git exits non-zero.
|
|
506
|
+
*
|
|
507
|
+
* The sha + path both go into an array-spawn argv (no shell). The sha is
|
|
508
|
+
* validated to a hex-only shape (git accepts abbreviated shas + symbolic
|
|
509
|
+
* refs, but for this read surface we only allow `[0-9a-f]` so a caller can't
|
|
510
|
+
* smuggle a `..`-style ref or option-looking token). The path runs through
|
|
511
|
+
* the same `noteHistoryPathspec` normalization as the log read.
|
|
512
|
+
*/
|
|
513
|
+
export async function showMirrorRevision(
|
|
514
|
+
repoDir: string,
|
|
515
|
+
sha: string,
|
|
516
|
+
notePath: string,
|
|
517
|
+
): Promise<string | null> {
|
|
518
|
+
const cleanSha = sha.trim();
|
|
519
|
+
// Hex-only, bounded. Covers full + abbreviated shas; rejects refs,
|
|
520
|
+
// ranges, and anything option-looking.
|
|
521
|
+
if (!/^[0-9a-f]{4,64}$/.test(cleanSha)) return null;
|
|
522
|
+
const spec = noteHistoryPathspec(notePath);
|
|
523
|
+
if (spec === null) return null;
|
|
524
|
+
const proc = Bun.spawn(["git", "show", `${cleanSha}:${spec}`], {
|
|
525
|
+
cwd: repoDir,
|
|
526
|
+
stdout: "pipe",
|
|
527
|
+
stderr: "pipe",
|
|
528
|
+
});
|
|
529
|
+
const exitCode = await proc.exited;
|
|
530
|
+
if (exitCode !== 0) return null;
|
|
531
|
+
return new TextDecoder().decode(
|
|
532
|
+
await new Response(proc.stdout).arrayBuffer(),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
372
536
|
/**
|
|
373
537
|
* Singleton lifecycle controller. Holds the active mirror config, the
|
|
374
538
|
* resolved path, hook subscriptions (when sync_mode=events), the
|
|
@@ -1073,33 +1237,49 @@ export class MirrorManager {
|
|
|
1073
1237
|
return;
|
|
1074
1238
|
}
|
|
1075
1239
|
// github_oauth: the active token is set, but a repo may not yet be
|
|
1076
|
-
// picked. The select-repo route handler writes the URL
|
|
1077
|
-
//
|
|
1078
|
-
// owner/repo. Best-effort: if `origin` already points at a github.com
|
|
1079
|
-
// URL, rewrite it with the stored token in case the token rotated.
|
|
1240
|
+
// picked. The select-repo route handler writes the URL onto origin AND
|
|
1241
|
+
// (vault#401) persists owner/name into the credentials struct.
|
|
1080
1242
|
if (creds.active_method === "github_oauth" && creds.github_oauth) {
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1243
|
+
// vault#401: prefer the persisted owner/name. Reconstructing the
|
|
1244
|
+
// remote from credentials alone means the selection survives the
|
|
1245
|
+
// origin being cleared (DELETE /auth then re-OAuth) across a restart —
|
|
1246
|
+
// the bug class where regex-parsing the origin silently lost the repo.
|
|
1247
|
+
const persistedOwner = creds.github_oauth.owner;
|
|
1248
|
+
const persistedName = creds.github_oauth.name;
|
|
1249
|
+
let owner: string | undefined;
|
|
1250
|
+
let repo: string | undefined;
|
|
1251
|
+
if (persistedOwner && persistedName) {
|
|
1252
|
+
owner = persistedOwner;
|
|
1253
|
+
repo = persistedName;
|
|
1254
|
+
} else {
|
|
1255
|
+
// Back-compat fallback: credentials written before #401 don't carry
|
|
1256
|
+
// owner/name. Parse them out of the current github.com origin (the
|
|
1257
|
+
// pre-#401 behavior) when origin is still present.
|
|
1258
|
+
const current = await readCurrentOrigin(repoDir);
|
|
1259
|
+
if (current && current.includes("github.com")) {
|
|
1260
|
+
const match = current.match(
|
|
1261
|
+
/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?$/,
|
|
1094
1262
|
);
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
`[mirror] could not refresh github_oauth remote URL (non-fatal): ${result.error}`,
|
|
1099
|
-
);
|
|
1263
|
+
if (match) {
|
|
1264
|
+
owner = match[1];
|
|
1265
|
+
repo = match[2];
|
|
1100
1266
|
}
|
|
1101
1267
|
}
|
|
1102
1268
|
}
|
|
1269
|
+
if (owner && repo) {
|
|
1270
|
+
const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
|
|
1271
|
+
const authed = githubAuthedRemoteUrl(
|
|
1272
|
+
creds.github_oauth.access_token,
|
|
1273
|
+
owner,
|
|
1274
|
+
repo,
|
|
1275
|
+
);
|
|
1276
|
+
const result = await applyToGitRemote(repoDir, authed);
|
|
1277
|
+
if (!result.ok) {
|
|
1278
|
+
console.warn(
|
|
1279
|
+
`[mirror] could not refresh github_oauth remote URL (non-fatal): ${result.error}`,
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1103
1283
|
}
|
|
1104
1284
|
} catch (err) {
|
|
1105
1285
|
console.warn(
|
|
@@ -912,6 +912,84 @@ describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)",
|
|
|
912
912
|
await manager.stop();
|
|
913
913
|
}, 30_000);
|
|
914
914
|
|
|
915
|
+
test("vault#401: owner/name survive a cleared origin + restart (persisted in creds, not just the git remote)", async () => {
|
|
916
|
+
// The bug: pre-#401, select-repo wrote owner/name ONLY into the git
|
|
917
|
+
// origin URL. On restart, applyCredentialsToRemote regex-parsed them back
|
|
918
|
+
// out of the origin — so clearing the origin (DELETE /auth → re-OAuth
|
|
919
|
+
// without re-selecting) silently lost the repo even with a valid token.
|
|
920
|
+
// Now select-repo persists owner/name into the credentials struct, and
|
|
921
|
+
// applyCredentialsToRemote prefers the persisted values. With the origin
|
|
922
|
+
// cleared, a restart still reconstructs the github.com remote.
|
|
923
|
+
home = tmp("mirror-selectrepo-persist-");
|
|
924
|
+
const { manager, deps } = makeManager(home);
|
|
925
|
+
const cfg = {
|
|
926
|
+
...defaultMirrorConfig(),
|
|
927
|
+
enabled: true,
|
|
928
|
+
location: "internal" as const,
|
|
929
|
+
sync_mode: "manual" as const,
|
|
930
|
+
auto_commit: false,
|
|
931
|
+
auto_push: false,
|
|
932
|
+
};
|
|
933
|
+
// The manager reads config via deps.readMirrorConfig (in-memory); the
|
|
934
|
+
// route's history-on-link keys off the real per-vault file's existence.
|
|
935
|
+
// Seed both, same as the autopush test above.
|
|
936
|
+
deps.writeMirrorConfig(cfg);
|
|
937
|
+
writeMirrorConfigForVault("default", cfg);
|
|
938
|
+
await manager.start();
|
|
939
|
+
const mirrorPath = manager.getStatus().mirror_path;
|
|
940
|
+
expect(mirrorPath).toBeTruthy();
|
|
941
|
+
|
|
942
|
+
writeCredentials("default", {
|
|
943
|
+
active_method: "github_oauth",
|
|
944
|
+
github_oauth: {
|
|
945
|
+
access_token: "gho_persisttoken12345",
|
|
946
|
+
scope: "repo",
|
|
947
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
948
|
+
user_login: "aaron",
|
|
949
|
+
user_id: 1,
|
|
950
|
+
},
|
|
951
|
+
pat: null,
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
const res = await handleAuthGithubSelectRepo(
|
|
955
|
+
new Request("http://x/select", {
|
|
956
|
+
method: "POST",
|
|
957
|
+
body: JSON.stringify({ owner: "aaron", name: "backup-repo" }),
|
|
958
|
+
}),
|
|
959
|
+
manager,
|
|
960
|
+
);
|
|
961
|
+
expect(res.status).toBe(200);
|
|
962
|
+
|
|
963
|
+
// The fix: owner/name persisted into the credentials struct (not just origin).
|
|
964
|
+
const persisted = readCredentials("default");
|
|
965
|
+
expect(persisted?.github_oauth?.owner).toBe("aaron");
|
|
966
|
+
expect(persisted?.github_oauth?.name).toBe("backup-repo");
|
|
967
|
+
|
|
968
|
+
// Simulate the DELETE /auth + re-OAuth-without-reselect path: the OAuth
|
|
969
|
+
// token survives, but the git origin gets cleared.
|
|
970
|
+
Bun.spawnSync(["git", "remote", "remove", "origin"], { cwd: mirrorPath! });
|
|
971
|
+
const afterClear = Bun.spawnSync(["git", "remote", "get-url", "origin"], {
|
|
972
|
+
cwd: mirrorPath!,
|
|
973
|
+
});
|
|
974
|
+
expect(afterClear.exitCode).not.toBe(0); // origin is gone
|
|
975
|
+
|
|
976
|
+
// Restart — applyCredentialsToRemote runs again. Pre-#401 it would find no
|
|
977
|
+
// origin to parse and leave the remote unset; post-#401 it rebuilds from
|
|
978
|
+
// the persisted owner/name.
|
|
979
|
+
await manager.stop();
|
|
980
|
+
await manager.start();
|
|
981
|
+
|
|
982
|
+
const reapplied = Bun.spawnSync(["git", "remote", "get-url", "origin"], {
|
|
983
|
+
cwd: mirrorPath!,
|
|
984
|
+
});
|
|
985
|
+
expect(reapplied.exitCode).toBe(0);
|
|
986
|
+
const reappliedUrl = reapplied.stdout.toString().trim();
|
|
987
|
+
// The remote was reconstructed from persisted owner/name (token embedded,
|
|
988
|
+
// not asserted here). Without persistence this would be empty.
|
|
989
|
+
expect(reappliedUrl).toContain("github.com/aaron/backup-repo");
|
|
990
|
+
await manager.stop();
|
|
991
|
+
}, 30_000);
|
|
992
|
+
|
|
915
993
|
test("select-repo is a no-op for auto_push when mirror is disabled", async () => {
|
|
916
994
|
// Operator wiring credentials before flipping the mirror on — don't
|
|
917
995
|
// mutate auto_push behind their back.
|
package/src/mirror-routes.ts
CHANGED
|
@@ -34,7 +34,11 @@ import {
|
|
|
34
34
|
validateMirrorConfigShape,
|
|
35
35
|
type MirrorConfig,
|
|
36
36
|
} from "./mirror-config.ts";
|
|
37
|
-
import
|
|
37
|
+
import {
|
|
38
|
+
readMirrorHistory,
|
|
39
|
+
showMirrorRevision,
|
|
40
|
+
type MirrorManager,
|
|
41
|
+
} from "./mirror-manager.ts";
|
|
38
42
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
39
43
|
import {
|
|
40
44
|
applyToGitRemote,
|
|
@@ -296,6 +300,183 @@ export async function handleMirrorPushNow(
|
|
|
296
300
|
);
|
|
297
301
|
}
|
|
298
302
|
|
|
303
|
+
/**
|
|
304
|
+
* `GET /vault/<name>/.parachute/mirror/history` — surface the mirror's git
|
|
305
|
+
* commit history (vault#300).
|
|
306
|
+
*
|
|
307
|
+
* The vault is already git-backed via the mirror (one file per note), so
|
|
308
|
+
* `git log` IS a tamper-evident, diffable write history. This endpoint
|
|
309
|
+
* SURFACES it through the admin gate — it's a read-only ops/forensics
|
|
310
|
+
* surface, not a content read path (hence admin, not read, scoped upstream).
|
|
311
|
+
*
|
|
312
|
+
* Query params:
|
|
313
|
+
* - `?path=<note.path>` — scope the log to a single note's file
|
|
314
|
+
* (`git log --follow -- <path>.md`). An unsafe path (traversal /
|
|
315
|
+
* absolute) yields an empty list, never an unscoped log.
|
|
316
|
+
* - `?limit=<n>` — cap the number of commits (default
|
|
317
|
+
* HISTORY_DEFAULT_LIMIT, clamped to HISTORY_MAX_LIMIT).
|
|
318
|
+
*
|
|
319
|
+
* Response: 200 `{ history: [{ sha, date, message }], mirror_path,
|
|
320
|
+
* path?, limit }`. Degrades gracefully:
|
|
321
|
+
* - mirror disabled / no path resolved → 200 with empty history + a
|
|
322
|
+
* `note` explaining the mirror isn't initialized (not a 500).
|
|
323
|
+
* - git not installed → 503 git_not_installed (consistent with the other
|
|
324
|
+
* mirror routes).
|
|
325
|
+
* - no commits yet / path with no history → 200 empty history.
|
|
326
|
+
*/
|
|
327
|
+
export async function handleMirrorHistory(
|
|
328
|
+
req: Request,
|
|
329
|
+
manager: MirrorManager,
|
|
330
|
+
): Promise<Response> {
|
|
331
|
+
const url = new URL(req.url);
|
|
332
|
+
const pathParam = url.searchParams.get("path") ?? undefined;
|
|
333
|
+
const limitParam = url.searchParams.get("limit");
|
|
334
|
+
let limit: number | undefined;
|
|
335
|
+
if (limitParam !== null) {
|
|
336
|
+
const n = Number(limitParam);
|
|
337
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
338
|
+
return Response.json(
|
|
339
|
+
{
|
|
340
|
+
error: "Invalid limit",
|
|
341
|
+
field: "limit",
|
|
342
|
+
message: "limit must be a positive integer.",
|
|
343
|
+
},
|
|
344
|
+
{ status: 400 },
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
limit = n;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const status = manager.getStatus();
|
|
351
|
+
// No resolved mirror path means the mirror was never enabled /
|
|
352
|
+
// bootstrapped for this vault — there's no git repo to read. Return an
|
|
353
|
+
// empty history with an explanatory note rather than a 500/404; the
|
|
354
|
+
// caller (SPA / CLI) renders "no history yet, enable backup to start".
|
|
355
|
+
if (!status.mirror_path) {
|
|
356
|
+
return Response.json(
|
|
357
|
+
{
|
|
358
|
+
history: [],
|
|
359
|
+
mirror_path: null,
|
|
360
|
+
...(pathParam ? { path: pathParam } : {}),
|
|
361
|
+
note: "Mirror is not initialized for this vault — no git history exists yet. Enable history (backup) to start recording write history.",
|
|
362
|
+
},
|
|
363
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Preflight git — the helper degrades a missing repo to [], but a
|
|
368
|
+
// git-less server should get the friendly, actionable 503 (same shape as
|
|
369
|
+
// the import / PUT routes) rather than a silently-empty list.
|
|
370
|
+
try {
|
|
371
|
+
ensureGitAvailable();
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (err instanceof GitNotInstalledError) {
|
|
374
|
+
return Response.json(
|
|
375
|
+
{
|
|
376
|
+
error: "git not installed",
|
|
377
|
+
error_type: "git_not_installed",
|
|
378
|
+
message: err.message,
|
|
379
|
+
},
|
|
380
|
+
{ status: 503 },
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const history = await readMirrorHistory(status.mirror_path, {
|
|
387
|
+
notePath: pathParam,
|
|
388
|
+
limit,
|
|
389
|
+
});
|
|
390
|
+
return Response.json(
|
|
391
|
+
{
|
|
392
|
+
history,
|
|
393
|
+
mirror_path: status.mirror_path,
|
|
394
|
+
...(pathParam ? { path: pathParam } : {}),
|
|
395
|
+
...(limit !== undefined ? { limit } : {}),
|
|
396
|
+
},
|
|
397
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* `GET /vault/<name>/.parachute/mirror/history/show?sha=<sha>&path=<note.path>`
|
|
403
|
+
* — read a single note file at a past revision (`git show <sha>:<path>.md`).
|
|
404
|
+
* The companion to `/history`: history lists the commits, this reads what a
|
|
405
|
+
* note looked like at one of them. Admin-gated (vault#300).
|
|
406
|
+
*
|
|
407
|
+
* Query params (both required):
|
|
408
|
+
* - `sha` — a commit sha (hex, validated in `showMirrorRevision`).
|
|
409
|
+
* - `path` — the note path (normalized to `<path>.md`).
|
|
410
|
+
*
|
|
411
|
+
* Response:
|
|
412
|
+
* 200 `{ sha, path, content }` — the file content at that revision.
|
|
413
|
+
* 400 — missing sha/path.
|
|
414
|
+
* 404 — the sha/path pair doesn't resolve (unknown sha, note didn't
|
|
415
|
+
* exist at that commit, or an unsafe path).
|
|
416
|
+
* 503 — git not installed.
|
|
417
|
+
*/
|
|
418
|
+
export async function handleMirrorHistoryShow(
|
|
419
|
+
req: Request,
|
|
420
|
+
manager: MirrorManager,
|
|
421
|
+
): Promise<Response> {
|
|
422
|
+
const url = new URL(req.url);
|
|
423
|
+
const sha = url.searchParams.get("sha")?.trim() ?? "";
|
|
424
|
+
const notePath = url.searchParams.get("path")?.trim() ?? "";
|
|
425
|
+
if (sha.length === 0 || notePath.length === 0) {
|
|
426
|
+
return Response.json(
|
|
427
|
+
{
|
|
428
|
+
error: "sha and path required",
|
|
429
|
+
message:
|
|
430
|
+
"Provide both `sha` (a commit from /history) and `path` (the note path) query params.",
|
|
431
|
+
},
|
|
432
|
+
{ status: 400 },
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const status = manager.getStatus();
|
|
437
|
+
if (!status.mirror_path) {
|
|
438
|
+
return Response.json(
|
|
439
|
+
{
|
|
440
|
+
error: "Mirror not initialized",
|
|
441
|
+
message:
|
|
442
|
+
"No git history exists for this vault yet — enable history (backup) first.",
|
|
443
|
+
},
|
|
444
|
+
{ status: 404 },
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
ensureGitAvailable();
|
|
450
|
+
} catch (err) {
|
|
451
|
+
if (err instanceof GitNotInstalledError) {
|
|
452
|
+
return Response.json(
|
|
453
|
+
{
|
|
454
|
+
error: "git not installed",
|
|
455
|
+
error_type: "git_not_installed",
|
|
456
|
+
message: err.message,
|
|
457
|
+
},
|
|
458
|
+
{ status: 503 },
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
throw err;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const content = await showMirrorRevision(status.mirror_path, sha, notePath);
|
|
465
|
+
if (content === null) {
|
|
466
|
+
return Response.json(
|
|
467
|
+
{
|
|
468
|
+
error: "Revision not found",
|
|
469
|
+
message: `No content for ${notePath} at ${sha} — the sha may be unknown, the note may not have existed at that commit, or the path is invalid.`,
|
|
470
|
+
},
|
|
471
|
+
{ status: 404 },
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
return Response.json(
|
|
475
|
+
{ sha, path: notePath, content },
|
|
476
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
299
480
|
/**
|
|
300
481
|
* Convenience for tests + future callers: build the GET response from a
|
|
301
482
|
* known-good config without needing a real MirrorManager.
|
|
@@ -1200,6 +1381,19 @@ export async function handleAuthGithubSelectRepo(
|
|
|
1200
1381
|
}
|
|
1201
1382
|
}
|
|
1202
1383
|
|
|
1384
|
+
// vault#401: persist the selected owner/name INTO the credentials struct,
|
|
1385
|
+
// not only into the git origin URL. Pre-#401 the selection lived solely in
|
|
1386
|
+
// the origin URL; `applyCredentialsToRemote` regex-parsed it back out on
|
|
1387
|
+
// restart, so clearing the origin (e.g. `DELETE /auth` then re-OAuth without
|
|
1388
|
+
// re-selecting) silently lost the repo even with a still-valid token. Saving
|
|
1389
|
+
// it here lets the restart path reconstruct the remote from credentials
|
|
1390
|
+
// alone. Write before the remote-apply so the persisted selection survives
|
|
1391
|
+
// even if the apply later fails.
|
|
1392
|
+
writeCredentials(manager.getVaultName(), {
|
|
1393
|
+
...creds,
|
|
1394
|
+
github_oauth: { ...creds.github_oauth, owner, name },
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1203
1397
|
// Reach into mirror-credentials.ts for the authed URL builder.
|
|
1204
1398
|
const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
|
|
1205
1399
|
const authedUrl = githubAuthedRemoteUrl(
|