@openparachute/vault 0.3.3 → 0.4.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 +15 -0
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST handlers for `/vault/<name>/tokens`.
|
|
3
|
+
*
|
|
4
|
+
* The endpoint is admin-gated upstream — `route()` checks
|
|
5
|
+
* `hasScopeForVault(auth.scopes, vaultName, "admin")` before dispatching here,
|
|
6
|
+
* so any caller reaching this module already holds vault:admin (broad or
|
|
7
|
+
* narrowed). POST mints a new pvt_* token with caller-narrowed scopes and
|
|
8
|
+
* returns the plaintext exactly once. GET lists existing tokens (metadata
|
|
9
|
+
* only — no plaintext, no hash). DELETE revokes by display id (`t_…`); a
|
|
10
|
+
* non-existent id returns 404; an id that resolves to a different
|
|
11
|
+
* vault's binding returns **403** (per #257 spec — silent 200 on
|
|
12
|
+
* cross-vault revoke would let an operator believe a token was
|
|
13
|
+
* revoked while it kept working from its owning vault).
|
|
14
|
+
*
|
|
15
|
+
* Scope narrowing is enforced as a strict subset check via
|
|
16
|
+
* `validateMintedScopes` — defense-in-depth even with the admin gate, so a
|
|
17
|
+
* future relaxation of the gate cannot accidentally permit privilege
|
|
18
|
+
* escalation. Cross-vault scopes (`vault:<other>:<verb>`) are rejected with
|
|
19
|
+
* the same path.
|
|
20
|
+
*
|
|
21
|
+
* Tag-allowlist narrowing (per patterns/tag-scoped-tokens.md) follows the
|
|
22
|
+
* same defense-in-depth shape: the requested allowlist must be a subset of
|
|
23
|
+
* the minter's. A `null` minter allowlist is the universe — any allowlist
|
|
24
|
+
* may be granted. Each tag must be an existing root-tag name (no `/`) —
|
|
25
|
+
* sub-tags are reached via the `_tags/<name>` hierarchy at enforcement time,
|
|
26
|
+
* not at mint time.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { Database } from "bun:sqlite";
|
|
30
|
+
import type { SqliteStore } from "../core/src/store.ts";
|
|
31
|
+
import {
|
|
32
|
+
generateToken,
|
|
33
|
+
createToken,
|
|
34
|
+
normalizePermission,
|
|
35
|
+
type TokenPermission,
|
|
36
|
+
} from "./token-store.ts";
|
|
37
|
+
import {
|
|
38
|
+
validateMintedScopes,
|
|
39
|
+
parseScopes,
|
|
40
|
+
hasScope,
|
|
41
|
+
SCOPE_WRITE,
|
|
42
|
+
SCOPE_ADMIN,
|
|
43
|
+
} from "./scopes.ts";
|
|
44
|
+
|
|
45
|
+
interface MintRequestBody {
|
|
46
|
+
label?: string;
|
|
47
|
+
/** Either an array (preferred) or a space-separated OAuth-style string. */
|
|
48
|
+
scopes?: string[];
|
|
49
|
+
scope?: string;
|
|
50
|
+
/** ISO-8601 future timestamp, or null/omitted for never-expiring. */
|
|
51
|
+
expires_at?: string | null;
|
|
52
|
+
/**
|
|
53
|
+
* Optional tag-allowlist. Each entry must be an existing root-tag name
|
|
54
|
+
* (no `/`). When omitted or null, the token is unscoped (current behavior).
|
|
55
|
+
* The minted allowlist must be a subset of the caller's allowlist.
|
|
56
|
+
*/
|
|
57
|
+
tags?: string[] | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function badRequest(message: string, extra?: Record<string, unknown>): Response {
|
|
61
|
+
return Response.json({ error: "Bad Request", message, ...extra }, { status: 400 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function methodNotAllowed(): Response {
|
|
65
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function permissionForScopes(scopes: string[]): TokenPermission {
|
|
69
|
+
return hasScope(scopes, SCOPE_WRITE) || hasScope(scopes, SCOPE_ADMIN) ? "full" : "read";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function handleTokens(
|
|
73
|
+
req: Request,
|
|
74
|
+
store: SqliteStore,
|
|
75
|
+
vaultName: string,
|
|
76
|
+
callerScopes: string[],
|
|
77
|
+
callerScopedTags: string[] | null,
|
|
78
|
+
subpath: string,
|
|
79
|
+
): Promise<Response> {
|
|
80
|
+
if (subpath === "" || subpath === "/") {
|
|
81
|
+
if (req.method === "GET") return listHandler(store.db, vaultName);
|
|
82
|
+
if (req.method === "POST") return mintHandler(req, store, vaultName, callerScopes, callerScopedTags);
|
|
83
|
+
return methodNotAllowed();
|
|
84
|
+
}
|
|
85
|
+
const idMatch = subpath.match(/^\/([^/]+)$/);
|
|
86
|
+
if (idMatch && idMatch[1]) {
|
|
87
|
+
if (req.method === "DELETE") return revokeHandler(store.db, vaultName, idMatch[1]);
|
|
88
|
+
return methodNotAllowed();
|
|
89
|
+
}
|
|
90
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function mintHandler(
|
|
94
|
+
req: Request,
|
|
95
|
+
store: SqliteStore,
|
|
96
|
+
vaultName: string,
|
|
97
|
+
callerScopes: string[],
|
|
98
|
+
callerScopedTags: string[] | null,
|
|
99
|
+
): Promise<Response> {
|
|
100
|
+
let body: MintRequestBody;
|
|
101
|
+
try {
|
|
102
|
+
const raw = await req.text();
|
|
103
|
+
body = raw.length === 0 ? {} : (JSON.parse(raw) as MintRequestBody);
|
|
104
|
+
} catch {
|
|
105
|
+
return badRequest("invalid JSON body");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let requested: string[];
|
|
109
|
+
if (Array.isArray(body.scopes)) {
|
|
110
|
+
requested = body.scopes.filter((s): s is string => typeof s === "string" && s.length > 0);
|
|
111
|
+
} else if (typeof body.scope === "string") {
|
|
112
|
+
requested = parseScopes(body.scope);
|
|
113
|
+
} else {
|
|
114
|
+
// Default: full scope set. Admin caller is required to reach this code,
|
|
115
|
+
// so granting full scope is within their power; explicit narrowing is
|
|
116
|
+
// available via `scopes` / `scope` for least-privilege deployments.
|
|
117
|
+
requested = ["vault:read", "vault:write", "vault:admin"];
|
|
118
|
+
}
|
|
119
|
+
if (requested.length === 0) {
|
|
120
|
+
return badRequest("at least one scope required");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const validation = validateMintedScopes(requested, vaultName, callerScopes);
|
|
124
|
+
if (!validation.ok) {
|
|
125
|
+
return Response.json(
|
|
126
|
+
{ error: "Bad Request", message: "scope rejected", rejected: validation.rejected },
|
|
127
|
+
{ status: 400 },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Tag-allowlist narrowing. `tags === undefined` or `null` means unscoped
|
|
132
|
+
// (no filtering). When provided, every entry must (a) be a non-empty
|
|
133
|
+
// string, (b) contain no `/` (root tags only — sub-tags reach via the
|
|
134
|
+
// `_tags/<name>` hierarchy at enforcement time), (c) exist in the vault's
|
|
135
|
+
// tag list, and (d) fall within the caller's own allowlist when the caller
|
|
136
|
+
// is themselves tag-scoped (null caller scope = universe = anything OK).
|
|
137
|
+
//
|
|
138
|
+
// Privilege-escalation guard: a tag-scoped minter must NOT be able to
|
|
139
|
+
// produce a `null`-allowlist token (the universe is broader than any
|
|
140
|
+
// finite allowlist). We reject the omission with a clear 403 rather than
|
|
141
|
+
// silently inheriting — explicit > implicit at a security boundary.
|
|
142
|
+
let scopedTags: string[] | null = null;
|
|
143
|
+
if ((body.tags === undefined || body.tags === null) && callerScopedTags !== null) {
|
|
144
|
+
return Response.json(
|
|
145
|
+
{
|
|
146
|
+
error: "Forbidden",
|
|
147
|
+
error_type: "tag_scope_violation",
|
|
148
|
+
message: `minter is tag-scoped (${callerScopedTags.join(", ")}) — request must include an explicit "tags" array within that allowlist; an unscoped token cannot be minted from a scoped one`,
|
|
149
|
+
minter_scoped_tags: callerScopedTags,
|
|
150
|
+
},
|
|
151
|
+
{ status: 403 },
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (body.tags !== undefined && body.tags !== null) {
|
|
155
|
+
if (!Array.isArray(body.tags)) {
|
|
156
|
+
return badRequest("tags must be an array of strings or null");
|
|
157
|
+
}
|
|
158
|
+
if (body.tags.length === 0) {
|
|
159
|
+
return badRequest("tags must be a non-empty array (omit the field, or pass null, for an unscoped token)");
|
|
160
|
+
}
|
|
161
|
+
const cleaned: string[] = [];
|
|
162
|
+
for (const t of body.tags) {
|
|
163
|
+
if (typeof t !== "string" || t.length === 0) {
|
|
164
|
+
return badRequest("each tag must be a non-empty string");
|
|
165
|
+
}
|
|
166
|
+
if (t.includes("/")) {
|
|
167
|
+
return badRequest(
|
|
168
|
+
`tag "${t}" must be a root-tag name (no path separators). Sub-tags inherit via the _tags/<name> hierarchy at enforcement time.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
cleaned.push(t);
|
|
172
|
+
}
|
|
173
|
+
// Dedupe while preserving caller-supplied order.
|
|
174
|
+
const seen = new Set<string>();
|
|
175
|
+
const deduped: string[] = [];
|
|
176
|
+
for (const t of cleaned) {
|
|
177
|
+
if (!seen.has(t)) {
|
|
178
|
+
seen.add(t);
|
|
179
|
+
deduped.push(t);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Existence check against the vault's tag list. The pattern doc names
|
|
183
|
+
// list-tags as the source of truth — keeps the CLI/SPA picker and the
|
|
184
|
+
// server in agreement.
|
|
185
|
+
const known = new Set((await store.listTags()).map((t) => t.name));
|
|
186
|
+
const unknown = deduped.filter((t) => !known.has(t));
|
|
187
|
+
if (unknown.length > 0) {
|
|
188
|
+
return badRequest(
|
|
189
|
+
`unknown tag(s): ${unknown.join(", ")} — must be existing root-tag names in vault '${vaultName}'`,
|
|
190
|
+
{ unknown_tags: unknown },
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
// Subset rule: if the caller is tag-scoped, every requested tag must be
|
|
194
|
+
// in the caller's allowlist. Null caller = universe.
|
|
195
|
+
if (callerScopedTags !== null) {
|
|
196
|
+
const callerSet = new Set(callerScopedTags);
|
|
197
|
+
const escalating = deduped.filter((t) => !callerSet.has(t));
|
|
198
|
+
if (escalating.length > 0) {
|
|
199
|
+
return Response.json(
|
|
200
|
+
{
|
|
201
|
+
error: "Forbidden",
|
|
202
|
+
error_type: "tag_scope_violation",
|
|
203
|
+
message: `cannot mint a token with tag(s) outside the minter's allowlist: ${escalating.join(", ")}`,
|
|
204
|
+
rejected_tags: escalating,
|
|
205
|
+
minter_scoped_tags: callerScopedTags,
|
|
206
|
+
},
|
|
207
|
+
{ status: 403 },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
scopedTags = deduped;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let expiresAt: string | null = null;
|
|
215
|
+
if (body.expires_at !== undefined && body.expires_at !== null) {
|
|
216
|
+
if (typeof body.expires_at !== "string") {
|
|
217
|
+
return badRequest("expires_at must be an ISO-8601 string or null");
|
|
218
|
+
}
|
|
219
|
+
const t = Date.parse(body.expires_at);
|
|
220
|
+
if (Number.isNaN(t)) {
|
|
221
|
+
return badRequest("expires_at is not a valid ISO-8601 timestamp");
|
|
222
|
+
}
|
|
223
|
+
if (t <= Date.now()) {
|
|
224
|
+
return badRequest("expires_at must be in the future");
|
|
225
|
+
}
|
|
226
|
+
expiresAt = new Date(t).toISOString();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const label = typeof body.label === "string" && body.label.length > 0 ? body.label : "API token";
|
|
230
|
+
const permission = permissionForScopes(requested);
|
|
231
|
+
|
|
232
|
+
const { fullToken } = generateToken();
|
|
233
|
+
const created = createToken(store.db, fullToken, {
|
|
234
|
+
label,
|
|
235
|
+
permission,
|
|
236
|
+
scopes: requested,
|
|
237
|
+
scoped_tags: scopedTags,
|
|
238
|
+
expires_at: expiresAt,
|
|
239
|
+
// Per-vault binding (v16): tokens minted via /vault/<name>/tokens are
|
|
240
|
+
// pinned to that vault. authenticateVaultRequest rejects them at any
|
|
241
|
+
// other vault. NULL would be legacy / server-wide; reserved for the
|
|
242
|
+
// YAML-import path and explicit --all CLI mints (see vault#257).
|
|
243
|
+
vault_name: vaultName,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Display id mirrors `listTokens`: `t_` + first 12 chars of the SHA-256
|
|
247
|
+
// hash payload (the part after the `sha256:` prefix). Stable across reads.
|
|
248
|
+
const id = `t_${created.token_hash.slice(7, 19)}`;
|
|
249
|
+
|
|
250
|
+
return Response.json(
|
|
251
|
+
{
|
|
252
|
+
id,
|
|
253
|
+
token: fullToken,
|
|
254
|
+
label: created.label,
|
|
255
|
+
permission: created.permission,
|
|
256
|
+
scopes: requested,
|
|
257
|
+
scoped_tags: scopedTags,
|
|
258
|
+
vault_name: created.vault_name,
|
|
259
|
+
expires_at: created.expires_at,
|
|
260
|
+
created_at: created.created_at,
|
|
261
|
+
},
|
|
262
|
+
{ status: 201 },
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function listHandler(db: Database, vaultName: string): Response {
|
|
267
|
+
// Direct SELECT (rather than reusing `listTokens`) so we can include the
|
|
268
|
+
// `scopes` and `scoped_tags` columns without changing the existing
|
|
269
|
+
// CLI-facing shape that goes through `listTokens`.
|
|
270
|
+
//
|
|
271
|
+
// Per-vault filter (v16): rows where vault_name matches THIS vault, plus
|
|
272
|
+
// legacy server-wide rows (vault_name IS NULL). The latter authenticate
|
|
273
|
+
// cross-vault by design; the operator should see them in any vault's
|
|
274
|
+
// admin UI to revoke. Tokens bound to a different vault never appear
|
|
275
|
+
// here — this is the SPA-side fix for vault#257.
|
|
276
|
+
const rows = db.prepare(`
|
|
277
|
+
SELECT token_hash, label, permission, scopes, scoped_tags, vault_name, expires_at, created_at, last_used_at
|
|
278
|
+
FROM tokens
|
|
279
|
+
WHERE vault_name = ? OR vault_name IS NULL
|
|
280
|
+
ORDER BY created_at DESC
|
|
281
|
+
`).all(vaultName) as {
|
|
282
|
+
token_hash: string;
|
|
283
|
+
label: string;
|
|
284
|
+
permission: string;
|
|
285
|
+
scopes: string | null;
|
|
286
|
+
scoped_tags: string | null;
|
|
287
|
+
vault_name: string | null;
|
|
288
|
+
expires_at: string | null;
|
|
289
|
+
created_at: string;
|
|
290
|
+
last_used_at: string | null;
|
|
291
|
+
}[];
|
|
292
|
+
|
|
293
|
+
return Response.json({
|
|
294
|
+
tokens: rows.map((r) => ({
|
|
295
|
+
id: `t_${r.token_hash.slice(7, 19)}`,
|
|
296
|
+
label: r.label,
|
|
297
|
+
permission: normalizePermission(r.permission),
|
|
298
|
+
scopes: parseScopes(r.scopes),
|
|
299
|
+
scoped_tags: parseScopedTagsJSON(r.scoped_tags),
|
|
300
|
+
vault_name: r.vault_name,
|
|
301
|
+
expires_at: r.expires_at,
|
|
302
|
+
created_at: r.created_at,
|
|
303
|
+
last_used_at: r.last_used_at,
|
|
304
|
+
})),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Defensive parser for the `tokens.scoped_tags` JSON column. Mirrors the
|
|
310
|
+
* shape used by `token-store.ts#parseScopedTags`: collapse anything that
|
|
311
|
+
* isn't a non-empty array of strings to `null` (the unscoped sentinel) so a
|
|
312
|
+
* corrupt row can't masquerade as a scoped token in the listing.
|
|
313
|
+
*/
|
|
314
|
+
function parseScopedTagsJSON(raw: string | null): string[] | null {
|
|
315
|
+
if (!raw) return null;
|
|
316
|
+
try {
|
|
317
|
+
const parsed = JSON.parse(raw);
|
|
318
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
319
|
+
const cleaned = parsed.filter((s): s is string => typeof s === "string" && s.length > 0);
|
|
320
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function revokeHandler(db: Database, vaultName: string, id: string): Response {
|
|
327
|
+
// Per-vault scope (v16, per #257 spec): pre-check the row's vault binding
|
|
328
|
+
// before deleting so we can distinguish three cases:
|
|
329
|
+
//
|
|
330
|
+
// - row missing entirely → 404 (the id never existed in this vault's DB)
|
|
331
|
+
// - row exists, vault_name = <other> → 403 with descriptive error
|
|
332
|
+
// - row exists, vault_name = <this> OR NULL → DELETE + 200
|
|
333
|
+
//
|
|
334
|
+
// The non-leakage argument for "always 200" doesn't hold given that GET
|
|
335
|
+
// /vault/<name>/tokens already exposes the full vault-scoped + legacy-NULL
|
|
336
|
+
// listing — so existence isn't being protected. 403 over silent 200 is the
|
|
337
|
+
// operator-debuggable shape: clicking "revoke" and seeing success while the
|
|
338
|
+
// token still works (because it's bound to a different vault) is the worst
|
|
339
|
+
// UX. Tell the operator which vault owns it.
|
|
340
|
+
//
|
|
341
|
+
// Ambiguous prefix matches: the pre-check uses the same prefix shape as the
|
|
342
|
+
// DELETE, so a prefix that hits multiple rows is treated as "found" if any
|
|
343
|
+
// row matches the calling vault. The 12-char hex prefix collision space is
|
|
344
|
+
// large enough that organic ambiguity is effectively impossible, and
|
|
345
|
+
// security-significant ambiguity would require a chosen-prefix attack
|
|
346
|
+
// against SHA-256.
|
|
347
|
+
let row: { vault_name: string | null } | null;
|
|
348
|
+
if (id.startsWith("t_")) {
|
|
349
|
+
const hashPrefix = id.slice(2);
|
|
350
|
+
row = db.prepare(`
|
|
351
|
+
SELECT vault_name FROM tokens
|
|
352
|
+
WHERE token_hash LIKE ?
|
|
353
|
+
LIMIT 1
|
|
354
|
+
`).get(`sha256:${hashPrefix}%`) as { vault_name: string | null } | null;
|
|
355
|
+
} else {
|
|
356
|
+
row = db.prepare(`
|
|
357
|
+
SELECT vault_name FROM tokens
|
|
358
|
+
WHERE token_hash = ?
|
|
359
|
+
LIMIT 1
|
|
360
|
+
`).get(id) as { vault_name: string | null } | null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!row) {
|
|
364
|
+
return Response.json({ error: "Not found", message: "no token with that id" }, { status: 404 });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (row.vault_name !== null && row.vault_name !== vaultName) {
|
|
368
|
+
return Response.json(
|
|
369
|
+
{
|
|
370
|
+
error: "Forbidden",
|
|
371
|
+
message: `token belongs to vault '${row.vault_name}', not '${vaultName}'; revoke it from that vault's admin surface`,
|
|
372
|
+
},
|
|
373
|
+
{ status: 403 },
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (id.startsWith("t_")) {
|
|
378
|
+
const hashPrefix = id.slice(2);
|
|
379
|
+
db.prepare(`
|
|
380
|
+
DELETE FROM tokens
|
|
381
|
+
WHERE token_hash LIKE ?
|
|
382
|
+
AND (vault_name = ? OR vault_name IS NULL)
|
|
383
|
+
`).run(`sha256:${hashPrefix}%`, vaultName);
|
|
384
|
+
} else {
|
|
385
|
+
db.prepare(`
|
|
386
|
+
DELETE FROM tokens
|
|
387
|
+
WHERE token_hash = ?
|
|
388
|
+
AND (vault_name = ? OR vault_name IS NULL)
|
|
389
|
+
`).run(id, vaultName);
|
|
390
|
+
}
|
|
391
|
+
return Response.json({ revoked: true });
|
|
392
|
+
}
|
|
@@ -126,8 +126,13 @@ describe("transcription worker", () => {
|
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
test("no placeholder: replaces full body when stub is set", async () => {
|
|
129
|
+
// Voice memos have a path in production, so the empty-content path is
|
|
130
|
+
// legitimate (paired with the stub marker, the worker fills the body
|
|
131
|
+
// when the transcript lands). The Store's empty-note invariant (#213)
|
|
132
|
+
// requires content OR path; this is the path-only case.
|
|
129
133
|
await store.createNote("", {
|
|
130
134
|
id: "n3",
|
|
135
|
+
path: "memos/n3",
|
|
131
136
|
metadata: { transcribe_stub: true },
|
|
132
137
|
});
|
|
133
138
|
seedAudio("memos/c.webm");
|
package/src/triggers.ts
CHANGED
|
@@ -132,7 +132,7 @@ function saveAudioToAssets(
|
|
|
132
132
|
: contentType.includes("mp4") ? ".m4a"
|
|
133
133
|
: ".ogg"; // default to ogg
|
|
134
134
|
|
|
135
|
-
const date = new Date().toISOString().split("T")[0]
|
|
135
|
+
const date = new Date().toISOString().split("T")[0]!;
|
|
136
136
|
const dir = join(assetsRoot, date);
|
|
137
137
|
mkdirSync(dir, { recursive: true });
|
|
138
138
|
|
package/src/two-factor.ts
CHANGED
|
@@ -148,7 +148,7 @@ function randomBackupCode(): string {
|
|
|
148
148
|
const bytes = crypto.getRandomValues(new Uint8Array(BACKUP_CODE_LENGTH));
|
|
149
149
|
let out = "";
|
|
150
150
|
for (let i = 0; i < BACKUP_CODE_LENGTH; i++) {
|
|
151
|
-
out += alphabet[bytes[i] % alphabet.length];
|
|
151
|
+
out += alphabet[bytes[i]! % alphabet.length];
|
|
152
152
|
}
|
|
153
153
|
return out;
|
|
154
154
|
}
|
|
@@ -208,7 +208,7 @@ async function doVerifyAndConsume(normalized: string): Promise<boolean> {
|
|
|
208
208
|
|
|
209
209
|
for (let i = 0; i < hashes.length; i++) {
|
|
210
210
|
try {
|
|
211
|
-
if (await Bun.password.verify(normalized, hashes[i])) {
|
|
211
|
+
if (await Bun.password.verify(normalized, hashes[i]!)) {
|
|
212
212
|
// Consume: splice from the snapshot we verified against and persist.
|
|
213
213
|
config.backup_codes = hashes.filter((_, j) => j !== i);
|
|
214
214
|
writeGlobalConfig(config);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for `parachute-vault create <name> [--json]`.
|
|
3
|
+
*
|
|
4
|
+
* The `--json` mode is the contract the hub orchestrator parses: stdout
|
|
5
|
+
* carries a single JSON object with name/token/paths/set_as_default. These
|
|
6
|
+
* tests spawn the CLI in a temp `PARACHUTE_HOME` so the create lands on a
|
|
7
|
+
* fresh, isolated vault tree and we can assert the on-disk artifacts the
|
|
8
|
+
* payload claims to have written.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
12
|
+
import { resolve } from "path";
|
|
13
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
|
|
17
|
+
const CLI = resolve(import.meta.dir, "cli.ts");
|
|
18
|
+
|
|
19
|
+
function runCli(
|
|
20
|
+
args: string[],
|
|
21
|
+
env: Record<string, string>,
|
|
22
|
+
): { exitCode: number; stdout: string; stderr: string } {
|
|
23
|
+
const proc = Bun.spawnSync({
|
|
24
|
+
cmd: ["bun", CLI, ...args],
|
|
25
|
+
stdout: "pipe",
|
|
26
|
+
stderr: "pipe",
|
|
27
|
+
env: { ...process.env, ...env },
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
exitCode: proc.exitCode ?? -1,
|
|
31
|
+
stdout: new TextDecoder().decode(proc.stdout),
|
|
32
|
+
stderr: new TextDecoder().decode(proc.stderr),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let home: string;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
home = mkdtempSync(join(tmpdir(), "vault-create-test-"));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
rmSync(home, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("vault create --json", () => {
|
|
47
|
+
test("emits parseable JSON with name, token, paths, set_as_default=true on first vault", () => {
|
|
48
|
+
const { exitCode, stdout, stderr } = runCli(
|
|
49
|
+
["create", "myvault", "--json"],
|
|
50
|
+
{ PARACHUTE_HOME: home },
|
|
51
|
+
);
|
|
52
|
+
expect(exitCode).toBe(0);
|
|
53
|
+
expect(stderr).toBe("");
|
|
54
|
+
|
|
55
|
+
// stdout must be exactly one JSON object — single-line, parseable.
|
|
56
|
+
// Asserting line count first so a regression that prints a banner above
|
|
57
|
+
// the JSON fails with "expected 1 line, got 2" rather than the much
|
|
58
|
+
// less actionable "JSON parse error".
|
|
59
|
+
const lines = stdout.trim().split("\n");
|
|
60
|
+
expect(lines).toHaveLength(1);
|
|
61
|
+
const payload = JSON.parse(lines[0]!);
|
|
62
|
+
expect(payload.name).toBe("myvault");
|
|
63
|
+
expect(payload.token).toMatch(/^pvt_/);
|
|
64
|
+
expect(payload.set_as_default).toBe(true);
|
|
65
|
+
expect(payload.paths.vault_dir).toBe(join(home, "vault", "data", "myvault"));
|
|
66
|
+
expect(payload.paths.vault_db).toBe(join(home, "vault", "data", "myvault", "vault.db"));
|
|
67
|
+
expect(payload.paths.vault_config).toBe(join(home, "vault", "data", "myvault", "vault.yaml"));
|
|
68
|
+
|
|
69
|
+
// Sanity: the on-disk artifacts the payload describes actually exist.
|
|
70
|
+
expect(existsSync(payload.paths.vault_dir)).toBe(true);
|
|
71
|
+
expect(existsSync(payload.paths.vault_db)).toBe(true);
|
|
72
|
+
expect(existsSync(payload.paths.vault_config)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("set_as_default=false when another vault already holds the default slot", () => {
|
|
76
|
+
runCli(["create", "first", "--json"], { PARACHUTE_HOME: home });
|
|
77
|
+
const { exitCode, stdout } = runCli(
|
|
78
|
+
["create", "second", "--json"],
|
|
79
|
+
{ PARACHUTE_HOME: home },
|
|
80
|
+
);
|
|
81
|
+
expect(exitCode).toBe(0);
|
|
82
|
+
const payload = JSON.parse(stdout.trim());
|
|
83
|
+
expect(payload.name).toBe("second");
|
|
84
|
+
expect(payload.set_as_default).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("--json works regardless of flag position (before or after name)", () => {
|
|
88
|
+
const { exitCode, stdout } = runCli(
|
|
89
|
+
["create", "--json", "before"],
|
|
90
|
+
{ PARACHUTE_HOME: home },
|
|
91
|
+
);
|
|
92
|
+
expect(exitCode).toBe(0);
|
|
93
|
+
const payload = JSON.parse(stdout.trim());
|
|
94
|
+
expect(payload.name).toBe("before");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("invalid name in --json mode still errors on stderr (not stdout) and exits non-zero", () => {
|
|
98
|
+
const { exitCode, stdout, stderr } = runCli(
|
|
99
|
+
["create", "Bad Name", "--json"],
|
|
100
|
+
{ PARACHUTE_HOME: home },
|
|
101
|
+
);
|
|
102
|
+
expect(exitCode).not.toBe(0);
|
|
103
|
+
expect(stdout).toBe("");
|
|
104
|
+
expect(stderr).toContain("letters, numbers");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("duplicate name in --json mode errors on stderr and exits non-zero", () => {
|
|
108
|
+
runCli(["create", "dup", "--json"], { PARACHUTE_HOME: home });
|
|
109
|
+
const { exitCode, stdout, stderr } = runCli(
|
|
110
|
+
["create", "dup", "--json"],
|
|
111
|
+
{ PARACHUTE_HOME: home },
|
|
112
|
+
);
|
|
113
|
+
expect(exitCode).not.toBe(0);
|
|
114
|
+
expect(stdout).toBe("");
|
|
115
|
+
expect(stderr).toContain("already exists");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Regression tests for #208: `vault create` was not updating
|
|
121
|
+
* `~/.parachute/services.json`, so vaults created after init were invisible
|
|
122
|
+
* to the hub well-known endpoint and to paraclaw's attach picker. cmdCreate
|
|
123
|
+
* now re-registers the parachute-vault entry with the full vault list every
|
|
124
|
+
* time. The default vault must sort first because the hub uses `paths[0]`
|
|
125
|
+
* as the canonical mount for `.well-known/parachute.json`.
|
|
126
|
+
*/
|
|
127
|
+
describe("vault create — services.json registration (#208)", () => {
|
|
128
|
+
function readServices(): { name: string; paths: string[]; port: number }[] {
|
|
129
|
+
const raw = readFileSync(join(home, "services.json"), "utf-8");
|
|
130
|
+
return JSON.parse(raw).services;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test("create-first-vault registers parachute-vault entry with /vault/<name>", () => {
|
|
134
|
+
const { exitCode } = runCli(["create", "first", "--json"], {
|
|
135
|
+
PARACHUTE_HOME: home,
|
|
136
|
+
});
|
|
137
|
+
expect(exitCode).toBe(0);
|
|
138
|
+
|
|
139
|
+
const services = readServices();
|
|
140
|
+
const vault = services.find((s) => s.name === "parachute-vault");
|
|
141
|
+
expect(vault).toBeDefined();
|
|
142
|
+
expect(vault!.paths).toEqual(["/vault/first"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("create-additional-vault grows the paths array (default stays first)", () => {
|
|
146
|
+
runCli(["create", "alpha", "--json"], { PARACHUTE_HOME: home });
|
|
147
|
+
runCli(["create", "beta", "--json"], { PARACHUTE_HOME: home });
|
|
148
|
+
|
|
149
|
+
const vault = readServices().find((s) => s.name === "parachute-vault");
|
|
150
|
+
expect(vault).toBeDefined();
|
|
151
|
+
// alpha is the default (created first), so it must lead. beta follows.
|
|
152
|
+
expect(vault!.paths).toEqual(["/vault/alpha", "/vault/beta"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("create-multiple preserves default-first ordering across N vaults", () => {
|
|
156
|
+
runCli(["create", "one", "--json"], { PARACHUTE_HOME: home });
|
|
157
|
+
runCli(["create", "two", "--json"], { PARACHUTE_HOME: home });
|
|
158
|
+
runCli(["create", "three", "--json"], { PARACHUTE_HOME: home });
|
|
159
|
+
|
|
160
|
+
const vault = readServices().find((s) => s.name === "parachute-vault");
|
|
161
|
+
expect(vault).toBeDefined();
|
|
162
|
+
expect(vault!.paths[0]).toBe("/vault/one");
|
|
163
|
+
expect(vault!.paths.slice(1).sort()).toEqual([
|
|
164
|
+
"/vault/three",
|
|
165
|
+
"/vault/two",
|
|
166
|
+
]);
|
|
167
|
+
expect(vault!.paths).toHaveLength(3);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("--json mode keeps stdout parseable even when services.json is updated", () => {
|
|
171
|
+
// Regression guard: warnings from upsertService must go to stderr, not
|
|
172
|
+
// stdout — otherwise the hub orchestrator's JSON.parse(stdout) breaks.
|
|
173
|
+
const { stdout } = runCli(["create", "clean", "--json"], {
|
|
174
|
+
PARACHUTE_HOME: home,
|
|
175
|
+
});
|
|
176
|
+
expect(() => JSON.parse(stdout.trim())).not.toThrow();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("vault create (human mode unchanged)", () => {
|
|
181
|
+
test("prints multi-line human output without --json", () => {
|
|
182
|
+
const { exitCode, stdout } = runCli(
|
|
183
|
+
["create", "human"],
|
|
184
|
+
{ PARACHUTE_HOME: home },
|
|
185
|
+
);
|
|
186
|
+
expect(exitCode).toBe(0);
|
|
187
|
+
expect(stdout).toContain('Vault "human" created.');
|
|
188
|
+
expect(stdout).toContain("API token:");
|
|
189
|
+
expect(stdout).toContain("Save this");
|
|
190
|
+
// Human output should NOT be valid JSON.
|
|
191
|
+
expect(() => JSON.parse(stdout.trim())).toThrow();
|
|
192
|
+
});
|
|
193
|
+
});
|