@openparachute/hub 0.5.13 → 0.5.14-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
package/src/oauth-handlers.ts
CHANGED
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
} from "./oauth-ui.ts";
|
|
67
67
|
import { isSameOriginRequest } from "./origin-check.ts";
|
|
68
68
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
69
|
+
import { narrowResourceVaultScopes, resolveResourceVault } from "./resource-binding.ts";
|
|
69
70
|
import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope-explanations.ts";
|
|
70
71
|
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
71
72
|
import {
|
|
@@ -83,7 +84,7 @@ import {
|
|
|
83
84
|
findSession,
|
|
84
85
|
parseSessionCookie,
|
|
85
86
|
} from "./sessions.ts";
|
|
86
|
-
import { getUserById, getUserByUsername, verifyPassword } from "./users.ts";
|
|
87
|
+
import { getUserById, getUserByUsername, isFirstAdmin, verifyPassword } from "./users.ts";
|
|
87
88
|
import { listVaultNames } from "./vault-names.ts";
|
|
88
89
|
import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
|
|
89
90
|
|
|
@@ -114,30 +115,35 @@ function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
|
|
|
114
115
|
|
|
115
116
|
/**
|
|
116
117
|
* Derive the `vault_scope` claim value for a given hub user. Multi-user
|
|
117
|
-
* Phase
|
|
118
|
-
*
|
|
119
|
-
* §oauth-claim-shape).
|
|
118
|
+
* Phase 2 PR 2 (design 2026-05-20-multi-user-phase-1.md §Phase 2 —
|
|
119
|
+
* many-to-many membership via the `user_vaults` table).
|
|
120
120
|
*
|
|
121
121
|
* - `userId` resolves to no row → `[]`. Defensive: the caller already
|
|
122
122
|
* validated the user existed (auth-code redemption / refresh row's
|
|
123
|
-
* user_id), but a delete-between-mint-and-now race shouldn't 500.
|
|
124
|
-
* is the safe sentinel — the scope-bearing `scope` claim is
|
|
125
|
-
* gate.
|
|
126
|
-
* -
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* vault
|
|
123
|
+
* user_id), but a delete-between-mint-and-now race shouldn't 500.
|
|
124
|
+
* Empty is the safe sentinel — the scope-bearing `scope` claim is
|
|
125
|
+
* still the gate.
|
|
126
|
+
* - First admin → `[]`. Admin posture is unrestricted by design (see
|
|
127
|
+
* `isFirstAdmin`). The consent picker is the source of truth and
|
|
128
|
+
* the scope-guard reads an empty `vault_scope` claim as "no
|
|
129
|
+
* narrowing" — first admin can request scope against any vault.
|
|
130
|
+
* - Non-admin user → the list of vault names from `user_vaults`. The
|
|
131
|
+
* scope-guard at vault/notes/scribe enforces that the user can
|
|
132
|
+
* only request scope against vaults in their list (Phase 1 pinned
|
|
133
|
+
* to a single vault; Phase 2 lifts that to N). A non-admin with
|
|
134
|
+
* zero assignments returns `[]` — distinct semantics from the
|
|
135
|
+
* admin's `[]` because the consent picker plus the picked-must-
|
|
136
|
+
* match-assignment defense in `handleConsentSubmit` enforces that
|
|
137
|
+
* non-admin tokens carry a non-empty `vault_scope`.
|
|
132
138
|
*
|
|
133
139
|
* Always returns an array (never undefined) so the JWT carries the claim
|
|
134
140
|
* unconditionally — readers don't have to distinguish "absent" from "empty."
|
|
135
141
|
*/
|
|
136
142
|
export function vaultScopeForUser(db: Database, userId: string): string[] {
|
|
143
|
+
if (isFirstAdmin(db, userId)) return [];
|
|
137
144
|
const user = getUserById(db, userId);
|
|
138
145
|
if (!user) return [];
|
|
139
|
-
|
|
140
|
-
return [user.assignedVault];
|
|
146
|
+
return [...user.assignedVaults];
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
export interface OAuthDeps {
|
|
@@ -455,6 +461,9 @@ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: stri
|
|
|
455
461
|
codeChallenge,
|
|
456
462
|
codeChallengeMethod,
|
|
457
463
|
state: url.searchParams.get("state"),
|
|
464
|
+
// RFC 8707 resource indicator (optional). When present and resolvable to
|
|
465
|
+
// a per-vault MCP resource, drives the narrow-consent + named-scope path.
|
|
466
|
+
resource: url.searchParams.get("resource"),
|
|
458
467
|
};
|
|
459
468
|
}
|
|
460
469
|
|
|
@@ -839,6 +848,41 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
839
848
|
);
|
|
840
849
|
}
|
|
841
850
|
|
|
851
|
+
// RFC 8707 resource binding. When the client named a per-vault MCP
|
|
852
|
+
// resource (`<origin>/vault/<name>/mcp` or its PRM URL), narrow the
|
|
853
|
+
// requested vault verbs to the named `vault:<name>:<verb>` form BEFORE any
|
|
854
|
+
// downstream processing. Two effects:
|
|
855
|
+
//
|
|
856
|
+
// 1. The consent screen shows ONLY that vault's scopes (the picker locks
|
|
857
|
+
// to <name>) instead of the whole-hub catalog — a friend connecting to
|
|
858
|
+
// one vault no longer sees `hub:admin`, `scribe:admin`, or every other
|
|
859
|
+
// vault's verbs.
|
|
860
|
+
// 2. The minted token carries the named scope, so `inferAudience` stamps
|
|
861
|
+
// `aud=vault.<name>` and a current-line vault accepts it (an unnamed
|
|
862
|
+
// `vault:read` token is rejected by `findBroadVaultScopes`).
|
|
863
|
+
//
|
|
864
|
+
// Narrowing happens before the non-requestable gate (below) on purpose: if
|
|
865
|
+
// a resource-bound client somehow asked for `vault:admin`, narrowing makes
|
|
866
|
+
// it `vault:<name>:admin`, which IS non-requestable — so the gate correctly
|
|
867
|
+
// blocks it. Read/write narrow to the requestable named form. Non-vault
|
|
868
|
+
// scopes and already-named scopes for other vaults pass through unchanged.
|
|
869
|
+
//
|
|
870
|
+
// No resource, or a resource that isn't one of our per-vault MCP resources
|
|
871
|
+
// (off-origin, malformed, non-vault path) → `boundVault` is null and the
|
|
872
|
+
// flow is byte-for-byte the pre-#461 behavior (manual picker, etc.).
|
|
873
|
+
const boundVault = resolveResourceVault(parsed.resource, resolveBoundOrigins(deps));
|
|
874
|
+
if (boundVault) {
|
|
875
|
+
const narrowed = narrowResourceVaultScopes(
|
|
876
|
+
parsed.scope.split(" ").filter((s) => s.length > 0),
|
|
877
|
+
boundVault,
|
|
878
|
+
);
|
|
879
|
+
// Rewrite `parsed.scope` so the narrowed named scopes flow through every
|
|
880
|
+
// downstream consumer: the login-redirect query round-trip, the consent
|
|
881
|
+
// props + hidden inputs, the skip-consent grant lookup, and the
|
|
882
|
+
// auth-code mint.
|
|
883
|
+
parsed.scope = narrowed.join(" ");
|
|
884
|
+
}
|
|
885
|
+
|
|
842
886
|
// Operator-only scope gate (#96). Reject any request that names a scope
|
|
843
887
|
// we'll never mint via this flow — `parachute:host:admin` and friends.
|
|
844
888
|
// Per RFC 6749 §4.1.2.1, errors that aren't redirect-uri-related are
|
|
@@ -862,12 +906,20 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
862
906
|
return htmlResponse(renderLogin({ params: parsed, csrfToken: csrf.token }), 200, extra);
|
|
863
907
|
}
|
|
864
908
|
|
|
865
|
-
// Multi-user Phase
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
909
|
+
// Multi-user Phase 2 PR 2: non-admin users see the picker narrowed to
|
|
910
|
+
// their assigned vault list — they can't pick a vault they don't own.
|
|
911
|
+
// First admin (admin posture) sees the full dropdown of every vault on
|
|
912
|
+
// the hub.
|
|
913
|
+
//
|
|
914
|
+
// Two shapes for non-admin users emerge:
|
|
915
|
+
// - exactly one assigned vault → picker renders locked to that name
|
|
916
|
+
// (same shape as Phase 1; smallest diff for the common case).
|
|
917
|
+
// - two or more assigned vaults → picker renders a free dropdown
|
|
918
|
+
// filtered to those names — user picks one per consent.
|
|
919
|
+
//
|
|
920
|
+
// Defensive null-coalesce: the session points at a deleted user
|
|
921
|
+
// shouldn't 500; treat as admin posture (the broader scope-validation
|
|
922
|
+
// gate will catch any actual privilege issue).
|
|
871
923
|
//
|
|
872
924
|
// Resolved here (before the fast-paths) because the stale-assignment
|
|
873
925
|
// predicate below — which gates both skip-consent (#75) and same-hub
|
|
@@ -876,24 +928,28 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
876
928
|
// closing the silent-mint-on-stale-vault gap; the read is one JSON parse
|
|
877
929
|
// off-disk per /authorize.
|
|
878
930
|
const user = getUserById(db, session.userId);
|
|
879
|
-
const
|
|
931
|
+
const userIsAdmin = isFirstAdmin(db, session.userId);
|
|
932
|
+
// Non-admin user's assigned vaults; admin posture (or no row) → empty.
|
|
933
|
+
const assignedVaults: string[] = userIsAdmin ? [] : (user?.assignedVaults ?? []);
|
|
880
934
|
const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
|
|
881
935
|
const vaultNames = listVaultNames(manifest);
|
|
882
936
|
|
|
883
|
-
// Stale-assignment predicate (hub#284
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
|
|
937
|
+
// Stale-assignment predicate (hub#284, generalized in Phase 2 PR 2 from
|
|
938
|
+
// single-vault to N-vault). For a non-admin user, "stale" means at
|
|
939
|
+
// least one of their assigned vaults no longer exists in services.json
|
|
940
|
+
// AND no vault in their list still exists — i.e. they have *zero*
|
|
941
|
+
// valid vaults to consent against. The banner surfaces this state with
|
|
942
|
+
// an admin-remediation hint instead of silently minting a token
|
|
943
|
+
// against a missing vault. If at least one of their vaults still
|
|
944
|
+
// exists, the consent flow proceeds normally — the missing ones drop
|
|
945
|
+
// out of the picker without ceremony.
|
|
946
|
+
//
|
|
947
|
+
// Admin users are never stale (they aren't pinned to any vault list).
|
|
948
|
+
const remainingValidVaults = assignedVaults.filter((v) => vaultNames.includes(v));
|
|
949
|
+
const hasStaleAssignment = assignedVaults.length > 0 && remainingValidVaults.length === 0;
|
|
894
950
|
|
|
895
951
|
// Skip-consent gate (#75). If the user has previously granted every
|
|
896
|
-
// requested scope to this client, mint the auth code immediately.
|
|
952
|
+
// requested scope to this client, mint the auth code immediately. Three
|
|
897
953
|
// important constraints:
|
|
898
954
|
// - Unnamed vault verbs (`vault:read`) need the picker even if a prior
|
|
899
955
|
// grant exists, because the operator's vault choice isn't recorded
|
|
@@ -905,10 +961,25 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
905
961
|
// - Stale-assignment (above) also forces the consent render so the
|
|
906
962
|
// banner explains the broken state rather than silently minting a
|
|
907
963
|
// token against the missing vault.
|
|
964
|
+
// - The user is admin OR has at least one assigned vault (hub#429
|
|
965
|
+
// reviewer fold, follow-up). A zero-vault non-admin whose prior
|
|
966
|
+
// grants survived a `setUserVaults(_, [])` admin action would
|
|
967
|
+
// otherwise silently re-mint a token against the now-revoked
|
|
968
|
+
// vault assignment — the grants table has no FK cascade from
|
|
969
|
+
// `user_vaults`, so deleting assignments doesn't revoke grants.
|
|
970
|
+
// Same privesc shape as the same-hub auto-trust gate below;
|
|
971
|
+
// identical guard (`userHasVaultPosture`). Force fall-through to
|
|
972
|
+
// the consent render where the zero-vault gate in
|
|
973
|
+
// `handleConsentSubmit` also refuses (defense in depth). This
|
|
974
|
+
// also transitively defends the trust-by-client_name auto-
|
|
975
|
+
// promote path (~line 554) which recursively re-enters
|
|
976
|
+
// `handleAuthorizeGet` after promoting the pending client.
|
|
908
977
|
const hasUnnamedVault = unnamedVaultVerbs(requestedScopes).length > 0;
|
|
978
|
+
const userHasVaultPosture = userIsAdmin || assignedVaults.length > 0;
|
|
909
979
|
if (
|
|
910
980
|
!hasStaleAssignment &&
|
|
911
981
|
!hasUnnamedVault &&
|
|
982
|
+
userHasVaultPosture &&
|
|
912
983
|
isCoveredByGrant(db, session.userId, client.clientId, requestedScopes)
|
|
913
984
|
) {
|
|
914
985
|
console.log(
|
|
@@ -931,16 +1002,31 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
931
1002
|
// still want explicit consent as a sanity gate.
|
|
932
1003
|
// 3. No unnamed vault verbs are requested — those need the picker to
|
|
933
1004
|
// narrow `vault:<verb>` → `vault:<name>:<verb>` before mint.
|
|
934
|
-
// 4. The user's
|
|
935
|
-
// otherwise the same-hub gate would silently mint a token
|
|
936
|
-
// removed vault before the consent-render path's stale
|
|
937
|
-
// ever runs.
|
|
1005
|
+
// 4. The user's assigned_vaults list is not stale (hub#284 reviewer
|
|
1006
|
+
// fold) — otherwise the same-hub gate would silently mint a token
|
|
1007
|
+
// for a removed vault before the consent-render path's stale
|
|
1008
|
+
// detection ever runs.
|
|
1009
|
+
// 5. The user is admin OR has at least one assigned vault (Phase 2
|
|
1010
|
+
// PR 2 reviewer fold). A zero-vault non-admin has
|
|
1011
|
+
// `hasStaleAssignment=false` (length===0 short-circuits the stale
|
|
1012
|
+
// predicate above) and would otherwise sail through the auto-
|
|
1013
|
+
// trust gate. The resulting `vault_scope: []` claim is the admin
|
|
1014
|
+
// "unrestricted" sentinel — minting it for a non-admin grants
|
|
1015
|
+
// hub-wide vault access. Force fall-through to the consent
|
|
1016
|
+
// render where the zero-vault gate in `handleConsentSubmit` also
|
|
1017
|
+
// refuses (defense in depth).
|
|
938
1018
|
//
|
|
939
1019
|
// The grant is also recorded so subsequent flows with the same scopes
|
|
940
1020
|
// hit the standard skip-consent gate above. Logged so an operator
|
|
941
1021
|
// auditing "who did this" can trace it back to a same-hub DCR.
|
|
942
1022
|
const hasAdminScope = requestedScopes.some(scopeIsAdmin);
|
|
943
|
-
if (
|
|
1023
|
+
if (
|
|
1024
|
+
client.sameHub &&
|
|
1025
|
+
!hasAdminScope &&
|
|
1026
|
+
!hasUnnamedVault &&
|
|
1027
|
+
!hasStaleAssignment &&
|
|
1028
|
+
userHasVaultPosture
|
|
1029
|
+
) {
|
|
944
1030
|
console.log(
|
|
945
1031
|
`[oauth] auto-approved same-hub client client_id=${client.clientId} user_id=${session.userId} scopes=${requestedScopes.join(" ")} (hub#312)`,
|
|
946
1032
|
);
|
|
@@ -952,7 +1038,9 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
952
1038
|
}
|
|
953
1039
|
|
|
954
1040
|
return htmlResponse(
|
|
955
|
-
renderConsent(
|
|
1041
|
+
renderConsent(
|
|
1042
|
+
consentProps(client, parsed, vaultNames, csrf.token, assignedVaults, userIsAdmin),
|
|
1043
|
+
),
|
|
956
1044
|
200,
|
|
957
1045
|
extra,
|
|
958
1046
|
);
|
|
@@ -1072,6 +1160,21 @@ async function handleConsentSubmit(
|
|
|
1072
1160
|
csrfToken: string,
|
|
1073
1161
|
): Promise<Response> {
|
|
1074
1162
|
const params = paramsFromForm(form);
|
|
1163
|
+
// RFC 8707 resource binding — defense-in-depth (mirror of the GET handler).
|
|
1164
|
+
// The consent form's hidden inputs already carry the narrowed named scopes
|
|
1165
|
+
// (the GET handler rewrote `parsed.scope` before rendering), but a hand-
|
|
1166
|
+
// crafted POST could re-supply an unnamed `vault:read` alongside the
|
|
1167
|
+
// `resource` field. Re-narrow here so the minted token is always named +
|
|
1168
|
+
// correctly-audienced regardless of what the form body claims. Same
|
|
1169
|
+
// semantics as the GET path: only when `resource` resolves to one of our
|
|
1170
|
+
// per-vault MCP resources; no-op otherwise (manual-pick path unchanged).
|
|
1171
|
+
const boundVault = resolveResourceVault(params.resource, resolveBoundOrigins(deps));
|
|
1172
|
+
if (boundVault) {
|
|
1173
|
+
params.scope = narrowResourceVaultScopes(
|
|
1174
|
+
params.scope.split(" ").filter((s) => s.length > 0),
|
|
1175
|
+
boundVault,
|
|
1176
|
+
).join(" ");
|
|
1177
|
+
}
|
|
1075
1178
|
const approve = String(form.get("approve") ?? "") === "yes";
|
|
1076
1179
|
const sessionId = parseSessionCookie(req.headers.get("cookie"));
|
|
1077
1180
|
const session = sessionId ? findSession(db, sessionId) : null;
|
|
@@ -1140,18 +1243,51 @@ async function handleConsentSubmit(
|
|
|
1140
1243
|
params.state,
|
|
1141
1244
|
);
|
|
1142
1245
|
}
|
|
1143
|
-
// Multi-user Phase
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
// server-side defense here refuses any mint where the user's
|
|
1148
|
-
// disagrees
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
|
|
1152
|
-
// as-source-of-truth behavior.
|
|
1246
|
+
// Multi-user Phase 2 PR 2: non-admin users are pinned to a list of one
|
|
1247
|
+
// or more vaults via `user_vaults`. The consent screen renders the
|
|
1248
|
+
// picker narrowed to that list and any named scopes (`vault:<name>:
|
|
1249
|
+
// <verb>`) requested by the client must target a vault in the list.
|
|
1250
|
+
// The server-side defense here refuses any mint where the user's
|
|
1251
|
+
// submission disagrees, so a hand-crafted POST or a misbehaving SPA
|
|
1252
|
+
// can't bypass the narrowing. First admin (admin posture) keeps the
|
|
1253
|
+
// existing picker-as-source-of-truth behavior (empty `assignedVaults`).
|
|
1254
|
+
const userIsAdmin = isFirstAdmin(db, session.userId);
|
|
1153
1255
|
const sessionUser = getUserById(db, session.userId);
|
|
1154
|
-
const
|
|
1256
|
+
const assignedVaults: string[] = userIsAdmin ? [] : (sessionUser?.assignedVaults ?? []);
|
|
1257
|
+
const isPinned = assignedVaults.length > 0;
|
|
1258
|
+
// By design: the resource-bound re-narrow above does NOT check the bound
|
|
1259
|
+
// vault exists in services.json for the admin path — admin (isPinned=false)
|
|
1260
|
+
// can already consent to any vault via the manual picker, so the asymmetry
|
|
1261
|
+
// (named-scope mint against a possibly-missing vault) is deliberate, not an
|
|
1262
|
+
// oversight. Non-admins still hit the assignment + stale-vault defenses below.
|
|
1263
|
+
|
|
1264
|
+
// Zero-vault non-admin gate (Phase 2 PR 2 reviewer fold). A non-admin
|
|
1265
|
+
// user with no `user_vaults` rows is a known-but-not-yet-assigned
|
|
1266
|
+
// posture — they can sign in to /account/, change their password, and
|
|
1267
|
+
// see the home page, but they have no vaults to authorize against.
|
|
1268
|
+
// Block any vault-scoped consent at the submit boundary so an OAuth
|
|
1269
|
+
// client can't trick them into minting a token: an empty `vault_scope`
|
|
1270
|
+
// claim is the admin "unrestricted" sentinel (see `vaultScopeForUser`),
|
|
1271
|
+
// and we must keep that sentinel reserved for true admins. Non-vault
|
|
1272
|
+
// scopes (`scribe:transcribe`, etc.) still consent normally — only
|
|
1273
|
+
// `vault:...` scopes are gated here. The defense pairs with the GET-
|
|
1274
|
+
// path same-hub-auto-trust gate below (which falls through to the
|
|
1275
|
+
// consent render that would otherwise show the picker).
|
|
1276
|
+
if (!userIsAdmin && assignedVaults.length === 0) {
|
|
1277
|
+
const submittedScopes = params.scope.split(" ").filter((s) => s.length > 0);
|
|
1278
|
+
const hasVaultScope = submittedScopes.some((s) => {
|
|
1279
|
+
if (s === "vault:read" || s === "vault:write" || s === "vault:admin") return true;
|
|
1280
|
+
const parts = s.split(":");
|
|
1281
|
+
return parts.length === 3 && parts[0] === "vault" && parts[2] && VAULT_VERBS.has(parts[2]);
|
|
1282
|
+
});
|
|
1283
|
+
if (hasVaultScope) {
|
|
1284
|
+
return htmlError(
|
|
1285
|
+
"No vaults assigned",
|
|
1286
|
+
"vault_scope_mismatch: you have no assigned vaults on this hub yet, so you can't authorize an app for vault access. Ask the hub admin to assign you at least one vault via /admin/users, then try again.",
|
|
1287
|
+
400,
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1155
1291
|
|
|
1156
1292
|
// Vault picker (Q1 of the vault-config-and-scopes design): an unnamed
|
|
1157
1293
|
// `vault:<verb>` scope is ambiguous about which vault it grants access to.
|
|
@@ -1171,24 +1307,17 @@ async function handleConsentSubmit(
|
|
|
1171
1307
|
const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
|
|
1172
1308
|
const validNames = listVaultNames(manifest);
|
|
1173
1309
|
if (!validNames.includes(pickedVault)) {
|
|
1174
|
-
// Stale-assignment branch (hub#284).
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
// removed
|
|
1178
|
-
// stale name. The locked picker rendered the missing name, the
|
|
1179
|
-
// form posted it, the user saw "vault X is not registered" and
|
|
1180
|
-
// had no path forward. The new copy names the actual condition
|
|
1181
|
-
// (assignment removed) and points at the admin remediation
|
|
1182
|
-
// surface (/admin/users) instead of leaving the user wondering
|
|
1183
|
-
// whether hub itself is broken.
|
|
1310
|
+
// Stale-assignment branch (hub#284, generalized Phase 2 PR 2). The
|
|
1311
|
+
// user is consenting via an assignment that points at a vault no
|
|
1312
|
+
// longer in services.json. The new copy names the actual condition
|
|
1313
|
+
// (assignment removed) and points at the admin remediation surface.
|
|
1184
1314
|
//
|
|
1185
|
-
// The check fires
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
|
|
1191
|
-
if (assignedVault !== null && pickedVault === assignedVault) {
|
|
1315
|
+
// The check fires when the picked vault is in the user's assigned
|
|
1316
|
+
// list — narrows the special-case to the "user is consenting via
|
|
1317
|
+
// a now-stale assignment" shape rather than swallowing every
|
|
1318
|
+
// Unknown-vault. A hand-crafted POST naming a never-existed vault
|
|
1319
|
+
// still hits the generic branch.
|
|
1320
|
+
if (isPinned && assignedVaults.includes(pickedVault)) {
|
|
1192
1321
|
return htmlError(
|
|
1193
1322
|
"Assigned vault was removed",
|
|
1194
1323
|
`Your assigned vault "${pickedVault}" is no longer registered on this hub. Ask the hub admin to reassign you to an existing vault via /admin/users, then try again.`,
|
|
@@ -1201,16 +1330,16 @@ async function handleConsentSubmit(
|
|
|
1201
1330
|
400,
|
|
1202
1331
|
);
|
|
1203
1332
|
}
|
|
1204
|
-
// Server-side defense: non-admin user submitted a vault that
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
// overwriting; the explicit error tells the operator the assignment
|
|
1209
|
-
// load-bearing.
|
|
1210
|
-
if (
|
|
1333
|
+
// Server-side defense: non-admin user submitted a vault that's not in
|
|
1334
|
+
// their assigned list. The picker rendered as narrowed, so a UI-path
|
|
1335
|
+
// user couldn't reach this — but a hand-crafted form bypassing the
|
|
1336
|
+
// narrowed input lands here. Refuse the mint instead of silently
|
|
1337
|
+
// overwriting; the explicit error tells the operator the assignment
|
|
1338
|
+
// is load-bearing.
|
|
1339
|
+
if (isPinned && !assignedVaults.includes(pickedVault)) {
|
|
1211
1340
|
return htmlError(
|
|
1212
1341
|
"Vault assignment mismatch",
|
|
1213
|
-
`vault_scope_mismatch: the picked vault "${pickedVault}"
|
|
1342
|
+
`vault_scope_mismatch: the picked vault "${pickedVault}" is not in your vault assignment. Ask the hub admin to update your assignment, or pick a vault shown on the consent screen.`,
|
|
1214
1343
|
400,
|
|
1215
1344
|
);
|
|
1216
1345
|
}
|
|
@@ -1218,12 +1347,12 @@ async function handleConsentSubmit(
|
|
|
1218
1347
|
}
|
|
1219
1348
|
|
|
1220
1349
|
// Server-side defense for named-vault scopes (`vault:<name>:<verb>`) too.
|
|
1221
|
-
// A non-admin user can't request scope against any vault
|
|
1222
|
-
//
|
|
1223
|
-
// scopes that arrived already-named (e.g. a client that knows the
|
|
1224
|
-
// vault and asked for `vault:bob:read` directly).
|
|
1225
|
-
//
|
|
1226
|
-
if (
|
|
1350
|
+
// A non-admin user can't request scope against any vault outside their
|
|
1351
|
+
// assignment list — same invariant as the picker check above, applied
|
|
1352
|
+
// to scopes that arrived already-named (e.g. a client that knows the
|
|
1353
|
+
// user's vault and asked for `vault:bob:read` directly). Admin posture
|
|
1354
|
+
// (`isPinned === false`) skips this check.
|
|
1355
|
+
if (isPinned) {
|
|
1227
1356
|
const mismatched: string[] = [];
|
|
1228
1357
|
for (const s of scopes) {
|
|
1229
1358
|
const parts = s.split(":");
|
|
@@ -1233,7 +1362,7 @@ async function handleConsentSubmit(
|
|
|
1233
1362
|
parts[1] &&
|
|
1234
1363
|
parts[2] &&
|
|
1235
1364
|
VAULT_VERBS.has(parts[2]) &&
|
|
1236
|
-
parts[1]
|
|
1365
|
+
!assignedVaults.includes(parts[1])
|
|
1237
1366
|
) {
|
|
1238
1367
|
mismatched.push(s);
|
|
1239
1368
|
}
|
|
@@ -1241,29 +1370,29 @@ async function handleConsentSubmit(
|
|
|
1241
1370
|
if (mismatched.length > 0) {
|
|
1242
1371
|
return htmlError(
|
|
1243
1372
|
"Vault assignment mismatch",
|
|
1244
|
-
`vault_scope_mismatch: requested scopes ${mismatched.join(", ")} target a vault
|
|
1373
|
+
`vault_scope_mismatch: requested scopes ${mismatched.join(", ")} target a vault outside your assignment.`,
|
|
1245
1374
|
400,
|
|
1246
1375
|
);
|
|
1247
1376
|
}
|
|
1248
1377
|
|
|
1249
|
-
// Stale-assignment defense (hub#284
|
|
1250
|
-
// shaped `vault:<assigned>:<verb>` passes the mismatch
|
|
1251
|
-
// points at a vault that no longer exists in
|
|
1252
|
-
// token here would silently issue scope
|
|
1253
|
-
// server can't find — the user thinks
|
|
1254
|
-
// subsequent API calls fail with no
|
|
1255
|
-
// and surface the same admin-
|
|
1256
|
-
//
|
|
1257
|
-
// the same recovery story.
|
|
1378
|
+
// Stale-assignment defense (hub#284, generalized Phase 2 PR 2). A
|
|
1379
|
+
// named scope shaped `vault:<assigned>:<verb>` passes the mismatch
|
|
1380
|
+
// check above but points at a vault that no longer exists in
|
|
1381
|
+
// services.json. Minting a token here would silently issue scope
|
|
1382
|
+
// against a vault the resource server can't find — the user thinks
|
|
1383
|
+
// consent succeeded but the subsequent API calls fail with no
|
|
1384
|
+
// actionable signal. Refuse the mint and surface the same admin-
|
|
1385
|
+
// remediation hint the GET path's banner uses.
|
|
1258
1386
|
const namedStaleScopes: string[] = [];
|
|
1259
1387
|
for (const s of scopes) {
|
|
1260
1388
|
const parts = s.split(":");
|
|
1261
1389
|
if (
|
|
1262
1390
|
parts.length === 3 &&
|
|
1263
1391
|
parts[0] === "vault" &&
|
|
1264
|
-
parts[1]
|
|
1392
|
+
parts[1] !== undefined &&
|
|
1265
1393
|
parts[2] &&
|
|
1266
|
-
VAULT_VERBS.has(parts[2])
|
|
1394
|
+
VAULT_VERBS.has(parts[2]) &&
|
|
1395
|
+
assignedVaults.includes(parts[1])
|
|
1267
1396
|
) {
|
|
1268
1397
|
namedStaleScopes.push(s);
|
|
1269
1398
|
}
|
|
@@ -1273,10 +1402,19 @@ async function handleConsentSubmit(
|
|
|
1273
1402
|
// the no-vault-scope hot path off-disk for the common admin flows.
|
|
1274
1403
|
const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
|
|
1275
1404
|
const validNames = listVaultNames(manifest);
|
|
1276
|
-
|
|
1405
|
+
// Collect the stale vault names embedded in the named scopes.
|
|
1406
|
+
const staleNames = new Set<string>();
|
|
1407
|
+
for (const s of namedStaleScopes) {
|
|
1408
|
+
const parts = s.split(":");
|
|
1409
|
+
if (parts[1] !== undefined && !validNames.includes(parts[1])) {
|
|
1410
|
+
staleNames.add(parts[1]);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (staleNames.size > 0) {
|
|
1414
|
+
const exemplar = [...staleNames][0];
|
|
1277
1415
|
return htmlError(
|
|
1278
1416
|
"Assigned vault was removed",
|
|
1279
|
-
`Your assigned vault "${
|
|
1417
|
+
`Your assigned vault "${exemplar}" is no longer registered on this hub. Ask the hub admin to reassign you to an existing vault via /admin/users, then try again.`,
|
|
1280
1418
|
400,
|
|
1281
1419
|
);
|
|
1282
1420
|
}
|
|
@@ -1421,10 +1559,7 @@ export async function handleApproveClientPost(
|
|
|
1421
1559
|
);
|
|
1422
1560
|
}
|
|
1423
1561
|
target.searchParams.set("error", "access_denied");
|
|
1424
|
-
target.searchParams.set(
|
|
1425
|
-
"error_description",
|
|
1426
|
-
"The user denied the authorization request.",
|
|
1427
|
-
);
|
|
1562
|
+
target.searchParams.set("error_description", "The user denied the authorization request.");
|
|
1428
1563
|
if (denyState !== undefined) target.searchParams.set("state", denyState);
|
|
1429
1564
|
return redirectResponse(target.toString());
|
|
1430
1565
|
}
|
|
@@ -1478,6 +1613,7 @@ function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): Authori
|
|
|
1478
1613
|
codeChallenge: String(form.get("code_challenge") ?? ""),
|
|
1479
1614
|
codeChallengeMethod: String(form.get("code_challenge_method") ?? "S256"),
|
|
1480
1615
|
state: (form.get("state") as string | null) ?? null,
|
|
1616
|
+
resource: (form.get("resource") as string | null) ?? null,
|
|
1481
1617
|
};
|
|
1482
1618
|
}
|
|
1483
1619
|
|
|
@@ -1491,6 +1627,10 @@ function authorizeParamsToQuery(p: AuthorizeFormParams): Record<string, string>
|
|
|
1491
1627
|
code_challenge_method: p.codeChallengeMethod,
|
|
1492
1628
|
};
|
|
1493
1629
|
if (p.state) q.state = p.state;
|
|
1630
|
+
// Round-trip the RFC 8707 resource indicator through the login redirect so
|
|
1631
|
+
// the resource-bound narrowing survives a sign-in (it re-enters GET
|
|
1632
|
+
// /oauth/authorize with the original params).
|
|
1633
|
+
if (p.resource) q.resource = p.resource;
|
|
1494
1634
|
return q;
|
|
1495
1635
|
}
|
|
1496
1636
|
|
|
@@ -1660,11 +1800,17 @@ async function handleTokenAuthorizationCode(
|
|
|
1660
1800
|
audience,
|
|
1661
1801
|
clientId: redeemed.clientId,
|
|
1662
1802
|
issuer: deps.issuer,
|
|
1663
|
-
// vault_scope claim — Phase
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1666
|
-
//
|
|
1667
|
-
//
|
|
1803
|
+
// vault_scope claim — Phase 2 per-user vault pin (Phase 1 had a single
|
|
1804
|
+
// `assigned_vault` column; Phase 2 PR 2 generalized to `assigned_vaults`
|
|
1805
|
+
// via `user_vaults`). Non-empty list for non-admin users with at least
|
|
1806
|
+
// one assigned vault; empty for first-admin (unrestricted sentinel).
|
|
1807
|
+
// Zero-vault non-admin is also empty by `vaultScopeForUser`, but the
|
|
1808
|
+
// OAuth flow refuses to mint a vault-scoped token for them upstream
|
|
1809
|
+
// (see the zero-vault gate in `handleConsentSubmit` + the same-hub
|
|
1810
|
+
// auto-trust posture check), so we never reach here with that user
|
|
1811
|
+
// posture. The narrowing in `handleConsentSubmit` already rewrote
|
|
1812
|
+
// `vault:<verb>` → `vault:<assigned>:<verb>`, so the auth code's
|
|
1813
|
+
// scopes are pre-aligned; this claim is the explicit "owned vaults"
|
|
1668
1814
|
// signal PR 5 consumes downstream.
|
|
1669
1815
|
vaultScope: vaultScopeForUser(db, redeemed.userId),
|
|
1670
1816
|
now: deps.now,
|
|
@@ -1781,12 +1927,12 @@ async function handleTokenRefresh(
|
|
|
1781
1927
|
clientId: row.clientId,
|
|
1782
1928
|
issuer: deps.issuer,
|
|
1783
1929
|
// vault_scope claim — re-derived from the user's *current*
|
|
1784
|
-
// `
|
|
1785
|
-
// token row). An admin who changes a user's
|
|
1930
|
+
// `assigned_vaults` at refresh time (not snapshotted onto the refresh-
|
|
1931
|
+
// token row). An admin who changes a user's vault assignments between
|
|
1786
1932
|
// mint and refresh sees the new value on the next refresh; existing
|
|
1787
1933
|
// access tokens carry their original claim until their 15-minute TTL
|
|
1788
1934
|
// elapses. Same posture as the design's "OAuth issuer reads
|
|
1789
|
-
// `
|
|
1935
|
+
// `assigned_vaults` at mint time, not at session-creation time" pin.
|
|
1790
1936
|
vaultScope: vaultScopeForUser(db, refreshUserId),
|
|
1791
1937
|
now: deps.now,
|
|
1792
1938
|
});
|
|
@@ -2156,95 +2302,117 @@ function consentProps(
|
|
|
2156
2302
|
params: AuthorizeFormParams,
|
|
2157
2303
|
vaultNames: string[],
|
|
2158
2304
|
csrfToken: string,
|
|
2159
|
-
|
|
2305
|
+
assignedVaults: readonly string[],
|
|
2306
|
+
userIsAdmin: boolean,
|
|
2160
2307
|
) {
|
|
2161
2308
|
const scopes = params.scope.split(" ").filter((s) => s.length > 0);
|
|
2162
2309
|
const unnamedVerbs = unnamedVaultVerbs(scopes);
|
|
2163
|
-
// Multi-user Phase
|
|
2164
|
-
//
|
|
2165
|
-
//
|
|
2166
|
-
//
|
|
2167
|
-
//
|
|
2168
|
-
// "Unknown vault" 400. The user couldn't tell what happened or how to
|
|
2169
|
-
// recover.
|
|
2310
|
+
// Multi-user Phase 2 PR 2 stale-assignment branch (hub#284 generalized
|
|
2311
|
+
// from one vault to N). A non-admin user whose entire vault list has
|
|
2312
|
+
// been removed from services.json — admin removed / renamed the vaults
|
|
2313
|
+
// without reassigning. The banner surfaces this state with an admin-
|
|
2314
|
+
// remediation hint instead of silently minting against a missing vault.
|
|
2170
2315
|
//
|
|
2171
|
-
//
|
|
2172
|
-
// the
|
|
2173
|
-
//
|
|
2174
|
-
//
|
|
2175
|
-
//
|
|
2176
|
-
// depends on a vault — see the `vaultPicker` + `approveDisabled` logic
|
|
2177
|
-
// below.
|
|
2316
|
+
// The user-facing surface is "your assigned vault(s) were removed" —
|
|
2317
|
+
// the banner names the first stale vault as the canonical example. The
|
|
2318
|
+
// exact list is unimportant for the recovery path (operator goes to
|
|
2319
|
+
// /admin/users either way), and pluralizing the banner copy doesn't
|
|
2320
|
+
// change the action.
|
|
2178
2321
|
//
|
|
2179
|
-
// Security posture: we deliberately do NOT relax the picked-must-
|
|
2180
|
-
// assigned check
|
|
2181
|
-
|
|
2182
|
-
|
|
2322
|
+
// Security posture: we deliberately do NOT relax the picked-must-be-in-
|
|
2323
|
+
// assigned-list check. Stale-assignment is admin-remediated.
|
|
2324
|
+
const remainingValidAssigned = assignedVaults.filter((v) => vaultNames.includes(v));
|
|
2325
|
+
const hasStaleAssignment = assignedVaults.length > 0 && remainingValidAssigned.length === 0;
|
|
2183
2326
|
const staleAssignedVault =
|
|
2184
|
-
|
|
2185
|
-
// A named scope like `vault:<old
|
|
2186
|
-
// <old> is the user's stale
|
|
2187
|
-
// defense
|
|
2188
|
-
//
|
|
2189
|
-
//
|
|
2190
|
-
//
|
|
2327
|
+
hasStaleAssignment && assignedVaults[0] !== undefined ? assignedVaults[0] : undefined;
|
|
2328
|
+
// A named scope like `vault:<old>:<verb>` requested by the client where
|
|
2329
|
+
// <old> is one of the user's stale vaults. The server-side named-scope
|
|
2330
|
+
// defense allows this through because the scope matches an assigned
|
|
2331
|
+
// vault, but the token it mints would point at a vault that no longer
|
|
2332
|
+
// exists. Gate Approve on this case too so the user doesn't burn a
|
|
2333
|
+
// consent into a token that fails at the resource server.
|
|
2191
2334
|
const hasNamedStaleVaultScope =
|
|
2192
|
-
|
|
2335
|
+
hasStaleAssignment &&
|
|
2193
2336
|
scopes.some((s) => {
|
|
2194
2337
|
const parts = s.split(":");
|
|
2195
|
-
|
|
2196
|
-
parts.length
|
|
2197
|
-
parts[0]
|
|
2198
|
-
parts[1] ===
|
|
2199
|
-
parts[2]
|
|
2200
|
-
VAULT_VERBS.has(parts[2])
|
|
2201
|
-
)
|
|
2338
|
+
if (
|
|
2339
|
+
parts.length !== 3 ||
|
|
2340
|
+
parts[0] !== "vault" ||
|
|
2341
|
+
parts[1] === undefined ||
|
|
2342
|
+
parts[2] === undefined ||
|
|
2343
|
+
!VAULT_VERBS.has(parts[2])
|
|
2344
|
+
) {
|
|
2345
|
+
return false;
|
|
2346
|
+
}
|
|
2347
|
+
// Named for one of the user's vaults — and given hasStaleAssignment,
|
|
2348
|
+
// none of the user's vaults exist on this hub, so this scope points
|
|
2349
|
+
// at a stale name.
|
|
2350
|
+
return assignedVaults.includes(parts[1]);
|
|
2202
2351
|
});
|
|
2203
2352
|
|
|
2204
|
-
// Multi-user Phase
|
|
2205
|
-
//
|
|
2206
|
-
// non-null) see the picker locked to their `assigned_vault` rather than a
|
|
2207
|
-
// free dropdown. Phase 2 will hide other vaults entirely; Phase 1 ships
|
|
2208
|
-
// lock-the-picker (the smallest diff that satisfies "user can't pick a
|
|
2209
|
-
// vault they don't own"). Server-side defense in `handleConsentSubmit`
|
|
2210
|
-
// refuses mints whose POST disagrees regardless of how the picker is
|
|
2211
|
-
// rendered.
|
|
2353
|
+
// Multi-user Phase 2 PR 2: non-admin users see the picker narrowed to
|
|
2354
|
+
// their assigned vault list. Four shapes emerge:
|
|
2212
2355
|
//
|
|
2213
|
-
//
|
|
2214
|
-
//
|
|
2215
|
-
//
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2356
|
+
// - Single assigned vault (still valid) → render locked to that name
|
|
2357
|
+
// (same shape as Phase 1).
|
|
2358
|
+
// - Two-or-more assigned vaults → render a dropdown filtered to the
|
|
2359
|
+
// user's list. Same control as the admin dropdown but narrowed.
|
|
2360
|
+
// - Stale-assigned (all vaults gone) → render no-vaults-available so
|
|
2361
|
+
// the form gracefully rejects an Approve click instead of silently
|
|
2362
|
+
// submitting a missing name.
|
|
2363
|
+
// - First admin (admin posture, empty `assignedVaults`) → full hub-
|
|
2364
|
+
// wide dropdown of every vault on the hub.
|
|
2365
|
+
// - Zero-vault non-admin (Phase 2 PR 2 reviewer fold) → no-vaults-
|
|
2366
|
+
// available so the form gracefully rejects an Approve click. The
|
|
2367
|
+
// prior shape rendered the full hub-wide list for non-admins with
|
|
2368
|
+
// zero assignments, which let them pick a vault they had no
|
|
2369
|
+
// business consenting to. The consent-submit gate refuses any
|
|
2370
|
+
// vault-scoped POST from a zero-vault non-admin (defense in depth);
|
|
2371
|
+
// this branch keeps the picker UI honest.
|
|
2218
2372
|
let vaultPicker: VaultPickerProps | undefined;
|
|
2219
2373
|
if (unnamedVerbs.length > 0) {
|
|
2220
|
-
if (
|
|
2374
|
+
if (hasStaleAssignment) {
|
|
2221
2375
|
vaultPicker = { unnamedVerbs, availableVaults: [] };
|
|
2222
|
-
} else if (
|
|
2223
|
-
|
|
2224
|
-
|
|
2376
|
+
} else if (remainingValidAssigned.length === 1) {
|
|
2377
|
+
const only = remainingValidAssigned[0];
|
|
2378
|
+
if (only !== undefined) {
|
|
2379
|
+
vaultPicker = { unnamedVerbs, availableVaults: [only], lockedVault: only };
|
|
2380
|
+
}
|
|
2381
|
+
} else if (remainingValidAssigned.length > 1) {
|
|
2382
|
+
vaultPicker = { unnamedVerbs, availableVaults: remainingValidAssigned };
|
|
2383
|
+
} else if (userIsAdmin) {
|
|
2384
|
+
// Admin posture (no assignments) → full hub-wide list.
|
|
2225
2385
|
vaultPicker = { unnamedVerbs, availableVaults: vaultNames };
|
|
2386
|
+
} else {
|
|
2387
|
+
// Zero-vault non-admin → no-vaults-available picker with the
|
|
2388
|
+
// "ask your admin to assign you" copy. The Approve button renders
|
|
2389
|
+
// disabled (same shape as the empty-services-json case) so the
|
|
2390
|
+
// form can't post a hand-picked name. The consent-submit gate
|
|
2391
|
+
// refuses any vault-scoped POST from this user too (defense in
|
|
2392
|
+
// depth — see `handleConsentSubmit`).
|
|
2393
|
+
vaultPicker = { unnamedVerbs, availableVaults: [], emptyReason: "no-assignments" };
|
|
2226
2394
|
}
|
|
2227
2395
|
}
|
|
2228
2396
|
// Named-scope display: substitute unnamed `vault:<verb>` rows with the
|
|
2229
2397
|
// resolved form the operator will actually consent to.
|
|
2230
|
-
// - Non-admin
|
|
2231
|
-
// - Stale-assigned
|
|
2232
|
-
//
|
|
2233
|
-
// -
|
|
2234
|
-
//
|
|
2235
|
-
//
|
|
2236
|
-
// - Admin with multiple
|
|
2237
|
-
// a `<TBD>` placeholder + a hint pointing at the picker. Once the
|
|
2238
|
-
// operator clicks a radio the JS-free form still posts the chosen
|
|
2239
|
-
// name; the displayed scope only changes after the next page render.
|
|
2398
|
+
// - Non-admin with exactly one valid assigned vault → render that name.
|
|
2399
|
+
// - Stale-assigned → null; the row carries the `<TBD>` placeholder.
|
|
2400
|
+
// The banner explains why a name isn't bound.
|
|
2401
|
+
// - Non-admin with multiple assigned vaults → null sentinel (the user
|
|
2402
|
+
// hasn't picked yet).
|
|
2403
|
+
// - Admin with exactly one vault available → render that name.
|
|
2404
|
+
// - Admin with multiple / no vaults → null sentinel.
|
|
2240
2405
|
let displayVault: string | null = null;
|
|
2241
|
-
if (
|
|
2242
|
-
|
|
2406
|
+
if (!hasStaleAssignment && remainingValidAssigned.length === 1) {
|
|
2407
|
+
const only = remainingValidAssigned[0];
|
|
2408
|
+
if (only !== undefined) displayVault = only;
|
|
2243
2409
|
} else if (
|
|
2244
|
-
|
|
2410
|
+
!hasStaleAssignment &&
|
|
2411
|
+
assignedVaults.length === 0 &&
|
|
2245
2412
|
unnamedVerbs.length > 0 &&
|
|
2246
2413
|
vaultNames.length === 1
|
|
2247
2414
|
) {
|
|
2415
|
+
// Admin with a single vault on the hub: pre-check pattern from Phase 1.
|
|
2248
2416
|
const only = vaultNames[0];
|
|
2249
2417
|
if (only) displayVault = only;
|
|
2250
2418
|
}
|
|
@@ -2271,4 +2439,5 @@ interface VaultPickerProps {
|
|
|
2271
2439
|
unnamedVerbs: string[];
|
|
2272
2440
|
availableVaults: string[];
|
|
2273
2441
|
lockedVault?: string;
|
|
2442
|
+
emptyReason?: "no-assignments" | "no-vaults-on-hub";
|
|
2274
2443
|
}
|