portal-mojo 0.2.19 → 0.2.20

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.
Files changed (35) hide show
  1. package/docs/admin-credentials.md +27 -0
  2. package/docs/admin-dns-certificates.md +160 -0
  3. package/docs/admin-dns.md +16 -0
  4. package/docs/admin-fleet-configuration.md +98 -2
  5. package/docs/admin-identity-directories.md +12 -0
  6. package/docs/admin-modals.md +28 -0
  7. package/docs/auth-pages.md +18 -6
  8. package/docs/auth.md +16 -2
  9. package/docs/collection-select.md +14 -0
  10. package/package.json +1 -1
  11. package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
  12. package/src/admin/credentials/acme-consumer-key.ts +34 -0
  13. package/src/admin/credentials/group-api-keys.tsx +5 -3
  14. package/src/admin/dns/CertificateDetail.tsx +8 -3
  15. package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
  16. package/src/admin/dns/DomainDetail.tsx +7 -1
  17. package/src/admin/dns/DomainsPage.tsx +6 -2
  18. package/src/admin/dns/SharedValidationDialog.tsx +298 -0
  19. package/src/admin/dns/SharedValidationPanel.tsx +76 -0
  20. package/src/admin/dns/shared-validation-api.ts +90 -0
  21. package/src/admin/dns/shared-validation-data.ts +106 -0
  22. package/src/admin/domains/operations.ts +6 -4
  23. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
  24. package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
  25. package/src/admin/fleet-configuration/data.ts +4 -0
  26. package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
  27. package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
  28. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
  29. package/src/admin/identity/groups/models.ts +2 -0
  30. package/src/client/auth.ts +77 -25
  31. package/src/client/fleet-mock.ts +73 -1
  32. package/src/client/index.ts +2 -2
  33. package/src/client/mock.ts +233 -6
  34. package/src/styles/components/admin-dns-certificates.css +33 -0
  35. package/src/ui/CollectionSelect.tsx +30 -8
@@ -206,3 +206,30 @@ See [Admin modal policy](admin-modals.md) for header actions, content-only
206
206
  sections, supported lifecycle fields and retained irreversible exceptions.
207
207
  Group API keys and webhook subscriptions deactivate/reactivate; their built-in
208
208
  Delete controls are removed. Reveal, secret rotation and cache boundaries remain.
209
+
210
+ ## Protected ACME consumer purpose
211
+
212
+ Literal deployment operators can open **Create ACME consumer key** from the
213
+ existing fixed-Group or cross-Group API-key inventory. This lazy form resolves
214
+ the selected original hub Group through the private shared-validation recipient
215
+ endpoint. It displays the actual active Group name/UUID, the named ACME validation
216
+ consumer purpose and the fixed `dnsman_acme_federation` consequence. A tenant or
217
+ acting user cannot substitute for the original recipient. It has no freeform
218
+ permission or JSON input. The server rechecks Group activity, expected label and
219
+ protected grant at issuance and rejects key-backed sessions.
220
+
221
+ Issue is separately confirmed, single-flight and session-bound. Its response is
222
+ split before presentation: safe key metadata may be displayed; the transient
223
+ token stays outside Query/Mutation caches, URL/history and storage. Closing the
224
+ native token dialog drops that display. The existing explicit, audited
225
+ `/api/group/apikey/<id>?graph=token` reveal remains the recovery route. A timeout
226
+ may follow successful issuance: inspect the exact Group's recent inventory and
227
+ use audited reveal before creating another successor. The form blocks automatic
228
+ reissue and never retries after fresh authentication.
229
+
230
+ Create a replacement, enter it normally in the receiving deployment's protected
231
+ connection form, Preview/Publish, Apply and verify every expected node. Only then
232
+ explicitly review/deactivate the old hub key using existing key controls. This
233
+ form neither rotates nor retires keys, grants allocation trust, activates hub
234
+ sharing or asserts cross-deployment verification. Existing in-place key rotation
235
+ semantics remain unchanged and can invalidate the previous token immediately.
@@ -120,3 +120,163 @@ npm run verify:admin-dns-records
120
120
 
121
121
  Live verification is read-only. Do not request or revoke a real certificate
122
122
  without separate operator authorization.
