@openparachute/vault 0.5.1 → 0.5.2-rc.1
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 +125 -9
- package/src/config.test.ts +16 -0
- package/src/config.ts +39 -0
- 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 +194 -2
- package/src/vault.test.ts +1064 -7
package/core/src/types.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
|
|
1
|
+
import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
2
2
|
import type { PrunedField } from "./indexed-fields.js";
|
|
3
3
|
|
|
4
4
|
// ---- Re-exports ----
|
|
5
5
|
|
|
6
|
-
export type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
|
|
6
|
+
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
7
7
|
export type { PrunedField } from "./indexed-fields.js";
|
|
8
8
|
|
|
9
9
|
// ---- Note ----
|
|
@@ -25,6 +25,14 @@ export interface Note {
|
|
|
25
25
|
updatedAt?: string;
|
|
26
26
|
tags?: string[];
|
|
27
27
|
links?: Link[];
|
|
28
|
+
/**
|
|
29
|
+
* Opt-in link degree (raw row count, both directions by default). Present
|
|
30
|
+
* only when the caller requests it via `include_link_count` (REST/MCP).
|
|
31
|
+
* Surfaced the same way `links`/`attachments` are — an extra key injected
|
|
32
|
+
* onto the response after the base shape. See `getLinkCounts` in links.ts
|
|
33
|
+
* for the exact degree semantics (self-loop = 2 under `both`).
|
|
34
|
+
*/
|
|
35
|
+
linkCount?: number;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
// ---- Link ----
|
|
@@ -59,6 +67,16 @@ export interface VaultStats {
|
|
|
59
67
|
tagCount: number;
|
|
60
68
|
attachmentCount: number;
|
|
61
69
|
linkCount: number;
|
|
70
|
+
/**
|
|
71
|
+
* Total bytes of all note content, computed as the sum of the UTF-8 byte
|
|
72
|
+
* length of every note's `content`. The SQL uses `LENGTH(CAST(content AS
|
|
73
|
+
* BLOB))` deliberately: SQLite's bare `LENGTH(text)` returns the number of
|
|
74
|
+
* *characters*, not bytes, so a note full of multibyte UTF-8 (emoji, CJK,
|
|
75
|
+
* accents) would undercount its true on-disk/on-wire footprint. Casting to
|
|
76
|
+
* BLOB forces `LENGTH` to count raw bytes. This is the logical content size,
|
|
77
|
+
* not the physical DB-file size (see `usage.ts:dbBytes` for the latter).
|
|
78
|
+
*/
|
|
79
|
+
contentBytes: number;
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
// ---- Query Options ----
|
|
@@ -115,6 +133,12 @@ export interface QueryOpts {
|
|
|
115
133
|
// declared `indexed: true`; errors loudly otherwise. Direction is taken
|
|
116
134
|
// from `sort` (default "asc") and `created_at` is appended as a stable
|
|
117
135
|
// tiebreaker.
|
|
136
|
+
//
|
|
137
|
+
// The pseudo-field `link_count` is special-cased (no indexed-field
|
|
138
|
+
// declaration needed): it sorts by link DEGREE — the both-directions
|
|
139
|
+
// raw row count — using the same directional-sum definition as the
|
|
140
|
+
// `linkCount` response field, so the sort key equals the field value for
|
|
141
|
+
// every note (self-loops included). See `queryNotes`/`getLinkCounts`.
|
|
118
142
|
orderBy?: string;
|
|
119
143
|
limit?: number;
|
|
120
144
|
offset?: number;
|
|
@@ -153,6 +177,8 @@ export interface NoteSummary {
|
|
|
153
177
|
createdAt: string;
|
|
154
178
|
updatedAt?: string;
|
|
155
179
|
tags?: string[];
|
|
180
|
+
/** Opt-in link degree (see `Note.linkCount`). */
|
|
181
|
+
linkCount?: number;
|
|
156
182
|
}
|
|
157
183
|
|
|
158
184
|
/**
|
|
@@ -169,6 +195,8 @@ export interface NoteIndex {
|
|
|
169
195
|
metadata?: Record<string, unknown>;
|
|
170
196
|
byteSize: number;
|
|
171
197
|
preview: string;
|
|
198
|
+
/** Opt-in link degree (see `Note.linkCount`). */
|
|
199
|
+
linkCount?: number;
|
|
172
200
|
}
|
|
173
201
|
|
|
174
202
|
/** Link with hydrated note summaries. */
|
|
@@ -313,7 +341,7 @@ export interface Store {
|
|
|
313
341
|
patch: {
|
|
314
342
|
description?: string | null;
|
|
315
343
|
fields?: Record<string, TagFieldSchema> | null;
|
|
316
|
-
relationships?:
|
|
344
|
+
relationships?: TagRelationshipMap | null;
|
|
317
345
|
parent_names?: string[] | null;
|
|
318
346
|
},
|
|
319
347
|
): Promise<TagRecord>;
|
package/package.json
CHANGED
package/src/auth.test.ts
CHANGED
|
@@ -26,7 +26,8 @@ import {
|
|
|
26
26
|
hashKey,
|
|
27
27
|
} from "./config.ts";
|
|
28
28
|
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
29
|
-
import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
|
|
29
|
+
import { authenticateVaultRequest, authenticateGlobalRequest, warnLegacyGlobalApiKeys } from "./auth.ts";
|
|
30
|
+
import type { StoredKey } from "./config.ts";
|
|
30
31
|
|
|
31
32
|
let tmpHome: string;
|
|
32
33
|
let prevHome: string | undefined;
|
|
@@ -442,3 +443,38 @@ describe("auth — VAULT_AUTH_TOKEN server-wide operator bearer", () => {
|
|
|
442
443
|
expect("error" in result).toBe(true);
|
|
443
444
|
});
|
|
444
445
|
});
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Legacy GLOBAL api_keys boot warning (security review — multi-user
|
|
449
|
+
// hardening). Cross-vault credentials in config.yaml must be surfaced loudly
|
|
450
|
+
// at boot, but never altered. Pure-function unit tests (no server boot).
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
describe("warnLegacyGlobalApiKeys (legacy cross-vault key boot warning)", () => {
|
|
453
|
+
function key(id: string): StoredKey {
|
|
454
|
+
return {
|
|
455
|
+
id,
|
|
456
|
+
label: id,
|
|
457
|
+
key_hash: `sha256:${id}`,
|
|
458
|
+
scope: "full",
|
|
459
|
+
created_at: new Date().toISOString(),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
test("warns when global api_keys are present", () => {
|
|
464
|
+
const msgs: string[] = [];
|
|
465
|
+
const count = warnLegacyGlobalApiKeys([key("a"), key("b")], (m) => msgs.push(m));
|
|
466
|
+
expect(count).toBe(2);
|
|
467
|
+
expect(msgs).toHaveLength(1);
|
|
468
|
+
expect(msgs[0]).toContain("legacy GLOBAL api_key");
|
|
469
|
+
expect(msgs[0]).toContain("CROSS-VAULT");
|
|
470
|
+
// Heads-up only — must signal it does NOT alter the keys.
|
|
471
|
+
expect(msgs[0]).toContain("remain active");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("silent when there are no global api_keys", () => {
|
|
475
|
+
const msgs: string[] = [];
|
|
476
|
+
expect(warnLegacyGlobalApiKeys([], (m) => msgs.push(m))).toBe(0);
|
|
477
|
+
expect(warnLegacyGlobalApiKeys(undefined, (m) => msgs.push(m))).toBe(0);
|
|
478
|
+
expect(msgs).toHaveLength(0);
|
|
479
|
+
});
|
|
480
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -171,6 +171,35 @@ export function warnLegacyOnce(cacheKey: string, context: string): void {
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Boot-time warning for legacy GLOBAL `api_keys` in `config.yaml` (security
|
|
176
|
+
* review — multi-user hardening). Those keys are CROSS-VAULT credentials: a
|
|
177
|
+
* single key authenticates against EVERY vault on this server (see the global
|
|
178
|
+
* `api_keys` branch in `authenticate` ~L283). That predates per-vault keys +
|
|
179
|
+
* tag-scoped hub JWTs and is a confidentiality hazard once a server hosts
|
|
180
|
+
* multiple users' vaults — one user's global key reads another's vault.
|
|
181
|
+
*
|
|
182
|
+
* WARNING ONLY — never touches the keys (the operator owns them). The
|
|
183
|
+
* verification flagged 6 such keys on the live box; this surfaces them at
|
|
184
|
+
* boot so they're rotated/removed before multi-user sharing. Returns the
|
|
185
|
+
* count it warned about (0 = silent) so callers / tests can assert.
|
|
186
|
+
*/
|
|
187
|
+
export function warnLegacyGlobalApiKeys(
|
|
188
|
+
globalApiKeys: StoredKey[] | undefined,
|
|
189
|
+
warn: (msg: string) => void = console.warn,
|
|
190
|
+
): number {
|
|
191
|
+
const count = globalApiKeys?.length ?? 0;
|
|
192
|
+
if (count === 0) return 0;
|
|
193
|
+
warn(
|
|
194
|
+
`[auth] WARNING: ${count} legacy GLOBAL api_key(s) found in config.yaml. ` +
|
|
195
|
+
"These are CROSS-VAULT credentials (each grants access to every vault on this server) " +
|
|
196
|
+
"and predate per-vault keys + tag-scoped hub JWTs. Before multi-user sharing, ROTATE or " +
|
|
197
|
+
"REMOVE them — a global key leaks one user's vault to another. They remain active (the " +
|
|
198
|
+
"operator owns them); this is a heads-up, not an automatic change.",
|
|
199
|
+
);
|
|
200
|
+
return count;
|
|
201
|
+
}
|
|
202
|
+
|
|
174
203
|
/** Read-only tools (the only tools allowed for "read" permission). */
|
|
175
204
|
const READ_TOOLS = new Set([
|
|
176
205
|
"query-notes",
|
package/src/cli.ts
CHANGED
|
@@ -102,6 +102,13 @@ import { listTokens, revokeToken, migrateVaultKeys } from "./token-store.ts";
|
|
|
102
102
|
import { VAULT_SCOPES } from "./scopes.ts";
|
|
103
103
|
import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
|
|
104
104
|
import { getVaultStore } from "./vault-store.ts";
|
|
105
|
+
import {
|
|
106
|
+
defaultMirrorConfig,
|
|
107
|
+
resolveMirrorPath,
|
|
108
|
+
writeMirrorConfigForVault,
|
|
109
|
+
type MirrorConfig,
|
|
110
|
+
} from "./mirror-config.ts";
|
|
111
|
+
import { bootstrapInternalMirror } from "./mirror-manager.ts";
|
|
105
112
|
import { selfRegister } from "./self-register.ts";
|
|
106
113
|
import {
|
|
107
114
|
hasOwnerPassword,
|
|
@@ -814,11 +821,15 @@ async function cmdCreate(args: string[]) {
|
|
|
814
821
|
// POST /vaults shells out to this CLI and parses stdout). Errors still go
|
|
815
822
|
// to stderr as plain text and exit nonzero — callers branch on exit code.
|
|
816
823
|
const jsonMode = args.includes("--json");
|
|
824
|
+
// `--no-mirror` opts THIS create out of the default internal live mirror
|
|
825
|
+
// even when the server-wide `default_mirror` knob is `internal`. Parity for
|
|
826
|
+
// operators who want one bare vault without flipping the global default.
|
|
827
|
+
const noMirror = args.includes("--no-mirror");
|
|
817
828
|
// Greedy strip of any `--*` token to recover the positional vault name.
|
|
818
|
-
//
|
|
819
|
-
// If a future flag (e.g. `--force`, `--dry-run`) is added, the
|
|
820
|
-
// here needs to whitelist it — otherwise an invalid flag becomes a
|
|
821
|
-
// no-op rather than a usage error.
|
|
829
|
+
// `--json` and `--no-mirror` are recognized; any other `--foo` is silently
|
|
830
|
+
// dropped. If a future flag (e.g. `--force`, `--dry-run`) is added, the
|
|
831
|
+
// parsing here needs to whitelist it — otherwise an invalid flag becomes a
|
|
832
|
+
// silent no-op rather than a usage error.
|
|
822
833
|
const positional = args.filter((a) => !a.startsWith("--"));
|
|
823
834
|
const name = positional[0];
|
|
824
835
|
if (!name) {
|
|
@@ -826,8 +837,14 @@ async function cmdCreate(args: string[]) {
|
|
|
826
837
|
process.exit(1);
|
|
827
838
|
}
|
|
828
839
|
|
|
829
|
-
|
|
830
|
-
|
|
840
|
+
// Lowercase-only (security review — multi-user hardening). An uppercase
|
|
841
|
+
// vault name flips the audience case (`vault.<Name>` vs `vault.<name>`)
|
|
842
|
+
// and drifts from hub-side / init-path lowercasing, breaking JWT
|
|
843
|
+
// audience matching. `init` already enforces lowercase via
|
|
844
|
+
// `validateVaultName`; mirror that rule here so uppercase can't enter
|
|
845
|
+
// through `create` either.
|
|
846
|
+
if (!/^[a-z0-9_-]+$/.test(name)) {
|
|
847
|
+
console.error("Vault name must be lowercase alphanumeric with hyphens or underscores (no uppercase).");
|
|
831
848
|
process.exit(1);
|
|
832
849
|
}
|
|
833
850
|
if (name === "list") {
|
|
@@ -846,7 +863,7 @@ async function cmdCreate(args: string[]) {
|
|
|
846
863
|
|
|
847
864
|
ensureConfigDirSync();
|
|
848
865
|
const wasFirst = listVaults().length === 0;
|
|
849
|
-
const credential = await createVault(name);
|
|
866
|
+
const credential = await createVault(name, noMirror ? { enableMirror: false } : {});
|
|
850
867
|
|
|
851
868
|
// If this is the only vault now, make it the default so unscoped routes
|
|
852
869
|
// (/mcp, /api/*, /oauth/*) target it. Avoids the "single vault named
|
|
@@ -3333,7 +3350,59 @@ async function mintBootstrapCredential(name: string): Promise<VaultCredential> {
|
|
|
3333
3350
|
* is reachable — vault#282 Stage 2). The DB is created lazily via
|
|
3334
3351
|
* `getVaultStore` so migrations + schema run; we no longer write any pvt_* row.
|
|
3335
3352
|
*/
|
|
3336
|
-
|
|
3353
|
+
interface CreateVaultOptions {
|
|
3354
|
+
/**
|
|
3355
|
+
* Override the server-wide `default_mirror` knob for this one create.
|
|
3356
|
+
* `--no-mirror` on `parachute-vault create` sets this to `false` so the
|
|
3357
|
+
* vault is created with no mirror config even when the knob is `internal`.
|
|
3358
|
+
* Unset → fall back to the `default_mirror` global config knob (default
|
|
3359
|
+
* `internal`).
|
|
3360
|
+
*/
|
|
3361
|
+
enableMirror?: boolean;
|
|
3362
|
+
/**
|
|
3363
|
+
* Test seam threaded straight into `bootstrapInternalMirror` (default
|
|
3364
|
+
* `Bun.which`). Inject a fn returning `null` to exercise the
|
|
3365
|
+
* git-not-installed best-effort path without uninstalling git from the
|
|
3366
|
+
* test host.
|
|
3367
|
+
*/
|
|
3368
|
+
which?: (cmd: string) => string | null;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
/**
|
|
3372
|
+
* The History / "Live Mirror" preset, written at create time when the
|
|
3373
|
+
* `default_mirror` knob resolves to `internal`. Matches the History preset
|
|
3374
|
+
* the admin SPA's VaultMirror page applies:
|
|
3375
|
+
* `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
|
|
3376
|
+
* auto_push:false}`.
|
|
3377
|
+
* Built on top of `defaultMirrorConfig()` so the non-preset fields
|
|
3378
|
+
* (commit_template, safety_net_seconds) stay canonical.
|
|
3379
|
+
*/
|
|
3380
|
+
function historyPresetMirrorConfig(): MirrorConfig {
|
|
3381
|
+
return {
|
|
3382
|
+
...defaultMirrorConfig(),
|
|
3383
|
+
enabled: true,
|
|
3384
|
+
location: "internal",
|
|
3385
|
+
sync_mode: "events",
|
|
3386
|
+
auto_commit: true,
|
|
3387
|
+
auto_push: false,
|
|
3388
|
+
};
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
/**
|
|
3392
|
+
* Resolve whether a freshly created vault should get the internal mirror.
|
|
3393
|
+
* Precedence: explicit per-create override (`--no-mirror`) → server-wide
|
|
3394
|
+
* `default_mirror` knob (default `internal`).
|
|
3395
|
+
*/
|
|
3396
|
+
function shouldEnableCreateTimeMirror(opts: CreateVaultOptions): boolean {
|
|
3397
|
+
if (opts.enableMirror !== undefined) return opts.enableMirror;
|
|
3398
|
+
// Default to "internal" when the knob is unset — backup-on-by-default.
|
|
3399
|
+
return (readGlobalConfig().default_mirror ?? "internal") === "internal";
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
async function createVault(
|
|
3403
|
+
name: string,
|
|
3404
|
+
opts: CreateVaultOptions = {},
|
|
3405
|
+
): Promise<VaultCredential> {
|
|
3337
3406
|
const config: VaultConfig = {
|
|
3338
3407
|
name,
|
|
3339
3408
|
api_keys: [],
|
|
@@ -3344,6 +3413,47 @@ async function createVault(name: string): Promise<VaultCredential> {
|
|
|
3344
3413
|
// Touch the store so the vault's SQLite DB + schema are created. No token
|
|
3345
3414
|
// row is written — vault is a pure hub resource-server post-0.5.0.
|
|
3346
3415
|
getVaultStore(name);
|
|
3416
|
+
|
|
3417
|
+
// Default new vaults to an internal live mirror (local git backup of the
|
|
3418
|
+
// markdown projection). Backup-on-by-default; GitHub off-site backup is an
|
|
3419
|
+
// opt-in upgrade layered on top later. Opt out via the `default_mirror: off`
|
|
3420
|
+
// global knob (operators on git-less / disk-constrained / cloud boxes) or
|
|
3421
|
+
// the `--no-mirror` flag (this one create only).
|
|
3422
|
+
//
|
|
3423
|
+
// BEST-EFFORT, NON-FATAL: write the mirror config first (so the operator's
|
|
3424
|
+
// intent persists even if git is absent), then attempt the bootstrap. A
|
|
3425
|
+
// git-less box leaves the config written but inactive + logs an actionable
|
|
3426
|
+
// hint — it must NEVER fail the vault create. Create-time ONLY: existing
|
|
3427
|
+
// vaults are never retroactively migrated.
|
|
3428
|
+
if (shouldEnableCreateTimeMirror(opts)) {
|
|
3429
|
+
const mirrorConfig = historyPresetMirrorConfig();
|
|
3430
|
+
writeMirrorConfigForVault(name, mirrorConfig);
|
|
3431
|
+
const mirrorPath = resolveMirrorPath(vaultDir(name), mirrorConfig);
|
|
3432
|
+
if (mirrorPath) {
|
|
3433
|
+
try {
|
|
3434
|
+
const result = await bootstrapInternalMirror(mirrorPath, opts.which);
|
|
3435
|
+
if (!result.ok) {
|
|
3436
|
+
// git-not-installed (or refuse-to-clobber) — config stays written,
|
|
3437
|
+
// mirror just isn't active yet. Surface an actionable line; the
|
|
3438
|
+
// vault create succeeds regardless.
|
|
3439
|
+
console.error(
|
|
3440
|
+
`Note: local git backup configured but not yet active — ${result.error} ` +
|
|
3441
|
+
`Install git to activate; the backup turns on automatically on the next vault restart.`,
|
|
3442
|
+
);
|
|
3443
|
+
}
|
|
3444
|
+
} catch (err) {
|
|
3445
|
+
// Defense-in-depth: bootstrapInternalMirror already converts the
|
|
3446
|
+
// git-missing case into a non-throwing { ok:false } result, but a
|
|
3447
|
+
// truly unexpected throw must still not fail the create.
|
|
3448
|
+
console.error(
|
|
3449
|
+
`Note: local git backup configured but bootstrap hit an unexpected error ` +
|
|
3450
|
+
`(${(err as Error).message ?? err}). The vault was still created; ` +
|
|
3451
|
+
`the backup will retry on the next vault restart.`,
|
|
3452
|
+
);
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3347
3457
|
return mintBootstrapCredential(name);
|
|
3348
3458
|
}
|
|
3349
3459
|
|
|
@@ -3448,7 +3558,13 @@ Setup:
|
|
|
3448
3558
|
parachute --version Print the installed version (alias: -v, version)
|
|
3449
3559
|
|
|
3450
3560
|
Vaults:
|
|
3451
|
-
parachute-vault create <name> [--json]
|
|
3561
|
+
parachute-vault create <name> [--json] [--no-mirror]
|
|
3562
|
+
Create a new vault (--json: emit { name, token, paths, set_as_default }).
|
|
3563
|
+
New vaults default to an internal live mirror — a local git backup of
|
|
3564
|
+
the markdown projection (backup on by default; GitHub off-site is an
|
|
3565
|
+
opt-in upgrade). --no-mirror creates a bare vault with no mirror config.
|
|
3566
|
+
Operators can flip the server-wide default with 'default_mirror: off' in
|
|
3567
|
+
config.yaml (recommended for cloud / disk-constrained boxes).
|
|
3452
3568
|
parachute-vault list List all vaults
|
|
3453
3569
|
parachute-vault remove <name> [--yes] Remove a vault
|
|
3454
3570
|
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
|
}
|
package/src/mcp-tools.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
9
9
|
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
10
|
+
import type { Note } from "../core/src/types.ts";
|
|
10
11
|
import {
|
|
11
12
|
buildVaultProjection,
|
|
12
13
|
projectionToMarkdown,
|
|
@@ -18,6 +19,7 @@ import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts
|
|
|
18
19
|
import type { AuthResult } from "./auth.ts";
|
|
19
20
|
import {
|
|
20
21
|
expandTokenTagScope,
|
|
22
|
+
filterHydratedLinksByTagScope,
|
|
21
23
|
noteWithinTagScope,
|
|
22
24
|
tagsWithinScope,
|
|
23
25
|
} from "./tag-scope.ts";
|
|
@@ -117,11 +119,30 @@ export function generateScopedMcpTools(
|
|
|
117
119
|
callerBearer?: string | null,
|
|
118
120
|
): McpToolDef[] {
|
|
119
121
|
const store = getVaultStore(vaultName);
|
|
120
|
-
|
|
122
|
+
|
|
123
|
+
// Tag-scope confidentiality (security review): when the session is
|
|
124
|
+
// tag-scoped, build an expand-visibility predicate so `query-notes`'s
|
|
125
|
+
// `expand_links` inlining can't embed out-of-scope note content. The
|
|
126
|
+
// predicate reads from a SHARED holder that `applyTagScopeWrappers`
|
|
127
|
+
// populates with the resolved allowlist before core's execute runs the
|
|
128
|
+
// (synchronous) expansion — so by the time core calls `isVisible(note)`
|
|
129
|
+
// the allowlist is ready. Core stays scope-unaware: it only receives the
|
|
130
|
+
// plain closure. Unscoped sessions pass no predicate (unchanged path).
|
|
131
|
+
const scoped = Boolean(auth?.scoped_tags && auth.scoped_tags.length > 0);
|
|
132
|
+
const allowedHolder: { value: Set<string> | null } = { value: null };
|
|
133
|
+
const rawTags = scoped ? auth!.scoped_tags : null;
|
|
134
|
+
const expandVisibility = scoped
|
|
135
|
+
? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
|
|
136
|
+
: undefined;
|
|
137
|
+
|
|
138
|
+
const tools = generateMcpTools(
|
|
139
|
+
store,
|
|
140
|
+
expandVisibility ? { expandVisibility } : undefined,
|
|
141
|
+
);
|
|
121
142
|
|
|
122
143
|
overrideVaultInfo(tools, vaultName, auth);
|
|
123
144
|
applyTagDependencyGuards(tools, vaultName);
|
|
124
|
-
applyTagScopeWrappers(tools, vaultName, auth);
|
|
145
|
+
applyTagScopeWrappers(tools, vaultName, auth, allowedHolder);
|
|
125
146
|
|
|
126
147
|
// manage-token is server-only (needs token-store + auth context), so it
|
|
127
148
|
// lives here rather than in core. Always appended to the surface; the
|
|
@@ -181,6 +202,7 @@ function applyTagScopeWrappers(
|
|
|
181
202
|
tools: McpToolDef[],
|
|
182
203
|
vaultName: string,
|
|
183
204
|
auth: AuthResult | undefined,
|
|
205
|
+
allowedHolder?: { value: Set<string> | null },
|
|
184
206
|
): void {
|
|
185
207
|
if (!auth || !auth.scoped_tags || auth.scoped_tags.length === 0) return;
|
|
186
208
|
const store = getVaultStore(vaultName);
|
|
@@ -188,12 +210,40 @@ function applyTagScopeWrappers(
|
|
|
188
210
|
let allowedPromise: Promise<Set<string> | null> | null = null;
|
|
189
211
|
const getAllowed = (): Promise<Set<string> | null> => {
|
|
190
212
|
if (!allowedPromise) {
|
|
191
|
-
allowedPromise = expandTokenTagScope(store, auth.scoped_tags)
|
|
213
|
+
allowedPromise = expandTokenTagScope(store, auth.scoped_tags).then((a) => {
|
|
214
|
+
// Publish the resolved allowlist into the shared holder so the
|
|
215
|
+
// expand-visibility predicate (wired in generateScopedMcpTools and
|
|
216
|
+
// baked into the query-notes expand context) sees the same set.
|
|
217
|
+
// The query-notes wrapper awaits getAllowed() before calling the
|
|
218
|
+
// core execute that runs expansion, so the holder is populated in
|
|
219
|
+
// time. Security review: closes the expand_links content leak.
|
|
220
|
+
if (allowedHolder) allowedHolder.value = a;
|
|
221
|
+
return a;
|
|
222
|
+
});
|
|
192
223
|
}
|
|
193
224
|
return allowedPromise;
|
|
194
225
|
};
|
|
195
226
|
const rawTags = auth.scoped_tags;
|
|
196
227
|
|
|
228
|
+
// Scrub a returned note's hydrated `links` array (present when the caller
|
|
229
|
+
// set `include_links`) so out-of-scope NEIGHBOR summaries (id/path/tags)
|
|
230
|
+
// don't leak — symmetric with the REST `include_links` fix. Mutates in
|
|
231
|
+
// place and returns the note for chaining. No-op when `links` is absent.
|
|
232
|
+
//
|
|
233
|
+
// Ordering invariant: reading `allowedHolder.value` here is safe ONLY
|
|
234
|
+
// because every wrapper that calls scrubNoteLinks first does
|
|
235
|
+
// `await getAllowed()` (which populates the holder) before `orig(params)`
|
|
236
|
+
// and before this scrub runs. So by the time we read `holder.value` it is
|
|
237
|
+
// the resolved allowlist, never the initial `null`. The `?? null` fallback
|
|
238
|
+
// is the unscoped/holder-absent path; `filterHydratedLinksByTagScope` then
|
|
239
|
+
// keys off `rawTags` (non-null here) for the actual scope check.
|
|
240
|
+
const scrubNoteLinks = (n: any): any => {
|
|
241
|
+
if (n && Array.isArray(n.links)) {
|
|
242
|
+
n.links = filterHydratedLinksByTagScope(n.links, allowedHolder?.value ?? null, rawTags);
|
|
243
|
+
}
|
|
244
|
+
return n;
|
|
245
|
+
};
|
|
246
|
+
|
|
197
247
|
wrapReadTool(tools, "query-notes", async (orig, params) => {
|
|
198
248
|
const allowed = await getAllowed();
|
|
199
249
|
const result = await orig(params);
|
|
@@ -203,7 +253,9 @@ function applyTagScopeWrappers(
|
|
|
203
253
|
// - `{notes, next_cursor}` (cursor mode, vault#313)
|
|
204
254
|
// - `{...note}` with `id`+`tags` (single-note by id)
|
|
205
255
|
if (Array.isArray(result)) {
|
|
206
|
-
return result
|
|
256
|
+
return result
|
|
257
|
+
.filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
|
|
258
|
+
.map(scrubNoteLinks);
|
|
207
259
|
}
|
|
208
260
|
if (
|
|
209
261
|
result &&
|
|
@@ -214,13 +266,15 @@ function applyTagScopeWrappers(
|
|
|
214
266
|
) {
|
|
215
267
|
const r = result as { notes: any[]; next_cursor: string | null };
|
|
216
268
|
return {
|
|
217
|
-
notes: r.notes
|
|
269
|
+
notes: r.notes
|
|
270
|
+
.filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
|
|
271
|
+
.map(scrubNoteLinks),
|
|
218
272
|
next_cursor: r.next_cursor,
|
|
219
273
|
};
|
|
220
274
|
}
|
|
221
275
|
if (result && typeof result === "object" && "id" in result && "tags" in result) {
|
|
222
276
|
return noteWithinTagScope(result as any, allowed, rawTags)
|
|
223
|
-
? result
|
|
277
|
+
? scrubNoteLinks(result)
|
|
224
278
|
: { error: "Note not found", id: (result as any).id };
|
|
225
279
|
}
|
|
226
280
|
return result;
|