@openparachute/hub 0.5.7 → 0.5.10-rc.2
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 +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +238 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/auth/tokens` — paginated list of the hub's `tokens` registry,
|
|
3
|
+
* for the future admin UI's token-management view (Phase 2 of hub#212).
|
|
4
|
+
*
|
|
5
|
+
* Same auth shape as the rest of `/api/auth/*`: bearer-gated on
|
|
6
|
+
* `parachute:host:auth`. The list is intentionally rich — every column
|
|
7
|
+
* the registry holds is surfaced, since the consumer (admin UI) needs
|
|
8
|
+
* status pills, sort, filter, and per-row revoke actions, all of which
|
|
9
|
+
* key off these fields.
|
|
10
|
+
*
|
|
11
|
+
* Wire shape:
|
|
12
|
+
*
|
|
13
|
+
* GET /api/auth/tokens?revoked=true|false|all&subject=...&cursor=...
|
|
14
|
+
* →
|
|
15
|
+
* {
|
|
16
|
+
* "tokens": [
|
|
17
|
+
* {
|
|
18
|
+
* "jti": "...",
|
|
19
|
+
* "user_id": "..." | null,
|
|
20
|
+
* "subject": "..." | null,
|
|
21
|
+
* "client_id": "...",
|
|
22
|
+
* "scopes": [...],
|
|
23
|
+
* "expires_at": "ISO-8601",
|
|
24
|
+
* "revoked_at": "ISO-8601" | null,
|
|
25
|
+
* "created_at": "ISO-8601",
|
|
26
|
+
* "created_via": "oauth_refresh" | "cli_mint" | "operator_mint",
|
|
27
|
+
* "permissions": "<json-string>" | null
|
|
28
|
+
* }
|
|
29
|
+
* ],
|
|
30
|
+
* "next_cursor": "<opaque>" | null
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* Pagination is opaque cursor (newest-first; cursor encodes the previous
|
|
34
|
+
* page's last `(created_at, jti)` composite). Page size is a hardcoded
|
|
35
|
+
* 50 — see `listTokens` in `jwt-sign.ts`.
|
|
36
|
+
*
|
|
37
|
+
* Filter semantics:
|
|
38
|
+
* - `revoked=true` — only revoked rows.
|
|
39
|
+
* - `revoked=false` — only un-revoked rows.
|
|
40
|
+
* - `revoked=all` (or omitted) — all rows.
|
|
41
|
+
* - `subject=<value>` — exact match against either `user_id` (OAuth
|
|
42
|
+
* rows) or `subject` (CLI / operator / service mint rows). The
|
|
43
|
+
* consumer doesn't need to know which column to query; the helper
|
|
44
|
+
* handles both.
|
|
45
|
+
* - `created_via=<value>` — narrow by mint provenance. One of
|
|
46
|
+
* `oauth_refresh` (OAuth refresh-token rotation), `operator_mint`
|
|
47
|
+
* (operator-token rotation via `parachute auth rotate-operator`),
|
|
48
|
+
* or `cli_mint` (CLI / `POST /api/auth/mint-token`). Powers the
|
|
49
|
+
* admin UI's "by source" filter pills (hub#212 Phase F).
|
|
50
|
+
*
|
|
51
|
+
* Why bearer-gated rather than session-cookie-gated: matches the rest
|
|
52
|
+
* of `/api/auth/*` (mint-token, revoke-token), so an automation client
|
|
53
|
+
* holding a `parachute:host:auth` bearer can read the registry without
|
|
54
|
+
* juggling browser session state. The admin UI mints its bearer via
|
|
55
|
+
* the same `getHostAdminToken()` helper that powers the existing
|
|
56
|
+
* `/vaults` and `/api/grants` calls.
|
|
57
|
+
*/
|
|
58
|
+
import type { Database } from "bun:sqlite";
|
|
59
|
+
import { type TokenCreatedVia, listTokens, validateAccessToken } from "./jwt-sign.ts";
|
|
60
|
+
|
|
61
|
+
/** Scope required on the bearer token to call this endpoint. */
|
|
62
|
+
export const API_TOKENS_REQUIRED_SCOPE = "parachute:host:auth";
|
|
63
|
+
|
|
64
|
+
export interface ApiTokensDeps {
|
|
65
|
+
db: Database;
|
|
66
|
+
/** Hub origin — used to validate the bearer's `iss`. */
|
|
67
|
+
issuer: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface TokenWireShape {
|
|
71
|
+
jti: string;
|
|
72
|
+
user_id: string | null;
|
|
73
|
+
subject: string | null;
|
|
74
|
+
client_id: string;
|
|
75
|
+
scopes: string[];
|
|
76
|
+
expires_at: string;
|
|
77
|
+
revoked_at: string | null;
|
|
78
|
+
created_at: string;
|
|
79
|
+
created_via: string;
|
|
80
|
+
/**
|
|
81
|
+
* Parsed `permissions` claim — JSON object as the UI consumer expects.
|
|
82
|
+
* `scopes` is similarly parsed from its space-separated wire form to an
|
|
83
|
+
* array at this boundary; folding `permissions` parsing here keeps the
|
|
84
|
+
* contract uniform (consumers receive native objects, not raw strings).
|
|
85
|
+
* Stored as a JSON string in the DB; if the row's permissions value is
|
|
86
|
+
* malformed (shouldn't happen — `recordTokenMint` validates on write,
|
|
87
|
+
* but defense-in-depth), surface as `null` rather than crashing the
|
|
88
|
+
* list response.
|
|
89
|
+
*/
|
|
90
|
+
permissions: Record<string, unknown> | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface TokensListResponse {
|
|
94
|
+
tokens: TokenWireShape[];
|
|
95
|
+
next_cursor: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function handleApiTokens(req: Request, deps: ApiTokensDeps): Promise<Response> {
|
|
99
|
+
if (req.method !== "GET") {
|
|
100
|
+
return jsonError(405, "method_not_allowed", "use GET");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 1. Bearer presence + parsing.
|
|
104
|
+
const auth = req.headers.get("authorization");
|
|
105
|
+
if (!auth || !auth.startsWith("Bearer ")) {
|
|
106
|
+
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
107
|
+
}
|
|
108
|
+
const bearer = auth.slice("Bearer ".length).trim();
|
|
109
|
+
if (!bearer) {
|
|
110
|
+
return jsonError(401, "unauthenticated", "empty bearer token");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. Bearer validation.
|
|
114
|
+
let bearerScopes: string[];
|
|
115
|
+
try {
|
|
116
|
+
const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
|
|
117
|
+
if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
|
|
118
|
+
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
119
|
+
}
|
|
120
|
+
bearerScopes =
|
|
121
|
+
typeof validated.payload.scope === "string"
|
|
122
|
+
? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
123
|
+
: [];
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
126
|
+
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. Scope gate.
|
|
130
|
+
if (!bearerScopes.includes(API_TOKENS_REQUIRED_SCOPE)) {
|
|
131
|
+
return jsonError(403, "insufficient_scope", `bearer token lacks ${API_TOKENS_REQUIRED_SCOPE}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 4. Query-string parsing. All filters are optional; defaults match
|
|
135
|
+
// listTokens (`revoked=all`, no subject filter, default page size).
|
|
136
|
+
const url = new URL(req.url);
|
|
137
|
+
const revokedParam = url.searchParams.get("revoked");
|
|
138
|
+
let revoked: "true" | "false" | "all" | undefined;
|
|
139
|
+
if (revokedParam === "true" || revokedParam === "false" || revokedParam === "all") {
|
|
140
|
+
revoked = revokedParam;
|
|
141
|
+
} else if (revokedParam !== null) {
|
|
142
|
+
return jsonError(400, "invalid_request", "revoked must be one of: true | false | all");
|
|
143
|
+
}
|
|
144
|
+
const subjectParam = url.searchParams.get("subject");
|
|
145
|
+
const subject =
|
|
146
|
+
typeof subjectParam === "string" && subjectParam.length > 0 ? subjectParam : undefined;
|
|
147
|
+
const createdViaParam = url.searchParams.get("created_via");
|
|
148
|
+
let createdVia: TokenCreatedVia | undefined;
|
|
149
|
+
if (
|
|
150
|
+
createdViaParam === "oauth_refresh" ||
|
|
151
|
+
createdViaParam === "operator_mint" ||
|
|
152
|
+
createdViaParam === "cli_mint"
|
|
153
|
+
) {
|
|
154
|
+
createdVia = createdViaParam;
|
|
155
|
+
} else if (createdViaParam !== null) {
|
|
156
|
+
return jsonError(
|
|
157
|
+
400,
|
|
158
|
+
"invalid_request",
|
|
159
|
+
"created_via must be one of: oauth_refresh | operator_mint | cli_mint",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const cursor = url.searchParams.get("cursor");
|
|
163
|
+
|
|
164
|
+
// 5. Query.
|
|
165
|
+
const page = listTokens(deps.db, {
|
|
166
|
+
filter: {
|
|
167
|
+
...(revoked ? { revoked } : {}),
|
|
168
|
+
...(subject ? { subject } : {}),
|
|
169
|
+
...(createdVia ? { createdVia } : {}),
|
|
170
|
+
},
|
|
171
|
+
cursor,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const body: TokensListResponse = {
|
|
175
|
+
tokens: page.rows.map((r) => ({
|
|
176
|
+
jti: r.jti,
|
|
177
|
+
user_id: r.userId,
|
|
178
|
+
subject: r.subject,
|
|
179
|
+
client_id: r.clientId,
|
|
180
|
+
scopes: r.scopes,
|
|
181
|
+
expires_at: r.expiresAt,
|
|
182
|
+
revoked_at: r.revokedAt,
|
|
183
|
+
created_at: r.createdAt,
|
|
184
|
+
created_via: r.createdVia,
|
|
185
|
+
permissions: parsePermissions(r.permissions),
|
|
186
|
+
})),
|
|
187
|
+
next_cursor: page.nextCursor,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return new Response(JSON.stringify(body), {
|
|
191
|
+
status: 200,
|
|
192
|
+
headers: {
|
|
193
|
+
"content-type": "application/json",
|
|
194
|
+
"cache-control": "no-store",
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Parse a row's `permissions` JSON-string column into the wire shape's
|
|
201
|
+
* native object. `null`/empty stays `null`. Malformed JSON (defense-in-depth;
|
|
202
|
+
* `recordTokenMint` validates on the write side) also surfaces as `null`
|
|
203
|
+
* rather than crashing the list response.
|
|
204
|
+
*/
|
|
205
|
+
function parsePermissions(raw: string | null): Record<string, unknown> | null {
|
|
206
|
+
if (!raw) return null;
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
209
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
210
|
+
return parsed as Record<string, unknown>;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
217
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
218
|
+
status,
|
|
219
|
+
headers: {
|
|
220
|
+
"content-type": "application/json",
|
|
221
|
+
"cache-control": "no-store",
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { exposePublic, exposeTailnet } from "./commands/expose.ts";
|
|
|
13
13
|
import { install } from "./commands/install.ts";
|
|
14
14
|
import { logs, restart, start, stop } from "./commands/lifecycle.ts";
|
|
15
15
|
import { migrate } from "./commands/migrate.ts";
|
|
16
|
+
import { serve } from "./commands/serve.ts";
|
|
16
17
|
import { setup } from "./commands/setup.ts";
|
|
17
18
|
import { status } from "./commands/status.ts";
|
|
18
19
|
import { upgrade } from "./commands/upgrade.ts";
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
logsHelp,
|
|
25
26
|
migrateHelp,
|
|
26
27
|
restartHelp,
|
|
28
|
+
serveHelp,
|
|
27
29
|
setupHelp,
|
|
28
30
|
startHelp,
|
|
29
31
|
statusHelp,
|
|
@@ -589,6 +591,32 @@ async function main(argv: string[]): Promise<number> {
|
|
|
589
591
|
return await migrate({ dryRun, yes });
|
|
590
592
|
}
|
|
591
593
|
|
|
594
|
+
case "serve": {
|
|
595
|
+
if (isHelpFlag(rest[0])) {
|
|
596
|
+
console.log(serveHelp());
|
|
597
|
+
return 0;
|
|
598
|
+
}
|
|
599
|
+
if (rest.length > 0) {
|
|
600
|
+
console.error(`parachute serve: unexpected argument "${rest[0]}"`);
|
|
601
|
+
console.error("usage: parachute serve");
|
|
602
|
+
return 1;
|
|
603
|
+
}
|
|
604
|
+
// `serve` returns once Bun.serve is bound; the listener keeps the
|
|
605
|
+
// event loop alive until SIGINT/SIGTERM, at which point we stop the
|
|
606
|
+
// server cleanly and exit. Container supervisor (tini, Render, Docker)
|
|
607
|
+
// reaps us once the event loop drains.
|
|
608
|
+
const { stop: stopServer } = await serve();
|
|
609
|
+
await new Promise<void>((resolve) => {
|
|
610
|
+
const handler = async () => {
|
|
611
|
+
await stopServer();
|
|
612
|
+
resolve();
|
|
613
|
+
};
|
|
614
|
+
process.on("SIGINT", handler);
|
|
615
|
+
process.on("SIGTERM", handler);
|
|
616
|
+
});
|
|
617
|
+
return 0;
|
|
618
|
+
}
|
|
619
|
+
|
|
592
620
|
case "auth":
|
|
593
621
|
return await auth(rest);
|
|
594
622
|
|