@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
@@ -1,20 +1,24 @@
1
1
  /**
2
- * Token operations for per-vault token management.
2
+ * Token operations for the per-vault `tokens` table.
3
3
  *
4
- * Tokens live in each vault's SQLite database (the `tokens` table is part of
5
- * the vault schema as of v7). All functions take a Database parameter — the
6
- * vault's own DB connection.
4
+ * VESTIGIAL as of 0.5.0 (vault#282 Stage 2). Vault is a pure hub
5
+ * resource-server: it no longer mints (`pvt_*`) or validates rows in this
6
+ * table. What survives here:
7
+ * - `listTokens` / `revokeToken` / `findTokensReferencingTag` — read/clean up
8
+ * any leftover pre-0.5.0 rows.
9
+ * - `migrateVaultKeys` — the legacy-YAML-api_keys import landing zone (raw
10
+ * INSERT; the only writer left).
11
+ * - the `mcp_mint_ledger` helpers — hub-JWT attribution for manage-token.
7
12
  *
8
- * Two permission levels:
9
- * - "full" — unrestricted access (CRUD, delete, token management)
10
- * - "read" — query-only (no mutations)
13
+ * The `Token`/field docs below describe the historical (pre-0.5.0) auth
14
+ * semantics for the surviving read/cleanup paths; no validation path reads
15
+ * `scoped_tags` / `vault_name` off these rows anymore.
11
16
  *
12
- * Legacy "admin" and "write" values in the DB are normalized to "full" at
13
- * read time for backward compatibility.
17
+ * Permission levels ("full" / "read") and the legacy "admin"/"write" "full"
18
+ * normalization are kept for displaying any leftover rows.
14
19
  */
15
20
 
16
21
  import { Database } from "bun:sqlite";
17
- import crypto from "node:crypto";
18
22
  import { hashKey } from "./config.ts";
19
23
  import { legacyPermissionToScopes, parseScopes, serializeScopes } from "./scopes.ts";
20
24
 
@@ -71,51 +75,19 @@ export interface Token {
71
75
  created_via: string | null;
72
76
  /**
73
77
  * Session pin (v19). When this token was minted via manage-token, this
74
- * is the display id (`t_<prefix>`) of the calling session's token (for
75
- * pvt_* MCP sessions) or the hub JWT's jti claim (for hub-issued
76
- * sessions). NULL otherwise.
78
+ * is the hub JWT's jti claim of the minting session. NULL otherwise.
79
+ * (Vestigial post-0.5.0 no new rows are written; vault no longer mints
80
+ * vault-DB tokens. See vault#282 Stage 2.)
77
81
  */
78
82
  parent_jti: string | null;
79
83
  /**
80
- * Soft-revoke timestamp (v19). When set, `resolveToken` returns null
81
- * and the row stays in place for audit history. manage-token revoke is
82
- * idempotent calling revoke a second time on the same jti is a no-op
83
- * with ok=true. NULL = active.
84
+ * Soft-revoke timestamp (v19). Marked the row revoked while keeping it in
85
+ * place for audit history. Vestigial post-0.5.0 (vault#282 Stage 2) — no
86
+ * validation path reads these rows anymore. NULL = active.
84
87
  */
85
88
  revoked_at: string | null;
86
89
  }
87
90
 
