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,486 @@
1
+ /** Narrow Maintenance protocol boundary; no mutation cache, retry or replay. */
2
+ import { apiOrigin, usingMockTransport } from '../../client/client';
3
+ import { getAuthSessionRevision, getAuthSnapshot, mojoCall, MojoError } from '../../client/runtime';
4
+ import { backendGlobalCan, type BackendGlobalUser } from '../fleet/access';
5
+ export const MAINTENANCE_ENDPOINTS = {
6
+ versions: '/api/aws/maintenance/versions',
7
+ status: '/api/aws/maintenance/status',
8
+ apply: '/api/aws/maintenance/apply',
9
+ framework: '/api/account/admin/platform/framework',
10
+ update: '/api/account/admin/platform/framework/update',
11
+ bootstrap: '/api/account/admin/bootstrap',
12
+ policy: '/api/account/admin/advanced/settings',
13
+ platform: '/api/account/admin/platform',
14
+ } as const;
15
+ export type Kind = 'rds-instance' | 'rds-cluster' | 'elasticache';
16
+ export interface Finding {
17
+ kind: Kind;
18
+ resource_id: string;
19
+ engine: string;
20
+ current_version: string;
21
+ available_major: string;
22
+ deadline: string | null;
23
+ extended_deadline: string | null;
24
+ days_remaining: number | null;
25
+ note: string | null;
26
+ release_notes_url: string | null;
27
+ status: string | null;
28
+ pending_version: string | null;
29
+ settled: boolean | null;
30
+ members?: number;
31
+ engine_versions?: string[];
32
+ }
33
+ export interface Report {
34
+ schema_version: 1;
35
+ generated_at: string | null;
36
+ region: string | null;
37
+ status: 'ok' | 'unavailable';
38
+ level: number;
39
+ scheduled: boolean;
40
+ findings: Finding[];
41
+ warnings: { iam_action: string; aws_code: string | null }[];
42
+ reason: string | null;
43
+ }
44
+ export interface ResourceStatus {
45
+ schema_version: 1;
46
+ kind: Kind;
47
+ resource: string;
48
+ target_version: string;
49
+ found: boolean;
50
+ status: string | null;
51
+ engine_version: string | null;
52
+ engine_versions: string[];
53
+ pending_version: string | null;
54
+ settled: boolean;
55
+ upgraded: boolean;
56
+ }
57
+ export interface Framework {
58
+ schema_version: 1;
59
+ installed: string | null;
60
+ latest: string | null;
61
+ checked_at: string | number | null;
62
+ source: 'pypi' | 'cache' | 'unavailable';
63
+ update_available: boolean;
64
+ pin: { mode: 'latest' | 'hold' | 'pinned'; value: string | null };
65
+ can_update: boolean;
66
+ blocked_reason:
67
+ | 'update_unavailable'
68
+ | 'requires_superuser'
69
+ | 'no_converged_deployment'
70
+ | 'infrastructure_external'
71
+ | null;
72
+ }
73
+ export interface ApplyInput {
74
+ kind: Kind;
75
+ resource: string;
76
+ target_version: string;
77
+ confirm_resource: string;
78
+ apply_immediately: boolean;
79
+ }
80
+ export interface Scope {
81
+ signal?: AbortSignal;
82
+ beforeSend?: () => void;
83
+ }
84
+ export class MaintenanceError extends Error {
85
+ constructor(
86
+ public code: string,
87
+ public mutationState: 'none' | 'attempted' | 'unknown' = 'none',
88
+ public failure?: {
89
+ operation: string;
90
+ provider_code: string;
91
+ retryable: boolean;
92
+ mutation_state: string;
93
+ iam_action?: string;
94
+ },
95
+ ) {
96
+ super(
97
+ code === 'fresh_auth_required'
98
+ ? 'Authenticate again, then refresh and review a new confirmation. Nothing is replayed.'
99
+ : code === 'incompatible'
100
+ ? 'Maintenance returned incompatible evidence. Refresh before continuing.'
101
+ : code === 'denied'
102
+ ? 'Current global permission or session does not allow this operation.'
103
+ : `${code.replaceAll('_', ' ')}${mutationState !== 'none' ? ' — the operation may have started; reconcile provider status before another request.' : '. Refresh authoritative evidence before continuing.'}`,
104
+ );
105
+ if (failure) {
106
+ this.message += ` Operation: ${failure.operation}; provider code: ${failure.provider_code}; mutation state: ${failure.mutation_state}; retryable: ${String(failure.retryable)}${failure.iam_action ? `; IAM action: ${failure.iam_action}` : ''}.`;
107
+ }
108
+ }
109
+ }
110
+ const invalid = (): never => {
111
+ throw new MaintenanceError('incompatible');
112
+ };
113
+ const obj = (v: unknown): Record<string, unknown> =>
114
+ v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : invalid();
115
+ const text = (v: unknown, max = 512): string =>
116
+ typeof v === 'string' && v.length > 0 && v.length <= max && !/[\x00-\x1f\x7f]/.test(v) ? v : invalid();
117
+ const nullableText = (v: unknown) => (v == null ? null : text(v));
118
+ const errorToken = (v: unknown): string =>
119
+ typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,160}$/.test(v) ? v : invalid();
120
+ const bool = (v: unknown): boolean => (typeof v === 'boolean' ? v : invalid());
121
+ const enumeration = <T extends string>(v: unknown, values: readonly T[]): T =>
122
+ values.includes(v as T) ? (v as T) : invalid();
123
+ const array = (v: unknown, max = 1024): unknown[] => (Array.isArray(v) && v.length <= max ? v : invalid());
124
+ const schema = (v: unknown) => {
125
+ const r = obj(v);
126
+ if (r.schema_version !== 1) invalid();
127
+ return r;
128
+ };
129
+ const kind = (v: unknown) => enumeration(v, ['rds-instance', 'rds-cluster', 'elasticache'] as const);
130
+ export function releaseNotesURL(v: unknown): string | null {
131
+ if (v == null) return null;
132
+ const s = text(v, 2048);
133
+ try {
134
+ const u = new URL(s);
135
+ return u.protocol === 'https:' && !u.username && !u.password ? u.href : null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+ export function parseFinding(v: unknown): Finding {
141
+ const r = obj(v);
142
+ return {
143
+ kind: kind(r.kind),
144
+ resource_id: text(r.resource_id),
145
+ engine: text(r.engine),
146
+ current_version: text(r.current_version),
147
+ available_major: text(r.available_major),
148
+ deadline: nullableText(r.deadline),
149
+ extended_deadline: nullableText(r.extended_deadline),
150
+ days_remaining:
151
+ r.days_remaining == null
152
+ ? null
153
+ : typeof r.days_remaining === 'number' && Number.isFinite(r.days_remaining)
154
+ ? r.days_remaining
155
+ : invalid(),
156
+ note: nullableText(r.note),
157
+ release_notes_url: releaseNotesURL(r.release_notes_url),
158
+ status: nullableText(r.status),
159
+ pending_version: nullableText(r.pending_version),
160
+ settled: r.settled == null ? null : bool(r.settled),
161
+ ...(r.members == null
162
+ ? {}
163
+ : {
164
+ members:
165
+ Number.isSafeInteger(r.members) && Number(r.members) >= 0
166
+ ? Number(r.members)
167
+ : invalid(),
168
+ }),
169
+ ...(r.engine_versions == null
170
+ ? {}
171
+ : { engine_versions: array(r.engine_versions, 256).map((x) => text(x)) }),
172
+ };
173
+ }
174
+ export function parseReport(v: unknown): Report {
175
+ const r = schema(v);
176
+ const findings = array(r.findings).map(parseFinding);
177
+ if (new Set(findings.map((f) => `${f.kind}:${f.resource_id}`)).size !== findings.length) invalid();
178
+ return {
179
+ schema_version: 1,
180
+ generated_at: nullableText(r.generated_at),
181
+ region: nullableText(r.region),
182
+ status: enumeration(r.status, ['ok', 'unavailable']),
183
+ level: typeof r.level === 'number' && Number.isFinite(r.level) ? r.level : invalid(),
184
+ scheduled: bool(r.scheduled),
185
+ findings,
186
+ warnings: array(r.warnings, 128).map((v) => {
187
+ const w = obj(v);
188
+ return { iam_action: text(w.iam_action), aws_code: nullableText(w.aws_code) };
189
+ }),
190
+ reason: nullableText(r.reason),
191
+ };
192
+ }
193
+ export function parseResourceStatus(
194
+ v: unknown,
195
+ target: Pick<ApplyInput, 'kind' | 'resource' | 'target_version'>,
196
+ ): ResourceStatus {
197
+ const r = schema(v);
198
+ if (
199
+ r.kind !== target.kind ||
200
+ r.resource !== target.resource ||
201
+ r.target_version !== target.target_version
202
+ )
203
+ invalid();
204
+ return {
205
+ schema_version: 1,
206
+ kind: kind(r.kind),
207
+ resource: text(r.resource),
208
+ target_version: text(r.target_version),
209
+ found: bool(r.found),
210
+ status: nullableText(r.status),
211
+ engine_version: nullableText(r.engine_version),
212
+ engine_versions: array(r.engine_versions, 256).map((x) => text(x)),
213
+ pending_version: nullableText(r.pending_version),
214
+ settled: bool(r.settled),
215
+ upgraded: bool(r.upgraded),
216
+ };
217
+ }
218
+ export function parseFramework(v: unknown): Framework {
219
+ const r = schema(v),
220
+ p = obj(r.pin);
221
+ const result: Framework = {
222
+ schema_version: 1,
223
+ installed: nullableText(r.installed),
224
+ latest: nullableText(r.latest),
225
+ checked_at:
226
+ r.checked_at == null
227
+ ? null
228
+ : typeof r.checked_at === 'number' && Number.isFinite(r.checked_at)
229
+ ? r.checked_at
230
+ : text(r.checked_at),
231
+ source: enumeration(r.source, ['pypi', 'cache', 'unavailable']),
232
+ update_available: bool(r.update_available),
233
+ pin: { mode: enumeration(p.mode, ['latest', 'hold', 'pinned']), value: nullableText(p.value) },
234
+ can_update: bool(r.can_update),
235
+ blocked_reason:
236
+ r.blocked_reason === null
237
+ ? null
238
+ : enumeration(r.blocked_reason, [
239
+ 'update_unavailable',
240
+ 'requires_superuser',
241
+ 'no_converged_deployment',
242
+ 'infrastructure_external',
243
+ ] as const),
244
+ };
245
+ if (
246
+ result.can_update &&
247
+ (!result.latest ||
248
+ result.blocked_reason ||
249
+ (!result.update_available && result.pin.mode === 'latest'))
250
+ )
251
+ invalid();
252
+ return result;
253
+ }
254
+ export function parseInfrastructure(v: unknown): 'managed' | 'external' {
255
+ const r = obj(v),
256
+ mode = enumeration(obj(r.infrastructure).mode, ['managed', 'external'] as const),
257
+ managed = obj(obj(obj(r.features).platform).capabilities).infrastructure_managed;
258
+ if (managed !== (mode === 'managed')) invalid();
259
+ return mode;
260
+ }
261
+ export function maintenanceAccess(user: BackendGlobalUser | null | undefined) {
262
+ return {
263
+ aws: backendGlobalCan(user, 'manage_aws'),
264
+ framework: backendGlobalCan(user, ['view_platform', 'manage_platform', 'admin']),
265
+ bootstrap: backendGlobalCan(user, ['view_admin', 'manage_users', 'manage_settings', 'admin']),
266
+ manage: backendGlobalCan(user, ['manage_platform', 'admin']),
267
+ };
268
+ }
269
+ export function maintenanceFence(scope: Scope = {}): Scope {
270
+ const revision = getAuthSessionRevision(),
271
+ actor = getAuthSnapshot().uid;
272
+ return {
273
+ signal: scope.signal,
274
+ beforeSend() {
275
+ if (
276
+ scope.signal?.aborted ||
277
+ !actor ||
278
+ !getAuthSnapshot().authenticated ||
279
+ actor !== getAuthSnapshot().uid ||
280
+ revision !== getAuthSessionRevision()
281
+ )
282
+ throw new MaintenanceError('denied');
283
+ scope.beforeSend?.();
284
+ },
285
+ };
286
+ }
287
+ function sameOrigin() {
288
+ if (
289
+ !usingMockTransport() &&
290
+ new URL(apiOrigin(), window.location.origin).origin !== window.location.origin
291
+ )
292
+ throw new MaintenanceError('denied');
293
+ }
294
+ async function call(
295
+ path: string,
296
+ scope: Scope = {},
297
+ body?: Record<string, unknown>,
298
+ params?: Record<string, string | number>,
299
+ ) {
300
+ const fence = maintenanceFence(scope);
301
+ try {
302
+ if (body) sameOrigin();
303
+ return (
304
+ await mojoCall(path, {
305
+ ...(body ? { method: 'POST', body } : { params }),
306
+ signal: scope.signal,
307
+ beforeSend: () => {
308
+ if (body) sameOrigin();
309
+ fence.beforeSend?.();
310
+ },
311
+ })
312
+ ).data;
313
+ } catch (e) {
314
+ if (e instanceof MaintenanceError) throw e;
315
+ if (e instanceof MojoError) {
316
+ const knownCodes = new Set([
317
+ 'provider_denied',
318
+ 'provider_unavailable',
319
+ 'provider_error',
320
+ 'cache_unavailable',
321
+ 'upgrade_in_progress',
322
+ 'upgrade_not_offered',
323
+ 'infrastructure_external',
324
+ 'permission_denied',
325
+ 'invalid_request',
326
+ 'update_unavailable',
327
+ 'deployment_coordination_unavailable',
328
+ ]);
329
+ const code =
330
+ e.status === 440
331
+ ? 'fresh_auth_required'
332
+ : typeof e.errorCode === 'string' && knownCodes.has(e.errorCode)
333
+ ? e.errorCode
334
+ : [401, 403].includes(e.status)
335
+ ? 'denied'
336
+ : 'service_unavailable';
337
+ let failure: MaintenanceError['failure'];
338
+ try {
339
+ const f = obj(obj(e.data).failure);
340
+ failure = {
341
+ operation: errorToken(f.operation),
342
+ provider_code: errorToken(f.provider_code),
343
+ retryable: bool(f.retryable),
344
+ mutation_state: enumeration(f.mutation_state, ['none', 'attempted', 'unknown']),
345
+ ...(f.iam_action == null ? {} : { iam_action: errorToken(f.iam_action) }),
346
+ };
347
+ } catch {
348
+ /* No provider evidence. */
349
+ }
350
+ // A generic Framework 409 may follow a successful pin clear. An
351
+ // upgrade_in_progress refusal also proves outstanding provider work.
352
+ // Neither outcome is safe to unlock from a refreshed old version.
353
+ const preProviderCodes = new Set([
354
+ 'cache_unavailable',
355
+ 'upgrade_not_offered',
356
+ 'invalid_request',
357
+ 'infrastructure_external',
358
+ 'permission_denied',
359
+ 'update_unavailable',
360
+ ]);
361
+ const safeRefusal =
362
+ e.status === 440 ||
363
+ (!failure &&
364
+ (preProviderCodes.has(code) ||
365
+ ([400, 401, 403, 404, 405].includes(e.status) &&
366
+ !['provider_denied', 'provider_error', 'provider_unavailable'].includes(code))));
367
+ throw new MaintenanceError(
368
+ code,
369
+ failure?.mutation_state === 'attempted'
370
+ ? 'attempted'
371
+ : failure?.mutation_state === 'unknown' || (body && !safeRefusal)
372
+ ? 'unknown'
373
+ : 'none',
374
+ failure,
375
+ );
376
+ }
377
+ throw new MaintenanceError(
378
+ body ? 'operation_uncertain' : 'service_unavailable',
379
+ body ? 'unknown' : 'none',
380
+ );
381
+ }
382
+ }
383
+ export const readMaintenance = async (scope?: Scope, refresh = false) =>
384
+ parseReport(
385
+ await call(MAINTENANCE_ENDPOINTS.versions, scope, undefined, refresh ? { refresh: 1 } : undefined),
386
+ );
387
+ export const readFramework = async (scope?: Scope, refresh = false) =>
388
+ parseFramework(
389
+ await call(MAINTENANCE_ENDPOINTS.framework, scope, undefined, refresh ? { refresh: 1 } : undefined),
390
+ );
391
+ export const readInfrastructure = async (scope?: Scope) =>
392
+ parseInfrastructure(await call(MAINTENANCE_ENDPOINTS.bootstrap, scope));
393
+ export const readMaintenanceStatus = async (
394
+ target: Pick<ApplyInput, 'kind' | 'resource' | 'target_version'>,
395
+ scope?: Scope,
396
+ ) => parseResourceStatus(await call(MAINTENANCE_ENDPOINTS.status, scope, undefined, target), target);
397
+ export async function applyMaintenance(input: ApplyInput, scope?: Scope) {
398
+ kind(input.kind);
399
+ text(input.resource);
400
+ text(input.target_version);
401
+ if (input.confirm_resource !== input.resource || typeof input.apply_immediately !== 'boolean') invalid();
402
+ const raw = await call(MAINTENANCE_ENDPOINTS.apply, scope, { ...input });
403
+ try {
404
+ const r = schema(raw);
405
+ if (
406
+ r.requested !== true ||
407
+ r.kind !== input.kind ||
408
+ r.resource !== input.resource ||
409
+ r.target_version !== input.target_version ||
410
+ r.apply_immediately !== input.apply_immediately
411
+ )
412
+ throw new MaintenanceError('operation_uncertain', 'unknown');
413
+ return { requested: true as const, operation: text(r.operation) };
414
+ } catch {
415
+ throw new MaintenanceError('operation_uncertain', 'unknown');
416
+ }
417
+ }
418
+ export async function updateFramework(version: string, scope?: Scope) {
419
+ text(version);
420
+ const raw = await call(MAINTENANCE_ENDPOINTS.update, scope, { version, confirm_version: version });
421
+ try {
422
+ const r = schema(raw);
423
+ if (r.requested !== true || r.version !== version)
424
+ throw new MaintenanceError('operation_uncertain', 'unknown');
425
+ const d = obj(r.deployment);
426
+ return {
427
+ requested: true as const,
428
+ version,
429
+ cleared_pin: nullableText(r.cleared_pin),
430
+ deployment: { id: text(d.id), sha: text(d.sha), status: text(d.status) },
431
+ };
432
+ } catch {
433
+ throw new MaintenanceError('operation_uncertain', 'unknown');
434
+ }
435
+ }
436
+ export const frameworkActionLabel = (f: Framework) =>
437
+ f.pin.mode === 'latest' ? `Update to ${f.latest}` : 'Resume and update';
438
+
439
+ /** These labels do not interpret policy-suppressed update_available as current. */
440
+ export function frameworkStatusLabel(f: Framework) {
441
+ if (!f.latest || f.source === 'unavailable') return 'Latest version unknown';
442
+ if (!f.installed) return 'Installed version unknown';
443
+ if (f.pin.mode !== 'latest') return f.latest === f.installed ? 'Update policy paused' : 'Published version differs · policy paused';
444
+ if (f.update_available) return 'Update available';
445
+ return f.latest === f.installed ? 'Current' : 'Published version differs';
446
+ }
447
+ export function supportUrgency(f: Finding) {
448
+ const days = f.days_remaining;
449
+ return days == null ? null : days < 0 ? `Standard support ended ${Math.abs(days)} days ago` : days === 0 ? 'Standard support ends today' : days <= 30 ? `Standard support ends in ${days} days` : null;
450
+ }
451
+ export function normalizePolicy(value: string) {
452
+ const normalized = value.trim().toLowerCase();
453
+ if (normalized === '' || normalized === 'hold' || /^[0-9][a-z0-9._!+-]{0,63}$/.test(normalized)) return normalized;
454
+ throw new MaintenanceError('invalid_framework_version');
455
+ }
456
+ export function policyMatches(pin: Framework['pin'], value: string) {
457
+ return value === '' ? pin.mode === 'latest' : value === 'hold' ? pin.mode === 'hold' : pin.mode === 'pinned' && pin.value === value;
458
+ }
459
+ export async function saveFrameworkPolicy(value: string, scope?: Scope) {
460
+ const pin = normalizePolicy(value);
461
+ const raw = await call(MAINTENANCE_ENDPOINTS.policy, scope, { framework_pin: pin });
462
+ try { const r = schema(raw); if (r.saved !== true || r.value !== pin) invalid(); return pin; }
463
+ catch { throw new MaintenanceError('operation_uncertain', 'unknown'); }
464
+ }
465
+ export interface FrameworkObservation {
466
+ id: string; sha: string; status: string; framework_version: string | null;
467
+ nodes: { runner: string; state: string; version: string | null }[];
468
+ terminal: boolean; versionProven: boolean;
469
+ }
470
+ export async function readFrameworkDeployment(target: { id: string; sha: string }, version: string, scope?: Scope): Promise<FrameworkObservation> {
471
+ const r = schema(await call(MAINTENANCE_ENDPOINTS.platform, scope, undefined, { sections: 'deployments' }));
472
+ const collector = obj(obj(r.sections).deployments);
473
+ if (!['ok', 'healthy', 'degraded', 'unhealthy'].includes(String(collector.status))) invalid();
474
+ const row = array(obj(collector.data).items, 50).map(obj).find(d => d.id === target.id);
475
+ if (!row || row.sha !== target.sha) throw new MaintenanceError('deployment_evidence_missing');
476
+ const roster = array(row.frozen_roster, 256).map(v => text(v));
477
+ const evidence = array(row.node_evidence, 256).map(obj);
478
+ const nodes = evidence.map(e => ({ runner: text(e.runner), state: text(e.state), version: obj(e.proof ?? {}).django_mojo_version === '' ? null : nullableText(obj(e.proof ?? {}).django_mojo_version) }));
479
+ const status = text(row.status);
480
+ const versionProven = status === 'converged' && row.framework_version === version && roster.length > 0 && new Set(roster).size === roster.length && roster.every(runner => {
481
+ const matches = evidence.filter(e => e.runner === runner); if (matches.length !== 1) return false;
482
+ const e = matches[0]!, p = obj(e.proof ?? {});
483
+ return e.state === 'proven' && typeof e.observed_at === 'string' && Number.isFinite(Date.parse(e.observed_at)) && Date.parse(e.observed_at) <= Date.now()+60_000 && typeof e.stale_after === 'string' && Date.parse(e.stale_after) > Date.now() && p.platform_deployment === target.id && typeof p.platform_sha === 'string' && /^[a-f0-9]{40}$/i.test(p.platform_sha) && p.platform_sha === target.sha && p.django_mojo_version === version;
484
+ });
485
+ return { id: target.id, sha: target.sha, status, framework_version: row.framework_version === '' ? null : nullableText(row.framework_version), nodes, terminal: ['converged','failed','superseded'].includes(status), versionProven };
486
+ }
@@ -0,0 +1,3 @@
1
+ export { MaintenancePage, maintenanceDeploymentsHref } from './MaintenancePage';
2
+ export { maintenanceAccess, frameworkActionLabel, MAINTENANCE_ENDPOINTS } from './data';
3
+ export { maintenanceCompleted } from './operations';