@openparachute/vault 0.6.4-rc.6 → 0.6.4-rc.8

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.
@@ -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
@@ -34,7 +34,11 @@ import {
34
34
  validateMirrorConfigShape,
35
35
  type MirrorConfig,
36
36
  } from "./mirror-config.ts";
37
- import type { MirrorManager } from "./mirror-manager.ts";
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.
package/src/routes.ts CHANGED
@@ -328,7 +328,7 @@ function parseMetaBrackets(url: URL): {
328
328
  return {
329
329
  error: json(
330
330
  {
331
- error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract uses these two ops because the equivalent flat shape (\`date_field=${field}&date_from=…&date_to=…\`) is half-open by design.`,
331
+ error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract is half-open by design.`,
332
332
  code: "INVALID_QUERY",
333
333
  },
334
334
  400,
@@ -623,20 +623,13 @@ export function parseNotesQueryOpts(url: URL): {
623
623
  lastUpdatedBy: parseQuery(url, "last_updated_by") ?? undefined,
624
624
  createdVia: parseQuery(url, "created_via") ?? undefined,
625
625
  lastUpdatedVia: parseQuery(url, "last_updated_via") ?? undefined,
626
- ...(bracket.dateFilter
627
- ? { dateFilter: bracket.dateFilter }
628
- : parseQuery(url, "date_field")
629
- ? {
630
- dateFilter: {
631
- field: parseQuery(url, "date_field")!,
632
- from: parseQuery(url, "date_from") ?? undefined,
633
- to: parseQuery(url, "date_to") ?? undefined,
634
- },
635
- }
636
- : {
637
- dateFrom: parseQuery(url, "date_from") ?? undefined,
638
- dateTo: parseQuery(url, "date_to") ?? undefined,
639
- }),
626
+ // Date-range filtering on the query string is bracket-style only:
627
+ // `?meta[created_at][gte]=…&meta[created_at][lt]=…` (see
628
+ // `parseMetaBrackets`). The flat `date_field` / `date_from` / `date_to`
629
+ // params were removed in 0.6.4 (vault#288, breaking) — they are now
630
+ // ignored. The MCP `date_from` / `date_to` shorthand is a separate,
631
+ // supported convenience and is unaffected.
632
+ ...(bracket.dateFilter ? { dateFilter: bracket.dateFilter } : {}),
640
633
  sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
641
634
  orderBy: parseQuery(url, "order_by") ?? undefined,
642
635
  limit: parseInt10(parseQuery(url, "limit")) ?? 50,
@@ -907,7 +900,7 @@ async function handleNotesInner(
907
900
 
908
901
  // Structured query
909
902
  //
910
- // Two filter syntaxes coexist on this endpoint:
903
+ // Date-range filtering on the query string uses one syntax:
911
904
  //
912
905
  // - **Bracket-style** (canonical, vault#285 friction point 1.3):
913
906
  // `?meta[field][op]=value` / `?meta[created_at][gte]=…`. Exposes
@@ -915,21 +908,18 @@ async function handleNotesInner(
915
908
  // not_in/exists) and the dateFilter bridge through one consistent
916
909
  // shape. See `parseMetaBrackets` for the grammar.
917
910
  //
918
- // - **Flat date params** (DEPRECATED): `?date_field=created_at&
919
- // date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
920
- // Still functional through 0.5.x; planned removal in a later 0.x
921
- // (vault#288). New consumers should use bracket-style.
922
- //
923
- // Precedence on overlap: bracket-style wins. If a caller passes both
924
- // `meta[created_at][gte]=X` and `date_field=created_at&date_from=Y`,
925
- // the bracket form is the dateFilter the engine sees; the flat
926
- // params are silently dropped. We don't error — the bracket form is
927
- // documented as canonical, and rejecting the overlap would block a
928
- // realistic migration path where a caller half-converted their code.
911
+ // The flat date params (`?date_field=created_at&date_from=…&date_to=…`
912
+ // and the legacy bare `?date_from=…&date_to=…`) were REMOVED in 0.6.4
913
+ // (vault#288, breaking change). They are now silently ignored — a
914
+ // request that passes only flat date params comes back unfiltered.
915
+ // Bracket-style is functionally complete (full operator set), so no
916
+ // capability was lost. Migrate `date_field=created_at&date_from=X` to
917
+ // `meta[created_at][gte]=X` (and `date_to=Y` to `meta[created_at][lt]=Y`).
929
918
  //
930
919
  // Surface asymmetry: REST flattens to a query string; MCP takes a
931
- // nested `date_filter: { field, from, to }` object directly. Both
932
- // lower to the same store-level `dateFilter` shape.
920
+ // nested `date_filter: { field, from, to }` object directly (plus a
921
+ // top-level `date_from` / `date_to` shorthand, which is unaffected by
922
+ // this removal). Both lower to the same store-level `dateFilter` shape.
933
923
  // Structured-query parsing is shared with the live `/subscribe` route
934
924
  // (see `parseNotesQueryOpts`) so both endpoints lower an identical query
935
925
  // string to the same `QueryOpts` — predicate parity by construction.
@@ -2266,6 +2266,79 @@ describe("/vault/<name>/.parachute/mirror/run-now — auth + dispatch", () => {
2266
2266
  });
2267
2267
  });
2268
2268
 
2269
+ // ---------------------------------------------------------------------------
2270
+ // /vault/<name>/.parachute/mirror/history — surface the git write history
2271
+ // (vault#300). Admin-gated (ops/forensics surface). Tests pin the auth gate
2272
+ // matches the sibling mirror routes; helper + handler shape lives in
2273
+ // mirror-history.test.ts.
2274
+ // ---------------------------------------------------------------------------
2275
+
2276
+ describe("/vault/<name>/.parachute/mirror/history — auth + dispatch", () => {
2277
+ test("unauthenticated → 401", async () => {
2278
+ createVault("journal");
2279
+ const p = "/vault/journal/.parachute/mirror/history";
2280
+ const res = await route(new Request(`http://localhost:1940${p}`), p);
2281
+ expect(res.status).toBe(401);
2282
+ });
2283
+
2284
+ test("vault:read token → 403 insufficient_scope (admin required)", async () => {
2285
+ createVault("journal");
2286
+ const fullToken = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2287
+ const p = "/vault/journal/.parachute/mirror/history";
2288
+ const res = await route(
2289
+ new Request(`http://localhost:1940${p}`, {
2290
+ headers: { authorization: `Bearer ${fullToken}` },
2291
+ }),
2292
+ p,
2293
+ );
2294
+ expect(res.status).toBe(403);
2295
+ const body = (await res.json()) as { error_type?: string; required_scope?: string };
2296
+ expect(body.error_type).toBe("insufficient_scope");
2297
+ expect(body.required_scope).toBe("vault:admin");
2298
+ });
2299
+
2300
+ test("admin token reaches the handler (200 when wired, 503 when not)", async () => {
2301
+ createVault("journal");
2302
+ const token = await createAdminToken("journal");
2303
+ const p = "/vault/journal/.parachute/mirror/history";
2304
+ const res = await route(
2305
+ new Request(`http://localhost:1940${p}`, {
2306
+ headers: { authorization: `Bearer ${token}` },
2307
+ }),
2308
+ p,
2309
+ );
2310
+ // Either way the auth gate passed — that's what this routing-level test pins.
2311
+ expect([200, 503]).toContain(res.status);
2312
+ });
2313
+
2314
+ test("/history/show admin gate matches /history", async () => {
2315
+ createVault("journal");
2316
+ const fullToken = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2317
+ const p = "/vault/journal/.parachute/mirror/history/show";
2318
+ const res = await route(
2319
+ new Request(`http://localhost:1940${p}?sha=abc123&path=Notes/a`, {
2320
+ headers: { authorization: `Bearer ${fullToken}` },
2321
+ }),
2322
+ p,
2323
+ );
2324
+ expect(res.status).toBe(403);
2325
+ });
2326
+
2327
+ test("non-GET method → 405 (or 503 when manager not wired)", async () => {
2328
+ createVault("journal");
2329
+ const token = await createAdminToken("journal");
2330
+ const p = "/vault/journal/.parachute/mirror/history";
2331
+ const res = await route(
2332
+ new Request(`http://localhost:1940${p}`, {
2333
+ method: "POST",
2334
+ headers: { authorization: `Bearer ${token}` },
2335
+ }),
2336
+ p,
2337
+ );
2338
+ expect([405, 503]).toContain(res.status);
2339
+ });
2340
+ });
2341
+
2269
2342
  // ---------------------------------------------------------------------------
2270
2343
  // /vault/<name>/.parachute/usage — per-vault data-footprint endpoint.
2271
2344
  //
package/src/routing.ts CHANGED
@@ -94,6 +94,8 @@ import {
94
94
  handleAuthGithubSelectRepo,
95
95
  handleAuthPat,
96
96
  handleMirrorGet,
97
+ handleMirrorHistory,
98
+ handleMirrorHistoryShow,
97
99
  handleMirrorImport,
98
100
  handleMirrorPushNow,
99
101
  handleMirrorPut,
@@ -671,6 +673,50 @@ export async function route(
671
673
  return Response.json({ error: "Method not allowed" }, { status: 405 });
672
674
  }
673
675
 
676
+ // /.parachute/mirror/history — surface the mirror's git commit history
677
+ // (vault#300). The vault is already git-backed (one file per note), so
678
+ // `git log` IS a tamper-evident write history; this read path exposes it.
679
+ // Admin-gated (it's an ops/forensics surface, not a content read) — same
680
+ // gate + manager check as the sibling mirror routes. GET-only.
681
+ // /history — full mirror history (?path=<note> scopes to one note,
682
+ // ?limit=<n> caps the count)
683
+ // /history/show — `git show <sha>:<path>.md` to read a past revision
684
+ if (
685
+ subpath === "/.parachute/mirror/history" ||
686
+ subpath === "/.parachute/mirror/history/show"
687
+ ) {
688
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
689
+ return Response.json(
690
+ {
691
+ error: "Forbidden",
692
+ error_type: "insufficient_scope",
693
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
694
+ required_scope: SCOPE_ADMIN,
695
+ granted_scopes: auth.scopes,
696
+ },
697
+ { status: 403 },
698
+ );
699
+ }
700
+ const manager = getMirrorManager(vaultName);
701
+ if (!manager) {
702
+ return Response.json(
703
+ {
704
+ error: "Mirror manager not initialized",
705
+ message:
706
+ "The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
707
+ },
708
+ { status: 503 },
709
+ );
710
+ }
711
+ if (req.method !== "GET") {
712
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
713
+ }
714
+ if (subpath === "/.parachute/mirror/history/show") {
715
+ return handleMirrorHistoryShow(req, manager);
716
+ }
717
+ return handleMirrorHistory(req, manager);
718
+ }
719
+
674
720
  // /.parachute/mirror/import — clone a vault export from git + import.
675
721
  // Admin-gated. POST-only. Synchronous (imports finish in <30s for
676
722
  // typical vaults). See mirror-routes.ts:handleMirrorImport for the
@@ -416,9 +416,12 @@ describe("handleSubscribe — rejected query shapes", () => {
416
416
  expect(body.error).toContain("has_links");
417
417
  });
418
418
 
419
- it("date filter → 400 (M1) — legacy date_from", async () => {
419
+ it("date filter → 400 (M1) — bracket date_filter on created_at", async () => {
420
+ // The flat `date_from` param was removed in 0.6.4 (vault#288) and is now
421
+ // ignored, so it no longer reaches the M1 date guard. Bracket-style is the
422
+ // only query-string date filter; assert it's still rejected for live subs.
420
423
  const res = await handleSubscribe(
421
- subscribeReq("date_from=2026-01-01"),
424
+ subscribeReq("meta%5Bcreated_at%5D%5Bgte%5D=2026-01-01"),
422
425
  store,
423
426
  VAULT,
424
427
  unscopedScope(),
@@ -430,6 +433,24 @@ describe("handleSubscribe — rejected query shapes", () => {
430
433
  expect(body.error).toContain("date");
431
434
  });
432
435
 
436
+ it("flat date_from is ignored → subscription created (vault#288 removal)", async () => {
437
+ // Documents the breaking change: the removed flat param no longer reaches
438
+ // the date guard, so a sub that ONLY carries it is now a valid (unfiltered)
439
+ // subscription rather than a 400.
440
+ const res = await handleSubscribe(
441
+ subscribeReq("date_from=2026-01-01"),
442
+ store,
443
+ VAULT,
444
+ unscopedScope(),
445
+ manager,
446
+ );
447
+ expect(res.status).toBe(200);
448
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
449
+ const r = sseReader(res);
450
+ await r.pump();
451
+ await r.close();
452
+ });
453
+
433
454
  it("date filter → 400 (M1) — bracket date_filter on updated_at", async () => {
434
455
  const res = await handleSubscribe(
435
456
  subscribeReq("meta%5Bupdated_at%5D%5Bgte%5D=2026-01-01"),