@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/core/src/schema.ts
CHANGED
|
@@ -2,17 +2,25 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
3
|
import { rebuildIndexes } from "./indexed-fields.js";
|
|
4
4
|
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 18;
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
|
-
-- Notes: the universal record
|
|
8
|
+
-- Notes: the universal record.
|
|
9
|
+
--
|
|
10
|
+
-- extension (v18, vault#328) carries the file suffix the note should
|
|
11
|
+
-- exhibit when serialized to disk — "md" by default, "csv"/"yaml"/"json"/
|
|
12
|
+
-- "mdx"/etc. for non-markdown notes. Stored extension-less in the path
|
|
13
|
+
-- column; on-disk uniqueness key is (path, extension). See
|
|
14
|
+
-- core/src/portable-md.ts:supportsInlineFrontmatter for the
|
|
15
|
+
-- frontmatter-vs-sidecar split.
|
|
9
16
|
CREATE TABLE IF NOT EXISTS notes (
|
|
10
17
|
id TEXT PRIMARY KEY,
|
|
11
18
|
content TEXT DEFAULT '',
|
|
12
19
|
path TEXT,
|
|
13
20
|
metadata TEXT DEFAULT '{}',
|
|
14
21
|
created_at TEXT NOT NULL,
|
|
15
|
-
updated_at TEXT
|
|
22
|
+
updated_at TEXT,
|
|
23
|
+
extension TEXT NOT NULL DEFAULT 'md'
|
|
16
24
|
);
|
|
17
25
|
|
|
18
26
|
-- Tags: first-class identity carrying schema, hierarchy, and typed-link
|
|
@@ -266,6 +274,11 @@ export function initSchema(db: Database): void {
|
|
|
266
274
|
// as `tags.fields` if needed. See vault#267.
|
|
267
275
|
migrateToV17(db);
|
|
268
276
|
|
|
277
|
+
// Migrate v17 → v18: add `notes.extension TEXT NOT NULL DEFAULT 'md'`.
|
|
278
|
+
// Backward-compat by construction — every existing row defaults to "md"
|
|
279
|
+
// (markdown), unchanged in meaning. See vault#328.
|
|
280
|
+
migrateToV18(db);
|
|
281
|
+
|
|
269
282
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
270
283
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
271
284
|
rebuildIndexes(db);
|
|
@@ -793,6 +806,51 @@ function migrateToV17(db: Database): void {
|
|
|
793
806
|
}
|
|
794
807
|
}
|
|
795
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Migrate v17 → v18: add `notes.extension TEXT NOT NULL DEFAULT 'md'`
|
|
811
|
+
* (vault#328) AND widen the path-uniqueness index from `(path)` to
|
|
812
|
+
* `(path, extension)` so two notes can share a path differing only by
|
|
813
|
+
* extension (`Recipes/pasta` with both .md and .csv variants).
|
|
814
|
+
*
|
|
815
|
+
* Backward-compat by construction — every existing row defaults to "md",
|
|
816
|
+
* so the new composite-index uniqueness collapses to the v5
|
|
817
|
+
* "(path WHERE NOT NULL) is unique" behavior on existing data. Wrapped
|
|
818
|
+
* in BEGIN IMMEDIATE / COMMIT / ROLLBACK per the v14+ pattern.
|
|
819
|
+
*/
|
|
820
|
+
function migrateToV18(db: Database): void {
|
|
821
|
+
if (!hasTable(db, "notes")) return;
|
|
822
|
+
|
|
823
|
+
// Two responsibilities (column add + index swap) — both idempotent
|
|
824
|
+
// individually. Wrap in one transaction so an upgrading v17 vault
|
|
825
|
+
// ends up at exactly v17 or v18, never partial.
|
|
826
|
+
const needsColumn = !hasColumn(db, "notes", "extension");
|
|
827
|
+
const indexes = db.prepare(
|
|
828
|
+
"SELECT name FROM sqlite_master WHERE type='index' AND name IN ('idx_notes_path_unique', 'idx_notes_path_ext_unique')",
|
|
829
|
+
).all() as { name: string }[];
|
|
830
|
+
const hasOldUnique = indexes.some((r) => r.name === "idx_notes_path_unique");
|
|
831
|
+
const hasNewUnique = indexes.some((r) => r.name === "idx_notes_path_ext_unique");
|
|
832
|
+
if (!needsColumn && hasNewUnique && !hasOldUnique) return;
|
|
833
|
+
|
|
834
|
+
db.exec("BEGIN IMMEDIATE");
|
|
835
|
+
try {
|
|
836
|
+
if (needsColumn) {
|
|
837
|
+
db.exec("ALTER TABLE notes ADD COLUMN extension TEXT NOT NULL DEFAULT 'md'");
|
|
838
|
+
}
|
|
839
|
+
if (hasOldUnique) {
|
|
840
|
+
db.exec("DROP INDEX idx_notes_path_unique");
|
|
841
|
+
}
|
|
842
|
+
if (!hasNewUnique) {
|
|
843
|
+
db.exec(
|
|
844
|
+
"CREATE UNIQUE INDEX idx_notes_path_ext_unique ON notes(path, extension) WHERE path IS NOT NULL",
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
db.exec("COMMIT");
|
|
848
|
+
} catch (err) {
|
|
849
|
+
db.exec("ROLLBACK");
|
|
850
|
+
throw err;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
796
854
|
function hasTable(db: Database, name: string): boolean {
|
|
797
855
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
798
856
|
return !!row;
|
package/core/src/store.ts
CHANGED
|
@@ -92,7 +92,7 @@ export class BunSqliteStore implements Store {
|
|
|
92
92
|
|
|
93
93
|
// ---- Notes ----
|
|
94
94
|
|
|
95
|
-
async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
|
|
95
|
+
async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
|
|
96
96
|
const note = noteOps.createNote(this.db, content, opts);
|
|
97
97
|
|
|
98
98
|
if (content) {
|
|
@@ -113,8 +113,8 @@ export class BunSqliteStore implements Store {
|
|
|
113
113
|
return noteOps.getNote(this.db, id);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
async getNoteByPath(path: string): Promise<Note | null> {
|
|
117
|
-
return noteOps.getNoteByPath(this.db, path);
|
|
116
|
+
async getNoteByPath(path: string, extension?: string): Promise<Note | null> {
|
|
117
|
+
return noteOps.getNoteByPath(this.db, path, extension);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
async getNotes(ids: string[]): Promise<Note[]> {
|
|
@@ -128,6 +128,7 @@ export class BunSqliteStore implements Store {
|
|
|
128
128
|
append?: string;
|
|
129
129
|
prepend?: string;
|
|
130
130
|
path?: string;
|
|
131
|
+
extension?: string;
|
|
131
132
|
metadata?: Record<string, unknown>;
|
|
132
133
|
created_at?: string;
|
|
133
134
|
skipUpdatedAt?: boolean;
|
|
@@ -498,7 +499,7 @@ export class BunSqliteStore implements Store {
|
|
|
498
499
|
* `syncAllWikilinks`, so adding the cache rebuild there is the natural
|
|
499
500
|
* place.)
|
|
500
501
|
*/
|
|
501
|
-
async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
|
|
502
|
+
async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
|
|
502
503
|
return noteOps.createNote(this.db, content, opts);
|
|
503
504
|
}
|
|
504
505
|
|
package/core/src/types.ts
CHANGED
|
@@ -10,6 +10,14 @@ export interface Note {
|
|
|
10
10
|
id: string;
|
|
11
11
|
content: string;
|
|
12
12
|
path?: string;
|
|
13
|
+
/**
|
|
14
|
+
* File extension (sans dot). Defaults to `"md"`. Controls the
|
|
15
|
+
* serialized file suffix on export — `.md`/`.mdx` carry frontmatter
|
|
16
|
+
* inline; `.csv`/`.yaml`/`.json`/etc. carry their metadata in a
|
|
17
|
+
* sidecar at `.parachute/notes-meta/<id>.yaml`. See vault#328 +
|
|
18
|
+
* `core/src/portable-md.ts:supportsInlineFrontmatter`.
|
|
19
|
+
*/
|
|
20
|
+
extension?: string;
|
|
13
21
|
metadata?: Record<string, unknown>;
|
|
14
22
|
createdAt: string; // ISO-8601
|
|
15
23
|
updatedAt?: string;
|
|
@@ -64,6 +72,13 @@ export interface QueryOpts {
|
|
|
64
72
|
hasLinks?: boolean;
|
|
65
73
|
path?: string; // exact path match (case-insensitive)
|
|
66
74
|
pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
|
|
75
|
+
/**
|
|
76
|
+
* Filter by file extension. Pass a single extension (e.g. `"csv"`) or
|
|
77
|
+
* an array (e.g. `["csv", "yaml", "json"]`). Extension is compared
|
|
78
|
+
* lower-case. Notes default to `"md"` so `extension: "md"` matches
|
|
79
|
+
* the existing markdown corpus. See vault#328.
|
|
80
|
+
*/
|
|
81
|
+
extension?: string | string[];
|
|
67
82
|
// Restrict results to a specific set of note IDs. The MCP `near` query uses
|
|
68
83
|
// this to push graph-neighborhood scoping into the SQL WHERE clause so that
|
|
69
84
|
// LIMIT and ORDER BY apply to the filtered set, not the whole notes table.
|
|
@@ -107,6 +122,7 @@ export interface QueryOpts {
|
|
|
107
122
|
export interface NoteSummary {
|
|
108
123
|
id: string;
|
|
109
124
|
path?: string;
|
|
125
|
+
extension?: string;
|
|
110
126
|
metadata?: Record<string, unknown>;
|
|
111
127
|
createdAt: string;
|
|
112
128
|
updatedAt?: string;
|
|
@@ -120,6 +136,7 @@ export interface NoteSummary {
|
|
|
120
136
|
export interface NoteIndex {
|
|
121
137
|
id: string;
|
|
122
138
|
path?: string;
|
|
139
|
+
extension?: string;
|
|
123
140
|
createdAt: string;
|
|
124
141
|
updatedAt?: string;
|
|
125
142
|
tags?: string[];
|
|
@@ -138,11 +155,18 @@ export interface HydratedLink extends Link {
|
|
|
138
155
|
|
|
139
156
|
export interface Store {
|
|
140
157
|
// Notes
|
|
141
|
-
createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note>;
|
|
158
|
+
createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note>;
|
|
142
159
|
getNote(id: string): Promise<Note | null>;
|
|
143
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Look up a note by path. Pass `extension` to disambiguate when
|
|
162
|
+
* multiple notes share a path differing only by extension (post-
|
|
163
|
+
* vault#328). When omitted and >1 row matches, throws
|
|
164
|
+
* `AmbiguousPathError` instead of silently picking one. See
|
|
165
|
+
* vault#330 S1.
|
|
166
|
+
*/
|
|
167
|
+
getNoteByPath(path: string, extension?: string): Promise<Note | null>;
|
|
144
168
|
getNotes(ids: string[]): Promise<Note[]>;
|
|
145
|
-
updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
|
|
169
|
+
updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
|
|
146
170
|
/**
|
|
147
171
|
* Set a note's `created_at` and `updated_at` explicitly. Import-only:
|
|
148
172
|
* used by the portable-md round-trip path to restore timestamps from
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -110,19 +110,57 @@ function stripCode(content: string): string {
|
|
|
110
110
|
* Resolve a wikilink target to a note ID.
|
|
111
111
|
*
|
|
112
112
|
* Resolution order:
|
|
113
|
-
* 1.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
113
|
+
* 1. **Explicit-extension target**: `[[Foo.csv]]` matches the note with
|
|
114
|
+
* `path: "Foo"` AND `extension: "csv"` (vault#328). Lets a reader
|
|
115
|
+
* disambiguate when multiple notes share a path differing only by
|
|
116
|
+
* extension. The trailing `.<ext>` must match a known
|
|
117
|
+
* alphanumeric pattern (1–16 chars) — otherwise the dot is treated
|
|
118
|
+
* as part of the path string and falls through to the existing
|
|
119
|
+
* rules.
|
|
120
|
+
* 2. Exact path match (case-insensitive) — multiple matches resolve to
|
|
121
|
+
* a single note when only ONE extension is in play; if `Foo.md` and
|
|
122
|
+
* `Foo.csv` both exist, the link refuses to resolve and the caller
|
|
123
|
+
* sees an unresolved-wikilink entry (vault#328 ambiguity policy).
|
|
124
|
+
* 3. Basename match — target matches the last segment of a path
|
|
125
|
+
* (e.g., "README" matches "Projects/Parachute/README"). Same
|
|
126
|
+
* cross-extension ambiguity policy as #2.
|
|
117
127
|
*/
|
|
118
128
|
export function resolveWikilink(db: Database, target: string): string | null {
|
|
119
|
-
// 1.
|
|
129
|
+
// 1. Explicit extension form: `[[path.ext]]` where `.ext` is a
|
|
130
|
+
// pattern-matching tail (lowercase alphanumeric, 1–16 chars).
|
|
131
|
+
// Mirrors EXTENSION_PATTERN in core/src/notes.ts so the wikilink
|
|
132
|
+
// parser and the API surface share the same notion of "this looks
|
|
133
|
+
// like an extension."
|
|
134
|
+
const extMatch = target.match(/^(.*)\.([a-z0-9]{1,16})$/i);
|
|
135
|
+
if (extMatch) {
|
|
136
|
+
const pathPart = extMatch[1]!;
|
|
137
|
+
const extPart = extMatch[2]!.toLowerCase();
|
|
138
|
+
const explicit = db.prepare(
|
|
139
|
+
"SELECT id FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
|
|
140
|
+
).get(pathPart, extPart) as { id: string } | undefined;
|
|
141
|
+
if (explicit) return explicit.id;
|
|
142
|
+
// No match for explicit (path, ext) — fall through to the looser
|
|
143
|
+
// rules so a literal note named `Recipe.v2` (where `v2` isn't an
|
|
144
|
+
// extension on a real note) can still resolve under exact-path
|
|
145
|
+
// match.
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 2. Exact match (case-insensitive). When multiple notes share a path
|
|
149
|
+
// (post-vault#328 this happens when `Foo.md` and `Foo.csv` both
|
|
150
|
+
// exist, since path is stored extension-less), refuse to resolve.
|
|
120
151
|
const exact = db.prepare(
|
|
121
152
|
"SELECT id FROM notes WHERE path = ? COLLATE NOCASE",
|
|
122
|
-
).
|
|
123
|
-
if (exact) return exact
|
|
153
|
+
).all(target) as { id: string }[];
|
|
154
|
+
if (exact.length === 1) return exact[0]!.id;
|
|
155
|
+
if (exact.length > 1) {
|
|
156
|
+
// Ambiguous — refuse-and-require-explicit-extension policy
|
|
157
|
+
// (vault#328). Returning null routes through the existing
|
|
158
|
+
// unresolved-wikilinks workflow, so the reader (or the indexer)
|
|
159
|
+
// sees the conflict.
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
124
162
|
|
|
125
|
-
//
|
|
163
|
+
// 3. Basename match — last path segment equals target.
|
|
126
164
|
// e.g., target "README" matches path "Projects/Parachute/README"
|
|
127
165
|
const basename = db.prepare(`
|
|
128
166
|
SELECT id FROM notes
|
|
@@ -150,17 +188,39 @@ export interface WikilinkResolution {
|
|
|
150
188
|
|
|
151
189
|
/**
|
|
152
190
|
* Resolve a wikilink target with full detail — single match, ambiguous, or unresolved.
|
|
191
|
+
* Mirrors `resolveWikilink`'s resolution order, including the explicit-
|
|
192
|
+
* extension form `[[Foo.csv]]` introduced by vault#328.
|
|
153
193
|
*/
|
|
154
194
|
export function resolveWikilinkDetailed(db: Database, target: string): WikilinkResolution {
|
|
155
|
-
// 1.
|
|
195
|
+
// 1. Explicit-extension form: `[[path.ext]]`.
|
|
196
|
+
const extMatch = target.match(/^(.*)\.([a-z0-9]{1,16})$/i);
|
|
197
|
+
if (extMatch) {
|
|
198
|
+
const pathPart = extMatch[1]!;
|
|
199
|
+
const extPart = extMatch[2]!.toLowerCase();
|
|
200
|
+
const explicit = db.prepare(
|
|
201
|
+
"SELECT id, path FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
|
|
202
|
+
).get(pathPart, extPart) as { id: string; path: string } | undefined;
|
|
203
|
+
if (explicit) {
|
|
204
|
+
return { resolved: true, note_id: explicit.id, path: explicit.path, candidates: [] };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 2. Exact path match (case-insensitive). Multiple matches → ambiguous.
|
|
156
209
|
const exact = db.prepare(
|
|
157
|
-
"SELECT id, path FROM notes WHERE path = ? COLLATE NOCASE",
|
|
158
|
-
).
|
|
159
|
-
if (exact) {
|
|
160
|
-
return { resolved: true, note_id: exact
|
|
210
|
+
"SELECT id, path, extension FROM notes WHERE path = ? COLLATE NOCASE",
|
|
211
|
+
).all(target) as { id: string; path: string; extension: string }[];
|
|
212
|
+
if (exact.length === 1) {
|
|
213
|
+
return { resolved: true, note_id: exact[0]!.id, path: exact[0]!.path, candidates: [] };
|
|
214
|
+
}
|
|
215
|
+
if (exact.length > 1) {
|
|
216
|
+
return {
|
|
217
|
+
resolved: false,
|
|
218
|
+
ambiguous: true,
|
|
219
|
+
candidates: exact.map((r) => ({ note_id: r.id, path: r.path })),
|
|
220
|
+
};
|
|
161
221
|
}
|
|
162
222
|
|
|
163
|
-
//
|
|
223
|
+
// 3. Basename match
|
|
164
224
|
const basename = db.prepare(`
|
|
165
225
|
SELECT id, path FROM notes
|
|
166
226
|
WHERE path IS NOT NULL
|
package/package.json
CHANGED
package/src/auth.test.ts
CHANGED
|
@@ -398,3 +398,238 @@ describe("auth — legacy global YAML keys honor declared scope", () => {
|
|
|
398
398
|
}
|
|
399
399
|
});
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// VAULT_AUTH_TOKEN — server-wide operator bearer (vault#339)
|
|
404
|
+
//
|
|
405
|
+
// The container-shape auth gate. When the env var is set, a request whose
|
|
406
|
+
// `Authorization: Bearer <value>` matches authenticates as full/admin
|
|
407
|
+
// against any vault on the server — the operator-channel path for sibling
|
|
408
|
+
// services on Render where vault and hub run as separate containers and
|
|
409
|
+
// hub needs a stable shared bearer to call vault.
|
|
410
|
+
//
|
|
411
|
+
// Semantic confirmed for the loopback/non-loopback split (auth gate is
|
|
412
|
+
// orthogonal to socket-level loopback): when VAULT_AUTH_TOKEN is unset,
|
|
413
|
+
// vault's existing token surface (per-vault DB tokens + hub JWTs + legacy
|
|
414
|
+
// YAML keys) is the ONLY auth surface. The bind socket defaults to
|
|
415
|
+
// 127.0.0.1 (`VAULT_BIND` in bind.ts), but no implicit loopback trust
|
|
416
|
+
// exists at the auth layer — a request from 127.0.0.1 still has to
|
|
417
|
+
// present a valid bearer. This matches docs/auth-model.md §1.
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
describe("auth — VAULT_AUTH_TOKEN server-wide operator bearer", () => {
|
|
421
|
+
const TOKEN = "test-operator-token-deadbeef0123456789abcdef";
|
|
422
|
+
let prevToken: string | undefined;
|
|
423
|
+
|
|
424
|
+
beforeEach(() => {
|
|
425
|
+
prevToken = process.env.VAULT_AUTH_TOKEN;
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
afterEach(() => {
|
|
429
|
+
if (prevToken === undefined) delete process.env.VAULT_AUTH_TOKEN;
|
|
430
|
+
else process.env.VAULT_AUTH_TOKEN = prevToken;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("env set + matching bearer → 200 on vault auth, full permission, admin scopes", async () => {
|
|
434
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
435
|
+
seedVault("journal");
|
|
436
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
437
|
+
const journalStore = getVaultStore("journal");
|
|
438
|
+
|
|
439
|
+
const result = await authenticateVaultRequest(
|
|
440
|
+
bearer(TOKEN),
|
|
441
|
+
journalConfig,
|
|
442
|
+
journalStore.db,
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
expect("error" in result).toBe(false);
|
|
446
|
+
if (!("error" in result)) {
|
|
447
|
+
expect(result.permission).toBe("full");
|
|
448
|
+
expect(result.scopes).toContain("vault:admin");
|
|
449
|
+
expect(result.legacyDerived).toBe(false);
|
|
450
|
+
expect(result.scoped_tags).toBeNull();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("env set + matching bearer authenticates against ANY vault on the server", async () => {
|
|
455
|
+
// Server-wide → not tied to any one vault's DB. Same bearer works
|
|
456
|
+
// for journal and work without minting a per-vault token in either.
|
|
457
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
458
|
+
seedVault("journal");
|
|
459
|
+
seedVault("work");
|
|
460
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
461
|
+
const journalStore = getVaultStore("journal");
|
|
462
|
+
const workConfig = readVaultConfig("work")!;
|
|
463
|
+
const workStore = getVaultStore("work");
|
|
464
|
+
|
|
465
|
+
const j = await authenticateVaultRequest(bearer(TOKEN), journalConfig, journalStore.db);
|
|
466
|
+
const w = await authenticateVaultRequest(bearer(TOKEN), workConfig, workStore.db);
|
|
467
|
+
|
|
468
|
+
expect("error" in j).toBe(false);
|
|
469
|
+
expect("error" in w).toBe(false);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("env set + missing bearer → 401 (no implicit auth)", async () => {
|
|
473
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
474
|
+
seedVault("journal");
|
|
475
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
476
|
+
const journalStore = getVaultStore("journal");
|
|
477
|
+
|
|
478
|
+
// No Authorization header at all.
|
|
479
|
+
const noBearer = new Request("https://vault.test/x");
|
|
480
|
+
const result = await authenticateVaultRequest(noBearer, journalConfig, journalStore.db);
|
|
481
|
+
|
|
482
|
+
expect("error" in result).toBe(true);
|
|
483
|
+
if ("error" in result) {
|
|
484
|
+
expect(result.error.status).toBe(401);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("env set + wrong bearer → 401", async () => {
|
|
489
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
490
|
+
seedVault("journal");
|
|
491
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
492
|
+
const journalStore = getVaultStore("journal");
|
|
493
|
+
|
|
494
|
+
const result = await authenticateVaultRequest(
|
|
495
|
+
bearer("wrong-token-doesnotmatch"),
|
|
496
|
+
journalConfig,
|
|
497
|
+
journalStore.db,
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
expect("error" in result).toBe(true);
|
|
501
|
+
if ("error" in result) {
|
|
502
|
+
expect(result.error.status).toBe(401);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("env set + bearer that matches a vault token still resolves (server-wide first, but per-vault unchanged)", async () => {
|
|
507
|
+
// Per-vault tokens keep working even when the server-wide bearer is
|
|
508
|
+
// set. The server-wide check is a fast-path lookup before token DB
|
|
509
|
+
// resolution — a per-vault token doesn't match the env var so it
|
|
510
|
+
// falls through to the existing path.
|
|
511
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
512
|
+
seedVault("journal");
|
|
513
|
+
const perVaultToken = mintTokenInVault("journal");
|
|
514
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
515
|
+
const journalStore = getVaultStore("journal");
|
|
516
|
+
|
|
517
|
+
const result = await authenticateVaultRequest(
|
|
518
|
+
bearer(perVaultToken),
|
|
519
|
+
journalConfig,
|
|
520
|
+
journalStore.db,
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
expect("error" in result).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("env unset + valid per-vault bearer → 200 (existing behavior preserved)", async () => {
|
|
527
|
+
delete process.env.VAULT_AUTH_TOKEN;
|
|
528
|
+
seedVault("journal");
|
|
529
|
+
const token = mintTokenInVault("journal");
|
|
530
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
531
|
+
const journalStore = getVaultStore("journal");
|
|
532
|
+
|
|
533
|
+
const result = await authenticateVaultRequest(bearer(token), journalConfig, journalStore.db);
|
|
534
|
+
expect("error" in result).toBe(false);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("env unset + missing bearer → 401 (existing behavior preserved)", async () => {
|
|
538
|
+
delete process.env.VAULT_AUTH_TOKEN;
|
|
539
|
+
seedVault("journal");
|
|
540
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
541
|
+
const journalStore = getVaultStore("journal");
|
|
542
|
+
|
|
543
|
+
const noBearer = new Request("https://vault.test/x");
|
|
544
|
+
const result = await authenticateVaultRequest(noBearer, journalConfig, journalStore.db);
|
|
545
|
+
expect("error" in result).toBe(true);
|
|
546
|
+
if ("error" in result) {
|
|
547
|
+
expect(result.error.status).toBe(401);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("env unset + non-loopback simulated via X-Forwarded-For → still 401 without bearer", async () => {
|
|
552
|
+
// Doc note: vault has NO implicit loopback trust at the auth layer.
|
|
553
|
+
// The X-Forwarded-For shape (set by hub / Cloudflare Tunnel / etc.)
|
|
554
|
+
// doesn't affect the auth gate; tokens are required regardless of
|
|
555
|
+
// socket origin. The `bind.ts` 127.0.0.1 default is a socket-level
|
|
556
|
+
// listen-restriction, not a trust-asymmetric auth bypass.
|
|
557
|
+
delete process.env.VAULT_AUTH_TOKEN;
|
|
558
|
+
seedVault("journal");
|
|
559
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
560
|
+
const journalStore = getVaultStore("journal");
|
|
561
|
+
|
|
562
|
+
const remote = new Request("https://vault.test/x", {
|
|
563
|
+
headers: { "X-Forwarded-For": "203.0.113.7" },
|
|
564
|
+
});
|
|
565
|
+
const result = await authenticateVaultRequest(remote, journalConfig, journalStore.db);
|
|
566
|
+
expect("error" in result).toBe(true);
|
|
567
|
+
if ("error" in result) {
|
|
568
|
+
expect(result.error.status).toBe(401);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("env set with whitespace-only value → treated as unset", async () => {
|
|
573
|
+
process.env.VAULT_AUTH_TOKEN = " ";
|
|
574
|
+
seedVault("journal");
|
|
575
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
576
|
+
const journalStore = getVaultStore("journal");
|
|
577
|
+
|
|
578
|
+
// An empty/whitespace VAULT_AUTH_TOKEN must NOT allow any bearer to
|
|
579
|
+
// pass — the operator either commits to bearer auth or doesn't.
|
|
580
|
+
const result = await authenticateVaultRequest(
|
|
581
|
+
bearer(""),
|
|
582
|
+
journalConfig,
|
|
583
|
+
journalStore.db,
|
|
584
|
+
);
|
|
585
|
+
expect("error" in result).toBe(true);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("env set + matching bearer also works on the global auth surface", async () => {
|
|
589
|
+
// /vaults metadata listing + /health vault names go through
|
|
590
|
+
// authenticateGlobalRequest. The server-wide bearer must work there
|
|
591
|
+
// too — otherwise hub couldn't enumerate vaults using the operator
|
|
592
|
+
// channel.
|
|
593
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
594
|
+
seedVault("journal");
|
|
595
|
+
|
|
596
|
+
const result = await authenticateGlobalRequest(bearer(TOKEN));
|
|
597
|
+
expect("error" in result).toBe(false);
|
|
598
|
+
if (!("error" in result)) {
|
|
599
|
+
expect(result.permission).toBe("full");
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("env set + wrong bearer on global auth surface → 401", async () => {
|
|
604
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
605
|
+
seedVault("journal");
|
|
606
|
+
|
|
607
|
+
const result = await authenticateGlobalRequest(bearer("wrong-token"));
|
|
608
|
+
expect("error" in result).toBe(true);
|
|
609
|
+
if ("error" in result) {
|
|
610
|
+
expect(result.error.status).toBe(401);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("near-miss bearer (one-char difference, same length) → 401 (constant-time compare)", async () => {
|
|
615
|
+
// Defensive: the server-wide compare uses crypto.timingSafeEqual so
|
|
616
|
+
// a one-char-off bearer that matches length-wise still rejects.
|
|
617
|
+
// We can't measure timing in a unit test, but we can pin the
|
|
618
|
+
// correctness side: a same-length near-miss must still reject.
|
|
619
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
620
|
+
seedVault("journal");
|
|
621
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
622
|
+
const journalStore = getVaultStore("journal");
|
|
623
|
+
|
|
624
|
+
const nearMiss = TOKEN.slice(0, -1) + "x";
|
|
625
|
+
expect(nearMiss).not.toBe(TOKEN);
|
|
626
|
+
expect(nearMiss.length).toBe(TOKEN.length);
|
|
627
|
+
|
|
628
|
+
const result = await authenticateVaultRequest(
|
|
629
|
+
bearer(nearMiss),
|
|
630
|
+
journalConfig,
|
|
631
|
+
journalStore.db,
|
|
632
|
+
);
|
|
633
|
+
expect("error" in result).toBe(true);
|
|
634
|
+
});
|
|
635
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { VaultConfig, StoredKey } from "./config.ts";
|
|
|
20
20
|
import { resolveToken } from "./token-store.ts";
|
|
21
21
|
import type { TokenPermission } from "./token-store.ts";
|
|
22
22
|
import type { Database } from "bun:sqlite";
|
|
23
|
+
import crypto from "node:crypto";
|
|
23
24
|
import { getVaultStore } from "./vault-store.ts";
|
|
24
25
|
import {
|
|
25
26
|
findBroadVaultScopes,
|
|
@@ -32,6 +33,66 @@ import {
|
|
|
32
33
|
} from "./scopes.ts";
|
|
33
34
|
import { HubJwtError, looksLikeJwt, validateHubJwt } from "./hub-jwt.ts";
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Server-wide operator bearer token, sourced from the `VAULT_AUTH_TOKEN`
|
|
38
|
+
* environment variable.
|
|
39
|
+
*
|
|
40
|
+
* Read dynamically per request (not cached at import time) so test seams
|
|
41
|
+
* that mutate `process.env.VAULT_AUTH_TOKEN` work without re-importing.
|
|
42
|
+
* In production the env var is set at container start and doesn't change.
|
|
43
|
+
*
|
|
44
|
+
* Empty / whitespace-only values are treated as unset — the operator
|
|
45
|
+
* either commits to bearer auth or doesn't, no degraded "empty token
|
|
46
|
+
* always matches" failure mode.
|
|
47
|
+
*/
|
|
48
|
+
function getServerWideAuthToken(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
49
|
+
const raw = env.VAULT_AUTH_TOKEN;
|
|
50
|
+
if (typeof raw !== "string") return null;
|
|
51
|
+
const trimmed = raw.trim();
|
|
52
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Constant-time string equality. Returns false when lengths differ
|
|
57
|
+
* (timingSafeEqual throws on length mismatch; we want a quiet false).
|
|
58
|
+
*/
|
|
59
|
+
function constantTimeEquals(a: string, b: string): boolean {
|
|
60
|
+
const aBuf = Buffer.from(a, "utf8");
|
|
61
|
+
const bBuf = Buffer.from(b, "utf8");
|
|
62
|
+
if (aBuf.length !== bBuf.length) return false;
|
|
63
|
+
return crypto.timingSafeEqual(aBuf, bBuf);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* If `VAULT_AUTH_TOKEN` is set and the provided bearer matches it
|
|
68
|
+
* (constant-time), return a full-admin AuthResult that's accepted by
|
|
69
|
+
* every vault on the server.
|
|
70
|
+
*
|
|
71
|
+
* The operator-channel auth shape for non-loopback deploys (Render,
|
|
72
|
+
* sibling-container setups, vault#339). Hub uses this to call vault
|
|
73
|
+
* across a container boundary; end-user OAuth tokens still take the
|
|
74
|
+
* per-vault hub-JWT / pvt_* paths below. See `docs/auth-model.md` §2.
|
|
75
|
+
*
|
|
76
|
+
* Scope set is broad (`vault:admin`) — the env-var bearer is an
|
|
77
|
+
* operator credential, not a user credential. Tag-scoping doesn't
|
|
78
|
+
* apply; we represent it as unscoped (`scoped_tags: null`).
|
|
79
|
+
*/
|
|
80
|
+
function tryServerWideAuth(
|
|
81
|
+
providedKey: string,
|
|
82
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
83
|
+
): AuthResult | null {
|
|
84
|
+
const configured = getServerWideAuthToken(env);
|
|
85
|
+
if (configured === null) return null;
|
|
86
|
+
if (!constantTimeEquals(providedKey, configured)) return null;
|
|
87
|
+
return {
|
|
88
|
+
permission: "full",
|
|
89
|
+
scopes: [SCOPE_ADMIN, SCOPE_WRITE, SCOPE_READ],
|
|
90
|
+
legacyDerived: false,
|
|
91
|
+
scoped_tags: null,
|
|
92
|
+
vault_name: null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
35
96
|
/** Result of a successful auth check. */
|
|
36
97
|
export interface AuthResult {
|
|
37
98
|
permission: TokenPermission;
|
|
@@ -168,6 +229,15 @@ export async function authenticateVaultRequest(
|
|
|
168
229
|
return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
|
|
169
230
|
}
|
|
170
231
|
|
|
232
|
+
// Server-wide operator token (vault#339). When VAULT_AUTH_TOKEN is set,
|
|
233
|
+
// a matching bearer authenticates as full/admin against any vault. This
|
|
234
|
+
// is the cross-container path for Render / sibling-service deployments
|
|
235
|
+
// where hub talks to vault over HTTP. Checked first so it short-circuits
|
|
236
|
+
// both JWT validation and per-vault DB lookups — the operator token is
|
|
237
|
+
// a credential the operator opts into, not one we'd ever fall through.
|
|
238
|
+
const serverWide = tryServerWideAuth(key);
|
|
239
|
+
if (serverWide !== null) return serverWide;
|
|
240
|
+
|
|
171
241
|
// JWT path: hub-issued tokens. Trust pinned to the hub origin via `iss`
|
|
172
242
|
// verification inside validateHubJwt; signature checked against hub's JWKS.
|
|
173
243
|
// Audience strict-checked against `vault.<name>` so a token stamped for
|
|
@@ -326,6 +396,14 @@ export async function authenticateGlobalRequest(
|
|
|
326
396
|
return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
|
|
327
397
|
}
|
|
328
398
|
|
|
399
|
+
// Server-wide operator token (vault#339). When VAULT_AUTH_TOKEN is set,
|
|
400
|
+
// a matching bearer authenticates as full/admin on cross-vault routes
|
|
401
|
+
// (/vaults metadata listing, /health detail). Checked first so a
|
|
402
|
+
// container-host operator-channel call doesn't depend on any per-vault
|
|
403
|
+
// DB lookup.
|
|
404
|
+
const serverWide = tryServerWideAuth(key);
|
|
405
|
+
if (serverWide !== null) return serverWide;
|
|
406
|
+
|
|
329
407
|
// Hub-issued JWTs are always vault-bound (aud=vault.<name>). The unified
|
|
330
408
|
// /vaults / /health surface spans every vault and has no single audience to
|
|
331
409
|
// strict-check against, so JWTs aren't accepted here. Cross-vault listing
|