123
+
124
+ ## Shared validation setup
125
+
126
+ Use **Domains → Shared validation setup**, or **Set up shared validation** in a
127
+ group-managed Domain or Certificate header menu. Domain **Validation** and
128
+ Certificate **Delegation** show the same read-only facts. No new route or sidebar
129
+ resource is added. The flow never reads certificate material.
130
+
131
+ 1. Select an authorized existing local Domain, or an eligible group and exact
132
+ apex name. Group selection uses `/api/dnsman/delegation/group-choice`, bounded
133
+ `search/start/size` and explicit `?id=` hydration. Product active-group context
134
+ and credential group choices do not authorize shared setup.
135
+ 2. Enter the existing public allocation target supplied by its controller and
136
+ **Inspect plan**. Inspection creates no connection or reference.
137
+ 3. **Prepare connection** and confirm the displayed domain/profile/target.
138
+ Preparation pauses direct local validation and renewal until authorization
139
+ and checking finish. Existing valid certificate material remains available.
140
+ 4. **Download request** saves `shared-validation-request.json`. Send it to the
141
+ allocation controller using your normal coordination channel. **Share request**
142
+ uses native file sharing when supported; **Copy request** is optional. Download
143
+ stays available after copy/share denial or cancellation. The canonical file is
144
+ at most 2048 UTF-8 bytes and has exactly `version:1`, `consumer_project_uuid`,
145
+ `client_ref`, `domain`, `allocation_ref`, with no wrapper, key, token or scope.
146
+ 5. The controller opens their deployment’s **Domains → Shared validation setup →
147
+ Controller review**, checks access and uses **Import request**. There is no JSON
148
+ editor or controller URL. Empty, oversized, malformed, noncanonical or extra-field
149
+ files are refused before installation. Import alone grants nothing. The named
150
+ public fields fill the exact domain query; **Find allocations**, page if needed
151
+ and select the exact existing target. Only a matching request and resolved active
152
+ exact recipient enable **Review grant**, then **Allow issuance** after consent
153
+ names both apex and wildcard authority. No allocation is created.
154
+ 6. Back in the project, **Refresh**, **Connect approved validation**, then
155
+ **Check validation**. These are separate deliberate actions. Check requires
156
+ the approved existing one-hop `_acme-challenge.<apex>` CNAME.
157
+ 7. **Request wildcard certificate** issues this deployment's own apex-plus-
158
+ wildcard certificate. Existing valid exact-profile certificates remain in
159
+ place; queued work stays queued and due renewal reuses its lineage. Watch
160
+ the local certificate and separate renewal status, or Refresh after timeout.
161
+
162
+ Only global DNS managers (`sys.manage_dns | sys.security`) see local mutation
163
+ controls, and every action also requires the backend's current per-target flag.
164
+ Controller operations additionally require v2 `shared_validation` and
165
+ `can_manage_trust`, then exact-allocation authority. A human controller can have
166
+ `can_consume:false`; local project setup uses the server-held project identity
167
+ independently. Ordinary DNS managers cannot grant themselves trust. Groupless
168
+ house domains are read-only and ineligible in this rollout; direct operation
169
+ remains available and Portal never assigns a group automatically.
170
+
171
+ ### Local API and safe state
172
+
173
+ | Method | Local route under `/api/dnsman` | Input |
174
+ |---|---|---|
175
+ | GET | `delegation/plan` | `{domain}` or `{group,name}`, optional `allocation_ref` |
176
+ | POST | `delegation/connect` | Same target plus `allocation_ref`, optional explicit `restart:true` |
177
+ | POST | `delegation/check` | `{delegation:<local id>}` |
178
+ | POST | `delegation/request` | `{delegation:<local id>}`; no custom SANs, force or keys |
179
+ | GET | `delegation/group-choice` | `id` or bounded `search/start/size` |
180
+
181
+ All setup actions return Readiness v1. Portal strictly projects only public
182
+ metadata and validates version, selected identity, source/target, fixed profile,
183
+ booleans and enums. Expected 409s can include the same safe readiness DTO.
184
+ Unknown containers and provider error text are discarded before state. Plans,
185
+ descriptors and controller identities are modal-local; no draft is persisted or
186
+ written to URLs, diagnostics or logs. Writes are imperative and non-retrying,
187
+ freeze the scope, recheck auth/session/permission before send, and reject late
188
+ responses after scope loss. After every attempted write, safe Domain,
189
+ Certificate and legacy delegation caches are invalidated and plan/trust is
190
+ refreshed. Ambiguous results require successful Refresh before another write.
191
+ A 440 prompts authentication but never automatically repeats the operation.
192
+ Every explicit session replacement, including the same user signing in again,
193
+ clears controller capabilities, recipients and participant facts. Check controller
194
+ access and select an authorized allocation again. A new grant also requires a matching imported request and freshly resolved active recipient.
195
+ Closing a view cannot cancel a write already submitted.
196
+
197
+ Controller POSTs use only the local `/api/dnsman/acme` endpoints:
198
+ `capabilities {}`, `allocation/list {domain,cursor?}`,
199
+ `trust/list {allocation_ref,cursor?}`,
200
+ `trust/recipient {allocation_ref,consumer_project_uuid}`,
201
+ `trust/grant {connection_request}` and
202
+ `trust/revoke {allocation_ref,participant_ref}`. Each page contains at most 20
203
+ rows. Only a capability-approved controller probes the allocation/trust routes;
204
+ there is no arbitrary project directory, remote hub call, key provisioning or
205
+ consumer/status call from a human JWT. Recipient and allocation authorization
206
+ are repeated server-side on every operation.
207
+
208
+ ### Operator setup and mixed versions
209
+
210
+ If the local plan says the hub is unconfigured or misconfigured, a deployment
211
+ operator uses the existing server configuration delivery to set
212
+ `DNSMAN_ACME_HUB_URL` to the already-used Maestro hub and
213
+ `DNSMAN_ACME_HUB_API_KEY` to the already-provisioned protected project key in
214
+ Django settings, normally `VAR_ROOT/django.conf`. Upgrade the hub first, then
215
+ project API and every certificate worker; drain/restart old issuance workers
216
+ before shared connections are accepted. Restart application and worker
217
+ processes and **Refresh Portal**. A responsive API does not prove that workers
218
+ were restarted; worker version is unknown unless authoritative telemetry exists.
219
+ These settings are static deployment configuration, never DB Setting overrides,
220
+ browser fields, arbitrary per-site URLs or values copied into descriptors.
221
+
222
+ 404/405 and unsupported new versions disable only shared setup; existing strict
223
+ DNS config, direct-provider and legacy single-consumer certificate paths retain
224
+ their contracts. 401/403 report access refusal instead of suggesting an upgrade.
225
+ Disabled sharing, unconfigured/auth-rejected/unavailable hubs and unknown proof
226
+ never display setup as complete. Portal does not bypass backend activation.
227
+
228
+ ### Repair and revocation
229
+
230
+ ACME validation and traffic routing are separate. The validation CNAME does not
231
+ route apex/www/API/app traffic. Follow the deployment's separate A/AAAA/CNAME
232
+ traffic instructions. Missing, changed or chained validation proof shows the
233
+ expected and bounded observed target and its check time. Ask the domain owner
234
+ to restore the approved one-hop route after checking other consumers. Portal
235
+ does not replace/remove that CNAME or rotate the allocation as a default repair.
236
+
237
+ Controller maintenance starts cold with **Check controller access**, an **Exact
238
+ domain** and **Find allocations**. Native choices show target and owner UUID.
239
+ **Next allocation page** / **First allocation page** and **Next project page** /
240
+ **First project page** navigate bounded 20-row pages. Select an allocation to
241
+ inspect participants, role, state, exact scope and revoked time. No request file
242
+ or recipient lookup is needed for this path. An imported inactive consumer can
243
+ fail recipient lookup while participant inspection, revoke and cleanup remain
244
+ available. Owner participants cannot be revoked. **Refresh trust** checks current
245
+ capabilities; an unsuccessful reconciliation blocks mutation until refresh succeeds.
246
+
247
+ Controller revocation stops future DNS-01 validation for that project. Already-
248
+ issued certificates remain valid until expiry or separate certificate revocation,
249
+ and other trusted projects keep working. A 503 may occur after revocation was
250
+ persisted; refreshed trust can say revoked while DNS cleanup remains unconfirmed.
251
+ Retrying cleanup never regrants the reference. A revoked reference cannot be
252
+ revived: the project must explicitly confirm **Prepare fresh request**, keep
253
+ the existing target, and obtain a new exact controller grant.
254
+
255
+ A valid local certificate with blocked/failed/retrying renewal is shown as
256
+ “valid until …; renewal needs attention.” It is not labeled expired or revoked
257
+ as a repair. Material custody is API-node evidence, not proof that any serving
258
+ host installed that certificate. Shared pending/broken/revoked routing never
259
+ permits fallback TXT writes through the direct provider. Private keys stay in
260
+ each deployment.
261
+
262
+ The mock showcase includes deterministic controller-maintenance pages and an inactive consumer, plus the complete project/controller handshake, viewer,
263
+ revoked, pending/changed/chained DNS, disconnected/unconfigured/disabled hub,
264
+ older API, failed renewal and 390px embedded-content scenarios. Scenario changes
265
+ reset mock shared state; identity changes preserve it. Leaving the demo restores
266
+ its default mock identity and scenario. `verify:admin-dns-shared-validation`
267
+ runs API/projection and React StrictMode mounted contracts. Real browser proof
268
+ remains required for focus, native-dialog stacking, clipboard and both themes.
269
+
270
+ Owner and consumer project identities preserve existing lowercase16hex Group
271
+ IDs as well as the previously supported32hex and canonical UUID forms. They
272
+ are compared exactly and never padded, regenerated or rewritten. Client and
273
+ participant references retain their strict UUID validation; a short project ID
274
+ cannot stand in for either reference.
275
+
276
+
277
+ Shared setup prerequisites now link literal deployment operators to the actual
278
+ mount-aware Fleet shared-validation connection topic. It owns named approved
279
+ profiles, write-only protected keys, expected/observed identity and revision-bound
280
+ Preview/Publish/Apply. Ordinary DNS managers receive a concrete domain, next
281
+ action and checked-time handoff with no private inputs or private API background
282
+ calls. This route does not perform the protected hub activation ceremony.
package/docs/admin-dns.md CHANGED
@@ -84,3 +84,19 @@ See [Admin modal policy](admin-modals.md) for header actions, content-only
84
84
  sections, supported lifecycle fields and retained irreversible exceptions.
