@openparachute/hub 0.7.6 → 0.7.7-rc.4
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/package.json +6 -3
- package/src/__tests__/account-api.test.ts +511 -0
- package/src/__tests__/account-token.test.ts +292 -0
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/scope-explanations.test.ts +22 -0
- package/src/account-api.ts +632 -0
- package/src/account-token.ts +200 -0
- package/src/hub-server.ts +176 -0
- package/src/scope-explanations.ts +33 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /account/token` — exchange a valid admin session cookie for a
|
|
3
|
+
* short-lived JWT carrying the ACCOUNT credential: the superset
|
|
4
|
+
*
|
|
5
|
+
* { account:self:admin, parachute:host:admin, parachute:host:auth }
|
|
6
|
+
*
|
|
7
|
+
* with `aud="account"`. This is the self-host door's half of the Parachute App
|
|
8
|
+
* campaign's `/account/*` contract (Phase 2, H1) — the same shape the cloud
|
|
9
|
+
* door mints from its own session cookie.
|
|
10
|
+
*
|
|
11
|
+
* Why a SUPERSET (SCOPE-b). The account bearer must drive both the net-new
|
|
12
|
+
* `/account/*` REST surface (H2, gated on `account:self:admin`) AND — on a
|
|
13
|
+
* single-operator self-host box — the EXISTING hub admin endpoints it wraps
|
|
14
|
+
* (`POST /vaults`, `/api/auth/*`, caps), all gated on `parachute:host:admin` /
|
|
15
|
+
* `parachute:host:auth` via `requireScope`. Carrying all three scopes means no
|
|
16
|
+
* existing hub endpoint needs rewiring: `requireScope` checks the scope claim +
|
|
17
|
+
* signature + `iss` and does NOT pin `aud` (admin-auth.ts), so the `aud="account"`
|
|
18
|
+
* token validates unchanged on the host-scoped surfaces. This is the "relabel a
|
|
19
|
+
* semantics that ships" path — it reuses the `admin-host-admin-token.ts` mint
|
|
20
|
+
* and adds the `account:self:admin` string on top of the host superset.
|
|
21
|
+
*
|
|
22
|
+
* Why `aud="account"` (SCOPE-a). The account token is minted with an explicit
|
|
23
|
+
* `aud="account"` (not the `inferAudience` fallback) so the `/account/*`
|
|
24
|
+
* validator can pin the audience and a vault RS's strict `aud=vault.<name>`
|
|
25
|
+
* check refuses it outright — an account token can never be spent as a vault
|
|
26
|
+
* token.
|
|
27
|
+
*
|
|
28
|
+
* Authorization — the same spine as `/admin/host-admin-token`:
|
|
29
|
+
* 1. **Session.** A valid, unexpired `parachute_hub_session` cookie (set by
|
|
30
|
+
* `/login` after a password check). No session → 401.
|
|
31
|
+
* 2. **CSRF.** Unlike the GET host-admin mint, this is a POST that performs a
|
|
32
|
+
* privileged mint, so it carries the double-submit CSRF token
|
|
33
|
+
* (`__csrf` in the JSON body, same `verifyCsrfToken` as `/api/account/*`).
|
|
34
|
+
* Missing/mismatched → 403. The hub-server dispatcher additionally applies
|
|
35
|
+
* the strict same-origin Origin belt (`assertSameOriginForCookieMutation`)
|
|
36
|
+
* before this handler runs — belt-and-suspenders on the cookie path.
|
|
37
|
+
* 3. **First-admin gate.** A valid session is necessary but NOT sufficient: it
|
|
38
|
+
* must belong to the hub admin (earliest-created user row). Without this a
|
|
39
|
+
* signed-in friend account could mint a full host-admin superset — a
|
|
40
|
+
* privesc. On a single-operator box first-admin ≡ the account ≡ the box
|
|
41
|
+
* (the ratified "operator ≡ account ≡ box"), so the first-admin gate is the
|
|
42
|
+
* account gate. Non-first-admin session → 403.
|
|
43
|
+
* 4. **Admin screen-lock.** When the optional idle-lock PIN is set and this
|
|
44
|
+
* session isn't in an unlock window, refuse (423) — same as the host-admin
|
|
45
|
+
* mint (the account token is at least as powerful, so it inherits the gate).
|
|
46
|
+
*
|
|
47
|
+
* Statelessness + revocation (SCOPE-d). Like the host-admin token, the account
|
|
48
|
+
* token is deliberately NOT persisted in the `tokens` table — no registry row,
|
|
49
|
+
* no per-jti revocation. It is short-lived (10 min) and re-minted from the live
|
|
50
|
+
* session; revocation is by logout (kills the cookie → no re-mint) plus the
|
|
51
|
+
* read-time session-expiry chokepoint. A stateless mint avoids a DB write on
|
|
52
|
+
* every re-mint.
|
|
53
|
+
*/
|
|
54
|
+
import type { Database } from "bun:sqlite";
|
|
55
|
+
import { HOST_ADMIN_SCOPES } from "./admin-host-admin-token.ts";
|
|
56
|
+
import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
|
|
57
|
+
import { CSRF_FIELD_NAME, verifyCsrfToken } from "./csrf.ts";
|
|
58
|
+
import { signAccessToken } from "./jwt-sign.ts";
|
|
59
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
60
|
+
import { ACCOUNT_SELF_ADMIN_SCOPE } from "./scope-explanations.ts";
|
|
61
|
+
import {
|
|
62
|
+
SESSION_TTL_MS,
|
|
63
|
+
buildSessionCookie,
|
|
64
|
+
findSession,
|
|
65
|
+
parseSessionCookie,
|
|
66
|
+
touchSession,
|
|
67
|
+
} from "./sessions.ts";
|
|
68
|
+
import { isFirstAdmin } from "./users.ts";
|
|
69
|
+
|
|
70
|
+
/** Short TTL — page-snapshot threats can't carry the token forever. Matches the
|
|
71
|
+
* host-admin mint's 10-min window; the app re-mints when it's about to lapse. */
|
|
72
|
+
export const ACCOUNT_TOKEN_TTL_SECONDS = 10 * 60;
|
|
73
|
+
/** Explicit account audience (SCOPE-a) — pinned by the `/account/*` validator;
|
|
74
|
+
* a vault RS's strict `aud=vault.<name>` check refuses it. */
|
|
75
|
+
export const ACCOUNT_TOKEN_AUDIENCE = "account";
|
|
76
|
+
/** First-party browser client id for the account credential (matches the
|
|
77
|
+
* `parachute-account` id the friend vault-token mints already use). */
|
|
78
|
+
export const ACCOUNT_TOKEN_CLIENT_ID = "parachute-account";
|
|
79
|
+
/** The superset the account bearer carries (SCOPE-b): the account scope plus
|
|
80
|
+
* the host superset, so the same token drives both the `/account/*` surface and
|
|
81
|
+
* the host-scoped endpoints it wraps without rewiring any existing gate. */
|
|
82
|
+
export const ACCOUNT_TOKEN_SCOPES = [ACCOUNT_SELF_ADMIN_SCOPE, ...HOST_ADMIN_SCOPES] as const;
|
|
83
|
+
|
|
84
|
+
export interface MintAccountTokenDeps {
|
|
85
|
+
db: Database;
|
|
86
|
+
/** Hub origin — written into JWT `iss`. */
|
|
87
|
+
issuer: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function handleAccountToken(
|
|
91
|
+
req: Request,
|
|
92
|
+
deps: MintAccountTokenDeps,
|
|
93
|
+
): Promise<Response> {
|
|
94
|
+
if (req.method !== "POST") {
|
|
95
|
+
return jsonError(405, "method_not_allowed", "use POST");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Gate 1 — session. No identity, no mint.
|
|
99
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
100
|
+
const session = sid ? findSession(deps.db, sid) : null;
|
|
101
|
+
if (!session || !sid) {
|
|
102
|
+
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Gate 2 — CSRF double-submit. This is a POST mint, so a cross-site forged
|
|
106
|
+
// submission must be refused before any privileged work. The token rides the
|
|
107
|
+
// JSON body's `__csrf` field, same shape + helper as `/api/account/*`. The
|
|
108
|
+
// dispatcher's Origin belt already ran; this is the double-submit half.
|
|
109
|
+
const csrfToken = await readCsrfToken(req);
|
|
110
|
+
if (!verifyCsrfToken(req, csrfToken)) {
|
|
111
|
+
return jsonError(403, "csrf_failed", "missing or invalid CSRF token");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Gate 3 — first-admin. A friend account (non-first-admin) holds a valid
|
|
115
|
+
// session but must not mint the account superset (host:admin + host:auth is a
|
|
116
|
+
// full-admin privesc). On a single-operator box first-admin ≡ the account, so
|
|
117
|
+
// this gate IS the account gate. Mirror the host-admin mint's 403 shape.
|
|
118
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
119
|
+
return jsonError(
|
|
120
|
+
403,
|
|
121
|
+
"not_admin",
|
|
122
|
+
"account token mint is restricted to the hub admin — your account home is at /account/",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Gate 4 — admin screen-lock (optional, off by default). When a lock PIN is
|
|
127
|
+
// set AND this session isn't within an unlock window, refuse. Same PURE-CHECK
|
|
128
|
+
// posture as the host-admin mint: it does NOT slide the idle window (sliding
|
|
129
|
+
// is heartbeat-only), so a background re-mint can't keep an idle tab unlocked.
|
|
130
|
+
if (!requireUnlocked(deps.db, sid).ok) {
|
|
131
|
+
return lockedResponse();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const minted = await signAccessToken(deps.db, {
|
|
135
|
+
sub: session.userId,
|
|
136
|
+
scopes: [...ACCOUNT_TOKEN_SCOPES],
|
|
137
|
+
audience: ACCOUNT_TOKEN_AUDIENCE,
|
|
138
|
+
clientId: ACCOUNT_TOKEN_CLIENT_ID,
|
|
139
|
+
issuer: deps.issuer,
|
|
140
|
+
ttlSeconds: ACCOUNT_TOKEN_TTL_SECONDS,
|
|
141
|
+
// No per-user vault pin — the account bearer talks to hub-scoped account +
|
|
142
|
+
// provisioning endpoints, not a single vault. Empty `vault_scope` is the
|
|
143
|
+
// "no per-user restriction" sentinel, matching the host-admin mint.
|
|
144
|
+
vaultScope: [],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Sliding session renewal — a successful mint pushes the session's expiry
|
|
148
|
+
// forward and re-issues the cookie with the EXACT attributes creation uses,
|
|
149
|
+
// so an app re-minting ~every 10 min keeps the operator signed in without
|
|
150
|
+
// broadening the cookie. Identical to the host-admin mint.
|
|
151
|
+
touchSession(deps.db, sid);
|
|
152
|
+
const sessionCookie = buildSessionCookie(sid, Math.floor(SESSION_TTL_MS / 1000), {
|
|
153
|
+
secure: isHttpsRequest(req),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return new Response(
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
token: minted.token,
|
|
159
|
+
expires_at: minted.expiresAt,
|
|
160
|
+
scopes: ACCOUNT_TOKEN_SCOPES,
|
|
161
|
+
aud: ACCOUNT_TOKEN_AUDIENCE,
|
|
162
|
+
}),
|
|
163
|
+
{
|
|
164
|
+
status: 200,
|
|
165
|
+
headers: {
|
|
166
|
+
"content-type": "application/json",
|
|
167
|
+
// No browser cache — the token rotates per-mint; a stale 200 from a
|
|
168
|
+
// back/forward navigation could hand the app a long-expired JWT.
|
|
169
|
+
"cache-control": "no-store",
|
|
170
|
+
"set-cookie": sessionCookie,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pull the double-submit CSRF token from the JSON request body's `__csrf`
|
|
178
|
+
* field. Tolerant of an absent/malformed body (returns null → the verify
|
|
179
|
+
* fails closed with a 403), matching `readJsonBody` + `checkCsrf` in
|
|
180
|
+
* `api-account-2fa.ts`.
|
|
181
|
+
*/
|
|
182
|
+
async function readCsrfToken(req: Request): Promise<string | null> {
|
|
183
|
+
try {
|
|
184
|
+
const body = (await req.json()) as unknown;
|
|
185
|
+
if (body && typeof body === "object") {
|
|
186
|
+
const token = (body as Record<string, unknown>)[CSRF_FIELD_NAME];
|
|
187
|
+
return typeof token === "string" ? token : null;
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// Empty or non-JSON body — no token.
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
196
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
197
|
+
status,
|
|
198
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
199
|
+
});
|
|
200
|
+
}
|
package/src/hub-server.ts
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* /.well-known/parachute.json → built dynamically from services.json
|
|
34
34
|
* /.well-known/parachute-revocation.json → revoked-jti list (hub#212 Phase 1)
|
|
35
35
|
* /.well-known/jwks.json → JWKS from hub.db
|
|
36
|
+
* /.well-known/parachute-account → account-door capabilities descriptor (public, H2)
|
|
36
37
|
* /.well-known/oauth-authorization-server → RFC 8414 metadata (issuer, endpoints)
|
|
37
38
|
*
|
|
38
39
|
* # OAuth issuer.
|
|
@@ -49,6 +50,13 @@
|
|
|
49
50
|
* tokens/grants/user_vaults/invites/
|
|
50
51
|
* connections sweep, CLI remove,
|
|
51
52
|
* supervisor restart)
|
|
53
|
+
* # /account/* — the Bearer-gated account-door REST facade (Phase 2, H2).
|
|
54
|
+
* # account:self:admin OR parachute:host:admin (mutations); +:read (reads).
|
|
55
|
+
* /account (GET) → account bootstrap {id,email,door} (Bearer only)
|
|
56
|
+
* /account/vaults (GET/POST) → list / create vault (create returns vault_token)
|
|
57
|
+
* /account/vaults/<name> (DELETE) → teardown (wraps /vaults/<name> cascade)
|
|
58
|
+
* /account/vaults/<name>/token (POST) → per-vault scoped token mint
|
|
59
|
+
* /account/vaults/<name>/caps (GET/PUT) → read / set the storage cap
|
|
52
60
|
* /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
|
|
53
61
|
* /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
|
|
54
62
|
* /admin/agent-token (GET) → agent UI bearer mint (cookie-gated)
|
|
@@ -116,6 +124,13 @@
|
|
|
116
124
|
* (forceChangePasswordGate); only this
|
|
117
125
|
* route and /logout stay reachable
|
|
118
126
|
* pre-rotation.
|
|
127
|
+
* /account/token (POST) → cookie→account-bearer mint (App
|
|
128
|
+
* campaign Phase 2 H1): session cookie →
|
|
129
|
+
* short-lived JWT carrying the account
|
|
130
|
+
* superset {account:self:admin,
|
|
131
|
+
* parachute:host:admin, parachute:host:auth}
|
|
132
|
+
* aud="account"; first-admin-gated,
|
|
133
|
+
* CSRF + same-origin belt; stateless
|
|
119
134
|
* /account/2fa (GET + POST) → user self-service 2FA enroll/disenroll
|
|
120
135
|
* (hub#473; QR + backup codes)
|
|
121
136
|
* /account/vault-token/<name> (POST) → friend mints a scoped
|
|
@@ -163,7 +178,20 @@ import { existsSync } from "node:fs";
|
|
|
163
178
|
import { dirname, join, resolve } from "node:path";
|
|
164
179
|
import { fileURLToPath } from "node:url";
|
|
165
180
|
import pkg from "../package.json" with { type: "json" };
|
|
181
|
+
import {
|
|
182
|
+
ACCOUNT_MUTATION_SCOPES,
|
|
183
|
+
type AccountApiDeps,
|
|
184
|
+
handleAccountCapabilities,
|
|
185
|
+
handleAccountCreateVault,
|
|
186
|
+
handleAccountGetVaultCaps,
|
|
187
|
+
handleAccountListVaults,
|
|
188
|
+
handleAccountMintVaultToken,
|
|
189
|
+
handleAccountRoot,
|
|
190
|
+
handleAccountSetVaultCaps,
|
|
191
|
+
requireAnyScope,
|
|
192
|
+
} from "./account-api.ts";
|
|
166
193
|
import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
|
|
194
|
+
import { handleAccountToken } from "./account-token.ts";
|
|
167
195
|
import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
|
|
168
196
|
import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
|
|
169
197
|
import {
|
|
@@ -172,6 +200,7 @@ import {
|
|
|
172
200
|
handleOAuthGrantCallback,
|
|
173
201
|
} from "./admin-agent-grants.ts";
|
|
174
202
|
import { handleAgentToken } from "./admin-agent-token.ts";
|
|
203
|
+
import { adminAuthErrorResponse } from "./admin-auth.ts";
|
|
175
204
|
import { handleApproveClient, handleDeleteClient, handleGetClient } from "./admin-clients.ts";
|
|
176
205
|
import {
|
|
177
206
|
type ConnectionsDeps,
|
|
@@ -2812,6 +2841,24 @@ export function hubFetch(
|
|
|
2812
2841
|
return new Response(res.body, { status: res.status, headers: merged });
|
|
2813
2842
|
}
|
|
2814
2843
|
|
|
2844
|
+
// GET /.well-known/parachute-account — the account-door capabilities
|
|
2845
|
+
// descriptor (Phase 2, H2). Public, no auth, wildcard CORS (the app pulls
|
|
2846
|
+
// it cross-origin to render honestly per door: billing:false + plans:[] on
|
|
2847
|
+
// self-host means no upgrade UI). See account-api.handleAccountCapabilities.
|
|
2848
|
+
if (pathname === "/.well-known/parachute-account") {
|
|
2849
|
+
const corsHeaders = {
|
|
2850
|
+
"access-control-allow-origin": "*",
|
|
2851
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
2852
|
+
};
|
|
2853
|
+
if (req.method === "OPTIONS") {
|
|
2854
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
2855
|
+
}
|
|
2856
|
+
const res = handleAccountCapabilities(req, { issuer: oauthDeps(req).issuer });
|
|
2857
|
+
const merged = new Headers(res.headers);
|
|
2858
|
+
for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
|
|
2859
|
+
return new Response(res.body, { status: res.status, headers: merged });
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2815
2862
|
// OAuth surface — every handler return is wrapped in `applyCorsHeaders`
|
|
2816
2863
|
// so third-party SPAs can fetch these endpoints cross-origin (the entire
|
|
2817
2864
|
// point of OAuth DCR: arbitrary SPAs register → authorize → exchange
|
|
@@ -3721,6 +3768,27 @@ export function hubFetch(
|
|
|
3721
3768
|
if (gate) return gate;
|
|
3722
3769
|
}
|
|
3723
3770
|
|
|
3771
|
+
// /account/token — cookie→account-bearer mint (Parachute App campaign,
|
|
3772
|
+
// Phase 2 H1). POST-only. Exchanges the `parachute_hub_session` cookie for
|
|
3773
|
+
// a short-lived JWT carrying the account superset
|
|
3774
|
+
// {account:self:admin, parachute:host:admin, parachute:host:auth} with
|
|
3775
|
+
// aud="account" — the same-origin app's bootstrap into holding the account
|
|
3776
|
+
// credential (it never touches /oauth/authorize, so never hits consent).
|
|
3777
|
+
// First-admin-gated (account ≡ box), CSRF double-submit + the same-origin
|
|
3778
|
+
// Origin belt below. Stateless (no tokens row); revocation = logout.
|
|
3779
|
+
if (pathname === "/account/token") {
|
|
3780
|
+
if (!getDb) return dbNotConfigured();
|
|
3781
|
+
// Origin belt (hub#632 posture): a cookie-authed POST mint gets the
|
|
3782
|
+
// strict same-origin check as defense-in-depth over the body's CSRF
|
|
3783
|
+
// token. A Bearer-authed caller (none expected here) would bypass the
|
|
3784
|
+
// belt but still fail the cookie-only session gate inside the handler.
|
|
3785
|
+
{
|
|
3786
|
+
const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
|
|
3787
|
+
if (rejected) return rejected;
|
|
3788
|
+
}
|
|
3789
|
+
return handleAccountToken(req, { db: getDb(), issuer: oauthDeps(req).issuer });
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3724
3792
|
// /account/2fa — user self-service TOTP 2FA enroll / disenroll (hub#473).
|
|
3725
3793
|
// Both GET (render state) and POST (start/confirm/disable) require an
|
|
3726
3794
|
// active session; the handler does the session check + 302 to /login when
|
|
@@ -3777,6 +3845,114 @@ export function hubFetch(
|
|
|
3777
3845
|
});
|
|
3778
3846
|
}
|
|
3779
3847
|
|
|
3848
|
+
// ====================================================================
|
|
3849
|
+
// /account/* — the Bearer-gated account-door REST facade (Phase 2, H2).
|
|
3850
|
+
// The normalized account API both doors mount (the hosted cloud door
|
|
3851
|
+
// mounts the twin). Bearer-authed, machine-to-machine — no session
|
|
3852
|
+
// cookie — so these precede the session-cookie `/account/vault-token/`
|
|
3853
|
+
// + `/account/` HTML routes below. Mutations accept account:self:admin
|
|
3854
|
+
// OR parachute:host:admin; reads also accept account:self:read. See
|
|
3855
|
+
// `account-api.ts`.
|
|
3856
|
+
// ====================================================================
|
|
3857
|
+
if (pathname === "/account/vaults") {
|
|
3858
|
+
if (!getDb) return dbNotConfigured();
|
|
3859
|
+
const accountDeps: AccountApiDeps = {
|
|
3860
|
+
db: getDb(),
|
|
3861
|
+
issuer: oauthDeps(req).issuer,
|
|
3862
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3863
|
+
manifestPath,
|
|
3864
|
+
};
|
|
3865
|
+
if (req.method === "GET") return handleAccountListVaults(req, accountDeps);
|
|
3866
|
+
if (req.method === "POST") return handleAccountCreateVault(req, accountDeps);
|
|
3867
|
+
return new Response("method not allowed", { status: 405 });
|
|
3868
|
+
}
|
|
3869
|
+
if (pathname.startsWith("/account/vaults/")) {
|
|
3870
|
+
if (!getDb) return dbNotConfigured();
|
|
3871
|
+
const rest = pathname.slice("/account/vaults/".length);
|
|
3872
|
+
const accountDeps: AccountApiDeps = {
|
|
3873
|
+
db: getDb(),
|
|
3874
|
+
issuer: oauthDeps(req).issuer,
|
|
3875
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3876
|
+
manifestPath,
|
|
3877
|
+
};
|
|
3878
|
+
// POST /account/vaults/<name>/token — per-vault scoped token mint.
|
|
3879
|
+
if (rest.endsWith("/token")) {
|
|
3880
|
+
const name = decodeURIComponent(rest.slice(0, -"/token".length));
|
|
3881
|
+
return handleAccountMintVaultToken(req, name, accountDeps);
|
|
3882
|
+
}
|
|
3883
|
+
// GET/PUT /account/vaults/<name>/caps — read / set the storage cap.
|
|
3884
|
+
if (rest.endsWith("/caps")) {
|
|
3885
|
+
const name = decodeURIComponent(rest.slice(0, -"/caps".length));
|
|
3886
|
+
if (req.method === "GET") return handleAccountGetVaultCaps(req, name, accountDeps);
|
|
3887
|
+
if (req.method === "PUT") return handleAccountSetVaultCaps(req, name, accountDeps);
|
|
3888
|
+
return new Response("method not allowed", { status: 405 });
|
|
3889
|
+
}
|
|
3890
|
+
// DELETE /account/vaults/<name> — teardown via the full identity
|
|
3891
|
+
// cascade. Gate with the account-scope set here (accept account:self:admin
|
|
3892
|
+
// OR parachute:host:admin), then delegate to `handleDeleteVault`, which
|
|
3893
|
+
// re-gates parachute:host:admin — the superset account token carries it,
|
|
3894
|
+
// so this is belt-and-suspenders, not a second authorization.
|
|
3895
|
+
if (req.method === "DELETE") {
|
|
3896
|
+
const name = decodeURIComponent(rest);
|
|
3897
|
+
if (!name || name.includes("/")) return new Response("not found", { status: 404 });
|
|
3898
|
+
try {
|
|
3899
|
+
await requireAnyScope(
|
|
3900
|
+
getDb(),
|
|
3901
|
+
req,
|
|
3902
|
+
ACCOUNT_MUTATION_SCOPES,
|
|
3903
|
+
oauthDeps(req).hubBoundOrigins(),
|
|
3904
|
+
);
|
|
3905
|
+
} catch (err) {
|
|
3906
|
+
return adminAuthErrorResponse(err);
|
|
3907
|
+
}
|
|
3908
|
+
const services = readManifestLenient(manifestPath).services;
|
|
3909
|
+
const agentEntry = findServiceByShort(services, "agent");
|
|
3910
|
+
const agentOrigin = agentEntry ? `http://127.0.0.1:${agentEntry.port}` : null;
|
|
3911
|
+
const resolveVaultOrigin = (vaultName: string): string | null => {
|
|
3912
|
+
const match = findVaultUpstream(
|
|
3913
|
+
readManifestLenient(manifestPath).services,
|
|
3914
|
+
`/vault/${vaultName}`,
|
|
3915
|
+
);
|
|
3916
|
+
return match ? `http://127.0.0.1:${match.port}` : null;
|
|
3917
|
+
};
|
|
3918
|
+
const supervisor = deps?.supervisor;
|
|
3919
|
+
return handleDeleteVault(req, name, {
|
|
3920
|
+
db: getDb(),
|
|
3921
|
+
issuer: oauthDeps(req).issuer,
|
|
3922
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3923
|
+
manifestPath,
|
|
3924
|
+
connectionsStorePath:
|
|
3925
|
+
deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
|
|
3926
|
+
agentOrigin,
|
|
3927
|
+
resolveVaultOrigin,
|
|
3928
|
+
resolveModuleOrigin: makeResolveModuleOrigin(manifestPath),
|
|
3929
|
+
...(supervisor
|
|
3930
|
+
? {
|
|
3931
|
+
restartVaultModule: async () => {
|
|
3932
|
+
await supervisor.restart("vault");
|
|
3933
|
+
},
|
|
3934
|
+
}
|
|
3935
|
+
: {}),
|
|
3936
|
+
});
|
|
3937
|
+
}
|
|
3938
|
+
return new Response("method not allowed", { status: 405 });
|
|
3939
|
+
}
|
|
3940
|
+
// GET /account (Bearer) — account bootstrap {account_id,email,door}. Only
|
|
3941
|
+
// intercepts when an Authorization header is present, so the cookie-authed
|
|
3942
|
+
// HTML account home at `/account`/`/account/` below stays unaffected.
|
|
3943
|
+
if (
|
|
3944
|
+
(pathname === "/account" || pathname === "/account/") &&
|
|
3945
|
+
req.headers.get("authorization")
|
|
3946
|
+
) {
|
|
3947
|
+
if (!getDb) return dbNotConfigured();
|
|
3948
|
+
return handleAccountRoot(req, {
|
|
3949
|
+
db: getDb(),
|
|
3950
|
+
issuer: oauthDeps(req).issuer,
|
|
3951
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3952
|
+
manifestPath,
|
|
3953
|
+
});
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3780
3956
|
// /account/vault-token/<name> — friend-facing scoped vault token mint.
|
|
3781
3957
|
// POST-only, session-gated, assignment-capped: a non-admin friend mints a
|
|
3782
3958
|
// `vault:<name>:read|write` bearer for a vault they're ASSIGNED to, for
|
|
@@ -112,8 +112,34 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
|
|
|
112
112
|
label: "Administer vaults on this host (create, configure, delete).",
|
|
113
113
|
level: "admin",
|
|
114
114
|
},
|
|
115
|
+
// Account scopes (Parachute App campaign, Phase 2 — the `/account/*` door
|
|
116
|
+
// contract). `account:<id>:admin` authorizes every account mutation
|
|
117
|
+
// (create/delete/import vaults, mint per-vault tokens, set caps); `:read`
|
|
118
|
+
// authorizes the read side (list vaults, read caps/usage). On a self-host
|
|
119
|
+
// hub `<id>` is the sentinel `self` (account ≡ box). These are minted ONLY
|
|
120
|
+
// by the cookie→bearer `POST /account/token` exchange, NEVER via the public
|
|
121
|
+
// OAuth flow — they're in NON_REQUESTABLE_SCOPES below, like the host scopes.
|
|
122
|
+
// Listed here (a) so `NON_REQUESTABLE_SCOPES ⊆ FIRST_PARTY_SCOPES` holds and
|
|
123
|
+
// (b) so scope-guard's `admin ⊇ read` inheritance recognizes the grammar
|
|
124
|
+
// (`account:self:admin` satisfies `account:self:read`).
|
|
125
|
+
"account:self:admin": {
|
|
126
|
+
label:
|
|
127
|
+
"Manage your Parachute account — create, delete, and configure your vaults, and mint access tokens for them.",
|
|
128
|
+
level: "admin",
|
|
129
|
+
},
|
|
130
|
+
"account:self:read": {
|
|
131
|
+
label: "View your Parachute account — list your vaults and read their settings and usage.",
|
|
132
|
+
level: "read",
|
|
133
|
+
},
|
|
115
134
|
};
|
|
116
135
|
|
|
136
|
+
/** Account scope strings (Parachute App campaign, Phase 2). Exported so the
|
|
137
|
+
* `POST /account/token` mint and the `/account/*` route gates reference the
|
|
138
|
+
* canonical constants rather than re-literaling the strings. `self` is the
|
|
139
|
+
* self-host account sentinel (account ≡ box). */
|
|
140
|
+
export const ACCOUNT_SELF_ADMIN_SCOPE = "account:self:admin";
|
|
141
|
+
export const ACCOUNT_SELF_READ_SCOPE = "account:self:read";
|
|
142
|
+
|
|
117
143
|
/**
|
|
118
144
|
* Sorted list of every first-party scope the hub recognizes. Used as the
|
|
119
145
|
* baseline for `scopes_supported` in the OAuth-AS metadata; module-declared
|
|
@@ -170,6 +196,13 @@ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
|
|
|
170
196
|
// Service-admin scopes: operator-only, never requestable via /oauth/authorize.
|
|
171
197
|
"hub:admin",
|
|
172
198
|
"scribe:admin",
|
|
199
|
+
// Account scopes (Parachute App campaign, Phase 2): cookie-minted only via
|
|
200
|
+
// `POST /account/token`, NEVER OAuth-requestable. A third-party app pointed
|
|
201
|
+
// at the hub AS must not be able to consent its way to account authority
|
|
202
|
+
// (create/delete vaults, mint per-vault tokens) — the account credential is
|
|
203
|
+
// exclusively the same-origin app's cookie→bearer exchange.
|
|
204
|
+
ACCOUNT_SELF_ADMIN_SCOPE,
|
|
205
|
+
ACCOUNT_SELF_READ_SCOPE,
|
|
173
206
|
]);
|
|
174
207
|
|
|
175
208
|
/**
|