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.
- package/docs/README.md +7 -1
- package/docs/admin-credentials.md +27 -0
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +160 -0
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +18 -0
- package/docs/admin-edge.md +200 -196
- package/docs/admin-fleet-configuration.md +98 -2
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-identity-directories.md +12 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +45 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/auth-pages.md +18 -6
- package/docs/auth.md +16 -2
- package/docs/client.md +7 -0
- package/docs/collection-select.md +14 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
- package/src/admin/credentials/acme-consumer-key.ts +34 -0
- package/src/admin/credentials/group-api-keys.tsx +5 -3
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/CertificateDetail.tsx +8 -3
- package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
- package/src/admin/dns/DomainDetail.tsx +14 -4
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +26 -13
- package/src/admin/dns/SharedValidationDialog.tsx +298 -0
- package/src/admin/dns/SharedValidationPanel.tsx +76 -0
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/dns/shared-validation-api.ts +90 -0
- package/src/admin/dns/shared-validation-data.ts +106 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +27 -10
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
- package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
- package/src/admin/fleet-configuration/data.ts +4 -0
- package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
- package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
- package/src/admin/identity/groups/models.ts +2 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/auth.ts +77 -25
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/fleet-mock.ts +73 -1
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/index.ts +2 -2
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +280 -11
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-dns-certificates.css +33 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/CollectionSelect.tsx +30 -8
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- 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
|
+
}
|
package/src/client/fleet-mock.ts
CHANGED
|
@@ -7,6 +7,7 @@ const entries: FleetEntry[] = [
|
|
|
7
7
|
{ key: 'EXAMPLE_WORKER_LIMIT', label: 'Worker limit', section: 'Application', description: 'Example application-owned setting.', value_type: 'integer', default: 4, current: 4, min_value: 1, max_value: 32, sensitive: false, restart_required: true, overridden: true },
|
|
8
8
|
{ key: 'EXAMPLE_SERVICE_TOKEN', label: 'Service token', section: 'Application', description: 'Write-only credential for the example service.', value_type: 'string', sensitive: true, configured: true, restart_required: true, overridden: true },
|
|
9
9
|
];
|
|
10
|
+
for (const [key, label] of [['DNSMAN_ACME_HUB_PROFILE_ID', 'Approved hub profile'], ['DNSMAN_ACME_HUB_URL', 'Approved hub origin'], ['DNSMAN_ACME_HUB_API_KEY', 'Protected recipient credential']]) entries.push({ key, label, section: 'Shared validation', description: 'Managed connection; use its protected topic.', value_type: 'string', sensitive: key.endsWith('API_KEY'), configured: true, restart_required: true, overridden: false });
|
|
10
11
|
let sequence = 1;
|
|
11
12
|
let revision = sequence.toString(16).padStart(32, '0');
|
|
12
13
|
let values: Record<string, unknown> = { GEOIP_SMART_ENABLED: true, EXAMPLE_WORKER_LIMIT: 4, EXAMPLE_SERVICE_TOKEN: 'mock-private-value' };
|
|
@@ -19,6 +20,8 @@ const ok = (data: unknown) => ({ status: true, data });
|
|
|
19
20
|
const fail = (error: string, error_code = 400) => ({ status: false, error, error_code });
|
|
20
21
|
export function fleetMock(path: string, method: string, body: Record<string, unknown> | undefined, superuser: boolean): unknown {
|
|
21
22
|
if (!superuser) return fail('Superuser required', 403);
|
|
23
|
+
if (path === `${endpoint}/shared-validation/hub`) return hubMock(method, body);
|
|
24
|
+
if (path === `${endpoint}/shared-validation`) return connectionMock(method, body);
|
|
22
25
|
if (method === 'GET' && path === endpoint) {
|
|
23
26
|
const state: FleetState = { schema_version: 1, revision, version_id: versions[0].version_id, entries: entries.map(entry => ({ ...entry,
|
|
24
27
|
overridden: entry.key in values, ...(entry.sensitive ? { configured: Boolean(values[entry.key]) } : { current: values[entry.key] ?? entry.default }) })),
|
|
@@ -27,11 +30,15 @@ export function fleetMock(path: string, method: string, body: Record<string, unk
|
|
|
27
30
|
return ok(state);
|
|
28
31
|
}
|
|
29
32
|
if (method === 'GET' && path === `${endpoint}/history`) return ok({ versions: versions.map((version, index) => ({ version_id: version.version_id, current: index === 0, published_at: version.published_at })), truncated: false });
|
|
30
|
-
if (method === 'GET' && path === `${endpoint}/operation/${operation?.operation_id}`)
|
|
33
|
+
if (method === 'GET' && path === `${endpoint}/operation/${operation?.operation_id}`) {
|
|
34
|
+
if (connectionApplied && operation) { operation = { ...operation, status: ++applyReads >= 3 && scenario !== 'offline' ? 'healthy' : 'pending', nodes: connectionNodes(applyReads >= 3), healthy_everywhere: applyReads >= 3 && !['offline', 'stale-proof', 'draining'].includes(scenario) }; }
|
|
35
|
+
return ok(operation);
|
|
36
|
+
}
|
|
31
37
|
if (method !== 'POST' || path !== endpoint || !body) return fail('Not found', 404);
|
|
32
38
|
if (body.expected_revision !== revision) return fail('Fleet configuration changed; reload before publishing', 409);
|
|
33
39
|
if (body.action === 'apply') {
|
|
34
40
|
operation = { health_scope, operation_id: 'a'.repeat(32), revision, status: 'timed_out', job_status: 'completed', nodes: nodes(revision), healthy_everywhere: false };
|
|
41
|
+
if (connectionPublished) { connectionApplied = true; applyReads = 0; operation = { ...operation, status: 'pending', nodes: connectionNodes(false) }; }
|
|
35
42
|
return ok(operation);
|
|
36
43
|
}
|
|
37
44
|
if (body.action === 'restore') {
|
|
@@ -43,6 +50,7 @@ export function fleetMock(path: string, method: string, body: Record<string, unk
|
|
|
43
50
|
if (!changes || !Object.keys(changes).length) return fail('Changes are required');
|
|
44
51
|
const next = { ...values };
|
|
45
52
|
for (const [key, change] of Object.entries(changes)) {
|
|
53
|
+
if (key.startsWith('DNSMAN_ACME_HUB_')) return connectionFail('hub_identity_migration_required', 409);
|
|
46
54
|
const entry = entries.find(item => item.key === key);
|
|
47
55
|
if (!entry) return fail('Setting is not delegated');
|
|
48
56
|
if (change.action === 'clear') delete next[key];
|
|
@@ -59,3 +67,67 @@ export function fleetMock(path: string, method: string, body: Record<string, unk
|
|
|
59
67
|
versions.unshift({ version_id: `mock-version-${sequence}`, revision, values: { ...values }, published_at: new Date().toISOString() });
|
|
60
68
|
return ok({ published: true, revision, version_id: versions[0].version_id, pending_restart: true, applied: false });
|
|
61
69
|
}
|
|
70
|
+
|
|
71
|
+
export const mockConnectionScenarios = ['ready', 'old-api', 'unavailable', 'dnsman-absent', 'empty-catalog', 'sharing-disabled', 'identity-mismatch', 'busy', 'catalog-conflict', 'revision-conflict', 'publication-uncertain', 'offline', 'draining', 'stale-proof', 'malformed', 'recipient-denied', 'recipient-renamed', 'issue-uncertain', 'reauth', 'disable-ineligible', 'hub-ready', 'hub-unconfigured', 'hub-no-deployment', 'hub-upgrade-required', 'hub-incomplete', 'hub-conflict', 'hub-reauth', 'hub-uncertain', 'legacy-project-identity'] as const;
|
|
72
|
+
export type MockConnectionScenario = typeof mockConnectionScenarios[number];
|
|
73
|
+
let scenario: MockConnectionScenario = 'ready', faultUsed = false, connectionPublished = false, connectionApplied = false, applyReads = 0;
|
|
74
|
+
let profile = { id: 'approved_hub', label: 'Approved existing validation hub', origin: 'https://hub.example', consumer_project_uuid: '1'.repeat(32), consumer_label: 'Original recipient' };
|
|
75
|
+
let catalogRevision = 'a'.repeat(64), credential = 'mock_protected_consumer', disabled = false;
|
|
76
|
+
let hubEnabled = true;
|
|
77
|
+
const hubDeployment = '99999999-9999-4999-8999-999999999999';
|
|
78
|
+
const keyIdentities = new Map<string, string>();
|
|
79
|
+
export function setMockConnectionScenario(value: MockConnectionScenario) {
|
|
80
|
+
if (!mockConnectionScenarios.includes(value)) throw new Error('Unknown mock connection scenario');
|
|
81
|
+
scenario = value; faultUsed = false; connectionPublished = false; connectionApplied = false; applyReads = 0; disabled = false; credential = 'mock_protected_consumer'; catalogRevision = 'a'.repeat(64); operation = null;
|
|
82
|
+
hubEnabled = value !== 'sharing-disabled' && value !== 'empty-catalog' && !value.startsWith('hub-');
|
|
83
|
+
}
|
|
84
|
+
export const getMockConnectionScenario = () => scenario;
|
|
85
|
+
export function configureMockHubRecipient(consumer_project_uuid: string, consumer_label: string) { profile = { ...profile, consumer_project_uuid, consumer_label }; }
|
|
86
|
+
export function registerMockConsumerKey(secret: string, consumer: string) { keyIdentities.set(secret, consumer); }
|
|
87
|
+
export const consumeMockConnectionFault = (value: MockConnectionScenario) => { if (scenario !== value || faultUsed) return false; faultUsed = true; return true; };
|
|
88
|
+
export const connectionFail = (code: string, status: number) => ({ status: false, code: status, error_code: code, error: code });
|
|
89
|
+
const observed = (includeProfile = true) => ({ state: disabled ? 'disabled' : 'verified', ...(includeProfile ? { profile_id: disabled ? null : profile.id } : {}), observed_project_uuid: disabled ? null : profile.consumer_project_uuid, checked_at: new Date().toISOString(), error_code: null, sharing_ready: !disabled && hubEnabled });
|
|
90
|
+
function hubMock(method: string, body?: Record<string, unknown>) {
|
|
91
|
+
if (scenario === 'old-api') return connectionFail('not_found', 404);
|
|
92
|
+
const configured = scenario !== 'hub-unconfigured';
|
|
93
|
+
const reason = !configured ? 'hub_unconfigured' : scenario === 'hub-no-deployment' ? 'deployment_required' : scenario === 'hub-upgrade-required' ? 'framework_upgrade_required' : scenario === 'hub-incomplete' ? 'deployment_proof_unavailable' : null;
|
|
94
|
+
if (method === 'POST') {
|
|
95
|
+
if (!body || Object.keys(body).length !== 1 || typeof body.deployment_uuid !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(body.deployment_uuid)) return connectionFail('invalid_request', 400);
|
|
96
|
+
if (consumeMockConnectionFault('hub-reauth')) return connectionFail('fresh_auth_required', 440);
|
|
97
|
+
if (body.deployment_uuid !== hubDeployment || consumeMockConnectionFault('hub-conflict')) return connectionFail('deployment_changed', 409);
|
|
98
|
+
if (reason) return connectionFail(reason, 409);
|
|
99
|
+
hubEnabled = true;
|
|
100
|
+
if (consumeMockConnectionFault('hub-uncertain')) return connectionFail('hub_unavailable', 503);
|
|
101
|
+
} else if (method !== 'GET') return connectionFail('not_found', 404);
|
|
102
|
+
return ok({ version: 1, enabled: hubEnabled, configured, deployment_uuid: scenario === 'hub-no-deployment' ? null : hubDeployment, can_enable: !hubEnabled && !reason, reason: hubEnabled ? null : reason });
|
|
103
|
+
}
|
|
104
|
+
function connectionNodes(healthy: boolean) {
|
|
105
|
+
return nodes(revision, healthy).map((n, index) => ({ ...n, ...(scenario === 'offline' && index === 1 ? { installed: false, restarted: false, healthy: false, status: 'unknown', error_code: 'node_did_not_reply' } : {}), ...(scenario === 'draining' ? { healthy: false, status: 'draining', error_code: 'engine_draining' } : {}), shared_validation: n.installed ? { ...observed(), ...(scenario === 'stale-proof' ? { observed_project_uuid: '0'.repeat(32) } : {}) } : null }));
|
|
106
|
+
}
|
|
107
|
+
function connectionMock(method: string, body?: Record<string, unknown>) {
|
|
108
|
+
if (scenario === 'old-api') return connectionFail('not_found', 404);
|
|
109
|
+
if (method === 'GET') {
|
|
110
|
+
const unavailable = ['unavailable', 'dnsman-absent', 'empty-catalog'].includes(scenario);
|
|
111
|
+
const report = { status: connectionApplied ? (applyReads >= 3 ? 'healthy' : 'pending') : 'pending', nodes: connectionNodes(connectionApplied && applyReads >= 3), healthy_everywhere: connectionApplied && applyReads >= 3 && !['offline', 'draining', 'stale-proof'].includes(scenario), health_scope, observed_at: new Date().toISOString() };
|
|
112
|
+
return ok({ schema_version: scenario === 'malformed' ? 99 : 1, available: !unavailable, unavailable_reasons: unavailable ? [scenario === 'dnsman-absent' ? 'dnsman_unavailable' : scenario === 'empty-catalog' ? 'profile_catalog_unavailable' : 'publisher_unavailable'] : [], catalog_revision: catalogRevision, profiles: scenario === 'empty-catalog' ? [] : [profile], selected_profile_id: disabled || scenario === 'empty-catalog' ? null : profile.id, expected_identity: disabled || scenario === 'empty-catalog' ? null : { consumer_project_uuid: profile.consumer_project_uuid, consumer_label: profile.consumer_label }, loaded_identity: observed(), credential: { configured: !!credential, source: connectionPublished ? 'published' : 'static', overridden: connectionPublished }, revision, loaded_revision: connectionApplied && applyReads >= 3 ? revision : null, pending_restart: !connectionApplied || applyReads < 3, fleet: report, setup_destination: { route: 'setup', section: 'aws', capacity_route: 'capacity', actor: 'deployment_operator', adoption_item: 8903 } });
|
|
113
|
+
}
|
|
114
|
+
if (method !== 'POST' || !body || Object.keys(body).some((key) => !['action', 'expected_revision', 'expected_catalog_revision', 'profile_id', 'key_action', 'replacement_key'].includes(key)) || !['preview', 'publish'].includes(String(body.action)) || !['keep', 'replace', 'clear'].includes(String(body.key_action)) || 'replacement_key' in body && body.key_action !== 'replace') return connectionFail('invalid_request', 400);
|
|
115
|
+
if (consumeMockConnectionFault('reauth')) return connectionFail('fresh_auth_required', 440);
|
|
116
|
+
if (consumeMockConnectionFault('busy')) return connectionFail('configuration_busy', 409);
|
|
117
|
+
if (consumeMockConnectionFault('catalog-conflict')) { catalogRevision = 'b'.repeat(64); return connectionFail('profile_catalog_changed', 409); }
|
|
118
|
+
if (consumeMockConnectionFault('revision-conflict')) { revision = (++sequence).toString(16).padStart(32, '0'); return connectionFail('fleet_revision_changed', 409); }
|
|
119
|
+
if (body.expected_revision !== revision) return connectionFail('fleet_revision_changed', 409);
|
|
120
|
+
if (body.expected_catalog_revision !== catalogRevision) return connectionFail('profile_catalog_changed', 409);
|
|
121
|
+
if (body.profile_id !== profile.id) return connectionFail('profile_not_approved', 400);
|
|
122
|
+
if (['unavailable', 'dnsman-absent', 'empty-catalog'].includes(scenario)) return connectionFail('publisher_unavailable', 503);
|
|
123
|
+
if (body.key_action === 'clear' && scenario === 'disable-ineligible') return connectionFail('connection_disable_ineligible', 409);
|
|
124
|
+
const replacement = body.key_action === 'replace' ? String(body.replacement_key ?? '') : '';
|
|
125
|
+
if (scenario === 'identity-mismatch' || replacement && (replacement === 'wrong-project-key' || keyIdentities.has(replacement) && keyIdentities.get(replacement) !== profile.consumer_project_uuid)) return connectionFail('hub_identity_mismatch', 400);
|
|
126
|
+
const wasDisabled = disabled; disabled = body.key_action === 'clear';
|
|
127
|
+
const result = { can_publish: true, revision, catalog_revision: catalogRevision, profile_id: disabled ? null : profile.id, expected_identity: disabled ? null : { consumer_project_uuid: profile.consumer_project_uuid, consumer_label: profile.consumer_label }, identity: observed(false), credential_changed: disabled ? !!credential : !!replacement && replacement !== credential, consequences: [disabled ? 'connection_disabled' : 'restart_required', 'apply_and_verify_every_expected_node', 'old_key_retirement_is_separate'] };
|
|
128
|
+
if (body.action === 'preview') { disabled = wasDisabled; return ok(result); }
|
|
129
|
+
credential = disabled ? '' : replacement || credential; connectionPublished = true; connectionApplied = false; revision = (++sequence).toString(16).padStart(32, '0');
|
|
130
|
+
versions.unshift({ version_id: `mock-version-${sequence}`, revision, values: { ...values }, published_at: new Date().toISOString() });
|
|
131
|
+
if (consumeMockConnectionFault('publication-uncertain')) return connectionFail('publication_uncertain', 503);
|
|
132
|
+
return ok({ published: true, revision, version_id: versions[0].version_id, pending_restart: true, applied: false });
|
|
133
|
+
}
|