portal-mojo 0.2.19 → 0.2.21

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 (157) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-credentials.md +27 -0
  3. package/docs/admin-deployments.md +201 -0
  4. package/docs/admin-dns-certificates.md +160 -0
  5. package/docs/admin-dns-domains.md +92 -0
  6. package/docs/admin-dns.md +18 -0
  7. package/docs/admin-edge.md +200 -196
  8. package/docs/admin-fleet-configuration.md +98 -2
  9. package/docs/admin-geolocation.md +100 -0
  10. package/docs/admin-identity-directories.md +12 -0
  11. package/docs/admin-maintenance.md +133 -0
  12. package/docs/admin-modals.md +45 -2
  13. package/docs/admin-operational-fleet.md +206 -0
  14. package/docs/admin-presentation.md +51 -0
  15. package/docs/admin-signin.md +12 -2
  16. package/docs/auth-pages.md +18 -6
  17. package/docs/auth.md +16 -2
  18. package/docs/client.md +7 -0
  19. package/docs/collection-select.md +14 -0
  20. package/docs/combobox.md +18 -0
  21. package/docs/popover.md +5 -4
  22. package/package.json +1 -1
  23. package/src/admin/core/OperationsHelp.tsx +10 -0
  24. package/src/admin/core/index.ts +6 -4
  25. package/src/admin/core/route-context.ts +6 -0
  26. package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
  27. package/src/admin/credentials/acme-consumer-key.ts +34 -0
  28. package/src/admin/credentials/group-api-keys.tsx +5 -3
  29. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  30. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  31. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  32. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  33. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  34. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  35. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  36. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  37. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  38. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  39. package/src/admin/deployments/api.ts +158 -0
  40. package/src/admin/deployments/authority-state.ts +9 -0
  41. package/src/admin/deployments/deployment-state.ts +13 -0
  42. package/src/admin/deployments/index.ts +4 -0
  43. package/src/admin/deployments/onboarding-state.ts +11 -0
  44. package/src/admin/deployments/permissions.ts +37 -0
  45. package/src/admin/deployments/receipts.ts +78 -0
  46. package/src/admin/deployments/sanitize.ts +73 -0
  47. package/src/admin/deployments/shared.tsx +39 -0
  48. package/src/admin/deployments/types.ts +238 -0
  49. package/src/admin/deployments/upload.ts +53 -0
  50. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  51. package/src/admin/dns/CertificateDetail.tsx +8 -3
  52. package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
  53. package/src/admin/dns/DomainDetail.tsx +14 -4
  54. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  55. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  56. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  57. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  58. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  59. package/src/admin/dns/DomainsPage.tsx +26 -13
  60. package/src/admin/dns/SharedValidationDialog.tsx +298 -0
  61. package/src/admin/dns/SharedValidationPanel.tsx +76 -0
  62. package/src/admin/dns/api.ts +4 -2
  63. package/src/admin/dns/domain-navigation.ts +16 -0
  64. package/src/admin/dns/registrar-api.ts +13 -0
  65. package/src/admin/dns/shared-validation-api.ts +90 -0
  66. package/src/admin/dns/shared-validation-data.ts +106 -0
  67. package/src/admin/domains/assistant.ts +5 -4
  68. package/src/admin/domains/communications.ts +10 -9
  69. package/src/admin/domains/identity.ts +8 -7
  70. package/src/admin/domains/infrastructure.ts +19 -13
  71. package/src/admin/domains/lazy-page.ts +6 -0
  72. package/src/admin/domains/observability.ts +6 -5
  73. package/src/admin/domains/operations.ts +27 -10
  74. package/src/admin/domains/security.ts +16 -15
  75. package/src/admin/edge/EdgeHelp.tsx +10 -0
  76. package/src/admin/edge/ServingReadiness.tsx +54 -0
  77. package/src/admin/edge/VhostDetail.tsx +145 -139
  78. package/src/admin/edge/VhostWizard.tsx +204 -115
  79. package/src/admin/edge/VhostsPage.tsx +27 -28
  80. package/src/admin/edge/api.ts +24 -10
  81. package/src/admin/edge/models.ts +4 -4
  82. package/src/admin/edge/serving-data.ts +78 -0
  83. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  84. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  85. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  86. package/src/admin/fleet/FleetHelp.tsx +7 -0
  87. package/src/admin/fleet/FleetOverview.tsx +150 -0
  88. package/src/admin/fleet/FleetPage.tsx +11 -0
  89. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  90. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  91. package/src/admin/fleet/access.ts +22 -0
  92. package/src/admin/fleet/api.ts +77 -0
  93. package/src/admin/fleet/data.ts +148 -0
  94. package/src/admin/fleet/index.ts +8 -0
  95. package/src/admin/fleet/operations.ts +179 -0
  96. package/src/admin/fleet/standalone-data.ts +87 -0
  97. package/src/admin/fleet/types.ts +208 -0
  98. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
  99. package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
  100. package/src/admin/fleet-configuration/data.ts +4 -0
  101. package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
  102. package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
  103. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  104. package/src/admin/geolocation/data.ts +138 -0
  105. package/src/admin/geolocation/index.ts +3 -0
  106. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
  107. package/src/admin/identity/groups/models.ts +2 -0
  108. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  109. package/src/admin/index.ts +8 -0
  110. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  111. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  112. package/src/admin/maintenance/data.ts +486 -0
  113. package/src/admin/maintenance/index.ts +3 -0
  114. package/src/admin/maintenance/operations.ts +208 -0
  115. package/src/admin/public/infrastructure.ts +1 -0
  116. package/src/admin/public/operations.ts +3 -0
  117. package/src/admin/registry.ts +6 -2
  118. package/src/admin/settings/SettingDetail.tsx +7 -2
  119. package/src/admin/settings/SettingEditor.tsx +2 -1
  120. package/src/admin/settings/SettingsPage.tsx +3 -1
  121. package/src/admin/settings/model.ts +1 -0
  122. package/src/admin/signin/SigninPage.tsx +17 -3
  123. package/src/admin/signin/api.ts +2 -0
  124. package/src/charts/MetricsChartView.tsx +4 -11
  125. package/src/charts/MetricsMiniWidget.tsx +3 -15
  126. package/src/client/auth.ts +77 -25
  127. package/src/client/client.ts +6 -0
  128. package/src/client/deployments-mock.ts +375 -0
  129. package/src/client/domain-operations-mock.ts +37 -0
  130. package/src/client/fleet-mock.ts +73 -1
  131. package/src/client/geo-provider-mock.ts +82 -0
  132. package/src/client/index.ts +2 -2
  133. package/src/client/maintenance-mock.ts +285 -0
  134. package/src/client/mock.ts +280 -11
  135. package/src/client/operational-fleet-mock.ts +279 -0
  136. package/src/client/operations-mock.ts +85 -0
  137. package/src/client/serving-mock.ts +22 -0
  138. package/src/styles/components/admin-deployments.css +35 -0
  139. package/src/styles/components/admin-dns-certificates.css +33 -0
  140. package/src/styles/components/admin-domains.css +12 -0
  141. package/src/styles/components/admin-edge.css +125 -1
  142. package/src/styles/components/admin-geolocation.css +40 -0
  143. package/src/styles/components/admin-maintenance.css +127 -0
  144. package/src/styles/components/admin-operational-fleet.css +38 -0
  145. package/src/styles/components/admin-operations.css +9 -0
  146. package/src/styles/components/admin-signin.css +2 -0
  147. package/src/styles/components/combobox.css +12 -2
  148. package/src/styles/components/fleet-page.css +9 -0
  149. package/src/styles/index.css +7 -0
  150. package/src/ui/CollectionSelect.tsx +30 -8
  151. package/src/ui/ComboBox.tsx +39 -8
  152. package/src/ui/FilterBar.tsx +3 -5
  153. package/src/ui/ModelTable.tsx +2 -8
  154. package/src/ui/Popover.tsx +15 -7
  155. package/src/ui/date/TimePicker.tsx +4 -14
  156. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  157. package/src/ui/schema-form-core.tsx +3 -5
