@openparachute/hub 0.7.0 → 0.7.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/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +266 -0
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-connections.ts +916 -14
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-vaults.ts +9 -0
- package/src/api-invites.ts +92 -12
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/connections-store.ts +32 -2
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +501 -12
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +7 -1
- package/src/module-manifest.ts +107 -0
- package/src/origin-check.ts +7 -4
- package/src/services-manifest.ts +97 -0
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
package/src/admin-connections.ts
CHANGED
|
@@ -33,7 +33,43 @@
|
|
|
33
33
|
*
|
|
34
34
|
* AUTH. Same gate as the admin-token mints: a cookie-gated operator session
|
|
35
35
|
* pinned to the first admin. The catalog (`/api/connections/catalog`) is
|
|
36
|
-
* operator-only metadata; it uses the same session gate.
|
|
36
|
+
* operator-only metadata; it uses the same session gate. TWO exceptions:
|
|
37
|
+
* `POST /admin/connections/:id/renew` (H4 credential renewal) and
|
|
38
|
+
* `POST /admin/connections/:id/claim` (surface#113 claim/reconcile) both
|
|
39
|
+
* authenticate by PROOF OF POSSESSION of the connection's current
|
|
40
|
+
* still-valid credential as Bearer — no operator click; an expired
|
|
41
|
+
* credential can neither renew nor claim (the operator re-links in the UI).
|
|
42
|
+
*
|
|
43
|
+
* THE SECOND KIND — `kind: "credential"` (H4, surface-runtime design). A
|
|
44
|
+
* module declares `credentials` in module.json (scope TEMPLATE
|
|
45
|
+
* `vault:{vault}:read|write` — never admin, never another namespace; both
|
|
46
|
+
* the manifest validator and this engine enforce it). The operator approves
|
|
47
|
+
* granting <module> a standing tag-scoped credential on <vault>: the hub
|
|
48
|
+
* mints a REGISTERED 90-day JWT carrying `permissions.scoped_tags`, delivers
|
|
49
|
+
* it to the module's declared endpoint over loopback (authenticated with a
|
|
50
|
+
* short-lived `<module>:admin` bearer — the channel-config delivery shape),
|
|
51
|
+
* and persists the jti + scope + tags on the ConnectionRecord. Teardown
|
|
52
|
+
* revokes the jtis + best-effort notifies the endpoint with a removal
|
|
53
|
+
* payload. Tags are REQUIRED for write scopes (tags are the sharing scope);
|
|
54
|
+
* read may be tag-scoped or vault-wide per the operator's choice (the
|
|
55
|
+
* approval UI defaults to tag-scoped).
|
|
56
|
+
*
|
|
57
|
+
* CLAIM / RECONCILE (surface#113). A credential delivered to a module
|
|
58
|
+
* OUTSIDE this engine (e.g. minted via the CLI and POSTed straight to the
|
|
59
|
+
* module's delivery endpoint) leaves no ConnectionRecord, so jti-bound
|
|
60
|
+
* renewal 404s at the pre-expiry window. `POST /admin/connections/:id/claim`
|
|
61
|
+
* lets the module backfill the record: it presents the credential it ALREADY
|
|
62
|
+
* holds as Bearer (the renew endpoint's proof-of-possession posture), and
|
|
63
|
+
* the hub — after verifying the jti is REGISTERED in the tokens table and
|
|
64
|
+
* that the token's scope/aud/vault_scope match what the claimed connection
|
|
65
|
+
* id implies — writes the record in `status: "pending"`. A claim grants
|
|
66
|
+
* NOTHING: renewal refuses pending records; only the operator-gated
|
|
67
|
+
* `POST /admin/connections/:id/approve` flips it active, after which the
|
|
68
|
+
* existing renewal flow proceeds unchanged. Expired/revoked/unregistered/
|
|
69
|
+
* mismatched claims are refused with ONE generic error (no oracle on
|
|
70
|
+
* registry contents); re-linking through the operator flow is the recovery
|
|
71
|
+
* path. Rejecting a claim = DELETE on the pending record (which revokes the
|
|
72
|
+
* claimed jti — the safe direction).
|
|
37
73
|
*/
|
|
38
74
|
import type { Database } from "bun:sqlite";
|
|
39
75
|
import {
|
|
@@ -44,8 +80,20 @@ import {
|
|
|
44
80
|
readConnections,
|
|
45
81
|
removeConnection,
|
|
46
82
|
} from "./connections-store.ts";
|
|
47
|
-
import {
|
|
48
|
-
|
|
83
|
+
import {
|
|
84
|
+
findTokenRowByJti,
|
|
85
|
+
recordTokenMint,
|
|
86
|
+
revokeTokenByJti,
|
|
87
|
+
signAccessToken,
|
|
88
|
+
validateAccessToken,
|
|
89
|
+
} from "./jwt-sign.ts";
|
|
90
|
+
import {
|
|
91
|
+
CREDENTIAL_SCOPE_TEMPLATE_RE,
|
|
92
|
+
type ModuleAction,
|
|
93
|
+
type ModuleCredential,
|
|
94
|
+
type ModuleEvent,
|
|
95
|
+
type ModuleManifest,
|
|
96
|
+
} from "./module-manifest.ts";
|
|
49
97
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
50
98
|
import { isFirstAdmin } from "./users.ts";
|
|
51
99
|
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
@@ -111,6 +159,16 @@ export interface ConnectionsDeps {
|
|
|
111
159
|
* services.json, or `null` when no vault by that name is installed.
|
|
112
160
|
*/
|
|
113
161
|
resolveVaultOrigin: (vaultName: string) => string | null;
|
|
162
|
+
/**
|
|
163
|
+
* Resolve a module's loopback origin (e.g. `http://127.0.0.1:1946`) by
|
|
164
|
+
* short name, or `null` when not installed (H4 — credential delivery +
|
|
165
|
+
* removal notification go direct to the daemon, not through the hub
|
|
166
|
+
* proxy). Optional: callers that never touch credential connections (and
|
|
167
|
+
* the vault-delete cascade on a hub without H4 consumers) may omit it;
|
|
168
|
+
* delivery then fails with a clear `module_unreachable` step error and
|
|
169
|
+
* teardown logs the skipped notification.
|
|
170
|
+
*/
|
|
171
|
+
resolveModuleOrigin?: (short: string) => string | null;
|
|
114
172
|
/** Loopback origin for the channel daemon, or `null` when not installed. */
|
|
115
173
|
channelOrigin: string | null;
|
|
116
174
|
/** Absolute path to `connections.json` in the hub state dir. */
|
|
@@ -141,6 +199,21 @@ interface CatalogAction {
|
|
|
141
199
|
/** The provision descriptor (e.g. `{ type: "vault-trigger" }`), opaque to the SPA. */
|
|
142
200
|
provision: unknown;
|
|
143
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* A credential declaration (H4) surfaced through the catalog so module UIs
|
|
204
|
+
* can render the link flow (which vaults to offer, which tags to suggest).
|
|
205
|
+
* NO tokens, NO secrets — declaration metadata only, like everything else
|
|
206
|
+
* in the catalog.
|
|
207
|
+
*/
|
|
208
|
+
interface CatalogCredential {
|
|
209
|
+
module: string;
|
|
210
|
+
key: string;
|
|
211
|
+
title: string;
|
|
212
|
+
description: string | null;
|
|
213
|
+
/** The scope TEMPLATE, e.g. `vault:{vault}:read`. */
|
|
214
|
+
scope: string;
|
|
215
|
+
endpoint: string;
|
|
216
|
+
}
|
|
144
217
|
/**
|
|
145
218
|
* A connection preset declared in a module's `module.json`
|
|
146
219
|
* `connectionTemplates` (boundary D2). Drives the SPA builder's one-click
|
|
@@ -175,10 +248,12 @@ export function buildCatalog(modules: InstalledModuleInfo[]): {
|
|
|
175
248
|
events: CatalogEvent[];
|
|
176
249
|
actions: CatalogAction[];
|
|
177
250
|
templates: CatalogTemplate[];
|
|
251
|
+
credentials: CatalogCredential[];
|
|
178
252
|
} {
|
|
179
253
|
const events: CatalogEvent[] = [];
|
|
180
254
|
const actions: CatalogAction[] = [];
|
|
181
255
|
const templates: CatalogTemplate[] = [];
|
|
256
|
+
const credentials: CatalogCredential[] = [];
|
|
182
257
|
for (const { short, manifest } of modules) {
|
|
183
258
|
for (const e of manifest.events ?? []) {
|
|
184
259
|
events.push({
|
|
@@ -197,6 +272,16 @@ export function buildCatalog(modules: InstalledModuleInfo[]): {
|
|
|
197
272
|
provision: a.provision ?? null,
|
|
198
273
|
});
|
|
199
274
|
}
|
|
275
|
+
for (const c of manifest.credentials ?? []) {
|
|
276
|
+
credentials.push({
|
|
277
|
+
module: short,
|
|
278
|
+
key: c.key,
|
|
279
|
+
title: c.title,
|
|
280
|
+
description: c.description ?? null,
|
|
281
|
+
scope: c.scope,
|
|
282
|
+
endpoint: c.endpoint,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
200
285
|
for (const t of manifest.connectionTemplates ?? []) {
|
|
201
286
|
// Only event→action presets surface here — a template without BOTH
|
|
202
287
|
// source and sink (e.g. scribe's `kind: "config"` link, consumed by
|
|
@@ -220,7 +305,7 @@ export function buildCatalog(modules: InstalledModuleInfo[]): {
|
|
|
220
305
|
});
|
|
221
306
|
}
|
|
222
307
|
}
|
|
223
|
-
return { events, actions, templates };
|
|
308
|
+
return { events, actions, templates, credentials };
|
|
224
309
|
}
|
|
225
310
|
|
|
226
311
|
export async function handleConnectionsCatalog(
|
|
@@ -238,24 +323,64 @@ export async function handleConnectionsCatalog(
|
|
|
238
323
|
|
|
239
324
|
export async function handleConnections(
|
|
240
325
|
req: Request,
|
|
241
|
-
/** Path after `/admin/connections` — `""` for the collection, `/<id>` for
|
|
326
|
+
/** Path after `/admin/connections` — `""` for the collection, `/<id>` for
|
|
327
|
+
* an item, `/<id>/renew` for credential renewal (H4). */
|
|
242
328
|
subPath: string,
|
|
243
329
|
deps: ConnectionsDeps,
|
|
244
330
|
): Promise<Response> {
|
|
331
|
+
const method = req.method;
|
|
332
|
+
const segments = subPath.startsWith("/")
|
|
333
|
+
? subPath
|
|
334
|
+
.slice(1)
|
|
335
|
+
.split("/")
|
|
336
|
+
.map((s) => decodeURIComponent(s))
|
|
337
|
+
: [];
|
|
338
|
+
|
|
339
|
+
// H4 — credential renewal. Routed BEFORE the operator gate: the renew
|
|
340
|
+
// endpoint authenticates by proof of possession of the connection's
|
|
341
|
+
// current still-valid credential (Bearer), not by an operator session —
|
|
342
|
+
// a headless module daemon renews without a click. Everything else below
|
|
343
|
+
// stays operator-gated.
|
|
344
|
+
if (segments.length === 2 && segments[1] === "renew") {
|
|
345
|
+
if (method !== "POST") {
|
|
346
|
+
return jsonError(405, "method_not_allowed", "use POST on /admin/connections/:id/renew");
|
|
347
|
+
}
|
|
348
|
+
return renewCredentialConnection(req, segments[0] ?? "", deps);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// surface#113 — claim/reconcile. Same auth class as renew (proof of
|
|
352
|
+
// possession of the credential as Bearer, no operator session), so it's
|
|
353
|
+
// routed before the gate too. A successful claim only writes a PENDING
|
|
354
|
+
// record — the operator-gated approve below is what activates it.
|
|
355
|
+
if (segments.length === 2 && segments[1] === "claim") {
|
|
356
|
+
if (method !== "POST") {
|
|
357
|
+
return jsonError(405, "method_not_allowed", "use POST on /admin/connections/:id/claim");
|
|
358
|
+
}
|
|
359
|
+
return claimCredentialConnection(req, segments[0] ?? "", deps);
|
|
360
|
+
}
|
|
361
|
+
|
|
245
362
|
const gate = operatorGate(req, deps);
|
|
246
363
|
if (gate) return gate;
|
|
247
364
|
const { userId } = sessionUser(req, deps);
|
|
248
365
|
|
|
249
|
-
const
|
|
250
|
-
|
|
366
|
+
const itemId = segments.length === 1 ? (segments[0] ?? "") : "";
|
|
367
|
+
|
|
368
|
+
// surface#113 — operator approval of a pending claim. Cookie-gated like
|
|
369
|
+
// create/teardown (and CSRF-belted by the dispatch in hub-server.ts).
|
|
370
|
+
if (segments.length === 2 && segments[1] === "approve") {
|
|
371
|
+
if (method !== "POST") {
|
|
372
|
+
return jsonError(405, "method_not_allowed", "use POST on /admin/connections/:id/approve");
|
|
373
|
+
}
|
|
374
|
+
return approveCredentialConnection(segments[0] ?? "", deps);
|
|
375
|
+
}
|
|
251
376
|
|
|
252
|
-
if (
|
|
253
|
-
if (
|
|
377
|
+
if (segments.length === 0 && method === "GET") return listConnections(deps);
|
|
378
|
+
if (segments.length === 0 && method === "POST") return createConnection(req, userId, deps);
|
|
254
379
|
if (itemId !== "" && method === "DELETE") return teardownConnection(itemId, userId, deps);
|
|
255
380
|
return jsonError(
|
|
256
381
|
405,
|
|
257
382
|
"method_not_allowed",
|
|
258
|
-
"use GET/POST on /admin/connections or
|
|
383
|
+
"use GET/POST on /admin/connections, DELETE on /admin/connections/:id, or POST on /admin/connections/:id/renew, /claim, /approve",
|
|
259
384
|
);
|
|
260
385
|
}
|
|
261
386
|
|
|
@@ -269,6 +394,14 @@ function listConnections(deps: ConnectionsDeps): Response {
|
|
|
269
394
|
// the response is stable regardless of the on-disk record shape.
|
|
270
395
|
const connections = readConnections(deps.storePath).map((c) => ({
|
|
271
396
|
id: c.id,
|
|
397
|
+
// Kind discriminator (H4): absent = event→action; "credential" = a
|
|
398
|
+
// standing module credential. Projected so the SPA can render the two
|
|
399
|
+
// shapes distinctly.
|
|
400
|
+
...(c.kind !== undefined ? { kind: c.kind } : {}),
|
|
401
|
+
// Approval state (surface#113): "pending" = a module-initiated claim
|
|
402
|
+
// awaiting the operator's one-click approve in the Connections view.
|
|
403
|
+
// Absent = active.
|
|
404
|
+
...(c.status !== undefined ? { status: c.status } : {}),
|
|
272
405
|
source: c.source,
|
|
273
406
|
sink: c.sink,
|
|
274
407
|
provisioned: c.provisioned,
|
|
@@ -294,6 +427,8 @@ function isLegacyRecord(c: ConnectionRecord): boolean {
|
|
|
294
427
|
// ---------------------------------------------------------------------------
|
|
295
428
|
|
|
296
429
|
interface CreateBody {
|
|
430
|
+
/** `"credential"` routes to the H4 flow; absent/anything-else = event→action. */
|
|
431
|
+
kind?: unknown;
|
|
297
432
|
source?: {
|
|
298
433
|
module?: unknown;
|
|
299
434
|
vault?: unknown;
|
|
@@ -305,6 +440,13 @@ interface CreateBody {
|
|
|
305
440
|
action?: unknown;
|
|
306
441
|
params?: unknown;
|
|
307
442
|
};
|
|
443
|
+
/** H4 — the credential request: which module/key, which vault, which tags. */
|
|
444
|
+
credential?: {
|
|
445
|
+
module?: unknown;
|
|
446
|
+
key?: unknown;
|
|
447
|
+
vault?: unknown;
|
|
448
|
+
tags?: unknown;
|
|
449
|
+
};
|
|
308
450
|
/** Optional operator-supplied id; otherwise derived from source/sink. */
|
|
309
451
|
id?: unknown;
|
|
310
452
|
/**
|
|
@@ -327,6 +469,12 @@ async function createConnection(
|
|
|
327
469
|
return jsonError(400, "invalid_request", "request body must be JSON");
|
|
328
470
|
}
|
|
329
471
|
|
|
472
|
+
// H4 — the second kind. Routed by an explicit discriminator so the two
|
|
473
|
+
// body shapes never ambiguously overlap.
|
|
474
|
+
if (str(body.kind) === "credential") {
|
|
475
|
+
return createCredentialConnection(body, userId, deps);
|
|
476
|
+
}
|
|
477
|
+
|
|
330
478
|
const sourceModule = str(body.source?.module);
|
|
331
479
|
const sourceEvent = str(body.source?.event);
|
|
332
480
|
const sinkModule = str(body.sink?.module);
|
|
@@ -599,6 +747,707 @@ async function prepareChannelSink(
|
|
|
599
747
|
}
|
|
600
748
|
}
|
|
601
749
|
|
|
750
|
+
// ===========================================================================
|
|
751
|
+
// kind: "credential" — provision / renew / deliver (H4)
|
|
752
|
+
// ===========================================================================
|
|
753
|
+
|
|
754
|
+
/** TTL of the standing credential (matches the engine's webhook bearer). */
|
|
755
|
+
const CREDENTIAL_TTL_SECONDS = WEBHOOK_BEARER_TTL_SECONDS;
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The payload POSTed to the module's declared endpoint over loopback. One
|
|
759
|
+
* shape for all three lifecycle moments, discriminated by `op`:
|
|
760
|
+
* `"provisioned"` and `"renewed"` carry the token; `"removed"` carries only
|
|
761
|
+
* the identity fields (the module drops its stored credential).
|
|
762
|
+
*/
|
|
763
|
+
interface CredentialPayload {
|
|
764
|
+
kind: "credential";
|
|
765
|
+
op: "provisioned" | "renewed" | "removed";
|
|
766
|
+
connection_id: string;
|
|
767
|
+
key: string;
|
|
768
|
+
vault: string;
|
|
769
|
+
scope: string;
|
|
770
|
+
/** Tag allowlist. Empty = vault-wide (read scopes only). */
|
|
771
|
+
scoped_tags: string[];
|
|
772
|
+
token?: string;
|
|
773
|
+
jti?: string;
|
|
774
|
+
expires_at?: string;
|
|
775
|
+
/** Hub path the module POSTs (Bearer = this token) to renew before expiry. */
|
|
776
|
+
renew_path?: string;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Mint the standing credential for a credential connection: a REGISTERED
|
|
781
|
+
* (created_via "connection_credential") 90-day JWT at `vault:<v>:<verb>`,
|
|
782
|
+
* audience-bound + vault_scope-pinned to the vault, carrying
|
|
783
|
+
* `permissions.scoped_tags` when tags were chosen (the claim path vault's
|
|
784
|
+
* tag-scope enforcement reads — vault/src/auth.ts `scoped_tags`).
|
|
785
|
+
*/
|
|
786
|
+
async function mintCredential(
|
|
787
|
+
deps: ConnectionsDeps,
|
|
788
|
+
userId: string,
|
|
789
|
+
vault: string,
|
|
790
|
+
scope: string,
|
|
791
|
+
scopedTags: readonly string[],
|
|
792
|
+
): Promise<{ token: string; jti: string; expiresAt: string }> {
|
|
793
|
+
const signed = await mint(deps, userId, {
|
|
794
|
+
scopes: [scope],
|
|
795
|
+
audience: `vault.${vault}`,
|
|
796
|
+
vaultScope: [vault],
|
|
797
|
+
ttlSeconds: CREDENTIAL_TTL_SECONDS,
|
|
798
|
+
createdVia: "connection_credential",
|
|
799
|
+
...(scopedTags.length > 0 ? { permissions: { scoped_tags: [...scopedTags] } } : {}),
|
|
800
|
+
});
|
|
801
|
+
return { token: signed.token, jti: signed.jti, expiresAt: signed.expiresAt };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* POST a credential payload to the module's declared endpoint over loopback,
|
|
806
|
+
* authenticated with a short-lived `<module>:admin` bearer (the engine's
|
|
807
|
+
* channel-config delivery shape — the module's endpoint gates on its own
|
|
808
|
+
* admin scope, so a random on-box process can't plant a forged credential).
|
|
809
|
+
*/
|
|
810
|
+
async function deliverCredentialPayload(
|
|
811
|
+
deps: ConnectionsDeps,
|
|
812
|
+
userId: string,
|
|
813
|
+
moduleShort: string,
|
|
814
|
+
endpoint: string,
|
|
815
|
+
payload: CredentialPayload,
|
|
816
|
+
): Promise<{ ok: true } | { ok: false; detail: string }> {
|
|
817
|
+
const moduleOrigin = deps.resolveModuleOrigin?.(moduleShort) ?? null;
|
|
818
|
+
if (moduleOrigin === null) {
|
|
819
|
+
return { ok: false, detail: `module "${moduleShort}" has no resolvable loopback origin` };
|
|
820
|
+
}
|
|
821
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
822
|
+
try {
|
|
823
|
+
const adminBearer = (
|
|
824
|
+
await mint(deps, userId, {
|
|
825
|
+
scopes: [`${moduleShort}:admin`],
|
|
826
|
+
audience: moduleShort,
|
|
827
|
+
vaultScope: [],
|
|
828
|
+
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
829
|
+
})
|
|
830
|
+
).token;
|
|
831
|
+
const res = await fetchImpl(`${moduleOrigin}${endpoint}`, {
|
|
832
|
+
method: "POST",
|
|
833
|
+
headers: {
|
|
834
|
+
authorization: `Bearer ${adminBearer}`,
|
|
835
|
+
"content-type": "application/json",
|
|
836
|
+
},
|
|
837
|
+
body: JSON.stringify(payload),
|
|
838
|
+
});
|
|
839
|
+
if (!res.ok) return { ok: false, detail: await remoteDetail(res) };
|
|
840
|
+
return { ok: true };
|
|
841
|
+
} catch (err) {
|
|
842
|
+
return { ok: false, detail: errMsg(err) };
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* POST /admin/connections with `kind: "credential"` — the operator approves
|
|
848
|
+
* granting <module> a standing tag-scoped credential on <vault>.
|
|
849
|
+
*
|
|
850
|
+
* Validation (the privilege-escalation guard's runtime half):
|
|
851
|
+
* - the module must be installed AND must DECLARE the credential key;
|
|
852
|
+
* - the declared scope template must (still) be `vault:{vault}:read|write`
|
|
853
|
+
* — re-checked here even though the manifest validator enforces it, so a
|
|
854
|
+
* manifest read through a non-validating path can't widen the grant;
|
|
855
|
+
* - the vault must exist; tags must be non-empty strings;
|
|
856
|
+
* - WRITE scopes require non-empty tags (tags are the sharing scope —
|
|
857
|
+
* an untagged write credential would be a vault-wide write). Read may be
|
|
858
|
+
* vault-wide per operator choice (the UI defaults to tag-scoped).
|
|
859
|
+
*
|
|
860
|
+
* Provision order: mint (registered) → deliver to the module's endpoint →
|
|
861
|
+
* persist. A failed delivery revokes the fresh mint — an undelivered live
|
|
862
|
+
* credential must not outlive the request.
|
|
863
|
+
*
|
|
864
|
+
* Re-approval: POSTing the same module/key/vault again (the expired-renewal
|
|
865
|
+
* path) upserts by the derived id; the prior record's jtis are revoked first
|
|
866
|
+
* so exactly one live credential exists per connection.
|
|
867
|
+
*/
|
|
868
|
+
async function createCredentialConnection(
|
|
869
|
+
body: CreateBody,
|
|
870
|
+
userId: string,
|
|
871
|
+
deps: ConnectionsDeps,
|
|
872
|
+
): Promise<Response> {
|
|
873
|
+
const moduleShort = str(body.credential?.module);
|
|
874
|
+
const key = str(body.credential?.key);
|
|
875
|
+
const vault = str(body.credential?.vault);
|
|
876
|
+
if (!moduleShort || !key || !vault) {
|
|
877
|
+
return jsonError(
|
|
878
|
+
400,
|
|
879
|
+
"invalid_request",
|
|
880
|
+
"credential.module, credential.key, credential.vault are all required",
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const requestedByRaw = str(body.requestedBy);
|
|
885
|
+
const requestedBy = requestedByRaw === "" ? DEFAULT_REQUESTED_BY : requestedByRaw.toLowerCase();
|
|
886
|
+
if (!REQUESTED_BY_RE.test(requestedBy)) {
|
|
887
|
+
return jsonError(
|
|
888
|
+
400,
|
|
889
|
+
"invalid_request",
|
|
890
|
+
`requestedBy "${requestedByRaw}" is not a valid label (letters, numbers, dash, underscore)`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// --- Declaration check: installed module, declared key, sane template. ---
|
|
895
|
+
const mod = deps.modules.find((m) => m.short === moduleShort);
|
|
896
|
+
if (!mod) return jsonError(400, "unknown_module", `no installed module "${moduleShort}"`);
|
|
897
|
+
const decl = findCredentialDecl(mod.manifest, key);
|
|
898
|
+
if (!decl) {
|
|
899
|
+
return jsonError(
|
|
900
|
+
400,
|
|
901
|
+
"unknown_credential",
|
|
902
|
+
`module "${moduleShort}" declares no credential "${key}"`,
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
// Escalation guard, runtime half: ONLY vault:{vault}:read|write. A module
|
|
906
|
+
// requesting vault:{vault}:admin, scribe:{vault}:read, or a literal vault
|
|
907
|
+
// name is refused regardless of what its manifest says.
|
|
908
|
+
if (!CREDENTIAL_SCOPE_TEMPLATE_RE.test(decl.scope)) {
|
|
909
|
+
return jsonError(
|
|
910
|
+
400,
|
|
911
|
+
"invalid_scope",
|
|
912
|
+
`credential "${moduleShort}.${key}" declares scope "${decl.scope}" — only "vault:{vault}:read" or "vault:{vault}:write" are grantable (never admin, never another namespace)`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
if (!decl.endpoint || !decl.endpoint.startsWith("/")) {
|
|
916
|
+
return jsonError(
|
|
917
|
+
400,
|
|
918
|
+
"credential_underdeclared",
|
|
919
|
+
`credential "${moduleShort}.${key}" declares no delivery endpoint`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// --- Vault + tags. ---------------------------------------------------------
|
|
924
|
+
if (!VAULT_NAME_CHARSET_RE.test(vault)) {
|
|
925
|
+
return jsonError(
|
|
926
|
+
400,
|
|
927
|
+
"invalid_request",
|
|
928
|
+
`credential.vault "${vault}" is not a valid identifier`,
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
if (deps.resolveVaultOrigin(vault) === null) {
|
|
932
|
+
return jsonError(400, "unknown_vault", `no vault named "${vault}" in this hub`);
|
|
933
|
+
}
|
|
934
|
+
const rawTags = body.credential?.tags;
|
|
935
|
+
if (rawTags !== undefined && !Array.isArray(rawTags)) {
|
|
936
|
+
return jsonError(400, "invalid_request", "credential.tags must be an array of tag names");
|
|
937
|
+
}
|
|
938
|
+
const tags: string[] = [];
|
|
939
|
+
for (const t of (rawTags as unknown[] | undefined) ?? []) {
|
|
940
|
+
if (typeof t !== "string" || t.trim().length === 0) {
|
|
941
|
+
return jsonError(400, "invalid_request", "credential.tags entries must be non-empty strings");
|
|
942
|
+
}
|
|
943
|
+
tags.push(t.trim());
|
|
944
|
+
}
|
|
945
|
+
const verb = decl.scope.endsWith(":write") ? "write" : "read";
|
|
946
|
+
if (verb === "write" && tags.length === 0) {
|
|
947
|
+
return jsonError(
|
|
948
|
+
400,
|
|
949
|
+
"invalid_request",
|
|
950
|
+
"a write credential requires a non-empty tag scope — tags are the sharing scope; vault-wide write is not grantable here",
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
const scope = `vault:${vault}:${verb}`;
|
|
954
|
+
|
|
955
|
+
// --- Id (stable per module/key/vault → re-approve upserts). ----------------
|
|
956
|
+
const suppliedId = str(body.id);
|
|
957
|
+
const id = (suppliedId || `cred-${moduleShort}-${key}-${vault}`).toLowerCase();
|
|
958
|
+
if (!CONNECTION_ID_RE.test(id)) {
|
|
959
|
+
return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Re-approval path: revoke the prior record's still-registered jtis BEFORE
|
|
963
|
+
// minting the replacement, so exactly one live credential exists per
|
|
964
|
+
// connection (idempotent for already-revoked/expired rows).
|
|
965
|
+
const prior = readConnections(deps.storePath).find((r) => r.id === id);
|
|
966
|
+
if (prior) {
|
|
967
|
+
const now = deps.now?.() ?? new Date();
|
|
968
|
+
for (const jti of prior.provisioned?.mintedJtis ?? []) {
|
|
969
|
+
try {
|
|
970
|
+
revokeTokenByJti(deps.db, jti, now);
|
|
971
|
+
} catch {
|
|
972
|
+
// Best-effort — a missing registry row leaves nothing to revoke.
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// --- Mint (registered) → deliver → persist. --------------------------------
|
|
978
|
+
let minted: { token: string; jti: string; expiresAt: string };
|
|
979
|
+
try {
|
|
980
|
+
minted = await mintCredential(deps, userId, vault, scope, tags);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
return stepError("mint_credential", err);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const payload: CredentialPayload = {
|
|
986
|
+
kind: "credential",
|
|
987
|
+
op: "provisioned",
|
|
988
|
+
connection_id: id,
|
|
989
|
+
key,
|
|
990
|
+
vault,
|
|
991
|
+
scope,
|
|
992
|
+
scoped_tags: tags,
|
|
993
|
+
token: minted.token,
|
|
994
|
+
jti: minted.jti,
|
|
995
|
+
expires_at: minted.expiresAt,
|
|
996
|
+
renew_path: `/admin/connections/${id}/renew`,
|
|
997
|
+
};
|
|
998
|
+
const delivered = await deliverCredentialPayload(
|
|
999
|
+
deps,
|
|
1000
|
+
userId,
|
|
1001
|
+
moduleShort,
|
|
1002
|
+
decl.endpoint,
|
|
1003
|
+
payload,
|
|
1004
|
+
);
|
|
1005
|
+
if (!delivered.ok) {
|
|
1006
|
+
// An undelivered live credential must not outlive the request.
|
|
1007
|
+
try {
|
|
1008
|
+
revokeTokenByJti(deps.db, minted.jti, deps.now?.() ?? new Date());
|
|
1009
|
+
} catch {
|
|
1010
|
+
// Registry row just written by mint() — failure here is exotic; the
|
|
1011
|
+
// step error below still surfaces the delivery fault.
|
|
1012
|
+
}
|
|
1013
|
+
return stepError("credential_delivery", delivered.detail);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const record: ConnectionRecord = {
|
|
1017
|
+
id,
|
|
1018
|
+
kind: "credential",
|
|
1019
|
+
// The source of authority is the granting vault; the sink is the module
|
|
1020
|
+
// holding the credential. Populating both keeps the store filter, the
|
|
1021
|
+
// list projection, and the vault-delete cascade (`source.vault === name
|
|
1022
|
+
// || provisioned.vault === name`) uniform across both kinds.
|
|
1023
|
+
source: { module: "vault", vault, event: "credential" },
|
|
1024
|
+
sink: {
|
|
1025
|
+
module: moduleShort,
|
|
1026
|
+
action: `credential.${key}`,
|
|
1027
|
+
...(tags.length > 0 ? { params: { tags } } : {}),
|
|
1028
|
+
},
|
|
1029
|
+
provisioned: {
|
|
1030
|
+
type: "credential",
|
|
1031
|
+
vault,
|
|
1032
|
+
mintedJtis: [minted.jti],
|
|
1033
|
+
scope,
|
|
1034
|
+
scopedTags: tags,
|
|
1035
|
+
credentialKey: key,
|
|
1036
|
+
endpoint: decl.endpoint,
|
|
1037
|
+
},
|
|
1038
|
+
createdAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
1039
|
+
requestedBy,
|
|
1040
|
+
};
|
|
1041
|
+
putConnection(deps.storePath, record);
|
|
1042
|
+
|
|
1043
|
+
return json(200, { ok: true, connection: record, expires_at: minted.expiresAt });
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* POST /admin/connections/:id/renew — credential renewal by PROOF OF
|
|
1048
|
+
* POSSESSION: the caller presents the connection's CURRENT still-valid
|
|
1049
|
+
* credential as Bearer. No operator click — a headless module daemon renews
|
|
1050
|
+
* before expiry.
|
|
1051
|
+
*
|
|
1052
|
+
* The possession check is the load-bearing gate: the presented token must
|
|
1053
|
+
* (a) verify against the hub's JWKS (signature, expiry, revocation — via
|
|
1054
|
+
* `validateAccessToken` WITHOUT an issuer pin; the signature proves the hub
|
|
1055
|
+
* minted it, and the jti binding below makes a foreign-issuer replay
|
|
1056
|
+
* structurally impossible), and (b) carry the EXACT jti recorded on this
|
|
1057
|
+
* connection. An expired or revoked credential fails (a) → 401 → the
|
|
1058
|
+
* operator re-approves in the UI (the upsert path in create).
|
|
1059
|
+
*
|
|
1060
|
+
* Renewal re-mints the SAME scope + tags from the record (never request
|
|
1061
|
+
* input), delivers the fresh credential in the RESPONSE BODY (the caller is
|
|
1062
|
+
* the proven credential holder — that's the delivery; no second loopback
|
|
1063
|
+
* POST), revokes the old jti, and updates the record.
|
|
1064
|
+
*/
|
|
1065
|
+
async function renewCredentialConnection(
|
|
1066
|
+
req: Request,
|
|
1067
|
+
id: string,
|
|
1068
|
+
deps: ConnectionsDeps,
|
|
1069
|
+
): Promise<Response> {
|
|
1070
|
+
if (!CONNECTION_ID_RE.test(id)) {
|
|
1071
|
+
return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
|
|
1072
|
+
}
|
|
1073
|
+
const record = readConnections(deps.storePath).find((r) => r.id === id);
|
|
1074
|
+
if (!record || record.kind !== "credential") {
|
|
1075
|
+
return jsonError(404, "not_found", `no credential connection "${id}"`);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const auth = req.headers.get("authorization");
|
|
1079
|
+
if (!auth || !auth.startsWith("Bearer ")) {
|
|
1080
|
+
return jsonError(
|
|
1081
|
+
401,
|
|
1082
|
+
"unauthenticated",
|
|
1083
|
+
"renewal requires the connection's current credential as Authorization: Bearer",
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
const bearer = auth.slice("Bearer ".length).trim();
|
|
1087
|
+
let presentedJti: string;
|
|
1088
|
+
try {
|
|
1089
|
+
// Deliberately NO expectedIssuer pin here — unlike the audience gate's
|
|
1090
|
+
// Bearer branch (audience-gate.ts → validateHostAdminToken, iss ∈ the
|
|
1091
|
+
// hub's bound-origin set). See the fn docstring: the JWKS signature
|
|
1092
|
+
// proves local issuance, and the jti binding below makes a foreign
|
|
1093
|
+
// replay structurally impossible — an iss check would add nothing but
|
|
1094
|
+
// the #516 loopback-vs-public false-reject class.
|
|
1095
|
+
const validated = await validateAccessToken(deps.db, bearer);
|
|
1096
|
+
presentedJti = typeof validated.payload.jti === "string" ? validated.payload.jti : "";
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
// Signature/expiry/revocation failure — including the EXPIRED case the
|
|
1099
|
+
// design calls out: an expired credential cannot renew itself; the
|
|
1100
|
+
// operator re-approves in the UI.
|
|
1101
|
+
return jsonError(
|
|
1102
|
+
401,
|
|
1103
|
+
"invalid_credential",
|
|
1104
|
+
`credential is not valid (expired credentials require operator re-approval in the hub UI): ${errMsg(err)}`,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
const currentJtis = record.provisioned?.mintedJtis ?? [];
|
|
1108
|
+
if (!presentedJti || !currentJtis.includes(presentedJti)) {
|
|
1109
|
+
return jsonError(
|
|
1110
|
+
403,
|
|
1111
|
+
"not_credential_holder",
|
|
1112
|
+
"the presented token is not this connection's current credential",
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// surface#113 — a CLAIMED record grants nothing until the operator
|
|
1117
|
+
// approves. Checked AFTER the possession proof so the pending state is
|
|
1118
|
+
// revealed only to the actual credential holder (the claimant itself).
|
|
1119
|
+
if (record.status === "pending") {
|
|
1120
|
+
return jsonError(
|
|
1121
|
+
403,
|
|
1122
|
+
"pending_approval",
|
|
1123
|
+
"this connection's claim awaits operator approval in the hub admin Connections view — renewal is enabled after approval",
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const vault = record.provisioned?.vault ?? "";
|
|
1128
|
+
const scope = record.provisioned?.scope ?? "";
|
|
1129
|
+
const scopedTags = record.provisioned?.scopedTags ?? [];
|
|
1130
|
+
const key = record.provisioned?.credentialKey ?? "";
|
|
1131
|
+
if (!vault || !scope) {
|
|
1132
|
+
return jsonError(500, "record_corrupt", `credential connection "${id}" has no minted shape`);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Renewal authority is the connection itself (operator approved the
|
|
1136
|
+
// standing grant; renewal extends it without escalation — same scope, same
|
|
1137
|
+
// tags). No operator user is in the loop, so the registry row carries the
|
|
1138
|
+
// provenance subject only (empty userId → mint() omits user_id).
|
|
1139
|
+
let minted: { token: string; jti: string; expiresAt: string };
|
|
1140
|
+
try {
|
|
1141
|
+
minted = await mintCredential(deps, "", vault, scope, scopedTags);
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
return stepError("mint_credential", err);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Revoke the old credential, persist the new jti. The ORDERING (mint new →
|
|
1147
|
+
// revoke old → write record → respond) is a deliberate trade-off: a
|
|
1148
|
+
// connection drop after the record write but before the response leaves
|
|
1149
|
+
// the module holding NEITHER credential (old revoked, new never received)
|
|
1150
|
+
// → operator re-approval required. We fail toward lockout, never toward
|
|
1151
|
+
// two live credentials. If that window ever bites in practice, the future
|
|
1152
|
+
// option is a retrieve-current-by-jti endpoint (present the revoked-but-
|
|
1153
|
+
// recorded predecessor, fetch its successor) — not reordering the steps.
|
|
1154
|
+
const now = deps.now?.() ?? new Date();
|
|
1155
|
+
for (const jti of currentJtis) {
|
|
1156
|
+
try {
|
|
1157
|
+
revokeTokenByJti(deps.db, jti, now);
|
|
1158
|
+
} catch {
|
|
1159
|
+
// Best-effort; the new mint is already the only one the record names.
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
const updated: ConnectionRecord = {
|
|
1163
|
+
...record,
|
|
1164
|
+
provisioned: {
|
|
1165
|
+
...record.provisioned,
|
|
1166
|
+
mintedJtis: [minted.jti],
|
|
1167
|
+
},
|
|
1168
|
+
};
|
|
1169
|
+
putConnection(deps.storePath, updated);
|
|
1170
|
+
|
|
1171
|
+
const payload: CredentialPayload = {
|
|
1172
|
+
kind: "credential",
|
|
1173
|
+
op: "renewed",
|
|
1174
|
+
connection_id: id,
|
|
1175
|
+
key,
|
|
1176
|
+
vault,
|
|
1177
|
+
scope,
|
|
1178
|
+
scoped_tags: [...scopedTags],
|
|
1179
|
+
token: minted.token,
|
|
1180
|
+
jti: minted.jti,
|
|
1181
|
+
expires_at: minted.expiresAt,
|
|
1182
|
+
renew_path: `/admin/connections/${id}/renew`,
|
|
1183
|
+
};
|
|
1184
|
+
return json(200, { ok: true, credential: payload });
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* POST /admin/connections/:id/claim — backfill the hub-side record for a
|
|
1189
|
+
* credential that was delivered to a module OUTSIDE this engine (surface#113:
|
|
1190
|
+
* CLI-minted + POSTed straight to the module's delivery endpoint), so the
|
|
1191
|
+
* existing jti-bound renewal flow can find a record instead of 404ing.
|
|
1192
|
+
*
|
|
1193
|
+
* AUTH mirrors renew: proof of possession — the module presents the
|
|
1194
|
+
* credential it ALREADY holds as Bearer; the jti is derived from the
|
|
1195
|
+
* validated token, never from request input. The claim grants NOTHING:
|
|
1196
|
+
*
|
|
1197
|
+
* - the presented token must verify (signature / expiry / revocation —
|
|
1198
|
+
* an expired or revoked credential cannot be claimed; re-link via the
|
|
1199
|
+
* operator flow is the path);
|
|
1200
|
+
* - its jti must be REGISTERED in the tokens table (the registered-mint
|
|
1201
|
+
* rule is the precondition for renewal anyway), with the registry row
|
|
1202
|
+
* recording the same scope;
|
|
1203
|
+
* - the token's scope / aud / vault_scope must carry EXACTLY the grant the
|
|
1204
|
+
* claimed connection id implies (`cred-<module>-<key>-<vault>` +
|
|
1205
|
+
* the module's DECLARED credential template — same declaration checks
|
|
1206
|
+
* as create);
|
|
1207
|
+
* - the record is written `status: "pending"`: renewal refuses it until
|
|
1208
|
+
* the operator's one-click approve in the Connections view.
|
|
1209
|
+
*
|
|
1210
|
+
* So the only thing a claim can ever enable — and only after explicit
|
|
1211
|
+
* operator approval — is renewal of a token the module already holds, at the
|
|
1212
|
+
* scope/tags already baked into that token. NOTE the deliberate asymmetry
|
|
1213
|
+
* with create: a claim ACCEPTS the existing token's shape verbatim,
|
|
1214
|
+
* including an untagged write (create refuses those for NEW grants) — the
|
|
1215
|
+
* operator already granted that shape when they minted + delivered it, and
|
|
1216
|
+
* the approve click is the explicit sanction of carrying it forward.
|
|
1217
|
+
*
|
|
1218
|
+
* All post-authentication mismatches refuse with ONE generic error so the
|
|
1219
|
+
* endpoint is not an oracle on registry contents; the specific reason is
|
|
1220
|
+
* logged server-side (no token material).
|
|
1221
|
+
*
|
|
1222
|
+
* Idempotency: re-claiming with the same credential returns the same pending
|
|
1223
|
+
* record (no dupes). A pending record's claim may be superseded by another
|
|
1224
|
+
* fully-valid claim for the same id (pending grants nothing — last writer
|
|
1225
|
+
* wins until approval). An ACTIVE record is never touched: claiming it with
|
|
1226
|
+
* its own current credential reports "active" (renewal already works);
|
|
1227
|
+
* anything else is refused.
|
|
1228
|
+
*/
|
|
1229
|
+
async function claimCredentialConnection(
|
|
1230
|
+
req: Request,
|
|
1231
|
+
id: string,
|
|
1232
|
+
deps: ConnectionsDeps,
|
|
1233
|
+
): Promise<Response> {
|
|
1234
|
+
if (!CONNECTION_ID_RE.test(id)) {
|
|
1235
|
+
return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
|
|
1236
|
+
}
|
|
1237
|
+
let body: { module?: unknown; key?: unknown; vault?: unknown };
|
|
1238
|
+
try {
|
|
1239
|
+
body = (await req.json()) as { module?: unknown; key?: unknown; vault?: unknown };
|
|
1240
|
+
} catch {
|
|
1241
|
+
return jsonError(400, "invalid_request", "request body must be JSON");
|
|
1242
|
+
}
|
|
1243
|
+
const moduleShort = str(body.module);
|
|
1244
|
+
const key = str(body.key);
|
|
1245
|
+
const vault = str(body.vault);
|
|
1246
|
+
if (!moduleShort || !key || !vault) {
|
|
1247
|
+
return jsonError(400, "invalid_request", "module, key, vault are all required");
|
|
1248
|
+
}
|
|
1249
|
+
if (!VAULT_NAME_CHARSET_RE.test(vault)) {
|
|
1250
|
+
return jsonError(400, "invalid_request", `vault "${vault}" is not a valid identifier`);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const auth = req.headers.get("authorization");
|
|
1254
|
+
if (!auth || !auth.startsWith("Bearer ")) {
|
|
1255
|
+
return jsonError(
|
|
1256
|
+
401,
|
|
1257
|
+
"unauthenticated",
|
|
1258
|
+
"a claim requires the delivered credential as Authorization: Bearer",
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
const bearer = auth.slice("Bearer ".length).trim();
|
|
1262
|
+
let payload: Record<string, unknown>;
|
|
1263
|
+
try {
|
|
1264
|
+
// Same validation posture as renew (and the same deliberate absence of
|
|
1265
|
+
// an issuer pin — see renewCredentialConnection): signature proves local
|
|
1266
|
+
// issuance; the registry + claim-shape binding below does the rest.
|
|
1267
|
+
payload = (await validateAccessToken(deps.db, bearer)).payload as Record<string, unknown>;
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
// Signature/expiry/revocation failure — an expired or revoked credential
|
|
1270
|
+
// cannot be claimed; the operator re-links through the module's flow.
|
|
1271
|
+
return jsonError(
|
|
1272
|
+
401,
|
|
1273
|
+
"invalid_credential",
|
|
1274
|
+
`credential is not valid (expired or revoked credentials cannot be claimed — re-link via the operator flow): ${errMsg(err)}`,
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Every post-authentication mismatch refuses identically (no oracle on
|
|
1279
|
+
// registry contents); the reason is logged server-side, token-free.
|
|
1280
|
+
const reject = (reason: string): Response => {
|
|
1281
|
+
console.warn(`[connections] claim for "${id}" rejected: ${reason}`);
|
|
1282
|
+
return jsonError(
|
|
1283
|
+
403,
|
|
1284
|
+
"claim_rejected",
|
|
1285
|
+
"the presented credential does not match a claimable connection",
|
|
1286
|
+
);
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
const jti = typeof payload.jti === "string" ? payload.jti : "";
|
|
1290
|
+
if (!jti) return reject("token carries no jti");
|
|
1291
|
+
|
|
1292
|
+
// Registry half: the jti must be a REGISTERED mint (renewal's revocation
|
|
1293
|
+
// lifecycle depends on the row; an unregistered long-lived token is
|
|
1294
|
+
// unrevocable by construction and not reconcilable here).
|
|
1295
|
+
const registryRow = findTokenRowByJti(deps.db, jti);
|
|
1296
|
+
if (!registryRow) return reject("jti is not in the token registry");
|
|
1297
|
+
// NOTE: created_via is deliberately NOT filtered here. A claim grandfathers
|
|
1298
|
+
// a token that already exists and is already registered — provenance
|
|
1299
|
+
// (cli_mint, connection_credential, …) adds no authority either way, and
|
|
1300
|
+
// a connection_credential jti with an active record is already refused by
|
|
1301
|
+
// the existing-record check below.
|
|
1302
|
+
|
|
1303
|
+
// Declaration half — the same checks create performs, so a claim can't
|
|
1304
|
+
// smuggle past anything the operator-initiated path would refuse.
|
|
1305
|
+
const mod = deps.modules.find((m) => m.short === moduleShort);
|
|
1306
|
+
if (!mod) return reject(`no installed module "${moduleShort}"`);
|
|
1307
|
+
const decl = findCredentialDecl(mod.manifest, key);
|
|
1308
|
+
if (!decl) return reject(`module "${moduleShort}" declares no credential "${key}"`);
|
|
1309
|
+
if (!CREDENTIAL_SCOPE_TEMPLATE_RE.test(decl.scope)) {
|
|
1310
|
+
return reject(`declared scope template "${decl.scope}" is not grantable`);
|
|
1311
|
+
}
|
|
1312
|
+
if (!decl.endpoint || !decl.endpoint.startsWith("/")) {
|
|
1313
|
+
return reject(`credential "${moduleShort}.${key}" declares no delivery endpoint`);
|
|
1314
|
+
}
|
|
1315
|
+
if (deps.resolveVaultOrigin(vault) === null) return reject(`no vault named "${vault}"`);
|
|
1316
|
+
|
|
1317
|
+
// Identity half: the claimed id must be EXACTLY the id the hub derives for
|
|
1318
|
+
// this module/key/vault (the same derivation create uses by default) — the
|
|
1319
|
+
// id alone is ambiguous to parse (keys may contain dashes), so the body
|
|
1320
|
+
// names the parts and the derivation closes the loop.
|
|
1321
|
+
const impliedId = `cred-${moduleShort}-${key}-${vault}`.toLowerCase();
|
|
1322
|
+
if (id !== impliedId)
|
|
1323
|
+
return reject(`id does not match module/key/vault (implies "${impliedId}")`);
|
|
1324
|
+
|
|
1325
|
+
// Token-shape half: the presented credential must carry EXACTLY the grant
|
|
1326
|
+
// the connection implies — scope at the declared verb, audience-bound and
|
|
1327
|
+
// vault_scope-pinned to the vault (what makes it usable there at all).
|
|
1328
|
+
const verb = decl.scope.endsWith(":write") ? "write" : "read";
|
|
1329
|
+
const scope = `vault:${vault}:${verb}`;
|
|
1330
|
+
const tokenScopes =
|
|
1331
|
+
typeof payload.scope === "string" ? payload.scope.split(" ").filter((s) => s.length > 0) : [];
|
|
1332
|
+
if (!tokenScopes.includes(scope)) return reject(`token scope does not include "${scope}"`);
|
|
1333
|
+
const aud = payload.aud;
|
|
1334
|
+
const audOk = aud === `vault.${vault}` || (Array.isArray(aud) && aud.includes(`vault.${vault}`));
|
|
1335
|
+
if (!audOk) return reject(`token aud is not "vault.${vault}"`);
|
|
1336
|
+
// vault_scope: connection-minted tokens pin the vault here; CLI-minted
|
|
1337
|
+
// tokens (the very population claims reconcile — surface#113's live case)
|
|
1338
|
+
// carry vault_scope: [] and pin the vault via scope + aud instead, both
|
|
1339
|
+
// already exact-matched above. An EMPTY vault_scope is therefore accepted;
|
|
1340
|
+
// a NON-empty one that omits this vault is a genuine mismatch (the token
|
|
1341
|
+
// was pinned elsewhere) and is refused.
|
|
1342
|
+
const vaultScopePin = Array.isArray(payload.vault_scope) ? payload.vault_scope : [];
|
|
1343
|
+
if (vaultScopePin.length > 0 && !vaultScopePin.includes(vault)) {
|
|
1344
|
+
return reject(`token vault_scope does not pin "${vault}"`);
|
|
1345
|
+
}
|
|
1346
|
+
if (!registryRow.scopes.includes(scope)) return reject("registry row scope mismatch");
|
|
1347
|
+
|
|
1348
|
+
// Tags ride along verbatim from the SIGNED token (never request input) —
|
|
1349
|
+
// renewal will re-mint exactly this shape.
|
|
1350
|
+
const scopedTags = readScopedTagsClaim(payload);
|
|
1351
|
+
|
|
1352
|
+
const nowIso = (deps.now?.() ?? new Date()).toISOString();
|
|
1353
|
+
const pendingRecord: ConnectionRecord = {
|
|
1354
|
+
id,
|
|
1355
|
+
kind: "credential",
|
|
1356
|
+
status: "pending",
|
|
1357
|
+
source: { module: "vault", vault, event: "credential" },
|
|
1358
|
+
sink: {
|
|
1359
|
+
module: moduleShort,
|
|
1360
|
+
action: `credential.${key}`,
|
|
1361
|
+
...(scopedTags.length > 0 ? { params: { tags: scopedTags } } : {}),
|
|
1362
|
+
},
|
|
1363
|
+
provisioned: {
|
|
1364
|
+
type: "credential",
|
|
1365
|
+
vault,
|
|
1366
|
+
mintedJtis: [jti],
|
|
1367
|
+
scope,
|
|
1368
|
+
scopedTags,
|
|
1369
|
+
credentialKey: key,
|
|
1370
|
+
endpoint: decl.endpoint,
|
|
1371
|
+
},
|
|
1372
|
+
createdAt: nowIso,
|
|
1373
|
+
requestedBy: moduleShort,
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
const existing = readConnections(deps.storePath).find((r) => r.id === id);
|
|
1377
|
+
if (existing) {
|
|
1378
|
+
if (existing.kind !== "credential") {
|
|
1379
|
+
return reject("id names an existing non-credential connection");
|
|
1380
|
+
}
|
|
1381
|
+
const holdsCurrent = (existing.provisioned?.mintedJtis ?? []).includes(jti);
|
|
1382
|
+
if (existing.status !== "pending") {
|
|
1383
|
+
// ACTIVE record: never mutated by a claim. The current holder learns
|
|
1384
|
+
// renewal already works; anything else is refused generically.
|
|
1385
|
+
if (holdsCurrent) {
|
|
1386
|
+
return json(200, { ok: true, connection_id: id, status: "active" });
|
|
1387
|
+
}
|
|
1388
|
+
return reject("an active connection already exists for this id");
|
|
1389
|
+
}
|
|
1390
|
+
if (holdsCurrent) {
|
|
1391
|
+
// Idempotent re-claim — same pending record, no dupes, no rewrite.
|
|
1392
|
+
return json(202, claimPendingBody(id));
|
|
1393
|
+
}
|
|
1394
|
+
// A different fully-validated credential supersedes the unapproved claim
|
|
1395
|
+
// (pending grants nothing; last writer wins until the operator approves).
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
putConnection(deps.storePath, pendingRecord);
|
|
1399
|
+
return json(202, claimPendingBody(id));
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function claimPendingBody(id: string): Record<string, unknown> {
|
|
1403
|
+
return {
|
|
1404
|
+
ok: true,
|
|
1405
|
+
connection_id: id,
|
|
1406
|
+
status: "pending",
|
|
1407
|
+
detail:
|
|
1408
|
+
"claim recorded — awaiting operator approval in the hub admin Connections view; renewal is enabled after approval",
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** `permissions.scoped_tags` from a validated token payload (strings only). */
|
|
1413
|
+
function readScopedTagsClaim(payload: Record<string, unknown>): string[] {
|
|
1414
|
+
const permissions = payload.permissions;
|
|
1415
|
+
if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return [];
|
|
1416
|
+
const tags = (permissions as Record<string, unknown>).scoped_tags;
|
|
1417
|
+
if (!Array.isArray(tags)) return [];
|
|
1418
|
+
return tags.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* POST /admin/connections/:id/approve — the operator's one-click activation
|
|
1423
|
+
* of a pending claim (surface#113). Operator-gated by the caller (same
|
|
1424
|
+
* session gate as create/teardown, CSRF-belted in hub-server.ts). Flips
|
|
1425
|
+
* `status: "pending"` → active by dropping the field; mints NOTHING and
|
|
1426
|
+
* delivers NOTHING — the module already holds the credential, approval only
|
|
1427
|
+
* lets the existing renewal flow find the record. Idempotent: approving an
|
|
1428
|
+
* already-active credential record reports active without rewriting it.
|
|
1429
|
+
*/
|
|
1430
|
+
function approveCredentialConnection(id: string, deps: ConnectionsDeps): Response {
|
|
1431
|
+
if (!CONNECTION_ID_RE.test(id)) {
|
|
1432
|
+
return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
|
|
1433
|
+
}
|
|
1434
|
+
const record = readConnections(deps.storePath).find((r) => r.id === id);
|
|
1435
|
+
if (!record) return jsonError(404, "not_found", `no connection "${id}"`);
|
|
1436
|
+
if (record.kind !== "credential") {
|
|
1437
|
+
return jsonError(400, "not_claimable", `connection "${id}" is not a credential connection`);
|
|
1438
|
+
}
|
|
1439
|
+
if (record.status !== "pending") {
|
|
1440
|
+
return json(200, { ok: true, id, status: "active" });
|
|
1441
|
+
}
|
|
1442
|
+
const { status: _pending, ...approved } = record;
|
|
1443
|
+
putConnection(deps.storePath, approved);
|
|
1444
|
+
return json(200, { ok: true, id, status: "active" });
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function findCredentialDecl(m: ModuleManifest, key: string): ModuleCredential | undefined {
|
|
1448
|
+
return (m.credentials ?? []).find((c) => c.key === key);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
602
1451
|
// ---------------------------------------------------------------------------
|
|
603
1452
|
// DELETE — teardown
|
|
604
1453
|
// ---------------------------------------------------------------------------
|
|
@@ -656,8 +1505,43 @@ export async function teardownConnection(
|
|
|
656
1505
|
}
|
|
657
1506
|
}
|
|
658
1507
|
|
|
1508
|
+
// --- Credential removal notification (H4, best-effort). -------------------
|
|
1509
|
+
// The module holding the credential gets a removal payload at its declared
|
|
1510
|
+
// endpoint so it can drop the stored token. Best-effort by design: the jti
|
|
1511
|
+
// revocation below is the authoritative kill (the revocation list reaches
|
|
1512
|
+
// every resource server); a missed notification only leaves the module
|
|
1513
|
+
// holding a dead credential it will discover on first use.
|
|
1514
|
+
if (record.kind === "credential") {
|
|
1515
|
+
const endpoint = record.provisioned?.endpoint;
|
|
1516
|
+
const key = record.provisioned?.credentialKey ?? "";
|
|
1517
|
+
const credVault = record.provisioned?.vault ?? "";
|
|
1518
|
+
if (endpoint) {
|
|
1519
|
+
const removal: CredentialPayload = {
|
|
1520
|
+
kind: "credential",
|
|
1521
|
+
op: "removed",
|
|
1522
|
+
connection_id: record.id,
|
|
1523
|
+
key,
|
|
1524
|
+
vault: credVault,
|
|
1525
|
+
scope: record.provisioned?.scope ?? "",
|
|
1526
|
+
scoped_tags: [...(record.provisioned?.scopedTags ?? [])],
|
|
1527
|
+
};
|
|
1528
|
+
const notified = await deliverCredentialPayload(
|
|
1529
|
+
deps,
|
|
1530
|
+
userId,
|
|
1531
|
+
record.sink.module,
|
|
1532
|
+
endpoint,
|
|
1533
|
+
removal,
|
|
1534
|
+
);
|
|
1535
|
+
if (!notified.ok) {
|
|
1536
|
+
errors.push({ step: "credential_notify", detail: notified.detail });
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
659
1541
|
// --- Channel-sink teardown (remove the channel config entry). ------------
|
|
660
|
-
|
|
1542
|
+
// Fenced to event→action records: a credential connection whose HOLDER is
|
|
1543
|
+
// the channel module must not delete an unrelated channel config entry.
|
|
1544
|
+
if (record.kind !== "credential" && record.sink.module === "channel" && deps.channelOrigin) {
|
|
661
1545
|
const channelName =
|
|
662
1546
|
typeof record.sink.params?.channel === "string" ? record.sink.params.channel : record.id;
|
|
663
1547
|
try {
|
|
@@ -904,18 +1788,33 @@ interface MintSpec {
|
|
|
904
1788
|
audience: string;
|
|
905
1789
|
vaultScope: string[];
|
|
906
1790
|
ttlSeconds: number;
|
|
1791
|
+
/**
|
|
1792
|
+
* Registry provenance for long-lived mints. Defaults to the engine's
|
|
1793
|
+
* original `connection_provision`; credential connections (H4) pass
|
|
1794
|
+
* `connection_credential` so the registry distinguishes the two grants.
|
|
1795
|
+
*/
|
|
1796
|
+
createdVia?: "connection_provision" | "connection_credential";
|
|
1797
|
+
/**
|
|
1798
|
+
* Extra `permissions` claim (H4 — `{ scoped_tags: [...] }`, the claim path
|
|
1799
|
+
* vault's tag-scope enforcement reads). Embedded in the JWT AND persisted
|
|
1800
|
+
* (JSON) on the registry row.
|
|
1801
|
+
*/
|
|
1802
|
+
permissions?: Record<string, unknown>;
|
|
907
1803
|
}
|
|
908
1804
|
|
|
909
1805
|
async function mint(deps: ConnectionsDeps, userId: string, spec: MintSpec) {
|
|
910
1806
|
const sign = deps.signToken ?? signAccessToken;
|
|
911
1807
|
const signed = await sign(deps.db, {
|
|
912
|
-
sub
|
|
1808
|
+
// `sub` falls back to the provenance subject when no operator user is in
|
|
1809
|
+
// the loop (H4 renewal is module-initiated — no session, no user row).
|
|
1810
|
+
sub: userId || "connection",
|
|
913
1811
|
scopes: spec.scopes,
|
|
914
1812
|
audience: spec.audience,
|
|
915
1813
|
clientId: PROVISION_CLIENT_ID,
|
|
916
1814
|
issuer: deps.hubOrigin,
|
|
917
1815
|
ttlSeconds: spec.ttlSeconds,
|
|
918
1816
|
vaultScope: spec.vaultScope,
|
|
1817
|
+
...(spec.permissions !== undefined ? { extraClaims: { permissions: spec.permissions } } : {}),
|
|
919
1818
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
920
1819
|
});
|
|
921
1820
|
// Register long-lived mints so they're revocable on teardown. Short-lived
|
|
@@ -923,12 +1822,15 @@ async function mint(deps: ConnectionsDeps, userId: string, spec: MintSpec) {
|
|
|
923
1822
|
if (spec.ttlSeconds > REGISTERED_MINT_TTL_THRESHOLD_SECONDS) {
|
|
924
1823
|
recordTokenMint(deps.db, {
|
|
925
1824
|
jti: signed.jti,
|
|
926
|
-
createdVia: "connection_provision",
|
|
1825
|
+
createdVia: spec.createdVia ?? "connection_provision",
|
|
927
1826
|
subject: "connection",
|
|
928
|
-
|
|
1827
|
+
// tokens.user_id carries an FK to users(id) — only write it when a
|
|
1828
|
+
// real operator user is in the loop (empty = renewal, no session).
|
|
1829
|
+
...(userId ? { userId } : {}),
|
|
929
1830
|
clientId: PROVISION_CLIENT_ID,
|
|
930
1831
|
scopes: spec.scopes,
|
|
931
1832
|
expiresAt: signed.expiresAt,
|
|
1833
|
+
...(spec.permissions !== undefined ? { permissions: JSON.stringify(spec.permissions) } : {}),
|
|
932
1834
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
933
1835
|
});
|
|
934
1836
|
}
|