85
85
  Provider credentials retire/reactivate through `is_active`, keeping history;
86
86
  rotation remains write-only and imperative. Built-in credential Delete is absent.
87
+
88
+ ## Shared wildcard validation
89
+
90
+ The existing Domains header offers **Shared validation setup** to global DNS
91
+ managers even when server setup needs an operator. Domain detail includes
92
+ read-only **Validation**; its header action and Certificate Delegation compose
93
+ the same focused flow. See [the human walkthrough and exact
94
+ contracts](admin-dns-certificates.md#shared-validation-setup).
95
+
96
+ Shared setup uses independent Readiness v1/hub v2 adapters. It never changes the
97
+ strict legacy `/config` response contract. A controller grant is explicit
98
+ apex/wildcard certificate issuance authority; local DNS management alone cannot
99
+ supply it. Only public five-field connection requests are copied. No private
100
+ key, PEM, DNS credential or protected hub key is requested, read, persisted or
101
+ logged. Deployment operators deliver static hub settings server-side and restart
102
+ API/workers. Validation CNAME routing remains separate from traffic routing.
@@ -7,8 +7,9 @@ page checks `is_superuser === true` before fetching. Backend authority remains
7
7
  mandatory, including rejection of diagnostic/key-backed sessions and fresh auth.
8
8
 
9
9
  Applications register their schema in django-mojo. The page renders the returned
10
- section, label, description, default, type and restart requirement; no application
11
- setting names are hardcoded. Each field defaults to Keep current value. Set value
10
+ section, label, description, default, type and restart requirement; application schemas remain server-owned. The three managed shared-validation
11
+ connection fields route to the dedicated topic below, rather than appearing as
12
+ independently editable generic fields. Each field defaults to Keep current value. Set value
12
13
  and Remove published override are explicit actions. Clearing falls back to base
13
14
  configuration/default; it does not promise erasure of a bootstrap secret.
14
15
 
@@ -48,3 +49,98 @@ example. Mock Portal uses representative registered settings and an offline node
48
49
  Django receives this page only after a canonical Portal artifact is built and
49
50
  vendored; follow `docs/admin-artifact.md` and the Django vendor tool. Ordinary
50
51
  Vite build output is not the canonical artifact.
52
+
53
+ ## Shared validation connection
54
+
55
+ Operations → Fleet Configuration → Shared validation connection is a private, lazy
56
+ route. It requires a current literal superuser; the server additionally requires
57
+ an active interactive session, same-origin writes and fresh authentication.
58
+ Key-backed sessions are rejected by the server even when their user is an owner.
59
+
60
+ On the validation hub, update every expected node using the existing Deployments
61
+ controls, then use **Enable shared validation** once. The compact hub section is
62
+ independent of consumer connection configuration and uses the same Portal login,
63
+ fresh authentication and native confirmation as the existing page. No separate
64
+ operator service, login or wizard is required. Missing or incompatible deployment
65
+ proof shows the ordinary prerequisite instead of an enabled action. Each issuer
66
+ keeps its own ACME account, certificates and private keys; allocation trust still
67
+ requires explicit approval in the DNS setup screen. Renewal remains automatic.
68
+
69
+ `GET /api/account/admin/fleet/shared-validation/hub` returns
70
+ `{version:1, enabled, configured, deployment_uuid, can_enable, reason}`.
71
+ The deployment UUID is canonical lowercase with hyphens, unlike consumer project
72
+ UUIDs. `POST` takes only `{deployment_uuid}` after confirmation. The backend
73
+ rechecks the complete deployed roster and framework version atomically before
74
+ enabling. A stale deployment, failed read or fresh-auth refusal requires Refresh
75
+ and another deliberate review; mutations are never replayed. Keep the compatible
76
+ framework on every node after enabling sharing.
77
+
78
+ The panel consumes the private `/api/account/admin/fleet/shared-validation`
79
+ contract. Choose a named approved hub profile, inspect its exact origin and
80
+ original project UUID, then Keep, Replace or Disable the connection. Replacement
81
+ is a normal write-only password field; blank preserves the existing credential.
82
+ The approved catalog fixes the origin. Arbitrary URLs and raw JSON are not inputs.
83
+
84
+ Project identity fields also accept existing lowercase16hex Group IDs, preserving
85
+ the exact value through profiles, observations and protected key issuance. Fleet
86
+ revisions/operation IDs remain32hex and deployment IDs remain canonical UUIDs.
87
+ The legacy identity showcase fixture changes wire data only; no Group identity
88
+ is migrated or rewritten.
89
+
90
+ Preview probes the candidate without saving it. Publication has a separate
91
+ confirmation, frozen against the preview and auth session. Publishing revalidates
92
+ the catalog and expected revision; it does not Apply. Apply has another
93
+ confirmation and targets the exact current revision. Every expected node must
94
+ report installed, restarted and combined request-service/job-engine/scheduler
95
+ health at that revision and the expected topic profile/project identity. Offline,
96
+ draining, stale, partial and missing-node results remain unconfirmed. The UI
97
+ polls at most120 observations and offers deliberate Refresh afterward. Verified
98
+ connection identity with `sharing_ready=false` still needs sharing enabled on the
99
+ approved hub. Refresh the connection after enabling it there.
100
+
101
+ Create the replacement on the approved hub with the named **ACME validation
102
+ consumer** key form. Preview/Publish, Apply, verify every expected node, then
103
+ explicitly review and deactivate the old key in the hub inventory. Issuance,
104
+ publication and successful dispatch never retire keys. Disable masks the whole
105
+ connection tuple and base credential without deleting base settings or revoking
106
+ remote trust; retained/shared identities can make disabling ineligible.
107
+
108
+ Secrets remain in an uncontrolled mounted password input and a short-lived
109
+ imperative request draft. They never enter Query/Mutation caches, URL/history,
110
+ storage, receipts or logs. These new writes call `requestFreshAuth` without
111
+ replaying the attempted action; the existing generic Fleet helpers keep their
112
+ prior behavior. Auth/session change or unmount invalidates drafts and closes
113
+ owned confirmations. Double activation is single-flight. A409 revision/catalog
114
+ conflict or `configuration_busy` requires Refresh and a deliberate retry. An
115
+ uncertain publication or issuance may have persisted; read authoritative status
116
+ or exact Group inventory before another attempt.
117
+
118
+ Unavailable APIs, missing DNSMan/publisher/delegation/schema/enrollment or an empty
119
+ approved catalog render a concrete blocked state. Bootstrap supplies `admin_path`
120
+ and permission facts for existing legacy System Setup, Deployments, topology and
121
+ Fleet Scaling links. Links are same-origin and capability-gated. The backend's
122
+ semantic `capacity` hint maps to the actual registered `#/fleet` route. These
123
+ legacy destinations retain their protected confirmations, policy, release holds
124
+ and IaC restrictions. This React topic reuses those controls without a new
125
+ deployment or root authority service.
126
+
127
+ The Runtime settings showcase mounts the actual connection panel with central
128
+ mock scenarios and operator/ordinary-manager identities. The named
129
+ `verify:admin-shared-validation-config` gate includes private DTO/API and mounted
130
+ StrictMode contracts, and is included once by `verify:admin-contracts`. Browser
131
+ proof remains necessary for real native dialogs, keyboard/focus and390px in both
132
+ themes. Mock receipts are fixtures, never live configuration evidence.
133
+
134
+ The private form uses an inline-size container: fact labels stack above UUIDs and
135
+ revision values below420px of available content, including a narrow embedded
136
+ Admin host. The existing Admin sidebar's Collapse control supplies its normal
137
+ compact host at390px; the topic does not change global navigation behavior.
138
+
139
+ Every successful authoritative refresh invalidates a preview when revision,
140
+ catalog, profile, identity or credential facts change. Late preview/operation
141
+ responses cannot restore spent consent or overwrite a newer observation. After
142
+ the automatic observation limit, explicit Refresh re-observes the tracked exact
143
+ operation and resets the read budget; it never dispatches another Apply. Only
144
+ known healthy report/node statuses with no top-level or node error can turn the
145
+ combined proof green. Apply/read replies remain bound to their exact operation
146
+ ID and requested/tracked revision.
@@ -35,6 +35,18 @@ existing system permission gate and conditional queries. Parent, child, member
35
35
  and user navigation opens native detail modals while keeping the directory
36
36
  underneath.
37
37
 
38
+ The Sub-Groups section carries the "Administrators can create sub-groups"
39
+ switch for `sys.groups | sys.manage_groups` holders only; nobody else sees it.
40
+ It reads `metadata.protected.allow_member_subgroups === true` (anything else
41
+ is off) and changes it only through the django-mojo Group action
42
+ `POST /api/group/<id> {"allow_member_subgroups": true|false}`, which answers
43
+ `{"status": true, "allow_member_subgroups": <bool>}`. Never write
44
+ `metadata.protected` directly: that path refuses `manage_groups` admins. The
45
+ action needs django-mojo with #9126; the server refuses a caller without the
46
+ global grant with 403 and a non-boolean with 400. While on, people who manage
47
+ the group as members can create groups directly under it; the setting is not
48
+ inherited by the new groups, and turning it off leaves existing groups alone.
49
+
38
50
  The group model and section code are adapted from the reference portal at
39
51
  `v0.2.5` commit `93c7fdfeb3096d33010e72bd4483cf9ca6e3d6f5`. The source hashes
40
52
  and corresponding package hashes are recorded in
@@ -135,3 +135,31 @@ browser interactions for all family bodies, both themes, desktop/narrow,
135
135
  keyboard navigation and console checks. The lazy Showcase **Admin modal audit**
136
136
  demo supplies long-title, rich/zero/dot badges, nested editor, lifecycle-cancel and
137
137
  embedded-host cases. Existing ToastHost top-layer visibility work remains separate.
138
+
139
+ ## Shared validation confirmations
140
+
141
+ The DNS shared-validation editor freezes the target group/domain, existing
142
+ allocation and session while an operation is pending. Grant additionally freezes
143
+ the imported request, active exact recipient UUID and client reference. Participant
144
+ revoke/cleanup freezes the selected current-page participant independently of any
145
+ request or recipient lookup; cleanup retry leaves the reference revoked.
146
+ Its owner AbortSignal closes only its confirmation on unmount, scope loss or
147
+ auth/session replacement; submitted writes can still persist. Escape/backdrop
148
+ and Close are unavailable while the outer operation is pending.
149
+
150
+ Prepare explains the pause of direct validation/renewal while valid material
151
+ remains available. Grant names the exact project and apex/wildcard issuance
152
+ authority and independent private-key custody. Revoke explains that future
153
+ validation stops, already-issued certificates remain valid and other consumers
154
+ keep working. Revoked references require a fresh, explicitly prepared request.
155
+ No confirmation is inferred from a copied descriptor. Fresh authentication
156
+ prompts without replay; every submitted result reconciles before another write.
157
+
158
+
159
+ The protected connection topic separately confirms Publish and Apply, naming
160
+ profile/project and exact revision consequences. The ACME consumer key form
161
+ confirms the current original Group name/UUID and fixed protected purpose before
162
+ issuance; its token display is transient and the existing audited reveal is
163
+ available for recovery. Every owned nested dialog receives the auth-owner abort
164
+ signal. Session replacement, scope loss and unmount close those dialogs; submitted
165
+ writes may still persist and are never replayed after fresh authentication.
@@ -124,12 +124,18 @@ Client surface (`portal-mojo/client`):
124
124
  - `withFreshAuth(fn)` — run fn; on a 440 prompt, then retry **once** after a
125
125
  successful re-login; a dismissed prompt rejects with the ORIGINAL error.
126
126
 
127
- The modal re-prompts credentials OVER the current screen — page state
128
- survives. It pins the identity to the current session's email (step-up is
129
- "prove you are still you"), offers password + passkey + the MFA step, and
130
- keeps the session in its original storage (`sessionIsPersistent()` →
131
- `remember`). Dev affordance: `__mojo.requestFreshAuth()` in the console
132
- exercises the modal without a 440.
127
+ In hosted mode (including packaged Admin), the native dialog offers Cancel
128
+ and Continue to sign-in. Continue opens the existing configured sign-in page
129
+ with `force_reauth=1`, so Google-only deployments can use Google. It preserves
130
+ the exact return route and original storage choice. The pending challenge
131
+ resolves false before unloading: after returning, re-read and deliberately
132
+ retry the action. Picking another account starts a new session and screen;
133
+ the former account's operation is never carried over.
134
+
135
+ In explicit in-app/mock mode the modal re-prompts credentials over the current
136
+ screen, pins the current email when available, and keeps the existing
137
+ password/passkey/MFA and storage behavior. Dev affordance:
138
+ `__mojo.requestFreshAuth()` exercises the modal without a 440.
133
139
 
