@openparachute/vault 0.5.1 → 0.5.2-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.
- package/core/src/core.test.ts +183 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +77 -0
- package/core/src/mcp.ts +130 -22
- package/core/src/notes.ts +36 -0
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/schema.ts +7 -4
- package/core/src/store.ts +1 -1
- package/core/src/tag-schemas.ts +59 -44
- package/core/src/types.ts +31 -3
- package/package.json +1 -1
- package/src/auth.test.ts +37 -1
- package/src/auth.ts +29 -0
- package/src/cli.ts +286 -68
- package/src/config.test.ts +16 -0
- package/src/config.ts +39 -0
- package/src/init-summary.test.ts +77 -5
- package/src/init-summary.ts +37 -19
- package/src/mcp-tools.ts +60 -6
- package/src/routes.ts +486 -53
- package/src/routing.test.ts +185 -0
- package/src/routing.ts +32 -2
- package/src/server.ts +7 -0
- package/src/storage.test.ts +162 -0
- package/src/tag-scope.ts +68 -1
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +298 -11
- package/src/vault.test.ts +1064 -7
package/src/cli.ts
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
buildMcpConfigJson,
|
|
57
57
|
buildMcpEntryPlan,
|
|
58
58
|
chooseHubOrigin,
|
|
59
|
+
chooseMcpUrl,
|
|
59
60
|
detectInstallContext,
|
|
60
61
|
mintHubJwt,
|
|
61
62
|
readOperatorToken,
|
|
@@ -102,6 +103,13 @@ import { listTokens, revokeToken, migrateVaultKeys } from "./token-store.ts";
|
|
|
102
103
|
import { VAULT_SCOPES } from "./scopes.ts";
|
|
103
104
|
import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
|
|
104
105
|
import { getVaultStore } from "./vault-store.ts";
|
|
106
|
+
import {
|
|
107
|
+
defaultMirrorConfig,
|
|
108
|
+
resolveMirrorPath,
|
|
109
|
+
writeMirrorConfigForVault,
|
|
110
|
+
type MirrorConfig,
|
|
111
|
+
} from "./mirror-config.ts";
|
|
112
|
+
import { bootstrapInternalMirror } from "./mirror-manager.ts";
|
|
105
113
|
import { selfRegister } from "./self-register.ts";
|
|
106
114
|
import {
|
|
107
115
|
hasOwnerPassword,
|
|
@@ -471,11 +479,16 @@ async function cmdInit(args: string[] = []) {
|
|
|
471
479
|
addMcp = true; // non-interactive: preserve the installable-via-pipe default
|
|
472
480
|
}
|
|
473
481
|
|
|
474
|
-
// 7b.
|
|
475
|
-
// Cursor, Zed, Cline, scripts, curl.)
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
482
|
+
// 7b. Mint an API token for the header-auth / script use case? (Codex,
|
|
483
|
+
// Goose, OpenCode, Cursor, Zed, Cline, scripts, curl.)
|
|
484
|
+
//
|
|
485
|
+
// vault#442: default vault auth is per-user OAuth — the Claude Code MCP entry
|
|
486
|
+
// is written WITHOUT a baked bearer, so the first connection does browser
|
|
487
|
+
// sign-in. We mint a token ONLY when the operator explicitly opts in
|
|
488
|
+
// (`--token`, or "yes" at the prompt), and then it's scope-narrow
|
|
489
|
+
// (`vault:<name>:read`), NEVER admin. `--no-token` (and the non-interactive
|
|
490
|
+
// default) skips minting entirely — no auto-mint, no noisy mint-failure on a
|
|
491
|
+
// fresh vault.
|
|
479
492
|
let addToken: boolean;
|
|
480
493
|
if (flagTokenOff) {
|
|
481
494
|
addToken = false;
|
|
@@ -483,25 +496,22 @@ async function cmdInit(args: string[] = []) {
|
|
|
483
496
|
addToken = true;
|
|
484
497
|
} else if (process.stdin.isTTY) {
|
|
485
498
|
addToken = await confirm(
|
|
486
|
-
"
|
|
487
|
-
|
|
499
|
+
"Also mint a header-auth API token for non-OAuth clients / scripts (Codex, Goose, curl)? (OAuth works without one)",
|
|
500
|
+
false,
|
|
488
501
|
);
|
|
489
502
|
} else {
|
|
490
|
-
addToken =
|
|
503
|
+
addToken = false; // non-interactive default: OAuth-first, no auto-mint
|
|
491
504
|
}
|
|
492
505
|
|
|
493
|
-
// Mint a token
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
// summary — the operator runs `mcp-install` once a hub is up, or sets
|
|
500
|
-
// VAULT_AUTH_TOKEN.
|
|
506
|
+
// Mint a scope-narrow token ONLY when explicitly opted in and we don't
|
|
507
|
+
// already have one from vault creation. vault#282 Stage 2: vault no longer
|
|
508
|
+
// mints pvt_* tokens — this is a hub JWT via the operator.token → hub
|
|
509
|
+
// mint-token path (`mintBootstrapCredential`), scoped `vault:<name>:read`.
|
|
510
|
+
// When no hub is reachable, `apiKey` stays undefined and we carry the
|
|
511
|
+
// guidance to the summary. The default (OAuth) path never reaches here.
|
|
501
512
|
const defaultVault = globalConfig.default_vault || "default";
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const credential = await mintBootstrapCredential(defaultVault);
|
|
513
|
+
if (addToken && !apiKey) {
|
|
514
|
+
const credential = await mintBootstrapCredential(defaultVault, "read");
|
|
505
515
|
apiKey = credential.token ?? undefined;
|
|
506
516
|
credentialGuidance = credential.guidance;
|
|
507
517
|
if (!apiKey) console.log(` ${credential.guidance}`);
|
|
@@ -510,10 +520,9 @@ async function cmdInit(args: string[] = []) {
|
|
|
510
520
|
if (addMcp) {
|
|
511
521
|
// Goes through `buildMcpEntryPlan` for entryKey + url so this path shares
|
|
512
522
|
// the writer-side invariant with `executeMcpInstall` — a future URL-shape
|
|
513
|
-
// change can't drift between init and mcp-install.
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
// hub is up).
|
|
523
|
+
// change can't drift between init and mcp-install. By default NO bearer is
|
|
524
|
+
// baked (vault#442 — OAuth on first connect); a bearer is embedded only
|
|
525
|
+
// when the operator explicitly opted into a scope-narrow token above.
|
|
517
526
|
const target = resolveInstallTarget("user");
|
|
518
527
|
const { entryKey, url, source } = buildMcpEntryPlan({
|
|
519
528
|
vaultName: defaultVault,
|
|
@@ -528,6 +537,9 @@ async function cmdInit(args: string[] = []) {
|
|
|
528
537
|
});
|
|
529
538
|
console.log(`MCP URL: ${url} (${source})`);
|
|
530
539
|
console.log(` MCP server added to ~/.claude.json`);
|
|
540
|
+
if (!apiKey) {
|
|
541
|
+
console.log(` No token baked in — you'll sign in via OAuth on first connect.`);
|
|
542
|
+
}
|
|
531
543
|
} else {
|
|
532
544
|
console.log(" Skipped adding MCP to ~/.claude.json.");
|
|
533
545
|
console.log(" Run `parachute-vault mcp-install` later if you want it.");
|
|
@@ -544,6 +556,7 @@ async function cmdInit(args: string[] = []) {
|
|
|
544
556
|
bindHost,
|
|
545
557
|
port,
|
|
546
558
|
mcpUrl,
|
|
559
|
+
vaultName: defaultVault,
|
|
547
560
|
noTokenGuidance: credentialGuidance,
|
|
548
561
|
});
|
|
549
562
|
for (const line of lines) console.log(line);
|
|
@@ -814,20 +827,71 @@ async function cmdCreate(args: string[]) {
|
|
|
814
827
|
// POST /vaults shells out to this CLI and parses stdout). Errors still go
|
|
815
828
|
// to stderr as plain text and exit nonzero — callers branch on exit code.
|
|
816
829
|
const jsonMode = args.includes("--json");
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
830
|
+
// `--no-mirror` opts THIS create out of the default internal live mirror
|
|
831
|
+
// even when the server-wide `default_mirror` knob is `internal`. Parity for
|
|
832
|
+
// operators who want one bare vault without flipping the global default.
|
|
833
|
+
const noMirror = args.includes("--no-mirror");
|
|
834
|
+
|
|
835
|
+
// --- Auth opt-in (vault#442). Default = per-user OAuth, NO token minted. ---
|
|
836
|
+
// `--mint` opts into a scope-narrow hub JWT for the header-auth / script
|
|
837
|
+
// use case; `--scope read|write` (default read) picks the verb. `:admin` is
|
|
838
|
+
// intentionally NOT accepted from the create flow. `--token <bearer>` is the
|
|
839
|
+
// paste path — use an existing bearer instead of minting. The two are
|
|
840
|
+
// mutually exclusive.
|
|
841
|
+
const wantMint = args.includes("--mint");
|
|
842
|
+
const createTokenArg = takeArgValue(args, "--token");
|
|
843
|
+
if (createTokenArg.missingValue) {
|
|
844
|
+
console.error("--token requires a value (the bearer token to embed).");
|
|
845
|
+
process.exit(1);
|
|
846
|
+
}
|
|
847
|
+
const pastedToken = createTokenArg.value;
|
|
848
|
+
if (wantMint && pastedToken !== undefined) {
|
|
849
|
+
console.error("--mint and --token are mutually exclusive.");
|
|
850
|
+
process.exit(1);
|
|
851
|
+
}
|
|
852
|
+
const createScopeArg = takeArgValue(args, "--scope");
|
|
853
|
+
if (createScopeArg.missingValue) {
|
|
854
|
+
console.error("--scope requires a value: read or write.");
|
|
855
|
+
process.exit(1);
|
|
856
|
+
}
|
|
857
|
+
const rawCreateVerb = createScopeArg.value ?? "read";
|
|
858
|
+
if (rawCreateVerb !== "read" && rawCreateVerb !== "write") {
|
|
859
|
+
console.error(
|
|
860
|
+
`--scope must be "read" or "write" for create (admin is minted out-of-band via \`mcp-install --scope vault:admin\`). Got: ${rawCreateVerb}.`,
|
|
861
|
+
);
|
|
862
|
+
process.exit(1);
|
|
863
|
+
}
|
|
864
|
+
const mintVerb: "read" | "write" | undefined = wantMint ? rawCreateVerb : undefined;
|
|
865
|
+
|
|
866
|
+
// Greedy strip of any `--*` token (and any `--flag value` pairs we consumed
|
|
867
|
+
// above) to recover the positional vault name. `--json`, `--no-mirror`,
|
|
868
|
+
// `--mint`, `--token <v>`, `--scope <v>` are recognized; any other `--foo` is
|
|
869
|
+
// silently dropped, and the value following a recognized value-flag is
|
|
870
|
+
// skipped so it can't be mistaken for the vault name.
|
|
871
|
+
const VALUE_FLAGS = new Set(["--token", "--scope"]);
|
|
872
|
+
const positional: string[] = [];
|
|
873
|
+
for (let i = 0; i < args.length; i++) {
|
|
874
|
+
const a = args[i]!;
|
|
875
|
+
if (a.startsWith("--")) {
|
|
876
|
+
if (VALUE_FLAGS.has(a)) i++; // skip the flag's value
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
positional.push(a);
|
|
880
|
+
}
|
|
823
881
|
const name = positional[0];
|
|
824
882
|
if (!name) {
|
|
825
883
|
console.error("Usage: parachute-vault create <name> [--json]");
|
|
826
884
|
process.exit(1);
|
|
827
885
|
}
|
|
828
886
|
|
|
829
|
-
|
|
830
|
-
|
|
887
|
+
// Lowercase-only (security review — multi-user hardening). An uppercase
|
|
888
|
+
// vault name flips the audience case (`vault.<Name>` vs `vault.<name>`)
|
|
889
|
+
// and drifts from hub-side / init-path lowercasing, breaking JWT
|
|
890
|
+
// audience matching. `init` already enforces lowercase via
|
|
891
|
+
// `validateVaultName`; mirror that rule here so uppercase can't enter
|
|
892
|
+
// through `create` either.
|
|
893
|
+
if (!/^[a-z0-9_-]+$/.test(name)) {
|
|
894
|
+
console.error("Vault name must be lowercase alphanumeric with hyphens or underscores (no uppercase).");
|
|
831
895
|
process.exit(1);
|
|
832
896
|
}
|
|
833
897
|
if (name === "list") {
|
|
@@ -846,7 +910,18 @@ async function cmdCreate(args: string[]) {
|
|
|
846
910
|
|
|
847
911
|
ensureConfigDirSync();
|
|
848
912
|
const wasFirst = listVaults().length === 0;
|
|
849
|
-
const credential = await createVault(name
|
|
913
|
+
const credential = await createVault(name, {
|
|
914
|
+
...(noMirror ? { enableMirror: false } : {}),
|
|
915
|
+
...(mintVerb ? { mintVerb } : {}),
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// `--token <bearer>` paste path: the operator supplied their own bearer
|
|
919
|
+
// instead of minting one. Surface it as the credential token so the
|
|
920
|
+
// downstream JSON / human / summary copy treats it the same as a minted one.
|
|
921
|
+
const effectiveToken = pastedToken ?? credential.token;
|
|
922
|
+
const effectiveGuidance = pastedToken
|
|
923
|
+
? "Using the bearer you supplied via --token."
|
|
924
|
+
: credential.guidance;
|
|
850
925
|
|
|
851
926
|
// If this is the only vault now, make it the default so unscoped routes
|
|
852
927
|
// (/mcp, /api/*, /oauth/*) target it. Avoids the "single vault named
|
|
@@ -880,17 +955,19 @@ async function cmdCreate(args: string[]) {
|
|
|
880
955
|
log: () => {}, // CLI create has its own status lines.
|
|
881
956
|
});
|
|
882
957
|
|
|
958
|
+
const endpoint = chooseMcpUrl(name, globalConfig.port || DEFAULT_PORT).url;
|
|
959
|
+
|
|
883
960
|
if (jsonMode) {
|
|
884
961
|
// Contract (hub's admin-vaults.ts requires `typeof token === "string"`):
|
|
885
|
-
// emit
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
962
|
+
// emit a token string when one was minted/pasted. vault#442 default is
|
|
963
|
+
// per-user OAuth — no token is minted — so `token` is the empty string and
|
|
964
|
+
// `token_guidance` carries the OAuth-first connect path. (Hub's admin SPA
|
|
965
|
+
// already handles the empty-token case + has its own session-cookie admin
|
|
966
|
+
// mint path, so it doesn't depend on create minting a token.)
|
|
890
967
|
const payload = {
|
|
891
968
|
name,
|
|
892
|
-
token:
|
|
893
|
-
token_guidance:
|
|
969
|
+
token: effectiveToken ?? "",
|
|
970
|
+
token_guidance: effectiveGuidance,
|
|
894
971
|
paths: {
|
|
895
972
|
vault_dir: vaultDir(name),
|
|
896
973
|
vault_db: vaultDbPath(name),
|
|
@@ -904,18 +981,20 @@ async function cmdCreate(args: string[]) {
|
|
|
904
981
|
|
|
905
982
|
console.log(`Vault "${name}" created.`);
|
|
906
983
|
console.log(` Path: ${vaultDir(name)}`);
|
|
907
|
-
if (
|
|
908
|
-
console.log(` API token: ${
|
|
909
|
-
console.log(` ${
|
|
984
|
+
if (effectiveToken) {
|
|
985
|
+
console.log(` API token: ${effectiveToken}`);
|
|
986
|
+
console.log(` ${effectiveGuidance}`);
|
|
910
987
|
console.log(` Save this — it will not be shown again.`);
|
|
911
988
|
} else {
|
|
912
|
-
console.log(` ${
|
|
989
|
+
console.log(` ${effectiveGuidance}`);
|
|
913
990
|
}
|
|
914
991
|
if (defaultNote) {
|
|
915
992
|
console.log(` ${defaultNote}`);
|
|
916
993
|
}
|
|
917
994
|
console.log();
|
|
918
|
-
console.log(`
|
|
995
|
+
console.log(`Connect your AI: claude mcp add --transport http parachute-${name} ${endpoint}`);
|
|
996
|
+
console.log(` (no token needed — you'll sign in on first use)`);
|
|
997
|
+
console.log(`Need a header-auth token for a script? parachute auth mint-token --scope vault:${name}:read`);
|
|
919
998
|
}
|
|
920
999
|
|
|
921
1000
|
function cmdList() {
|
|
@@ -3257,30 +3336,40 @@ async function firstChangedNoteTitle(
|
|
|
3257
3336
|
/**
|
|
3258
3337
|
* Outcome of bootstrapping a fresh vault's first credential (vault#282 Stage 2).
|
|
3259
3338
|
*
|
|
3260
|
-
* Vault no longer mints `pvt_*` tokens.
|
|
3261
|
-
* a hub-issued JWT
|
|
3262
|
-
*
|
|
3263
|
-
*
|
|
3264
|
-
*
|
|
3339
|
+
* Vault no longer mints `pvt_*` tokens. When a token IS minted (explicit opt-in
|
|
3340
|
+
* only — vault#442), it's a hub-issued JWT scoped narrow (`vault:<name>:read`
|
|
3341
|
+
* or `:write`, NEVER `:admin`), minted via the same operator.token → hub
|
|
3342
|
+
* mint-token path `mcp-install --mint` uses (cli.ts ~`cmdMcpInstall`). When no
|
|
3343
|
+
* hub is reachable (standalone install, no operator.token, or no real hub
|
|
3344
|
+
* origin), `token` is null and `guidance` carries the operator's next step.
|
|
3265
3345
|
*/
|
|
3266
3346
|
interface VaultCredential {
|
|
3267
|
-
/** Hub-issued JWT scoped
|
|
3347
|
+
/** Hub-issued JWT scoped narrow (read/write), or null when not minted / no hub reachable. */
|
|
3268
3348
|
token: string | null;
|
|
3269
3349
|
/** Human-readable note: how the token was issued, or why it wasn't. */
|
|
3270
3350
|
guidance: string;
|
|
3271
3351
|
}
|
|
3272
3352
|
|
|
3273
3353
|
/**
|
|
3274
|
-
* Mint
|
|
3354
|
+
* Mint a scope-narrow credential for a vault (explicit opt-in — vault#442).
|
|
3275
3355
|
*
|
|
3276
|
-
*
|
|
3277
|
-
*
|
|
3278
|
-
*
|
|
3279
|
-
*
|
|
3356
|
+
* Default vault auth is per-user OAuth (browser sign-in on first MCP connect);
|
|
3357
|
+
* tokens are only for the header-auth / script use case and are minted ONLY
|
|
3358
|
+
* when explicitly requested. Decision (vault#442): the create/init flow NEVER
|
|
3359
|
+
* auto-mints, and when a token IS requested it's scope-narrow — `verb` is
|
|
3360
|
+
* `read` (default) or `write`, NEVER `admin`. (Admin tokens, when truly
|
|
3361
|
+
* needed, are minted out-of-band via `mcp-install --scope vault:admin` against
|
|
3362
|
+
* a hub running hub#449, or the hub admin SPA's own session-cookie path.)
|
|
3363
|
+
*
|
|
3364
|
+
* When a hub is reachable (operator.token present AND a real hub origin
|
|
3365
|
+
* resolves), mint a `vault:<name>:<verb>` hub JWT and return it. When no hub is
|
|
3280
3366
|
* reachable, return `token: null` plus explicit standalone guidance. There is
|
|
3281
3367
|
* no local pvt_* fallback anymore.
|
|
3282
3368
|
*/
|
|
3283
|
-
async function mintBootstrapCredential(
|
|
3369
|
+
async function mintBootstrapCredential(
|
|
3370
|
+
name: string,
|
|
3371
|
+
verb: "read" | "write" = "read",
|
|
3372
|
+
): Promise<VaultCredential> {
|
|
3284
3373
|
const operatorToken = readOperatorToken();
|
|
3285
3374
|
if (!operatorToken) {
|
|
3286
3375
|
return {
|
|
@@ -3305,7 +3394,7 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
|
|
|
3305
3394
|
const result = await mintHubJwt({
|
|
3306
3395
|
hubOrigin: hub.url,
|
|
3307
3396
|
operatorToken,
|
|
3308
|
-
scope: `vault:${name}
|
|
3397
|
+
scope: `vault:${name}:${verb}`,
|
|
3309
3398
|
subject: "parachute-vault-bootstrap",
|
|
3310
3399
|
});
|
|
3311
3400
|
if ("kind" in result) {
|
|
@@ -3316,7 +3405,7 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
|
|
|
3316
3405
|
return {
|
|
3317
3406
|
token: null,
|
|
3318
3407
|
guidance:
|
|
3319
|
-
`No token issued — ${detail}. Verify the hub is running
|
|
3408
|
+
`No token issued — ${detail}. Verify the hub is running, ` +
|
|
3320
3409
|
"then run `parachute-vault mcp-install`, or set VAULT_AUTH_TOKEN.",
|
|
3321
3410
|
};
|
|
3322
3411
|
}
|
|
@@ -3327,13 +3416,76 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
|
|
|
3327
3416
|
}
|
|
3328
3417
|
|
|
3329
3418
|
/**
|
|
3330
|
-
* Create a vault's config + DB
|
|
3419
|
+
* Create a vault's config + DB.
|
|
3331
3420
|
*
|
|
3332
|
-
*
|
|
3333
|
-
*
|
|
3334
|
-
* `
|
|
3421
|
+
* Default vault auth is per-user OAuth (vault#442) — create does NOT mint or
|
|
3422
|
+
* bake in any token. Returns a `VaultCredential` whose `token` is null and
|
|
3423
|
+
* whose `guidance` points at the OAuth-first connect path. A scope-narrow
|
|
3424
|
+
* token is minted only when the caller passes `mintVerb` (the explicit
|
|
3425
|
+
* header-auth / script opt-in: `read` default or `write`, NEVER `admin`). The
|
|
3426
|
+
* DB is created lazily via `getVaultStore` so migrations + schema run; we never
|
|
3427
|
+
* write any pvt_* row.
|
|
3335
3428
|
*/
|
|
3336
|
-
|
|
3429
|
+
interface CreateVaultOptions {
|
|
3430
|
+
/**
|
|
3431
|
+
* Opt-in token mint (vault#442). Unset → no token is minted; the vault uses
|
|
3432
|
+
* per-user OAuth on first MCP connect. Set to `read`/`write` → mint a
|
|
3433
|
+
* scope-narrow `vault:<name>:<verb>` hub JWT for the header-auth / script
|
|
3434
|
+
* use case. `admin` is intentionally NOT accepted here.
|
|
3435
|
+
*/
|
|
3436
|
+
mintVerb?: "read" | "write";
|
|
3437
|
+
/**
|
|
3438
|
+
* Override the server-wide `default_mirror` knob for this one create.
|
|
3439
|
+
* `--no-mirror` on `parachute-vault create` sets this to `false` so the
|
|
3440
|
+
* vault is created with no mirror config even when the knob is `internal`.
|
|
3441
|
+
* Unset → fall back to the `default_mirror` global config knob (default
|
|
3442
|
+
* `internal`).
|
|
3443
|
+
*/
|
|
3444
|
+
enableMirror?: boolean;
|
|
3445
|
+
/**
|
|
3446
|
+
* Test seam threaded straight into `bootstrapInternalMirror` (default
|
|
3447
|
+
* `Bun.which`). Inject a fn returning `null` to exercise the
|
|
3448
|
+
* git-not-installed best-effort path without uninstalling git from the
|
|
3449
|
+
* test host.
|
|
3450
|
+
*/
|
|
3451
|
+
which?: (cmd: string) => string | null;
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
/**
|
|
3455
|
+
* The History / "Live Mirror" preset, written at create time when the
|
|
3456
|
+
* `default_mirror` knob resolves to `internal`. Matches the History preset
|
|
3457
|
+
* the admin SPA's VaultMirror page applies:
|
|
3458
|
+
* `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
|
|
3459
|
+
* auto_push:false}`.
|
|
3460
|
+
* Built on top of `defaultMirrorConfig()` so the non-preset fields
|
|
3461
|
+
* (commit_template, safety_net_seconds) stay canonical.
|
|
3462
|
+
*/
|
|
3463
|
+
function historyPresetMirrorConfig(): MirrorConfig {
|
|
3464
|
+
return {
|
|
3465
|
+
...defaultMirrorConfig(),
|
|
3466
|
+
enabled: true,
|
|
3467
|
+
location: "internal",
|
|
3468
|
+
sync_mode: "events",
|
|
3469
|
+
auto_commit: true,
|
|
3470
|
+
auto_push: false,
|
|
3471
|
+
};
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
/**
|
|
3475
|
+
* Resolve whether a freshly created vault should get the internal mirror.
|
|
3476
|
+
* Precedence: explicit per-create override (`--no-mirror`) → server-wide
|
|
3477
|
+
* `default_mirror` knob (default `internal`).
|
|
3478
|
+
*/
|
|
3479
|
+
function shouldEnableCreateTimeMirror(opts: CreateVaultOptions): boolean {
|
|
3480
|
+
if (opts.enableMirror !== undefined) return opts.enableMirror;
|
|
3481
|
+
// Default to "internal" when the knob is unset — backup-on-by-default.
|
|
3482
|
+
return (readGlobalConfig().default_mirror ?? "internal") === "internal";
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
async function createVault(
|
|
3486
|
+
name: string,
|
|
3487
|
+
opts: CreateVaultOptions = {},
|
|
3488
|
+
): Promise<VaultCredential> {
|
|
3337
3489
|
const config: VaultConfig = {
|
|
3338
3490
|
name,
|
|
3339
3491
|
api_keys: [],
|
|
@@ -3344,7 +3496,60 @@ async function createVault(name: string): Promise<VaultCredential> {
|
|
|
3344
3496
|
// Touch the store so the vault's SQLite DB + schema are created. No token
|
|
3345
3497
|
// row is written — vault is a pure hub resource-server post-0.5.0.
|
|
3346
3498
|
getVaultStore(name);
|
|
3347
|
-
|
|
3499
|
+
|
|
3500
|
+
// Default new vaults to an internal live mirror (local git backup of the
|
|
3501
|
+
// markdown projection). Backup-on-by-default; GitHub off-site backup is an
|
|
3502
|
+
// opt-in upgrade layered on top later. Opt out via the `default_mirror: off`
|
|
3503
|
+
// global knob (operators on git-less / disk-constrained / cloud boxes) or
|
|
3504
|
+
// the `--no-mirror` flag (this one create only).
|
|
3505
|
+
//
|
|
3506
|
+
// BEST-EFFORT, NON-FATAL: write the mirror config first (so the operator's
|
|
3507
|
+
// intent persists even if git is absent), then attempt the bootstrap. A
|
|
3508
|
+
// git-less box leaves the config written but inactive + logs an actionable
|
|
3509
|
+
// hint — it must NEVER fail the vault create. Create-time ONLY: existing
|
|
3510
|
+
// vaults are never retroactively migrated.
|
|
3511
|
+
if (shouldEnableCreateTimeMirror(opts)) {
|
|
3512
|
+
const mirrorConfig = historyPresetMirrorConfig();
|
|
3513
|
+
writeMirrorConfigForVault(name, mirrorConfig);
|
|
3514
|
+
const mirrorPath = resolveMirrorPath(vaultDir(name), mirrorConfig);
|
|
3515
|
+
if (mirrorPath) {
|
|
3516
|
+
try {
|
|
3517
|
+
const result = await bootstrapInternalMirror(mirrorPath, opts.which);
|
|
3518
|
+
if (!result.ok) {
|
|
3519
|
+
// git-not-installed (or refuse-to-clobber) — config stays written,
|
|
3520
|
+
// mirror just isn't active yet. Surface an actionable line; the
|
|
3521
|
+
// vault create succeeds regardless.
|
|
3522
|
+
console.error(
|
|
3523
|
+
`Note: local git backup configured but not yet active — ${result.error} ` +
|
|
3524
|
+
`Install git to activate; the backup turns on automatically on the next vault restart.`,
|
|
3525
|
+
);
|
|
3526
|
+
}
|
|
3527
|
+
} catch (err) {
|
|
3528
|
+
// Defense-in-depth: bootstrapInternalMirror already converts the
|
|
3529
|
+
// git-missing case into a non-throwing { ok:false } result, but a
|
|
3530
|
+
// truly unexpected throw must still not fail the create.
|
|
3531
|
+
console.error(
|
|
3532
|
+
`Note: local git backup configured but bootstrap hit an unexpected error ` +
|
|
3533
|
+
`(${(err as Error).message ?? err}). The vault was still created; ` +
|
|
3534
|
+
`the backup will retry on the next vault restart.`,
|
|
3535
|
+
);
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
// vault#442: default to per-user OAuth — do NOT auto-mint or bake in a
|
|
3541
|
+
// shared token. Only mint when the caller explicitly opted in (header-auth /
|
|
3542
|
+
// script use case), and then scope-narrow (read/write, never admin).
|
|
3543
|
+
if (opts.mintVerb) {
|
|
3544
|
+
return mintBootstrapCredential(name, opts.mintVerb);
|
|
3545
|
+
}
|
|
3546
|
+
return {
|
|
3547
|
+
token: null,
|
|
3548
|
+
guidance:
|
|
3549
|
+
"No token minted — this vault uses per-user OAuth (sign in on first connect). " +
|
|
3550
|
+
"Need a header-auth token for a script? Run " +
|
|
3551
|
+
`\`parachute auth mint-token --scope vault:${name}:read\` (or \`:write\`).`,
|
|
3552
|
+
};
|
|
3348
3553
|
}
|
|
3349
3554
|
|
|
3350
3555
|
interface InstallMcpConfigOpts {
|
|
@@ -3426,9 +3631,11 @@ Setup:
|
|
|
3426
3631
|
parachute-vault init [--mcp|--no-mcp] [--token|--no-token] [--vault-name <name>]
|
|
3427
3632
|
[--autostart|--no-autostart]
|
|
3428
3633
|
Set up everything (one command, idempotent).
|
|
3429
|
-
--mcp/--no-mcp controls the Claude Code MCP entry
|
|
3430
|
-
|
|
3431
|
-
|
|
3634
|
+
--mcp/--no-mcp controls the Claude Code MCP entry (written
|
|
3635
|
+
for per-user OAuth by default — no baked token; sign in on
|
|
3636
|
+
first connect). --token opts into ALSO minting a scope-narrow
|
|
3637
|
+
header-auth token (vault:<name>:read) for non-OAuth clients /
|
|
3638
|
+
scripts; --no-token (the default) skips minting entirely.
|
|
3432
3639
|
--vault-name skips the prompt and names the vault
|
|
3433
3640
|
(lowercase alphanumeric, hyphens, underscores;
|
|
3434
3641
|
omit to be prompted interactively, default "default").
|
|
@@ -3448,7 +3655,18 @@ Setup:
|
|
|
3448
3655
|
parachute --version Print the installed version (alias: -v, version)
|
|
3449
3656
|
|
|
3450
3657
|
Vaults:
|
|
3451
|
-
parachute-vault create <name> [--json]
|
|
3658
|
+
parachute-vault create <name> [--json] [--no-mirror] [--mint [--scope read|write]] [--token <bearer>]
|
|
3659
|
+
Create a new vault (--json: emit { name, token, paths, set_as_default }).
|
|
3660
|
+
Default auth is per-user OAuth — NO token is minted; connect with
|
|
3661
|
+
"claude mcp add --transport http parachute-<name> <endpoint>" and sign in
|
|
3662
|
+
on first use. --mint opts into a scope-narrow hub JWT for the header-auth /
|
|
3663
|
+
script case (--scope read [default] | write — admin is NOT mintable from
|
|
3664
|
+
create); --token <bearer> pastes an existing bearer instead of minting.
|
|
3665
|
+
New vaults default to an internal live mirror — a local git backup of
|
|
3666
|
+
the markdown projection (backup on by default; GitHub off-site is an
|
|
3667
|
+
opt-in upgrade). --no-mirror creates a bare vault with no mirror config.
|
|
3668
|
+
Operators can flip the server-wide default with 'default_mirror: off' in
|
|
3669
|
+
config.yaml (recommended for cloud / disk-constrained boxes).
|
|
3452
3670
|
parachute-vault list List all vaults
|
|
3453
3671
|
parachute-vault remove <name> [--yes] Remove a vault
|
|
3454
3672
|
parachute-vault mcp-install [--mint|--token <t>]
|
package/src/config.test.ts
CHANGED
|
@@ -250,6 +250,22 @@ describe("config", () => {
|
|
|
250
250
|
writeGlobalConfig({ port: 1940, autostart: false });
|
|
251
251
|
expect(readGlobalConfig().autostart).toBe(false);
|
|
252
252
|
});
|
|
253
|
+
|
|
254
|
+
test("round-trips default_mirror: internal|off", () => {
|
|
255
|
+
// Absent: createVault falls back to the in-code default ("internal" —
|
|
256
|
+
// backup-on-by-default). The knob is only persisted when explicitly set.
|
|
257
|
+
writeGlobalConfig({ port: 1940 });
|
|
258
|
+
expect(readGlobalConfig().default_mirror).toBeUndefined();
|
|
259
|
+
|
|
260
|
+
// Explicit internal — new vaults get the History-preset local git mirror.
|
|
261
|
+
writeGlobalConfig({ port: 1940, default_mirror: "internal" });
|
|
262
|
+
expect(readGlobalConfig().default_mirror).toBe("internal");
|
|
263
|
+
|
|
264
|
+
// Explicit off — the opt-out operators set on git-less / disk-constrained
|
|
265
|
+
// / cloud boxes so new vaults are created with no mirror config.
|
|
266
|
+
writeGlobalConfig({ port: 1940, default_mirror: "off" });
|
|
267
|
+
expect(readGlobalConfig().default_mirror).toBe("off");
|
|
268
|
+
});
|
|
253
269
|
});
|
|
254
270
|
|
|
255
271
|
// ---------------------------------------------------------------------------
|
package/src/config.ts
CHANGED
|
@@ -115,6 +115,17 @@ export function vaultConfigPath(name: string): string {
|
|
|
115
115
|
return join(vaultDir(name), "vault.yaml");
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Per-vault attachments directory: `<vaultDir>/assets`, or the `ASSETS_DIR`
|
|
120
|
+
* env override when set (single-assets-root deployments). Lives here next to
|
|
121
|
+
* the other path helpers — neutral ground that both `routes.ts` (upload/serve)
|
|
122
|
+
* and `usage.ts` (footprint dir-walk) import without a cycle. `routes.ts`
|
|
123
|
+
* re-exports it for the existing callers (mirror-deps, server, triggers, …).
|
|
124
|
+
*/
|
|
125
|
+
export function assetsDir(name: string): string {
|
|
126
|
+
return process.env.ASSETS_DIR ?? join(vaultDir(name), "assets");
|
|
127
|
+
}
|
|
128
|
+
|
|
118
129
|
// ---------------------------------------------------------------------------
|
|
119
130
|
// Types
|
|
120
131
|
// ---------------------------------------------------------------------------
|
|
@@ -280,6 +291,29 @@ export interface GlobalConfig {
|
|
|
280
291
|
* resolved path. See `./mirror-config.ts`.
|
|
281
292
|
*/
|
|
282
293
|
mirror?: MirrorConfigType;
|
|
294
|
+
/**
|
|
295
|
+
* Server-wide DEFAULT for newly created vaults' backup posture. Decides
|
|
296
|
+
* whether `createVault` writes the History-preset internal mirror
|
|
297
|
+
* (local git backup of the markdown projection) at create time.
|
|
298
|
+
*
|
|
299
|
+
* - `"internal"` (default) — new vaults get a local git mirror enabled
|
|
300
|
+
* out of the box (backup-on-by-default). The History preset:
|
|
301
|
+
* `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
|
|
302
|
+
* auto_push:false}`. GitHub off-site backup remains an opt-in upgrade.
|
|
303
|
+
* - `"off"` — new vaults are created with no mirror config (the historical
|
|
304
|
+
* pre-default behavior). The escape hatch for git-less / disk-constrained
|
|
305
|
+
* boxes and cloud deploys, where doubling disk per vault is unwanted.
|
|
306
|
+
* Cloud / container deploys SHOULD set this to `off`.
|
|
307
|
+
*
|
|
308
|
+
* Create-time ONLY — this knob does NOT retroactively enable mirrors on
|
|
309
|
+
* already-created vaults (that would ~double disk across every existing
|
|
310
|
+
* vault). Existing-vault opt-in is a separate, deliberate follow-up.
|
|
311
|
+
*
|
|
312
|
+
* The container/cloud first-boot auto-create path in `server.ts` does NOT
|
|
313
|
+
* funnel through `createVault`, so it is unaffected by this knob and stays
|
|
314
|
+
* mirror-off regardless — matching the recommended cloud posture.
|
|
315
|
+
*/
|
|
316
|
+
default_mirror?: "internal" | "off";
|
|
283
317
|
/**
|
|
284
318
|
* Auto-transcribe configuration for the vault↔scribe handoff (vault#353,
|
|
285
319
|
* design 2026-05-21 Part 2). When `enabled: true` AND scribe is discoverable
|
|
@@ -1162,6 +1196,7 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1162
1196
|
const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
|
|
1163
1197
|
const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
|
|
1164
1198
|
const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
|
|
1199
|
+
const defaultMirrorMatch = yaml.match(/^default_mirror:\s*(internal|off)/m);
|
|
1165
1200
|
// auto_transcribe block — currently single boolean `enabled` (vault#353).
|
|
1166
1201
|
// Parsed as a nested 2-space-indent block so future fields can grow under
|
|
1167
1202
|
// it without breaking the regex; only `enabled` is read for v0.6.
|
|
@@ -1190,6 +1225,9 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1190
1225
|
if (autostartMatch) {
|
|
1191
1226
|
config.autostart = autostartMatch[1]! === "true";
|
|
1192
1227
|
}
|
|
1228
|
+
if (defaultMirrorMatch) {
|
|
1229
|
+
config.default_mirror = defaultMirrorMatch[1]! as "internal" | "off";
|
|
1230
|
+
}
|
|
1193
1231
|
if (autoTranscribeEnabled !== undefined) {
|
|
1194
1232
|
config.auto_transcribe = { enabled: autoTranscribeEnabled };
|
|
1195
1233
|
}
|
|
@@ -1259,6 +1297,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
|
|
|
1259
1297
|
if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
|
|
1260
1298
|
if (config.discovery) lines.push(`discovery: ${config.discovery}`);
|
|
1261
1299
|
if (config.autostart !== undefined) lines.push(`autostart: ${config.autostart}`);
|
|
1300
|
+
if (config.default_mirror) lines.push(`default_mirror: ${config.default_mirror}`);
|
|
1262
1301
|
if (config.owner_password_hash) {
|
|
1263
1302
|
lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
|
|
1264
1303
|
}
|