@openparachute/hub 0.3.0-rc.1 → 0.5.1
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 +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub config portal logic (#46) — pure functions over the module manifest +
|
|
3
|
+
* filesystem. Render-side and HTTP-side helpers live in `admin-config-ui.ts`
|
|
4
|
+
* and `admin-handlers.ts`; this file is io + validation.
|
|
5
|
+
*
|
|
6
|
+
* Design choices:
|
|
7
|
+
*
|
|
8
|
+
* - **JSON-only at v1.** The team-lead's brief covers .env / YAML / TOML
|
|
9
|
+
* follow-ups; this commit ships the JSON path so the surface is real
|
|
10
|
+
* for at least one module. Each configurable module's values land at
|
|
11
|
+
* `<configDir>/<name>/config.json` — the same per-module config dir
|
|
12
|
+
* auto-wire and lifecycle already use.
|
|
13
|
+
*
|
|
14
|
+
* - **Skip modules without a `configSchema`.** A module that hasn't
|
|
15
|
+
* declared its operator-editable keys gets no card on the portal, no
|
|
16
|
+
* empty form. This is the explicit edge case from the brief.
|
|
17
|
+
*
|
|
18
|
+
* - **Atomic writes.** Same `tmp + rename` shape as
|
|
19
|
+
* `services-manifest.ts` and `auto-wire.ts` — a crash mid-write must
|
|
20
|
+
* not leave a half-truncated config.json next to a running module.
|
|
21
|
+
*
|
|
22
|
+
* - **Validation = coercion + check.** HTML form values arrive as
|
|
23
|
+
* strings; `validateAndCoerce` coerces each per its declared type
|
|
24
|
+
* (and the `enum` allow-list, when present), reports the first error
|
|
25
|
+
* per field, and returns the typed object on success. Booleans
|
|
26
|
+
* follow the standard form convention: present (any non-empty value)
|
|
27
|
+
* = true, absent = false. Required booleans are accepted as either
|
|
28
|
+
* state — required means "the key is in the schema", not "must be
|
|
29
|
+
* truthy".
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import {
|
|
34
|
+
type ConfigSchema,
|
|
35
|
+
type ConfigSchemaProperty,
|
|
36
|
+
type ModuleManifest,
|
|
37
|
+
readModuleManifest,
|
|
38
|
+
} from "./module-manifest.ts";
|
|
39
|
+
import type { ServiceEntry, ServicesManifest } from "./services-manifest.ts";
|
|
40
|
+
|
|
41
|
+
export interface ConfigurableModule {
|
|
42
|
+
/** Stable ecosystem name (services.json key, route segment). */
|
|
43
|
+
name: string;
|
|
44
|
+
/** Operator-facing label rendered in the portal. */
|
|
45
|
+
displayName: string;
|
|
46
|
+
/** One-line subtitle if the manifest provides one. */
|
|
47
|
+
tagline?: string;
|
|
48
|
+
/** The validated schema from `.parachute/module.json`. */
|
|
49
|
+
schema: ConfigSchema;
|
|
50
|
+
/** Absolute path to `<configDir>/<name>/config.json`. */
|
|
51
|
+
configPath: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DiscoverDeps {
|
|
55
|
+
/** Resolves installed services (production: `services-manifest.readManifest`). */
|
|
56
|
+
loadServicesManifest: () => ServicesManifest;
|
|
57
|
+
/** `~/.parachute` (or `$PARACHUTE_HOME`) — per-module config lives at `<configDir>/<name>/config.json`. */
|
|
58
|
+
configDir: string;
|
|
59
|
+
/** Test seam — defaults to `module-manifest.readModuleManifest`. */
|
|
60
|
+
readManifest?: (installDir: string) => Promise<ModuleManifest | null>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function configPathFor(configDir: string, moduleName: string): string {
|
|
64
|
+
return join(configDir, moduleName, "config.json");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Walk services.json, read each module's `.parachute/module.json` (when its
|
|
69
|
+
* row carries an `installDir`), keep only those with a `configSchema`. The
|
|
70
|
+
* result is sorted by `displayName` so the portal renders deterministically.
|
|
71
|
+
*/
|
|
72
|
+
export async function discoverConfigurableModules(
|
|
73
|
+
deps: DiscoverDeps,
|
|
74
|
+
): Promise<ConfigurableModule[]> {
|
|
75
|
+
const reader = deps.readManifest ?? readModuleManifest;
|
|
76
|
+
const manifest = deps.loadServicesManifest();
|
|
77
|
+
const out: ConfigurableModule[] = [];
|
|
78
|
+
for (const svc of manifest.services) {
|
|
79
|
+
const mod = await readModuleFor(svc, reader);
|
|
80
|
+
if (!mod || !mod.configSchema) continue;
|
|
81
|
+
const entry: ConfigurableModule = {
|
|
82
|
+
name: svc.name,
|
|
83
|
+
displayName: svc.displayName ?? mod.displayName ?? mod.manifestName ?? svc.name,
|
|
84
|
+
schema: mod.configSchema,
|
|
85
|
+
configPath: configPathFor(deps.configDir, svc.name),
|
|
86
|
+
};
|
|
87
|
+
if (svc.tagline ?? mod.tagline) entry.tagline = svc.tagline ?? mod.tagline;
|
|
88
|
+
out.push(entry);
|
|
89
|
+
}
|
|
90
|
+
out.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readModuleFor(
|
|
95
|
+
svc: ServiceEntry,
|
|
96
|
+
reader: (installDir: string) => Promise<ModuleManifest | null>,
|
|
97
|
+
): Promise<ModuleManifest | null> {
|
|
98
|
+
if (!svc.installDir) return null;
|
|
99
|
+
try {
|
|
100
|
+
return await reader(svc.installDir);
|
|
101
|
+
} catch {
|
|
102
|
+
// A malformed third-party manifest shouldn't take down the whole portal —
|
|
103
|
+
// skip the module and let the operator see the others. Logging the
|
|
104
|
+
// skip is the lifecycle layer's job; this stays pure.
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read `<configDir>/<name>/config.json`. Returns `{}` for a missing file,
|
|
111
|
+
* `{}` for a malformed file (with a `parseError` flag) — the portal renders
|
|
112
|
+
* defaults for missing keys either way; surfacing parse failures in the UI
|
|
113
|
+
* without erroring the page lets the operator overwrite a corrupted file.
|
|
114
|
+
*/
|
|
115
|
+
export interface ReadConfigResult {
|
|
116
|
+
data: Record<string, unknown>;
|
|
117
|
+
parseError?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readModuleConfig(configPath: string): ReadConfigResult {
|
|
121
|
+
if (!existsSync(configPath)) return { data: {} };
|
|
122
|
+
let raw: string;
|
|
123
|
+
try {
|
|
124
|
+
raw = readFileSync(configPath, "utf8");
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return { data: {}, parseError: err instanceof Error ? err.message : String(err) };
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(raw);
|
|
130
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
131
|
+
return { data: {}, parseError: "config.json must contain a JSON object" };
|
|
132
|
+
}
|
|
133
|
+
return { data: parsed as Record<string, unknown> };
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return { data: {}, parseError: err instanceof Error ? err.message : String(err) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Atomic write: tmp file + rename, mkdir -p first. Same pattern as
|
|
141
|
+
* services-manifest. Trailing newline so the file plays nice with
|
|
142
|
+
* line-oriented tooling (`wc -l`, diffs).
|
|
143
|
+
*/
|
|
144
|
+
export function writeModuleConfig(configPath: string, data: Record<string, unknown>): void {
|
|
145
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
146
|
+
const tmp = `${configPath}.tmp-${process.pid}-${Date.now()}`;
|
|
147
|
+
writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`);
|
|
148
|
+
renameSync(tmp, configPath);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ValidateConfigResult {
|
|
152
|
+
ok: boolean;
|
|
153
|
+
/** Field-level errors; empty when ok === true. */
|
|
154
|
+
errors: Record<string, string>;
|
|
155
|
+
/** Coerced typed values; populated only when ok === true. */
|
|
156
|
+
data?: Record<string, unknown>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Coerce + validate a form-data submission against `schema`. `formValues`
|
|
161
|
+
* is keyed by property name; checkbox semantics are encoded in the caller
|
|
162
|
+
* (booleans missing → false). The first failure per field is reported;
|
|
163
|
+
* once any field fails, `data` is omitted.
|
|
164
|
+
*/
|
|
165
|
+
export function validateAndCoerce(
|
|
166
|
+
formValues: Record<string, string | boolean | undefined>,
|
|
167
|
+
schema: ConfigSchema,
|
|
168
|
+
): ValidateConfigResult {
|
|
169
|
+
const errors: Record<string, string> = {};
|
|
170
|
+
const data: Record<string, unknown> = {};
|
|
171
|
+
const required = new Set(schema.required ?? []);
|
|
172
|
+
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
173
|
+
const raw = formValues[key];
|
|
174
|
+
const present = raw !== undefined && raw !== "";
|
|
175
|
+
if (!present) {
|
|
176
|
+
if (prop.type === "boolean") {
|
|
177
|
+
// Booleans are always "present" — checkbox absence = false.
|
|
178
|
+
data[key] = false;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (required.has(key)) {
|
|
182
|
+
errors[key] = "required";
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// Missing optional field → omit from output rather than write a null.
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const coerced = coerceValue(raw, prop);
|
|
189
|
+
if ("error" in coerced) {
|
|
190
|
+
errors[key] = coerced.error;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (prop.enum && !prop.enum.includes(coerced.value as string | number)) {
|
|
194
|
+
errors[key] = `must be one of: ${prop.enum.join(", ")}`;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
data[key] = coerced.value;
|
|
198
|
+
}
|
|
199
|
+
if (Object.keys(errors).length > 0) return { ok: false, errors };
|
|
200
|
+
return { ok: true, errors, data };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function coerceValue(
|
|
204
|
+
raw: string | boolean,
|
|
205
|
+
prop: ConfigSchemaProperty,
|
|
206
|
+
): { value: string | number | boolean } | { error: string } {
|
|
207
|
+
switch (prop.type) {
|
|
208
|
+
case "string":
|
|
209
|
+
return { value: typeof raw === "string" ? raw : String(raw) };
|
|
210
|
+
case "boolean":
|
|
211
|
+
if (typeof raw === "boolean") return { value: raw };
|
|
212
|
+
return { value: raw.length > 0 && raw !== "false" && raw !== "0" };
|
|
213
|
+
case "number": {
|
|
214
|
+
const s = typeof raw === "string" ? raw : String(raw);
|
|
215
|
+
const n = Number(s);
|
|
216
|
+
if (!Number.isFinite(n)) return { error: "must be a number" };
|
|
217
|
+
return { value: n };
|
|
218
|
+
}
|
|
219
|
+
case "integer": {
|
|
220
|
+
const s = typeof raw === "string" ? raw : String(raw);
|
|
221
|
+
const n = Number(s);
|
|
222
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) return { error: "must be an integer" };
|
|
223
|
+
return { value: n };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin endpoints for the operator's OAuth-grant skip-list.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/grants[?vault=<name>] list operator's grants
|
|
5
|
+
* DELETE /api/grants/<client_id> revoke one grant
|
|
6
|
+
*
|
|
7
|
+
* Both gated by `parachute:host:admin` Bearer (the SPA mints one via the
|
|
8
|
+
* session cookie at `/admin/host-admin-token`). The "operator" is the JWT
|
|
9
|
+
* `sub` — list and revoke are both scoped to that user, so a host-admin
|
|
10
|
+
* token can't enumerate or delete another user's grants. (The hub is
|
|
11
|
+
* single-operator today; this is forward-looking.)
|
|
12
|
+
*
|
|
13
|
+
* The grants table's primary key is composite `(user_id, client_id)`. Since
|
|
14
|
+
* `user_id` is fixed by the bearer, `client_id` alone is sufficient as the
|
|
15
|
+
* URL-segment "id" — and matches the operator's mental model: "revoke this
|
|
16
|
+
* app's access".
|
|
17
|
+
*
|
|
18
|
+
* Optional `?vault=<name>` filter narrows the list to grants whose scope
|
|
19
|
+
* set touches `vault:<name>:*`. The match is per-grant (any matching scope
|
|
20
|
+
* keeps the row); the row's full scope set is still returned, not a slice.
|
|
21
|
+
*
|
|
22
|
+
* Audit: revocation emits a `console.log("grant revoked: ...")` line in the
|
|
23
|
+
* same `key=value` shape as the existing `consent skipped:` line at
|
|
24
|
+
* `oauth-handlers.ts`. No structured-logging infra exists in the hub yet;
|
|
25
|
+
* matching the prevailing format keeps log-grep ergonomics consistent.
|
|
26
|
+
*/
|
|
27
|
+
import type { Database } from "bun:sqlite";
|
|
28
|
+
import {
|
|
29
|
+
type AdminAuthContext,
|
|
30
|
+
type AdminAuthError,
|
|
31
|
+
adminAuthErrorResponse,
|
|
32
|
+
requireScope,
|
|
33
|
+
} from "./admin-auth.ts";
|
|
34
|
+
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
35
|
+
import { listGrantsForUser, revokeGrant } from "./grants.ts";
|
|
36
|
+
|
|
37
|
+
export interface AdminGrantsDeps {
|
|
38
|
+
db: Database;
|
|
39
|
+
/** Hub origin — passed through to JWT validation as the expected `iss`. */
|
|
40
|
+
issuer: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AdminGrantListing {
|
|
44
|
+
user_id: string;
|
|
45
|
+
client_id: string;
|
|
46
|
+
/** Display name from `clients.client_name`. Null when the client never set one. */
|
|
47
|
+
client_name: string | null;
|
|
48
|
+
scopes: string[];
|
|
49
|
+
granted_at: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function handleListGrants(req: Request, deps: AdminGrantsDeps): Promise<Response> {
|
|
53
|
+
if (req.method !== "GET") {
|
|
54
|
+
return jsonError(405, "method_not_allowed", "use GET");
|
|
55
|
+
}
|
|
56
|
+
let ctx: AdminAuthContext;
|
|
57
|
+
try {
|
|
58
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const url = new URL(req.url);
|
|
64
|
+
const vaultFilter = url.searchParams.get("vault");
|
|
65
|
+
if (vaultFilter !== null && !isValidVaultName(vaultFilter)) {
|
|
66
|
+
return jsonError(400, "invalid_request", "?vault must match [a-zA-Z0-9_-]+");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const grants = listGrantsForUser(deps.db, ctx.sub);
|
|
70
|
+
const filtered = vaultFilter
|
|
71
|
+
? grants.filter((g) => grantTouchesVault(g.scopes, vaultFilter))
|
|
72
|
+
: grants;
|
|
73
|
+
|
|
74
|
+
// One bulk lookup keyed by client_id, instead of N getClient calls. Empty
|
|
75
|
+
// list short-circuits because `IN ()` is a SQL syntax error.
|
|
76
|
+
const names = new Map<string, string | null>();
|
|
77
|
+
if (filtered.length > 0) {
|
|
78
|
+
const placeholders = filtered.map(() => "?").join(",");
|
|
79
|
+
const rows = deps.db
|
|
80
|
+
.query<{ client_id: string; client_name: string | null }, string[]>(
|
|
81
|
+
`SELECT client_id, client_name FROM clients WHERE client_id IN (${placeholders})`,
|
|
82
|
+
)
|
|
83
|
+
.all(...filtered.map((g) => g.clientId));
|
|
84
|
+
for (const r of rows) names.set(r.client_id, r.client_name);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const enriched: AdminGrantListing[] = filtered.map((g) => ({
|
|
88
|
+
user_id: g.userId,
|
|
89
|
+
client_id: g.clientId,
|
|
90
|
+
client_name: names.get(g.clientId) ?? null,
|
|
91
|
+
scopes: g.scopes,
|
|
92
|
+
granted_at: g.grantedAt,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
return new Response(JSON.stringify({ grants: enriched }), {
|
|
96
|
+
status: 200,
|
|
97
|
+
headers: {
|
|
98
|
+
"content-type": "application/json",
|
|
99
|
+
"cache-control": "no-store",
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function handleRevokeGrant(
|
|
105
|
+
req: Request,
|
|
106
|
+
clientId: string,
|
|
107
|
+
deps: AdminGrantsDeps,
|
|
108
|
+
): Promise<Response> {
|
|
109
|
+
if (req.method !== "DELETE") {
|
|
110
|
+
return jsonError(405, "method_not_allowed", "use DELETE");
|
|
111
|
+
}
|
|
112
|
+
let ctx: AdminAuthContext;
|
|
113
|
+
try {
|
|
114
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Capture the prior scopes for the audit line — `revokeGrant` only returns
|
|
120
|
+
// a boolean, and we want the deleted scope set on disk in the log.
|
|
121
|
+
const grants = listGrantsForUser(deps.db, ctx.sub);
|
|
122
|
+
const target = grants.find((g) => g.clientId === clientId);
|
|
123
|
+
if (!target) {
|
|
124
|
+
return jsonError(404, "not_found", `no grant for client ${clientId}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const removed = revokeGrant(deps.db, ctx.sub, clientId);
|
|
128
|
+
if (!removed) {
|
|
129
|
+
// Race: another revoke landed between the read and the delete. Treat
|
|
130
|
+
// as 404 since the operator's intent (no grant for this client) is
|
|
131
|
+
// already satisfied.
|
|
132
|
+
return jsonError(404, "not_found", `no grant for client ${clientId}`);
|
|
133
|
+
}
|
|
134
|
+
console.log(
|
|
135
|
+
`grant revoked: client_id=${clientId} user_id=${ctx.sub} scopes=${target.scopes.join(" ")}`,
|
|
136
|
+
);
|
|
137
|
+
return new Response(null, { status: 204 });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
141
|
+
const VAULT_SCOPE_PREFIX = /^vault:([^:]+):/;
|
|
142
|
+
|
|
143
|
+
function isValidVaultName(name: string): boolean {
|
|
144
|
+
return VAULT_NAME_RE.test(name);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function grantTouchesVault(scopes: readonly string[], vault: string): boolean {
|
|
148
|
+
for (const s of scopes) {
|
|
149
|
+
const m = s.match(VAULT_SCOPE_PREFIX);
|
|
150
|
+
if (m && m[1] === vault) return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
156
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
157
|
+
status,
|
|
158
|
+
headers: { "content-type": "application/json" },
|
|
159
|
+
});
|
|
160
|
+
}
|