134
140
  Call-site pattern:
135
141
 
@@ -172,6 +178,12 @@ bouncer-enforcing deployments.
172
178
 
173
179
  ## Mock affordances
174
180
 
181
+ `npm run verify:hosted-reauth` covers the mounted hosted confirmation and
182
+ return/storage paths. For native dialog, responsive and theme checks, run
183
+ `node scripts/serve-hosted-reauth.mjs` and open its printed local URL. The
184
+ fixture uses the actual Portal host/bridge and a simulated Google-only sign-in
185
+ page; it does not prove a live OAuth provider exchange.
186
+
175
187
  Under the mock transport the pages show inline dev hints: password `mojo`
176
188
  for any seeded user (e.g. `ian@mojoverify.com`); Maya (seeded user 2) requires
177
189
  MFA; TOTP `123456`, recovery `mock-recovery`, and SMS `654321`; reset code
package/docs/auth.md CHANGED
@@ -102,11 +102,25 @@ its bot-check interstitial). Flow:
102
102
  `<origin>/auth?redirect=<HASH-FREE current url>`. Hash-free is
103
103
  load-bearing: the page string-appends `?auth_code=<code>` to the
104
104
  redirect, and a `#` in it would swallow the code.
105
- 2. User signs in on the hosted page; it mints a single-use 60s handoff code
105
+ 2. User signs in using that page's configured methods (Google, password,
106
+ passkey, etc.). Same-origin return uses its existing stored session;
107
+ a cross-origin return mints a single-use 60s handoff code
106
108
  (`POST /api/auth/handoff`) and navigates back with `?auth_code=`.