@@ -1,5 +1,7 @@
1
1
  import type { AssistantSetupState, SetupCheck } from '../admin/assistant/setup';
2
- import { fleetMock } from './fleet-mock';
2
+ import { fleetMock, configureMockHubRecipient, consumeMockConnectionFault, getMockConnectionScenario, registerMockConsumerKey, setMockConnectionScenario, connectionFail } from './fleet-mock';
3
+ export { setMockConnectionScenario };
4
+ export type { MockConnectionScenario } from './fleet-mock';
3
5
  import { describeRenditions, validateRenditionSetting } from './renditions-mock';
4
6
  // In-memory django-mojo mock. Speaks the EXACT wire contract the real client
5
7
  // uses — envelope {status, data|rows..., message}, start/size paging, sort
@@ -210,6 +212,8 @@ function buildGroups(): MockGroup[] {
210
212
  ORG_NAMES.forEach((orgName, oi) => {
211
213
  const org = mk({
212
214
  name: orgName, kind: 'org',
215
+ // The guided protected-purpose fixture needs one original active hub identity.
216
+ ...(oi === 0 ? { uuid: '11111111111111111111111111111111' } : {}),
213
217
  created: Math.floor((now - (420 - oi * 40) * 864e5) / 1000),
214
218
  metadata: oi % 2 === 0
215
219
  ? { timezone: 'America/Los_Angeles', short_name: orgName.split(' ')[0]!.toLowerCase() }
@@ -683,6 +687,7 @@ function buildSettings(): MockSetting[] {
683
687
  { id: 404, created: now - 5 * 86400, modified: now - 5 * 86400, key: 'SITE_NAME', value: 'NativeMojo fallback', is_secret: false, group: null, secretValue: null },
684
688
  // #7729: one rendition override so Media rendering shows a changed field beside greyed defaults on first load.
685
689
  { id: 405, created: now - 3 * 86400, modified: now - 3 * 86400, key: 'FILEMAN_RENDITIONS_VIDEO', value: JSON.stringify({ video_hevc: { crf: 24 } }), is_secret: false, group: null, secretValue: null },
690
+ { id: 406, created: now - 3 * 86400, modified: now - 3 * 86400, key: 'EDGE_FRAMEWORK_VERSION', value: '', is_secret: false, group: null, secretValue: null },
686
691
  ];
687
692
  }
688
693
 
@@ -2890,6 +2895,12 @@ function decorateUsers(users: MockUser[], groups: MockGroup[]): void {
2890
2895
  dnsPlatform.email = 'dns.platform@nativemojo.com';
2891
2896
  dnsPlatform.display_name = 'DNS Platform Operator';
2892
2897
  dnsPlatform.permissions = {};
2898
+ for (const [offset, role, grants] of [
2899
+ [53, 'viewer', { view_platform: true, view_dns: true }],
2900
+ [54, 'platform', { view_admin: true, manage_platform: true }],
2901
+ [55, 'webapp', { view_admin: true, manage_webapp: true, manage_dns: true, manage_groups: true }],
2902
+ [56, 'group', {}], [57, 'denied', {}],
2903
+ ] as const) Object.assign(at(offset), { is_active: true, is_superuser: false, username: `deployments.${role}`, email: `deployments.${role}@nativemojo.com`, display_name: `Deployments ${role}`, permissions: { ...grants } });
2893
2904
  const dnsTenant = at(23);
2894
2905
  dnsTenant.is_active = true;
2895
2906
  dnsTenant.is_superuser = false;
@@ -4105,6 +4116,7 @@ const deviceRows = buildDevices();
4105
4116
  const geoIpRows = buildGeoIps();
4106
4117
  const loginEventRows = linkLoginDevices(buildLoginEvents(users), deviceRows);
4107
4118
  const members = buildMembers(users, groups);
4119
+ members.push({ id: 192, created: groups[0]!.created, modified: groups[0]!.modified, is_active: true, permissions: { manage_webapp: true, manage_dns: true }, metadata: {}, user: 56, group: 1 });
4108
4120
  members.push({
4109
4121
  id: 190, created: groups[0]!.created, modified: groups[0]!.modified,
4110
4122
  is_active: true, permissions: { view_dns: true }, metadata: {},
@@ -5425,17 +5437,29 @@ function saveUser(user: MockUser, body: Record<string, unknown>, caller?: MockUs
5425
5437
  // django-mojo account/models/group.py RestMeta: POST_SAVE_ACTIONS include
5426
5438
  // disable/reactivate (reason REQUIRED ∈ GROUP_REST_REASONS = admin | abuse |
5427
5439
  // archived — one more than users get); NO_SAVE_FIELDS id/pk/created.
5428
- const GROUP_ACTIONS = new Set(['disable', 'reactivate']);
5440
+ // allow_member_subgroups (#9126) switches metadata.protected's sub-group
5441
+ // policy (#9107): a boolean only, off removes the key, and the reply is the
5442
+ // handler's flat {status, allow_member_subgroups}, not the row.
5443
+ const GROUP_ACTIONS = new Set(['disable', 'reactivate', 'allow_member_subgroups']);
5444
+ const GROUP_SUBGROUP_POLICY_KEY = 'allow_member_subgroups';
5429
5445
  const GROUP_DISABLE_REASONS = new Set(['abuse', 'admin', 'archived']);
5430
5446
  // NO_SAVE_FIELDS parity (id/pk/created); member_count additionally blocked
5431
5447
  // here because it is a graph EXTRA, not a column (uuid IS savable live).
5432
5448
  const GROUP_NO_SAVE = new Set(['id', 'pk', 'created', 'member_count']);
5433
5449
 
5450
+ // A controlled legacy wire fixture; the stored Group identity is never rewritten.
5451
+ function acmeProjectIdentity(group: MockGroup) {
5452
+ return getMockConnectionScenario() === 'legacy-project-identity' && group.id === 1 ? '74a3c6699bed4ddf' : group.uuid;
5453
+ }
5434
5454
  function serializeGroup(g: MockGroup): Record<string, unknown> {
5435
- return { ...g };
5455
+ return { ...g, uuid: acmeProjectIdentity(g) };
5436
5456
  }
5437
5457
 
5438
5458
  function saveGroup(group: MockGroup, body: Record<string, unknown>): unknown {
5459
+ // Group.on_rest_save judges the switch before anything is written.
5460
+ if (GROUP_SUBGROUP_POLICY_KEY in body && typeof body[GROUP_SUBGROUP_POLICY_KEY] !== 'boolean') {
5461
+ return { status: false, error: `${GROUP_SUBGROUP_POLICY_KEY} must be true or false`, error_code: 400 };
5462
+ }
5439
5463
  const fields: Record<string, unknown> = {};
5440
5464
  const actionEntries: [string, unknown][] = [];
5441
5465
  for (const [key, value] of Object.entries(body)) {
@@ -5469,9 +5493,17 @@ function saveGroup(group: MockGroup, body: Record<string, unknown>): unknown {
5469
5493
  target[key] = isPlainObject(value) && isPlainObject(existing) ? mergeDicts(existing, value) : value;
5470
5494
  }
5471
5495
  }
5496
+ let actionReply: Record<string, unknown> | null = null;
5472
5497
  for (const [action, value] of actionEntries) {
5473
5498
  const dict = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>;
5474
- if (action === 'disable') {
5499
+ if (action === GROUP_SUBGROUP_POLICY_KEY) {
5500
+ const metadata = isPlainObject(group.metadata) ? group.metadata : {};
5501
+ const protectedMeta = isPlainObject(metadata.protected) ? { ...metadata.protected } : {};
5502
+ if (value === true) protectedMeta[GROUP_SUBGROUP_POLICY_KEY] = true;
5503
+ else delete protectedMeta[GROUP_SUBGROUP_POLICY_KEY];
5504
+ group.metadata = { ...metadata, protected: protectedMeta };
5505
+ actionReply = { status: true, [GROUP_SUBGROUP_POLICY_KEY]: value };
5506
+ } else if (action === 'disable') {
5475
5507
  const reason = String(dict.reason ?? '');
5476
5508
  if (!GROUP_DISABLE_REASONS.has(reason)) {
5477
5509
  return { status: false, error: `reason must be one of: ${[...GROUP_DISABLE_REASONS].sort().join(', ')}`, error_code: 400 };
@@ -5482,6 +5514,7 @@ function saveGroup(group: MockGroup, body: Record<string, unknown>): unknown {
5482
5514
  }
5483
5515
  }
5484
5516
  group.modified = Math.floor(Date.now() / 1000);
5517
+ if (actionReply) return actionReply;
5485
5518
  return { status: true, data: serializeGroup(group), graph: 'default' };
5486
5519
  }
5487
5520
 
@@ -6563,10 +6596,186 @@ function accountSelfFetch(path: string, method: string, opts: MockFetchOpts): un
6563
6596
  return { status: false, error: `Method not allowed: ${method} ${path}`, error_code: 405 };
6564
6597
  }
6565
6598
 
6599
+ export type MockSharedValidationScenario = 'ready' | 'missing-trust' | 'revoked' | 'pending-dns' | 'changed-alias' | 'chained-alias' | 'unavailable' | 'unconfigured' | 'old-api' | 'renewal-error' | 'malformed' | 'disabled' | 'controller-maintenance';
6600
+ let sharedScenario: MockSharedValidationScenario = 'ready';
6601
+ let sharedFault: 'ambiguous' | 'reconcile' | 'reauth' | 'revoke-cleanup' | null = null;
6602
+ let sharedReadFails = false;
6603
+ let sharedReadFailure: { path: 'allocation/list' | 'trust/list' | 'capabilities'; status: 401 | 403 | 440 | 404 | 405 | 503 } | null = null;
6604
+ let sharedCounter = 1;
6605
+ const SHARED_CONSUMER_UUID = '11111111-1111-4111-8111-111111111111';
6606
+ const SHARED_OWNER_UUID = '22222222-2222-4222-8222-222222222222';
6607
+ interface MockSharedConnection { id: number; domain: string; domainId: number | null; group: number; allocation: string; client: string; participant: string | null; state: string; verified: string | null; reads: number }
6608
+ interface MockSharedParticipant { allocation_ref: string; participant_ref: string; client_ref: string; domain: string; source: string; target: string; role: string; trust_state: string; scope: string[]; can_publish: boolean; can_withdraw: boolean; revoked_at: string | null; consumer_project_uuid: string; consumer_label: string }
6609
+ const sharedConnections = new Map<string, MockSharedConnection>();
6610
+ const sharedParticipants: MockSharedParticipant[] = [];
6611
+ const sharedAllocations = [
6612
+ { allocation_ref: 'shared-acme.acme-hub.example', domain: 'acme.example' },
6613
+ { allocation_ref: 'shared-customer.acme-hub.example', domain: 'customer.example' },
6614
+ ].map((v) => ({ ...v, source: `_acme-challenge.${v.domain}`, target: v.allocation_ref, owner_project_uuid: SHARED_OWNER_UUID, can_manage_trust: true }));
6615
+ const sharedUuid = () => `33333333-3333-4333-8333-${String(sharedCounter++).padStart(12, '0')}`;
6616
+ function sharedDescriptor(row: MockSharedConnection) { return { version: 1, consumer_project_uuid: SHARED_CONSUMER_UUID, client_ref: row.client, domain: row.domain, allocation_ref: row.allocation }; }
6617
+ function sharedParticipant(request: Record<string, unknown>): MockSharedParticipant { const domain = String(request.domain); return { allocation_ref: String(request.allocation_ref), participant_ref: sharedUuid(), client_ref: String(request.client_ref), domain, source: `_acme-challenge.${domain}`, target: String(request.allocation_ref), role: 'trusted', trust_state: 'active', scope: [domain, `*.${domain}`], can_publish: false, can_withdraw: false, revoked_at: null, consumer_project_uuid: String(request.consumer_project_uuid), consumer_label: 'Customer project · independent wildcard issuer' }; }
6618
+ /** Bounded mock-only controls. No setup draft or protected credential is observed. */
6619
+ export function setMockSharedValidationScenario(scenario: MockSharedValidationScenario): void {
6620
+ const allowed: MockSharedValidationScenario[] = ['ready', 'missing-trust', 'revoked', 'pending-dns', 'changed-alias', 'chained-alias', 'unavailable', 'unconfigured', 'old-api', 'renewal-error', 'malformed', 'disabled', 'controller-maintenance'];
6621
+ if (!allowed.includes(scenario)) throw new Error('Unknown shared validation mock scenario');
6622
+ sharedScenario = scenario; sharedReadFailure = null; sharedFault = null; sharedReadFails = false; sharedConnections.clear(); sharedParticipants.splice(0);
6623
+ sharedAllocations.splice(2);
6624
+ if (scenario === 'controller-maintenance') {
6625
+ for (let i = 1; i <= 21; i++) {
6626
+ const target = `maintenance-${String(i).padStart(2, '0')}.acme-hub.example`;
6627
+ sharedAllocations.push({ allocation_ref: target, domain: 'maintenance.example', source: '_acme-challenge.maintenance.example', target, owner_project_uuid: SHARED_OWNER_UUID, can_manage_trust: true });
6628
+ }
6629
+ for (let i = 0; i < 22; i++) {
6630
+ const participant = sharedParticipant({ domain: 'maintenance.example', allocation_ref: 'maintenance-21.acme-hub.example', client_ref: sharedUuid(), consumer_project_uuid: i === 21 ? SHARED_CONSUMER_UUID : sharedUuid() });
6631
+ participant.consumer_label = i === 21 ? 'Inactive consumer · cleanup remains available' : i === 0 ? 'Allocation owner' : `Trusted project ${i}`;
6632
+ if (i === 0) { participant.role = 'owner'; participant.consumer_project_uuid = SHARED_OWNER_UUID; }
6633
+ sharedParticipants.push(participant);
6634
+ }
6635
+ }
6636
+ db.dnsDomains = db.dnsDomains.filter((d) => d.id < 8900);
6637
+ db.dnsCertificates = db.dnsCertificates.filter((c) => c.id < 8900);
6638
+ if (['missing-trust', 'revoked', 'pending-dns', 'changed-alias', 'chained-alias', 'renewal-error'].includes(scenario)) {
6639
+ const row: MockSharedConnection = { id: 8901, domain: 'acme.example', domainId: 8201, group: 1, allocation: 'shared-acme.acme-hub.example', client: sharedUuid(), participant: null, state: 'pending', verified: null, reads: 0 };
6640
+ if (scenario !== 'missing-trust') { const participant = sharedParticipant(sharedDescriptor(row)); if (scenario === 'revoked') { participant.trust_state = 'revoked'; participant.revoked_at = new Date().toISOString(); } sharedParticipants.push(participant); row.participant = participant.participant_ref; row.state = 'verified'; row.verified = new Date().toISOString(); }
6641
+ sharedConnections.set(row.domain, row);
6642
+ }
6643
+ }
6644
+ export function armMockSharedValidationFault(fault: typeof sharedFault): void { sharedFault = fault; if (fault === null) sharedReadFails = false; }
6645
+ export function armMockSharedValidationReadFailure(path: NonNullable<typeof sharedReadFailure>['path'], status: NonNullable<typeof sharedReadFailure>['status']): void { sharedReadFailure = { path, status }; }
6646
+ function mockSharedValidation(path: string, opts: MockFetchOpts, method: string): unknown {
6647
+ const caller = userFromBearer(opts.headers); if (!caller) return permissionDenied(401);
6648
+ if (sharedScenario === 'old-api') return { status: false, error: 'Route unavailable', error_code: 404 };
6649
+ const body = opts.body ?? {}, params = opts.params ?? {};
6650
+ const failure = (code: string, status = 409, data?: unknown) => ({ status: false, code: status, error: code, error_code: code, ...(data ? { data } : {}) });
6651
+ const manage = hasGlobalPermission(caller, DNS_MANAGE_GRANTS);
6652
+ const controller = caller.is_superuser === true;
6653
+ const iso = (value: number | null) => value == null ? null : new Date(value * 1000).toISOString();
6654
+ const at = new Date().toISOString();
6655
+ if (path === '/api/dnsman/delegation/group-choice') {
6656
+ if (method !== 'GET' || !manage) return permissionDenied();
6657
+ const rows = db.groups.filter((g) => g.is_active && dnsMemberCan(caller, g.id, true) && (params.id == null || g.id === Number(params.id)) && (!params.search || g.name.toLowerCase().includes(String(params.search).toLowerCase())));
6658
+ const start = Math.max(0, Number(params.start ?? 0)); const size = Math.min(50, Math.max(1, Number(params.size ?? 25)));
6659
+ return { status: true, data: rows.slice(start, start + size).map((g) => ({ id: g.id, name: g.name })), count: rows.length, start, size };
6660
+ }
6661
+ if (path.startsWith('/api/dnsman/acme/')) {
6662
+ if (method !== 'POST' || !controller) return permissionDenied();
6663
+ if (sharedReadFailure && path.endsWith(`/${sharedReadFailure.path}`)) { const status = sharedReadFailure.status; sharedReadFailure = null; return failure('mock_controller_read_refused', status); }
6664
+ if (path.endsWith('/capabilities')) return { status: true, data: { version: 2, shared_validation: sharedScenario !== 'disabled', consumer_project_uuid: null, can_consume: false, can_manage_trust: sharedScenario !== 'disabled', trust_scope: 'apex_and_wildcard' } };
6665
+ if (sharedScenario === 'disabled') return failure('acme_not_authorized', 403);
6666
+ const page = <T,>(rows: T[]) => { const start = body.cursor == null ? 0 : Number(body.cursor); if (!Number.isSafeInteger(start) || start < 0 || (body.cursor != null && !/^[1-9]\d*$/.test(String(body.cursor)))) return failure('acme_invalid_request', 400); return { status: true, data: { items: rows.slice(start, start + 20), next_cursor: start + 20 < rows.length ? String(start + 20) : null } }; };
6667
+ if (path.endsWith('/allocation/list')) return page(sharedAllocations.filter((v) => v.domain === body.domain));
6668
+ const allocation = sharedAllocations.find((v) => v.allocation_ref === body.allocation_ref || v.allocation_ref === (body.connection_request as Record<string, unknown> | undefined)?.allocation_ref);
6669
+ if (!allocation) return permissionDenied();
6670
+ if (path.endsWith('/trust/list')) return sharedReadFails ? failure('acme_hub_unavailable', 503) : page(sharedParticipants.filter((p) => p.allocation_ref === allocation.allocation_ref));
6671
+ if (path.endsWith('/trust/recipient')) return sharedScenario !== 'controller-maintenance' && body.consumer_project_uuid === SHARED_CONSUMER_UUID ? { status: true, data: { consumer_project_uuid: SHARED_CONSUMER_UUID, label: 'Customer project · independent wildcard issuer', active: true } } : permissionDenied();
6672
+ if (path.endsWith('/trust/grant')) {
6673
+ const request = body.connection_request as Record<string, unknown>;
6674
+ if (sharedScenario === 'controller-maintenance') return permissionDenied();
6675
+ if (!request || Object.keys(request).sort().join(',') !== 'allocation_ref,client_ref,consumer_project_uuid,domain,version' || request.version !== 1 || request.domain !== allocation.domain || request.consumer_project_uuid !== SHARED_CONSUMER_UUID || !/^[a-f0-9-]{36}$/.test(String(request.client_ref))) return failure('acme_reference_conflict');
6676
+ let participant = sharedParticipants.find((p) => p.client_ref === request.client_ref && p.consumer_project_uuid === request.consumer_project_uuid);
6677
+ if (participant && (participant.allocation_ref !== allocation.allocation_ref || participant.trust_state === 'revoked')) return failure('acme_reference_conflict');
6678
+ if (['changed-alias', 'chained-alias', 'pending-dns'].includes(sharedScenario)) return failure('acme_dns_mismatch');
6679
+ if (!participant) { participant = sharedParticipant(request); sharedParticipants.push(participant); }
6680
+ if (sharedFault === 'ambiguous') { sharedFault = null; return failure('acme_hub_unavailable', 503); }
6681
+ return { status: true, data: { ...participant } };
6682
+ }
6683
+ if (path.endsWith('/trust/revoke')) {
6684
+ const participant = sharedParticipants.find((p) => p.allocation_ref === allocation.allocation_ref && p.participant_ref === body.participant_ref);
6685
+ if (!participant || participant.role === 'owner') return permissionDenied();
6686
+ participant.trust_state = 'revoked'; participant.revoked_at ??= at;
6687
+ if (sharedFault === 'revoke-cleanup' || sharedFault === 'ambiguous') { sharedFault = null; return failure('acme_hub_unavailable', 503); }
6688
+ return { status: true, data: { ...participant } };
6689
+ }
6690
+ return failure('acme_invalid_request', 400);
6691
+ }
6692
+ const write = method === 'POST';
6693
+ if (sharedFault === 'reauth' && write) { sharedFault = null; return { status: false, code: 440, error: 'reauth_required', error_code: 440 }; }
6694
+ let row = body.delegation == null ? null : [...sharedConnections.values()].find((v) => v.id === Number(body.delegation)) ?? null;
6695
+ const requested = write ? body : params;
6696
+ let domain = db.dnsDomains.find((d) => requested.domain != null ? d.id === Number(requested.domain) : d.name === (row?.domain ?? requested.name));
6697
+ const name = row?.domain ?? domain?.name ?? String(requested.name ?? '');
6698
+ const groupId = row?.group ?? domain?.group ?? Number(requested.group ?? 0);
6699
+ if (!name || !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(name) || (!domain && !db.groups.some((g) => g.id === groupId && g.is_active))) return failure('invalid_target', 400);
6700
+ if (domain && requested.group != null && domain.group !== Number(requested.group)) return permissionDenied();
6701
+ if (!hasGlobalPermission(caller, DNS_VIEW_GRANTS) || (groupId && !dnsMemberCan(caller, groupId, write))) return permissionDenied();
6702
+ if (write && (!manage || !groupId)) return permissionDenied();
6703
+ row ??= sharedConnections.get(name) ?? null;
6704
+ const allocation = row?.allocation ?? (typeof requested.allocation_ref === 'string' ? requested.allocation_ref : null);
6705
+ const plan = () => {
6706
+ if (sharedReadFails) return failure('hub_unavailable', 503);
6707
+ const participant = row ? sharedParticipants.find((p) => p.client_ref === row!.client) : null;
6708
+ const trust = !row ? 'not_requested' : participant?.trust_state ?? 'missing';
6709
+ const hub = sharedScenario === 'unconfigured' ? 'unconfigured' : sharedScenario === 'unavailable' ? 'unavailable' : sharedScenario === 'disabled' ? 'unsupported' : 'ready';
6710
+ const aliasState = ['changed-alias', 'chained-alias'].includes(sharedScenario) ? 'mismatch' : sharedScenario === 'pending-dns' ? 'lookup_failed' : row?.participant ? 'match' : 'unknown';
6711
+ let cert = db.dnsCertificates.find((c) => c.domain === domain?.id && c.sans.length === 2 && c.sans.includes(name) && c.sans.includes(`*.${name}`));
6712
+ if (cert?.id && cert.id >= 8900 && cert.status === 'pending' && row && ++row.reads >= 3) { cert.status = 'active'; cert.not_after = Math.floor(Date.now() / 1000) + 90 * 86400; cert.renew_after = Math.floor(Date.now() / 1000) + 60 * 86400; cert.material_present = true; cert.attempts = 1; }
6713
+ const valid = !!cert && cert.status === 'active' && (cert.not_after ?? 0) > Date.now() / 1000;
6714
+ const renewal = sharedScenario === 'renewal-error' ? 'failed' : cert?.status === 'pending' ? 'queued' : valid ? (trust === 'active' && aliasState === 'match' ? 'scheduled' : row ? 'blocked' : 'scheduled') : cert ? 'failed' : 'not_requested';
6715
+ let next = 'none', actor = 'project_operator';
6716
+ if (!groupId) { next = 'unsupported_domain_scope'; actor = 'deployment_operator'; }
6717
+ else if (hub !== 'ready') { next = hub === 'unconfigured' ? 'configure_hub' : hub === 'unsupported' ? 'upgrade_hub' : 'retry_hub'; actor = 'deployment_operator'; }
6718
+ else if (!row) next = allocation ? 'prepare_connection' : 'choose_allocation';
6719
+ else if (trust === 'revoked') next = 'restart_connection';
6720
+ else if (trust === 'missing') { next = 'grant_trust'; actor = 'allocation_controller'; }
6721
+ else if (!row.participant) next = 'connect';
6722
+ else if (aliasState !== 'match') { next = 'repair_alias'; actor = 'domain_owner'; }
6723
+ else if (row.state !== 'verified') next = 'check';
6724
+ else if (renewal === 'queued') next = 'wait_for_job';
6725
+ else if (renewal === 'failed') next = 'inspect_failure';
6726
+ else if (!cert) next = 'request';
6727
+ else actor = 'none';
6728
+ const allowed = manage && !!groupId;
6729
+ return { status: true, data: {
6730
+ version: sharedScenario === 'malformed' ? 99 : 1, checked_at: at,
6731
+ domain: { id: domain?.id ?? null, name, provider: domain?.provider ?? null, group: groupId ? { id: groupId, name: db.groups.find((g) => g.id === groupId)?.name ?? 'Customer' } : null },
6732
+ profile: { name: 'apex_wildcard', names: [name, `*.${name}`] }, hub: { state: hub, protocol_version: hub === 'ready' ? 2 : null, consumer_project_uuid: hub === 'ready' ? SHARED_CONSUMER_UUID : null },
6733
+ connection: row ? { id: row.id, mode: 'shared', state: row.state, client_ref: row.client, allocation_ref: row.allocation, participant_ref: row.participant, verified_at: row.verified } : null,
6734
+ trust: { state: trust, can_publish: trust === 'active', can_withdraw: !!participant },
6735
+ alias: { state: aliasState, source: `_acme-challenge.${name}`, target: allocation, observed_target: aliasState === 'unknown' || aliasState === 'lookup_failed' ? null : sharedScenario === 'changed-alias' ? 'changed.acme-hub.example' : allocation, one_hop: aliasState === 'unknown' || aliasState === 'lookup_failed' ? null : sharedScenario !== 'chained-alias', checked_at: aliasState === 'unknown' ? null : at },
6736
+ certificate: cert ? { id: cert.id, state: cert.status, names: cert.sans, not_after: iso(cert.not_after), renew_after: iso(cert.renew_after), serving_valid: valid, material_state: cert.material_present ? 'available' : 'missing' } : null,
6737
+ renewal: { state: renewal, next_attempt_at: cert ? iso(cert.renew_after) : null, attempts: cert?.attempts ?? 0, error_code: renewal === 'failed' ? 'trust_missing' : null, cleanup_pending: false, job: renewal === 'queued' ? { id: 'mock-shared-job', state: 'pending' } : null, worker_version: null },
6738
+ capabilities: { group_choices: allowed, can_connect: allowed && hub === 'ready' && !!allocation && trust !== 'revoked', can_restart: allowed && !!row && (trust === 'revoked' || (!row.participant && row.state === 'pending')), can_check: allowed && !!row?.participant && trust === 'active', can_request: allowed && row?.state === 'verified' && trust === 'active' && aliasState === 'match' && sharedScenario !== 'chained-alias' },
6739
+ next_action: { code: next, actor }, connection_request: row && allowed ? sharedDescriptor(row) : null, operation: { action: 'plan', outcome: 'observed', changed: false },
6740
+ } };
6741
+ };
6742
+ if (!write) return plan();
6743
+ if (sharedScenario === 'unconfigured' || sharedScenario === 'unavailable' || sharedScenario === 'disabled') return failure('hub_unavailable', 503);
6744
+ let sharedOutcome = 'queued';
6745
+ if (path.endsWith('/connect')) {
6746
+ if (!allocation || !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(allocation)) return failure('allocation_conflict');
6747
+ if (row && row.allocation !== allocation) return failure('allocation_conflict');
6748
+ const existing = row ? sharedParticipants.find((p) => p.client_ref === row!.client) : null;
6749
+ if (existing?.trust_state === 'revoked' && body.restart !== true) return failure('trust_revoked', 409, (plan() as { data: unknown }).data);
6750
+ if (body.restart === true && row && existing?.trust_state === 'active') return failure('stale_connection');
6751
+ if (!row || body.restart === true) { row = { id: 8900 + sharedCounter, domain: name, domainId: domain?.id ?? null, group: groupId, allocation, client: sharedUuid(), participant: null, state: 'pending', verified: null, reads: 0 }; sharedConnections.set(name, row); }
6752
+ const participant = sharedParticipants.find((p) => p.client_ref === row!.client && p.trust_state === 'active');
6753
+ if (participant) row.participant = participant.participant_ref;
6754
+ } else if (path.endsWith('/check')) {
6755
+ if (!row || !row.participant) return failure('connection_not_verified');
6756
+ const participant = sharedParticipants.find((p) => p.participant_ref === row!.participant);
6757
+ if (participant?.trust_state !== 'active') return failure('trust_revoked');
6758
+ if (['pending-dns', 'changed-alias', 'chained-alias'].includes(sharedScenario)) { row.state = 'broken'; return failure('alias_mismatch', 409, (plan() as { data: unknown }).data); }
6759
+ if (!domain) { domain = { ...buildDnsDomains()[0]!, id: 8900 + sharedCounter++, name, group: groupId, provider: 'mojo', credential: null, hosted_zone_id: null }; db.dnsDomains.push(domain); row.domainId = domain.id; }
6760
+ row.state = 'verified'; row.verified = at;
6761
+ } else if (path.endsWith('/request')) {
6762
+ const current = plan() as { data: { capabilities: { can_request: boolean } } };
6763
+ if (!row || !domain || !current.data?.capabilities.can_request) return failure('connection_not_verified');
6764
+ let cert = db.dnsCertificates.find((c) => c.domain === domain!.id && c.sans.length === 2 && c.sans.includes(name) && c.status !== 'revoked');
6765
+ sharedOutcome = cert?.status === 'active' ? 'already_current' : cert ? 'already_queued' : 'queued';
6766
+ if (!cert) { const now = Math.floor(Date.now() / 1000); cert = { id: 8900 + sharedCounter++, created: now, modified: now, domain: domain.id, common_name: name, sans: [name, `*.${name}`], status: 'pending', issuer: null, serial: null, not_before: null, not_after: null, renew_after: null, last_error: null, attempts: 0, material_present: false }; db.dnsCertificates.push(cert); }
6767
+ } else return failure('invalid_action', 400);
6768
+ const result = plan() as { data: { operation: unknown } };
6769
+ result.data.operation = { action: path.split('/').pop(), outcome: path.endsWith('/connect') ? row?.participant ? 'connected' : 'awaiting_trust' : path.endsWith('/check') ? 'verified' : sharedOutcome, changed: path.endsWith('/request') ? sharedOutcome === 'queued' : true };
6770
+ if (sharedFault === 'ambiguous' || sharedFault === 'reconcile') { sharedReadFails = sharedFault === 'reconcile'; sharedFault = null; return failure('hub_unavailable', 503); }
6771
+ return result;
6772
+ }
6773
+
6566
6774
  let dnsConfigMalformed = false;
6567
6775
  let dnsAcmeMode: 'production' | 'staging' | 'unconfigured' = 'staging';
6568
6776
  let dnsRegistrarMode: 'ready' | 'disabled' | 'contact-missing' = 'ready';
6569
6777
  let dnsRegistrarFault: 'failed' | 'ambiguous' | null = null;
6778
+ let domainRegistrarSaveFault: 'warning' | 'ambiguous' | null = null;
6570
6779
  let dnsWriteFault: 'reject' | 'ambiguous' | 'reconcile' | null = null;
6571
6780
  let dnsFailNextRead = false;
6572
6781
  const mockRegistrantScopeState = new Map<string, 'saved' | 'cleared'>();
@@ -6695,6 +6904,7 @@ export function setMockDnsRegistrarMode(value: 'ready' | 'disabled' | 'contact-m
6695
6904
 
6696
6905
  /** Verifier-only one-shot result after a confirmation token is consumed. */
6697
6906
  export function armMockRegistrarPurchaseFault(value: 'failed' | 'ambiguous'): void { dnsRegistrarFault = value; }
6907
+ export function armMockDomainRegistrarSaveFault(value: 'warning' | 'ambiguous'): void { domainRegistrarSaveFault = value; }
6698
6908
 
6699
6909
  /** Verifier-only one-shot DNS transport outcomes; all state still lives in db.dnsRecords. */
6700
6910
  export function armMockDnsWriteFault(mode: 'reject' | 'ambiguous' | 'reconcile'): void {
@@ -9716,6 +9926,15 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
9716
9926
  armedReauth = null;
9717
9927
  return { status: false, error: 'reauth_required', error_code: 440 };
9718
9928
  }
9929
+ if (path === '/api/account/admin/bootstrap' || path === '/api/account/admin/settings' || path === '/api/account/admin/advanced/settings' && Object.hasOwn(opts.body ?? {}, 'framework_pin')
9930
+ || path === '/api/account/admin/dashboard' || path.startsWith('/api/aws/capacity')
9931
+ || path.startsWith('/api/aws/maintenance/') || path.startsWith('/api/account/admin/platform') || path === '/api/account/admin/setup/readiness'
9932
+ || path.startsWith('/api/edge/webapp') || path.startsWith('/api/edge/release') || path === '/api/edge/deployment') {
9933
+ const { operationsMock } = await import('./operations-mock');
9934
+ const claims = decodeMockJwt((opts.headers?.Authorization ?? '').replace(/^Bearer /, ''));
9935
+ const operationsResult = await operationsMock(path, opts, { user: userFromBearer(opts.headers), credentialKind: claims?.credential_kind ?? claims?.token_type, authTime: authTimeFromBearer(opts.headers), origin: opts.headers?.Origin });
9936
+ if (operationsResult !== undefined) return operationsResult;
9937
+ }
9719
9938
  const pushResult = await pushFetch(path, opts);
9720
9939
  if (pushResult !== undefined) return pushResult;
9721
9940
  const phoneHubResult = await phoneHubFetch(path, opts);
@@ -10106,6 +10325,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10106
10325
  }
10107
10326
  // ══ end network security wire ════════════════════════════════════
10108
10327
  // ══ DNSMan — capabilities, safe models and custom operations ════════
10328
+ if (path.startsWith('/api/dnsman/delegation/') || path.startsWith('/api/dnsman/acme/')) return mockSharedValidation(path, opts, method);
10109
10329
  if (path === '/api/dnsman/config') {
10110
10330
  const caller = userFromBearer(opts.headers);
10111
10331
  if (!caller) return permissionDenied(401);
@@ -10227,6 +10447,8 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10227
10447
 
10228
10448
  const credentialMatch = path.match(/^\/api\/dnsman\/credential(?:\/(\d+))?$/);
10229
10449
  if (credentialMatch) {
10450
+ const { prepareMockDomainCredentials } = await import('./domain-operations-mock');
10451
+ prepareMockDomainCredentials(db.dnsCredentials);
10230
10452
  const caller = userFromBearer(opts.headers);
10231
10453
  if (!caller) return permissionDenied(401);
10232
10454
  const id = credentialMatch[1] ? Number(credentialMatch[1]) : null;
@@ -10276,6 +10498,11 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10276
10498
  if ('privacy' in opts.body) row.privacy = Boolean(opts.body.privacy);
10277
10499
  if ('credential' in opts.body) row.credential = opts.body.credential == null ? null : Number(opts.body.credential);
10278
10500
  row.modified = Math.floor(Date.now() / 1000);
10501
+ if (row.provider === 'route53' && domainRegistrarSaveFault) {
10502
+ const fault = domainRegistrarSaveFault; domainRegistrarSaveFault = null;
10503
+ row.last_error = 'Registrar update was not confirmed. Inspect the registrar before changing this setting again.';
10504
+ if (fault === 'ambiguous') return { status: false, error: 'Registrar update acknowledgement unavailable', error_code: 503 };
10505
+ }
10279
10506
  }
10280
10507
  return { status: true, data: serializeDnsDomain(row), graph: 'default' };
10281
10508
  }
@@ -10428,6 +10655,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10428
10655
  const domain = db.dnsDomains.find((candidate) => candidate.id === Number(body.domain));
10429
10656
  if (!domain) return { status: false, error: 'Domain not found', error_code: 404 };
10430
10657
  if (domain.group == null ? !hasGlobalPermission(caller, DNS_MANAGE_GRANTS) : !dnsMemberCan(caller, domain.group, true)) return permissionDenied();
10658
+ if (sharedConnections.has(domain.name)) return { status: false, error: 'Shared validation forbids direct fallback', error_code: 409 };
10431
10659
  if (dnsAcmeMode === 'unconfigured') return { status: false, error: 'ACME is not configured', error_code: 400 };
10432
10660
  if (domain.status !== 'active') return { status: false, error: 'Certificates require an active domain', error_code: 400 };
10433
10661
  const sticky = db.acmeDelegations.find((candidate) => candidate.domain === domain.id && candidate.verified_at != null && candidate.state !== 'retired');
@@ -10470,7 +10698,13 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10470
10698
  const action = path.slice('/api/dnsman/registrar/'.length);
10471
10699
  const body = opts.body ?? {};
10472
10700
  const houseAction = ['discover', 'adopt', 'assign-group'].includes(action);
10473
- if (houseAction && !caller.is_superuser) return permissionDenied();
10701
+ if (houseAction && caller.is_superuser !== true) return permissionDenied();
10702
+ if (houseAction || action === 'purchase') {
10703
+ const { mockRegistrarSession } = await import('./domain-operations-mock');
10704
+ const refusal = mockRegistrarSession(decodeMockJwt((opts.headers?.Authorization ?? '').replace(/^Bearer /, '')), action === 'purchase');
10705
+ if (caller.is_active !== true || refusal === 'denied') return permissionDenied();
10706
+ if (refusal === 'stale') return { status: false, error: 'reauth_required', error_code: 440 };
10707
+ }
10474
10708
  if (!houseAction) {
10475
10709
  const groupId = Number(body.group ?? opts.params?.group ?? 0);
10476
10710
  if (groupId > 0 ? !dnsMemberCan(caller, groupId, action !== 'search' && action !== 'suggest')
@@ -10514,14 +10748,16 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10514
10748
  const now = Math.floor(Date.now() / 1000);
10515
10749
  const id = Math.max(8300, ...db.domainPurchases.map((candidate) => candidate.id)) + 1;
10516
10750
  const token = `mock-once-${id}-${Math.random().toString(36).slice(2)}`;
10517
- const purchase: MockDomainPurchase = { id, created: now, modified: now, group: groupId, user: caller.id, domain_name: String(body.domain).toLowerCase(), kind: 'register', status: 'quoted', price: '12.00', cost: '12.00', currency: 'USD', years, quote_expires: now + 900, operation_id: null, error: null, metadata: { confirm_digest: mockTokenDigest(token) } };
10751
+ const purchase: MockDomainPurchase = { id, created: now, modified: now, group: groupId, user: caller.id, domain_name: answer.name, kind: 'register', status: 'quoted', price: '12.00', cost: '12.00', currency: 'USD', years, quote_expires: now + 900, operation_id: null, error: null, metadata: { confirm_digest: mockTokenDigest(token) } };
10518
10752
  db.domainPurchases.unshift(purchase);
10519
10753
  return { status: true, data: { purchase: id, name: purchase.domain_name, price: purchase.price, currency: purchase.currency, years: purchase.years, token, expires: purchase.quote_expires, privacy_supported: true } };
10520
10754
  }
10521
10755
  if (action === 'purchase') {
10756
+ if (method !== 'POST') return { status: false, error: 'Method not allowed', error_code: 405 };
10757
+ const { validMockPurchaseTuple } = await import('./domain-operations-mock');
10522
10758
  const purchase = db.domainPurchases.find((candidate) => candidate.id === Number(body.purchase));
10523
10759
  const digest = typeof purchase?.metadata.confirm_digest === 'string' ? purchase.metadata.confirm_digest : '';
10524
- if (!purchase || mockTokenDigest(String(body.confirm_token ?? '')) !== digest || purchase.status !== 'quoted' || (purchase.quote_expires ?? 0) <= Date.now() / 1000) return { status: false, error: 'That purchase confirmation is not valid. Request a new quote and try again.', error_code: 400 };
10760
+ if (!purchase || !validMockPurchaseTuple(purchase, body, caller.id) || mockTokenDigest(String(body.confirm_token ?? '')) !== digest || purchase.status !== 'quoted' || (purchase.quote_expires ?? 0) <= Date.now() / 1000) return { status: false, error: 'That purchase confirmation is not valid. Request a new quote and try again.', error_code: 400 };
10525
10761
  delete purchase.metadata.confirm_digest;
10526
10762
  purchase.status = dnsRegistrarFault === 'failed' ? 'failed' : 'submitted'; purchase.error = dnsRegistrarFault === 'failed' ? 'Registrar rejected the operation after durable intent' : null; purchase.lifecycle_reads = dnsRegistrarFault === 'failed' ? undefined : 0; purchase.modified = Math.floor(Date.now() / 1000);
10527
10763
  const now = purchase.modified;
@@ -10533,10 +10769,15 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10533
10769
  return { status: true, data: { purchase: purchase.id, domain: domain.id, name: domain.name, status: domain.status, operation_id: null, privacy: true, privacy_downgraded: false } };
10534
10770
  }
10535
10771
  if (action === 'register-existing') {
10772
+ const { mockDomainRegistrationName, prepareMockDomainCredentials } = await import('./domain-operations-mock');
10773
+ prepareMockDomainCredentials(db.dnsCredentials);
10774
+ const name = mockDomainRegistrationName(body.domain);
10536
10775
  const credential = db.dnsCredentials.find((candidate) => candidate.id === Number(body.credential));
10537
- if (!credential || !credential.is_active || !credential.verified || credential.group !== Number(body.group)) return { status: false, error: 'A verified credential is required', error_code: 400 };
10776
+ if (method !== 'POST' || !name || !credential || credential.provider !== 'godaddy' || !credential.is_active || !credential.verified || credential.group !== Number(body.group)) return { status: false, error: 'A same-group verified GoDaddy credential and exact domain are required', error_code: 400 };
10777
+ if (db.dnsDomains.some(row => row.name === name)) return { status: false, error: 'Domain is already registered', error_code: 400 };
10778
+ if (name.includes('provider-failure')) return { status: false, error: 'Provider ownership could not be verified', error_code: 400 };
10538
10779
  const now = Math.floor(Date.now() / 1000);
10539
- const domain: MockDnsDomain = { id: Math.max(8200, ...db.dnsDomains.map((candidate) => candidate.id)) + 1, created: now, modified: now, group: credential.group, user: caller.id, name: String(body.domain).toLowerCase(), provider: credential.provider, credential: credential.id, status: 'active', hosted_zone_id: null, auto_renew: false, privacy: false, verified: true, registered_on: null, expires: null, last_error: null, metadata: {} };
10780
+ const domain: MockDnsDomain = { id: Math.max(8200, ...db.dnsDomains.map((candidate) => candidate.id)) + 1, created: now, modified: now, group: credential.group, user: caller.id, name, provider: credential.provider, credential: credential.id, status: 'active', hosted_zone_id: null, auto_renew: false, privacy: false, verified: true, registered_on: null, expires: null, last_error: null, metadata: {} };
10540
10781
  db.dnsDomains.push(domain); db.dnsRecords.set(domain.id, []);
10541
10782
  return { status: true, data: serializeDnsDomain(domain) };
10542
10783
  }
@@ -10620,9 +10861,29 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10620
10861
  return { status: true, data: { name: domain.name, registrant: transientContact, admin: transientContact, tech: transientContact, privacy: domain.privacy, admin_privacy: domain.privacy, registrant_privacy: domain.privacy, tech_privacy: domain.privacy, auto_renew: domain.auto_renew, nameservers: ['ns-1.example.invalid', 'ns-2.example.invalid'], registrar: 'Amazon Registrar, Inc.', registered_on: domain.registered_on, expires: domain.expires, status_list: ['ok'], privacy_supported: true } };
10621
10862
  }
10622
10863
  // ══ end DNSMan wire ═══════════════════════════════════════════════
10864
+ if (path === '/api/account/admin/fleet/shared-validation/recipient') {
10865
+ const caller = userFromBearer(opts.headers); if (!caller) return permissionDenied(401);
10866
+ if (caller.is_superuser !== true) return permissionDenied();
10867
+ const input = method === 'GET' ? opts.params ?? {} : opts.body ?? {};
10868
+ if (method === 'GET' ? Object.keys(input).some((k) => k !== 'consumer_project_uuid') : method !== 'POST' || Object.keys(input).sort().join(',') !== 'consumer_project_uuid,expected_consumer_label,label,purpose' || input.purpose !== 'acme_validation_consumer') return connectionFail('invalid_request', 400);
10869
+ const group = db.groups.find((g) => acmeProjectIdentity(g) === input.consumer_project_uuid && isEffectivelyActive(g));
10870
+ if (!group) return connectionFail('recipient_unavailable', 404);
10871
+ const scenario = getMockConnectionScenario();
10872
+ if (method === 'GET') return { status: true, data: { purpose: 'acme_validation_consumer', consumer_project_uuid: acmeProjectIdentity(group), consumer_label: group.name, permission: 'dnsman_acme_federation', can_issue: scenario !== 'recipient-denied', grant_explanation: 'Protected platform grant required; issuance does not grant allocation trust.' } };
10873
+ if (consumeMockConnectionFault('reauth')) return connectionFail('fresh_auth_required', 440);
10874
+ if (scenario === 'recipient-denied') return permissionDenied();
10875
+ if (scenario === 'recipient-renamed' || input.expected_consumer_label !== group.name) return connectionFail('recipient_changed', 409);
10876
+ if (typeof input.label !== 'string' || !input.label.trim() || input.label.length > 200 || /[\x00-\x1f]/.test(input.label)) return connectionFail('invalid_request', 400);
10877
+ const id = Math.max(0, ...db.groupApiKeys.map((k) => k.id)) + 1, now = Math.floor(Date.now() / 1000), secret = `mock_acme_${mockHex32(mulberry32(id))}`;
10878
+ const row: MockGroupApiKey = { id, group: group.id, user: null, created: now, modified: now, name: input.label.trim(), is_active: true, permissions: { dnsman_acme_federation: true }, limits: {}, last_used: null, expires_at: null, metadata: {}, override_user: false, token: secret };
10879
+ db.groupApiKeys.unshift(row); registerMockConsumerKey(secret, acmeProjectIdentity(group)!);
10880
+ if (consumeMockConnectionFault('issue-uncertain')) return connectionFail('publication_uncertain', 503);
10881
+ return { status: true, data: { purpose: 'acme_validation_consumer', consumer_project_uuid: acmeProjectIdentity(group), consumer_label: group.name, api_key: { id, name: row.name, is_active: true, permission: 'dnsman_acme_federation' }, token: secret } };
10882
+ }
10623
10883
  if (path === '/api/account/admin/fleet' || path.startsWith('/api/account/admin/fleet/')) {
10624
10884
  const caller = userFromBearer(opts.headers);
10625
10885
  if (!caller) return permissionDenied(401);
10886
+ const original = db.groups.find((group) => group.id === 1); if (original?.uuid) configureMockHubRecipient(acmeProjectIdentity(original)!, original.name);
10626
10887
  return fleetMock(path, method, opts.body, caller.is_superuser === true);
10627
10888
  }
10628
10889
  // ── Settings — plaintext stays private; secrets serialize masked ─────
@@ -10631,11 +10892,18 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10631
10892
  const caller = userFromBearer(opts.headers);
10632
10893
  if (!caller) return permissionDenied(401);
10633
10894
  if (!hasGlobalPermission(caller, ['manage_settings', 'groups'])) return permissionDenied();
10895
+ if (method === 'GET') {
10896
+ const { maintenanceMockFramework } = await import('./maintenance-mock');
10897
+ const pin = maintenanceMockFramework(caller).pin;
10898
+ const row = db.settings.find(row => row.key === 'EDGE_FRAMEWORK_VERSION');
10899
+ if (row) row.value = pin.mode === 'latest' ? '' : pin.mode === 'hold' ? 'hold' : pin.value ?? '';
10900
+ }
10634
10901
  if (oneSetting) {
10635
10902
  const row = db.settings.find((candidate) => candidate.id === Number(oneSetting[1]));
10636
10903
  if (!row) return { status: false, error: 'Setting not found', error_code: 404 };
10637
10904
  if (opts.method === 'DELETE') return { status: false, error: 'DELETE not allowed: Setting', error_code: 403 };
10638
10905
  if (opts.method === 'POST' && opts.body) {
10906
+ if (row.key === 'EDGE_FRAMEWORK_VERSION' || opts.body.key === 'EDGE_FRAMEWORK_VERSION') return {status:false,error:'Framework policy is protected; use its dedicated owner writer.',error_code:400};
10639
10907
  if ('value' in opts.body && !row.is_secret) {
10640
10908
  // Registered validators run before the row mutates (Setting.on_rest_pre_save → config.validate).
10641
10909
  const refusal = validateRenditionSetting(typeof opts.body.key === 'string' ? opts.body.key : row.key, opts.body.value);
@@ -10654,6 +10922,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10654
10922
  return { status: true, data: serializeSetting(row), graph: 'default' };
10655
10923
  }
10656
10924
  if (opts.method === 'POST' && opts.body) {
10925
+ if (opts.body.key === 'EDGE_FRAMEWORK_VERSION') return {status:false,error:'Framework policy is protected; use its dedicated owner writer.',error_code:400};
10657
10926
  const now = Math.floor(Date.now() / 1000);
10658
10927
  const row: MockSetting = { id: Math.max(0, ...db.settings.map((candidate) => candidate.id)) + 1, created: now, modified: now, key: '', value: '', is_secret: false, group: null, secretValue: null };
10659
10928
  for (const [field, value] of Object.entries(opts.body)) {
@@ -11753,7 +12022,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
11753
12022
  }
11754
12023
  if (opts.method === 'POST' && opts.body) {
11755
12024
  if (!groupCanManage(caller, group.id)) return permissionDenied();
11756
- if (('disable' in opts.body || 'reactivate' in opts.body)
12025
+ if (('disable' in opts.body || 'reactivate' in opts.body || GROUP_SUBGROUP_POLICY_KEY in opts.body)
11757
12026
  && !hasGlobalPermission(caller, ['manage_groups', 'groups'])) return permissionDenied();
11758
12027
  return saveGroup(group, opts.body);
11759
12028
  }
@@ -11796,7 +12065,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
11796
12065
  db.groups.push(group);
11797
12066
  return { status: true, data: serializeGroup(group), graph: 'default' };
11798
12067
  }
11799
- return listRows(visibleGroups as unknown as Record<string, unknown>[], opts.params ?? {}, (g) => String(g.name), 'name');
12068
+ return listRows(visibleGroups.map(serializeGroup), opts.params ?? {}, (g) => String(g.name), 'name');
11800
12069
  }
11801
12070
  // User API keys — /api/account/api_keys (account/rest/user_api_key.py).
11802
12071
  // No DELETE (CAN_DELETE defaults false); the kill switch is the `revoke`