@openparachute/hub 0.7.4-rc.9 → 0.7.4
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-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +33 -1
- package/src/__tests__/admin-vaults.test.ts +52 -9
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +298 -0
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/oauth-handlers.test.ts +207 -21
- package/src/__tests__/oauth-ui.test.ts +52 -0
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/account-setup.ts +2 -0
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- package/src/admin-vaults.ts +70 -15
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +14 -1
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +29 -0
- package/src/clients.ts +164 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +53 -0
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +123 -19
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/oauth-handlers.ts +14 -1
- package/src/oauth-ui.ts +51 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
package/src/hub-server.ts
CHANGED
|
@@ -69,9 +69,11 @@
|
|
|
69
69
|
* # "CSRF-belted" = strict same-origin Origin check on cookie-authed
|
|
70
70
|
* # mutations (hub#632, boundary C1) — origin-check.ts
|
|
71
71
|
* # `assertSameOriginForCookieMutation` carries the canonical enumeration.
|
|
72
|
-
* /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
|
|
72
|
+
* /api/me (GET) → who-am-I (session+CSRF+two_factor_enabled or hasSession:false)
|
|
73
73
|
* /api/admin-lock (GET) → screen-lock status (cookie-gated; first-admin)
|
|
74
74
|
* /api/admin-lock/{set,change,remove,unlock,lock,heartbeat} (POST) → manage the optional admin idle PIN lock (cookie-gated; CSRF)
|
|
75
|
+
* /api/account/2fa/{start,confirm,disable} (POST) → self-service 2FA for the SPA (cookie-gated; CSRF; self-only) — hub#85
|
|
76
|
+
* /api/account/password (POST) → self-service password change for the SPA (cookie-gated; CSRF; self-only) — hub#85
|
|
75
77
|
* /api/hub (GET) → hub version + uptime + install-source (host:admin)
|
|
76
78
|
* /api/hub/upgrade (POST) → SPA-driven hub self-upgrade → 202 + detached helper (host:admin, §5.3/D4)
|
|
77
79
|
* /api/hub/upgrade/status (GET) → poll the on-disk hub-upgrade status (host:admin)
|
|
@@ -85,6 +87,7 @@
|
|
|
85
87
|
* /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
|
|
86
88
|
* /api/modules/operations/:id (GET) → poll async op status
|
|
87
89
|
* /api/settings/hub-origin (GET + PUT) → canonical hub URL (host:admin)
|
|
90
|
+
* /api/settings/root-redirect (GET + PUT) → bare-`/` redirect target (host:admin)
|
|
88
91
|
* /api/auth/mint-token (POST) → CLI/automation token mint (bearer)
|
|
89
92
|
* /api/auth/revoke-token (POST) → revoke registry-row token by jti
|
|
90
93
|
* /api/auth/tokens (GET) → paginated registry list
|
|
@@ -169,7 +172,7 @@ import {
|
|
|
169
172
|
handleOAuthGrantCallback,
|
|
170
173
|
} from "./admin-agent-grants.ts";
|
|
171
174
|
import { handleAgentToken } from "./admin-agent-token.ts";
|
|
172
|
-
import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
|
|
175
|
+
import { handleApproveClient, handleDeleteClient, handleGetClient } from "./admin-clients.ts";
|
|
173
176
|
import {
|
|
174
177
|
type ConnectionsDeps,
|
|
175
178
|
handleConnections,
|
|
@@ -186,6 +189,7 @@ import { handleHostAdminToken } from "./admin-host-admin-token.ts";
|
|
|
186
189
|
import { handleModuleToken } from "./admin-module-token.ts";
|
|
187
190
|
import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
|
|
188
191
|
import { handleCreateVault, handleDeleteVault } from "./admin-vaults.ts";
|
|
192
|
+
import { handleApiAccount } from "./api-account-2fa.ts";
|
|
189
193
|
import {
|
|
190
194
|
handleAccountChangePasswordGet,
|
|
191
195
|
handleAccountChangePasswordPost,
|
|
@@ -214,6 +218,7 @@ import { handleApiReady } from "./api-ready.ts";
|
|
|
214
218
|
import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
|
|
215
219
|
import { handleApiRevokeToken } from "./api-revoke-token.ts";
|
|
216
220
|
import { handleApiSettingsHubOrigin } from "./api-settings-hub-origin.ts";
|
|
221
|
+
import { handleApiSettingsRootRedirect } from "./api-settings-root-redirect.ts";
|
|
217
222
|
import { handleApiTokens } from "./api-tokens.ts";
|
|
218
223
|
import {
|
|
219
224
|
handleCreateUser,
|
|
@@ -243,7 +248,7 @@ import {
|
|
|
243
248
|
startDbPathLivenessTimer,
|
|
244
249
|
} from "./hub-db-liveness.ts";
|
|
245
250
|
import { hubDbPath, openHubDb } from "./hub-db.ts";
|
|
246
|
-
import { getHubOrigin } from "./hub-settings.ts";
|
|
251
|
+
import { getHubOrigin, resolveRootRedirect } from "./hub-settings.ts";
|
|
247
252
|
import { type RenderHubOpts, renderHub } from "./hub.ts";
|
|
248
253
|
import { pemToJwk } from "./jwks.ts";
|
|
249
254
|
import {
|
|
@@ -2476,23 +2481,32 @@ export function hubFetch(
|
|
|
2476
2481
|
);
|
|
2477
2482
|
}
|
|
2478
2483
|
|
|
2479
|
-
// Bare `/` → `/admin
|
|
2480
|
-
// SPA used to be two disconnected surfaces;
|
|
2481
|
-
// the single coherent admin shell, whose
|
|
2482
|
-
// discovery content (hub-native sections,
|
|
2483
|
-
// used to live here.
|
|
2484
|
+
// Bare `/` → configurable target (default `/admin`, the admin-shell IA).
|
|
2485
|
+
// The home page and the admin SPA used to be two disconnected surfaces;
|
|
2486
|
+
// `/` funnels straight into the single coherent admin shell, whose
|
|
2487
|
+
// Home/Overview carries the discovery content (hub-native sections,
|
|
2488
|
+
// modules, user surfaces) that used to live here.
|
|
2489
|
+
//
|
|
2490
|
+
// The target is operator-configurable (resolveRootRedirect): a hub_settings
|
|
2491
|
+
// `root_redirect` row → `PARACHUTE_HUB_ROOT_REDIRECT` env → `/admin`
|
|
2492
|
+
// default. Lets an operator point their hub's root at a surface (e.g. a
|
|
2493
|
+
// team reading-room) instead of the admin shell, without redeploying. The
|
|
2494
|
+
// resolver re-validates every layer through the same-origin guard
|
|
2495
|
+
// (`isSafeRedirectPath`) so a stored/env value can NEVER produce an open
|
|
2496
|
+
// redirect — an unsafe value is ignored and falls back to `/admin`.
|
|
2484
2497
|
//
|
|
2485
2498
|
// Ordering matters: this sits AFTER the fresh-hub wizard funnel above
|
|
2486
|
-
// (so a brand-new operator still lands on `/admin/setup`, not a
|
|
2487
|
-
//
|
|
2488
|
-
// 503s API callers correctly). 302 (not 301) —
|
|
2489
|
-
//
|
|
2490
|
-
//
|
|
2499
|
+
// (so a brand-new operator still lands on `/admin/setup`, not a surface
|
|
2500
|
+
// that can't work yet) and AFTER the pre-admin lockout (so an admin-less
|
|
2501
|
+
// hub still 503s API callers correctly). 302 (not 301) — the target is
|
|
2502
|
+
// operator-mutable, so a permanent/cached redirect would strand visitors
|
|
2503
|
+
// on a stale destination after the operator flips it.
|
|
2491
2504
|
//
|
|
2492
|
-
// The signed-out path is preserved
|
|
2493
|
-
// `/admin`, where the SPA's AuthIndicator
|
|
2494
|
-
// round-trips through `/login?next=/admin/...`
|
|
2495
|
-
// redirect on session state — the shell
|
|
2505
|
+
// The signed-out path is preserved when the target is `/admin`: a
|
|
2506
|
+
// signed-out visitor lands on `/admin`, where the SPA's AuthIndicator
|
|
2507
|
+
// shows a "Sign in" link that round-trips through `/login?next=/admin/...`
|
|
2508
|
+
// and back. We don't pin the redirect on session state — the shell
|
|
2509
|
+
// handles both auth states itself.
|
|
2496
2510
|
//
|
|
2497
2511
|
// `/hub.html` is INTENTIONALLY excluded: it still renders the discovery
|
|
2498
2512
|
// page (used by the static `parachute expose --set-path=/` disk file and
|
|
@@ -2500,7 +2514,7 @@ export function hubFetch(
|
|
|
2500
2514
|
if (pathname === "/") {
|
|
2501
2515
|
return new Response(null, {
|
|
2502
2516
|
status: 302,
|
|
2503
|
-
headers: { location:
|
|
2517
|
+
headers: { location: resolveRootRedirect(getDb ? getDb() : null) },
|
|
2504
2518
|
});
|
|
2505
2519
|
}
|
|
2506
2520
|
|
|
@@ -2764,6 +2778,34 @@ export function hubFetch(
|
|
|
2764
2778
|
return applyCorsHeaders(req, await handleRevoke(getDb(), req, oauthDeps(req)));
|
|
2765
2779
|
}
|
|
2766
2780
|
|
|
2781
|
+
// RFC 7592 client deregistration: DELETE /oauth/clients/<id> (hub#640).
|
|
2782
|
+
// Mounted at this TOP-LEVEL `/oauth/clients/` prefix — NOT under
|
|
2783
|
+
// `/api/oauth/clients/` — because that's the path parachute-surface's
|
|
2784
|
+
// remove-flow actually calls (`packages/surface-host/src/dcr.ts` fires a
|
|
2785
|
+
// best-effort DELETE on every Notes/Claude reconnect, carrying the
|
|
2786
|
+
// operator token as a Bearer). Without it the hub 404'd every such
|
|
2787
|
+
// DELETE and orphaned a `clients` row per reconnect. Operator-bearer-
|
|
2788
|
+
// gated (parachute:host:admin) inside handleDeleteClient; 204 on delete,
|
|
2789
|
+
// 404 if absent. CORS-wrapped + OPTIONS-preflighted like its OAuth
|
|
2790
|
+
// siblings (the top-of-dispatch isCorsAllowedRoute("/oauth/") preempts
|
|
2791
|
+
// the preflight). The GET/approve sub-paths stay on `/api/oauth/clients/`
|
|
2792
|
+
// (the SPA-facing admin surface) below.
|
|
2793
|
+
if (pathname.startsWith("/oauth/clients/")) {
|
|
2794
|
+
if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
|
|
2795
|
+
const clientId = decodeURIComponent(pathname.slice("/oauth/clients/".length));
|
|
2796
|
+
if (!clientId || clientId.includes("/")) {
|
|
2797
|
+
return applyCorsHeaders(req, new Response("not found", { status: 404 }));
|
|
2798
|
+
}
|
|
2799
|
+
return applyCorsHeaders(
|
|
2800
|
+
req,
|
|
2801
|
+
await handleDeleteClient(req, clientId, {
|
|
2802
|
+
db: getDb(),
|
|
2803
|
+
issuer: oauthDeps(req).issuer,
|
|
2804
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
2805
|
+
}),
|
|
2806
|
+
);
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2767
2809
|
// Agent-connector OAuth-client callback (Phase 4b-2). The operator's
|
|
2768
2810
|
// browser is redirected here by a REMOTE issuer after consenting to a
|
|
2769
2811
|
// `kind:mcp` grant. Standalone server-rendered route — NOT under /admin/*,
|
|
@@ -2801,6 +2843,7 @@ export function hubFetch(
|
|
|
2801
2843
|
return handleCreateVault(req, {
|
|
2802
2844
|
db: getDb(),
|
|
2803
2845
|
issuer: oauthDeps(req).issuer,
|
|
2846
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
2804
2847
|
});
|
|
2805
2848
|
}
|
|
2806
2849
|
|
|
@@ -2827,6 +2870,7 @@ export function hubFetch(
|
|
|
2827
2870
|
return handleDeleteVault(req, name, {
|
|
2828
2871
|
db: getDb(),
|
|
2829
2872
|
issuer: oauthDeps(req).issuer,
|
|
2873
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
2830
2874
|
manifestPath,
|
|
2831
2875
|
connectionsStorePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
|
|
2832
2876
|
agentOrigin,
|
|
@@ -2983,6 +3027,10 @@ export function hubFetch(
|
|
|
2983
3027
|
const agentGrantsDeps: AgentGrantsDeps = {
|
|
2984
3028
|
db: getDb(),
|
|
2985
3029
|
hubOrigin: oauthDeps(req).issuer,
|
|
3030
|
+
// hub#516 parity: validate the module's host-admin bearer `iss`
|
|
3031
|
+
// against the hub's known-origin set (PUT /admin/grants is the only
|
|
3032
|
+
// bearer-gated route here; the POST /approve|/revoke are cookie-authed).
|
|
3033
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
2986
3034
|
storePath: deps?.agentGrantsStorePath ?? join(CONFIG_DIR, "agent-grants.json"),
|
|
2987
3035
|
flowsStorePath:
|
|
2988
3036
|
deps?.agentOAuthFlowsStorePath ?? join(CONFIG_DIR, "agent-oauth-flows.json"),
|
|
@@ -3044,6 +3092,24 @@ export function hubFetch(
|
|
|
3044
3092
|
return handleAdminLock(req, subpath, { db: getDb() });
|
|
3045
3093
|
}
|
|
3046
3094
|
|
|
3095
|
+
// JSON self-service account surfaces for the admin SPA "My account" page
|
|
3096
|
+
// (hub#85): password change + 2FA enroll/confirm/disable. Self-only
|
|
3097
|
+
// (acts on `session.userId`, never a client-supplied id) — ANY signed-in
|
|
3098
|
+
// user, not just the first admin. Same cookie + CSRF + same-origin
|
|
3099
|
+
// posture as /api/admin-lock above (NOT the host-admin Bearer posture —
|
|
3100
|
+
// a user managing their own credentials needs no admin scope). The
|
|
3101
|
+
// server-rendered /account/2fa + /account/change-password pages stay for
|
|
3102
|
+
// the no-JS / friend-facing path; these are the JSON twins.
|
|
3103
|
+
if (pathname.startsWith("/api/account/")) {
|
|
3104
|
+
if (!getDb) return dbNotConfigured();
|
|
3105
|
+
{
|
|
3106
|
+
const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
|
|
3107
|
+
if (rejected) return rejected;
|
|
3108
|
+
}
|
|
3109
|
+
const subpath = pathname.slice("/api/account".length);
|
|
3110
|
+
return handleApiAccount(req, subpath, { db: getDb() });
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3047
3113
|
// SPA-driven hub self-upgrade (design 2026-06-01 §5.3 / D4). Dedicated
|
|
3048
3114
|
// endpoint — the hub is NOT a supervised module (no /api/modules/hub/*),
|
|
3049
3115
|
// so it gets its own route. Checked BEFORE the `/api/hub` exact match
|
|
@@ -3057,6 +3123,7 @@ export function hubFetch(
|
|
|
3057
3123
|
return handleHubUpgrade(req, {
|
|
3058
3124
|
db: getDb(),
|
|
3059
3125
|
issuer: oauthDeps(req).issuer,
|
|
3126
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3060
3127
|
configDir: CONFIG_DIR,
|
|
3061
3128
|
});
|
|
3062
3129
|
}
|
|
@@ -3065,6 +3132,7 @@ export function hubFetch(
|
|
|
3065
3132
|
return handleHubUpgradeStatus(req, {
|
|
3066
3133
|
db: getDb(),
|
|
3067
3134
|
issuer: oauthDeps(req).issuer,
|
|
3135
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3068
3136
|
configDir: CONFIG_DIR,
|
|
3069
3137
|
});
|
|
3070
3138
|
}
|
|
@@ -3077,6 +3145,7 @@ export function hubFetch(
|
|
|
3077
3145
|
return handleApiHub(req, {
|
|
3078
3146
|
db: getDb(),
|
|
3079
3147
|
issuer: oauthDeps(req).issuer,
|
|
3148
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3080
3149
|
});
|
|
3081
3150
|
}
|
|
3082
3151
|
|
|
@@ -3112,6 +3181,7 @@ export function hubFetch(
|
|
|
3112
3181
|
return handleApiModulesChannel(req, {
|
|
3113
3182
|
db: getDb(),
|
|
3114
3183
|
issuer: oauthDeps(req).issuer,
|
|
3184
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3115
3185
|
});
|
|
3116
3186
|
}
|
|
3117
3187
|
|
|
@@ -3125,11 +3195,25 @@ export function hubFetch(
|
|
|
3125
3195
|
return handleApiSettingsHubOrigin(req, {
|
|
3126
3196
|
db,
|
|
3127
3197
|
issuer: oauthDeps(req).issuer,
|
|
3198
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3128
3199
|
resolvedIssuer: resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin),
|
|
3129
3200
|
resolvedSource: resolveIssuerSource(db, configuredIssuer, loadExposeHubOrigin),
|
|
3130
3201
|
});
|
|
3131
3202
|
}
|
|
3132
3203
|
|
|
3204
|
+
// Bare-`/` redirect target (configurable; default `/admin`). Admin SPA /
|
|
3205
|
+
// CLI reads + writes the operator-set landing page. Same Bearer/scope
|
|
3206
|
+
// posture as hub-origin; the open-redirect guard lives in the handler +
|
|
3207
|
+
// resolver.
|
|
3208
|
+
if (pathname === "/api/settings/root-redirect") {
|
|
3209
|
+
if (!getDb) return dbNotConfigured();
|
|
3210
|
+
return handleApiSettingsRootRedirect(req, {
|
|
3211
|
+
db: getDb(),
|
|
3212
|
+
issuer: oauthDeps(req).issuer,
|
|
3213
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3133
3217
|
// Module operation poll surface — pre-empts the /api/modules/:short/*
|
|
3134
3218
|
// routes below so `/api/modules/operations/<uuid>` doesn't accidentally
|
|
3135
3219
|
// match a parseModulesPath("/operations") and fall through.
|
|
@@ -3229,6 +3313,7 @@ export function hubFetch(
|
|
|
3229
3313
|
return handleApiMintToken(req, {
|
|
3230
3314
|
db: getDb(),
|
|
3231
3315
|
issuer: oauthDeps(req).issuer,
|
|
3316
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3232
3317
|
knownVaultNames: mintKnownVaultNames,
|
|
3233
3318
|
});
|
|
3234
3319
|
}
|
|
@@ -3238,6 +3323,7 @@ export function hubFetch(
|
|
|
3238
3323
|
return handleApiRevokeToken(req, {
|
|
3239
3324
|
db: getDb(),
|
|
3240
3325
|
issuer: oauthDeps(req).issuer,
|
|
3326
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3241
3327
|
});
|
|
3242
3328
|
}
|
|
3243
3329
|
|
|
@@ -3246,6 +3332,7 @@ export function hubFetch(
|
|
|
3246
3332
|
return handleApiTokens(req, {
|
|
3247
3333
|
db: getDb(),
|
|
3248
3334
|
issuer: oauthDeps(req).issuer,
|
|
3335
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3249
3336
|
});
|
|
3250
3337
|
}
|
|
3251
3338
|
|
|
@@ -3254,6 +3341,7 @@ export function hubFetch(
|
|
|
3254
3341
|
return handleListGrants(req, {
|
|
3255
3342
|
db: getDb(),
|
|
3256
3343
|
issuer: oauthDeps(req).issuer,
|
|
3344
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3257
3345
|
});
|
|
3258
3346
|
}
|
|
3259
3347
|
|
|
@@ -3266,6 +3354,7 @@ export function hubFetch(
|
|
|
3266
3354
|
return handleRevokeGrant(req, clientId, {
|
|
3267
3355
|
db: getDb(),
|
|
3268
3356
|
issuer: oauthDeps(req).issuer,
|
|
3357
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3269
3358
|
});
|
|
3270
3359
|
}
|
|
3271
3360
|
|
|
@@ -3288,6 +3377,7 @@ export function hubFetch(
|
|
|
3288
3377
|
return handleApproveClient(req, clientId, {
|
|
3289
3378
|
db: getDb(),
|
|
3290
3379
|
issuer: oauthDeps(req).issuer,
|
|
3380
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3291
3381
|
});
|
|
3292
3382
|
}
|
|
3293
3383
|
const clientId = decodeURIComponent(tail);
|
|
@@ -3297,6 +3387,7 @@ export function hubFetch(
|
|
|
3297
3387
|
return handleGetClient(req, clientId, {
|
|
3298
3388
|
db: getDb(),
|
|
3299
3389
|
issuer: oauthDeps(req).issuer,
|
|
3390
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3300
3391
|
});
|
|
3301
3392
|
}
|
|
3302
3393
|
|
|
@@ -3312,6 +3403,7 @@ export function hubFetch(
|
|
|
3312
3403
|
const usersDeps = {
|
|
3313
3404
|
db: getDb(),
|
|
3314
3405
|
issuer: oauthDeps(req).issuer,
|
|
3406
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3315
3407
|
manifestPath,
|
|
3316
3408
|
};
|
|
3317
3409
|
if (req.method === "GET") return handleListUsers(req, usersDeps);
|
|
@@ -3323,6 +3415,7 @@ export function hubFetch(
|
|
|
3323
3415
|
return handleListVaults(req, {
|
|
3324
3416
|
db: getDb(),
|
|
3325
3417
|
issuer: oauthDeps(req).issuer,
|
|
3418
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3326
3419
|
manifestPath,
|
|
3327
3420
|
});
|
|
3328
3421
|
}
|
|
@@ -3342,6 +3435,7 @@ export function hubFetch(
|
|
|
3342
3435
|
return handleResetUserPassword(req, id, {
|
|
3343
3436
|
db: getDb(),
|
|
3344
3437
|
issuer: oauthDeps(req).issuer,
|
|
3438
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3345
3439
|
manifestPath,
|
|
3346
3440
|
});
|
|
3347
3441
|
}
|
|
@@ -3360,6 +3454,7 @@ export function hubFetch(
|
|
|
3360
3454
|
return handleUpdateUserVaults(req, id, {
|
|
3361
3455
|
db: getDb(),
|
|
3362
3456
|
issuer: oauthDeps(req).issuer,
|
|
3457
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3363
3458
|
manifestPath,
|
|
3364
3459
|
});
|
|
3365
3460
|
}
|
|
@@ -3373,6 +3468,7 @@ export function hubFetch(
|
|
|
3373
3468
|
return handleDeleteUser(req, id, {
|
|
3374
3469
|
db: getDb(),
|
|
3375
3470
|
issuer: oauthDeps(req).issuer,
|
|
3471
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3376
3472
|
manifestPath,
|
|
3377
3473
|
});
|
|
3378
3474
|
}
|
|
@@ -3382,7 +3478,12 @@ export function hubFetch(
|
|
|
3382
3478
|
// lists (status-annotated), DELETE /:id revokes by sha256 hash.
|
|
3383
3479
|
if (pathname === "/api/invites") {
|
|
3384
3480
|
if (!getDb) return dbNotConfigured();
|
|
3385
|
-
const invitesDeps = {
|
|
3481
|
+
const invitesDeps = {
|
|
3482
|
+
db: getDb(),
|
|
3483
|
+
issuer: oauthDeps(req).issuer,
|
|
3484
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3485
|
+
manifestPath,
|
|
3486
|
+
};
|
|
3386
3487
|
if (req.method === "GET") return handleListInvites(req, invitesDeps);
|
|
3387
3488
|
if (req.method === "POST") return handleCreateInvite(req, invitesDeps);
|
|
3388
3489
|
return new Response("method not allowed", { status: 405 });
|
|
@@ -3396,6 +3497,7 @@ export function hubFetch(
|
|
|
3396
3497
|
return handleRevokeInvite(req, id, {
|
|
3397
3498
|
db: getDb(),
|
|
3398
3499
|
issuer: oauthDeps(req).issuer,
|
|
3500
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3399
3501
|
manifestPath,
|
|
3400
3502
|
});
|
|
3401
3503
|
}
|
|
@@ -3408,6 +3510,7 @@ export function hubFetch(
|
|
|
3408
3510
|
return handleListVaultCaps(req, {
|
|
3409
3511
|
db: getDb(),
|
|
3410
3512
|
issuer: oauthDeps(req).issuer,
|
|
3513
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3411
3514
|
manifestPath,
|
|
3412
3515
|
});
|
|
3413
3516
|
}
|
|
@@ -3420,6 +3523,7 @@ export function hubFetch(
|
|
|
3420
3523
|
return handleSetVaultCap(req, name, {
|
|
3421
3524
|
db: getDb(),
|
|
3422
3525
|
issuer: oauthDeps(req).issuer,
|
|
3526
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3423
3527
|
manifestPath,
|
|
3424
3528
|
});
|
|
3425
3529
|
}
|
package/src/hub-settings.ts
CHANGED
|
@@ -106,7 +106,23 @@ export type HubSettingKey =
|
|
|
106
106
|
// Idle timeout for the admin screen-lock, in seconds. Optional override of
|
|
107
107
|
// the built-in default (DEFAULT_ADMIN_LOCK_IDLE_SECONDS). Stored as a
|
|
108
108
|
// stringified integer; absent / unparseable falls back to the default.
|
|
109
|
-
| "admin_lock_idle_seconds"
|
|
109
|
+
| "admin_lock_idle_seconds"
|
|
110
|
+
// hub: operator-settable target for the bare-`/` 302. Lets an operator
|
|
111
|
+
// point their hub's root at a surface (e.g. a team reading-room surface)
|
|
112
|
+
// instead of the default `/admin`. Stored as a SAME-ORIGIN relative path
|
|
113
|
+
// (must start with a single `/`, never `//` / `/\` / a scheme — see
|
|
114
|
+
// `isSafeRedirectPath`); validated on write (admin PUT + CLI) AND re-checked
|
|
115
|
+
// on read so a hand-edited sqlite row can never produce an open redirect.
|
|
116
|
+
//
|
|
117
|
+
// Precedence on each request (resolveRootRedirect): this row, then
|
|
118
|
+
// `PARACHUTE_HUB_ROOT_REDIRECT` env, then the `/admin` default. DB-first
|
|
119
|
+
// (unlike `module_install_channel`'s env-first) so an operator can flip the
|
|
120
|
+
// landing page from the admin SPA / CLI without a redeploy — the headline
|
|
121
|
+
// use case (custom-domain hub fronting a team surface). The fresh-hub
|
|
122
|
+
// wizard funnel + pre-admin 503 lockout run BEFORE this redirect, so a
|
|
123
|
+
// not-yet-set-up hub still lands on setup, not a surface that can't work
|
|
124
|
+
// yet.
|
|
125
|
+
| "root_redirect";
|
|
110
126
|
|
|
111
127
|
export type SetupExposeMode = "localhost" | "tailnet" | "public";
|
|
112
128
|
|
|
@@ -431,3 +447,149 @@ export function setNotesRedirectDisabled(db: Database, value: boolean): void {
|
|
|
431
447
|
deleteSetting(db, "notes_redirect_disabled");
|
|
432
448
|
}
|
|
433
449
|
}
|
|
450
|
+
|
|
451
|
+
// --- domain helpers: configurable bare-`/` redirect target ----------------
|
|
452
|
+
|
|
453
|
+
/** Env override for the bare-`/` redirect target. Below the DB row, above the default. */
|
|
454
|
+
export const PARACHUTE_HUB_ROOT_REDIRECT_ENV = "PARACHUTE_HUB_ROOT_REDIRECT";
|
|
455
|
+
|
|
456
|
+
/** Fallback when neither DB row nor env is set — the admin shell (unchanged behavior). */
|
|
457
|
+
export const DEFAULT_ROOT_REDIRECT = "/admin";
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Open-redirect guard for the configurable bare-`/` redirect target.
|
|
461
|
+
*
|
|
462
|
+
* The resolved value lands verbatim in a `Location:` header on the `/` 302,
|
|
463
|
+
* so an off-origin value would be a textbook open redirect. To be accepted it
|
|
464
|
+
* must be a SAME-ORIGIN relative path:
|
|
465
|
+
*
|
|
466
|
+
* - starts with a single `/` (a site-relative path). This alone rejects
|
|
467
|
+
* `https://evil.com`, `javascript:…`, and bare hostnames.
|
|
468
|
+
* - second char is NOT `/` (a protocol-relative `//evil.com` sends the
|
|
469
|
+
* browser to another origin) and NOT `\` (browsers normalize the
|
|
470
|
+
* backslash, so `/\evil.com` resolves like `//evil.com`).
|
|
471
|
+
* - contains no ASCII control chars or whitespace — a CR/LF would enable
|
|
472
|
+
* header injection, and tab/newline are stripped by some browsers which
|
|
473
|
+
* could re-expose a hidden `//` authority.
|
|
474
|
+
* - resolves same-origin against a placeholder base (belt-and-suspenders:
|
|
475
|
+
* `new URL(value, base).origin === base`) — catches any scheme/authority
|
|
476
|
+
* shape the prefix checks missed.
|
|
477
|
+
* - does NOT resolve to pathname `/` — that would re-enter this very route
|
|
478
|
+
* and 302-loop forever (`/`, `/?x`, `/#y` all rejected).
|
|
479
|
+
*
|
|
480
|
+
* A query string / fragment on a real path is allowed (stays same-origin).
|
|
481
|
+
* Returns false for non-strings, empty, and every off-origin shape.
|
|
482
|
+
*/
|
|
483
|
+
export function isSafeRedirectPath(value: unknown): value is string {
|
|
484
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
485
|
+
if (value[0] !== "/") return false;
|
|
486
|
+
if (value[1] === "/" || value[1] === "\\") return false;
|
|
487
|
+
// Reject whitespace (\t \n \r space + Unicode separators U+2028/U+2029) and
|
|
488
|
+
// ASCII control chars. A CR/LF would enable header injection; stripped
|
|
489
|
+
// whitespace could re-expose a hidden `//` authority. `\s` covers the
|
|
490
|
+
// whitespace family (incl. Unicode); the charCode scan covers the remaining
|
|
491
|
+
// non-whitespace control chars (0x00-0x1f, 0x7f) without a control-char
|
|
492
|
+
// regex literal.
|
|
493
|
+
if (/\s/u.test(value)) return false;
|
|
494
|
+
for (let i = 0; i < value.length; i++) {
|
|
495
|
+
const c = value.charCodeAt(i);
|
|
496
|
+
if (c < 0x20 || c === 0x7f) return false;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const base = "http://parachute.invalid";
|
|
500
|
+
const resolved = new URL(value, base);
|
|
501
|
+
if (resolved.origin !== base) return false;
|
|
502
|
+
// pathname "/" would match the bare-`/` route again -> infinite redirect.
|
|
503
|
+
if (resolved.pathname === "/") return false;
|
|
504
|
+
} catch {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Read the operator-set bare-`/` redirect target from hub_settings. Returns
|
|
512
|
+
* the raw stored value (or `null` when absent) WITHOUT re-validating — callers
|
|
513
|
+
* that need a safe value go through `resolveRootRedirect`, which re-checks the
|
|
514
|
+
* guard. The raw read is what the admin GET surfaces so the operator sees
|
|
515
|
+
* exactly what's stored (even if a hand-edit made it unsafe → ignored on use).
|
|
516
|
+
*/
|
|
517
|
+
export function getRootRedirect(db: Database): string | null {
|
|
518
|
+
return getSetting(db, "root_redirect") ?? null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Write or clear the bare-`/` redirect target. Passing `null`/empty deletes
|
|
523
|
+
* the row, reverting to env / default precedence (mirrors `setHubOrigin`).
|
|
524
|
+
* The caller MUST have validated via `isSafeRedirectPath` — this trusts the
|
|
525
|
+
* input (typed-callsite contract); `resolveRootRedirect` re-guards on read as
|
|
526
|
+
* defense-in-depth regardless.
|
|
527
|
+
*/
|
|
528
|
+
export function setRootRedirect(db: Database, value: string | null): void {
|
|
529
|
+
if (value === null || value === "") {
|
|
530
|
+
deleteSetting(db, "root_redirect");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
setSetting(db, "root_redirect", value);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Which precedence layer the resolved redirect came from. */
|
|
537
|
+
export type RootRedirectSource = "db" | "env" | "default";
|
|
538
|
+
|
|
539
|
+
export interface ResolvedRootRedirect {
|
|
540
|
+
/** The safe same-origin path the `/` 302 should target. */
|
|
541
|
+
value: string;
|
|
542
|
+
/** Which layer it came from (for admin-UI attribution). */
|
|
543
|
+
source: RootRedirectSource;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Resolve the bare-`/` redirect target with source attribution.
|
|
548
|
+
*
|
|
549
|
+
* Precedence: hub_settings.root_redirect → `PARACHUTE_HUB_ROOT_REDIRECT` env
|
|
550
|
+
* → `/admin` default. Every layer is re-validated through `isSafeRedirectPath`;
|
|
551
|
+
* an unsafe value at any layer is warned + skipped so the chain can never
|
|
552
|
+
* produce an open redirect (worst case falls all the way to `/admin`).
|
|
553
|
+
*
|
|
554
|
+
* `db` may be `null` (hub-server running without state) — the DB layer is then
|
|
555
|
+
* skipped and resolution starts from env. The `env` / `warn` knobs are test
|
|
556
|
+
* seams (production uses `process.env` + `console.warn`).
|
|
557
|
+
*/
|
|
558
|
+
export function resolveRootRedirectDetailed(
|
|
559
|
+
db: Database | null,
|
|
560
|
+
opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
|
|
561
|
+
): ResolvedRootRedirect {
|
|
562
|
+
const env = opts.env ?? process.env;
|
|
563
|
+
const warn = opts.warn ?? ((msg: string) => console.warn(msg));
|
|
564
|
+
|
|
565
|
+
// 1. DB row (operator-set via the admin PUT / `parachute hub set-root-redirect`).
|
|
566
|
+
if (db) {
|
|
567
|
+
const fromDb = getSetting(db, "root_redirect");
|
|
568
|
+
if (fromDb !== undefined) {
|
|
569
|
+
if (isSafeRedirectPath(fromDb)) return { value: fromDb, source: "db" };
|
|
570
|
+
warn(
|
|
571
|
+
`[hub-settings] root_redirect="${fromDb}" in hub_settings is not a safe same-origin path — ignoring (falling through to env/default).`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 2. Env override.
|
|
577
|
+
const fromEnv = env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
578
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
579
|
+
if (isSafeRedirectPath(fromEnv)) return { value: fromEnv, source: "env" };
|
|
580
|
+
warn(
|
|
581
|
+
`[hub-settings] ${PARACHUTE_HUB_ROOT_REDIRECT_ENV}="${fromEnv}" is not a safe same-origin path — falling back to "${DEFAULT_ROOT_REDIRECT}".`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// 3. Default — unchanged behavior.
|
|
586
|
+
return { value: DEFAULT_ROOT_REDIRECT, source: "default" };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Convenience: just the resolved path (see `resolveRootRedirectDetailed`). */
|
|
590
|
+
export function resolveRootRedirect(
|
|
591
|
+
db: Database | null,
|
|
592
|
+
opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
|
|
593
|
+
): string {
|
|
594
|
+
return resolveRootRedirectDetailed(db, opts).value;
|
|
595
|
+
}
|
package/src/jwt-sign.ts
CHANGED
|
@@ -483,11 +483,26 @@ export interface ValidatedAccessToken {
|
|
|
483
483
|
* this hub advertises — the same check vault performs against its own
|
|
484
484
|
* `PARACHUTE_HUB_ORIGIN`. Defense in depth: tokens forged or replayed from
|
|
485
485
|
* a different issuer get rejected at validation as well as issuance.
|
|
486
|
+
*
|
|
487
|
+
* `expectedIssuer` accepts a single string OR a SET of allowed issuers
|
|
488
|
+
* (`readonly string[]`), handed straight to jose's `issuer` option (which
|
|
489
|
+
* accepts `string | string[]`): the `iss` claim must equal the string, or be
|
|
490
|
+
* a member of the set. The set form is for the hub's own self-issued
|
|
491
|
+
* credentials, whose `iss` may be ANY origin the hub legitimately answers on
|
|
492
|
+
* (loopback ∪ expose-state ∪ platform ∪ per-request issuer — see
|
|
493
|
+
* `buildHubBoundOrigins`), so an origin switch doesn't reject a credential
|
|
494
|
+
* minted under a still-valid prior origin. SECURITY: this is ONLY an additive
|
|
495
|
+
* membership relaxation on `iss`. jose verifies the JWS signature against the
|
|
496
|
+
* hub's own public key FIRST and UNCONDITIONALLY — only tokens this hub
|
|
497
|
+
* minted can verify — before the `iss` claim is ever compared to the set. The
|
|
498
|
+
* set must come only from `buildHubBoundOrigins` (the hub's own origins),
|
|
499
|
+
* never a raw request Host. An empty/omitted value skips the `iss` check
|
|
500
|
+
* (signature-only); a single string is byte-identical to the prior behavior.
|
|
486
501
|
*/
|
|
487
502
|
export async function validateAccessToken(
|
|
488
503
|
db: Database,
|
|
489
504
|
token: string,
|
|
490
|
-
expectedIssuer?: string,
|
|
505
|
+
expectedIssuer?: string | readonly string[],
|
|
491
506
|
): Promise<ValidatedAccessToken> {
|
|
492
507
|
const header = decodeProtectedHeader(token);
|
|
493
508
|
const kid = header.kid;
|
|
@@ -495,11 +510,15 @@ export async function validateAccessToken(
|
|
|
495
510
|
const match = getAllPublicKeys(db).find((k) => k.kid === kid);
|
|
496
511
|
if (!match) throw new Error(`validateAccessToken: unknown or expired kid ${kid}`);
|
|
497
512
|
const pub = await importSPKI(match.publicKeyPem, SIGNING_ALGORITHM);
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
513
|
+
// `undefined` → no `iss` pin (signature-only, the internal-caller default).
|
|
514
|
+
// A string or a non-empty set is handed straight to jose, which checks
|
|
515
|
+
// membership AFTER the signature verify above. An empty array is passed
|
|
516
|
+
// through too and so fails closed (no `iss` can match) — same posture as
|
|
517
|
+
// `validateHostAdminToken`; callers offering an empty origin set get a
|
|
518
|
+
// rejection, not a silent widening.
|
|
519
|
+
const issuerOption =
|
|
520
|
+
expectedIssuer === undefined ? undefined : { issuer: expectedIssuer as string | string[] };
|
|
521
|
+
const { payload } = await jwtVerify(token, pub, issuerOption);
|
|
503
522
|
// RFC 7009 revocation enforcement (#73). OAuth-issued tokens carry a
|
|
504
523
|
// tokens row keyed by jti; if that row is marked revoked, the JWT is
|
|
505
524
|
// dead even though its signature + expiry are still valid. Tokens that
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import type { Database } from "bun:sqlite";
|
|
25
25
|
import { AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
26
|
+
import { recordLoginUnlock } from "./admin-lock.ts";
|
|
26
27
|
import { renderTotpChallenge } from "./admin-login-ui.ts";
|
|
27
28
|
import {
|
|
28
29
|
AuthCodeExpiredError,
|
|
@@ -1501,6 +1502,7 @@ async function handleLoginSubmit(
|
|
|
1501
1502
|
}
|
|
1502
1503
|
|
|
1503
1504
|
const session = createSession(db, { userId: user.id });
|
|
1505
|
+
recordLoginUnlock(db, session.id);
|
|
1504
1506
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
1505
1507
|
secure: isHttpsRequest(req),
|
|
1506
1508
|
});
|
|
@@ -2706,7 +2708,13 @@ export async function handleRegister(
|
|
|
2706
2708
|
let sameHub = false;
|
|
2707
2709
|
if (req.headers.get("authorization")) {
|
|
2708
2710
|
try {
|
|
2709
|
-
|
|
2711
|
+
// Validate the operator bearer's `iss` against the SET of origins the
|
|
2712
|
+
// hub answers on (`deps.hubBoundOrigins` — loopback ∪ expose-state ∪
|
|
2713
|
+
// platform ∪ per-request issuer), not just the single per-request
|
|
2714
|
+
// `issuer`, so a host-admin credential minted under a still-valid prior
|
|
2715
|
+
// origin keeps auto-approving across an origin switch (hub#516 parity).
|
|
2716
|
+
// Falls back to `[deps.issuer]` when no set getter is wired (tests).
|
|
2717
|
+
await requireScope(db, req, "hub:admin", deps.hubBoundOrigins?.() ?? deps.issuer);
|
|
2710
2718
|
status = "approved";
|
|
2711
2719
|
sameHub = true;
|
|
2712
2720
|
} catch (err) {
|
|
@@ -2984,6 +2992,11 @@ function consentProps(
|
|
|
2984
2992
|
blockApproveForStaleAssignment:
|
|
2985
2993
|
staleAssignedVault !== undefined && (unnamedVerbs.length > 0 || hasNamedStaleVaultScope),
|
|
2986
2994
|
userCanAuthorizeRequest,
|
|
2995
|
+
// hub#314 — surface the client's provenance (same-hub first-party vs
|
|
2996
|
+
// external third-party DCR) so the operator sees the trust level on the
|
|
2997
|
+
// consent screen. Clean DB-backed signal: the `same_hub` column written
|
|
2998
|
+
// at DCR time (bearer hub:admin / same-origin session → true).
|
|
2999
|
+
sameHub: client.sameHub,
|
|
2987
3000
|
};
|
|
2988
3001
|
}
|
|
2989
3002
|
|