@openparachute/vault 0.3.1 → 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/README.md +9 -5
- 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 +384 -78
- 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-summary.test.ts +133 -0
- package/src/init-summary.ts +90 -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 +31 -14
- 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
package/src/oauth.ts
CHANGED
|
@@ -46,6 +46,37 @@ export interface AuthorizePostOptions {
|
|
|
46
46
|
// Helpers
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Today the consent page binds one of two scope strings — "read" or "full" —
|
|
51
|
+
* with `read ⊂ full`. `narrowerScope` picks the more-restrictive of two
|
|
52
|
+
* inputs (used to floor `selected` by `requested` at /oauth/authorize),
|
|
53
|
+
* `isScopeSubset` checks an inbound /oauth/token scope against the bound
|
|
54
|
+
* scope. Both default to "full" only if **both** inputs allow "full",
|
|
55
|
+
* otherwise narrow to "read". When the consent vocabulary expands beyond
|
|
56
|
+
* read/full, both helpers should switch to vault:read|write|admin and the
|
|
57
|
+
* inheritance rules in scopes.ts (`hasScope`).
|
|
58
|
+
*/
|
|
59
|
+
function normalizeConsentScope(s: string | null | undefined): "read" | "full" {
|
|
60
|
+
return s === "read" ? "read" : "full";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function narrowerScope(a: string, b: string): "read" | "full" {
|
|
64
|
+
return normalizeConsentScope(a) === "read" || normalizeConsentScope(b) === "read"
|
|
65
|
+
? "read"
|
|
66
|
+
: "full";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isScopeSubset(requested: string, bound: string): boolean {
|
|
70
|
+
// Strict: only "read" / "full" are acceptable on the wire today. Unknown
|
|
71
|
+
// scope strings are rejected as out-of-bounds rather than silently
|
|
72
|
+
// normalized — otherwise `scope=vault:admin` would coast through when
|
|
73
|
+
// bound is "full".
|
|
74
|
+
if (requested !== "read" && requested !== "full") return false;
|
|
75
|
+
const bnd = normalizeConsentScope(bound);
|
|
76
|
+
if (bnd === "full") return requested === "read" || requested === "full";
|
|
77
|
+
return requested === "read";
|
|
78
|
+
}
|
|
79
|
+
|
|
49
80
|
/**
|
|
50
81
|
* Public-facing base URL of the server. Honors `x-forwarded-*` headers so a
|
|
51
82
|
* Cloudflare Tunnel / Tailscale Funnel / reverse-proxied deployment advertises
|
|
@@ -351,7 +382,7 @@ export async function handleAuthorizePost(
|
|
|
351
382
|
const { vaultName, clientIp, ownerPasswordHash, totpSecret, rateLimiter = authorizeRateLimit } = opts;
|
|
352
383
|
const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
|
|
353
384
|
|
|
354
|
-
let form:
|
|
385
|
+
let form: Awaited<ReturnType<typeof req.formData>>;
|
|
355
386
|
try {
|
|
356
387
|
form = await req.formData();
|
|
357
388
|
} catch {
|
|
@@ -363,13 +394,12 @@ export async function handleAuthorizePost(
|
|
|
363
394
|
const redirectUri = form.get("redirect_uri") as string;
|
|
364
395
|
const codeChallenge = form.get("code_challenge") as string;
|
|
365
396
|
const codeChallengeMethod = form.get("code_challenge_method") as string || "S256";
|
|
366
|
-
// Requested scope
|
|
367
|
-
//
|
|
368
|
-
|
|
397
|
+
// Requested scope is carried from the GET via a hidden field on the consent
|
|
398
|
+
// page; the user's radio-button choice arrives in `selected_scope`. The
|
|
399
|
+
// required-ness check runs *after* the deny short-circuit below — a deny
|
|
400
|
+
// POST doesn't mint anything and shouldn't need scope to refuse.
|
|
401
|
+
const requestedScopeRaw = form.get("scope");
|
|
369
402
|
const selectedScopeRaw = form.get("selected_scope") as string | null;
|
|
370
|
-
const selectedScope = selectedScopeRaw === "read" || selectedScopeRaw === "full"
|
|
371
|
-
? selectedScopeRaw
|
|
372
|
-
: (requestedScope === "read" ? "read" : "full");
|
|
373
403
|
const state = form.get("state") as string || "";
|
|
374
404
|
|
|
375
405
|
if (!clientId || !redirectUri || !codeChallenge) {
|
|
@@ -404,6 +434,20 @@ export async function handleAuthorizePost(
|
|
|
404
434
|
return Response.redirect(redirect.toString(), 302);
|
|
405
435
|
}
|
|
406
436
|
|
|
437
|
+
// Past this point we're processing consent — scope must be explicitly
|
|
438
|
+
// present. Defaulting absent scope to "full" would silently cement a
|
|
439
|
+
// grant the user never confirmed (#197).
|
|
440
|
+
if (typeof requestedScopeRaw !== "string" || requestedScopeRaw.length === 0) {
|
|
441
|
+
return Response.json(
|
|
442
|
+
{ error: "invalid_request", error_description: "scope is required" },
|
|
443
|
+
{ status: 400 },
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const requestedScope = requestedScopeRaw;
|
|
447
|
+
const selectedScope = selectedScopeRaw === "read" || selectedScopeRaw === "full"
|
|
448
|
+
? selectedScopeRaw
|
|
449
|
+
: (requestedScope === "read" ? "read" : "full");
|
|
450
|
+
|
|
407
451
|
// Rate-limit the owner-auth step. Applied before any credential check so
|
|
408
452
|
// brute-force attempts are capped regardless of which path (password or
|
|
409
453
|
// legacy token) is being used.
|
|
@@ -483,7 +527,13 @@ export async function handleAuthorizePost(
|
|
|
483
527
|
|
|
484
528
|
if (clientIp) rateLimiter.recordSuccess(clientIp);
|
|
485
529
|
|
|
486
|
-
// Generate auth code —
|
|
530
|
+
// Generate auth code — bind the NARROWER of (requested, selected). The
|
|
531
|
+
// user can shrink the requested scope at consent time (e.g. flip "full"
|
|
532
|
+
// to "read"); they cannot broaden it. Without this floor, a malicious
|
|
533
|
+
// form could smuggle `selected_scope=full` even when /authorize?scope=read
|
|
534
|
+
// was the original ask, escalating beyond what the client requested at
|
|
535
|
+
// authorize time (#94, RFC 6749 §3.3).
|
|
536
|
+
const boundScope = narrowerScope(requestedScope, selectedScope);
|
|
487
537
|
const code = crypto.randomBytes(32).toString("base64url");
|
|
488
538
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); // 10 minutes
|
|
489
539
|
|
|
@@ -492,7 +542,7 @@ export async function handleAuthorizePost(
|
|
|
492
542
|
db.prepare(`
|
|
493
543
|
INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at, vault_name)
|
|
494
544
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
495
|
-
`).run(code, clientId, codeChallenge, codeChallengeMethod,
|
|
545
|
+
`).run(code, clientId, codeChallenge, codeChallengeMethod, boundScope, redirectUri, expiresAt, new Date().toISOString(), vaultName ?? null);
|
|
496
546
|
|
|
497
547
|
redirect.searchParams.set("code", code);
|
|
498
548
|
return Response.redirect(redirect.toString(), 302);
|
|
@@ -607,14 +657,35 @@ export async function handleToken(
|
|
|
607
657
|
return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 });
|
|
608
658
|
}
|
|
609
659
|
|
|
660
|
+
// RFC 6749 §3.3 / §6: a `scope` parameter at /oauth/token, if present,
|
|
661
|
+
// must equal or be a subset of the scope bound to the auth code at
|
|
662
|
+
// /oauth/authorize. Reject expansion attempts as `invalid_scope` rather
|
|
663
|
+
// than silently honoring the bound scope (#94). Absent param → use bound.
|
|
664
|
+
const requestedTokenScopeRaw = params.get("scope");
|
|
665
|
+
let effectiveScope = authCode.scope;
|
|
666
|
+
if (requestedTokenScopeRaw !== null && requestedTokenScopeRaw.trim().length > 0) {
|
|
667
|
+
const requested = requestedTokenScopeRaw.trim();
|
|
668
|
+
if (!isScopeSubset(requested, authCode.scope)) {
|
|
669
|
+
return Response.json(
|
|
670
|
+
{
|
|
671
|
+
error: "invalid_scope",
|
|
672
|
+
error_description:
|
|
673
|
+
"Requested scope exceeds the scope bound at authorization time.",
|
|
674
|
+
},
|
|
675
|
+
{ status: 400 },
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
effectiveScope = requested;
|
|
679
|
+
}
|
|
680
|
+
|
|
610
681
|
// Mark code as used
|
|
611
682
|
db.prepare("UPDATE oauth_codes SET used = 1 WHERE code = ?").run(code);
|
|
612
683
|
|
|
613
|
-
// Translate the
|
|
614
|
-
// column and the OAuth-standard scope list we
|
|
615
|
-
// The consent page only offers read vs full today; full becomes
|
|
616
|
-
// admin-inheriting scope set so hub admin operations keep working.
|
|
617
|
-
const permission: TokenPermission =
|
|
684
|
+
// Translate the (possibly-narrowed) effective scope into both the legacy
|
|
685
|
+
// permission column and the OAuth-standard scope list we persist on the
|
|
686
|
+
// token row. The consent page only offers read vs full today; full becomes
|
|
687
|
+
// the admin-inheriting scope set so hub admin operations keep working.
|
|
688
|
+
const permission: TokenPermission = effectiveScope === "read" ? "read" : "full";
|
|
618
689
|
const scopes = legacyPermissionToScopes(permission);
|
|
619
690
|
const scopeString = serializeScopes(scopes);
|
|
620
691
|
|
package/src/owner-auth.ts
CHANGED
|
@@ -88,6 +88,9 @@ interface RateLimitEntry {
|
|
|
88
88
|
* - Up to MAX_FAILURES failed attempts within WINDOW_MS → lockout
|
|
89
89
|
* - Lockout lasts LOCKOUT_MS
|
|
90
90
|
* - A successful attempt clears the IP's counter
|
|
91
|
+
* - Hard cap on entry count — when full, the oldest insertion is evicted
|
|
92
|
+
* before a new one is recorded. Prevents memory exhaustion via IP /
|
|
93
|
+
* client_id enumeration (#93).
|
|
91
94
|
*/
|
|
92
95
|
export class RateLimiter {
|
|
93
96
|
private entries = new Map<string, RateLimitEntry>();
|
|
@@ -96,6 +99,7 @@ export class RateLimiter {
|
|
|
96
99
|
private readonly maxFailures = 10,
|
|
97
100
|
private readonly windowMs = 60_000,
|
|
98
101
|
private readonly lockoutMs = 15 * 60_000,
|
|
102
|
+
private readonly maxEntries = 10_000,
|
|
99
103
|
) {}
|
|
100
104
|
|
|
101
105
|
/**
|
|
@@ -130,6 +134,7 @@ export class RateLimiter {
|
|
|
130
134
|
const entry = this.entries.get(ip);
|
|
131
135
|
|
|
132
136
|
if (!entry || now - entry.firstFailureAt > this.windowMs) {
|
|
137
|
+
this.evictIfFull();
|
|
133
138
|
this.entries.set(ip, {
|
|
134
139
|
failures: 1,
|
|
135
140
|
firstFailureAt: now,
|
|
@@ -153,7 +158,58 @@ export class RateLimiter {
|
|
|
153
158
|
reset(): void {
|
|
154
159
|
this.entries.clear();
|
|
155
160
|
}
|
|
161
|
+
|
|
162
|
+
/** Current entry count — exposed for tests + observability. */
|
|
163
|
+
size(): number {
|
|
164
|
+
return this.entries.size;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Evict the oldest insertion(s) until size < maxEntries. Map preserves
|
|
169
|
+
* insertion order, so `keys().next().value` is the oldest. We re-insert
|
|
170
|
+
* on window-rollover (delete + new set), so insertion order tracks
|
|
171
|
+
* recency-of-failure closely enough for FIFO eviction.
|
|
172
|
+
*/
|
|
173
|
+
private evictIfFull(): void {
|
|
174
|
+
while (this.entries.size >= this.maxEntries) {
|
|
175
|
+
const oldest = this.entries.keys().next().value;
|
|
176
|
+
if (oldest === undefined) break;
|
|
177
|
+
this.entries.delete(oldest);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
156
180
|
}
|
|
157
181
|
|
|
158
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* Singleton rate limiter — kept for back-compat with callers that don't pass
|
|
184
|
+
* through per-vault routing. Fresh callers should prefer
|
|
185
|
+
* `getAuthorizeRateLimiter(vaultName)` so traffic on one vault's consent flow
|
|
186
|
+
* doesn't lock out IPs on another vault's consent flow (#93).
|
|
187
|
+
*
|
|
188
|
+
* @deprecated Use `getAuthorizeRateLimiter(vaultName)` instead. The singleton
|
|
189
|
+
* cross-pollutes per-vault consent traffic — one vault under brute-force can
|
|
190
|
+
* lock out IPs on every other vault's consent page.
|
|
191
|
+
*/
|
|
159
192
|
export const authorizeRateLimit = new RateLimiter();
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Per-vault rate limiter registry. The vault count is admin-bounded (vaults
|
|
196
|
+
* are created via CLI, not by clients) so this Map can grow only with operator
|
|
197
|
+
* action — no attacker-driven growth path. Each instance carries the
|
|
198
|
+
* default 10,000-entry IP cap, scoped to its vault (#93).
|
|
199
|
+
*/
|
|
200
|
+
const vaultAuthorizeRateLimiters = new Map<string, RateLimiter>();
|
|
201
|
+
|
|
202
|
+
/** Lazily get-or-create the rate limiter for a given vault. */
|
|
203
|
+
export function getAuthorizeRateLimiter(vaultName: string): RateLimiter {
|
|
204
|
+
let limiter = vaultAuthorizeRateLimiters.get(vaultName);
|
|
205
|
+
if (!limiter) {
|
|
206
|
+
limiter = new RateLimiter();
|
|
207
|
+
vaultAuthorizeRateLimiters.set(vaultName, limiter);
|
|
208
|
+
}
|
|
209
|
+
return limiter;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** For tests: drop all per-vault limiters. */
|
|
213
|
+
export function resetVaultAuthorizeRateLimiters(): void {
|
|
214
|
+
vaultAuthorizeRateLimiters.clear();
|
|
215
|
+
}
|
package/src/prompt.ts
CHANGED
|
@@ -74,36 +74,52 @@ export async function askPassword(question: string): Promise<string> {
|
|
|
74
74
|
}
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
+
// Batch visible output per data event. On Bun 1.2.x, per-char writes
|
|
78
|
+
// can appear in bursts (keystrokes echoing late or out of order);
|
|
79
|
+
// coalescing to a single write per data event keeps the visible
|
|
80
|
+
// stream in lock-step with the captured input.
|
|
77
81
|
const onData = (data: string) => {
|
|
78
82
|
try {
|
|
83
|
+
let toWrite = "";
|
|
84
|
+
let done = false;
|
|
85
|
+
let aborted = false;
|
|
79
86
|
for (const ch of data) {
|
|
80
87
|
// Enter — done
|
|
81
88
|
if (ch === "\r" || ch === "\n") {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
resolve(buf);
|
|
85
|
-
return;
|
|
89
|
+
done = true;
|
|
90
|
+
break;
|
|
86
91
|
}
|
|
87
92
|
// Ctrl-C — abort
|
|
88
93
|
if (ch === "\u0003") {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
process.exit(130);
|
|
94
|
+
aborted = true;
|
|
95
|
+
break;
|
|
92
96
|
}
|
|
93
97
|
// Backspace / DEL
|
|
94
98
|
if (ch === "\u0008" || ch === "\u007f") {
|
|
95
99
|
if (buf.length > 0) {
|
|
96
100
|
buf = buf.slice(0, -1);
|
|
97
|
-
|
|
101
|
+
toWrite += "\b \b";
|
|
98
102
|
}
|
|
99
103
|
continue;
|
|
100
104
|
}
|
|
101
105
|
// Printable
|
|
102
106
|
if (ch >= " ") {
|
|
103
107
|
buf += ch;
|
|
104
|
-
|
|
108
|
+
toWrite += "*";
|
|
105
109
|
}
|
|
106
110
|
}
|
|
111
|
+
if (toWrite) process.stdout.write(toWrite);
|
|
112
|
+
if (done) {
|
|
113
|
+
process.stdout.write("\n");
|
|
114
|
+
cleanup();
|
|
115
|
+
resolve(buf);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (aborted) {
|
|
119
|
+
process.stdout.write("\n");
|
|
120
|
+
cleanup();
|
|
121
|
+
process.exit(130);
|
|
122
|
+
}
|
|
107
123
|
} catch (err) {
|
|
108
124
|
cleanup();
|
|
109
125
|
reject(err);
|
|
@@ -125,17 +141,18 @@ export async function askPassword(question: string): Promise<string> {
|
|
|
125
141
|
export async function choose(question: string, options: { label: string; value: string; description?: string }[]): Promise<string> {
|
|
126
142
|
console.log(question);
|
|
127
143
|
for (let i = 0; i < options.length; i++) {
|
|
128
|
-
const
|
|
129
|
-
|
|
144
|
+
const opt = options[i]!;
|
|
145
|
+
const desc = opt.description ? ` — ${opt.description}` : "";
|
|
146
|
+
console.log(` ${i + 1}) ${opt.label}${desc}`);
|
|
130
147
|
}
|
|
131
148
|
process.stdout.write(` Choice [1]: `);
|
|
132
149
|
|
|
133
150
|
for await (const line of console) {
|
|
134
151
|
const answer = line.trim();
|
|
135
|
-
if (answer === "") return options[0]
|
|
152
|
+
if (answer === "") return options[0]!.value;
|
|
136
153
|
const idx = parseInt(answer, 10) - 1;
|
|
137
|
-
if (idx >= 0 && idx < options.length) return options[idx]
|
|
154
|
+
if (idx >= 0 && idx < options.length) return options[idx]!.value;
|
|
138
155
|
process.stdout.write(` Please enter 1-${options.length}: `);
|
|
139
156
|
}
|
|
140
|
-
return options[0]
|
|
157
|
+
return options[0]!.value;
|
|
141
158
|
}
|