portal-mojo 0.2.20 → 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 (131) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-deployments.md +201 -0
  3. package/docs/admin-dns-certificates.md +3 -3
  4. package/docs/admin-dns-domains.md +92 -0
  5. package/docs/admin-dns.md +4 -2
  6. package/docs/admin-edge.md +200 -196
  7. package/docs/admin-geolocation.md +100 -0
  8. package/docs/admin-maintenance.md +133 -0
  9. package/docs/admin-modals.md +17 -2
  10. package/docs/admin-operational-fleet.md +206 -0
  11. package/docs/admin-presentation.md +51 -0
  12. package/docs/admin-signin.md +12 -2
  13. package/docs/client.md +7 -0
  14. package/docs/combobox.md +18 -0
  15. package/docs/popover.md +5 -4
  16. package/package.json +1 -1
  17. package/src/admin/core/OperationsHelp.tsx +10 -0
  18. package/src/admin/core/index.ts +6 -4
  19. package/src/admin/core/route-context.ts +6 -0
  20. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  21. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  22. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  23. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  24. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  25. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  26. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  27. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  28. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  29. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  30. package/src/admin/deployments/api.ts +158 -0
  31. package/src/admin/deployments/authority-state.ts +9 -0
  32. package/src/admin/deployments/deployment-state.ts +13 -0
  33. package/src/admin/deployments/index.ts +4 -0
  34. package/src/admin/deployments/onboarding-state.ts +11 -0
  35. package/src/admin/deployments/permissions.ts +37 -0
  36. package/src/admin/deployments/receipts.ts +78 -0
  37. package/src/admin/deployments/sanitize.ts +73 -0
  38. package/src/admin/deployments/shared.tsx +39 -0
  39. package/src/admin/deployments/types.ts +238 -0
  40. package/src/admin/deployments/upload.ts +53 -0
  41. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  42. package/src/admin/dns/DomainDetail.tsx +10 -6
  43. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  44. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  45. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  46. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  47. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  48. package/src/admin/dns/DomainsPage.tsx +22 -13
  49. package/src/admin/dns/SharedValidationDialog.tsx +1 -1
  50. package/src/admin/dns/api.ts +4 -2
  51. package/src/admin/dns/domain-navigation.ts +16 -0
  52. package/src/admin/dns/registrar-api.ts +13 -0
  53. package/src/admin/domains/assistant.ts +5 -4
  54. package/src/admin/domains/communications.ts +10 -9
  55. package/src/admin/domains/identity.ts +8 -7
  56. package/src/admin/domains/infrastructure.ts +19 -13
  57. package/src/admin/domains/lazy-page.ts +6 -0
  58. package/src/admin/domains/observability.ts +6 -5
  59. package/src/admin/domains/operations.ts +22 -7
  60. package/src/admin/domains/security.ts +16 -15
  61. package/src/admin/edge/EdgeHelp.tsx +10 -0
  62. package/src/admin/edge/ServingReadiness.tsx +54 -0
  63. package/src/admin/edge/VhostDetail.tsx +145 -139
  64. package/src/admin/edge/VhostWizard.tsx +204 -115
  65. package/src/admin/edge/VhostsPage.tsx +27 -28
  66. package/src/admin/edge/api.ts +24 -10
  67. package/src/admin/edge/models.ts +4 -4
  68. package/src/admin/edge/serving-data.ts +78 -0
  69. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  70. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  71. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  72. package/src/admin/fleet/FleetHelp.tsx +7 -0
  73. package/src/admin/fleet/FleetOverview.tsx +150 -0
  74. package/src/admin/fleet/FleetPage.tsx +11 -0
  75. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  76. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  77. package/src/admin/fleet/access.ts +22 -0
  78. package/src/admin/fleet/api.ts +77 -0
  79. package/src/admin/fleet/data.ts +148 -0
  80. package/src/admin/fleet/index.ts +8 -0
  81. package/src/admin/fleet/operations.ts +179 -0
  82. package/src/admin/fleet/standalone-data.ts +87 -0
  83. package/src/admin/fleet/types.ts +208 -0
  84. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  85. package/src/admin/geolocation/data.ts +138 -0
  86. package/src/admin/geolocation/index.ts +3 -0
  87. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  88. package/src/admin/index.ts +8 -0
  89. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  90. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  91. package/src/admin/maintenance/data.ts +486 -0
  92. package/src/admin/maintenance/index.ts +3 -0
  93. package/src/admin/maintenance/operations.ts +208 -0
  94. package/src/admin/public/infrastructure.ts +1 -0
  95. package/src/admin/public/operations.ts +3 -0
  96. package/src/admin/registry.ts +6 -2
  97. package/src/admin/settings/SettingDetail.tsx +7 -2
  98. package/src/admin/settings/SettingEditor.tsx +2 -1
  99. package/src/admin/settings/SettingsPage.tsx +3 -1
  100. package/src/admin/settings/model.ts +1 -0
  101. package/src/admin/signin/SigninPage.tsx +17 -3
  102. package/src/admin/signin/api.ts +2 -0
  103. package/src/charts/MetricsChartView.tsx +4 -11
  104. package/src/charts/MetricsMiniWidget.tsx +3 -15
  105. package/src/client/client.ts +6 -0
  106. package/src/client/deployments-mock.ts +375 -0
  107. package/src/client/domain-operations-mock.ts +37 -0
  108. package/src/client/geo-provider-mock.ts +82 -0
  109. package/src/client/maintenance-mock.ts +285 -0
  110. package/src/client/mock.ts +52 -10
  111. package/src/client/operational-fleet-mock.ts +279 -0
  112. package/src/client/operations-mock.ts +85 -0
  113. package/src/client/serving-mock.ts +22 -0
  114. package/src/styles/components/admin-deployments.css +35 -0
  115. package/src/styles/components/admin-domains.css +12 -0
  116. package/src/styles/components/admin-edge.css +125 -1
  117. package/src/styles/components/admin-geolocation.css +40 -0
  118. package/src/styles/components/admin-maintenance.css +127 -0
  119. package/src/styles/components/admin-operational-fleet.css +38 -0
  120. package/src/styles/components/admin-operations.css +9 -0
  121. package/src/styles/components/admin-signin.css +2 -0
  122. package/src/styles/components/combobox.css +12 -2
  123. package/src/styles/components/fleet-page.css +9 -0
  124. package/src/styles/index.css +7 -0
  125. package/src/ui/ComboBox.tsx +39 -8
  126. package/src/ui/FilterBar.tsx +3 -5
  127. package/src/ui/ModelTable.tsx +2 -8
  128. package/src/ui/Popover.tsx +15 -7
  129. package/src/ui/date/TimePicker.tsx +4 -14
  130. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  131. package/src/ui/schema-form-core.tsx +3 -5
