@openparachute/vault 0.6.0-rc.1 → 0.6.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/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/src/config.test.ts
CHANGED
|
@@ -169,6 +169,33 @@ describe("config", () => {
|
|
|
169
169
|
expect(loaded!.transcription).toBeUndefined();
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
test("round-trips per-vault auto_transcribe.enabled (true + false)", () => {
|
|
173
|
+
// The PATCH /api/vault handler persists the per-vault toggle via
|
|
174
|
+
// writeVaultConfig; confirm it survives a read-back — this is the exact
|
|
175
|
+
// field shouldAutoTranscribe reads per-vault (per-vault → global → true).
|
|
176
|
+
const base: VaultConfig = {
|
|
177
|
+
name: "testvault",
|
|
178
|
+
api_keys: [],
|
|
179
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
writeVaultConfig({ ...base, auto_transcribe: { enabled: true } });
|
|
183
|
+
expect(readVaultConfig("testvault")!.auto_transcribe?.enabled).toBe(true);
|
|
184
|
+
|
|
185
|
+
writeVaultConfig({ ...base, auto_transcribe: { enabled: false } });
|
|
186
|
+
expect(readVaultConfig("testvault")!.auto_transcribe?.enabled).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("vault config without auto_transcribe loads as undefined (falls back to global)", () => {
|
|
190
|
+
const config: VaultConfig = {
|
|
191
|
+
name: "testvault",
|
|
192
|
+
api_keys: [],
|
|
193
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
194
|
+
};
|
|
195
|
+
writeVaultConfig(config);
|
|
196
|
+
expect(readVaultConfig("testvault")!.auto_transcribe).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
172
199
|
test("round-trips discovery: enabled|disabled", () => {
|
|
173
200
|
// Default: absent means enabled (endpoint serves names).
|
|
174
201
|
writeGlobalConfig({ port: 1940 });
|
|
@@ -213,6 +240,72 @@ describe("config", () => {
|
|
|
213
240
|
expect(reloaded.api_keys?.find((k) => k.id === "k_legacy")?.scope).toBe("write");
|
|
214
241
|
});
|
|
215
242
|
|
|
243
|
+
// ----- vault#234: anchored api_keys field regexes ----------------------
|
|
244
|
+
// The api_keys field regexes (label/scope/key_hash/created_at/last_used_at)
|
|
245
|
+
// used to be unanchored, so a COMMENTED `# scope: read` line matched, and a
|
|
246
|
+
// value-less `scope: ` (trailing space) captured the NEXT field's token
|
|
247
|
+
// (`key_hash`). The writer never emits either shape — only hand-editing
|
|
248
|
+
// reaches these branches — but a malformed scope could silently mis-scope a
|
|
249
|
+
// key. The regexes are now line-anchored + horizontal-whitespace-bounded.
|
|
250
|
+
|
|
251
|
+
test("vault#234: commented `# scope:` line is ignored, scope falls back to default", () => {
|
|
252
|
+
const fs = require("fs");
|
|
253
|
+
const path = join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234", "vault.yaml");
|
|
254
|
+
fs.mkdirSync(join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234"), { recursive: true });
|
|
255
|
+
// The only `scope:` line is commented out; the parser must NOT pick it up.
|
|
256
|
+
fs.writeFileSync(
|
|
257
|
+
path,
|
|
258
|
+
`name: mv234\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n - id: k_cmt\n label: commented\n # scope: read\n key_hash: sha256:cmt\n created_at: "2026-01-01T00:00:00.000Z"\n`,
|
|
259
|
+
);
|
|
260
|
+
const loaded = readVaultConfig("mv234");
|
|
261
|
+
const key = loaded!.api_keys.find((k) => k.id === "k_cmt");
|
|
262
|
+
expect(key).toBeDefined();
|
|
263
|
+
// Commented scope ignored → default "write", NOT "read".
|
|
264
|
+
expect(key!.scope).toBe("write");
|
|
265
|
+
expect(key!.key_hash).toBe("sha256:cmt");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("vault#234: value-less `scope: ` (trailing space) does NOT capture the next field", () => {
|
|
269
|
+
const fs = require("fs");
|
|
270
|
+
const path = join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234b", "vault.yaml");
|
|
271
|
+
fs.mkdirSync(join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234b"), { recursive: true });
|
|
272
|
+
// `scope: ` has a trailing space and no value; the OLD regex skipped the
|
|
273
|
+
// newline and captured `sha256:trailing` (the key_hash) as the scope.
|
|
274
|
+
fs.writeFileSync(
|
|
275
|
+
path,
|
|
276
|
+
`name: mv234b\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n - id: k_trail\n label: trailing\n scope: \n key_hash: sha256:trailing\n created_at: "2026-01-01T00:00:00.000Z"\n`,
|
|
277
|
+
);
|
|
278
|
+
const loaded = readVaultConfig("mv234b");
|
|
279
|
+
const key = loaded!.api_keys.find((k) => k.id === "k_trail");
|
|
280
|
+
expect(key).toBeDefined();
|
|
281
|
+
// The hash must NOT have been borrowed as the scope.
|
|
282
|
+
expect(key!.scope).not.toBe("sha256:trailing");
|
|
283
|
+
expect(key!.scope).toBe("write"); // default
|
|
284
|
+
// And the real key_hash is still parsed correctly.
|
|
285
|
+
expect(key!.key_hash).toBe("sha256:trailing");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("vault#234: a valid `scope: read` still parses (positive control, both parsers)", () => {
|
|
289
|
+
const fs = require("fs");
|
|
290
|
+
// Vault-level parser.
|
|
291
|
+
const vpath = join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234c", "vault.yaml");
|
|
292
|
+
fs.mkdirSync(join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234c"), { recursive: true });
|
|
293
|
+
fs.writeFileSync(
|
|
294
|
+
vpath,
|
|
295
|
+
`name: mv234c\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n - id: k_v\n label: reader\n scope: read\n key_hash: sha256:v\n created_at: "2026-01-01T00:00:00.000Z"\n`,
|
|
296
|
+
);
|
|
297
|
+
expect(readVaultConfig("mv234c")!.api_keys.find((k) => k.id === "k_v")?.scope).toBe("read");
|
|
298
|
+
|
|
299
|
+
// Global parser, same grammar.
|
|
300
|
+
const gpath = join(process.env.PARACHUTE_HOME!, "vault", "config.yaml");
|
|
301
|
+
fs.writeFileSync(
|
|
302
|
+
gpath,
|
|
303
|
+
`port: 1940\napi_keys:\n - id: k_g\n label: reader\n # scope: write\n scope: read\n key_hash: sha256:g\n created_at: "2026-01-01T00:00:00.000Z"\n`,
|
|
304
|
+
);
|
|
305
|
+
// The commented `# scope: write` is skipped; the real `scope: read` wins.
|
|
306
|
+
expect(readGlobalConfig().api_keys?.find((k) => k.id === "k_g")?.scope).toBe("read");
|
|
307
|
+
});
|
|
308
|
+
|
|
216
309
|
test("writeEnvFile writes .env at 0600 (SCRIBE_AUTH_TOKEN secrecy)", () => {
|
|
217
310
|
// Regression for vault#354 reviewer finding: the .env holds
|
|
218
311
|
// SCRIBE_AUTH_TOKEN (the vault↔scribe loopback bearer). On a
|
|
@@ -250,6 +343,22 @@ describe("config", () => {
|
|
|
250
343
|
writeGlobalConfig({ port: 1940, autostart: false });
|
|
251
344
|
expect(readGlobalConfig().autostart).toBe(false);
|
|
252
345
|
});
|
|
346
|
+
|
|
347
|
+
test("round-trips default_mirror: internal|off", () => {
|
|
348
|
+
// Absent: createVault falls back to the in-code default ("internal" —
|
|
349
|
+
// backup-on-by-default). The knob is only persisted when explicitly set.
|
|
350
|
+
writeGlobalConfig({ port: 1940 });
|
|
351
|
+
expect(readGlobalConfig().default_mirror).toBeUndefined();
|
|
352
|
+
|
|
353
|
+
// Explicit internal — new vaults get the History-preset local git mirror.
|
|
354
|
+
writeGlobalConfig({ port: 1940, default_mirror: "internal" });
|
|
355
|
+
expect(readGlobalConfig().default_mirror).toBe("internal");
|
|
356
|
+
|
|
357
|
+
// Explicit off — the opt-out operators set on git-less / disk-constrained
|
|
358
|
+
// / cloud boxes so new vaults are created with no mirror config.
|
|
359
|
+
writeGlobalConfig({ port: 1940, default_mirror: "off" });
|
|
360
|
+
expect(readGlobalConfig().default_mirror).toBe("off");
|
|
361
|
+
});
|
|
253
362
|
});
|
|
254
363
|
|
|
255
364
|
// ---------------------------------------------------------------------------
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -172,6 +183,23 @@ export interface VaultConfig {
|
|
|
172
183
|
transcription?: {
|
|
173
184
|
context?: TriggerIncludeContext[];
|
|
174
185
|
};
|
|
186
|
+
/**
|
|
187
|
+
* Per-vault auto-transcribe override (vault#353 follow-up). When set, this
|
|
188
|
+
* vault's value takes precedence over the server-wide
|
|
189
|
+
* `GlobalConfig.auto_transcribe.enabled`. Resolution at the decision point
|
|
190
|
+
* (`shouldAutoTranscribe`) is **per-vault → global → true**: a vault that
|
|
191
|
+
* sets `enabled` here uses it; a vault that leaves it unset falls back to
|
|
192
|
+
* the global toggle, which itself defaults ON.
|
|
193
|
+
*
|
|
194
|
+
* This is what makes scribe's "link to vault X" genuinely per-vault —
|
|
195
|
+
* `PATCH /vault/X/api/vault {auto_transcribe:{enabled:true}}` flips only
|
|
196
|
+
* vault X, never the whole server. URL + bearer are still resolved per-
|
|
197
|
+
* process (services.json / SCRIBE_AUTH_TOKEN); only the on/off toggle is
|
|
198
|
+
* per-vault.
|
|
199
|
+
*/
|
|
200
|
+
auto_transcribe?: {
|
|
201
|
+
enabled?: boolean;
|
|
202
|
+
};
|
|
175
203
|
}
|
|
176
204
|
|
|
177
205
|
// ---------------------------------------------------------------------------
|
|
@@ -229,6 +257,20 @@ export interface TriggerAction {
|
|
|
229
257
|
* top-level `context` field (send=json). send=content ignores this.
|
|
230
258
|
*/
|
|
231
259
|
include_context?: TriggerIncludeContext[];
|
|
260
|
+
/**
|
|
261
|
+
* Optional webhook auth. When `auth.bearer` is set, the trigger sends
|
|
262
|
+
* `Authorization: Bearer <bearer>` on the webhook POST — the JWT path that
|
|
263
|
+
* retires the shared `?secret=` query param. Back-compat: a webhook URL
|
|
264
|
+
* carrying its own `?secret=` still works; `auth` is purely additive.
|
|
265
|
+
* Runtime triggers (registered via the /api/triggers REST surface) are the
|
|
266
|
+
* primary users; config.yaml triggers may also carry it.
|
|
267
|
+
*/
|
|
268
|
+
auth?: TriggerAuth;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface TriggerAuth {
|
|
272
|
+
/** Bearer token (typically a hub-issued JWT) for the webhook Authorization header. */
|
|
273
|
+
bearer?: string;
|
|
232
274
|
}
|
|
233
275
|
|
|
234
276
|
export interface TriggerConfig {
|
|
@@ -270,6 +312,19 @@ export interface GlobalConfig {
|
|
|
270
312
|
* point their own supervisor at it.
|
|
271
313
|
*/
|
|
272
314
|
autostart?: boolean;
|
|
315
|
+
/**
|
|
316
|
+
* Boot auto-create marker (2026-06-09 hub-module-boundary migration, the
|
|
317
|
+
* vault wave's `cmdRemove` improvement). Server boot auto-creates a
|
|
318
|
+
* `default` vault when `listVaults()` is empty — the Docker / hub-install
|
|
319
|
+
* first-run path. When the operator EXPLICITLY deletes their last vault
|
|
320
|
+
* via `parachute-vault remove`, that auto-create would silently resurrect
|
|
321
|
+
* a fresh `default` (with fresh credentials) on the next boot. `cmdRemove`
|
|
322
|
+
* writes `auto_create: false` when it removes the last vault; boot skips
|
|
323
|
+
* the auto-create while the marker is present. Fresh installs (no
|
|
324
|
+
* config.yaml at all) never carry the marker, so the Docker first-run
|
|
325
|
+
* behavior is preserved. See `bootAutoCreateAllowed`.
|
|
326
|
+
*/
|
|
327
|
+
auto_create?: boolean;
|
|
273
328
|
/** Backup configuration: schedule, retention, destinations. */
|
|
274
329
|
backup?: BackupConfig;
|
|
275
330
|
/**
|
|
@@ -280,6 +335,29 @@ export interface GlobalConfig {
|
|
|
280
335
|
* resolved path. See `./mirror-config.ts`.
|
|
281
336
|
*/
|
|
282
337
|
mirror?: MirrorConfigType;
|
|
338
|
+
/**
|
|
339
|
+
* Server-wide DEFAULT for newly created vaults' backup posture. Decides
|
|
340
|
+
* whether `createVault` writes the History-preset internal mirror
|
|
341
|
+
* (local git backup of the markdown projection) at create time.
|
|
342
|
+
*
|
|
343
|
+
* - `"internal"` (default) — new vaults get a local git mirror enabled
|
|
344
|
+
* out of the box (backup-on-by-default). The History preset:
|
|
345
|
+
* `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
|
|
346
|
+
* auto_push:false}`. GitHub off-site backup remains an opt-in upgrade.
|
|
347
|
+
* - `"off"` — new vaults are created with no mirror config (the historical
|
|
348
|
+
* pre-default behavior). The escape hatch for git-less / disk-constrained
|
|
349
|
+
* boxes and cloud deploys, where doubling disk per vault is unwanted.
|
|
350
|
+
* Cloud / container deploys SHOULD set this to `off`.
|
|
351
|
+
*
|
|
352
|
+
* Create-time ONLY — this knob does NOT retroactively enable mirrors on
|
|
353
|
+
* already-created vaults (that would ~double disk across every existing
|
|
354
|
+
* vault). Existing-vault opt-in is a separate, deliberate follow-up.
|
|
355
|
+
*
|
|
356
|
+
* The container/cloud first-boot auto-create path in `server.ts` does NOT
|
|
357
|
+
* funnel through `createVault`, so it is unaffected by this knob and stays
|
|
358
|
+
* mirror-off regardless — matching the recommended cloud posture.
|
|
359
|
+
*/
|
|
360
|
+
default_mirror?: "internal" | "off";
|
|
283
361
|
/**
|
|
284
362
|
* Auto-transcribe configuration for the vault↔scribe handoff (vault#353,
|
|
285
363
|
* design 2026-05-21 Part 2). When `enabled: true` AND scribe is discoverable
|
|
@@ -396,6 +474,14 @@ function serializeVaultConfig(config: VaultConfig): string {
|
|
|
396
474
|
lines.push(`audio_retention: ${config.audio_retention}`);
|
|
397
475
|
}
|
|
398
476
|
|
|
477
|
+
// Per-vault auto-transcribe override. Serialized as a nested block so future
|
|
478
|
+
// fields can grow under it (mirrors the GlobalConfig shape). Only emitted
|
|
479
|
+
// when `enabled` is explicitly set — an unset vault falls back to global.
|
|
480
|
+
if (config.auto_transcribe?.enabled !== undefined) {
|
|
481
|
+
lines.push("auto_transcribe:");
|
|
482
|
+
lines.push(` enabled: ${config.auto_transcribe.enabled}`);
|
|
483
|
+
}
|
|
484
|
+
|
|
399
485
|
if (config.transcription?.context?.length) {
|
|
400
486
|
lines.push("transcription:");
|
|
401
487
|
lines.push(" context:");
|
|
@@ -484,14 +570,32 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
|
|
|
484
570
|
}
|
|
485
571
|
|
|
486
572
|
// Parse api_keys
|
|
573
|
+
//
|
|
574
|
+
// Accepted grammar (vault#234): each `id:` block is the writer's output —
|
|
575
|
+
// one field per line, indented two spaces under the `- id:` list item:
|
|
576
|
+
//
|
|
577
|
+
// - id: <id>
|
|
578
|
+
// label: <label> # free text to end of line
|
|
579
|
+
// scope: <scope> # single token (read|write|admin|…)
|
|
580
|
+
// key_hash: <hash> # single token
|
|
581
|
+
// created_at: "<iso>" # quoted or bare, no embedded newline
|
|
582
|
+
// last_used_at: "<iso>"
|
|
583
|
+
//
|
|
584
|
+
// Each field regex is line-anchored (`^...`, `m` flag) so a COMMENTED line
|
|
585
|
+
// (`# scope: read`) never matches — the line must begin with optional
|
|
586
|
+
// leading whitespace then the bare key. The value matcher uses horizontal
|
|
587
|
+
// whitespace only (`[^\S\r\n]*`, never `\s*`) after the colon so a
|
|
588
|
+
// value-less field (`scope: ` with a trailing space) can't skip the newline
|
|
589
|
+
// and capture the NEXT field's value. A missing optional field falls back to
|
|
590
|
+
// its default rather than borrowing a neighbor's token.
|
|
487
591
|
const keyBlocks = yaml.split(/\n\s+-\s+id:\s+/).slice(1);
|
|
488
592
|
for (const block of keyBlocks) {
|
|
489
593
|
const idMatch = block.match(/^(\S+)/);
|
|
490
|
-
const labelMatch = block.match(
|
|
491
|
-
const scopeMatch = block.match(
|
|
492
|
-
const hashMatch = block.match(
|
|
493
|
-
const createdAtMatch = block.match(
|
|
494
|
-
const lastUsedMatch = block.match(
|
|
594
|
+
const labelMatch = block.match(/^[^\S\r\n]*label:[^\S\r\n]*(.+)/m);
|
|
595
|
+
const scopeMatch = block.match(/^[^\S\r\n]*scope:[^\S\r\n]*(\S+)/m);
|
|
596
|
+
const hashMatch = block.match(/^[^\S\r\n]*key_hash:[^\S\r\n]*(\S+)/m);
|
|
597
|
+
const createdAtMatch = block.match(/^[^\S\r\n]*created_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
|
|
598
|
+
const lastUsedMatch = block.match(/^[^\S\r\n]*last_used_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
|
|
495
599
|
|
|
496
600
|
if (idMatch && hashMatch) {
|
|
497
601
|
config.api_keys.push({
|
|
@@ -514,6 +618,22 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
|
|
|
514
618
|
config.transcription = { context: transcriptionContext };
|
|
515
619
|
}
|
|
516
620
|
|
|
621
|
+
// Parse the per-vault auto_transcribe block — currently single boolean
|
|
622
|
+
// `enabled`. Nested 2-space-indent block (mirrors the GlobalConfig parser)
|
|
623
|
+
// so future fields can grow under it without breaking the regex.
|
|
624
|
+
const autoTranscribeStart = yaml.match(/^auto_transcribe:\s*$/m);
|
|
625
|
+
if (autoTranscribeStart) {
|
|
626
|
+
const after = yaml.slice((autoTranscribeStart.index ?? 0) + autoTranscribeStart[0].length);
|
|
627
|
+
for (const line of after.split("\n")) {
|
|
628
|
+
if (line.match(/^\S/) && line.trim().length > 0) break; // next top-level key
|
|
629
|
+
const m = line.match(/^\s+enabled:\s*(true|false)/);
|
|
630
|
+
if (m) {
|
|
631
|
+
config.auto_transcribe = { enabled: m[1]! === "true" };
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
517
637
|
return config;
|
|
518
638
|
}
|
|
519
639
|
|
|
@@ -1151,6 +1271,20 @@ export function migrateVaultInternalLayout(): void {
|
|
|
1151
1271
|
// Global config
|
|
1152
1272
|
// ---------------------------------------------------------------------------
|
|
1153
1273
|
|
|
1274
|
+
/**
|
|
1275
|
+
* Whether server boot may auto-create the first vault when none exist.
|
|
1276
|
+
*
|
|
1277
|
+
* Only the explicit `auto_create: false` marker (written by `cmdRemove`
|
|
1278
|
+
* when it deletes the LAST vault) blocks the auto-create. A fresh install
|
|
1279
|
+
* has no config.yaml — `readGlobalConfig()` returns defaults with
|
|
1280
|
+
* `auto_create` unset — so Docker / hub-install first-run still
|
|
1281
|
+
* auto-creates `default`. Pure + exported so the boot gate is testable
|
|
1282
|
+
* without booting a server.
|
|
1283
|
+
*/
|
|
1284
|
+
export function bootAutoCreateAllowed(config: Pick<GlobalConfig, "auto_create">): boolean {
|
|
1285
|
+
return config.auto_create !== false;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1154
1288
|
export function readGlobalConfig(): GlobalConfig {
|
|
1155
1289
|
try {
|
|
1156
1290
|
const gcPath = globalConfigPath();
|
|
@@ -1162,6 +1296,8 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1162
1296
|
const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
|
|
1163
1297
|
const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
|
|
1164
1298
|
const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
|
|
1299
|
+
const autoCreateMatch = yaml.match(/^auto_create:\s*(true|false)/m);
|
|
1300
|
+
const defaultMirrorMatch = yaml.match(/^default_mirror:\s*(internal|off)/m);
|
|
1165
1301
|
// auto_transcribe block — currently single boolean `enabled` (vault#353).
|
|
1166
1302
|
// Parsed as a nested 2-space-indent block so future fields can grow under
|
|
1167
1303
|
// it without breaking the regex; only `enabled` is read for v0.6.
|
|
@@ -1190,6 +1326,12 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1190
1326
|
if (autostartMatch) {
|
|
1191
1327
|
config.autostart = autostartMatch[1]! === "true";
|
|
1192
1328
|
}
|
|
1329
|
+
if (autoCreateMatch) {
|
|
1330
|
+
config.auto_create = autoCreateMatch[1]! === "true";
|
|
1331
|
+
}
|
|
1332
|
+
if (defaultMirrorMatch) {
|
|
1333
|
+
config.default_mirror = defaultMirrorMatch[1]! as "internal" | "off";
|
|
1334
|
+
}
|
|
1193
1335
|
if (autoTranscribeEnabled !== undefined) {
|
|
1194
1336
|
config.auto_transcribe = { enabled: autoTranscribeEnabled };
|
|
1195
1337
|
}
|
|
@@ -1212,16 +1354,19 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1212
1354
|
}
|
|
1213
1355
|
|
|
1214
1356
|
// Parse global api_keys
|
|
1357
|
+
// Same line-anchored grammar as the vault-level parser above (vault#234)
|
|
1358
|
+
// — commented lines don't match; a value-less field can't capture the
|
|
1359
|
+
// next field's token across the newline.
|
|
1215
1360
|
const keyBlocks = yaml.split(/\n\s+-\s+id:\s+/).slice(1);
|
|
1216
1361
|
if (keyBlocks.length > 0) {
|
|
1217
1362
|
config.api_keys = [];
|
|
1218
1363
|
for (const block of keyBlocks) {
|
|
1219
1364
|
const idMatch = block.match(/^(\S+)/);
|
|
1220
|
-
const labelMatch = block.match(
|
|
1221
|
-
const scopeMatch = block.match(
|
|
1222
|
-
const hashMatch = block.match(
|
|
1223
|
-
const createdAtMatch = block.match(
|
|
1224
|
-
const lastUsedMatch = block.match(
|
|
1365
|
+
const labelMatch = block.match(/^[^\S\r\n]*label:[^\S\r\n]*(.+)/m);
|
|
1366
|
+
const scopeMatch = block.match(/^[^\S\r\n]*scope:[^\S\r\n]*(\S+)/m);
|
|
1367
|
+
const hashMatch = block.match(/^[^\S\r\n]*key_hash:[^\S\r\n]*(\S+)/m);
|
|
1368
|
+
const createdAtMatch = block.match(/^[^\S\r\n]*created_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
|
|
1369
|
+
const lastUsedMatch = block.match(/^[^\S\r\n]*last_used_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
|
|
1225
1370
|
if (idMatch && hashMatch) {
|
|
1226
1371
|
config.api_keys.push({
|
|
1227
1372
|
id: idMatch[1]!,
|
|
@@ -1259,6 +1404,8 @@ export function writeGlobalConfig(config: GlobalConfig): void {
|
|
|
1259
1404
|
if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
|
|
1260
1405
|
if (config.discovery) lines.push(`discovery: ${config.discovery}`);
|
|
1261
1406
|
if (config.autostart !== undefined) lines.push(`autostart: ${config.autostart}`);
|
|
1407
|
+
if (config.auto_create !== undefined) lines.push(`auto_create: ${config.auto_create}`);
|
|
1408
|
+
if (config.default_mirror) lines.push(`default_mirror: ${config.default_mirror}`);
|
|
1262
1409
|
if (config.owner_password_hash) {
|
|
1263
1410
|
lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
|
|
1264
1411
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST face of content range / pagination (bounded reads for large notes).
|
|
3
|
+
*
|
|
4
|
+
* Exercises all four GET shapes that accept `content_offset` /
|
|
5
|
+
* `content_length`:
|
|
6
|
+
* - GET /notes?id=… (single, folded into the collection route)
|
|
7
|
+
* - GET /notes/:idOrPath (single point read)
|
|
8
|
+
* - GET /notes?… (structured list)
|
|
9
|
+
* - GET /notes?search=… (full-text list)
|
|
10
|
+
*
|
|
11
|
+
* Slice mechanics (codepoint boundaries, reassembly invariant) are pinned
|
|
12
|
+
* in core/src/content-range.test.ts; this suite covers the HTTP wiring:
|
|
13
|
+
* param parsing, 400s, per-shape application, and the no-params
|
|
14
|
+
* regression. Fully sandboxed — in-memory SQLite, no daemon.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
18
|
+
import { Database } from "bun:sqlite";
|
|
19
|
+
import { BunStore } from "./vault-store.ts";
|
|
20
|
+
import { handleNotes } from "./routes.ts";
|
|
21
|
+
|
|
22
|
+
let db: Database;
|
|
23
|
+
let store: BunStore;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
db = new Database(":memory:");
|
|
27
|
+
store = new BunStore(db);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
db.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const BASE = "http://localhost/api";
|
|
35
|
+
|
|
36
|
+
function get(path: string): Promise<Response> {
|
|
37
|
+
return handleNotes(new Request(`${BASE}/notes${path}`, { method: "GET" }), store, pathSub(path));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Extract the handleNotes subpath ("/<id>" for point reads, "" otherwise). */
|
|
41
|
+
function pathSub(path: string): string {
|
|
42
|
+
const m = path.match(/^\/([^?/]+)/);
|
|
43
|
+
return m ? `/${m[1]}` : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("REST content range — single note", () => {
|
|
47
|
+
it("GET /notes?id=… honors content_length and adds the range fields", async () => {
|
|
48
|
+
const note = await store.createNote("0123456789");
|
|
49
|
+
const res = await get(`?id=${note.id}&content_length=4`);
|
|
50
|
+
expect(res.status).toBe(200);
|
|
51
|
+
const body: any = await res.json();
|
|
52
|
+
expect(body.content).toBe("0123");
|
|
53
|
+
expect(body.content_offset).toBe(0);
|
|
54
|
+
expect(body.content_total_length).toBe(10);
|
|
55
|
+
expect(body.content_next_offset).toBe(4);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("GET /notes/:id honors content_offset (tail read to the end)", async () => {
|
|
59
|
+
const note = await store.createNote("hello world", { path: "tail-note" });
|
|
60
|
+
const res = await get(`/${note.id}?content_offset=6`);
|
|
61
|
+
expect(res.status).toBe(200);
|
|
62
|
+
const body: any = await res.json();
|
|
63
|
+
expect(body.content).toBe("world");
|
|
64
|
+
expect(body.content_total_length).toBe(11);
|
|
65
|
+
expect(body.content_next_offset).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("paged loop over REST reassembles multi-byte content byte-identically", async () => {
|
|
69
|
+
const content = "ab\u{1F600}cd 你好 ".repeat(20).trim();
|
|
70
|
+
const note = await store.createNote(content);
|
|
71
|
+
let offset = 0;
|
|
72
|
+
let assembled = "";
|
|
73
|
+
for (;;) {
|
|
74
|
+
const res = await get(`/${note.id}?content_offset=${offset}&content_length=16`);
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
const body: any = await res.json();
|
|
77
|
+
expect(Buffer.byteLength(body.content, "utf8")).toBeLessThanOrEqual(16);
|
|
78
|
+
assembled += body.content;
|
|
79
|
+
if (body.content_next_offset === null) break;
|
|
80
|
+
offset = body.content_next_offset;
|
|
81
|
+
}
|
|
82
|
+
expect(assembled).toBe(content);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("offset past end → 200 with empty slice and null next_offset", async () => {
|
|
86
|
+
const note = await store.createNote("abc");
|
|
87
|
+
const res = await get(`?id=${note.id}&content_offset=500`);
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
const body: any = await res.json();
|
|
90
|
+
expect(body.content).toBe("");
|
|
91
|
+
expect(body.content_next_offset).toBeNull();
|
|
92
|
+
expect(body.content_total_length).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("include_content=false + range params → 400 INVALID_QUERY", async () => {
|
|
96
|
+
const note = await store.createNote("abc");
|
|
97
|
+
const res = await get(`?id=${note.id}&include_content=false&content_length=8`);
|
|
98
|
+
expect(res.status).toBe(400);
|
|
99
|
+
const body: any = await res.json();
|
|
100
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
101
|
+
expect(body.error).toContain("include_content");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("zero / sub-minimum / malformed budgets → 400 INVALID_QUERY", async () => {
|
|
105
|
+
const note = await store.createNote("abc");
|
|
106
|
+
for (const qs of ["content_length=0", "content_length=2", "content_length=abc", "content_offset=-1"]) {
|
|
107
|
+
const res = await get(`?id=${note.id}&${qs}`);
|
|
108
|
+
expect(res.status).toBe(400);
|
|
109
|
+
const body: any = await res.json();
|
|
110
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("no range params → response shape unchanged (regression)", async () => {
|
|
115
|
+
const note = await store.createNote("plain body", { path: "plain" });
|
|
116
|
+
const res = await get(`/${note.id}`);
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
const body: any = await res.json();
|
|
119
|
+
expect(body.content).toBe("plain body");
|
|
120
|
+
expect("content_total_length" in body).toBe(false);
|
|
121
|
+
expect("content_next_offset" in body).toBe(false);
|
|
122
|
+
expect("content_offset" in body).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("REST content range — list shapes", () => {
|
|
127
|
+
it("structured list with include_content=true applies the window per note", async () => {
|
|
128
|
+
await store.createNote("first body first body", { tags: ["paged"] });
|
|
129
|
+
await store.createNote("second body second body", { tags: ["paged"] });
|
|
130
|
+
const res = await get(`?tag=paged&include_content=true&content_length=6`);
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
const body: any[] = await res.json();
|
|
133
|
+
expect(body.length).toBe(2);
|
|
134
|
+
for (const n of body) {
|
|
135
|
+
expect(Buffer.byteLength(n.content, "utf8")).toBeLessThanOrEqual(6);
|
|
136
|
+
expect(typeof n.content_total_length).toBe("number");
|
|
137
|
+
expect(n.content_next_offset).toBe(6);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("structured list on the lean default + range params → 400", async () => {
|
|
142
|
+
await store.createNote("body", { tags: ["paged"] });
|
|
143
|
+
const res = await get(`?tag=paged&content_length=8`);
|
|
144
|
+
expect(res.status).toBe(400);
|
|
145
|
+
const body: any = await res.json();
|
|
146
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
147
|
+
expect(body.error).toContain("include_content");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("search list with include_content=true applies the window per note", async () => {
|
|
151
|
+
await store.createNote("the quick brown fox jumps over the lazy dog");
|
|
152
|
+
const res = await get(`?search=fox&include_content=true&content_length=9`);
|
|
153
|
+
expect(res.status).toBe(200);
|
|
154
|
+
const body: any[] = await res.json();
|
|
155
|
+
expect(body.length).toBe(1);
|
|
156
|
+
expect(Buffer.byteLength(body[0].content, "utf8")).toBeLessThanOrEqual(9);
|
|
157
|
+
expect(body[0].content_total_length).toBe(
|
|
158
|
+
Buffer.byteLength("the quick brown fox jumps over the lazy dog", "utf8"),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("search list on the lean default + range params → 400", async () => {
|
|
163
|
+
await store.createNote("the quick brown fox");
|
|
164
|
+
const res = await get(`?search=fox&content_length=8`);
|
|
165
|
+
expect(res.status).toBe(400);
|
|
166
|
+
const body: any = await res.json();
|
|
167
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("list without range params → no range fields injected (regression)", async () => {
|
|
171
|
+
await store.createNote("body here", { tags: ["paged"] });
|
|
172
|
+
const res = await get(`?tag=paged&include_content=true`);
|
|
173
|
+
expect(res.status).toBe(200);
|
|
174
|
+
const body: any[] = await res.json();
|
|
175
|
+
expect("content_total_length" in body[0]).toBe(false);
|
|
176
|
+
expect("content_next_offset" in body[0]).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
package/src/export-watch.test.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
runGitCommitCycle,
|
|
36
36
|
shouldCommit,
|
|
37
37
|
} from "./export-watch.ts";
|
|
38
|
+
import { GitNotInstalledError } from "./git-preflight.ts";
|
|
38
39
|
|
|
39
40
|
const CLI = path.resolve(import.meta.dir, "cli.ts");
|
|
40
41
|
|
|
@@ -504,6 +505,28 @@ describe("runGitCommitCycle", () => {
|
|
|
504
505
|
});
|
|
505
506
|
expect(result.message).toBe("note: Inbox/DonorMeeting");
|
|
506
507
|
});
|
|
508
|
+
|
|
509
|
+
test("git missing → throws GitNotInstalledError (sync surfaces friendly error, not raw spawn crash)", async () => {
|
|
510
|
+
// vault#415 — the sync/commit path must surface the actionable
|
|
511
|
+
// git-not-installed message (which the manager threads into
|
|
512
|
+
// status.last_error) instead of crashing with a raw "Executable not
|
|
513
|
+
// found in $PATH". Force the preflight to see no git via the `which`
|
|
514
|
+
// seam; no real spawn should be reached.
|
|
515
|
+
fs.writeFileSync(path.join(dir, "Note.md"), "# n\n");
|
|
516
|
+
await expect(
|
|
517
|
+
runGitCommitCycle({
|
|
518
|
+
repoDir: dir,
|
|
519
|
+
template: DEFAULT_COMMIT_TEMPLATE,
|
|
520
|
+
notesChanged: 1,
|
|
521
|
+
vaultName: "default",
|
|
522
|
+
firstNoteTitle: "Note",
|
|
523
|
+
push: false,
|
|
524
|
+
which: () => null,
|
|
525
|
+
}),
|
|
526
|
+
).rejects.toBeInstanceOf(GitNotInstalledError);
|
|
527
|
+
// The commit cycle bailed at the preflight — no commit landed.
|
|
528
|
+
expect(gitLogOneline(dir)).toHaveLength(1); // only the seed
|
|
529
|
+
});
|
|
507
530
|
});
|
|
508
531
|
|
|
509
532
|
// ---------------------------------------------------------------------------
|
package/src/export-watch.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* detection. See `parachute-patterns/cookbook/vault-portable-export.md`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { ensureGitAvailable } from "./git-preflight.ts";
|
|
17
|
+
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
17
19
|
// Commit message templating
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
@@ -269,11 +271,23 @@ export async function runGitCommitCycle(opts: {
|
|
|
269
271
|
push: boolean;
|
|
270
272
|
/** Override for tests — defaults to `new Date().toISOString()`. */
|
|
271
273
|
now?: () => string;
|
|
274
|
+
/**
|
|
275
|
+
* Override the git-presence probe (test seam — defaults to `Bun.which`).
|
|
276
|
+
* Inject a fn returning `null` to exercise the git-not-installed path.
|
|
277
|
+
*/
|
|
278
|
+
which?: (cmd: string) => string | null;
|
|
272
279
|
}): Promise<{
|
|
273
280
|
committed: boolean;
|
|
274
281
|
message?: string;
|
|
275
282
|
push?: { attempted: true; ok: boolean; error?: string };
|
|
276
283
|
}> {
|
|
284
|
+
// Preflight: every step below shells `git`. On a git-less server the first
|
|
285
|
+
// `Bun.spawn(["git", ...])` would throw a raw "Executable not found" error;
|
|
286
|
+
// surface the friendly, actionable GitNotInstalledError so callers can
|
|
287
|
+
// thread it into mirror status (`last_error`) instead of crashing the
|
|
288
|
+
// watch loop with an opaque message.
|
|
289
|
+
ensureGitAvailable(opts.which);
|
|
290
|
+
|
|
277
291
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
278
292
|
|
|
279
293
|
const add = await gitAddAll(opts.repoDir);
|