107
109
  3. `handleAuthCodeFromURL()` (boot) exchanges it (`POST /api/auth/exchange`),
108
110
  scrubs the code from the URL synchronously, restores the stashed route.
109
- Handles a hash-embedded `#/route?auth_code=` landing too.
111
+ It also restores same-origin returns without a code. Handles a
112
+ hash-embedded `#/route?auth_code=` landing too. Await this at boot before
113
+ creating the router.
114
+
115
+ `redirectToHostedAuth({forceReauth: true})` uses the same page with
116
+ `force_reauth=1`, bypassing its automatic existing-session check. It retains
117
+ the initiating persistent/session-only choice, including when the hosted
118
+ page writes localStorage. `remember` may explicitly supply the choice captured
119
+ when the challenge opened. Tab-local return metadata contains only the route,
120
+ destination, time and storage choice; it is consumed once, expires after 30
121
+ minutes, and applies only at the matching origin/path/query. It never carries
122
+ an operation or establishes freshness. The server still enforces `auth_time`.
123
+ When storage cannot retain the choice, step-up navigation returns false.
110
124
 
111
125
  `hostedAuthUrl()` returns null under the mock transport — there is no page
112
126
  to host; the UI should fall back to a dev affordance (`__mojo.login(...)`).
@@ -130,3 +130,17 @@ too — there is no parallel fetch path.
130
130
  handed a picker that says to pick a group they can't pick there.
