@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.
Files changed (83) hide show
  1. package/README.md +7 -7
  2. package/package.json +8 -13
  3. package/src/__tests__/account-api.test.ts +798 -0
  4. package/src/__tests__/account-session.test.ts +252 -0
  5. package/src/__tests__/account-token.test.ts +316 -0
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-handlers.test.ts +25 -0
  8. package/src/__tests__/admin-lock.test.ts +3 -14
  9. package/src/__tests__/admin-module-token.test.ts +10 -30
  10. package/src/__tests__/admin-surfaces.test.ts +21 -0
  11. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  12. package/src/__tests__/api-mint-token.test.ts +25 -0
  13. package/src/__tests__/api-modules-ops.test.ts +34 -29
  14. package/src/__tests__/api-modules.test.ts +50 -58
  15. package/src/__tests__/api-revoke-token.test.ts +23 -0
  16. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  17. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  18. package/src/__tests__/api-tokens.test.ts +44 -0
  19. package/src/__tests__/audience-gate.test.ts +24 -0
  20. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  21. package/src/__tests__/chrome-strip.test.ts +18 -1
  22. package/src/__tests__/doctor.test.ts +10 -17
  23. package/src/__tests__/door-contract-parity.test.ts +46 -0
  24. package/src/__tests__/hub-command.test.ts +70 -2
  25. package/src/__tests__/hub-server.test.ts +322 -1
  26. package/src/__tests__/hub-settings.test.ts +110 -6
  27. package/src/__tests__/hub.test.ts +29 -0
  28. package/src/__tests__/install.test.ts +359 -5
  29. package/src/__tests__/migrate.test.ts +3 -1
  30. package/src/__tests__/notes-serve.test.ts +216 -0
  31. package/src/__tests__/oauth-handlers.test.ts +19 -8
  32. package/src/__tests__/operator-token.test.ts +1 -2
  33. package/src/__tests__/port-assign.test.ts +37 -17
  34. package/src/__tests__/root-serve.test.ts +139 -0
  35. package/src/__tests__/scope-explanations.test.ts +22 -2
  36. package/src/__tests__/serve-boot.test.ts +25 -36
  37. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  38. package/src/__tests__/services-manifest.test.ts +372 -132
  39. package/src/__tests__/sessions.test.ts +75 -28
  40. package/src/__tests__/setup-wizard.test.ts +7 -10
  41. package/src/__tests__/setup.test.ts +13 -14
  42. package/src/__tests__/status.test.ts +0 -5
  43. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  44. package/src/account-api.ts +677 -0
  45. package/src/account-session.ts +132 -0
  46. package/src/account-token.ts +200 -0
  47. package/src/admin-connections.ts +4 -2
  48. package/src/admin-lock.ts +1 -2
  49. package/src/admin-module-token.ts +13 -8
  50. package/src/admin-surfaces.ts +2 -1
  51. package/src/api-hub-upgrade.ts +2 -1
  52. package/src/api-invites.ts +19 -0
  53. package/src/api-mint-token.ts +2 -1
  54. package/src/api-modules-ops.ts +2 -1
  55. package/src/api-modules.ts +10 -8
  56. package/src/api-revoke-token.ts +2 -1
  57. package/src/api-settings-hub-origin.ts +2 -1
  58. package/src/api-settings-root-redirect.ts +97 -20
  59. package/src/api-tokens.ts +2 -1
  60. package/src/audience-gate.ts +2 -1
  61. package/src/chrome-strip.ts +16 -4
  62. package/src/commands/hub.ts +104 -0
  63. package/src/commands/install.ts +97 -8
  64. package/src/commands/migrate.ts +5 -1
  65. package/src/commands/setup.ts +9 -6
  66. package/src/help.ts +6 -6
  67. package/src/hub-server.ts +318 -55
  68. package/src/hub-settings.ts +162 -1
  69. package/src/hub.ts +64 -31
  70. package/src/invites.ts +42 -0
  71. package/src/module-ops-client.ts +2 -1
  72. package/src/notes-serve.ts +73 -31
  73. package/src/oauth-handlers.ts +1 -11
  74. package/src/operator-token.ts +0 -1
  75. package/src/origin-check.ts +2 -2
  76. package/src/root-serve.ts +156 -0
  77. package/src/scope-explanations.ts +35 -5
  78. package/src/service-spec.ts +129 -74
  79. package/src/services-manifest.ts +112 -52
  80. package/src/sessions.ts +66 -30
  81. package/src/surface-notes-alias.ts +126 -0
  82. package/src/__tests__/admin-agent-token.test.ts +0 -173
  83. 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
+ }
@@ -1082,7 +1082,8 @@ async function renewCredentialConnection(
1082
1082
  }
1083
1083
 
1084
1084
  const auth = req.headers.get("authorization");
1085
- if (!auth || !auth.startsWith("Bearer ")) {
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
- if (!auth || !auth.startsWith("Bearer ")) {
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 four share the exact `parseSessionCookie → findSession → [isFirstAdmin]
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 the same shape the agent config UI gets
12
- * from `/admin/agent-token` and the vault admin SPA gets from
13
- * `/admin/vault-admin-token/<name>`. This is the GENERIC mint that covers every
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`, `agent`) — the same shape `inferAudience`
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 / vault-admin-token / agent-token. A friend account holds
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 host/vault/agent admin-token. UI re-fetches on near-expiry. */
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).
@@ -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
- if (!auth || !auth.startsWith("Bearer ")) {
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();
@@ -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
- if (!auth || !auth.startsWith("Bearer ")) {
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();
@@ -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({
@@ -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
- if (!auth || !auth.startsWith("Bearer ")) {
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();
@@ -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
- if (!auth || !auth.startsWith("Bearer ")) {
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();
@@ -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 (agent `/agent/admin`, scribe
355
- * `/scribe/admin`, …), which mints its admin Bearer from the hub's
356
- * cookie-gated `/admin/module-token/<short>` (or `/admin/agent-token`).
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. Agent declares `configUiUrl: "/agent/admin"` + `uiUrl`;
362
- * vault declares `managementUrl` (its admin SPA is the config surface).
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
- if (!auth || !auth.startsWith("Bearer ")) {
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
- if (!auth || !auth.startsWith("Bearer ")) {
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();
@@ -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
- if (!auth || !auth.startsWith("Bearer ")) {
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
- if (!auth || !auth.startsWith("Bearer ")) {
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();