@openparachute/vault 0.4.4-rc.14 → 0.4.6-rc.3
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/README.md +29 -0
- package/core/src/core.test.ts +221 -0
- package/core/src/mcp.ts +56 -6
- package/core/src/notes.ts +185 -15
- package/core/src/portable-md.test.ts +531 -0
- package/core/src/portable-md.ts +508 -19
- package/core/src/schema.ts +61 -3
- package/core/src/store.ts +5 -4
- package/core/src/types.ts +27 -3
- package/core/src/wikilinks.ts +74 -14
- package/package.json +1 -1
- package/src/auth.test.ts +235 -0
- package/src/auth.ts +78 -0
- package/src/published.test.ts +17 -0
- package/src/routes.ts +121 -3
- package/src/routing.test.ts +85 -1
- package/src/server.ts +23 -4
- package/src/vault-name.test.ts +100 -4
- package/src/vault-name.ts +61 -3
- package/src/vault.test.ts +175 -0
package/src/published.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
2
3
|
import { handleViewNote } from "./routes.ts";
|
|
4
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
3
5
|
|
|
4
6
|
// Redirect URL builder — mirrors the logic in server.ts
|
|
5
7
|
function buildRedirectUrl(reqUrl: string, noteId: string, prefix = ""): string {
|
|
@@ -211,4 +213,19 @@ describe("handleViewNote", async () => {
|
|
|
211
213
|
const resp = await handleViewNote(store, "n1", { publishedTag: "public" });
|
|
212
214
|
expect(resp.status).toBe(404);
|
|
213
215
|
});
|
|
216
|
+
|
|
217
|
+
it("returns 409 ambiguous_path when bare path resolves to multiple notes (vault#331 N1)", async () => {
|
|
218
|
+
// Real SqliteStore so the v18 composite (path, extension) uniqueness
|
|
219
|
+
// index lets two notes coexist at the same path with different
|
|
220
|
+
// extensions — the scenario AmbiguousPathError is built for.
|
|
221
|
+
const store = new SqliteStore(new Database(":memory:"));
|
|
222
|
+
await store.createNote("# md", { id: "vn-md", path: "Foo", tags: ["publish"] });
|
|
223
|
+
await store.createNote("a,b\n1,2", { id: "vn-csv", path: "Foo", extension: "csv", tags: ["publish"] });
|
|
224
|
+
const resp = await handleViewNote(store, "Foo", { authenticated: true });
|
|
225
|
+
expect(resp.status).toBe(409);
|
|
226
|
+
const body = await resp.json() as any;
|
|
227
|
+
expect(body.error_type).toBe("ambiguous_path");
|
|
228
|
+
expect(body.path).toBe("Foo");
|
|
229
|
+
expect(body.candidates).toHaveLength(2);
|
|
230
|
+
});
|
|
214
231
|
});
|
package/src/routes.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Store, Note } from "../core/src/types.ts";
|
|
15
15
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
16
|
-
import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
|
|
16
|
+
import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
17
17
|
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
18
18
|
import * as linkOps from "../core/src/links.ts";
|
|
19
19
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
@@ -75,6 +75,25 @@ function parseQueryList(url: URL, key: string): string[] | undefined {
|
|
|
75
75
|
return val ? val.split(",") : undefined;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Parse the extension query parameter (vault#328). Two accepted shapes:
|
|
80
|
+
* - `?extension=csv` (single value → string)
|
|
81
|
+
* - `?extension=csv&extension=yaml` OR `?extension=csv,yaml`
|
|
82
|
+
* (repeated or comma-list → array)
|
|
83
|
+
* Returns undefined when absent so the queryNotes filter is skipped.
|
|
84
|
+
* Validation lives at the engine layer — bad strings result in zero
|
|
85
|
+
* matches rather than 400, mirroring how `path` works.
|
|
86
|
+
*/
|
|
87
|
+
function parseExtensionFilter(url: URL): string | string[] | undefined {
|
|
88
|
+
const all = url.searchParams.getAll("extension");
|
|
89
|
+
if (all.length === 0) return undefined;
|
|
90
|
+
// Flatten comma-lists inside each param.
|
|
91
|
+
const flat = all.flatMap((v) => v.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
92
|
+
if (flat.length === 0) return undefined;
|
|
93
|
+
if (flat.length === 1) return flat[0]!;
|
|
94
|
+
return flat;
|
|
95
|
+
}
|
|
96
|
+
|
|
78
97
|
function parseInt10(val: string | null): number | undefined {
|
|
79
98
|
if (!val) return undefined;
|
|
80
99
|
const n = parseInt(val, 10);
|
|
@@ -373,11 +392,21 @@ function parseExpandParams(
|
|
|
373
392
|
|
|
374
393
|
|
|
375
394
|
/**
|
|
376
|
-
* Resolve a note by ID or path. Tries ID first, then case-insensitive
|
|
395
|
+
* Resolve a note by ID or path. Tries ID first, then case-insensitive
|
|
396
|
+
* path. A trailing `.<ext>` matching the extension pattern is parsed
|
|
397
|
+
* as `(path, extension)` to disambiguate notes sharing a path
|
|
398
|
+
* differing only by extension (vault#330 S1). When the path is
|
|
399
|
+
* ambiguous and no extension hint is supplied, `getNoteByPath` throws
|
|
400
|
+
* `AmbiguousPathError` — REST handlers catch it and return 409.
|
|
377
401
|
*/
|
|
378
402
|
async function resolveNote(store: Store, idOrPath: string): Promise<Note | null> {
|
|
379
403
|
const byId = await store.getNote(idOrPath);
|
|
380
404
|
if (byId) return byId;
|
|
405
|
+
const extMatch = idOrPath.match(/^(.*)\.([a-z0-9]{1,16})$/i);
|
|
406
|
+
if (extMatch) {
|
|
407
|
+
const explicit = await store.getNoteByPath(extMatch[1]!, extMatch[2]!);
|
|
408
|
+
if (explicit) return explicit;
|
|
409
|
+
}
|
|
381
410
|
return await store.getNoteByPath(idOrPath);
|
|
382
411
|
}
|
|
383
412
|
|
|
@@ -398,12 +427,49 @@ class NotFoundError extends Error {
|
|
|
398
427
|
// Notes — GET/POST/PATCH/DELETE /api/notes[/:idOrPath]
|
|
399
428
|
// ---------------------------------------------------------------------------
|
|
400
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Convert a thrown `AmbiguousPathError` (vault#330 S1) into a structured
|
|
432
|
+
* 409 JSON response. Shared by every handler that calls `resolveNote`
|
|
433
|
+
* with a user-supplied path — handleNotes, handleFindPath,
|
|
434
|
+
* handleViewNote. Returns null when the error isn't an
|
|
435
|
+
* AmbiguousPathError so the caller can re-throw / fall through.
|
|
436
|
+
*/
|
|
437
|
+
function ambiguousPathResponse(e: any): Response | null {
|
|
438
|
+
if (!e || e.code !== "AMBIGUOUS_PATH") return null;
|
|
439
|
+
return json(
|
|
440
|
+
{
|
|
441
|
+
error_type: "ambiguous_path",
|
|
442
|
+
error: "ambiguous_path",
|
|
443
|
+
path: e.path,
|
|
444
|
+
candidates: e.candidates,
|
|
445
|
+
message: e.message,
|
|
446
|
+
},
|
|
447
|
+
409,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
401
451
|
export async function handleNotes(
|
|
402
452
|
req: Request,
|
|
403
453
|
store: Store,
|
|
404
454
|
subpath: string,
|
|
405
455
|
vault?: string,
|
|
406
456
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
457
|
+
): Promise<Response> {
|
|
458
|
+
try {
|
|
459
|
+
return await handleNotesInner(req, store, subpath, vault, tagScope);
|
|
460
|
+
} catch (e: any) {
|
|
461
|
+
const ambig = ambiguousPathResponse(e);
|
|
462
|
+
if (ambig) return ambig;
|
|
463
|
+
throw e;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function handleNotesInner(
|
|
468
|
+
req: Request,
|
|
469
|
+
store: Store,
|
|
470
|
+
subpath: string,
|
|
471
|
+
vault?: string,
|
|
472
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
407
473
|
): Promise<Response> {
|
|
408
474
|
const url = new URL(req.url);
|
|
409
475
|
const method = req.method;
|
|
@@ -508,6 +574,12 @@ export async function handleNotes(
|
|
|
508
574
|
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
509
575
|
path: parseQuery(url, "path") ?? undefined,
|
|
510
576
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
577
|
+
// Extension filter (vault#328). Accepts repeated `extension=`
|
|
578
|
+
// params for the array form: `?extension=csv&extension=yaml`.
|
|
579
|
+
// `parseQueryList` already returns undefined when no params
|
|
580
|
+
// are present, so the filter is silently skipped on a plain
|
|
581
|
+
// GET without the extension query.
|
|
582
|
+
extension: parseExtensionFilter(url),
|
|
511
583
|
metadata: bracket.metadata,
|
|
512
584
|
// Date-range precedence chain (highest to lowest):
|
|
513
585
|
// 1. Bracket-style `meta[created_at][gte]=…` (canonical).
|
|
@@ -660,12 +732,19 @@ export async function handleNotes(
|
|
|
660
732
|
if (batched) db.exec("BEGIN");
|
|
661
733
|
try {
|
|
662
734
|
for (const item of items) {
|
|
735
|
+
// Validate extension before reaching the Store (vault#328).
|
|
736
|
+
// Thrown inside the BEGIN block — outer catch rolls the batch
|
|
737
|
+
// back, same shape as the path-conflict path.
|
|
738
|
+
const extension = item.extension !== undefined
|
|
739
|
+
? validateExtension(item.extension)
|
|
740
|
+
: undefined;
|
|
663
741
|
const note = await store.createNote(item.content ?? "", {
|
|
664
742
|
id: item.id,
|
|
665
743
|
path: item.path,
|
|
666
744
|
tags: item.tags,
|
|
667
745
|
metadata: item.metadata,
|
|
668
746
|
created_at: item.createdAt ?? item.created_at,
|
|
747
|
+
...(extension !== undefined ? { extension } : {}),
|
|
669
748
|
});
|
|
670
749
|
|
|
671
750
|
// Create explicit links
|
|
@@ -688,6 +767,12 @@ export async function handleNotes(
|
|
|
688
767
|
409,
|
|
689
768
|
);
|
|
690
769
|
}
|
|
770
|
+
if (e && e.code === "INVALID_EXTENSION") {
|
|
771
|
+
return json(
|
|
772
|
+
{ error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
|
|
773
|
+
400,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
691
776
|
throw e;
|
|
692
777
|
}
|
|
693
778
|
|
|
@@ -845,12 +930,17 @@ export async function handleNotes(
|
|
|
845
930
|
}
|
|
846
931
|
const idLooksLikePath = idOrPathStr.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPathStr);
|
|
847
932
|
const explicitPath = typeof body.path === "string" ? body.path as string : undefined;
|
|
933
|
+
// Validate extension before reaching the Store (vault#328).
|
|
934
|
+
const createExt = body.extension !== undefined
|
|
935
|
+
? validateExtension(body.extension)
|
|
936
|
+
: undefined;
|
|
848
937
|
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
849
938
|
...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
850
939
|
...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
|
|
851
940
|
...(body.metadata !== undefined ? { metadata: body.metadata as Record<string, unknown> } : {}),
|
|
852
941
|
...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
|
|
853
942
|
...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
|
|
943
|
+
...(createExt !== undefined ? { extension: createExt } : {}),
|
|
854
944
|
};
|
|
855
945
|
const content = (body.content as string | undefined) ?? "";
|
|
856
946
|
const created = await store.createNote(content, createOpts);
|
|
@@ -1019,6 +1109,11 @@ export async function handleNotes(
|
|
|
1019
1109
|
if (body.prepend !== undefined) updates.prepend = body.prepend;
|
|
1020
1110
|
}
|
|
1021
1111
|
if (body.path !== undefined) updates.path = body.path;
|
|
1112
|
+
if (body.extension !== undefined) {
|
|
1113
|
+
// Validate up front (vault#328). Throws ExtensionValidationError
|
|
1114
|
+
// which the outer catch converts to a 400.
|
|
1115
|
+
updates.extension = validateExtension(body.extension);
|
|
1116
|
+
}
|
|
1022
1117
|
if (body.metadata !== undefined) {
|
|
1023
1118
|
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
1024
1119
|
updates.metadata = { ...existing, ...body.metadata };
|
|
@@ -1108,6 +1203,12 @@ export async function handleNotes(
|
|
|
1108
1203
|
409,
|
|
1109
1204
|
);
|
|
1110
1205
|
}
|
|
1206
|
+
if (e && e.code === "INVALID_EXTENSION") {
|
|
1207
|
+
return json(
|
|
1208
|
+
{ error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
|
|
1209
|
+
400,
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1111
1212
|
throw e;
|
|
1112
1213
|
}
|
|
1113
1214
|
}
|
|
@@ -1448,6 +1549,11 @@ export async function handleFindPath(
|
|
|
1448
1549
|
return json(result);
|
|
1449
1550
|
} catch (e: any) {
|
|
1450
1551
|
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
1552
|
+
// vault#331 N1 — surface AmbiguousPathError from resolveNote as 409
|
|
1553
|
+
// mirroring the handleNotes path. Without this, an ambiguous source/
|
|
1554
|
+
// target path on /api/find-path bubbled to a server-level 500.
|
|
1555
|
+
const ambig = ambiguousPathResponse(e);
|
|
1556
|
+
if (ambig) return ambig;
|
|
1451
1557
|
throw e;
|
|
1452
1558
|
}
|
|
1453
1559
|
}
|
|
@@ -1640,7 +1746,19 @@ export async function handleViewNote(
|
|
|
1640
1746
|
options: { authenticated?: boolean; publishedTag?: string } = {},
|
|
1641
1747
|
): Promise<Response> {
|
|
1642
1748
|
const { authenticated = false, publishedTag = "publish" } = options;
|
|
1643
|
-
|
|
1749
|
+
let note: Note | null;
|
|
1750
|
+
try {
|
|
1751
|
+
note = await resolveNote(store, idOrPath);
|
|
1752
|
+
} catch (e: any) {
|
|
1753
|
+
// vault#331 N1 — surface AmbiguousPathError as 409. The HTML view
|
|
1754
|
+
// route doesn't otherwise return JSON, but the structured body is
|
|
1755
|
+
// the right shape for the API contract; a human reader hitting
|
|
1756
|
+
// this URL gets the JSON inline (rare — the bare path form is
|
|
1757
|
+
// mostly an API consumer's mistake).
|
|
1758
|
+
const ambig = ambiguousPathResponse(e);
|
|
1759
|
+
if (ambig) return ambig;
|
|
1760
|
+
throw e;
|
|
1761
|
+
}
|
|
1644
1762
|
if (!note) {
|
|
1645
1763
|
return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain" } });
|
|
1646
1764
|
}
|
package/src/routing.test.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* never touch ~/.parachute.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { describe, test, expect, beforeEach, afterAll } from "bun:test";
|
|
20
|
+
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
|
|
21
21
|
import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
|
|
22
22
|
import { join } from "path";
|
|
23
23
|
import { tmpdir } from "os";
|
|
@@ -1641,3 +1641,87 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1641
1641
|
expect(writeRes.status).toBe(403);
|
|
1642
1642
|
});
|
|
1643
1643
|
});
|
|
1644
|
+
|
|
1645
|
+
// ---------------------------------------------------------------------------
|
|
1646
|
+
// /health — smoke tests for the unauthenticated liveness probe (vault#339).
|
|
1647
|
+
//
|
|
1648
|
+
// /health must ALWAYS return 200 regardless of VAULT_AUTH_TOKEN config so
|
|
1649
|
+
// Render's health probe + Docker HEALTHCHECK can poll the container even
|
|
1650
|
+
// before the operator has configured a bearer. The response shape changes
|
|
1651
|
+
// (vault names are leaked only to authed callers) but the status code is
|
|
1652
|
+
// invariant.
|
|
1653
|
+
// ---------------------------------------------------------------------------
|
|
1654
|
+
|
|
1655
|
+
describe("/health — always 200 (Render/Docker healthcheck contract)", () => {
|
|
1656
|
+
let prevToken: string | undefined;
|
|
1657
|
+
|
|
1658
|
+
beforeEach(() => {
|
|
1659
|
+
prevToken = process.env.VAULT_AUTH_TOKEN;
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
afterEach(() => {
|
|
1663
|
+
if (prevToken === undefined) delete process.env.VAULT_AUTH_TOKEN;
|
|
1664
|
+
else process.env.VAULT_AUTH_TOKEN = prevToken;
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
test("env unset + no bearer → 200 (anonymous probe)", async () => {
|
|
1668
|
+
delete process.env.VAULT_AUTH_TOKEN;
|
|
1669
|
+
createVault("journal");
|
|
1670
|
+
|
|
1671
|
+
const res = await route(new Request("http://localhost:1940/health"), "/health");
|
|
1672
|
+
expect(res.status).toBe(200);
|
|
1673
|
+
const body = await res.json();
|
|
1674
|
+
expect(body.status).toBe("ok");
|
|
1675
|
+
// No bearer → no vault names leaked.
|
|
1676
|
+
expect(body.vaults).toBeUndefined();
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
test("env set + no bearer → 200 (Render's health probe doesn't have the secret)", async () => {
|
|
1680
|
+
process.env.VAULT_AUTH_TOKEN = "operator-token-xyz";
|
|
1681
|
+
createVault("journal");
|
|
1682
|
+
|
|
1683
|
+
const res = await route(new Request("http://localhost:1940/health"), "/health");
|
|
1684
|
+
expect(res.status).toBe(200);
|
|
1685
|
+
const body = await res.json();
|
|
1686
|
+
expect(body.status).toBe("ok");
|
|
1687
|
+
expect(body.vaults).toBeUndefined();
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
test("env set + matching bearer → 200 + vault names leaked", async () => {
|
|
1691
|
+
process.env.VAULT_AUTH_TOKEN = "operator-token-xyz";
|
|
1692
|
+
createVault("journal");
|
|
1693
|
+
createVault("work");
|
|
1694
|
+
|
|
1695
|
+
const res = await route(
|
|
1696
|
+
new Request("http://localhost:1940/health", {
|
|
1697
|
+
headers: { Authorization: "Bearer operator-token-xyz" },
|
|
1698
|
+
}),
|
|
1699
|
+
"/health",
|
|
1700
|
+
);
|
|
1701
|
+
expect(res.status).toBe(200);
|
|
1702
|
+
const body = await res.json();
|
|
1703
|
+
expect(body.status).toBe("ok");
|
|
1704
|
+
expect(Array.isArray(body.vaults)).toBe(true);
|
|
1705
|
+
expect(body.vaults).toContain("journal");
|
|
1706
|
+
expect(body.vaults).toContain("work");
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
test("env set + wrong bearer → 200 (still healthy, just no vault detail)", async () => {
|
|
1710
|
+
// /health doesn't 401 on a wrong bearer — it just falls back to the
|
|
1711
|
+
// anonymous response. The operator's probe stays green even if the
|
|
1712
|
+
// bearer is mid-rotation.
|
|
1713
|
+
process.env.VAULT_AUTH_TOKEN = "operator-token-xyz";
|
|
1714
|
+
createVault("journal");
|
|
1715
|
+
|
|
1716
|
+
const res = await route(
|
|
1717
|
+
new Request("http://localhost:1940/health", {
|
|
1718
|
+
headers: { Authorization: "Bearer wrong-token" },
|
|
1719
|
+
}),
|
|
1720
|
+
"/health",
|
|
1721
|
+
);
|
|
1722
|
+
expect(res.status).toBe(200);
|
|
1723
|
+
const body = await res.json();
|
|
1724
|
+
expect(body.status).toBe("ok");
|
|
1725
|
+
expect(body.vaults).toBeUndefined();
|
|
1726
|
+
});
|
|
1727
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath } from "./config.ts";
|
|
19
19
|
import { existsSync, rmSync } from "fs";
|
|
20
20
|
import { migrateVaultKeys } from "./token-store.ts";
|
|
21
|
+
import { resolveFirstBootVaultName } from "./vault-name.ts";
|
|
21
22
|
import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
|
|
22
23
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
23
24
|
import { registerTriggers } from "./triggers.ts";
|
|
@@ -98,13 +99,31 @@ if (process.env.SCRIBE_URL) {
|
|
|
98
99
|
console.log("[transcribe] worker disabled (set SCRIBE_URL to enable)");
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
if (process.env.VAULT_AUTH_TOKEN?.trim()) {
|
|
103
|
+
console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Auto-init: create a default vault if none exist (first run in Docker).
|
|
107
|
+
// The vault name comes from PARACHUTE_VAULT_NAME when set + valid; otherwise
|
|
108
|
+
// falls back to "default". Hub's first-boot wizard (hub#267) passes through
|
|
109
|
+
// an operator-chosen name via this env var.
|
|
102
110
|
if (listVaults().length === 0) {
|
|
103
111
|
const globalConfig = readGlobalConfig();
|
|
104
112
|
if (!globalConfig.default_vault) {
|
|
113
|
+
const firstBoot = resolveFirstBootVaultName(process.env.PARACHUTE_VAULT_NAME);
|
|
114
|
+
if (firstBoot.source === "env") {
|
|
115
|
+
console.log(`[vault first-boot] using PARACHUTE_VAULT_NAME=${firstBoot.name}`);
|
|
116
|
+
} else if (firstBoot.source === "env-invalid") {
|
|
117
|
+
console.warn(
|
|
118
|
+
`[vault first-boot] PARACHUTE_VAULT_NAME=${JSON.stringify(firstBoot.rawValue)} is invalid (${firstBoot.reason}); falling back to "default"`,
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
console.log("[vault first-boot] using default name (no PARACHUTE_VAULT_NAME set)");
|
|
122
|
+
}
|
|
123
|
+
const vaultName = firstBoot.name;
|
|
105
124
|
const { fullKey, keyId } = generateApiKey();
|
|
106
125
|
writeVaultConfig({
|
|
107
|
-
name:
|
|
126
|
+
name: vaultName,
|
|
108
127
|
api_keys: [{
|
|
109
128
|
id: keyId,
|
|
110
129
|
label: "default",
|
|
@@ -114,7 +133,7 @@ if (listVaults().length === 0) {
|
|
|
114
133
|
}],
|
|
115
134
|
created_at: new Date().toISOString(),
|
|
116
135
|
});
|
|
117
|
-
globalConfig.default_vault =
|
|
136
|
+
globalConfig.default_vault = vaultName;
|
|
118
137
|
if (!globalConfig.api_keys?.length) {
|
|
119
138
|
globalConfig.api_keys = [{
|
|
120
139
|
id: keyId,
|
|
@@ -125,7 +144,7 @@ if (listVaults().length === 0) {
|
|
|
125
144
|
}];
|
|
126
145
|
}
|
|
127
146
|
writeGlobalConfig(globalConfig);
|
|
128
|
-
console.log(`Auto-created
|
|
147
|
+
console.log(`Auto-created vault "${vaultName}" (API key: ${fullKey})`);
|
|
129
148
|
}
|
|
130
149
|
}
|
|
131
150
|
|
package/src/vault-name.test.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unit tests for `validateVaultName` — the rule enforced by the `init`
|
|
3
|
-
* prompt
|
|
4
|
-
*
|
|
3
|
+
* prompt, the `--vault-name` flag, and the `PARACHUTE_VAULT_NAME` env var
|
|
4
|
+
* at server first-boot. Covers each rejection branch (empty, length,
|
|
5
|
+
* regex, reserved) plus the happy paths the prompt has to accept (default,
|
|
6
|
+
* hyphens, underscores, length boundaries).
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { describe, test, expect } from "bun:test";
|
|
8
|
-
import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
|
|
10
|
+
import { validateVaultName, decideInitVaultName, resolveFirstBootVaultName } from "./vault-name.ts";
|
|
9
11
|
|
|
10
12
|
describe("validateVaultName", () => {
|
|
11
13
|
describe("accepts", () => {
|
|
@@ -14,11 +16,12 @@ describe("validateVaultName", () => {
|
|
|
14
16
|
"aaron",
|
|
15
17
|
"personal",
|
|
16
18
|
"work",
|
|
17
|
-
"
|
|
19
|
+
"ab", // 2-char boundary (min length)
|
|
18
20
|
"vault-1",
|
|
19
21
|
"my_vault",
|
|
20
22
|
"a-b_c-1",
|
|
21
23
|
"abc123",
|
|
24
|
+
"a".repeat(32), // 32-char boundary (max length)
|
|
22
25
|
])("%s", (name) => {
|
|
23
26
|
const result = validateVaultName(name);
|
|
24
27
|
expect(result.ok).toBe(true);
|
|
@@ -71,6 +74,24 @@ describe("validateVaultName", () => {
|
|
|
71
74
|
expect(result.ok).toBe(false);
|
|
72
75
|
if (!result.ok) expect(result.error).toContain("reserved");
|
|
73
76
|
});
|
|
77
|
+
|
|
78
|
+
test("single character (below 2-char min)", () => {
|
|
79
|
+
const result = validateVaultName("a");
|
|
80
|
+
expect(result.ok).toBe(false);
|
|
81
|
+
if (!result.ok) expect(result.error).toContain("2");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("33 characters (above 32-char max)", () => {
|
|
85
|
+
const result = validateVaultName("a".repeat(33));
|
|
86
|
+
expect(result.ok).toBe(false);
|
|
87
|
+
if (!result.ok) expect(result.error).toContain("32");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("200 characters (well above max)", () => {
|
|
91
|
+
const result = validateVaultName("a".repeat(200));
|
|
92
|
+
expect(result.ok).toBe(false);
|
|
93
|
+
if (!result.ok) expect(result.error).toContain("32");
|
|
94
|
+
});
|
|
74
95
|
});
|
|
75
96
|
});
|
|
76
97
|
|
|
@@ -121,3 +142,78 @@ describe("decideInitVaultName", () => {
|
|
|
121
142
|
expect(d).toEqual({ kind: "name", name: "aaron" });
|
|
122
143
|
});
|
|
123
144
|
});
|
|
145
|
+
|
|
146
|
+
describe("resolveFirstBootVaultName", () => {
|
|
147
|
+
test("env var unset → fallback to default", () => {
|
|
148
|
+
const r = resolveFirstBootVaultName(undefined);
|
|
149
|
+
expect(r).toEqual({ source: "default", name: "default" });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("env var empty string → fallback to default", () => {
|
|
153
|
+
const r = resolveFirstBootVaultName("");
|
|
154
|
+
expect(r).toEqual({ source: "default", name: "default" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("env var whitespace-only → fallback to default", () => {
|
|
158
|
+
const r = resolveFirstBootVaultName(" ");
|
|
159
|
+
expect(r).toEqual({ source: "default", name: "default" });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("env var set to a valid name → that name (positive path)", () => {
|
|
163
|
+
const r = resolveFirstBootVaultName("smoke-1939");
|
|
164
|
+
expect(r).toEqual({ source: "env", name: "smoke-1939" });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("env var set to a valid name with underscores → that name", () => {
|
|
168
|
+
const r = resolveFirstBootVaultName("my_vault");
|
|
169
|
+
expect(r).toEqual({ source: "env", name: "my_vault" });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("env var set with surrounding whitespace → trimmed + accepted", () => {
|
|
173
|
+
const r = resolveFirstBootVaultName(" aaron ");
|
|
174
|
+
expect(r).toEqual({ source: "env", name: "aaron" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("env var set to invalid (uppercase + spaces + special) → fallback to default + record raw + reason", () => {
|
|
178
|
+
const r = resolveFirstBootVaultName("Bad Name!!");
|
|
179
|
+
expect(r.source).toBe("env-invalid");
|
|
180
|
+
expect(r.name).toBe("default");
|
|
181
|
+
if (r.source === "env-invalid") {
|
|
182
|
+
expect(r.rawValue).toBe("Bad Name!!");
|
|
183
|
+
expect(r.reason).toContain("lowercase alphanumeric");
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("env var set to reserved name 'list' → fallback to default", () => {
|
|
188
|
+
const r = resolveFirstBootVaultName("list");
|
|
189
|
+
expect(r.source).toBe("env-invalid");
|
|
190
|
+
expect(r.name).toBe("default");
|
|
191
|
+
if (r.source === "env-invalid") {
|
|
192
|
+
expect(r.reason).toContain("reserved");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("env var set to slash-containing name → fallback to default", () => {
|
|
197
|
+
const r = resolveFirstBootVaultName("team/work");
|
|
198
|
+
expect(r.source).toBe("env-invalid");
|
|
199
|
+
expect(r.name).toBe("default");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("env var set to a 200-char name → fallback to default (over max-length)", () => {
|
|
203
|
+
const r = resolveFirstBootVaultName("a".repeat(200));
|
|
204
|
+
expect(r.source).toBe("env-invalid");
|
|
205
|
+
expect(r.name).toBe("default");
|
|
206
|
+
if (r.source === "env-invalid") {
|
|
207
|
+
expect(r.reason).toContain("32");
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("env var set to a single character → fallback to default (under min-length)", () => {
|
|
212
|
+
const r = resolveFirstBootVaultName("a");
|
|
213
|
+
expect(r.source).toBe("env-invalid");
|
|
214
|
+
expect(r.name).toBe("default");
|
|
215
|
+
if (r.source === "env-invalid") {
|
|
216
|
+
expect(r.reason).toContain("2");
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
package/src/vault-name.ts
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
* the SQLite filename, and the OAuth consent page — anything that breaks
|
|
6
6
|
* URL routing or filesystem assumptions has to be rejected up front.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Rule: lowercase alphanumeric + hyphens or underscores, 2–32 chars, with
|
|
9
|
+
* `list` reserved. Used by the `init` prompt, the `--vault-name` flag, and
|
|
10
|
+
* the `PARACHUTE_VAULT_NAME` env var at server first-boot. `cmdCreate`
|
|
11
|
+
* keeps its own (slightly more permissive, legacy) regex for backward
|
|
12
|
+
* compat — tightening it would reject names existing users may already
|
|
13
|
+
* have minted.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
const VAULT_NAME_RE = /^[a-z0-9_-]+$/;
|
|
17
|
+
const VAULT_NAME_MIN_LEN = 2;
|
|
18
|
+
const VAULT_NAME_MAX_LEN = 32;
|
|
14
19
|
|
|
15
20
|
const RESERVED_NAMES = new Set([
|
|
16
21
|
// Collides with the `/vaults/list` discovery endpoint historically; the
|
|
@@ -23,11 +28,24 @@ export type VaultNameValidation =
|
|
|
23
28
|
| { ok: true; name: string }
|
|
24
29
|
| { ok: false; error: string };
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Validate a vault name. Accepts lowercase alphanumeric + hyphens or
|
|
33
|
+
* underscores, 2–32 chars. Trims surrounding whitespace before checking.
|
|
34
|
+
* `cmdCreate` keeps its own (legacy-permissive) regex; this validator is
|
|
35
|
+
* the strict gate used by the env var, the `--vault-name` flag, and
|
|
36
|
+
* hub's first-boot wizard.
|
|
37
|
+
*/
|
|
26
38
|
export function validateVaultName(raw: string): VaultNameValidation {
|
|
27
39
|
const name = raw.trim();
|
|
28
40
|
if (!name) {
|
|
29
41
|
return { ok: false, error: "vault name cannot be empty." };
|
|
30
42
|
}
|
|
43
|
+
if (name.length < VAULT_NAME_MIN_LEN || name.length > VAULT_NAME_MAX_LEN) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
error: `vault names must be ${VAULT_NAME_MIN_LEN}–${VAULT_NAME_MAX_LEN} characters long.`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
31
49
|
if (!VAULT_NAME_RE.test(name)) {
|
|
32
50
|
return {
|
|
33
51
|
ok: false,
|
|
@@ -78,3 +96,43 @@ export function decideInitVaultName(
|
|
|
78
96
|
}
|
|
79
97
|
return { kind: "prompt" };
|
|
80
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pick the first-boot vault name based on `PARACHUTE_VAULT_NAME`. Used by
|
|
102
|
+
* `server.ts` when the server starts with zero vaults on disk (Docker
|
|
103
|
+
* first-boot, hub-driven self-host install).
|
|
104
|
+
*
|
|
105
|
+
* - env var unset / empty / whitespace-only → `{ source: "default", name: "default" }`
|
|
106
|
+
* - env var present + valid → `{ source: "env", name: <validated> }`
|
|
107
|
+
* - env var present + invalid → `{ source: "env-invalid", name: "default",
|
|
108
|
+
* rawValue: <original>, reason: <validator message> }` (caller logs a
|
|
109
|
+
* warning and proceeds with the default name; we never abort first-boot
|
|
110
|
+
* over a misconfigured env var)
|
|
111
|
+
*
|
|
112
|
+
* Validation uses the same `validateVaultName` rule as the `--vault-name`
|
|
113
|
+
* flag — lowercase alphanumeric + hyphens or underscores, 2–32 chars, with
|
|
114
|
+
* the `list` reserved-name carveout — so hub's wizard, the CLI flag, and
|
|
115
|
+
* the env var all share one truth.
|
|
116
|
+
*/
|
|
117
|
+
export type FirstBootVaultName =
|
|
118
|
+
| { source: "default"; name: "default" }
|
|
119
|
+
| { source: "env"; name: string }
|
|
120
|
+
| { source: "env-invalid"; name: "default"; rawValue: string; reason: string };
|
|
121
|
+
|
|
122
|
+
export function resolveFirstBootVaultName(
|
|
123
|
+
rawEnvValue: string | undefined,
|
|
124
|
+
): FirstBootVaultName {
|
|
125
|
+
if (rawEnvValue === undefined || rawEnvValue.trim() === "") {
|
|
126
|
+
return { source: "default", name: "default" };
|
|
127
|
+
}
|
|
128
|
+
const v = validateVaultName(rawEnvValue);
|
|
129
|
+
if (v.ok) {
|
|
130
|
+
return { source: "env", name: v.name };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
source: "env-invalid",
|
|
134
|
+
name: "default",
|
|
135
|
+
rawValue: rawEnvValue,
|
|
136
|
+
reason: v.error,
|
|
137
|
+
};
|
|
138
|
+
}
|