131
131
  - Don't wire `onChange` to a per-keystroke autosave expectation — it fires
132
132
  on commit (that is the point; see the B3 autosave contract).
133
+
134
+ ### Optional ingress projection
135
+
136
+ `sanitizeRow={(row) => publicProjection(row)}` projects list and one-record
137
+ responses before Query cache storage. Such pickers use a separate cache keyed
138
+ by the sanitizer identity, auth UID and session revision; they never scan or
139
+ reuse raw endpoint caches. Keep the callback stable. Default consumers retain
140
+ the existing shared model keys and behavior. The callback must return only the
141
+ approved fields or reject malformed input. Full selected objects also need to
142
+ be projected by the caller before passing them in.
143
+
144
+ An endpoint with query-based hydration should hydrate through its own safe
145
+ `?id=` helper and pass the full selected object; do not pass a bare id that
146
+ would invent a `/<id>` record route.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.19",
3
+ "version": "0.2.20",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,88 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { requestFreshAuth } from '../../client/runtime';
4
+ import { CollectionSelect, FlatRow, modal } from '../../ui';
5
+ import { SecretBox } from './secret-dialog';
6
+ import { OperatorError, usePrivateOperator, projectIdentity } from '../fleet-configuration/shared-validation-data';
7
+ import { issueAcmeKey, projectKeyGroup, readAcmeRecipient, readKeyGroup, readRecipientKeyInventory, revealRecipientKey, type AcmeRecipient, type KeyGroup, type KeySummary } from './acme-consumer-key';
8
+ import { GroupApiKeyModel, type CredentialGroup } from './models';
9
+
10
+
11
+ export function AcmeConsumerKeyButton({ group }: { group?: CredentialGroup }) {
12
+ const owner = usePrivateOperator();
13
+ return owner.allowed ? <button className="btn btn-compact" onClick={() => void openAcmeConsumerKey(group)}>Create ACME consumer key</button> : null;
14
+ }
15
+
16
+ export function openAcmeConsumerKey(group?: CredentialGroup) {
17
+ let pending = false;
18
+ return modal.open((close) => <AcmeConsumerKeyDialog group={group} close={() => close(null)} onBusy={(busy) => { pending = busy; }} />, { size: 'lg', canDismiss: () => !pending });
19
+ }
20
+ export function AcmeConsumerKeyDialog({ group: fixedGroup, close, onBusy }: { group?: CredentialGroup; close: () => void; onBusy?: (busy: boolean) => void }) {
21
+ const owner = usePrivateOperator(), qc = useQueryClient();
22
+ const [group, setGroup] = useState<KeyGroup | null>(null), [recipient, setRecipient] = useState<AcmeRecipient | null>(null);
23
+ const [inventory, setInventory] = useState<KeySummary[] | null>(null), [blocked, setBlocked] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState('');
24
+ const name = useRef<HTMLInputElement>(null), flight = useRef(false), generation = useRef(0), recipientOwner = useRef('');
25
+ const selected = useRef(group?.id); selected.current = group?.id;
26
+ const opener = useRef<HTMLElement | null>(null);
27
+ const pending = (value: boolean) => {
28
+ if (value) opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
29
+ flight.current = value; setBusy(value); onBusy?.(value);
30
+ if (!value) { const fence = owner.fence(), target = opener.current; requestAnimationFrame(() => { try { fence.beforeSend(); if (target?.isConnected) target.focus(); } catch { /* Former owner cannot regain focus. */ } }); }
31
+ };
32
+ useEffect(() => () => { generation.current++; onBusy?.(false); }, []);
33
+ useEffect(() => { setRecipient(null); setInventory(null); setBlocked(true); if (name.current) name.current.value = ''; }, [owner.key]);
34
+ const failure = async (reason: unknown) => { const safe = reason instanceof OperatorError ? reason : new OperatorError('unavailable'); setError(safe.message); setBlocked(true); if (safe.kind === 'fresh') await requestFreshAuth(); };
35
+ useEffect(() => {
36
+ if (!fixedGroup || !owner.allowed) return;
37
+ const fence = owner.fence(); void readKeyGroup(fixedGroup.id, fence).then((g) => { fence.beforeSend(); setGroup(g); }).catch((e) => { if (!fence.signal?.aborted) void failure(e); });
38
+ }, [fixedGroup?.id, owner.key]);
39
+ useEffect(() => { setRecipient(null); setInventory(null); setError(''); setBlocked(false); generation.current++; }, [group?.id]);
40
+ const refresh = async () => {
41
+ if (flight.current || !group || !owner.allowed) return;
42
+ const fence = owner.fence(), id = group.id, run = ++generation.current; pending(true); setError(''); setRecipient(null);
43
+ try {
44
+ const current = await readKeyGroup(id, fence); fence.beforeSend();
45
+ if (selected.current !== id || generation.current !== run) return;
46
+ if (!current.uuid) throw new OperatorError('invalid'); projectIdentity(current.uuid);
47
+ const [person, keys] = await Promise.all([readAcmeRecipient(current.uuid, fence), readRecipientKeyInventory(id, fence)]); fence.beforeSend();
48
+ if (selected.current !== id || generation.current !== run) return;
49
+ recipientOwner.current = owner.key; setGroup(current); setRecipient(person); setInventory(keys); setBlocked(false);
50
+ } catch (e) { if (!fence.signal?.aborted && generation.current === run) await failure(e); }
51
+ finally { if (!fence.signal?.aborted) pending(false); }
52
+ };
53
+ const displaySecret = async (value: string, title: string) => {
54
+ const fence = owner.fence(); let secret: string | null = value;
55
+ try { fence.beforeSend(); await modal.open((done) => <div className="modal-pad"><h2 className="modal-title">{title}</h2><p>This transient display is not stored in Portal. A later explicit Token reveal in the key inventory is audited and remains available.</p><SecretBox secret={secret ?? ''} ariaLabel="ACME consumer token" /><p>Enter the replacement normally in the receiving deployment’s protected connection form. Publish, Apply and verify every expected node before explicitly deactivating the old hub key. Issuance alone grants no allocation trust or hub activation.</p><div className="modal-actions"><button className="btn" onClick={() => done(null)}>Close token</button></div></div>, { size: 'lg', signal: fence.signal }); }
56
+ finally { secret = null; value = ''; }
57
+ };
58
+ const issue = async () => {
59
+ if (flight.current || !owner.allowed || recipientOwner.current !== owner.key || !recipient?.can_issue || !group || blocked) return;
60
+ const fence = owner.fence(), person = recipient, id = group.id, keyName = name.current?.value.trim() ?? ''; if (!keyName) { setError('Name this replacement key.'); return; }
61
+ const run = generation.current; pending(true); setError(''); let attempted = false;
62
+ try {
63
+ const confirmed = await modal.open<boolean>((done) => <div className="modal-pad"><h2 className="modal-title">Issue protected ACME consumer key?</h2><div className="modal-message"><p>{person.consumer_label} · <code>{person.consumer_project_uuid}</code></p><p>Key: {keyName}</p><p>Grant this exact original hub project the protected ACME validation consumer purpose. The fixed grant is dnsman_acme_federation, with no linked user or override. This does not grant allocation trust or enable shared validation. The existing key stays active.</p></div><div className="modal-actions"><button className="btn" onClick={() => done(false)}>Cancel</button><button className="btn btn-primary" onClick={() => done(true)}>Issue replacement key</button></div></div>, { size: 'md', signal: fence.signal });
64
+ if (!confirmed) return; fence.beforeSend(); if (selected.current !== id || generation.current !== run) throw new OperatorError('conflict');
65
+ attempted = true; const result = await issueAcmeKey(person, keyName, fence); fence.beforeSend();
66
+ if (selected.current !== id || generation.current !== run) { result.token = ''; return; }
67
+ setNotice(`Key ${result.key.name} issued. The old key was not retired.`);
68
+ try { await displaySecret(result.token, 'ACME consumer key issued'); } finally { result.token = ''; }
69
+ } catch (e) { if (!fence.signal?.aborted) await failure(e); }
70
+ finally {
71
+ if (attempted) { if (name.current) name.current.value = ''; await GroupApiKeyModel.invalidate(qc); if (!fence.signal?.aborted && selected.current === id) { try { const keys = await readRecipientKeyInventory(id, fence); fence.beforeSend(); setInventory(keys); } catch { setBlocked(true); setError('Issuance may have persisted. Refresh the Group key inventory before issuing another key.'); } } }
72
+ if (!fence.signal?.aborted) pending(false);
73
+ }
74
+ };
75
+ const reveal = async (row: KeySummary) => {
76
+ if (flight.current || !owner.allowed || recipientOwner.current !== owner.key) return; const fence = owner.fence(); pending(true);
77
+ try { const value = await revealRecipientKey(row.id, fence); fence.beforeSend(); await displaySecret(value, `Audited token — ${row.name}`); }
78
+ catch (e) { if (!fence.signal?.aborted) await failure(e); } finally { if (!fence.signal?.aborted) pending(false); }
79
+ };
80
+ if (!owner.allowed) return <div className="modal-pad"><h2>Protected key access unavailable</h2><p>A current interactive literal superuser is required.</p><button className="btn" onClick={close}>Close</button></div>;
81
+ return <div className="modal-pad shared-validation-operator" style={{ minWidth: 0, overflowWrap: 'anywhere' }}><h2 className="modal-title">Create ACME consumer key</h2><p>Run this form on the approved hub. Choose its original recipient Group by name; a downstream tenant or acting user is not that identity.</p>
82
+ {fixedGroup ? <FlatRow label="Selected hub Group">{group?.name ?? fixedGroup.name}</FlatRow> : <CollectionSelect<KeyGroup> endpoint="/api/group" sanitizeRow={projectKeyGroup} value={group} label="Original hub recipient Group" disabled={busy} onChange={(_id, row) => setGroup(row ?? null)} />}
83
+ {group && <><FlatRow label="Group UUID"><code>{group.uuid ?? 'No valid UUID reported'}</code></FlatRow><button className="btn" disabled={busy} onClick={() => void refresh()}>Resolve recipient and refresh keys</button></>}
84
+ {error && <p className="form-alert" role="alert">{error}</p>}{notice && <p role="status">{notice}</p>}
85
+ {recipient && <div className="dns-shared-request"><FlatRow label="Actual active hub project">{recipient.consumer_label}</FlatRow><FlatRow label="Exact UUID"><code>{recipient.consumer_project_uuid}</code></FlatRow><FlatRow label="Purpose">ACME validation consumer</FlatRow><FlatRow label="Fixed protected grant"><code>{recipient.permission}</code></FlatRow><p>{recipient.grant_explanation}</p><p>Issuing a key does not grant allocation trust or activate the hub.</p><label className="field"><span className="field-label">Replacement key name</span><input className="input" ref={name} maxLength={200} disabled={busy || blocked || !recipient.can_issue} placeholder="Project shared validation" /></label><button className="btn btn-primary" disabled={busy || blocked || !recipient.can_issue} onClick={() => void issue()}>Review protected key issuance</button>{!recipient.can_issue && <p>Protected issuance is unavailable for this operator.</p>}</div>}
86
+ <h3>Recent Group key inventory</h3><p>After an uncertain response, Refresh this inventory and use audited reveal before creating another successor. Up to 100 recent keys are shown; generic key management remains in the existing inventory.</p>{inventory?.map((row) => <div className="dns-shared-request" key={row.id}><strong>{row.name} · #{row.id}</strong><p>{row.is_active ? 'Active' : 'Inactive'}</p><button className="btn" disabled={busy} onClick={() => void reveal(row)}>Reveal audited token</button></div>)}
87
+ <p>Replacement → protected connection Preview/Publish → Apply → verify every expected node → explicitly review/deactivate the old hub key. No key is rotated or retired by this form.</p><div className="modal-actions"><button className="btn" disabled={busy} onClick={close}>{busy ? 'Operation pending…' : 'Close'}</button></div></div>;
88
+ }
@@ -0,0 +1,34 @@
1
+ import { choice, flag, label, nullable, object, OperatorError, positive, privateCall, projectIdentity, type PrivateScope } from '../fleet-configuration/shared-validation-data';
2
+ import { GroupApiKeyModel } from './models';
3
+ export const ACME_RECIPIENT_ENDPOINT = '/api/account/admin/fleet/shared-validation/recipient';
4
+ export type KeyGroup = { id: number; name: string; uuid: string | null };
5
+ export interface AcmeRecipient { purpose: 'acme_validation_consumer'; consumer_project_uuid: string; consumer_label: string; permission: 'dnsman_acme_federation'; can_issue: boolean; grant_explanation: string }
6
+ export interface KeySummary { id: number; name: string; is_active: boolean }
7
+ export function projectKeyGroup(v: unknown): KeyGroup { const r = object(v); return { id: positive(r.id), name: label(r.name), uuid: r.uuid == null ? null : label(r.uuid, 36) }; }
8
+ export async function readKeyGroup(id: number, scope?: PrivateScope) { const g = projectKeyGroup(await privateCall(`/api/group/${positive(id)}`, null, scope)); if (g.id !== id) throw new OperatorError('invalid'); return g; }
9
+ export function parseAcmeRecipient(v: unknown, expected: string): AcmeRecipient {
10
+ const r = object(v); const out = { purpose: choice(r.purpose, ['acme_validation_consumer']), consumer_project_uuid: projectIdentity(r.consumer_project_uuid), consumer_label: label(r.consumer_label), permission: choice(r.permission, ['dnsman_acme_federation']), can_issue: flag(r.can_issue), grant_explanation: label(r.grant_explanation, 300) };
11
+ if (out.consumer_project_uuid !== expected) throw new OperatorError('invalid'); return out;
12
+ }
13
+ export const readAcmeRecipient = async (consumer: string, scope?: PrivateScope) => parseAcmeRecipient(await privateCall(ACME_RECIPIENT_ENDPOINT, null, scope, { consumer_project_uuid: projectIdentity(consumer) }), consumer);
14
+ export async function issueAcmeKey(recipient: AcmeRecipient, name: string, scope?: PrivateScope): Promise<{ key: KeySummary; token: string }> {
15
+ if (!recipient.can_issue || !name.trim()) throw new OperatorError('denied');
16
+ const r = object(await privateCall(ACME_RECIPIENT_ENDPOINT, { consumer_project_uuid: projectIdentity(recipient.consumer_project_uuid), expected_consumer_label: recipient.consumer_label, label: label(name.trim()), purpose: 'acme_validation_consumer' }, scope));
17
+ let secret = r.token; delete r.token;
18
+ try {
19
+ if (r.purpose !== 'acme_validation_consumer' || r.consumer_project_uuid !== recipient.consumer_project_uuid || r.consumer_label !== recipient.consumer_label) throw new OperatorError('invalid');
20
+ const key = object(r.api_key); if (key.permission !== 'dnsman_acme_federation' || key.is_active !== true || typeof secret !== 'string' || !secret || secret.length > 4096 || /\s/.test(secret)) throw new OperatorError('invalid');
21
+ return { key: { id: positive(key.id), name: label(key.name), is_active: true }, token: secret };
22
+ } finally { secret = null; }
23
+ }
24
+ export async function readRecipientKeyInventory(group: number, scope?: PrivateScope): Promise<KeySummary[]> {
25
+ const rows = await privateCall(GroupApiKeyModel.endpoint, null, scope, { group: positive(group), size: 100, sort: '-created', graph: 'default' });
26
+ if (!Array.isArray(rows) || rows.length > 100) throw new OperatorError('invalid');
27
+ return rows.map((v) => { const r = object(v), g = r.group && typeof r.group === 'object' ? object(r.group).id : r.group; if (g !== group) throw new OperatorError('invalid'); return { id: positive(r.id), name: label(r.name), is_active: flag(r.is_active) }; });
28
+ }
29
+ /** Same explicit audited graph as the existing Token control; no Query cache. */
30
+ export async function revealRecipientKey(id: number, scope?: PrivateScope): Promise<string> {
31
+ const r = object(await privateCall(`${GroupApiKeyModel.endpoint}/${positive(id)}`, null, scope, { graph: 'token' }));
32
+ const value = nullable(r.token, (v) => label(v, 4096)); delete r.token;
33
+ if (!value) throw new OperatorError('unavailable'); return value;
34
+ }