@openparachute/hub 0.7.6 → 0.7.7-rc.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +22 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +318 -55
- package/src/hub-settings.ts +162 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +35 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /account/session` — the same-origin boot oracle (hub-parity P1; the
|
|
3
|
+
* hub's twin of cloud `account-session.ts`). Returns `{ signed_in, csrf, ... }`
|
|
4
|
+
* so the same-origin `parachute-app` — served at the hub's own origin — can
|
|
5
|
+
* (a) decide whether to render the signed-in UI or the sign-in ceremony, and
|
|
6
|
+
* (b) echo `csrf` back as `__csrf` on its next same-origin `POST /account/token`
|
|
7
|
+
* mint (C2). The CSRF cookie is `HttpOnly` (csrf.ts), so the SPA can't read it
|
|
8
|
+
* directly; this hands the token over the same way a server-rendered form's
|
|
9
|
+
* hidden input does.
|
|
10
|
+
*
|
|
11
|
+
* PUBLIC — no Bearer, no scope gate. It drives the `parachute_hub_session`
|
|
12
|
+
* cookie directly, same as `/api/me`: a client with no account Bearer yet has
|
|
13
|
+
* nothing else to authenticate a read with, and this endpoint IS the
|
|
14
|
+
* bootstrap. Mounted ABOVE the force-change-password gate (hub-server.ts
|
|
15
|
+
* CHOKE POINT 1) — it's a pure state oracle, not a mutating surface, so a
|
|
16
|
+
* pre-rotation user must still be able to read it (the app needs `signed_in` +
|
|
17
|
+
* `csrf` to drive the rotation ceremony itself, not get walled off before it
|
|
18
|
+
* can even ask). `POST /account/token` stays BELOW the gate — a pre-rotation
|
|
19
|
+
* user hits its 403 `force_change_password` there, and the app is expected to
|
|
20
|
+
* weather that (P4).
|
|
21
|
+
*
|
|
22
|
+
* SAME-ORIGIN ONLY, by design — mirrors cloud's posture exactly: the response
|
|
23
|
+
* is CREDENTIALED (it reflects the session cookie and sets the CSRF cookie),
|
|
24
|
+
* so it carries NO CORS headers. A cross-origin caller simply gets no
|
|
25
|
+
* `access-control-allow-origin`, so the browser blocks it from reading the
|
|
26
|
+
* body — intended, not an oversight.
|
|
27
|
+
*/
|
|
28
|
+
import type { Database } from "bun:sqlite";
|
|
29
|
+
import { ensureCsrfToken } from "./csrf.ts";
|
|
30
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
31
|
+
import {
|
|
32
|
+
SESSION_TTL_MS,
|
|
33
|
+
buildSessionCookie,
|
|
34
|
+
findActiveSession,
|
|
35
|
+
shouldSlideSession,
|
|
36
|
+
touchSession,
|
|
37
|
+
} from "./sessions.ts";
|
|
38
|
+
import { getUserById } from "./users.ts";
|
|
39
|
+
|
|
40
|
+
export interface AccountSessionDeps {
|
|
41
|
+
db: Database;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Matches the `/account/*` 405 shape (`account-api.ts`'s `methodNotAllowed`)
|
|
45
|
+
* — same `{error, message}` body + `Allow` header, kept local to this file
|
|
46
|
+
* per the one-handler-one-file convention (`api-me.ts`, `account-token.ts`). */
|
|
47
|
+
function methodNotAllowed(allow: string): Response {
|
|
48
|
+
return new Response(JSON.stringify({ error: "method_not_allowed", message: `use ${allow}` }), {
|
|
49
|
+
status: 405,
|
|
50
|
+
headers: { "content-type": "application/json", allow },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A credentialed JSON reply that can carry MULTIPLE Set-Cookie headers (the
|
|
56
|
+
* CSRF mint and the session slide's re-issue can coincide — a plain
|
|
57
|
+
* `Record`-keyed header init can't hold two `set-cookie` values). NO CORS
|
|
58
|
+
* headers (see the module note); `no-store` so a back/forward navigation
|
|
59
|
+
* never replays a stale csrf/session state. Mirrors cloud's `sessionJson`.
|
|
60
|
+
*/
|
|
61
|
+
function sessionJson(body: unknown, cookies: readonly string[]): Response {
|
|
62
|
+
const headers = new Headers({ "content-type": "application/json", "cache-control": "no-store" });
|
|
63
|
+
for (const c of cookies) headers.append("set-cookie", c);
|
|
64
|
+
return new Response(JSON.stringify(body), { status: 200, headers });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function handleAccountSession(req: Request, deps: AccountSessionDeps): Response {
|
|
68
|
+
if (req.method !== "GET") return methodNotAllowed("GET");
|
|
69
|
+
|
|
70
|
+
// CSRF token for BOTH branches (G2 — anonymous CSRF; the piece `api-me.ts`
|
|
71
|
+
// lacks, since it mints only when signed in). ensureCsrfToken reuses an
|
|
72
|
+
// existing CSRF cookie or mints one (setCookie only when it minted) — it
|
|
73
|
+
// works WITHOUT a session and authorizes NOTHING alone (it's one half of a
|
|
74
|
+
// double-submit that also needs the matching cookie + same-origin + a live
|
|
75
|
+
// session at the mint gate it protects). Returning it on the signed-OUT
|
|
76
|
+
// branch lets the app run the sign-in moment in-app.
|
|
77
|
+
const csrf = ensureCsrfToken(req);
|
|
78
|
+
const cookies: string[] = [];
|
|
79
|
+
if (csrf.setCookie) cookies.push(csrf.setCookie);
|
|
80
|
+
|
|
81
|
+
// findActiveSession is the single chokepoint: it refuses an expired row (a
|
|
82
|
+
// logged-out row is gone outright). Nothing below rolls or reads a
|
|
83
|
+
// dead/absent session.
|
|
84
|
+
const session = findActiveSession(deps.db, req);
|
|
85
|
+
if (!session) {
|
|
86
|
+
return sessionJson({ signed_in: false, csrf: csrf.token }, cookies);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Bounded slide (the G3 twin, and the reason NOT to call bare
|
|
90
|
+
// `touchSession` unconditionally here): this endpoint is POLLED — the
|
|
91
|
+
// app's `/check-email` screen hits it every few seconds while it waits for
|
|
92
|
+
// a magic-link-style click — so sliding only past
|
|
93
|
+
// `SESSION_SLIDE_THRESHOLD_MS` of remaining life bounds the write (and the
|
|
94
|
+
// cookie re-issue) to ~once per threshold per session, not per request.
|
|
95
|
+
if (shouldSlideSession(session)) {
|
|
96
|
+
touchSession(deps.db, session.id);
|
|
97
|
+
cookies.push(
|
|
98
|
+
buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
99
|
+
secure: isHttpsRequest(req),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const user = getUserById(deps.db, session.userId);
|
|
105
|
+
if (!user) {
|
|
106
|
+
// Session row points at a deleted user — treat as signed-out (the
|
|
107
|
+
// `api-me.ts` precedent) rather than surface a stale identity. Any slide
|
|
108
|
+
// cookie computed above still rides along; it's harmless (the row is
|
|
109
|
+
// about to be orphaned regardless) and keeping the branch simple beats
|
|
110
|
+
// special-casing a cookie rollback for a should-never-happen state.
|
|
111
|
+
return sessionJson({ signed_in: false, csrf: csrf.token }, cookies);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return sessionJson(
|
|
115
|
+
{
|
|
116
|
+
signed_in: true,
|
|
117
|
+
csrf: csrf.token,
|
|
118
|
+
// users.email is null-by-history (migration v15) — username is always
|
|
119
|
+
// present, email only when the row has one (B2 signup capture).
|
|
120
|
+
username: user.username,
|
|
121
|
+
...(user.email ? { email: user.email } : {}),
|
|
122
|
+
account_created_at: user.createdAt,
|
|
123
|
+
// HUB EXTRA — additive, not part of the door-contract pin
|
|
124
|
+
// (`checkAccountSessionResponse` tolerates extra fields): lets the app
|
|
125
|
+
// hop a temp-password user straight to the change-password ceremony
|
|
126
|
+
// instead of hitting the force-change-password 403 wall on their next
|
|
127
|
+
// `/account/*` call.
|
|
128
|
+
...(user.passwordChanged === false ? { password_change_required: true } : {}),
|
|
129
|
+
},
|
|
130
|
+
cookies,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -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/admin-connections.ts
CHANGED
|
@@ -1082,7 +1082,8 @@ async function renewCredentialConnection(
|
|
|
1082
1082
|
}
|
|
1083
1083
|
|
|
1084
1084
|
const auth = req.headers.get("authorization");
|
|
1085
|
-
|
|
1085
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
1086
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
1086
1087
|
return jsonError(
|
|
1087
1088
|
401,
|
|
1088
1089
|
"unauthenticated",
|
|
@@ -1257,7 +1258,8 @@ async function claimCredentialConnection(
|
|
|
1257
1258
|
}
|
|
1258
1259
|
|
|
1259
1260
|
const auth = req.headers.get("authorization");
|
|
1260
|
-
|
|
1261
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
1262
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
1261
1263
|
return jsonError(
|
|
1262
1264
|
401,
|
|
1263
1265
|
"unauthenticated",
|
package/src/admin-lock.ts
CHANGED
|
@@ -24,11 +24,10 @@
|
|
|
24
24
|
* The admin SPA + every module config UI get their working Bearer from one of
|
|
25
25
|
* the cookie-gated mint endpoints:
|
|
26
26
|
* - `GET /admin/host-admin-token` (the SPA's own Bearer)
|
|
27
|
-
* - `GET /admin/agent-token` (agent chat + config UIs)
|
|
28
27
|
* - `GET /admin/vault-admin-token/<name>`(per-vault admin SPA)
|
|
29
28
|
* - `GET /admin/module-token/<short>` (generic module config UI Bearer)
|
|
30
29
|
*
|
|
31
|
-
* All
|
|
30
|
+
* All three share the exact `parseSessionCookie → findSession → [isFirstAdmin]
|
|
32
31
|
* → signAccessToken` shape. Inserting {@link requireUnlocked} into each makes
|
|
33
32
|
* the lock cascade to EVERY admin surface with no per-module changes: when
|
|
34
33
|
* locked, the mint returns 423 and the relevant UI shows the lock screen / its
|
|
@@ -8,15 +8,14 @@
|
|
|
8
8
|
* hub frames/links those surfaces consistently (the Modules page "Configure"
|
|
9
9
|
* action). Each module-owned config UI, served behind the hub proxy to a
|
|
10
10
|
* logged-in portal operator, needs an admin-scoped hub Bearer to call its own
|
|
11
|
-
* `<short>:admin`-gated endpoints
|
|
12
|
-
*
|
|
13
|
-
* `/admin/vault-admin-token/<name
|
|
14
|
-
* other self-registered single-audience module, so the hub doesn't grow a
|
|
11
|
+
* `<short>:admin`-gated endpoints. This generic mint covers every
|
|
12
|
+
* self-registered single-audience module, while the vault admin SPA uses
|
|
13
|
+
* `/admin/vault-admin-token/<name>`, so the hub doesn't grow a
|
|
15
14
|
* bespoke per-module mint endpoint as each module ships a config UI.
|
|
16
15
|
*
|
|
17
16
|
* Scope + audience: `<short>:admin`, audience = `<short>` (the bare service
|
|
18
17
|
* prefix). Modules validate the JWT's `aud` against their literal short name
|
|
19
|
-
* (`scribe`, `surface`,
|
|
18
|
+
* (`scribe`, `surface`, or a third-party short) — the same shape `inferAudience`
|
|
20
19
|
* stamps for the public OAuth flow, so a hub-minted and an OAuth-minted admin
|
|
21
20
|
* token are indistinguishable to the module. This mirrors the per-request
|
|
22
21
|
* `<short>:admin` proxy token `api-modules-config.ts` used to mint; the
|
|
@@ -32,7 +31,7 @@
|
|
|
32
31
|
*
|
|
33
32
|
* Gate: the session must belong to the first admin (the single hub admin under
|
|
34
33
|
* the Phase 1 multi-user model — `users.ts:isFirstAdmin`), exactly like
|
|
35
|
-
* host-admin-token
|
|
34
|
+
* host-admin-token and vault-admin-token. A friend account holds
|
|
36
35
|
* a valid session but must not mint a module admin Bearer.
|
|
37
36
|
*
|
|
38
37
|
* Tokens are short-lived (10 min — matches the sibling admin-token mints); the
|
|
@@ -45,12 +44,12 @@ import {
|
|
|
45
44
|
type ModuleManifest,
|
|
46
45
|
readModuleManifest as defaultReadModuleManifest,
|
|
47
46
|
} from "./module-manifest.ts";
|
|
48
|
-
import { findServiceByShort, isKnownModuleShort } from "./service-spec.ts";
|
|
47
|
+
import { RETIRED_MODULES, findServiceByShort, isKnownModuleShort } from "./service-spec.ts";
|
|
49
48
|
import type { ServiceEntry } from "./services-manifest.ts";
|
|
50
49
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
51
50
|
import { isFirstAdmin } from "./users.ts";
|
|
52
51
|
|
|
53
|
-
/** Short TTL — matches
|
|
52
|
+
/** Short TTL — matches sibling admin-token mints. UI re-fetches on near-expiry. */
|
|
54
53
|
export const MODULE_TOKEN_TTL_SECONDS = 10 * 60;
|
|
55
54
|
const MODULE_TOKEN_CLIENT_ID = "parachute-hub-spa";
|
|
56
55
|
|
|
@@ -115,6 +114,12 @@ export async function handleModuleToken(
|
|
|
115
114
|
if (!MODULE_SHORT_RE.test(short)) {
|
|
116
115
|
return jsonError(400, "invalid_request", `module short "${short}" is not a valid identifier`);
|
|
117
116
|
}
|
|
117
|
+
// Retirement wins over the generic self-registration trust path. Otherwise a
|
|
118
|
+
// stale row plus readable module.json could keep minting after Hub support is
|
|
119
|
+
// deliberately removed.
|
|
120
|
+
if (RETIRED_MODULES[short]) {
|
|
121
|
+
return jsonError(404, "not_found", `no module "${short}" known to this hub`);
|
|
122
|
+
}
|
|
118
123
|
// Vault is per-instance — its admin scope needs a vault name. Route the caller
|
|
119
124
|
// to the dedicated per-vault endpoint rather than minting a useless bare
|
|
120
125
|
// `vault:admin` (no vault validates that audience).
|
package/src/admin-surfaces.ts
CHANGED
|
@@ -61,7 +61,8 @@ function jsonError(status: number, error: string, description: string): Response
|
|
|
61
61
|
/** Validate the operator bearer + require the surfaces scope. Mirrors api-modules-ops. */
|
|
62
62
|
async function authorize(req: Request, deps: AdminSurfacesDeps): Promise<Response | undefined> {
|
|
63
63
|
const auth = req.headers.get("authorization");
|
|
64
|
-
|
|
64
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
65
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
65
66
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
66
67
|
}
|
|
67
68
|
const bearer = auth.slice("Bearer ".length).trim();
|
package/src/api-hub-upgrade.ts
CHANGED
|
@@ -158,7 +158,8 @@ interface ParsedBody {
|
|
|
158
158
|
|
|
159
159
|
async function authorize(req: Request, deps: ApiHubUpgradeDeps): Promise<Response | undefined> {
|
|
160
160
|
const auth = req.headers.get("authorization");
|
|
161
|
-
|
|
161
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
162
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
162
163
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
163
164
|
}
|
|
164
165
|
const bearer = auth.slice("Bearer ".length).trim();
|
package/src/api-invites.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { Database } from "bun:sqlite";
|
|
|
32
32
|
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
33
33
|
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
34
34
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
35
|
+
import { setSetting } from "./hub-settings.ts";
|
|
35
36
|
import {
|
|
36
37
|
DEFAULT_INVITE_TTL_SECONDS,
|
|
37
38
|
type Invite,
|
|
@@ -543,6 +544,24 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
|
|
|
543
544
|
vaultCapBytes: effectiveVaultCapBytes,
|
|
544
545
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
545
546
|
});
|
|
547
|
+
// Q2 (hub-parity P2, the raw-token reality): a multi-use PROVISIONING link
|
|
548
|
+
// IS the public signup page (the operator mints it to broadcast, and each
|
|
549
|
+
// redeemer gets their OWN new vault) — persisting ITS raw token so the
|
|
550
|
+
// account descriptor can advertise `signup_path` does NOT weaken the
|
|
551
|
+
// hash-only posture of single-use friend invites, which never write this row.
|
|
552
|
+
// The `provisionVault` guard is load-bearing security, not cosmetic: a
|
|
553
|
+
// multi-use SHARED-vault invite (`provision_vault=false` + an existing
|
|
554
|
+
// vault_name — "many users join one team vault", coherent per the mint gates
|
|
555
|
+
// above) must NEVER be auto-published on the anonymous, wildcard-CORS
|
|
556
|
+
// descriptor, or its team-vault-write link leaks to anyone who fetches the
|
|
557
|
+
// exposed hub. Only multi-use + provisioning = genuine public signup (the
|
|
558
|
+
// same conjunction the vault-cap heuristic above already treats as such).
|
|
559
|
+
// The newest public link wins (overwrites any prior value);
|
|
560
|
+
// `activePublicSignupPath` (invites.ts) re-validates liveness on every read
|
|
561
|
+
// and lazily clears a revoked/exhausted/expired one.
|
|
562
|
+
if (maxUses > 1 && provisionVault) {
|
|
563
|
+
setSetting(deps.db, "public_signup_token", issued.rawToken, deps.now ?? (() => new Date()));
|
|
564
|
+
}
|
|
546
565
|
const status = inviteStatus(issued.invite, now);
|
|
547
566
|
return new Response(
|
|
548
567
|
JSON.stringify({
|
package/src/api-mint-token.ts
CHANGED
|
@@ -132,7 +132,8 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
132
132
|
|
|
133
133
|
// 1. Bearer presence + parsing.
|
|
134
134
|
const auth = req.headers.get("authorization");
|
|
135
|
-
|
|
135
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
136
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
136
137
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
137
138
|
}
|
|
138
139
|
const bearer = auth.slice("Bearer ".length).trim();
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -308,7 +308,8 @@ export function parseModulesPath(pathname: string): PathMatch | undefined {
|
|
|
308
308
|
|
|
309
309
|
async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Response | undefined> {
|
|
310
310
|
const auth = req.headers.get("authorization");
|
|
311
|
-
|
|
311
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
312
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
312
313
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
313
314
|
}
|
|
314
315
|
const bearer = auth.slice("Bearer ".length).trim();
|
package/src/api-modules.ts
CHANGED
|
@@ -82,7 +82,7 @@ import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts"
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Resolve a known module to the display + install bootstrap data the admin SPA
|
|
85
|
-
* renders. Reads from FIRST_PARTY_FALLBACKS (notes) first,
|
|
85
|
+
* renders. Reads from FIRST_PARTY_FALLBACKS (notes / app) first,
|
|
86
86
|
* KNOWN_MODULES (vault / scribe / agent / surface) second.
|
|
87
87
|
*
|
|
88
88
|
* Returns `undefined` if the short is in neither table — a genuinely
|
|
@@ -351,15 +351,15 @@ interface ModuleWireShape {
|
|
|
351
351
|
* `.parachute/module.json` `configUiUrl`, joined against its mount path the
|
|
352
352
|
* same way `management_url` resolves `managementUrl`/`uiUrl`. Drives the
|
|
353
353
|
* Modules page's consistent **Configure** action — clicking lands the
|
|
354
|
-
* operator on the module's own config UI (
|
|
355
|
-
*
|
|
356
|
-
*
|
|
354
|
+
* operator on the module's own config UI (for example `/scribe/admin`),
|
|
355
|
+
* which mints its admin Bearer from the hub's cookie-gated
|
|
356
|
+
* `/admin/module-token/<short>`.
|
|
357
357
|
*
|
|
358
358
|
* Null when the module hasn't declared `configUiUrl` — the SPA omits the
|
|
359
359
|
* Configure action for that module rather than rendering a dead button.
|
|
360
360
|
* Distinct from `management_url`: a module may declare one, both, or
|
|
361
|
-
* neither.
|
|
362
|
-
*
|
|
361
|
+
* neither. Vault declares `managementUrl` (its admin SPA is the config
|
|
362
|
+
* surface).
|
|
363
363
|
*/
|
|
364
364
|
config_ui_url: string | null;
|
|
365
365
|
}
|
|
@@ -462,7 +462,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
462
462
|
|
|
463
463
|
// Bearer presence + parsing.
|
|
464
464
|
const auth = req.headers.get("authorization");
|
|
465
|
-
|
|
465
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
466
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
466
467
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
467
468
|
}
|
|
468
469
|
const bearer = auth.slice("Bearer ".length).trim();
|
|
@@ -780,7 +781,8 @@ export async function handleApiModulesChannel(
|
|
|
780
781
|
|
|
781
782
|
// Bearer presence + parsing.
|
|
782
783
|
const auth = req.headers.get("authorization");
|
|
783
|
-
|
|
784
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
785
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
784
786
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
785
787
|
}
|
|
786
788
|
const bearer = auth.slice("Bearer ".length).trim();
|
package/src/api-revoke-token.ts
CHANGED
|
@@ -100,7 +100,8 @@ export async function handleApiRevokeToken(
|
|
|
100
100
|
|
|
101
101
|
// 1. Bearer presence + parsing.
|
|
102
102
|
const auth = req.headers.get("authorization");
|
|
103
|
-
|
|
103
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
104
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
104
105
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
105
106
|
}
|
|
106
107
|
const bearer = auth.slice("Bearer ".length).trim();
|
|
@@ -185,7 +185,8 @@ export async function handleApiSettingsHubOrigin(
|
|
|
185
185
|
// Bearer presence + parsing — identical shape to api-modules
|
|
186
186
|
// for consistency across hub-internal admin endpoints.
|
|
187
187
|
const auth = req.headers.get("authorization");
|
|
188
|
-
|
|
188
|
+
// Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
|
|
189
|
+
if (!auth || !/^Bearer\s+/i.test(auth)) {
|
|
189
190
|
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
190
191
|
}
|
|
191
192
|
const bearer = auth.slice("Bearer ".length).trim();
|