@openparachute/vault 0.6.0-rc.1 → 0.6.0
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 +7 -7
- 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 +97 -2
- package/core/src/mcp.ts +201 -33
- package/core/src/notes.ts +44 -8
- 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/schema.ts +58 -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/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/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-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 +463 -1
- package/src/mirror-routes.ts +474 -4
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +696 -121
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +113 -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-CGL256oe.js +60 -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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted runtime triggers — per-vault CRUD over the `triggers` table
|
|
3
|
+
* (schema v21).
|
|
4
|
+
*
|
|
5
|
+
* Complements the static `config.yaml` trigger system: config.yaml triggers
|
|
6
|
+
* are loaded at boot and fire globally (all vaults); rows in this table live
|
|
7
|
+
* in a single vault's SQLite DB and are re-registered at boot scoped to that
|
|
8
|
+
* vault (they fire ONLY for events on the vault they were stored under).
|
|
9
|
+
*
|
|
10
|
+
* The structured columns (`events`, `when`, `action`) are JSON-encoded on
|
|
11
|
+
* write and parsed on read. `name` is the primary key, so `upsertTrigger`
|
|
12
|
+
* is a true upsert (insert-or-replace by name). The shape here is kept
|
|
13
|
+
* structurally compatible with `src/config.ts`'s `TriggerConfig` /
|
|
14
|
+
* `TriggerWhen` / `TriggerAction` without importing from `src/` — core stays
|
|
15
|
+
* dependency-free of the server layer.
|
|
16
|
+
*
|
|
17
|
+
* `action.auth.bearer`, when present, becomes an `Authorization: Bearer`
|
|
18
|
+
* header on the webhook POST (the JWT webhook-auth path that retires the
|
|
19
|
+
* old `?secret=` query param). It is stored verbatim in the JSON column.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Database } from "bun:sqlite";
|
|
23
|
+
|
|
24
|
+
/** Predicate shape — mirrors src/config.ts:TriggerWhen. */
|
|
25
|
+
export interface StoredTriggerWhen {
|
|
26
|
+
tags?: string[];
|
|
27
|
+
has_content?: boolean;
|
|
28
|
+
missing_metadata?: string[];
|
|
29
|
+
has_metadata?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Webhook auth — only the bearer-JWT path for now. */
|
|
33
|
+
export interface StoredTriggerAuth {
|
|
34
|
+
bearer?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Action shape — mirrors src/config.ts:TriggerAction plus `auth`. */
|
|
38
|
+
export interface StoredTriggerAction {
|
|
39
|
+
webhook: string;
|
|
40
|
+
timeout?: number;
|
|
41
|
+
send?: "json" | "attachment" | "content";
|
|
42
|
+
auth?: StoredTriggerAuth;
|
|
43
|
+
// Forward-compat: include_context and any other action fields round-trip
|
|
44
|
+
// through the JSON column verbatim even though core doesn't interpret them.
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A persisted trigger row, decoded. */
|
|
49
|
+
export interface StoredTrigger {
|
|
50
|
+
name: string;
|
|
51
|
+
events: Array<"created" | "updated">;
|
|
52
|
+
when: StoredTriggerWhen;
|
|
53
|
+
action: StoredTriggerAction;
|
|
54
|
+
created_at: string;
|
|
55
|
+
updated_at: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The writable subset callers pass to upsert. */
|
|
59
|
+
export interface TriggerInput {
|
|
60
|
+
name: string;
|
|
61
|
+
events?: Array<"created" | "updated">;
|
|
62
|
+
when: StoredTriggerWhen;
|
|
63
|
+
action: StoredTriggerAction;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface TriggerRow {
|
|
67
|
+
name: string;
|
|
68
|
+
events: string;
|
|
69
|
+
when: string;
|
|
70
|
+
action: string;
|
|
71
|
+
created_at: string;
|
|
72
|
+
updated_at: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function decodeRow(row: TriggerRow): StoredTrigger {
|
|
76
|
+
return {
|
|
77
|
+
name: row.name,
|
|
78
|
+
events: safeParse(row.events, ["created", "updated"]) as Array<"created" | "updated">,
|
|
79
|
+
when: safeParse(row.when, {}) as StoredTriggerWhen,
|
|
80
|
+
action: safeParse(row.action, { webhook: "" }) as StoredTriggerAction,
|
|
81
|
+
created_at: row.created_at,
|
|
82
|
+
updated_at: row.updated_at,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function safeParse(json: string, fallback: unknown): unknown {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(json);
|
|
89
|
+
} catch {
|
|
90
|
+
return fallback;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Insert or replace a trigger by name. Preserves `created_at` on update
|
|
96
|
+
* (re-fetches the existing row's timestamp) and stamps a fresh `updated_at`.
|
|
97
|
+
* Returns the stored shape.
|
|
98
|
+
*/
|
|
99
|
+
export function upsertTrigger(db: Database, input: TriggerInput): StoredTrigger {
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
const existing = db
|
|
102
|
+
.prepare("SELECT created_at FROM triggers WHERE name = ?")
|
|
103
|
+
.get(input.name) as { created_at: string } | null;
|
|
104
|
+
const createdAt = existing?.created_at ?? now;
|
|
105
|
+
const events = input.events ?? ["created", "updated"];
|
|
106
|
+
|
|
107
|
+
db.prepare(
|
|
108
|
+
`INSERT INTO triggers (name, events, "when", action, created_at, updated_at)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
110
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
111
|
+
events = excluded.events,
|
|
112
|
+
"when" = excluded."when",
|
|
113
|
+
action = excluded.action,
|
|
114
|
+
updated_at = excluded.updated_at`,
|
|
115
|
+
).run(
|
|
116
|
+
input.name,
|
|
117
|
+
JSON.stringify(events),
|
|
118
|
+
JSON.stringify(input.when),
|
|
119
|
+
JSON.stringify(input.action),
|
|
120
|
+
createdAt,
|
|
121
|
+
now,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
name: input.name,
|
|
126
|
+
events,
|
|
127
|
+
when: input.when,
|
|
128
|
+
action: input.action,
|
|
129
|
+
created_at: createdAt,
|
|
130
|
+
updated_at: now,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** List all persisted triggers for this vault, ordered by name. */
|
|
135
|
+
export function listTriggers(db: Database): StoredTrigger[] {
|
|
136
|
+
const rows = db
|
|
137
|
+
.prepare(
|
|
138
|
+
`SELECT name, events, "when", action, created_at, updated_at
|
|
139
|
+
FROM triggers ORDER BY name`,
|
|
140
|
+
)
|
|
141
|
+
.all() as TriggerRow[];
|
|
142
|
+
return rows.map(decodeRow);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Fetch a single trigger by name, or null. */
|
|
146
|
+
export function getTrigger(db: Database, name: string): StoredTrigger | null {
|
|
147
|
+
const row = db
|
|
148
|
+
.prepare(
|
|
149
|
+
`SELECT name, events, "when", action, created_at, updated_at
|
|
150
|
+
FROM triggers WHERE name = ?`,
|
|
151
|
+
)
|
|
152
|
+
.get(name) as TriggerRow | null;
|
|
153
|
+
return row ? decodeRow(row) : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Delete a trigger by name. Returns true if a row was removed. */
|
|
157
|
+
export function deleteTrigger(db: Database, name: string): boolean {
|
|
158
|
+
const res = db.prepare("DELETE FROM triggers WHERE name = ?").run(name);
|
|
159
|
+
return res.changes > 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Load all persisted triggers (boot path). Alias of listTriggers for clarity. */
|
|
163
|
+
export function loadAllTriggers(db: Database): StoredTrigger[] {
|
|
164
|
+
return listTriggers(db);
|
|
165
|
+
}
|
package/core/src/types.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
2
3
|
import type { PrunedField } from "./indexed-fields.js";
|
|
4
|
+
import type { TagExpandMode } from "./tag-hierarchy.js";
|
|
3
5
|
|
|
4
6
|
// ---- Re-exports ----
|
|
5
7
|
|
|
6
|
-
export type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
|
|
8
|
+
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
7
9
|
export type { PrunedField } from "./indexed-fields.js";
|
|
10
|
+
export type { TagExpandMode } from "./tag-hierarchy.js";
|
|
8
11
|
|
|
9
12
|
// ---- Note ----
|
|
10
13
|
|
|
@@ -25,6 +28,14 @@ export interface Note {
|
|
|
25
28
|
updatedAt?: string;
|
|
26
29
|
tags?: string[];
|
|
27
30
|
links?: Link[];
|
|
31
|
+
/**
|
|
32
|
+
* Opt-in link degree (raw row count, both directions by default). Present
|
|
33
|
+
* only when the caller requests it via `include_link_count` (REST/MCP).
|
|
34
|
+
* Surfaced the same way `links`/`attachments` are — an extra key injected
|
|
35
|
+
* onto the response after the base shape. See `getLinkCounts` in links.ts
|
|
36
|
+
* for the exact degree semantics (self-loop = 2 under `both`).
|
|
37
|
+
*/
|
|
38
|
+
linkCount?: number;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
// ---- Link ----
|
|
@@ -59,6 +70,16 @@ export interface VaultStats {
|
|
|
59
70
|
tagCount: number;
|
|
60
71
|
attachmentCount: number;
|
|
61
72
|
linkCount: number;
|
|
73
|
+
/**
|
|
74
|
+
* Total bytes of all note content, computed as the sum of the UTF-8 byte
|
|
75
|
+
* length of every note's `content`. The SQL uses `LENGTH(CAST(content AS
|
|
76
|
+
* BLOB))` deliberately: SQLite's bare `LENGTH(text)` returns the number of
|
|
77
|
+
* *characters*, not bytes, so a note full of multibyte UTF-8 (emoji, CJK,
|
|
78
|
+
* accents) would undercount its true on-disk/on-wire footprint. Casting to
|
|
79
|
+
* BLOB forces `LENGTH` to count raw bytes. This is the logical content size,
|
|
80
|
+
* not the physical DB-file size (see `usage.ts:dbBytes` for the latter).
|
|
81
|
+
*/
|
|
82
|
+
contentBytes: number;
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
// ---- Query Options ----
|
|
@@ -66,6 +87,18 @@ export interface VaultStats {
|
|
|
66
87
|
export interface QueryOpts {
|
|
67
88
|
tags?: string[];
|
|
68
89
|
tagMatch?: "all" | "any"; // "all" = must have ALL tags (default), "any" = must have ANY tag
|
|
90
|
+
/**
|
|
91
|
+
* Tag-expansion axis (vault tag `expand` axis — design
|
|
92
|
+
* `design/2026-06-09-tag-expand-axis.md`). Selects how each `tags` entry
|
|
93
|
+
* expands:
|
|
94
|
+
* - `"subtypes"` (DEFAULT): tag ∪ `parent_names` descendants. Today's
|
|
95
|
+
* semantic is-a behavior, unchanged. `_default` universal magic fires here.
|
|
96
|
+
* - `"namespace"`: tag ∪ lexically name-prefixed `tag/*` (the filing axis).
|
|
97
|
+
* - `"both"`: union of subtypes + namespace.
|
|
98
|
+
* - `"exact"`: the literal tag only, no expansion.
|
|
99
|
+
* Absent → `"subtypes"` → byte-identical to pre-axis behavior.
|
|
100
|
+
*/
|
|
101
|
+
expand?: TagExpandMode;
|
|
69
102
|
excludeTags?: string[];
|
|
70
103
|
// Presence filters. `true` → has at least one; `false` → has none.
|
|
71
104
|
// When `tags` is also set, `hasTags` is ignored (the tag filter already constrains the set).
|
|
@@ -115,6 +148,12 @@ export interface QueryOpts {
|
|
|
115
148
|
// declared `indexed: true`; errors loudly otherwise. Direction is taken
|
|
116
149
|
// from `sort` (default "asc") and `created_at` is appended as a stable
|
|
117
150
|
// tiebreaker.
|
|
151
|
+
//
|
|
152
|
+
// The pseudo-field `link_count` is special-cased (no indexed-field
|
|
153
|
+
// declaration needed): it sorts by link DEGREE — the both-directions
|
|
154
|
+
// raw row count — using the same directional-sum definition as the
|
|
155
|
+
// `linkCount` response field, so the sort key equals the field value for
|
|
156
|
+
// every note (self-loops included). See `queryNotes`/`getLinkCounts`.
|
|
118
157
|
orderBy?: string;
|
|
119
158
|
limit?: number;
|
|
120
159
|
offset?: number;
|
|
@@ -153,6 +192,8 @@ export interface NoteSummary {
|
|
|
153
192
|
createdAt: string;
|
|
154
193
|
updatedAt?: string;
|
|
155
194
|
tags?: string[];
|
|
195
|
+
/** Opt-in link degree (see `Note.linkCount`). */
|
|
196
|
+
linkCount?: number;
|
|
156
197
|
}
|
|
157
198
|
|
|
158
199
|
/**
|
|
@@ -169,6 +210,8 @@ export interface NoteIndex {
|
|
|
169
210
|
metadata?: Record<string, unknown>;
|
|
170
211
|
byteSize: number;
|
|
171
212
|
preview: string;
|
|
213
|
+
/** Opt-in link degree (see `Note.linkCount`). */
|
|
214
|
+
linkCount?: number;
|
|
172
215
|
}
|
|
173
216
|
|
|
174
217
|
/** Link with hydrated note summaries. */
|
|
@@ -180,6 +223,15 @@ export interface HydratedLink extends Link {
|
|
|
180
223
|
// ---- Store Interface ----
|
|
181
224
|
|
|
182
225
|
export interface Store {
|
|
226
|
+
/**
|
|
227
|
+
* The underlying `bun:sqlite` handle. Exposed (read-only) so callers that
|
|
228
|
+
* need to run a raw query the Store interface doesn't surface — e.g. the
|
|
229
|
+
* token-table reverse-lookups in routes.ts and MCP tool generation in
|
|
230
|
+
* mcp.ts — can reach it without an `(store as any).db` cast. The concrete
|
|
231
|
+
* `Store` class declares this as `public readonly db: Database`. See vault#242.
|
|
232
|
+
*/
|
|
233
|
+
readonly db: Database;
|
|
234
|
+
|
|
183
235
|
// Notes
|
|
184
236
|
createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note>;
|
|
185
237
|
getNote(id: string): Promise<Note | null>;
|
|
@@ -218,7 +270,7 @@ export interface Store {
|
|
|
218
270
|
* agent loop can persist a single watermark and keep polling.
|
|
219
271
|
*/
|
|
220
272
|
queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage>;
|
|
221
|
-
searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]>;
|
|
273
|
+
searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]>;
|
|
222
274
|
|
|
223
275
|
// Tags
|
|
224
276
|
tagNote(noteId: string, tags: string[]): Promise<void>;
|
|
@@ -230,6 +282,18 @@ export interface Store {
|
|
|
230
282
|
* compute the effective allowlisted tag-set at auth time.
|
|
231
283
|
*/
|
|
232
284
|
expandTagsWithDescendants(tags: string[]): Promise<Set<string>>;
|
|
285
|
+
/**
|
|
286
|
+
* Mode-aware tag expansion (vault tag `expand` axis). Expands each input tag
|
|
287
|
+
* along the selected axis and returns the union:
|
|
288
|
+
* - `"subtypes"` (default): `{tag} ∪ parent_names-descendants` — identical to
|
|
289
|
+
* `expandTagsWithDescendants` (which is a thin shim over this).
|
|
290
|
+
* - `"namespace"`: `{tag} ∪ lexically name-prefixed tag/*`.
|
|
291
|
+
* - `"both"`: union of the two.
|
|
292
|
+
* - `"exact"`: `{tag}` only.
|
|
293
|
+
* Always includes each input tag. Used by the live-query matcher to lower the
|
|
294
|
+
* IDENTICAL expansion the snapshot query engine uses for the same `expand`.
|
|
295
|
+
*/
|
|
296
|
+
expandTags(tags: string[], mode?: TagExpandMode): Promise<Set<string>>;
|
|
233
297
|
listTags(): Promise<{ name: string; count: number }[]>;
|
|
234
298
|
deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
|
|
235
299
|
renameTag(
|
|
@@ -313,7 +377,7 @@ export interface Store {
|
|
|
313
377
|
patch: {
|
|
314
378
|
description?: string | null;
|
|
315
379
|
fields?: Record<string, TagFieldSchema> | null;
|
|
316
|
-
relationships?:
|
|
380
|
+
relationships?: TagRelationshipMap | null;
|
|
317
381
|
parent_names?: string[] | null;
|
|
318
382
|
},
|
|
319
383
|
): Promise<TagRecord>;
|
|
@@ -305,5 +305,25 @@ export function projectionToMarkdown(args: {
|
|
|
305
305
|
lines.push("");
|
|
306
306
|
lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
|
|
307
307
|
|
|
308
|
+
// Scripting pointer block: the connect-time brief used to dead-end on
|
|
309
|
+
// querying — an agent had no path to "how do I script/automate against this
|
|
310
|
+
// vault." Point at the guide rather than inlining it, to keep this brief
|
|
311
|
+
// lean (token-budget note above). Uses the concrete vault name so the mint
|
|
312
|
+
// command is copy-paste ready.
|
|
313
|
+
lines.push("");
|
|
314
|
+
lines.push("## Scripting & automation (beyond this session)");
|
|
315
|
+
lines.push("");
|
|
316
|
+
lines.push(
|
|
317
|
+
"This vault is also a plain HTTP API — reach for it when the user wants a script, cron job, or CI step rather than an interactive session:",
|
|
318
|
+
);
|
|
319
|
+
lines.push(
|
|
320
|
+
`- Mint a scoped credential: \`parachute auth mint-token --scope vault:${vaultName}:read --ephemeral\` (\`--ephemeral\` = short-lived, ideal for scripts; use \`:write\` to create/edit).`,
|
|
321
|
+
);
|
|
322
|
+
lines.push(`- Call the REST API at \`<hub-origin>/vault/${vaultName}/api/...\`.`);
|
|
323
|
+
lines.push(
|
|
324
|
+
"- Full guide — copy-paste bash/Python/JS examples, plus how to design tags vs paths vs schemas: https://parachute.computer/scripting/",
|
|
325
|
+
);
|
|
326
|
+
lines.push("- For a prompt on a schedule with no code, see Parachute Runner.");
|
|
327
|
+
|
|
308
328
|
return lines.join("\n");
|
|
309
329
|
}
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -137,7 +137,7 @@ export function resolveWikilink(db: Database, target: string): string | null {
|
|
|
137
137
|
const extPart = extMatch[2]!.toLowerCase();
|
|
138
138
|
const explicit = db.prepare(
|
|
139
139
|
"SELECT id FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
|
|
140
|
-
).get(pathPart, extPart) as { id: string } |
|
|
140
|
+
).get(pathPart, extPart) as { id: string } | null;
|
|
141
141
|
if (explicit) return explicit.id;
|
|
142
142
|
// No match for explicit (path, ext) — fall through to the looser
|
|
143
143
|
// rules so a literal note named `Recipe.v2` (where `v2` isn't an
|
|
@@ -199,7 +199,7 @@ export function resolveWikilinkDetailed(db: Database, target: string): WikilinkR
|
|
|
199
199
|
const extPart = extMatch[2]!.toLowerCase();
|
|
200
200
|
const explicit = db.prepare(
|
|
201
201
|
"SELECT id, path FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
|
|
202
|
-
).get(pathPart, extPart) as { id: string; path: string } |
|
|
202
|
+
).get(pathPart, extPart) as { id: string; path: string } | null;
|
|
203
203
|
if (explicit) {
|
|
204
204
|
return { resolved: true, note_id: explicit.id, path: explicit.path, candidates: [] };
|
|
205
205
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.6.0
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
5
|
"module": "src/cli.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -22,12 +22,11 @@
|
|
|
22
22
|
"test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
|
|
25
|
-
"postinstall": "if [ -d web/ui ]; then bun run build:spa; fi",
|
|
26
25
|
"prepack": "bun run build:spa"
|
|
27
26
|
},
|
|
28
27
|
"dependencies": {
|
|
29
28
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
30
|
-
"@openparachute/scope-guard": "^0.4.
|
|
29
|
+
"@openparachute/scope-guard": "^0.4.1-rc.1",
|
|
31
30
|
"jose": "^6.2.2",
|
|
32
31
|
"otpauth": "^9.5.0",
|
|
33
32
|
"qrcode-terminal": "^0.12.0"
|
package/src/admin-spa.test.ts
CHANGED
|
@@ -12,7 +12,12 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
|
12
12
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
13
13
|
import { join } from "path";
|
|
14
14
|
import { tmpdir } from "os";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
isAdminSpaPath,
|
|
17
|
+
isDaemonAdminSpaPath,
|
|
18
|
+
serveAdminSpa,
|
|
19
|
+
serveDaemonAdminSpa,
|
|
20
|
+
} from "./admin-spa.ts";
|
|
16
21
|
|
|
17
22
|
const fixtureDir = join(tmpdir(), `vault-admin-spa-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
18
23
|
|
|
@@ -130,15 +135,76 @@ describe("serveAdminSpa", () => {
|
|
|
130
135
|
});
|
|
131
136
|
});
|
|
132
137
|
|
|
138
|
+
describe("isDaemonAdminSpaPath", () => {
|
|
139
|
+
test("matches /vault/admin and true subpaths", () => {
|
|
140
|
+
expect(isDaemonAdminSpaPath("/vault/admin")).toBe(true);
|
|
141
|
+
expect(isDaemonAdminSpaPath("/vault/admin/")).toBe(true);
|
|
142
|
+
expect(isDaemonAdminSpaPath("/vault/admin/assets/index.js")).toBe(true);
|
|
143
|
+
// The doubled path the per-vault regex would mis-read as vault "admin".
|
|
144
|
+
expect(isDaemonAdminSpaPath("/vault/admin/admin")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("does not match vaults whose name merely starts with 'admin'", () => {
|
|
148
|
+
expect(isDaemonAdminSpaPath("/vault/adminx")).toBe(false);
|
|
149
|
+
expect(isDaemonAdminSpaPath("/vault/admin2/admin")).toBe(false);
|
|
150
|
+
expect(isDaemonAdminSpaPath("/vault/admin-foo")).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("does not match per-vault mounts or unrelated paths", () => {
|
|
154
|
+
expect(isDaemonAdminSpaPath("/vault/work/admin")).toBe(false);
|
|
155
|
+
expect(isDaemonAdminSpaPath("/admin")).toBe(false);
|
|
156
|
+
expect(isDaemonAdminSpaPath("/vaults")).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("serveDaemonAdminSpa (the /vault/admin multi-vault mount)", () => {
|
|
161
|
+
test("bare /vault/admin redirects to trailing-slash form (301)", async () => {
|
|
162
|
+
// Same load-bearing canonicalization as the per-vault mount: Vite's
|
|
163
|
+
// relative asset URLs (./assets/...) resolve against the document's
|
|
164
|
+
// DIRECTORY, so /vault/admin (bare) would resolve assets to
|
|
165
|
+
// /vault/assets/... and 404 them.
|
|
166
|
+
const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin");
|
|
167
|
+
expect(res.status).toBe(301);
|
|
168
|
+
expect(res.headers.get("Location")).toBe("/vault/admin/");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("/vault/admin/ returns the SPA index", async () => {
|
|
172
|
+
const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/");
|
|
173
|
+
expect(res.status).toBe(200);
|
|
174
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
175
|
+
expect(await res.text()).toContain("shell");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("daemon-mount asset path strips cleanly", async () => {
|
|
179
|
+
const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/assets/index-abc.js");
|
|
180
|
+
expect(res.status).toBe(200);
|
|
181
|
+
expect(res.headers.get("content-type")).toContain("application/javascript");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("/vault/admin/admin serves the shell (client route, not a per-vault boot)", async () => {
|
|
185
|
+
const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/admin");
|
|
186
|
+
expect(res.status).toBe(200);
|
|
187
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
188
|
+
expect(await res.text()).toContain("shell");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("path traversal (..) cannot escape dist dir on the daemon mount", async () => {
|
|
192
|
+
const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/../../etc/passwd");
|
|
193
|
+
expect(res.status).toBe(200);
|
|
194
|
+
expect(await res.text()).toContain("shell");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
133
198
|
describe("hub <-> vault managementUrl contract", () => {
|
|
134
199
|
// Browsers drop the URL fragment when following a 301 (RFC 7231 SHOULD
|
|
135
200
|
// preserve, but Chrome/Firefox/Safari are inconsistent in practice). The
|
|
136
201
|
// hub-issued JWT travels in `#token=...`, so a redirected click loses the
|
|
137
|
-
// token and the SPA boots unauthenticated.
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// (no redirect, fragment
|
|
141
|
-
// `/vault/<name>/admin`,
|
|
202
|
+
// token and the SPA boots unauthenticated. Under the B4 URL-resolution
|
|
203
|
+
// semantics (hub#637) a RELATIVE managementUrl is mount-joined per
|
|
204
|
+
// instance (`/vault/<name>` + "/" + "admin/") — if it ends with "/" the
|
|
205
|
+
// canonical click target is `/vault/<name>/admin/` (no redirect, fragment
|
|
206
|
+
// preserved). Without the trailing slash hub emits `/vault/<name>/admin`,
|
|
207
|
+
// the server 301s, and the fragment is gone.
|
|
142
208
|
test("module.json managementUrl ends with '/' so hub emits the no-redirect form", () => {
|
|
143
209
|
const moduleJson = JSON.parse(
|
|
144
210
|
readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
|
|
@@ -146,16 +212,40 @@ describe("hub <-> vault managementUrl contract", () => {
|
|
|
146
212
|
expect(moduleJson.managementUrl).toMatch(/\/$/);
|
|
147
213
|
});
|
|
148
214
|
|
|
149
|
-
test("
|
|
150
|
-
//
|
|
151
|
-
//
|
|
215
|
+
test("managementUrl + uiUrl are RELATIVE (per-instance); configUiUrl is origin-absolute (daemon-level)", () => {
|
|
216
|
+
// B4 semantics (2026-06-09 hub-module-boundary): relative = mount-joined
|
|
217
|
+
// per instance; leading "/" = origin-absolute verbatim. The per-instance
|
|
218
|
+
// surfaces (manage tile, instance UI) stay per-vault; the module-level
|
|
219
|
+
// config UI points at the daemon-level multi-vault home. A leading "/"
|
|
220
|
+
// on managementUrl/uiUrl here would flip every instance tile to the
|
|
221
|
+
// module home; a relative configUiUrl would wrongly mount-join.
|
|
222
|
+
const moduleJson = JSON.parse(
|
|
223
|
+
readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
|
|
224
|
+
);
|
|
225
|
+
expect(moduleJson.managementUrl).toBe("admin/");
|
|
226
|
+
expect(moduleJson.uiUrl).toBe("admin/");
|
|
227
|
+
expect(moduleJson.configUiUrl).toBe("/vault/admin/");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("the canonical hub-emitted per-instance URL serves the SPA shell directly (no 301)", async () => {
|
|
231
|
+
// Mirror hub's per-instance join under B4: mount + "/" + relative
|
|
232
|
+
// managementUrl. With managementUrl="admin/" the result is
|
|
152
233
|
// /vault/<name>/admin/ — which serveAdminSpa returns as 200, not 301.
|
|
153
234
|
const moduleJson = JSON.parse(
|
|
154
235
|
readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
|
|
155
236
|
);
|
|
156
|
-
const canonical = `/vault/work
|
|
237
|
+
const canonical = `/vault/work/${moduleJson.managementUrl}`;
|
|
157
238
|
const res = await serveAdminSpa(fixtureDir, canonical);
|
|
158
239
|
expect(res.status).toBe(200);
|
|
159
240
|
expect(res.headers.get("Location")).toBeNull();
|
|
160
241
|
});
|
|
242
|
+
|
|
243
|
+
test("the canonical configUiUrl serves the daemon-level shell directly (no 301)", async () => {
|
|
244
|
+
const moduleJson = JSON.parse(
|
|
245
|
+
readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
|
|
246
|
+
);
|
|
247
|
+
const res = await serveDaemonAdminSpa(fixtureDir, moduleJson.configUiUrl);
|
|
248
|
+
expect(res.status).toBe(200);
|
|
249
|
+
expect(res.headers.get("Location")).toBeNull();
|
|
250
|
+
});
|
|
161
251
|
});
|
package/src/admin-spa.ts
CHANGED
|
@@ -28,6 +28,18 @@ import { fileURLToPath } from "node:url";
|
|
|
28
28
|
*/
|
|
29
29
|
const ADMIN_SPA_MOUNT_RE = /^\/vault\/([^/]+)\/admin(?=\/|$)/;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Regex anchoring the DAEMON-LEVEL multi-vault SPA mount at `/vault/admin`
|
|
33
|
+
* (B3 of the 2026-06-09 hub-module-boundary migration). Deliberately a
|
|
34
|
+
* SEPARATE regex from the per-vault one — merging them would let
|
|
35
|
+
* `/vault/admin/admin` boot per-vault mode with name="admin". `admin` is a
|
|
36
|
+
* reserved vault name (see `vault-name.ts:RESERVED_VAULT_NAMES`), so this
|
|
37
|
+
* mount can never collide with a real instance; routing dispatches it
|
|
38
|
+
* BEFORE the per-vault branch so a pre-reservation squatter is shadowed
|
|
39
|
+
* (and warned about at boot) rather than capturing the mount.
|
|
40
|
+
*/
|
|
41
|
+
const DAEMON_ADMIN_SPA_MOUNT_RE = /^\/vault\/admin(?=\/|$)/;
|
|
42
|
+
|
|
31
43
|
/**
|
|
32
44
|
* Resolve the default SPA bundle dir. Anchored to this file's location so
|
|
33
45
|
* a `bun src/server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
|
|
@@ -92,18 +104,24 @@ function spaContentType(pathname: string): string {
|
|
|
92
104
|
* even before a token has been minted (so the operator can actually see
|
|
93
105
|
* the empty / auth-required state we render in `VaultDetail.tsx`).
|
|
94
106
|
*/
|
|
95
|
-
export async function serveAdminSpa(
|
|
107
|
+
export async function serveAdminSpa(
|
|
108
|
+
spaDistDir: string,
|
|
109
|
+
pathname: string,
|
|
110
|
+
mountRe: RegExp = ADMIN_SPA_MOUNT_RE,
|
|
111
|
+
): Promise<Response> {
|
|
96
112
|
if (!existsSync(spaDistDir)) {
|
|
97
113
|
return new Response(
|
|
98
114
|
"vault admin SPA bundle not found — run `bun run build` in web/ui/ to produce dist/",
|
|
99
115
|
{ status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
|
|
100
116
|
);
|
|
101
117
|
}
|
|
102
|
-
// Strip the mount prefix
|
|
118
|
+
// Strip the mount prefix (per-vault by default; the daemon-level mount
|
|
119
|
+
// passes its own regex via `serveDaemonAdminSpa`):
|
|
103
120
|
// /vault/foo/admin → ""
|
|
104
121
|
// /vault/foo/admin/ → "/"
|
|
105
122
|
// /vault/foo/admin/x.js → "/x.js"
|
|
106
|
-
|
|
123
|
+
// /vault/admin/x.js → "/x.js" (daemon mount)
|
|
124
|
+
const sub = pathname.replace(mountRe, "");
|
|
107
125
|
|
|
108
126
|
// Canonicalize the bare mount → trailing-slash form. Vite emits
|
|
109
127
|
// *relative* asset URLs (`./assets/index-abc.js`) since `<name>` isn't
|
|
@@ -155,7 +173,34 @@ export async function serveAdminSpa(spaDistDir: string, pathname: string): Promi
|
|
|
155
173
|
* Match `/vault/<name>/admin` or `/vault/<name>/admin/...`. Bare
|
|
156
174
|
* `/vault/<name>/admin-foo` and `/vault/<name>` (the metadata endpoint)
|
|
157
175
|
* must NOT trigger this — only the SPA mount root and its true subpaths.
|
|
176
|
+
*
|
|
177
|
+
* NOTE: `/vault/admin/admin` also matches this regex (name="admin") — the
|
|
178
|
+
* router dispatches `isDaemonAdminSpaPath` FIRST so that path never
|
|
179
|
+
* reaches per-vault mode. Keep that dispatch order; it's pinned in
|
|
180
|
+
* routing.test.ts.
|
|
158
181
|
*/
|
|
159
182
|
export function isAdminSpaPath(pathname: string): boolean {
|
|
160
183
|
return ADMIN_SPA_MOUNT_RE.test(pathname);
|
|
161
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Match the daemon-level multi-vault mount: `/vault/admin` or
|
|
188
|
+
* `/vault/admin/...`. `/vault/adminx` (a real vault that begins with
|
|
189
|
+
* "admin") must NOT trigger this — only the exact segment.
|
|
190
|
+
*/
|
|
191
|
+
export function isDaemonAdminSpaPath(pathname: string): boolean {
|
|
192
|
+
return DAEMON_ADMIN_SPA_MOUNT_RE.test(pathname);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Serve the SPA bundle under the daemon-level `/vault/admin` mount. Same
|
|
197
|
+
* bundle as the per-vault mount — `web/ui/src/lib/mount.ts` detects which
|
|
198
|
+
* basename it booted under at runtime — with the daemon mount's own
|
|
199
|
+
* prefix-strip. The bare-mount 301 inside `serveAdminSpa` fires for
|
|
200
|
+
* `/vault/admin` too: Vite's relative asset URLs resolve against the
|
|
201
|
+
* document's DIRECTORY, so without the trailing-slash canonicalization
|
|
202
|
+
* assets would resolve to `/vault/assets/...` and 404.
|
|
203
|
+
*/
|
|
204
|
+
export function serveDaemonAdminSpa(spaDistDir: string, pathname: string): Promise<Response> {
|
|
205
|
+
return serveAdminSpa(spaDistDir, pathname, DAEMON_ADMIN_SPA_MOUNT_RE);
|
|
206
|
+
}
|
package/src/auth-hub-jwt.test.ts
CHANGED
|
@@ -143,6 +143,7 @@ function bearer(token: string): Request {
|
|
|
143
143
|
let tmpHome: string;
|
|
144
144
|
let prevHome: string | undefined;
|
|
145
145
|
let prevHubOrigin: string | undefined;
|
|
146
|
+
let prevJwksOrigin: string | undefined;
|
|
146
147
|
let fixture: HubFixture;
|
|
147
148
|
let kp: Keypair;
|
|
148
149
|
|
|
@@ -159,7 +160,11 @@ beforeEach(async () => {
|
|
|
159
160
|
kp = await makeKeypair("k1");
|
|
160
161
|
fixture = startHubFixture([kp]);
|
|
161
162
|
prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
163
|
+
prevJwksOrigin = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
162
164
|
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
165
|
+
// Post-vault#464 the JWKS fetch origin resolves separately (loopback by
|
|
166
|
+
// default); point it at the fixture so keys are reachable in-test.
|
|
167
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = fixture.origin;
|
|
163
168
|
resetJwksCache();
|
|
164
169
|
resetRevocationCache();
|
|
165
170
|
});
|
|
@@ -171,6 +176,8 @@ afterEach(() => {
|
|
|
171
176
|
else process.env.PARACHUTE_HOME = prevHome;
|
|
172
177
|
if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
173
178
|
else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
|
|
179
|
+
if (prevJwksOrigin === undefined) delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
180
|
+
else process.env.PARACHUTE_HUB_JWKS_ORIGIN = prevJwksOrigin;
|
|
174
181
|
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
|
|
175
182
|
});
|
|
176
183
|
|
|
@@ -668,7 +675,7 @@ describe("authenticateVaultRequest — hub JWT tag-scoping (auth-unification C0)
|
|
|
668
675
|
|
|
669
676
|
// ---------------------------------------------------------------------------
|
|
670
677
|
// pvt_* DROP (vault#282 Stage 2 — BREAKING). pvt_* tokens were the only
|
|
671
|
-
// non-JWT, non-YAML credential vault used to mint + validate. At 0.
|
|
678
|
+
// non-JWT, non-YAML credential vault used to mint + validate. At 0.5.0 the
|
|
672
679
|
// mint + validation were removed entirely: a pvt_*-prefixed bearer is no
|
|
673
680
|
// longer JWT-shaped (skips authenticateHubJwt) and matches no surviving
|
|
674
681
|
// credential, so it 401s. The hub JWT — the migration target — keeps working.
|
package/src/auth-status.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* What gets exposed:
|
|
10
10
|
* - `initialized` — at least one vault exists
|
|
11
|
-
* - `auth_modes` — accepted bearer formats. As of 0.
|
|
11
|
+
* - `auth_modes` — accepted bearer formats. As of 0.5.0 (vault#282 Stage 2)
|
|
12
12
|
* vault is a pure hub resource-server: the only first-class user
|
|
13
13
|
* credential is a hub-issued JWT, so this is `["hub_jwt"]`. (The
|
|
14
14
|
* server-wide VAULT_AUTH_TOKEN operator bearer + legacy YAML api_keys
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - `vaults` — list of `{ name, url }` for client-side dispatch
|
|
18
18
|
* - `hasOwnerPassword`, `hasTotp` — OAuth consent prerequisites
|
|
19
19
|
* - `hasTokens` — boolean | null. Probes the vestigial `tokens` table for
|
|
20
|
-
* any leftover pre-0.
|
|
20
|
+
* any leftover pre-0.5.0 rows (the table is kept inert as the YAML-import
|
|
21
21
|
* landing zone + a future-cosmetic-drop target). `null` ≈ "we couldn't
|
|
22
22
|
* read all DBs, don't trust this answer"; `true`/`false` are honest yes/no.
|
|
23
23
|
*
|