88
- export interface ResolvedToken {
89
- permission: TokenPermission;
90
- /**
91
- * Granted scopes, parsed from the token row's `scopes` column. Pre-v12
92
- * tokens (where the column is NULL) fall back to the legacy permission
93
- * → scopes mapping and `legacyDerived` is set true so callers can log
94
- * a deprecation warning on first use.
95
- */
96
- scopes: string[];
97
- /** True iff `scopes` was derived from the legacy `permission` column. */
98
- legacyDerived: boolean;
99
- /**
100
- * Tag-allowlist for tag-scoped tokens (root tag names). NULL = unscoped.
101
- * See `Token.scoped_tags`.
102
- */
103
- scoped_tags: string[] | null;
104
- /**
105
- * Per-vault binding (v16). Non-null = token is bound to this vault;
106
- * `authenticateVaultRequest` rejects when the bound vault doesn't match
107
- * the request's vault. NULL = legacy / server-wide, accepted for any
108
- * vault. See vault#257.
109
- */
110
- vault_name: string | null;
111
- /**
112
- * Display id (`t_<hashprefix>`) of THIS token. Surfaced so callers that
113
- * later mint child tokens (manage-token MCP tool) can stamp parent_jti
114
- * without re-derivation. Pre-v19 lookups still compute this on the fly.
115
- */
116
- jti: string;
117
- }
118
-
119
91
  /**
120
92
  * Parse the JSON-encoded `scoped_tags` column. Returns null for NULL/empty
121
93
  * input. Defensive: malformed JSON or non-array shapes degrade to null
@@ -136,161 +108,15 @@ export function parseScopedTags(raw: string | null): string[] | null {
136
108
 
137
109
  // ---------------------------------------------------------------------------
138
110
  // Token operations
111
+ //
112
+ // vault#282 Stage 2: the pvt_* mint (`generateToken` / `createToken`) and
113
+ // validation (`resolveToken`) were removed at 0.5.0 — vault is a pure hub
114
+ // resource-server and no longer issues or accepts opaque vault-DB tokens.
115
+ // What survives: `listTokens` / `revokeToken` (cleanup of vestigial pre-0.5.0
116
+ // rows), the YAML-import landing zone (`migrateVaultKeys`, raw INSERT), and the
117
+ // `mcp_mint_ledger` machinery (hub-JWT attribution for manage-token).
139
118
  // ---------------------------------------------------------------------------
140
119
 
141
- export function generateToken(): { fullToken: string; tokenHash: string } {
142
- const random = crypto.randomBytes(32).toString("base64url").slice(0, 32);
143
- const fullToken = `pvt_${random}`;
144
- return { fullToken, tokenHash: hashKey(fullToken) };
145
- }
146
-
147
- export function createToken(
148
- db: Database,
149
- fullToken: string,
150
- opts: {
151
- label: string;
152
- permission?: TokenPermission;
153
- /**
154
- * Explicit OAuth-standard scopes to persist. If omitted, derived from
155
- * `permission` (read → [vault:read], anything else → [vault:read,
156
- * vault:write, vault:admin]). Written as a whitespace-separated string.
157
- */
158
- scopes?: string[];
159
- /** @deprecated Written to DB but not enforced at runtime. */
160
- scope_tag?: string | null;
161
- /** @deprecated Written to DB but not enforced at runtime. */
162
- scope_path_prefix?: string | null;
163
- /**
164
- * Tag-allowlist (root tag names). null/undefined → unscoped (full vault
165
- * access per `scopes`). When provided, must be already-validated root tag
166
- * names per patterns/tag-scoped-tokens.md (no path separators); the mint
167
- * endpoint validates against existing tags before passing through.
168
- */
169
- scoped_tags?: string[] | null;
170
- /**
171
- * Per-vault binding (v16). Non-null = token can only authenticate
172
- * against this vault. NULL = legacy / server-wide; auth accepts the
173
- * token for any vault. New mints via per-vault routes set this; the
174
- * legacy YAML-import path leaves it NULL. See vault#257.
175
- */
176
- vault_name?: string | null;
177
- expires_at?: string | null;
178
- /**
179
- * Provenance tag (v19). `'mcp_mint'` for tokens minted via the
180
- * manage-token MCP tool; omit/null for CLI / REST / YAML paths.
181
- */
182
- created_via?: string | null;
183
- /**
184
- * Session pin (v19). Display id (`t_<prefix>`) or hub JWT `jti` of the
185
- * caller that minted this token via manage-token. Used by the
186
- * manage-token list/revoke surface to scope itself to one session.
187
- */
188
- parent_jti?: string | null;
189
- },
190
- ): Token {
191
- const tokenHash = hashKey(fullToken);
192
- const now = new Date().toISOString();
193
- const permission = opts.permission ?? "full";
194
- const scopes = opts.scopes ?? legacyPermissionToScopes(permission);
195
- const scopesStr = serializeScopes(scopes);
196
- const scopedTags = opts.scoped_tags && opts.scoped_tags.length > 0 ? opts.scoped_tags : null;
197
- const scopedTagsStr = scopedTags ? JSON.stringify(scopedTags) : null;
198
- const vaultName = opts.vault_name ?? null;
199
- const createdVia = opts.created_via ?? null;
200
- const parentJti = opts.parent_jti ?? null;
201
-
202
- db.prepare(`
203
- INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, scope_tag, scope_path_prefix, expires_at, created_at, vault_name, created_via, parent_jti)
204
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
205
- `).run(
206
- tokenHash,
207
- opts.label,
208
- permission,
209
- scopesStr,
210
- scopedTagsStr,
211
- opts.scope_tag ?? null,
212
- opts.scope_path_prefix ?? null,
213
- opts.expires_at ?? null,
214
- now,
215
- vaultName,
216
- createdVia,
217
- parentJti,
218
- );
219
-
220
- return {
221
- token_hash: tokenHash,
222
- label: opts.label,
223
- permission,
224
- scope_tag: opts.scope_tag ?? null,
225
- scope_path_prefix: opts.scope_path_prefix ?? null,
226
- scoped_tags: scopedTags,
227
- vault_name: vaultName,
228
- expires_at: opts.expires_at ?? null,
229
- created_at: now,
230
- last_used_at: null,
231
- created_via: createdVia,
232
- parent_jti: parentJti,
233
- revoked_at: null,
234
- };
235
- }
236
-
237
- /**
238
- * Resolve a bearer token. Returns the token info if valid, null if not found or expired.
239
- * Updates last_used_at on successful resolution.
240
- */
241
- export function resolveToken(db: Database, providedToken: string): ResolvedToken | null {
242
- // Hash-then-lookup: the SQL = comparison on SHA-256 output is not timing-safe,
243
- // but this is acceptable — the attacker would need to guess a valid SHA-256
244
- // preimage, which is computationally infeasible regardless of timing leaks.
245
- const candidateHash = hashKey(providedToken);
246
-
247
- // Defensive SELECT for revoked_at: the column exists post-v19, but a
248
- // freshly-opened ResolvedToken-only test fixture might run on a DB the
249
- // migration hasn't touched. SQLite returns NULL for missing columns when
250
- // the table is queried via prepared statements only after migration; here
251
- // initSchema fires on every store-open path, so the column is guaranteed
252
- // present in production. Tests instantiating bare DBs against this
253
- // module are expected to call initSchema first.
254
- const row = db.prepare(`
255
- SELECT token_hash, permission, scopes, scoped_tags, expires_at, vault_name, revoked_at
256
- FROM tokens WHERE token_hash = ?
257
- `).get(candidateHash) as {
258
- token_hash: string;
259
- permission: string;
260
- scopes: string | null;
261
- scoped_tags: string | null;
262
- expires_at: string | null;
263
- vault_name: string | null;
264
- revoked_at: string | null;
265
- } | null;
266
-
267
- if (!row) return null;
268
-
269
- // Soft-revoked tokens never authenticate (v19). The row stays in place
270
- // for audit; resolveToken just treats it as not-found from the caller's
271
- // perspective.
272
- if (row.revoked_at) return null;
273
-
274
- // Check expiry
275
- if (row.expires_at && new Date(row.expires_at) < new Date()) {
276
- return null;
277
- }
278
-
279
- // Update last_used_at
280
- db.prepare("UPDATE tokens SET last_used_at = ? WHERE token_hash = ?")
281
- .run(new Date().toISOString(), row.token_hash);
282
-
283
- const permission = normalizePermission(row.permission);
284
- const parsed = parseScopes(row.scopes);
285
- const hasVaultScope = parsed.some((s) => s.startsWith("vault:"));
286
- const scopes = hasVaultScope ? parsed : legacyPermissionToScopes(permission);
287
- const legacyDerived = !hasVaultScope;
288
- const scoped_tags = parseScopedTags(row.scoped_tags);
289
- const jti = `t_${row.token_hash.slice(7, 19)}`;
290
-
291
- return { permission, scopes, legacyDerived, scoped_tags, vault_name: row.vault_name, jti };
292
- }
293
-
294
120
  /**
295
121
  * List tokens (for CLI display + admin SPA). Never exposes the hash
296
122
  * directly — shows a truncated prefix for identification.
@@ -325,17 +151,70 @@ export function listTokens(
325
151
  }));
326
152
  }
327
153
 
154
+ // ---------------------------------------------------------------------------
155
+ // mcp_mint_ledger — session-pinned index of HUB JWTs minted by manage-token
156
+ // (vault#403, MGT). After the auth-unification arc the tool mints hub JWTs,
157
+ // not pvt_* rows, so the session attribution (parent_jti → minted jti) lives
158
+ // here instead of in the `tokens` table. Rows are NOT credentials — only the
159
+ // hub `jti` (the revocation handle) plus display metadata is stored; the
160
+ // signed token never touches the vault DB.
161
+ // ---------------------------------------------------------------------------
162
+
328
163
  /**
329
- * List tokens minted via the manage-token MCP tool by a given session
330
- * (parent_jti). Used by `manage-token` action="list" to scope its surface
331
- * to its own session's mints — operators with multiple MCP sessions open
332
- * don't see each other's tokens, and CLI/REST-minted tokens never appear.
164
+ * Record a hub-minted JWT in the session-pinned ledger. `jti` is hub's
165
+ * returned jti; `parentJti` is the minting MCP session (the caller's
166
+ * `caller_jti`).
333
167
  *
334
- * Returns metadata only (no token-hash exposure beyond the display id);
335
- * the display id is what the caller uses to revoke. Includes `revoked_at`
336
- * so the UI can render a tombstone for soft-revoked rows.
168
+ * Uses INSERT OR IGNORE, NOT OR REPLACE: hub guarantees jti uniqueness, so a
169
+ * pre-existing row with this jti shouldn't happen. If it does, it's a real bug
170
+ * (a hub jti collision) we log a warning and KEEP the existing row rather
171
+ * than overwriting it, because OR REPLACE would silently reset a previously-set
172
+ * `revoked_at` and resurrect a revoked token in the list/revoke surface.
337
173
  */
