@openparachute/vault 0.6.4-rc.3 → 0.6.4-rc.5
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 +126 -0
- package/core/src/mcp.ts +8 -4
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +37 -0
- package/core/src/store.ts +47 -18
- package/package.json +1 -1
- package/src/cli.ts +164 -3
- package/src/module-config.ts +46 -80
- package/src/routes.ts +28 -9
- package/src/routing.test.ts +42 -25
- package/src/vault.test.ts +52 -0
package/core/src/store.ts
CHANGED
|
@@ -631,36 +631,65 @@ export class BunSqliteStore implements Store {
|
|
|
631
631
|
const priorRecord =
|
|
632
632
|
patch.fields !== undefined ? tagSchemaOps.getTagRecord(this.db, tag) : null;
|
|
633
633
|
|
|
634
|
-
const
|
|
635
|
-
|
|
634
|
+
const indexedSet = (fields: Record<string, tagSchemaOps.TagFieldSchema> | null | undefined) =>
|
|
635
|
+
new Set(
|
|
636
|
+
Object.entries(fields ?? {})
|
|
637
|
+
.filter(([, v]) => v.indexed === true)
|
|
638
|
+
.map(([k]) => k),
|
|
639
|
+
);
|
|
640
|
+
const nextFields = patch.fields; // object | null | undefined
|
|
641
|
+
const priorIndexed = indexedSet(priorRecord?.fields);
|
|
642
|
+
const nextIndexed = indexedSet(nextFields);
|
|
643
|
+
|
|
644
|
+
// PRE-VALIDATE every newly-indexed field BEFORE any persistence. A bad
|
|
645
|
+
// field name (or unmappable type) must fail closed — the schema record
|
|
646
|
+
// must NOT be written when the backing index can't be created. Pre-checking
|
|
647
|
+
// here, before the transaction even opens, turns the failure into a clean
|
|
648
|
+
// caller error (IndexedFieldError → 400) and leaves the schema untouched.
|
|
649
|
+
// Without this, the prior code persisted the field declaration, THEN threw
|
|
650
|
+
// on declareField — a 500 plus a tag claiming an index the engine can't
|
|
651
|
+
// build (the "lying schema" loop). See vault#478.
|
|
636
652
|
if (patch.fields !== undefined) {
|
|
637
|
-
const indexedSet = (fields: Record<string, tagSchemaOps.TagFieldSchema> | null | undefined) =>
|
|
638
|
-
new Set(
|
|
639
|
-
Object.entries(fields ?? {})
|
|
640
|
-
.filter(([, v]) => v.indexed === true)
|
|
641
|
-
.map(([k]) => k),
|
|
642
|
-
);
|
|
643
|
-
const nextFields = patch.fields; // object | null
|
|
644
|
-
const priorIndexed = indexedSet(priorRecord?.fields);
|
|
645
|
-
const nextIndexed = indexedSet(nextFields);
|
|
646
653
|
for (const fieldName of nextIndexed) {
|
|
647
654
|
const spec = nextFields![fieldName]!;
|
|
648
655
|
const mapped = indexedFieldOps.mapFieldType(spec.type);
|
|
649
|
-
// Unmappable type for indexing is a caller error; surface it rather
|
|
650
|
-
// than silently skipping. MCP/REST validate up-front for a cleaner
|
|
651
|
-
// message, but this is the backstop at the chokepoint.
|
|
652
656
|
if (!mapped) {
|
|
653
657
|
throw new indexedFieldOps.IndexedFieldError(
|
|
654
658
|
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
|
|
655
659
|
);
|
|
656
660
|
}
|
|
657
|
-
|
|
661
|
+
// Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
|
|
662
|
+
indexedFieldOps.validateFieldName(fieldName);
|
|
658
663
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Persist the record + reconcile the indexed-field lifecycle atomically.
|
|
667
|
+
// If declareField throws inside the transaction (e.g. a cross-tag type
|
|
668
|
+
// mismatch only detectable once the existing declarer set is consulted),
|
|
669
|
+
// the whole write rolls back — the schema never ends up claiming an index
|
|
670
|
+
// that doesn't exist. vault#478 transactional fix.
|
|
671
|
+
let result: tagSchemaOps.TagRecord;
|
|
672
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
673
|
+
try {
|
|
674
|
+
result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
|
|
675
|
+
|
|
676
|
+
if (patch.fields !== undefined) {
|
|
677
|
+
for (const fieldName of nextIndexed) {
|
|
678
|
+
const spec = nextFields![fieldName]!;
|
|
679
|
+
// Type already validated above; non-null assertion is safe here.
|
|
680
|
+
const mapped = indexedFieldOps.mapFieldType(spec.type)!;
|
|
681
|
+
indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
|
|
682
|
+
}
|
|
683
|
+
for (const fieldName of priorIndexed) {
|
|
684
|
+
if (!nextIndexed.has(fieldName)) {
|
|
685
|
+
indexedFieldOps.releaseField(this.db, fieldName, tag);
|
|
686
|
+
}
|
|
662
687
|
}
|
|
663
688
|
}
|
|
689
|
+
this.db.exec("COMMIT");
|
|
690
|
+
} catch (err) {
|
|
691
|
+
this.db.exec("ROLLBACK");
|
|
692
|
+
throw err;
|
|
664
693
|
}
|
|
665
694
|
|
|
666
695
|
if (patch.parent_names !== undefined) {
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -3345,11 +3345,17 @@ async function cmdExport(args: string[]) {
|
|
|
3345
3345
|
*/
|
|
3346
3346
|
async function cmdSchema(args: string[] = []) {
|
|
3347
3347
|
const sub = args[0];
|
|
3348
|
+
if (sub === "migrate-field") {
|
|
3349
|
+
await cmdSchemaMigrateField(args.slice(1));
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3348
3352
|
if (sub !== "prune") {
|
|
3349
|
-
console.error("Usage: parachute-vault schema prune
|
|
3353
|
+
console.error("Usage: parachute-vault schema <prune|migrate-field> ...");
|
|
3350
3354
|
console.error("\nSubcommands:");
|
|
3351
|
-
console.error(" prune
|
|
3352
|
-
console.error("
|
|
3355
|
+
console.error(" prune Drop orphaned indexed-field columns whose declaring tags are gone.");
|
|
3356
|
+
console.error(" Dry-run by default (--dry-run is an explicit alias); pass --apply (or --yes) to execute.");
|
|
3357
|
+
console.error(" migrate-field Evolve a tag-schema field across existing notes (rename/remap/set-default/retype).");
|
|
3358
|
+
console.error(" Dry-run by default; pass --apply (or --yes) to write. See `schema migrate-field --help`.");
|
|
3353
3359
|
process.exit(1);
|
|
3354
3360
|
}
|
|
3355
3361
|
|
|
@@ -3416,6 +3422,154 @@ async function cmdSchema(args: string[] = []) {
|
|
|
3416
3422
|
}
|
|
3417
3423
|
}
|
|
3418
3424
|
|
|
3425
|
+
/**
|
|
3426
|
+
* `parachute-vault schema migrate-field <tag> <field> --transform <spec>` —
|
|
3427
|
+
* evolve a tag-schema field's type / values across the existing notes that
|
|
3428
|
+
* carry the tag (vault#314). Dry-run by default — prints the count + a sample
|
|
3429
|
+
* of what WOULD change and writes nothing; `--apply` (alias `--yes`) executes.
|
|
3430
|
+
*
|
|
3431
|
+
* Transform specs (the built-in set — issue #314):
|
|
3432
|
+
* rename:<newkey> move the field's value to <newkey>, drop the old key
|
|
3433
|
+
* remap:<old>=<new>[,…] rewrite enum-style values (repeatable / comma-sep)
|
|
3434
|
+
* set-default:<value> fill the field where missing/null
|
|
3435
|
+
* set-default-invalid:<v> set <v> where missing/null OR currently schema-invalid
|
|
3436
|
+
* to_string | to_int | to_number | to_boolean retype the value
|
|
3437
|
+
*
|
|
3438
|
+
* The migration writes through `strict` enforcement (the migrate-bypass path,
|
|
3439
|
+
* vault#299) so a freshly-declared strict field that the back-catalog violates
|
|
3440
|
+
* doesn't block the rewrite, and every bypassed write is logged. Rewrites are
|
|
3441
|
+
* attributed `migrate` (last_updated_*); created_* is preserved.
|
|
3442
|
+
*
|
|
3443
|
+
* Atomic by default: if any note's value can't be converted (e.g. `to_int` on
|
|
3444
|
+
* "banana"), the apply aborts BEFORE writing anything and lists the offenders.
|
|
3445
|
+
* Pass `--continue-on-error` for best-effort (write the convertible, skip the rest).
|
|
3446
|
+
*/
|
|
3447
|
+
async function cmdSchemaMigrateField(args: string[]) {
|
|
3448
|
+
const usageLine =
|
|
3449
|
+
"Usage: parachute-vault schema migrate-field <tag> <field> --transform <spec> [--vault <name>] [--apply|--yes] [--continue-on-error]";
|
|
3450
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
3451
|
+
console.log(usageLine);
|
|
3452
|
+
console.log("\nTransform specs:");
|
|
3453
|
+
console.log(" rename:<newkey> Move the field's value to <newkey>, drop the old key.");
|
|
3454
|
+
console.log(" remap:<old>=<new>[,...] Rewrite enum-style values (repeatable, comma-separated).");
|
|
3455
|
+
console.log(" set-default:<value> Set <value> on notes where the field is missing/null.");
|
|
3456
|
+
console.log(" set-default-invalid:<v> Set <v> where missing/null OR the current value is schema-invalid.");
|
|
3457
|
+
console.log(" to_string | to_int | to_number | to_boolean Retype the field's value.");
|
|
3458
|
+
console.log("\nDry-run by default — prints the plan. Pass --apply (or --yes) to write.");
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
let vaultName = "default";
|
|
3463
|
+
let apply = false;
|
|
3464
|
+
let continueOnError = false;
|
|
3465
|
+
let transformSpec: string | undefined;
|
|
3466
|
+
const positionals: string[] = [];
|
|
3467
|
+
for (let i = 0; i < args.length; i++) {
|
|
3468
|
+
const arg = args[i]!;
|
|
3469
|
+
if (arg === "--vault") {
|
|
3470
|
+
const v = args[++i];
|
|
3471
|
+
if (!v) { console.error("--vault requires a value."); process.exit(1); }
|
|
3472
|
+
vaultName = v;
|
|
3473
|
+
} else if (arg === "--transform") {
|
|
3474
|
+
const v = args[++i];
|
|
3475
|
+
if (!v) { console.error("--transform requires a value."); process.exit(1); }
|
|
3476
|
+
transformSpec = v;
|
|
3477
|
+
} else if (arg === "--apply" || arg === "--yes") {
|
|
3478
|
+
apply = true;
|
|
3479
|
+
} else if (arg === "--dry-run") {
|
|
3480
|
+
// default; accept as an explicit affirmative
|
|
3481
|
+
} else if (arg === "--continue-on-error") {
|
|
3482
|
+
continueOnError = true;
|
|
3483
|
+
} else if (arg.startsWith("--")) {
|
|
3484
|
+
console.error(`Unknown flag for \`schema migrate-field\`: ${arg}`);
|
|
3485
|
+
process.exit(1);
|
|
3486
|
+
} else {
|
|
3487
|
+
positionals.push(arg);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
const [tag, field] = positionals;
|
|
3492
|
+
if (!tag || !field || !transformSpec) {
|
|
3493
|
+
console.error(usageLine);
|
|
3494
|
+
console.error("\n <tag> <field> and --transform <spec> are all required.");
|
|
3495
|
+
console.error(" Run `schema migrate-field --help` for the transform spec list.");
|
|
3496
|
+
process.exit(1);
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
const { parseTransformSpec } = await import("../core/src/migrate-tag-field.ts");
|
|
3500
|
+
let transform;
|
|
3501
|
+
try {
|
|
3502
|
+
transform = parseTransformSpec(transformSpec);
|
|
3503
|
+
} catch (err) {
|
|
3504
|
+
console.error(`Invalid --transform "${transformSpec}": ${(err as Error).message}`);
|
|
3505
|
+
process.exit(1);
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
const config = readVaultConfig(vaultName);
|
|
3509
|
+
if (!config) {
|
|
3510
|
+
console.error(`Vault "${vaultName}" not found.`);
|
|
3511
|
+
process.exit(1);
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
const { getVaultStore } = await import("./vault-store.ts");
|
|
3515
|
+
const { migrateTagField } = await import("../core/src/migrate-tag-field.ts");
|
|
3516
|
+
const { logStrictBypass } = await import("./scopes.ts");
|
|
3517
|
+
const store = getVaultStore(vaultName);
|
|
3518
|
+
|
|
3519
|
+
const result = await migrateTagField(store, {
|
|
3520
|
+
tag,
|
|
3521
|
+
field,
|
|
3522
|
+
transform: transform!,
|
|
3523
|
+
apply,
|
|
3524
|
+
continueOnError,
|
|
3525
|
+
// The CLI is the operator path — it bypasses strict enforcement by nature
|
|
3526
|
+
// (writes go through the store, not the scope-gated transport). Log every
|
|
3527
|
+
// waived violation so the bypass is auditable, exactly as MW2 intends.
|
|
3528
|
+
onStrictBypass: (info) => logStrictBypass(info),
|
|
3529
|
+
});
|
|
3530
|
+
|
|
3531
|
+
const verb = result.applied ? "Migrated" : "Would migrate";
|
|
3532
|
+
console.log(
|
|
3533
|
+
`${verb} field "${result.field}" on #${result.tag} in vault "${vaultName}"${result.applied ? "" : " (dry-run)"}:`,
|
|
3534
|
+
);
|
|
3535
|
+
console.log(
|
|
3536
|
+
` scanned ${result.scanned} · ${result.applied ? "changed" : "would change"} ${result.changed} · unchanged ${result.unchanged} · errored ${result.errored}`,
|
|
3537
|
+
);
|
|
3538
|
+
|
|
3539
|
+
if (result.sample.length > 0) {
|
|
3540
|
+
console.log(`\n ${result.applied ? "Changes" : "Sample of changes"} (${result.sample.length}${result.changed > result.sample.length ? ` of ${result.changed}` : ""}):`);
|
|
3541
|
+
for (const c of result.sample) {
|
|
3542
|
+
const ref = c.path ?? c.id;
|
|
3543
|
+
if (c.renamedTo) {
|
|
3544
|
+
console.log(` ${ref}: ${field} → ${c.renamedTo} = ${JSON.stringify(c.after)}`);
|
|
3545
|
+
} else {
|
|
3546
|
+
console.log(` ${ref}: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`);
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
if (result.errors.length > 0) {
|
|
3552
|
+
console.log(`\n Unconvertible (${result.errors.length}):`);
|
|
3553
|
+
for (const e of result.errors) {
|
|
3554
|
+
console.log(` ${e.path ?? e.id}: ${e.error}`);
|
|
3555
|
+
}
|
|
3556
|
+
if (apply && !result.applied) {
|
|
3557
|
+
console.log(
|
|
3558
|
+
"\n Aborted without writing — some notes can't be converted (atomic by default).",
|
|
3559
|
+
);
|
|
3560
|
+
console.log(" Fix the data, or re-run with --continue-on-error to migrate the rest.");
|
|
3561
|
+
process.exit(1);
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
if (!apply && result.changed > 0) {
|
|
3566
|
+
console.log("\n Re-run with --apply (or --yes) to write these changes.");
|
|
3567
|
+
}
|
|
3568
|
+
if (result.changed === 0 && result.errored === 0) {
|
|
3569
|
+
console.log("\n Nothing to migrate — the back-catalog already conforms.");
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3419
3573
|
// ---------------------------------------------------------------------------
|
|
3420
3574
|
// Export-watch glue. The git-shell + commit-message logic lives in
|
|
3421
3575
|
// `./export-watch.ts` for unit-testability; cli.ts just wires it in.
|
|
@@ -3961,6 +4115,13 @@ Schema maintenance:
|
|
|
3961
4115
|
parachute-vault schema prune --apply Execute the prune (alias: --yes). Co-declared
|
|
3962
4116
|
fields keep their column; a drop loses only
|
|
3963
4117
|
the index (data lives in notes.metadata).
|
|
4118
|
+
parachute-vault schema migrate-field <tag> <field> Evolve a tag-schema field across existing
|
|
4119
|
+
--transform <spec> notes (rename:<key>, remap:<old>=<new>,
|
|
4120
|
+
set-default:<v>, to_string/to_int/to_number/
|
|
4121
|
+
to_boolean). Dry-run by default — prints the
|
|
4122
|
+
plan; pass --apply (or --yes) to write.
|
|
4123
|
+
Writes through strict enforcement (migrate
|
|
4124
|
+
bypass). See \`schema migrate-field --help\`.
|
|
3964
4125
|
|
|
3965
4126
|
── Advanced / standalone ──────────────────────────────────────────────
|
|
3966
4127
|
|
package/src/module-config.ts
CHANGED
|
@@ -14,31 +14,26 @@
|
|
|
14
14
|
*
|
|
15
15
|
* PUT /.parachute/config is Phase 3 — not implemented here.
|
|
16
16
|
*
|
|
17
|
+
* Scope boundary (vault#478): `GET /.parachute/config` is gated by
|
|
18
|
+
* `vault:<name>:admin` — admin over *your* vault only — so it describes and
|
|
19
|
+
* returns ONLY per-vault config, never daemon-GLOBAL settings.
|
|
20
|
+
*
|
|
17
21
|
* Fields currently described:
|
|
18
22
|
* - audio_retention: per-vault enum, backed by VaultConfig.audio_retention.
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* arbitrary scribe; the discovery layer is the gate.
|
|
31
|
-
* - scribeBearer: writeOnly — sourced from SCRIBE_AUTH_TOKEN env var.
|
|
32
|
-
* Hub install generates one at first boot
|
|
33
|
-
* (see scribe-env.ts:ensureScribeBearer); manual
|
|
34
|
-
* rotation is via `parachute-vault config set`.
|
|
35
|
-
* - scribe_url / scribe_token (deprecated): kept under their legacy names
|
|
36
|
-
* through one release for the hub admin SPA's prior
|
|
37
|
-
* render path; new code should read autoTranscribe.*.
|
|
23
|
+
* - autoTranscribe.enabled: per-vault toggle (vault#353). Reports the value
|
|
24
|
+
* THIS vault will use, resolving per-vault override
|
|
25
|
+
* (VaultConfig.auto_transcribe.enabled) → server
|
|
26
|
+
* default (GlobalConfig.auto_transcribe.enabled) →
|
|
27
|
+
* true. The inherited fallback is the per-vault
|
|
28
|
+
* truth, not a leak of the global setting.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately NOT exposed here (daemon-global → operator-only surface):
|
|
31
|
+
* - port: GlobalConfig.port (deployment-wide listen port).
|
|
32
|
+
* - autoTranscribe.scribeUrl / scribe_url: discovery-resolved per-process.
|
|
33
|
+
* - autoTranscribe.scribeBearer / scribe_token: shared SCRIBE_AUTH_TOKEN.
|
|
38
34
|
*/
|
|
39
35
|
|
|
40
36
|
import type { VaultConfig, GlobalConfig } from "./config.ts";
|
|
41
|
-
import { resolveScribeUrl } from "./scribe-discovery.ts";
|
|
42
37
|
|
|
43
38
|
export interface ModuleConfigSchema {
|
|
44
39
|
$schema: string;
|
|
@@ -75,83 +70,54 @@ export function buildConfigSchema(): ModuleConfigSchema {
|
|
|
75
70
|
default: true,
|
|
76
71
|
title: "Enable auto-transcription",
|
|
77
72
|
description:
|
|
78
|
-
"
|
|
79
|
-
},
|
|
80
|
-
scribeUrl: {
|
|
81
|
-
type: "string",
|
|
82
|
-
format: "uri",
|
|
83
|
-
readOnly: true,
|
|
84
|
-
title: "Scribe URL",
|
|
85
|
-
description:
|
|
86
|
-
"URL of the scribe service. Auto-populated from `~/.parachute/services.json` at vault startup (or from the SCRIBE_URL env var when set). Read-only — operators can't point at an arbitrary scribe.",
|
|
87
|
-
},
|
|
88
|
-
scribeBearer: {
|
|
89
|
-
type: "string",
|
|
90
|
-
writeOnly: true,
|
|
91
|
-
title: "Scribe auth bearer",
|
|
92
|
-
description:
|
|
93
|
-
"Shared bearer for the vault→scribe loopback contract. Hub install generates one at first boot. Write-only — never returned by GET.",
|
|
73
|
+
"Per-vault toggle. Default on — audio uploads transcribe automatically when scribe is reachable. Set to false to disable for THIS vault. Resolves per-vault override → server default → on, so leaving it unset inherits the deployment default.",
|
|
94
74
|
},
|
|
95
75
|
},
|
|
96
76
|
},
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
description:
|
|
104
|
-
"Legacy alias for `autoTranscribe.scribeUrl`. Will be removed in a future release.",
|
|
105
|
-
readOnly: true,
|
|
106
|
-
deprecated: true,
|
|
107
|
-
},
|
|
108
|
-
scribe_token: {
|
|
109
|
-
type: "string",
|
|
110
|
-
title: "Scribe auth token (deprecated alias)",
|
|
111
|
-
description:
|
|
112
|
-
"Legacy alias for `autoTranscribe.scribeBearer`. Will be removed in a future release.",
|
|
113
|
-
writeOnly: true,
|
|
114
|
-
deprecated: true,
|
|
115
|
-
},
|
|
116
|
-
port: {
|
|
117
|
-
type: "integer",
|
|
118
|
-
minimum: 1,
|
|
119
|
-
maximum: 65535,
|
|
120
|
-
title: "HTTP port",
|
|
121
|
-
description: "Port the vault server listens on. Set at init time; changing requires a restart.",
|
|
122
|
-
readOnly: true,
|
|
123
|
-
},
|
|
77
|
+
// NOTE (vault#478): daemon-GLOBAL fields (the listen `port`, the
|
|
78
|
+
// discovery-resolved scribe URL, the shared scribe bearer) are
|
|
79
|
+
// deliberately NOT described here. This endpoint is gated by
|
|
80
|
+
// `vault:<name>:admin` — admin over *your* vault only — so it neither
|
|
81
|
+
// describes nor returns deployment-wide settings. Those live behind the
|
|
82
|
+
// operator-only surface (CLI / global config).
|
|
124
83
|
},
|
|
125
84
|
};
|
|
126
85
|
}
|
|
127
86
|
|
|
128
87
|
/**
|
|
129
|
-
* Effective config values
|
|
130
|
-
*
|
|
131
|
-
*
|
|
88
|
+
* Effective config values for ONE vault. The shared scribe bearer is
|
|
89
|
+
* daemon-global and never returned (see the scope boundary below).
|
|
90
|
+
*
|
|
91
|
+
* Scope boundary (vault#478): the `GET /vault/<name>/.parachute/config`
|
|
92
|
+
* endpoint is gated by `vault:<name>:admin` — "admin over *your* vault only".
|
|
93
|
+
* It must therefore return ONLY per-vault config, never daemon-GLOBAL settings
|
|
94
|
+
* (the listen `port`, the discovery-resolved scribe URL, the server-wide
|
|
95
|
+
* auto-transcribe default). Those describe the whole deployment, not this
|
|
96
|
+
* vault, and leaking them across the per-vault admin boundary matters once a
|
|
97
|
+
* shared multi-vault daemon hands admin-on-one-vault to a beta signup. They
|
|
98
|
+
* live behind the operator-only surface (the CLI / global config), not here.
|
|
99
|
+
*
|
|
100
|
+
* `autoTranscribe.enabled` IS per-vault: it reports the value THIS vault will
|
|
101
|
+
* actually use, resolving per-vault override → global default → true (mirrors
|
|
102
|
+
* `shouldAutoTranscribe`). That's a per-vault effective value, not the raw
|
|
103
|
+
* daemon-global toggle — reporting it doesn't leak the global setting (an
|
|
104
|
+
* unset vault simply inherits, which is the per-vault truth).
|
|
132
105
|
*/
|
|
133
106
|
export function buildConfigValues(
|
|
134
107
|
vaultConfig: VaultConfig,
|
|
135
108
|
globalConfig: GlobalConfig,
|
|
136
|
-
env: { SCRIBE_URL?: string | undefined } = process.env as { SCRIBE_URL?: string },
|
|
137
109
|
): Record<string, unknown> {
|
|
138
|
-
// Resolve scribe URL through the discovery layer so the GET shape reflects
|
|
139
|
-
// what the worker will actually use (services.json > SCRIBE_URL > unset).
|
|
140
|
-
// Pass env through so the test harness's override is honored.
|
|
141
|
-
const scribeUrl = resolveScribeUrl(env as NodeJS.ProcessEnv) ?? "";
|
|
142
110
|
return {
|
|
143
111
|
audio_retention: vaultConfig.audio_retention ?? "keep",
|
|
144
112
|
autoTranscribe: {
|
|
145
|
-
//
|
|
146
|
-
// the
|
|
147
|
-
//
|
|
148
|
-
enabled:
|
|
149
|
-
|
|
113
|
+
// Per-vault effective value: this vault's own override wins; otherwise it
|
|
114
|
+
// inherits the server default; otherwise true. Same ladder as
|
|
115
|
+
// shouldAutoTranscribe, so the admin SPA shows what this vault will do.
|
|
116
|
+
enabled:
|
|
117
|
+
vaultConfig.auto_transcribe?.enabled
|
|
118
|
+
?? globalConfig.auto_transcribe?.enabled
|
|
119
|
+
?? true,
|
|
150
120
|
},
|
|
151
|
-
// Legacy alias mirrors `autoTranscribe.scribeUrl` so hubs reading the
|
|
152
|
-
// pre-vault#353 shape don't regress.
|
|
153
|
-
scribe_url: scribeUrl,
|
|
154
|
-
port: globalConfig.port,
|
|
155
121
|
};
|
|
156
122
|
}
|
|
157
123
|
|
package/src/routes.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
15
|
import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
16
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
17
|
-
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
17
|
+
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
18
18
|
import {
|
|
19
19
|
parseContentRange,
|
|
20
20
|
applyContentRange,
|
|
@@ -26,6 +26,7 @@ import type { ValidationWarning } from "../core/src/schema-defaults.ts";
|
|
|
26
26
|
import { logStrictBypass } from "./scopes.ts";
|
|
27
27
|
import * as linkOps from "../core/src/links.ts";
|
|
28
28
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
29
|
+
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
29
30
|
import {
|
|
30
31
|
buildExpandVisibility,
|
|
31
32
|
filterHydratedLinksByTagScope,
|
|
@@ -1652,8 +1653,11 @@ async function handleNotesInner(
|
|
|
1652
1653
|
updates.extension = validateExtension(body.extension);
|
|
1653
1654
|
}
|
|
1654
1655
|
if (body.metadata !== undefined) {
|
|
1655
|
-
|
|
1656
|
-
updates.metadata =
|
|
1656
|
+
// RFC 7386 merge: incoming `null` removes the key (vault#478/#479).
|
|
1657
|
+
updates.metadata = mergeMetadata(
|
|
1658
|
+
note.metadata as Record<string, unknown> | null | undefined,
|
|
1659
|
+
body.metadata as Record<string, unknown>,
|
|
1660
|
+
);
|
|
1657
1661
|
}
|
|
1658
1662
|
if (body.created_at !== undefined || body.createdAt !== undefined) {
|
|
1659
1663
|
updates.created_at = body.created_at ?? body.createdAt;
|
|
@@ -2093,12 +2097,27 @@ export async function handleTags(
|
|
|
2093
2097
|
fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
|
|
2094
2098
|
}
|
|
2095
2099
|
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2100
|
+
// A bad indexed-field name (or an unindexable type, or a cross-tag type
|
|
2101
|
+
// mismatch) is a CLIENT error — return 400, not the catch-all 500. The
|
|
2102
|
+
// store pre-validates and is transactional, so the schema is left
|
|
2103
|
+
// unchanged on failure (no orphan/lying index). vault#478.
|
|
2104
|
+
let result;
|
|
2105
|
+
try {
|
|
2106
|
+
result = await store.upsertTagRecord(tagName, {
|
|
2107
|
+
...(body.description !== undefined ? { description: body.description } : {}),
|
|
2108
|
+
...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
|
|
2109
|
+
...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
|
|
2110
|
+
...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
|
|
2111
|
+
});
|
|
2112
|
+
} catch (err) {
|
|
2113
|
+
if (err instanceof IndexedFieldError) {
|
|
2114
|
+
return json(
|
|
2115
|
+
{ error: err.message, error_type: "invalid_indexed_field" },
|
|
2116
|
+
400,
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
throw err;
|
|
2120
|
+
}
|
|
2102
2121
|
return json(result);
|
|
2103
2122
|
}
|
|
2104
2123
|
|
package/src/routing.test.ts
CHANGED
|
@@ -1262,12 +1262,17 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
|
|
|
1262
1262
|
expect(body.type).toBe("object");
|
|
1263
1263
|
expect(body.properties.audio_retention?.type).toBe("string");
|
|
1264
1264
|
expect(body.properties.audio_retention?.enum).toEqual(["keep", "until_transcribed", "never"]);
|
|
1265
|
-
expect(body.properties.
|
|
1266
|
-
|
|
1267
|
-
|
|
1265
|
+
expect(body.properties.autoTranscribe?.type).toBe("object");
|
|
1266
|
+
// vault#478 scope boundary: daemon-GLOBAL fields are NOT described by the
|
|
1267
|
+
// per-vault admin config schema. The per-vault admin surface is "admin
|
|
1268
|
+
// over *your* vault only" — port / scribe URL / scribe bearer are
|
|
1269
|
+
// deployment-wide and live behind the operator-only surface.
|
|
1270
|
+
expect(body.properties).not.toHaveProperty("scribe_url");
|
|
1271
|
+
expect(body.properties).not.toHaveProperty("scribe_token");
|
|
1272
|
+
expect(body.properties).not.toHaveProperty("port");
|
|
1268
1273
|
});
|
|
1269
1274
|
|
|
1270
|
-
test("config returns
|
|
1275
|
+
test("config returns ONLY per-vault values, never daemon-global ones (scope boundary, vault#478)", async () => {
|
|
1271
1276
|
createVault("journal");
|
|
1272
1277
|
const token = await createAdminToken("journal");
|
|
1273
1278
|
const path = "/vault/journal/.parachute/config";
|
|
@@ -1284,13 +1289,22 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
|
|
|
1284
1289
|
);
|
|
1285
1290
|
expect(res.status).toBe(200);
|
|
1286
1291
|
const body = (await res.json()) as Record<string, unknown>;
|
|
1292
|
+
// Per-vault config is present.
|
|
1287
1293
|
expect(body.audio_retention).toBe("keep"); // default when unset
|
|
1288
|
-
expect(body
|
|
1289
|
-
|
|
1294
|
+
expect(body).toHaveProperty("autoTranscribe");
|
|
1295
|
+
// Daemon-GLOBAL values must NOT cross the per-vault admin boundary.
|
|
1296
|
+
expect(body).not.toHaveProperty("port");
|
|
1297
|
+
expect(body).not.toHaveProperty("scribe_url");
|
|
1298
|
+
// The discovery-resolved scribe URL is daemon-global → must not leak,
|
|
1299
|
+
// even nested under autoTranscribe.
|
|
1300
|
+
const at = body.autoTranscribe as Record<string, unknown>;
|
|
1301
|
+
expect(at).not.toHaveProperty("scribeUrl");
|
|
1290
1302
|
// writeOnly field must not appear in GET.
|
|
1291
1303
|
expect(body).not.toHaveProperty("scribe_token");
|
|
1292
|
-
// Defense in depth:
|
|
1293
|
-
|
|
1304
|
+
// Defense in depth: no daemon-global value (URL or secret) anywhere.
|
|
1305
|
+
const raw = JSON.stringify(body);
|
|
1306
|
+
expect(raw).not.toContain("super-secret-should-never-appear");
|
|
1307
|
+
expect(raw).not.toContain("https://scribe.example/v1");
|
|
1294
1308
|
} finally {
|
|
1295
1309
|
if (origScribeToken === undefined) delete process.env.SCRIBE_TOKEN;
|
|
1296
1310
|
else process.env.SCRIBE_TOKEN = origScribeToken;
|
|
@@ -1318,24 +1332,27 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
|
|
|
1318
1332
|
expect(body.audio_retention).toBe("until_transcribed");
|
|
1319
1333
|
});
|
|
1320
1334
|
|
|
1321
|
-
test("
|
|
1322
|
-
|
|
1335
|
+
test("autoTranscribe.enabled reports the per-vault override (vault#478)", async () => {
|
|
1336
|
+
// Server default ON; this vault explicitly opts OUT — the per-vault value
|
|
1337
|
+
// must win, proving the field is reported per-vault (not the raw global).
|
|
1338
|
+
writeGlobalConfig({ port: 1940, auto_transcribe: { enabled: true } });
|
|
1339
|
+
writeVaultConfig({
|
|
1340
|
+
name: "journal",
|
|
1341
|
+
api_keys: [],
|
|
1342
|
+
created_at: new Date().toISOString(),
|
|
1343
|
+
auto_transcribe: { enabled: false },
|
|
1344
|
+
});
|
|
1323
1345
|
const token = await createAdminToken("journal");
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
const body = (await res.json()) as { scribe_url: string };
|
|
1335
|
-
expect(body.scribe_url).toBe("");
|
|
1336
|
-
} finally {
|
|
1337
|
-
if (orig !== undefined) process.env.SCRIBE_URL = orig;
|
|
1338
|
-
}
|
|
1346
|
+
const path = "/vault/journal/.parachute/config";
|
|
1347
|
+
const res = await route(
|
|
1348
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1349
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1350
|
+
}),
|
|
1351
|
+
path,
|
|
1352
|
+
);
|
|
1353
|
+
const body = (await res.json()) as { autoTranscribe: { enabled: boolean } };
|
|
1354
|
+
expect(body.autoTranscribe.enabled).toBe(false);
|
|
1355
|
+
writeGlobalConfig({ port: 1940 });
|
|
1339
1356
|
});
|
|
1340
1357
|
|
|
1341
1358
|
test("unknown vault returns 404 before reaching the config handlers", async () => {
|