@openparachute/vault 0.7.6-rc.1 → 0.7.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.6-rc.1",
3
+ "version": "0.7.6",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  * wiring and the response-shape contract.
22
22
  */
23
23
 
24
- import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
24
+ import { describe, test, expect, beforeAll, beforeEach, afterEach, spyOn } from "bun:test";
25
25
  import { mkdirSync, rmSync, existsSync } from "fs";
26
26
  import { join } from "path";
27
27
  import { tmpdir } from "os";
@@ -147,6 +147,15 @@ let prevJwksOrigin: string | undefined;
147
147
  let fixture: HubFixture;
148
148
  let kp: Keypair;
149
149
 
150
+ beforeAll(async () => {
151
+ // RSA keygen is the expensive part of this file (~tens of ms locally,
152
+ // hundreds under a loaded full-suite worker). Doing it per-test is what
153
+ // clustered failures under parallel contention (vault#609): 30 tests ×
154
+ // generateKeyPair competing with the rest of `bun test ./src/` blew the
155
+ // 5s default. One keypair is enough — kid, n, e are stable for the file.
156
+ kp = await makeKeypair("k1");
157
+ });
158
+
150
159
  beforeEach(async () => {
151
160
  tmpHome = join(
152
161
  tmpdir(),
@@ -157,7 +166,6 @@ beforeEach(async () => {
157
166
  process.env.PARACHUTE_HOME = tmpHome;
158
167
  clearVaultStoreCache();
159
168
 
160
- kp = await makeKeypair("k1");
161
169
  fixture = startHubFixture([kp]);
162
170
  prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
163
171
  prevJwksOrigin = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
@@ -828,11 +828,13 @@ describe("export CLI: --watch", () => {
828
828
  const watch = spawnWatchCli(["export", exportDir, "--watch", "--interval", "1"], tmp);
829
829
  try {
830
830
  // Initial export prints the "Exported N notes" line.
831
- await watch.awaitLine((l) => l.includes("Exported 1 note"), 10_000);
831
+ await watch.awaitLine((l) => l.includes("Exported 1 note"), 15_000);
832
832
  // Watch loop prints the polling banner.
833
- await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 5_000);
834
- // At least one idle tick.
835
- await watch.awaitLine((l) => l.includes("[watch] no changes"), 5_000);
833
+ await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 15_000);
834
+ // At least one idle tick. 15s not 5s: under full-suite spawn
835
+ // contention the child can take >5s to print its first idle tick
836
+ // even with --interval 1 (vault#609).
837
+ await watch.awaitLine((l) => l.includes("[watch] no changes"), 15_000);
836
838
  } finally {
837
839
  watch.proc.kill("SIGINT");
838
840
  }
@@ -841,7 +843,7 @@ describe("export CLI: --watch", () => {
841
843
  // Stopping line printed on shutdown.
842
844
  expect(watch.seenLines.some((l) => l.includes("[watch] stopping watch"))).toBe(true);
843
845
  },
844
- 30_000,
846
+ 60_000,
845
847
  );
846
848
 
847
849
  test(
@@ -849,7 +851,7 @@ describe("export CLI: --watch", () => {
849
851
  async () => {
850
852
  const watch = spawnWatchCli(["export", exportDir, "--watch", "--interval", "1"], tmp);
851
853
  try {
852
- await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 10_000);
854
+ await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 15_000);
853
855
 
854
856
  // Open the vault DB out-of-band and add a note. The watch loop
855
857
  // should pick it up within ~2 intervals.
@@ -866,7 +868,7 @@ describe("export CLI: --watch", () => {
866
868
  // Expect the watch-mode incremental-export line.
867
869
  const line = await watch.awaitLine(
868
870
  (l) => l.startsWith("[watch] exported"),
869
- 10_000,
871
+ 15_000,
870
872
  );
871
873
  expect(line).toMatch(/\[watch\] exported \d+ note/);
872
874
  // And the on-disk file landed.
@@ -878,7 +880,7 @@ describe("export CLI: --watch", () => {
878
880
  await watch.proc.exited;
879
881
  expect(fs.existsSync(path.join(exportDir, "Inbox", "triggered.md"))).toBe(true);
880
882
  },
881
- 30_000,
883
+ 60_000,
882
884
  );
883
885
 
884
886
  test(
@@ -925,8 +927,8 @@ describe("export CLI: --watch", () => {
925
927
  );
926
928
  try {
927
929
  // Initial export must succeed (seed has no collision).
928
- await watch.awaitLine((l) => l.includes("Exported 1 note"), 10_000);
929
- await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 5_000);
930
+ await watch.awaitLine((l) => l.includes("Exported 1 note"), 15_000);
931
+ await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 15_000);
930
932
 
931
933
  // Inject a collision: existing seed is `Inbox/seed`; write
932
934
  // `Inbox/SEED` so the lowercased (path, ext) key collides.
@@ -977,7 +979,7 @@ describe("export CLI: --watch", () => {
977
979
  expect(stderr).toContain("Resolve the collision in the vault");
978
980
  expect(stderr).toContain("--strict-case-collision");
979
981
  },
980
- 30_000,
982
+ 60_000,
981
983
  );
982
984
 
983
985
  test(
@@ -993,9 +995,9 @@ describe("export CLI: --watch", () => {
993
995
  tmp,
994
996
  );
995
997
  try {
996
- await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 10_000);
998
+ await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 15_000);
997
999
  // Initial export should have produced a commit.
998
- await watch.awaitLine((l) => l.startsWith("[git-commit] export:"), 10_000);
1000
+ await watch.awaitLine((l) => l.startsWith("[git-commit] export:"), 15_000);
999
1001
  const afterInitial = gitLogOneline(exportDir).length;
1000
1002
 
1001
1003
  // Add a note → expect another commit on the next cycle.
@@ -1023,6 +1025,6 @@ describe("export CLI: --watch", () => {
1023
1025
  }
1024
1026
  await watch.proc.exited;
1025
1027
  },
1026
- 30_000,
1028
+ 60_000,
1027
1029
  );
1028
1030
  });
package/src/mcp-tools.ts CHANGED
@@ -883,9 +883,13 @@ function overrideVaultInfo(
883
883
  * model error), the cap is the backstop. Operators wanting long-lived
884
884
  * tokens mint a hub-issued JWT via the hub mint-token flow (the REST
885
885
  * /vault/<name>/tokens endpoint was removed with the pvt_* drop, vault#282).
886
+ * The `long_lived: true` opt-in deliberately relaxes this to a 90-day
887
+ * ceiling for standing credentials; the caller's admin JWT could already
888
+ * mint longer directly from the hub, so the flag adds no new authority.
886
889
  */
887
890
  const MANAGE_TOKEN_DEFAULT_TTL_SECONDS = 900; // 15 minutes
888
891
  const MANAGE_TOKEN_MAX_TTL_SECONDS = 3600; // 1 hour
892
+ const MANAGE_TOKEN_LONG_TTL_MAX_SECONDS = 7776000; // 90 days; opt-in ceiling for `long_lived: true`, matching hub's API_MINT_TOKEN_DEFAULT_TTL_SECONDS
889
893
 
890
894
  /**
891
895
  * Resolve the bare hub origin for the mint/revoke proxy calls. Reuses
@@ -952,8 +956,10 @@ function buildManageTokenTool(
952
956
  "escalate. Minting requires a hub-JWT session holding 'vault:" + vaultName +
953
957
  ":admin'. List + revoke are scoped to tokens this session minted; " +
954
958
  "CLI/REST-minted tokens are not surfaced here.\n\n" +
959
+ "`long_lived: true` raises the mint TTL cap to 90 days; a 90-day token is a " +
960
+ "standing credential and meaningfully increases blast radius if leaked/misused.\n\n" +
955
961
  "Actions (discriminator: `action`):\n" +
956
- "- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at, scopes, scoped_tags, vault_name } (vault#555: scopes/scoped_tags/vault_name were previously undocumented here)\n" +
962
+ "- `mint` — { scope: string|string[], ttl_seconds?: number, long_lived?: boolean, description?: string } → { action: \"mint\", token, jti, expires_at, scopes, scoped_tags, vault_name } (vault#555: scopes/scoped_tags/vault_name were previously undocumented here)\n" +
957
963
  "- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean, already_revoked?: boolean } — idempotent; a jti not in this session's ledger, or already revoked, still returns ok:true. A genuine failure additionally carries error/message (and, for a hub-side rejection, hub_status).\n" +
958
964
  "- `list` — (no inputs) → { action: \"list\", tokens: [...] }",
959
965
  inputSchema: {
@@ -974,7 +980,11 @@ function buildManageTokenTool(
974
980
  },
975
981
  ttl_seconds: {
976
982
  type: "number",
977
- description: `(action=mint) Token lifetime in seconds. Default ${MANAGE_TOKEN_DEFAULT_TTL_SECONDS} (15 min), max ${MANAGE_TOKEN_MAX_TTL_SECONDS} (1 hour). Values outside (0, ${MANAGE_TOKEN_MAX_TTL_SECONDS}] are rejected.`,
983
+ description: `(action=mint) Token lifetime in seconds. Default ${MANAGE_TOKEN_DEFAULT_TTL_SECONDS} (15 min), max ${MANAGE_TOKEN_MAX_TTL_SECONDS} (1 hour); with long_lived=true, max ${MANAGE_TOKEN_LONG_TTL_MAX_SECONDS} (90 days). Values outside the applicable (0, max] range are rejected.`,
984
+ },
985
+ long_lived: {
986
+ type: "boolean",
987
+ description: "(action=mint, optional) Set true to raise the TTL cap from 1 hour to 90 days. A 90-day token is a standing credential: long-lived and meaningfully increases blast radius if leaked/misused.",
978
988
  },
979
989
  description: {
980
990
  type: "string",
@@ -1107,9 +1117,20 @@ async function mintAction(
1107
1117
  }
1108
1118
 
1109
1119
  // TTL bounds. Default 900 (15 min); explicit values must satisfy
1110
- // `0 < ttl <= MANAGE_TOKEN_MAX_TTL_SECONDS`. Zero, negative, NaN, and
1111
- // beyond-max all reject the cap is the safety backstop if revoke fails,
1112
- // so it must be strict.
1120
+ // `0 < ttl <= MANAGE_TOKEN_MAX_TTL_SECONDS`, or
1121
+ // `0 < ttl <= MANAGE_TOKEN_LONG_TTL_MAX_SECONDS` with `long_lived: true`.
1122
+ // Zero, negative, NaN, and beyond-max all reject — the cap is the safety
1123
+ // backstop if revoke fails, so it must be strict.
1124
+ if (params.long_lived !== undefined && typeof params.long_lived !== "boolean") {
1125
+ return {
1126
+ action: "mint",
1127
+ error: "invalid_request",
1128
+ message: "manage-token mint: long_lived must be a boolean.",
1129
+ };
1130
+ }
1131
+ const maxTtl = params.long_lived === true
1132
+ ? MANAGE_TOKEN_LONG_TTL_MAX_SECONDS
1133
+ : MANAGE_TOKEN_MAX_TTL_SECONDS;
1113
1134
  let ttl = MANAGE_TOKEN_DEFAULT_TTL_SECONDS;
1114
1135
  if (params.ttl_seconds !== undefined && params.ttl_seconds !== null) {
1115
1136
  if (typeof params.ttl_seconds !== "number" || !Number.isFinite(params.ttl_seconds)) {
@@ -1119,11 +1140,11 @@ async function mintAction(
1119
1140
  message: "manage-token mint: ttl_seconds must be a finite number.",
1120
1141
  };
1121
1142
  }
1122
- if (params.ttl_seconds <= 0 || params.ttl_seconds > MANAGE_TOKEN_MAX_TTL_SECONDS) {
1143
+ if (params.ttl_seconds <= 0 || params.ttl_seconds > maxTtl) {
1123
1144
  return {
1124
1145
  action: "mint",
1125
1146
  error: "invalid_request",
1126
- message: `manage-token mint: ttl_seconds must be in (0, ${MANAGE_TOKEN_MAX_TTL_SECONDS}]; got ${params.ttl_seconds}.`,
1147
+ message: `manage-token mint: ttl_seconds must be in (0, ${maxTtl}]; got ${params.ttl_seconds}.`,
1127
1148
  };
1128
1149
  }
1129
1150
  ttl = params.ttl_seconds;
package/src/vault.test.ts CHANGED
@@ -7598,6 +7598,23 @@ describe("manage-token MCP tool (vault#403, MGT — hub-JWT attenuation proxy)",
7598
7598
  closeAllStores();
7599
7599
  });
7600
7600
 
7601
+ test("long_lived=true permits a 30-day TTL", async () => {
7602
+ installHubStub();
7603
+ const { vaultName, auth } = await setupAdminSession("mint-long-lived");
7604
+ const { closeAllStores } = await import("./vault-store.ts");
7605
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
7606
+ action: "mint",
7607
+ scope: "vault:read",
7608
+ ttl_seconds: 2592000,
7609
+ long_lived: true,
7610
+ });
7611
+ expect(parsed.action).toBe("mint");
7612
+ expect(parsed.error).toBeUndefined();
7613
+ const mint = hubCalls.find((c) => c.url.endsWith("/api/auth/mint-token"));
7614
+ expect(mint!.body.expires_in).toBe(2592000);
7615
+ closeAllStores();
7616
+ });
7617
+
7601
7618
  test("tag-scoped caller's mint includes permissions.scoped_tags", async () => {
7602
7619
  installHubStub();
7603
7620
  const { vaultName, auth } = await setupAdminSession("mint-scoped", ["task", "project"]);
@@ -7625,6 +7642,52 @@ describe("manage-token MCP tool (vault#403, MGT — hub-JWT attenuation proxy)",
7625
7642
  const { closeAllStores } = await import("./vault-store.ts");
7626
7643
  const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", ttl_seconds: 3601 });
7627
7644
  expect(parsed.error).toBe("invalid_request");
7645
+ expect(parsed.message).toBe("manage-token mint: ttl_seconds must be in (0, 3600]; got 3601.");
7646
+ expect(hubCalls.length).toBe(0);
7647
+ closeAllStores();
7648
+ });
7649
+
7650
+ test("long_lived=true rejects TTL above the 90-day cap locally", async () => {
7651
+ installHubStub();
7652
+ const { vaultName, auth } = await setupAdminSession("mint-long-over");
7653
+ const { closeAllStores } = await import("./vault-store.ts");
7654
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
7655
+ action: "mint",
7656
+ scope: "vault:read",
7657
+ ttl_seconds: 7776001,
7658
+ long_lived: true,
7659
+ });
7660
+ expect(parsed.error).toBe("invalid_request");
7661
+ expect(parsed.message).toContain("7776000");
7662
+ expect(hubCalls.length).toBe(0);
7663
+ closeAllStores();
7664
+ });
7665
+
7666
+ test("long_lived=false keeps the 1-hour cap", async () => {
7667
+ installHubStub();
7668
+ const { vaultName, auth } = await setupAdminSession("mint-long-false");
7669
+ const { closeAllStores } = await import("./vault-store.ts");
7670
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
7671
+ action: "mint",
7672
+ scope: "vault:read",
7673
+ ttl_seconds: 7200,
7674
+ long_lived: false,
7675
+ });
7676
+ expect(parsed.error).toBe("invalid_request");
7677
+ expect(hubCalls.length).toBe(0);
7678
+ closeAllStores();
7679
+ });
7680
+
7681
+ test("omitted long_lived keeps the 1-hour cap", async () => {
7682
+ installHubStub();
7683
+ const { vaultName, auth } = await setupAdminSession("mint-long-omitted");
7684
+ const { closeAllStores } = await import("./vault-store.ts");
7685
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
7686
+ action: "mint",
7687
+ scope: "vault:read",
7688
+ ttl_seconds: 7200,
7689
+ });
7690
+ expect(parsed.error).toBe("invalid_request");
7628
7691
  expect(hubCalls.length).toBe(0);
7629
7692
  closeAllStores();
7630
7693
  });