338
- export function listMcpMintedTokens(
174
+ export function recordMcpMintLedger(
175
+ db: Database,
176
+ entry: {
177
+ jti: string;
178
+ parentJti: string;
179
+ vaultName: string;
180
+ label: string;
181
+ scopes: string[];
182
+ scopedTags: string[] | null;
183
+ expiresAt: string | null;
184
+ },
185
+ ): void {
186
+ const scopedTags = entry.scopedTags && entry.scopedTags.length > 0 ? entry.scopedTags : null;
187
+ const result = db.prepare(`
188
+ INSERT OR IGNORE INTO mcp_mint_ledger
189
+ (jti, parent_jti, vault_name, label, scopes, scoped_tags, created_at, expires_at, revoked_at)
190
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
191
+ `).run(
192
+ entry.jti,
193
+ entry.parentJti,
194
+ entry.vaultName,
195
+ entry.label,
196
+ serializeScopes(entry.scopes),
197
+ scopedTags ? JSON.stringify(scopedTags) : null,
198
+ new Date().toISOString(),
199
+ entry.expiresAt,
200
+ );
201
+ if (result.changes === 0) {
202
+ // Row already existed — IGNORE swallowed the conflict. Surface it: a hub
203
+ // jti collision is a real bug worth investigating (the existing row is
204
+ // left untouched, including any `revoked_at`).
205
+ console.warn(
206
+ `[manage-token] mcp_mint_ledger already has a row for jti '${entry.jti}' — skipped insert (kept existing row). A hub jti collision shouldn't happen; investigate.`,
207
+ );
208
+ }
209
+ }
210
+
211
+ /**
212
+ * List hub JWTs minted by a given MCP session (parent_jti) against a vault.
213
+ * Returns metadata only (the hub `jti`, label, scopes, timestamps) so the
214
+ * manage-token list surface stays session-scoped. Includes `revoked_at` so
215
+ * callers can render a tombstone for soft-revoked rows.
216
+ */
217
+ export function listMcpMintedHubJwts(
339
218
  db: Database,
340
219
  parentJti: string,
341
220
  vaultName: string,
@@ -349,14 +228,12 @@ export function listMcpMintedTokens(
349
228
  revoked_at: string | null;
350
229
  }> {
351
230
  const rows = db.prepare(`
352
- SELECT token_hash, label, scopes, scoped_tags, created_at, expires_at, revoked_at
353
- FROM tokens
354
- WHERE created_via = 'mcp_mint'
355
- AND parent_jti = ?
356
- AND vault_name = ?
231
+ SELECT jti, label, scopes, scoped_tags, created_at, expires_at, revoked_at
232
+ FROM mcp_mint_ledger
233
+ WHERE parent_jti = ? AND vault_name = ?
357
234
  ORDER BY created_at DESC
358
235
  `).all(parentJti, vaultName) as {
359
- token_hash: string;
236
+ jti: string;
360
237
  label: string;
361
238
  scopes: string | null;
362
239
  scoped_tags: string | null;
@@ -365,7 +242,7 @@ export function listMcpMintedTokens(
365
242
  revoked_at: string | null;
366
243
  }[];
367
244
  return rows.map((r) => ({
368
- jti: `t_${r.token_hash.slice(7, 19)}`,
245
+ jti: r.jti,
369
246
  label: r.label,
370
247
  scopes: parseScopes(r.scopes),
371
248
  scoped_tags: parseScopedTags(r.scoped_tags),
@@ -376,47 +253,44 @@ export function listMcpMintedTokens(
376
253
  }
377
254
 
378
255
  /**
379
- * Soft-revoke a token minted via manage-token, scoped to the session that
380
- * minted it. Idempotent: revoking an already-revoked or never-existent jti
381
- * returns the same shape; second-call to revoke is intentionally still
382
- * ok=true so the AI's revoke step doesn't surface a confusing failure on a
383
- * retry after a network blip. The row stays in place for audit trail —
384
- * resolveToken treats revoked_at-set rows as not-found.
385
- *
386
- * `parentJti` + `vaultName` scope the lookup: a token minted by a
387
- * different MCP session (or against a different vault) returns ok=false.
388
- * Returns { ok: true, already_revoked? } when the operation matched a row.
256
+ * Look up a single ledger row by (jti, parent_jti, vault_name) the
257
+ * session-pin gate for revoke. Returns null when the jti isn't in THIS
258
+ * session's ledger (a different session's mint, or a never-minted jti),
259
+ * which the caller turns into the not_found path. Returns the row (incl.
260
+ * `revoked_at`) when it belongs to this session.
389
261
  */
390
- export function softRevokeMcpToken(
262
+ export function findMcpMintLedgerEntry(
391
263
  db: Database,
392
264
  jti: string,
393
265
  parentJti: string,
394
266
  vaultName: string,
395
- ): { ok: true; already_revoked: boolean } | { ok: false; reason: "not_found" } {
396
- if (!jti.startsWith("t_")) {
397
- return { ok: false, reason: "not_found" };
398
- }
399
- const hashPrefix = jti.slice(2);
267
+ ): { jti: string; revoked_at: string | null } | null {
400
268
  const row = db.prepare(`
401
- SELECT token_hash, revoked_at FROM tokens
402
- WHERE token_hash LIKE ?
403
- AND created_via = 'mcp_mint'
404
- AND parent_jti = ?
405
- AND vault_name = ?
269
+ SELECT jti, revoked_at FROM mcp_mint_ledger
270
+ WHERE jti = ? AND parent_jti = ? AND vault_name = ?
406
271
  LIMIT 1
407
- `).get(`sha256:${hashPrefix}%`, parentJti, vaultName) as {
408
- token_hash: string;
409
- revoked_at: string | null;
410
- } | null;
272
+ `).get(jti, parentJti, vaultName) as { jti: string; revoked_at: string | null } | null;
273
+ return row ?? null;
274
+ }
411
275
 
412
- if (!row) return { ok: false, reason: "not_found" };
413
- if (row.revoked_at) {
414
- // Second revoke: idempotent already done, surface true with the flag.
415
- return { ok: true, already_revoked: true };
416
- }
417
- db.prepare("UPDATE tokens SET revoked_at = ? WHERE token_hash = ?")
418
- .run(new Date().toISOString(), row.token_hash);
419
- return { ok: true, already_revoked: false };
276
+ /**
277
+ * Mark a ledger row revoked (the local attribution-side soft-revoke; the
278
+ * authoritative revocation happens in hub's registry via the revoke-token
279
+ * call). Idempotent: a second call on an already-revoked row leaves the
280
+ * existing timestamp in place. Only flips rows belonging to the given
281
+ * session defense-in-depth on top of the caller's `findMcpMintLedgerEntry`
282
+ * gate.
283
+ */
284
+ export function markMcpMintLedgerRevoked(
285
+ db: Database,
286
+ jti: string,
287
+ parentJti: string,
288
+ vaultName: string,
289
+ ): void {
290
+ db.prepare(`
291
+ UPDATE mcp_mint_ledger SET revoked_at = ?
292
+ WHERE jti = ? AND parent_jti = ? AND vault_name = ? AND revoked_at IS NULL
293
+ `).run(new Date().toISOString(), jti, parentJti, vaultName);
420
294
  }
421
295
 
422
296
  /**
@@ -60,7 +60,13 @@ describe("vault create --json", () => {
60
60
  expect(lines).toHaveLength(1);
61
61
  const payload = JSON.parse(lines[0]!);
62
62
  expect(payload.name).toBe("myvault");
63
- expect(payload.token).toMatch(/^pvt_/);
63
+ // vault#282 Stage 2: vault no longer mints pvt_* tokens. The contract
64
+ // hub's admin-vaults.ts requires still holds (`token` is a string). In
65
+ // this sandbox there's no hub/operator.token, so no token is issued: the
66
+ // token field is the empty string and `token_guidance` explains why.
67
+ expect(typeof payload.token).toBe("string");
68
+ expect(payload.token).toBe("");
69
+ expect(payload.token_guidance).toContain("No token issued");
64
70
  expect(payload.set_as_default).toBe(true);
65
71
  expect(payload.paths.vault_dir).toBe(join(home, "vault", "data", "myvault"));
66
72
  expect(payload.paths.vault_db).toBe(join(home, "vault", "data", "myvault", "vault.db"));
@@ -177,7 +183,7 @@ describe("vault create — services.json registration (#208)", () => {
177
183
  });
178
184
  });
179
185
 
180
- describe("vault create (human mode unchanged)", () => {
186
+ describe("vault create (human mode)", () => {
181
187
  test("prints multi-line human output without --json", () => {
182
188
  const { exitCode, stdout } = runCli(
183
189
  ["create", "human"],
@@ -185,8 +191,10 @@ describe("vault create (human mode unchanged)", () => {
185
191
  );
186
192
  expect(exitCode).toBe(0);
187
193
  expect(stdout).toContain('Vault "human" created.');
188
- expect(stdout).toContain("API token:");
189
- expect(stdout).toContain("Save this");
194
+ // vault#282 Stage 2: with no hub reachable in this sandbox, no token is
195
+ // issued — the human output prints the guidance instead of "API token:".
196
+ expect(stdout).toContain("No token issued");
197
+ expect(stdout).toContain("Install the hub");
190
198
  // Human output should NOT be valid JSON.
191
199
  expect(() => JSON.parse(stdout.trim())).toThrow();
192
200
  });