@@ -0,0 +1,375 @@
1
+ /** Contract fixture only. No live calls, request-body/header history or retained token/URL. */
2
+ import { backendGlobalCan, type BackendGlobalUser } from '../admin/fleet/access';
3
+ export type DeploymentMockScenario = 'failed-newer' | 'partial' | 'reported-only' | 'converged' | 'unknown' | 'stale' | 'mixed-webapp' | 'rolled-back' | 'missing-destination' | 'missing-bucket' | 'missing-apps-domain' | 'revision-conflict' | 'lease-conflict' | 'unknown-create' | 'key-replay' | 'upload-failure' | 'timeout' | 'read-failure' | 'ownership-refusal' | 'superseded-webapp' | 'new-workspace-domain-ready' | 'collector-unavailable' | 'active-webapp' | 'many-releases';
4
+ let scenario: DeploymentMockScenario = 'failed-newer';
5
+ const A = '11111111-1111-4111-8111-111111111111', B = '22222222-2222-4222-8222-222222222222', shaA = 'a'.repeat(40), shaB = 'b'.repeat(40);
6
+ const stamp = '2026-10-21T12:00:00Z';
7
+ interface MockUser extends BackendGlobalUser {
8
+ id: number;
9
+ is_active?: boolean;
10
+ }
11
+ export interface DeploymentMockContext {
12
+ user: MockUser | null;
13
+ keyBacked: boolean;
14
+ authTime: number | null;
15
+ group?: number | null; /** False models an origin/interactive refusal. */
16
+ interactive?: boolean;
17
+ }
18
+ interface Opts {
19
+ method?: string;
20
+ body?: Record<string, unknown>;
21
+ params?: Record<string, unknown>;
22
+ headers?: Record<string, string>;
23
+ }
24
+ const operations = new Map<string, {
25
+ actor: number;
26
+ group: number;
27
+ origin?: string;
28
+ profile: Record<string, unknown>;
29
+ revision: number;
30
+ cursor: string;
31
+ status: string;
32
+ app: number;
33
+ address?: Record<string, unknown>;
34
+ }>();
35
+ const keyReceipts = new Set<string>();
36
+ const profiles = new Map<number, Record<string, string>>();
37
+ let activeReads = 0;
38
+ const apiReceipts = new Map<string, string>();
39
+ let deployment = 303, releaseId = 103, appId = 2;
40
+ const linkedKeys = new Set<number>([1]);
41
+ const releases = [{
42
+ id: 102, webapp: 1, version: '2.0.0', status: 'pending', source: 'github', created: stamp
43
+ }, {
44
+ id: 101, webapp: 1, version: '1.0.0', status: 'live', source: 'github', created: stamp
45
+ }, {
46
+ id: 100, webapp: 1, version: '0.9.0', status: 'superseded', source: 'upload', created: stamp
47
+ }];
48
+ const attempts = [{
49
+ id: 302, webapp: 1, release: releases[0]!, status: 'failed', created: stamp, finished: stamp
50
+ }, {
51
+ id: 301, webapp: 1, release: releases[1]!, status: 'live', created: stamp, finished: stamp
52
+ }];
53
+ const uploadManifests = new Map<number, {
54
+ app: number;
55
+ version: string;
56
+ manifest: {
57
+ path: string;
58
+ sha256: string;
59
+ size: number;
60
+ }[];
61
+ complete: boolean;
62
+ deployment?: number;
63
+ }>();
64
+ const uploadedPaths = new Set<string>();
65
+ const currentReleases = new Map<number, number>([[1,101]]);
66
+ export function setDeploymentMockScenario(value: DeploymentMockScenario) {
67
+ scenario = value;
68
+ if (value === 'many-releases' && !releases.some(r => r.id === 99)) for (let id = 99; id >= 60; id--) releases.push({ id, webapp: 1, version: `0.8.${id}`, status: 'superseded', source: 'github', created: stamp });
69
+ if (value === 'active-webapp' && attempts[0]) { attempts[0].status = 'deploying'; attempts[0].finished = ''; activeReads = 0; }
70
+ }
71
+ export function resetDeploymentMock() { scenario = 'failed-newer'; operations.clear(); profiles.clear(); activeReads = 0; keyReceipts.clear(); apiReceipts.clear(); uploadManifests.clear(); uploadedPaths.clear(); linkedKeys.clear(); linkedKeys.add(1); currentReleases.clear(); currentReleases.set(1,101); releases.splice(0, releases.length, {
72
+ id: 102, webapp: 1, version: '2.0.0', status: 'pending', source: 'github', created: stamp
73
+ }, {
74
+ id: 101, webapp: 1, version: '1.0.0', status: 'live', source: 'github', created: stamp
75
+ }, {
76
+ id: 100, webapp: 1, version: '0.9.0', status: 'superseded', source: 'upload', created: stamp
77
+ }); attempts.splice(0, attempts.length, {
78
+ id: 302, webapp: 1, release: releases[0]!, status: 'failed', created: stamp, finished: stamp
79
+ }, {
80
+ id: 301, webapp: 1, release: releases[1]!, status: 'live', created: stamp, finished: stamp
81
+ }); }
82
+ export function readDeploymentBootstrapFacts(caller: MockUser) { const manage = backendGlobalCan(caller, ['security']) || (backendGlobalCan(caller, ['manage_webapp']) && backendGlobalCan(caller, ['manage_dns'])); return {
83
+ capabilities: { manage_webapps: manage }, features: { webapps: { enabled: true } }, webapp_groups: manage ? [{
84
+ id: 1, name: 'Example workspace', can_manage_dns: true
85
+ }] : [], can_create_webapp_group: manage && backendGlobalCan(caller, ['manage_groups', 'groups'])
86
+ }; }
87
+ const ok = (data: unknown) => ({ status: true, data });
88
+ const no = (message = 'Permission denied', code = 403) => ({
89
+ status: false, code, error: message
90
+ });
91
+ function platformAttempt(id = B) { let status = scenario === 'partial' ? 'partial' : scenario === 'converged' ? 'converged' : scenario === 'reported-only' ? 'verified' : scenario === 'unknown' ? 'unknown' : 'failed'; if (id !== B && id !== A)
92
+ status = scenario === 'timeout' ? 'requested' : 'converged'; if (id === A)
93
+ status = 'converged'; return {
94
+ id, sha: id === A ? shaA : shaB, framework_version: '5.0.0', status, source: 'admin_retry', actor: 'fixture operator', retry_of: id !== B && id !== A ? B : null, created: stamp, finished: ['converged', 'failed', 'superseded'].includes(status) ? stamp : null, frozen_roster: ['node-a', 'node-b'], node_summary: {
95
+ expected: 2, proven: status === 'converged' ? 2 : status === 'partial' ? 1 : 0, reported: scenario === 'reported-only' ? 2 : 0, failed: status === 'failed' ? 1 : 0, dispatched: 2, other: 0
96
+ }, node_evidence: [{
97
+ runner: 'node-a', status: status === 'converged' ? 'proven' : 'reported', proof: status === 'converged' ? { platform_sha: id === A ? shaA : shaB, platform_deployment: id } : {}
98
+ }], diagnosis: status === 'failed' ? [{
99
+ runner: 'node-b', status: 'failed', detail: 'Process failed before restart proof; last convergence remains unchanged.'
100
+ }] : [], detail: { message: 'Bounded mock observation' }, current_commits: [shaA], desired_commit: shaB
101
+ }; }
102
+ function summary(id = 1, full = false) {
103
+ const created = [...operations.values()].find(entry => entry.app === id);
104
+ if (created) return {
105
+ schema_version: 1,
106
+ webapp: { id, slug: String(created.profile.slug ?? `app-${id}`), display_name: String(created.profile.display_name ?? ''), environment: String(created.profile.environment ?? 'production'), deployment_ref: String(created.profile.deployment_ref ?? 'main'), repository: String(created.profile.github_repository ?? ''), build_output: String(created.profile.build_output ?? 'dist'), ...profiles.get(id) },
107
+ address: { hostname: `${created.profile.slug ?? `app-${id}`}.apps.example.test`, certificate: { status: created.status === 'succeeded' ? 'active' : 'pending', not_after: '' }, aliases: [] },
108
+ current_release: releases.find(row => row.webapp === id && row.id === currentReleases.get(id)) ?? null, latest_deployment: attempts.find(row => row.webapp === id) ?? null,
109
+ onboarding: { status: created.status, cursor: created.cursor }, deployment_key: { linked: linkedKeys.has(id), active: linkedKeys.has(id) }
110
+ };
111
+ const current = releases.find(r => r.webapp === id && r.id === currentReleases.get(id)) ?? releases[1]!; return {
112
+ schema_version: 1, webapp: {
113
+ id, slug: id === 1 ? 'example-app' : `app-${id}`, display_name: id === 1 ? 'Example WebApp' : `New WebApp ${id}`, environment: 'production', deployment_ref: 'main', repository: 'NativeMojo/example', build_output: 'dist', ...profiles.get(id)
114
+ }, address: {
115
+ hostname: 'example.apps.example.test', certificate: { status: 'active', not_after: '2027-01-01T00:00:00Z' }, aliases: []
116
+ }, current_release: current, latest_deployment: {
117
+ ...attempts.find(row => row.webapp === id)!, status: scenario === 'rolled-back' ? 'rolled_back' : scenario === 'mixed-webapp' ? 'failed' : attempts.find(row => row.webapp === id)?.status, ...(full ? { release: undefined } : {})
118
+ }, onboarding: { status: 'succeeded', cursor: 'verify' }, deployment_key: { linked: linkedKeys.has(id), active: linkedKeys.has(id) }
119
+ }; }
120
+ function operation(id: string, entry: NonNullable<ReturnType<typeof operations.get>>) { return {
121
+ schema_version: 1, operation_id: id, id: 1, group: { id: entry.group, name: entry.group===1?'Example workspace':String(entry.profile.display_name??'New workspace') }, status: entry.status, cursor: entry.cursor, revision: entry.revision, profile: entry.profile, evidence: {
122
+ app: { status: 'ready' }, address: { status: entry.cursor === 'address' ? 'waiting' : 'ready', hostname: 'example.apps.example.test' }, github: { status: 'unavailable', reason: 'Repository verification has not been attested' }, verify: { status: entry.status === 'succeeded' ? 'ready' : 'unknown' }
123
+ }, activity: [{
124
+ message: `Observed ${entry.cursor} at revision ${entry.revision}`, kind: 'info', at: stamp
125
+ }], resources: { webapp: entry.app }, next_attempt_at: null, last_error: '', modified: stamp
126
+ }; }
127
+ export function deploymentsMock(path: string, opts: Opts, context: DeploymentMockContext): unknown | undefined {
128
+ const method = opts.method ?? 'GET', b = opts.body ?? {}, p = opts.params ?? {}, u = context.user;
129
+ const owned = path === '/api/account/admin/platform' && p.sections === 'deployments,api' || path.startsWith('/api/account/admin/platform/deploy/') || path.startsWith('/api/edge/webapp/onboarding/') || ['/api/edge/webapp/summaries', '/api/edge/webapp/summary', '/api/edge/webapp/key_status', '/api/edge/webapp/link_key', '/api/edge/webapp/revoke_key', '/api/edge/webapp/rollback', '/api/edge/webapp/health'].includes(path) || /^\/api\/edge\/webapp\/\d+$/.test(path) || /^\/api\/edge\/release\/deployment\/\d+$/.test(path) || (path === '/api/edge/release' && (method === 'POST' || p.webapp !== undefined)) || path === '/api/edge/release/complete' || (path === '/api/edge/deployment' && p.webapp !== undefined);
130
+ if (!owned)
131
+ return undefined;
132
+ if (!u || u.is_active === false)
133
+ return no('Authentication required', 401);
134
+ const api = path.startsWith('/api/account/admin/platform');
135
+ if (context.keyBacked || context.interactive === false)
136
+ return no('Interactive user session required');
137
+ if (api && !backendGlobalCan(u, method === 'GET' ? ['view_platform', 'manage_platform', 'admin'] : ['manage_platform', 'admin']))
138
+ return no();
139
+ const view = backendGlobalCan(u, ['view_dns', 'manage_dns', 'security']), save = backendGlobalCan(u, ['manage_dns', 'security']), custom = save && backendGlobalCan(u, ['manage_webapp']), workflow = backendGlobalCan(u, ['security']) || (backendGlobalCan(u, ['manage_webapp']) && backendGlobalCan(u, ['manage_dns']));
140
+ if (!api && !view)
141
+ return no();
142
+ if (scenario === 'read-failure' && method === 'GET')
143
+ return no('Observation unavailable', 503);
144
+ if (method === 'POST' && !/^\/api\/edge\/webapp\/\d+$/.test(path) && !['/api/edge/release', '/api/edge/release/complete', '/api/edge/webapp/onboarding/create'].includes(path)) {
145
+ const seconds = path.endsWith('/revoke_key') ? 300 : 600;
146
+ if (context.authTime === null || Date.now() / 1000 - context.authTime > seconds)
147
+ return no('Fresh authentication required', 440);
148
+ }
149
+ if (path === '/api/account/admin/platform' && method === 'GET')
150
+ return ok({ schema_version: 1, sections: { deployments: {
151
+ status: scenario === 'collector-unavailable' ? 'unavailable' : scenario === 'stale' ? 'stale' : 'healthy', observed_at: stamp, reason: scenario === 'collector-unavailable' ? 'The deployment collector is unavailable.' : '', data: scenario === 'collector-unavailable' ? null : {
152
+ items: [...apiReceipts.values()].reverse().map(x => platformAttempt(x)).concat([platformAttempt(), platformAttempt(A)]), limit: 50, desired_commit: shaB, desired_deployment: B, currently_serving: {
153
+ deployment: A, sha: shaA, framework_version: '5.0.0', converged_at: stamp
154
+ }, coordination: {
155
+ state: 'idle', deployment: B, sha: shaB, at: stamp
156
+ }, framework_pin: { mode: 'latest', resolved: '5.0.0' }
157
+ }
158
+ }, api: { status: 'healthy', data: { version: '5.0.0', commit: shaA } } } });
159
+ if (path.startsWith('/api/account/admin/platform/deploy/') && method === 'POST') {
160
+ const action = path.split('/').pop();
161
+ if (typeof b.deployment !== 'string' || ![A, B, ...apiReceipts.values()].includes(b.deployment))
162
+ return no('Unknown deployment', 404);
163
+ if (action === 'retry') {
164
+ const key = opts.headers?.['Idempotency-Key'];
165
+ if (!key)
166
+ return no('Idempotency receipt required', 400);
167
+ let id = apiReceipts.get(key);
168
+ if (!id) {
169
+ id = crypto.randomUUID();
170
+ apiReceipts.set(key, id);
171
+ }
172
+ return ok({
173
+ schema_version: 1, queued: true, deployment: platformAttempt(id)
174
+ });
175
+ }
176
+ return ok({ schema_version: 1, deployment: platformAttempt(b.deployment) });
177
+ }
178
+ if (path === '/api/edge/webapp/summaries' && method === 'GET')
179
+ return ok({
180
+ schema_version: 1, items: [summary(), ...[...operations.values()].filter(entry => entry.actor === u.id).map(entry => summary(entry.app))], count: 1 + [...operations.values()].filter(entry => entry.actor === u.id).length, limit: 50, truncated: false, fleet: { live: 1 }
181
+ });
182
+ if (path === '/api/edge/webapp/summary' && method === 'GET') {
183
+ const id = Number(p.webapp);
184
+ if (id !== 1 && ![...operations.values()].some(x => x.app === id))
185
+ return no('Unknown app', 404);
186
+ return ok(summary(id, true));
187
+ }
188
+ if (/^\/api\/edge\/webapp\/\d+$/.test(path) && method === 'GET') {
189
+ const id = Number(path.split('/').pop());
190
+ if (id !== 1 && ![...operations.values()].some(x => x.app === id))
191
+ return no('Unknown app', 404);
192
+ return ok({
193
+ ...summary(id).webapp, group: { id: [...operations.values()].find(entry => entry.app === id)?.group ?? 1, name: 'Example workspace' }, bucket: 'must-be-stripped', prefix: 'must-be-stripped'
194
+ });
195
+ }
196
+ if (/^\/api\/edge\/webapp\/\d+$/.test(path) && method === 'POST') {
197
+ const id = Number(path.split('/').pop());
198
+ if (!save || scenario === 'ownership-refusal') return no();
199
+ if (id !== 1 && ![...operations.values()].some(x => x.app === id)) return no('Unknown app', 404);
200
+ const allowed = ['display_name', 'environment', 'github_repository', 'deployment_ref', 'build_output'];
201
+ if (Object.keys(b).some(key => !allowed.includes(key)) || Object.values(b).some(value => typeof value !== 'string')) return no('Unsupported profile fields', 400);
202
+ const current = profiles.get(id) ?? {};
203
+ profiles.set(id, { ...current, ...Object.fromEntries(Object.entries(b).map(([key, value]) => [key === 'github_repository' ? 'repository' : key, String(value)])) });
204
+ return ok({ ...summary(id).webapp, group: { id: [...operations.values()].find(entry => entry.app === id)?.group ?? 1, name: 'Example workspace' } });
205
+ }
206
+ if (path === '/api/edge/release' && method === 'GET') {
207
+ const items = releases.filter(r => r.webapp === Number(p.webapp) && (p.id === undefined || r.id === Number(p.id)));
208
+ return {
209
+ status: true, data: items.slice(Number(p.start) || 0, (Number(p.start) || 0) + (Number(p.size) || 25)).map(r => ({id:r.id,version:r.version,status:r.status,source:r.source,created:r.created})), count: items.length
210
+ };
211
+ }
212
+ if (path === '/api/edge/deployment' && method === 'GET') {
213
+ if (scenario === 'active-webapp' && p.id && ++activeReads >= 2 && attempts[0]?.status === 'deploying') { attempts[0].status = 'live'; attempts[0].finished = stamp; currentReleases.set(attempts[0].webapp, attempts[0].release.id); }
214
+ const items = attempts.filter(r => r.webapp === Number(p.webapp) && (p.id === undefined || r.id === Number(p.id)));
215
+ return { status: true, data: items.slice(Number(p.start) || 0, (Number(p.start) || 0) + (Number(p.size) || 15)).map(r => ({ id: r.id, release: r.release, status: r.status, created: r.created, finished: r.finished, ...(p.graph === 'default' ? { detail: r.status === 'failed' ? 'The deployment failed; the desired version is separate.' : 'Observed CI deployment' } : {}) })), count: items.length };
216
+ }
217
+ if (path === '/api/edge/webapp/health' && method === 'GET')
218
+ return ok({
219
+ webapp: Number(p.webapp), status: 'healthy', detail: 'HTTP 200 (separate from version proof)', checked: stamp
220
+ });
221
+ if (path === '/api/edge/webapp/rollback' && method === 'POST') {
222
+ if (!custom || scenario === 'ownership-refusal' || Number(b.webapp) !== 1 || Number(b.group) !== 1)
223
+ return no();
224
+ const target = releases.find(r => r.id === b.release && r.webapp === b.webapp && ['uploaded', 'live', 'superseded'].includes(r.status));
225
+ if (!target)
226
+ return no('Release not verified', 400);
227
+ const a = {
228
+ id: ++deployment, webapp: 1, release: target, status: scenario === 'rolled-back'?'rolled_back':scenario === 'mixed-webapp' ? 'failed' : scenario==='superseded-webapp'?'superseded':'deploying', created: stamp, finished: ''
229
+ };
230
+ attempts.unshift(a);
231
+ return ok(statusPayload(a));
232
+ }
233
+ if (/^\/api\/edge\/release\/deployment\/\d+$/.test(path) && method === 'GET') {
234
+ if (!save) return no('Unknown deployment', 404);
235
+ const a = attempts.find(a => a.id === Number(path.split('/').pop()));
236
+ if (!a)
237
+ return no('Unknown deployment', 404);
238
+ if (scenario !== 'timeout' && a.status === 'deploying') {
239
+ a.status = 'live';
240
+ currentReleases.set(a.webapp, a.release.id);
241
+ }
242
+ return ok(statusPayload(a));
243
+ }
244
+ if (path.startsWith('/api/edge/webapp/onboarding/')) {
245
+ if (!workflow)
246
+ return no();
247
+ const action = path.split('/').pop();
248
+ if (action === 'options' && method === 'GET')
249
+ return ok({
250
+ schema_version: 1, buckets: scenario === 'missing-bucket' ? [] : ['approved-releases'], environments: ['production', 'staging', 'preview', 'development'], destination: scenario === 'missing-destination' ? null : { type: 'cname', value: 'serving.example.test' }, destination_error: scenario === 'missing-destination' ? 'Complete the serving destination in Setup' : null, apps_domain: scenario==='missing-apps-domain'||p.group_intent==='new'||(Number(p.group)>=1000&&scenario!=='new-workspace-domain-ready')?null:{id:10,name:'apps.example.test'}, apps_domain_error: p.group_intent==='new'||Number(p.group)>=1000||scenario==='missing-apps-domain'?'This workspace has no domain managed here yet.':null, github_connected: false
251
+ });
252
+ if (action === 'precheck' && method === 'GET') {
253
+ const host = String(p.url ?? '').replace(/^https?:\/\//, '');
254
+ return ok({
255
+ schema_version: 1, verdict: p.group_intent==='new'||(Number(p.group)>=1000&&scenario!=='new-workspace-domain-ready')?'domain_unknown':host.endsWith('.apps.example.test')?'ready':'domain_unknown', normalized: { hostname: host, label: host.split('.')[0] }, domain: { id: 10, name: 'apps.example.test' }
256
+ });
257
+ }
258
+ if (action === 'create' && method === 'POST') {
259
+ const id = String(b.operation_id);
260
+ let e = operations.get(id);
261
+ if (e && JSON.stringify(e.profile) !== JSON.stringify(b))
262
+ return no('Receipt profile changed', 409);
263
+ if (!e) {
264
+ e = {
265
+ actor: u.id, group: b.group_intent==='new'?1000+appId:Number(b.group), profile: { ...b }, revision: 1, cursor: b.group_intent==='new'?'address':'app', status: 'waiting', app: ++appId
266
+ };
267
+ operations.set(id, e);
268
+ }
269
+ if (scenario === 'unknown-create')
270
+ return no('Delivery uncertain; operation persisted', 503);
271
+ return ok({ created: true, operation: operation(id, e) });
272
+ }
273
+ if (action === 'workflow' && method === 'POST')
274
+ return ok({
275
+ schema_version: 1, repository: 'NativeMojo/example', filename: '.github/workflows/deploy-webapp.yml', yaml: `name: Deploy WebApp\non: [push]\njobs:\n deploy:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: NativeMojo/django-mojo/examples/github/actions/deploy-webapp@main\n with:\n webapp-id: '${Number(b.webapp)}'\n artifact-dir: dist\n env:\n MOJO_DEPLOY_KEY: \u0024{{ secrets.MOJO_DEPLOY_KEY }}\n`
276
+ });
277
+ const id = String(p.operation ?? b.operation ?? ''), entry = operations.get(id);
278
+ if (!entry || entry.actor !== u.id)
279
+ return no('Unknown operation', 404);
280
+ if (action === 'detail' && method === 'GET')
281
+ return ok(operation(id, entry));
282
+ if (action === 'choose' && method === 'POST') {
283
+ if (scenario === 'revision-conflict' || scenario === 'lease-conflict' || b.revision !== entry.revision || b.step !== entry.cursor)
284
+ return no('Revision changed or lease active; reload', 409);
285
+ const steps = ['app', 'address', 'github', 'verify'];
286
+ const index = steps.indexOf(entry.cursor);
287
+ entry.revision++;
288
+ entry.cursor = steps[Math.min(index + 1, 3)]!;
289
+ entry.status = index === 3 ? 'succeeded' : 'waiting';
290
+ return ok(operation(id, entry));
291
+ }
292
+ if (action === 'cancel' && method === 'POST') {
293
+ entry.status = 'cancelled';
294
+ entry.revision++;
295
+ return ok(operation(id, entry));
296
+ }
297
+ }
298
+ if (path === '/api/edge/webapp/key_status' && method === 'GET')
299
+ return ok({
300
+ webapp: Number(p.webapp), secret_name: 'MOJO_DEPLOY_KEY', status: { linked: linkedKeys.has(Number(p.webapp)), active: linkedKeys.has(Number(p.webapp)) }
301
+ });
302
+ if (['/api/edge/webapp/link_key', '/api/edge/webapp/revoke_key'].includes(path) && method === 'POST') {
303
+ if (!custom || scenario === 'ownership-refusal' || Number(b.group) !== 1)
304
+ return no();
305
+ const receipt = `${u.id}:${b.webapp}:${b.operation_id}`, replayed = keyReceipts.has(receipt) || scenario === 'key-replay';
306
+ keyReceipts.add(receipt);
307
+ if (path.endsWith('/revoke_key')) linkedKeys.delete(Number(b.webapp)); else linkedKeys.add(Number(b.webapp));
308
+ return ok({
309
+ webapp: Number(b.webapp), secret_name: 'MOJO_DEPLOY_KEY', replayed, token: linkedKeys.has(Number(b.webapp)) && !replayed ? crypto.randomUUID() + crypto.randomUUID() : null
310
+ });
311
+ }
312
+ if (path === '/api/edge/release' && method === 'POST') {
313
+ if (!save) return no();
314
+ const manifest = b.manifest as {
315
+ path: string;
316
+ sha256: string;
317
+ size: number;
318
+ }[];
319
+ if (!Array.isArray(manifest) || !manifest.length)
320
+ return no('Manifest required', 400);
321
+ let entry = [...uploadManifests.entries()].find(([, r]) => r.app === Number(b.webapp) && r.version === b.version);
322
+ if (entry && JSON.stringify(entry[1].manifest) !== JSON.stringify(manifest))
323
+ return no('Manifest differs', 400);
324
+ if (!entry) {
325
+ const r = {
326
+ app: Number(b.webapp), version: String(b.version), manifest, complete: false
327
+ };
328
+ entry = [++releaseId, r];
329
+ uploadManifests.set(entry[0], r);
330
+ }
331
+ return ok({
332
+ release: entry[0], version: entry[1].version, status: releases.find(x=>x.id===entry![0])?.status??(entry[1].complete?'uploaded':'pending'), uploads: entry[1].complete ? [] : manifest.map(e => ({
333
+ path: e.path, url: `https://deployment-storage.invalid/${entry![0]}/${encodeURIComponent(e.path)}`, headers: { 'x-amz-checksum-sha256': btoa(String.fromCharCode(...Uint8Array.from(e.sha256.match(/../g) ?? [], x => parseInt(x, 16)))) }
334
+ }))
335
+ });
336
+ }
337
+ if (path === '/api/edge/release/complete' && method === 'POST') {
338
+ if (!save) return no();
339
+ const r = uploadManifests.get(Number(b.release));
340
+ if (!r || r.manifest.some(m => !uploadedPaths.has(`${b.release}/${m.path}`)))
341
+ return no('Missing uploaded files', 400);
342
+ const active=attempts.find(a=>a.webapp===r.app&&a.release.id===Number(b.release)&&['queued','deploying','rolling_back','live'].includes(a.status));
343
+ if (currentReleases.get(r.app)===Number(b.release)&&active)
344
+ return ok({
345
+ release: Number(b.release), version: r.version, status: 'uploaded', promoted: true, deployment: active.id, deployment_status: active.status, webapp: r.app
346
+ });
347
+ r.complete = true;
348
+ const release = {
349
+ id: Number(b.release), webapp: r.app, version: r.version, status: 'live', source: 'upload', created: stamp
350
+ };
351
+ const a = {
352
+ id: ++deployment, webapp: r.app, release, status: scenario==='rolled-back'?'rolled_back':scenario==='mixed-webapp'?'failed':scenario==='superseded-webapp'?'superseded':'deploying', created: new Date().toISOString(), finished: ''
353
+ };
354
+ if(!['rolled_back','superseded'].includes(a.status))currentReleases.set(r.app,release.id);
355
+ attempts.unshift(a);
356
+ if(!releases.some(x=>x.id===release.id))releases.unshift(release);
357
+ r.deployment = a.id;
358
+ return ok({
359
+ release: release.id, version: r.version, status: 'uploaded', promoted: true, deployment: a.id, deployment_status: a.status, webapp: r.app
360
+ });
361
+ }
362
+ return no('Unsupported deployment method', 405);
363
+ }
364
+ function statusPayload(a: typeof attempts[number]) { const status = a.status; return {
365
+ deployment: a.id, webapp: a.webapp, release: a.release.id, version: a.release.version, status, terminal: ['live', 'failed', 'rolled_back', 'superseded'].includes(status), success: status === 'live', detail: status === 'rolled_back' ? 'Attempted release failed; rollback observed' : scenario === 'mixed-webapp' ? 'Partial node proof; mixed fleet possible' : 'Fixture observation', created: a.created, finished: a.finished, targets: [{ runner: 'node-a', status: status === 'live' ? 'succeeded' : 'unknown' }], rollback_targets: []
366
+ }; }
367
+ /** Secondary storage simulator receives the exact anonymous PUT contract, never an API token. */
368
+ export async function deploymentMockStorageFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { const url = new URL(String(input)); if (url.origin !== 'https://deployment-storage.invalid')
369
+ throw new Error('Unowned mock storage destination'); if (init?.method !== 'PUT' || init.credentials !== 'omit' || new Headers(init.headers).has('Authorization'))
370
+ return new Response('', { status: 403 }); if (scenario === 'upload-failure')
371
+ throw new TypeError('Storage CORS refused'); const [, id, path] = url.pathname.split('/'); const manifest = uploadManifests.get(Number(id)); if (!manifest || !path)
372
+ return new Response('', { status: 404 }); const target = manifest.manifest.find(e => e.path === decodeURIComponent(path)); if (!target)
373
+ return new Response('', { status: 400 }); const body = init.body; if (!(body instanceof Blob) || body.size !== target.size)
374
+ return new Response('', { status: 400 }); const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', await body.arrayBuffer()))).map(x => x.toString(16).padStart(2, '0')).join(''); const expected = btoa(String.fromCharCode(...Uint8Array.from(target.sha256.match(/../g) ?? [], x => parseInt(x, 16)))); if (hash !== target.sha256 || new Headers(init.headers).get('x-amz-checksum-sha256') !== expected)
375
+ return new Response('', { status: 400 }); uploadedPaths.add(`${id}/${target.path}`); return new Response('', { status: 200 }); }
@@ -0,0 +1,37 @@
1
+ /** Focused mock-only registrar checks, loaded only for registrar operations. */
2
+ const normalize = (value: unknown) => typeof value === 'string' ? value.trim().toLowerCase().replace(/\.+$/, '') : '';
3
+ const cents = (value: unknown) => {
4
+ if (typeof value !== 'string' && typeof value !== 'number') return null;
5
+ const match = String(value).trim().match(/^(\d+)(?:\.(\d{1,2}))?$/);
6
+ return match ? BigInt(match[1]!) * 100n + BigInt((match[2] ?? '').padEnd(2, '0')) : null;
7
+ };
8
+ interface Quote { group: number | null; user: number | null; domain_name: string; price: string | number | null }
9
+ /** No token or submitted body is retained by this validator. */
10
+ export function validMockPurchaseTuple(quote: Quote, body: Record<string, unknown>, actor: number): boolean {
11
+ const price = cents(body.confirm_price), expected = cents(quote.price);
12
+ return quote.user === actor && Number(body.group) === quote.group && normalize(body.confirm_domain) === quote.domain_name && price !== null && expected !== null && price === expected;
13
+ }
14
+ export function mockRegistrarSession(claims: Record<string, unknown> | null, fresh: boolean): 'denied' | 'stale' | null {
15
+ const kind = claims?.credential_kind ?? claims?.token_type;
16
+ if (typeof kind === 'string' && ['api_key', 'group_scoped_token', 'group_token'].includes(kind)) return 'denied';
17
+ if (fresh && (typeof claims?.auth_time !== 'number' || !Number.isFinite(claims.auth_time) || claims.auth_time > Date.now() / 1000 || Date.now() / 1000 - claims.auth_time > 600)) return 'stale';
18
+ return null;
19
+ }
20
+ export function mockDomainRegistrationName(value: unknown): string | null {
21
+ const name = normalize(value);
22
+ return /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name) ? name : null;
23
+ }
24
+
25
+ let credentialsPrepared = false;
26
+ /** Extra picker fixtures stay out of the eager seeded-mock chunk. */
27
+ export function prepareMockDomainCredentials(rows: Record<string, unknown>[]): void {
28
+ if (credentialsPrepared) return;
29
+ credentialsPrepared = true;
30
+ const now = Math.floor(Date.now() / 1000);
31
+ const base = { ...rows[0], created: now, modified: now, verified: true, verified_at: now, domain_count: 0, last_error: null };
32
+ rows.push(
33
+ { ...base, id: 8104, group: 2, name: 'Engineering verified', provider: 'godaddy', is_active: true, domain_count: 1, api_key_masked: '********P4Q5', api_secret_masked: '********R6S7' },
34
+ { ...base, id: 8105, group: 1, name: 'Unsupported registrar credential', provider: 'route53', is_active: true, api_key_masked: '', api_secret_masked: '' },
35
+ { ...base, id: 8106, group: 1, name: 'Inactive GoDaddy credential', provider: 'godaddy', is_active: false, api_key_masked: '********T8U9', api_secret_masked: '********V1W2' },
36
+ );
37
+ }
@@ -0,0 +1,82 @@
1
+ /** Fixture-only GeoLocation provider setup. No submitted credential is retained or observed. */
2
+ import type { GeoDraft, GeoLocationState, GeoVerdict, StoredGeoVerification } from '../admin/geolocation/data';
3
+ export const mockGeoProviderScenarios = ['ready', 'no-key', 'unknown-provider', 'empty-registry', 'unavailable', 'remote-error', 'rejected-key', 'missing-sync-permission', 'changed-origin', 'stale-revision', 'pending-restart', 'published-origin-pending', 'missing-delegation', 'old-api', 'malformed', 'fresh-auth', 'save-uncertain'] as const;
4
+ export type MockGeoProviderScenario = typeof mockGeoProviderScenarios[number];
5
+ const endpoint = '/api/account/admin/settings';
6
+ const keys = ['GEOIP_PRIMARY_PROVIDER', 'GEOIP_FALLBACK_PROVIDER', 'GEOIP_ADDITIONAL_PROVIDERS', 'GEOIP_MOJO_PROVIDER_URL', 'GEOIP_MOJO_SYNC_ENABLED'];
7
+ const defaults: GeoDraft = { GEOIP_PRIMARY_PROVIDER: 'mojo', GEOIP_FALLBACK_PROVIDER: 'ipinfo', GEOIP_ADDITIONAL_PROVIDERS: ['ip-api', 'maxmind'], GEOIP_MOJO_PROVIDER_URL: 'https://api.mojoverify.com', GEOIP_MOJO_SYNC_ENABLED: false };
8
+ const metadata = [
9
+ ['Primary GeoIP provider', 'Provider queried first for IP intelligence.', 'string', 'Provider identifier'],
10
+ ['Fallback GeoIP provider', 'Provider used when the primary cannot answer.', 'string', 'Provider identifier'],
11
+ ['Additional GeoIP providers', 'Extra providers available after primary and fallback.', 'list', 'Unique provider identifiers'],
12
+ ['Mojo GeoIP URL', 'Canonical HTTPS origin of the upstream django-mojo provider.', 'origin', 'One HTTPS origin'],
13
+ ['Mojo GeoIP sync', 'Push observed abuse signals back to the upstream provider.', 'boolean', ''],
14
+ ];
15
+ let scenario: MockGeoProviderScenario = 'ready', sequence = 1, publishedVersion = 1, usedFault = false;
16
+ let configurationRevision = '1'.repeat(32), publishedRevision = '1'.repeat(32), loadedRevision = '1'.repeat(32);
17
+ let values: GeoDraft = structuredClone(defaults), loadedOrigin = defaults.GEOIP_MOJO_PROVIDER_URL, loadedSync = false;
18
+ let keyConfigured = true, keyHint = '9f2c', stored: StoredGeoVerification | undefined;
19
+ const observations: { action: string; key_action: 'keep' | 'replace' | 'clear'; stored_test: boolean; expected_revision: string | null }[] = [];
20
+ export function setMockGeoProviderScenario(value: MockGeoProviderScenario): void {
21
+ if (!mockGeoProviderScenarios.includes(value)) throw new Error('Unknown GeoLocation fixture');
22
+ scenario = value; usedFault = false; sequence = 1; publishedVersion = 1; configurationRevision = publishedRevision = loadedRevision = '1'.repeat(32);
23
+ values = structuredClone(defaults); loadedOrigin = defaults.GEOIP_MOJO_PROVIDER_URL; loadedSync = false; keyConfigured = true; keyHint = '9f2c'; observations.length = 0;
24
+ stored = { ok: true, code: null, message: 'Connection verified', at: '2026-10-21T12:00:00Z' };
25
+ if (value === 'no-key') { keyConfigured = false; keyHint = ''; stored = undefined; }
26
+ if (value === 'unknown-provider') { values.GEOIP_PRIMARY_PROVIDER = 'custom_geo'; values.GEOIP_ADDITIONAL_PROVIDERS = ['custom_extra', 'maxmind']; }
27
+ if (['pending-restart', 'published-origin-pending', 'changed-origin'].includes(value)) { publishedRevision = configurationRevision = '2'.repeat(32); }
28
+ if (['published-origin-pending', 'changed-origin'].includes(value)) loadedOrigin = 'https://legacy-geo.example.invalid';
29
+ }
30
+ export const getMockGeoProviderScenario = (): MockGeoProviderScenario => scenario;
31
+ export const getMockGeoProviderObservations = () => observations.map(row => ({ ...row }));
32
+ export const clearMockGeoProviderObservations = () => { observations.length = 0; };
33
+ export function geoProviderSetupMock(): GeoLocationState {
34
+ return { available: scenario !== 'unavailable', bucket_configured: scenario !== 'unavailable', restart_configured: true, object_key: 'fixture/config/django.override.json',
35
+ delegated_keys: keys.filter(key => scenario !== 'missing-delegation' || key !== keys[2]), fleet_fields: keys.map((key, i) => ({ key, label: metadata[i]![0]!, description: metadata[i]![1]!, value_type: metadata[i]![2]!, constraints: metadata[i]![3]! })),
36
+ loaded_revision: loadedRevision, published_revision: publishedRevision, configuration_revision: configurationRevision, published_version: `fixture-version-${publishedVersion}`, pending_restart: publishedRevision !== loadedRevision,
37
+ remote_error: scenario === 'remote-error' ? 'FixtureRemoteReadError' : null, geoip: { ...structuredClone(values), GEOIP_API_KEY_MOJO_CONFIGURED: keyConfigured, GEOIP_API_KEY_MOJO_HINT: keyHint },
38
+ geoip_providers: scenario === 'empty-registry' ? [] : ['maxmind', 'ipinfo', 'ipstack', 'ip-api', 'mojo'], verify_state: stored ? { geoip: { ...stored } } : {} };
39
+ }
40
+ const fail = (error: string, code = 400) => ({ status: false, code, error_code: code, error });
41
+ const provider = (v: unknown): v is string => typeof v === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(v);
42
+ function origin(v: unknown): v is string {
43
+ if (typeof v !== 'string' || v.length > 512) return false;
44
+ try { const u = new URL(v); return u.protocol === 'https:' && !!u.hostname && !u.username && !u.password && !u.search && !u.hash && ['', '/'].includes(u.pathname) && (!u.port || u.port === '443'); } catch { return false; }
45
+ }
46
+ const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
47
+ /** Undefined means another topic/route owns the request. The central mock supplies session gates. */
48
+ export function geoProviderMock(path: string, method: string, body: Record<string, unknown> | undefined, superuser: boolean): unknown {
49
+ if (path !== endpoint) return undefined;
50
+ if (method !== 'GET' && body?.topic !== 'geoip') return undefined;
51
+ if (!superuser) return fail('Superuser required', 403);
52
+ if (scenario === 'old-api') return fail('Not found', 404);
53
+ if (method === 'GET') return { status: true, data: { schema_version: scenario === 'malformed' ? 999 : 1, provider_setup: geoProviderSetupMock() } };
54
+ if (method !== 'POST' || !body || Object.keys(body).sort().join(',') !== 'action,providers,topic' || !['test_providers', 'configure_providers'].includes(String(body.action))) return fail('Provider setup accepts only action, topic, and providers');
55
+ const p = body.providers;
56
+ if (!record(p) || Object.keys(p).some(k => !['expected_revision', 'geoip'].includes(k)) || !record(p.geoip)) return fail('Provider setup accepts only geoip and expected_revision');
57
+ const g = p.geoip;
58
+ if (Object.keys(g).some(k => ![...keys, 'GEOIP_API_KEY_MOJO', 'clear_api_key'].includes(k))) return fail('GeoIP setup contains unsupported fields');
59
+ const candidate = { ...defaults, GEOIP_ADDITIONAL_PROVIDERS: [], GEOIP_MOJO_PROVIDER_URL: 'https://api.mojoverify.com', ...g };
60
+ if (!provider(candidate.GEOIP_PRIMARY_PROVIDER) || !provider(candidate.GEOIP_FALLBACK_PROVIDER) || !Array.isArray(candidate.GEOIP_ADDITIONAL_PROVIDERS) || candidate.GEOIP_ADDITIONAL_PROVIDERS.length > 16 || !candidate.GEOIP_ADDITIONAL_PROVIDERS.every(provider) || new Set(candidate.GEOIP_ADDITIONAL_PROVIDERS).size !== candidate.GEOIP_ADDITIONAL_PROVIDERS.length || !origin(candidate.GEOIP_MOJO_PROVIDER_URL) || typeof candidate.GEOIP_MOJO_SYNC_ENABLED !== 'boolean') return fail('Invalid GeoIP provider settings');
61
+ if (p.expected_revision != null && (typeof p.expected_revision !== 'string' || !/^[a-f0-9]{32,64}$/.test(p.expected_revision))) return fail('Provider setup revision is invalid');
62
+ if (g.GEOIP_API_KEY_MOJO != null && (typeof g.GEOIP_API_KEY_MOJO !== 'string' || g.GEOIP_API_KEY_MOJO.length > 4096)) return fail('GeoIP API key is invalid');
63
+ if (scenario === 'missing-delegation' || body.action === 'configure_providers' && ['unavailable', 'remote-error'].includes(scenario)) return fail('Fleet publishing requires its exact object, all provider delegations, a KMS key, and config-sync restart');
64
+ if (scenario === 'fresh-auth' && !usedFault) { usedFault = true; return fail('Fresh authentication required', 440); }
65
+ const next: GeoDraft = { GEOIP_PRIMARY_PROVIDER: candidate.GEOIP_PRIMARY_PROVIDER, GEOIP_FALLBACK_PROVIDER: candidate.GEOIP_FALLBACK_PROVIDER, GEOIP_ADDITIONAL_PROVIDERS: [...candidate.GEOIP_ADDITIONAL_PROVIDERS], GEOIP_MOJO_PROVIDER_URL: candidate.GEOIP_MOJO_PROVIDER_URL.replace(/\/$/, ''), GEOIP_MOJO_SYNC_ENABLED: candidate.GEOIP_MOJO_SYNC_ENABLED };
66
+ const clear = g.clear_api_key === true, typed = typeof g.GEOIP_API_KEY_MOJO === 'string' && g.GEOIP_API_KEY_MOJO !== '';
67
+ // The real predicate ignores primary/fallback/additional differences and compares NODE-LOADED connection facts.
68
+ const testedStored = !clear && !typed && next.GEOIP_MOJO_PROVIDER_URL === loadedOrigin && next.GEOIP_MOJO_SYNC_ENABLED === loadedSync;
69
+ observations.push({ action: String(body.action), key_action: clear ? 'clear' : typed ? 'replace' : 'keep', stored_test: testedStored, expected_revision: p.expected_revision == null ? null : String(p.expected_revision) });
70
+ const accepted = scenario !== 'rejected-key' && (typed ? !String(g.GEOIP_API_KEY_MOJO).startsWith('rejected') : keyConfigured && next.GEOIP_MOJO_PROVIDER_URL === loadedOrigin);
71
+ const result: GeoVerdict = clear ? { success: true, code: 'clear_requested', message: 'GeoIP credential will be cleared' } : accepted && next.GEOIP_MOJO_SYNC_ENABLED && scenario === 'missing-sync-permission' ? { success: false, code: 'insufficient_permission', message: `${new URL(next.GEOIP_MOJO_PROVIDER_URL).hostname} accepted the key, but it is missing geoip_sync` } : accepted ? { success: true, code: null, message: 'Connection verified' } : { success: false, code: 'http_401', message: `${new URL(next.GEOIP_MOJO_PROVIDER_URL).hostname} rejected the API key` };
72
+ const remember = () => { stored = { ok: result.success, code: result.code, message: result.message, at: new Date().toISOString() }; };
73
+ if (body.action === 'test_providers') { if (testedStored) remember(); return { status: true, tested: true, topic: 'geoip', results: { geoip: result }, success: result.success }; }
74
+ if (!result.success) return fail('Provider verification failed for geoip');
75
+ if (p.expected_revision !== configurationRevision || scenario === 'stale-revision' && !usedFault) { usedFault = true; configurationRevision = '3'.repeat(32); return fail('Provider configuration changed; reload before publishing'); }
76
+ const changed = JSON.stringify(next) !== JSON.stringify(values);
77
+ if (clear) { keyConfigured = false; keyHint = ''; } else if (typed) { keyConfigured = true; keyHint = String(g.GEOIP_API_KEY_MOJO).length >= 8 ? String(g.GEOIP_API_KEY_MOJO).slice(-4) : ''; }
78
+ values = next; configurationRevision = (++sequence).toString(16).padStart(32, '0'); if (changed) { publishedRevision = configurationRevision; publishedVersion++; } remember();
79
+ if (scenario === 'save-uncertain' && !usedFault) { usedFault = true; return fail('Connection unavailable after persistence', 503); }
80
+ return { status: true, published: changed, unchanged: !changed, topic: 'geoip', revision: configurationRevision, published_revision: publishedRevision, version_id: `fixture-version-${publishedVersion}`, pending_restart: publishedRevision !== loadedRevision, results: { geoip: result } };
81
+ }
82
+ setMockGeoProviderScenario('ready');