portal-mojo 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-credentials.md +27 -0
  3. package/docs/admin-deployments.md +201 -0
  4. package/docs/admin-dns-certificates.md +160 -0
  5. package/docs/admin-dns-domains.md +92 -0
  6. package/docs/admin-dns.md +18 -0
  7. package/docs/admin-edge.md +200 -196
  8. package/docs/admin-fleet-configuration.md +98 -2
  9. package/docs/admin-geolocation.md +100 -0
  10. package/docs/admin-identity-directories.md +12 -0
  11. package/docs/admin-maintenance.md +133 -0
  12. package/docs/admin-modals.md +45 -2
  13. package/docs/admin-operational-fleet.md +206 -0
  14. package/docs/admin-presentation.md +51 -0
  15. package/docs/admin-signin.md +12 -2
  16. package/docs/auth-pages.md +18 -6
  17. package/docs/auth.md +16 -2
  18. package/docs/client.md +7 -0
  19. package/docs/collection-select.md +14 -0
  20. package/docs/combobox.md +18 -0
  21. package/docs/popover.md +5 -4
  22. package/package.json +1 -1
  23. package/src/admin/core/OperationsHelp.tsx +10 -0
  24. package/src/admin/core/index.ts +6 -4
  25. package/src/admin/core/route-context.ts +6 -0
  26. package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
  27. package/src/admin/credentials/acme-consumer-key.ts +34 -0
  28. package/src/admin/credentials/group-api-keys.tsx +5 -3
  29. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  30. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  31. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  32. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  33. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  34. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  35. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  36. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  37. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  38. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  39. package/src/admin/deployments/api.ts +158 -0
  40. package/src/admin/deployments/authority-state.ts +9 -0
  41. package/src/admin/deployments/deployment-state.ts +13 -0
  42. package/src/admin/deployments/index.ts +4 -0
  43. package/src/admin/deployments/onboarding-state.ts +11 -0
  44. package/src/admin/deployments/permissions.ts +37 -0
  45. package/src/admin/deployments/receipts.ts +78 -0
  46. package/src/admin/deployments/sanitize.ts +73 -0
  47. package/src/admin/deployments/shared.tsx +39 -0
  48. package/src/admin/deployments/types.ts +238 -0
  49. package/src/admin/deployments/upload.ts +53 -0
  50. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  51. package/src/admin/dns/CertificateDetail.tsx +8 -3
  52. package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
  53. package/src/admin/dns/DomainDetail.tsx +14 -4
  54. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  55. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  56. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  57. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  58. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  59. package/src/admin/dns/DomainsPage.tsx +26 -13
  60. package/src/admin/dns/SharedValidationDialog.tsx +298 -0
  61. package/src/admin/dns/SharedValidationPanel.tsx +76 -0
  62. package/src/admin/dns/api.ts +4 -2
  63. package/src/admin/dns/domain-navigation.ts +16 -0
  64. package/src/admin/dns/registrar-api.ts +13 -0
  65. package/src/admin/dns/shared-validation-api.ts +90 -0
  66. package/src/admin/dns/shared-validation-data.ts +106 -0
  67. package/src/admin/domains/assistant.ts +5 -4
  68. package/src/admin/domains/communications.ts +10 -9
  69. package/src/admin/domains/identity.ts +8 -7
  70. package/src/admin/domains/infrastructure.ts +19 -13
  71. package/src/admin/domains/lazy-page.ts +6 -0
  72. package/src/admin/domains/observability.ts +6 -5
  73. package/src/admin/domains/operations.ts +27 -10
  74. package/src/admin/domains/security.ts +16 -15
  75. package/src/admin/edge/EdgeHelp.tsx +10 -0
  76. package/src/admin/edge/ServingReadiness.tsx +54 -0
  77. package/src/admin/edge/VhostDetail.tsx +145 -139
  78. package/src/admin/edge/VhostWizard.tsx +204 -115
  79. package/src/admin/edge/VhostsPage.tsx +27 -28
  80. package/src/admin/edge/api.ts +24 -10
  81. package/src/admin/edge/models.ts +4 -4
  82. package/src/admin/edge/serving-data.ts +78 -0
  83. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  84. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  85. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  86. package/src/admin/fleet/FleetHelp.tsx +7 -0
  87. package/src/admin/fleet/FleetOverview.tsx +150 -0
  88. package/src/admin/fleet/FleetPage.tsx +11 -0
  89. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  90. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  91. package/src/admin/fleet/access.ts +22 -0
  92. package/src/admin/fleet/api.ts +77 -0
  93. package/src/admin/fleet/data.ts +148 -0
  94. package/src/admin/fleet/index.ts +8 -0
  95. package/src/admin/fleet/operations.ts +179 -0
  96. package/src/admin/fleet/standalone-data.ts +87 -0
  97. package/src/admin/fleet/types.ts +208 -0
  98. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
  99. package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
  100. package/src/admin/fleet-configuration/data.ts +4 -0
  101. package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
  102. package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
  103. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  104. package/src/admin/geolocation/data.ts +138 -0
  105. package/src/admin/geolocation/index.ts +3 -0
  106. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
  107. package/src/admin/identity/groups/models.ts +2 -0
  108. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  109. package/src/admin/index.ts +8 -0
  110. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  111. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  112. package/src/admin/maintenance/data.ts +486 -0
  113. package/src/admin/maintenance/index.ts +3 -0
  114. package/src/admin/maintenance/operations.ts +208 -0
  115. package/src/admin/public/infrastructure.ts +1 -0
  116. package/src/admin/public/operations.ts +3 -0
  117. package/src/admin/registry.ts +6 -2
  118. package/src/admin/settings/SettingDetail.tsx +7 -2
  119. package/src/admin/settings/SettingEditor.tsx +2 -1
  120. package/src/admin/settings/SettingsPage.tsx +3 -1
  121. package/src/admin/settings/model.ts +1 -0
  122. package/src/admin/signin/SigninPage.tsx +17 -3
  123. package/src/admin/signin/api.ts +2 -0
  124. package/src/charts/MetricsChartView.tsx +4 -11
  125. package/src/charts/MetricsMiniWidget.tsx +3 -15
  126. package/src/client/auth.ts +77 -25
  127. package/src/client/client.ts +6 -0
  128. package/src/client/deployments-mock.ts +375 -0
  129. package/src/client/domain-operations-mock.ts +37 -0
  130. package/src/client/fleet-mock.ts +73 -1
  131. package/src/client/geo-provider-mock.ts +82 -0
  132. package/src/client/index.ts +2 -2
  133. package/src/client/maintenance-mock.ts +285 -0
  134. package/src/client/mock.ts +280 -11
  135. package/src/client/operational-fleet-mock.ts +279 -0
  136. package/src/client/operations-mock.ts +85 -0
  137. package/src/client/serving-mock.ts +22 -0
  138. package/src/styles/components/admin-deployments.css +35 -0
  139. package/src/styles/components/admin-dns-certificates.css +33 -0
  140. package/src/styles/components/admin-domains.css +12 -0
  141. package/src/styles/components/admin-edge.css +125 -1
  142. package/src/styles/components/admin-geolocation.css +40 -0
  143. package/src/styles/components/admin-maintenance.css +127 -0
  144. package/src/styles/components/admin-operational-fleet.css +38 -0
  145. package/src/styles/components/admin-operations.css +9 -0
  146. package/src/styles/components/admin-signin.css +2 -0
  147. package/src/styles/components/combobox.css +12 -2
  148. package/src/styles/components/fleet-page.css +9 -0
  149. package/src/styles/index.css +7 -0
  150. package/src/ui/CollectionSelect.tsx +30 -8
  151. package/src/ui/ComboBox.tsx +39 -8
  152. package/src/ui/FilterBar.tsx +3 -5
  153. package/src/ui/ModelTable.tsx +2 -8
  154. package/src/ui/Popover.tsx +15 -7
  155. package/src/ui/date/TimePicker.tsx +4 -14
  156. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  157. package/src/ui/schema-form-core.tsx +3 -5
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { useMe } from '../../client/runtime';
4
+ import { getDeployment, getHistory, getRelease, resolveAppAuthority, getSummary, getWebApp, rollback } from './api';
5
+ import { assertOwner, isInteractive } from './permissions';
6
+ import { rollbackEligible } from './deployment-state';
7
+ import { makeReceipt, receiptsFor, saveReceipt, settleReceipt } from './receipts';
8
+ import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
9
+ import { ErrorNotice, Status, useWrite } from './shared';
10
+ import type { DeploymentStatus, Release, WebAppObject } from './types';
11
+ export function WebAppRollbackDialog({ app, release, onClose, onBusy, onChanged }: {
12
+ app: WebAppObject;
13
+ release?: Release;
14
+ onClose: () => void;
15
+ onBusy: (v: boolean) => void;
16
+ onChanged: () => void;
17
+ }) {
18
+ const s = useDeploymentOwner(), me = useMe(), w = useWrite(s.owner, s.signal, onBusy);
19
+ const pending = receiptsFor(s.owner).find(r => r.kind === 'rollback' && r.app === app.id && r.submitted && !r.settled);
20
+ const [start, setStart] = useState(0), [selection, setSelection] = useState<Release | undefined>(release), [confirmation, setConfirmation] = useState('');
21
+ const [tracked, setTracked] = useState<DeploymentStatus | null>(() => pending?.deployment && pending.release ? { deployment: pending.deployment, webapp: app.id, release: pending.release, version: '', status: 'unknown', terminal: false, success: false, detail: '', created: '', started: '', finished: '', targets: [], rollback_targets: [] } : null);
22
+ const [reconciled, setReconciled] = useState(false), [acknowledgement, setAcknowledgement] = useState('');
23
+ const q = useQuery({ queryKey: ['deployments', s.key, app.id, 'rollback-targets', start], queryFn: async ({ signal }) => ({ summary: await getSummary(s.owner, app.id, signal), releases: await getHistory(s.owner, app.id, 'release', start, signal) }), retry: false });
24
+ const selected = selection, target = selected?.id ?? 0;
25
+ const observation = useDeploymentObservation(s.owner, tracked ? String(tracked.deployment) : null, signal => getDeployment(s.owner, tracked!.deployment, app.id, tracked!.release, signal), d => d.terminal);
26
+ const execute = () => w.run(async () => {
27
+ if (!selected || confirmation !== `${app.slug}/${selected.version}` || !q.data || q.isError) return;
28
+ const [object, summary, fresh] = await Promise.all([getWebApp(s.owner, app.id, s.signal), getSummary(s.owner, app.id, s.signal), getRelease(s.owner, target, app.id, s.signal)]);
29
+ if (object.group.id !== app.group.id) throw new Error('App ownership changed');
30
+ const authority = await resolveAppAuthority(s.owner, me.data, object, 'custom', s.signal);
31
+ if (!isInteractive() || !authority.custom) throw new Error('Exact app authority is unavailable');
32
+ if (!rollbackEligible(fresh, summary.current_release) || fresh.version !== selected.version) throw new Error('Target changed; refresh and confirm again');
33
+ assertOwner(s.owner, s.signal);
34
+ const receipt = makeReceipt(s.owner, 'rollback', `${app.id}:${target}`, app.id, String(target));
35
+ const result = await rollback(s.owner, receipt, app.id, target, app.group.id, s.signal);
36
+ saveReceipt({ ...receipt, deployment: result.deployment, release: target });
37
+ setTracked(result); setConfirmation(''); onChanged();
38
+ });
39
+ const uncertain = receiptsFor(s.owner).find(r => r.kind === 'rollback' && r.app === app.id && r.submitted && !r.settled && !r.deployment);
40
+ const reconcile = () => w.run(async () => {
41
+ if (!uncertain) return;
42
+ const summary = await getSummary(s.owner, app.id, s.signal), history = await getHistory(s.owner, app.id, 'deployment', 0, s.signal);
43
+ const matches = history.items.filter(a => (a as import('./types').Attempt).release?.id === Number(uncertain.target) && Date.parse(a.created) >= (uncertain.submittedAt ?? 0) - 1000) as import('./types').Attempt[];
44
+ if (matches.length === 1) {
45
+ const match = matches[0]!, status = await getDeployment(s.owner, match.id, app.id, Number(uncertain.target), s.signal);
46
+ saveReceipt({ ...uncertain, deployment: status.deployment, release: status.release, settled: status.terminal }); setTracked(status);
47
+ } else {
48
+ setConfirmation('');
49
+ if (summary.current_release?.id === Number(uncertain.target)) throw new Error('The target is already the current desired pointer. Refresh its deployment evidence instead of dispatching another rollback.');
50
+ setReconciled(true);
51
+ }
52
+ });
53
+ const acknowledge = () => {
54
+ if (!uncertain || !reconciled || acknowledgement !== `${app.slug}/unknown-outcome`) return;
55
+ saveReceipt({ ...uncertain, settled: true, acknowledged: true }); setReconciled(false); setAcknowledgement(''); setConfirmation('');
56
+ };
57
+ const outcome = observation.value ?? tracked, notified = useRef(new Set<number>());
58
+ useEffect(() => {
59
+ if (!outcome?.terminal) return;
60
+ setTracked(current => current?.deployment === outcome.deployment && current !== outcome ? outcome : current);
61
+ const receipt = receiptsFor(s.owner).find(r => r.kind === 'rollback' && r.app === app.id && r.deployment === outcome.deployment);
62
+ if (receipt && !receipt.settled) settleReceipt(receipt);
63
+ if (!notified.current.has(outcome.deployment)) { notified.current.add(outcome.deployment); onChanged(); }
64
+ }, [outcome?.deployment, outcome?.terminal, onChanged]);
65
+ const succeeded = outcome?.terminal === true && outcome.success === true && outcome.status === 'live';
66
+ const releases = (q.data?.releases.items ?? []) as Release[], eligible = releases.filter(r => rollbackEligible(r, q.data!.summary.current_release));
67
+ return <div className="modal-pad admin-deployments"><h2 className="modal-title">{succeeded ? 'Rollback complete' : tracked ? 'Rollback progress' : `Roll back ${app.display_name || app.slug}`}</h2>{!tracked && <p className="modal-message">Choose a verified release to deploy. A failed rollout can leave mixed nodes.</p>}
68
+ {!tracked && <>
69
+ {q.isError && <ErrorNotice>Release observation unavailable. Refresh before choosing a target.</ErrorNotice>}
70
+ <label>Verified release<select value={target} disabled={w.busy || q.isFetching} onChange={e => { setSelection(releases.find(r => r.id === Number(e.target.value))); setConfirmation(''); }}><option value={0}>Choose a version</option>{selected && !eligible.some(r => r.id === target) && <option value={target}>{selected.version} · selected</option>}{eligible.map(r => <option key={r.id} value={r.id}>{r.version} · {r.status}</option>)}</select></label>
71
+ <div className="deployment-pagination" aria-label="Release history pagination"><span className="dim">{q.data ? `${q.data.releases.count ? start + 1 : 0}–${Math.min(start + releases.length, q.data.releases.count)} of ${q.data.releases.count} registered releases` : 'Loading releases…'}</span><div className="deployment-actions"><button className="btn" disabled={w.busy || q.isFetching || start === 0} onClick={() => setStart(value => Math.max(0, value - 25))}>Newer releases</button><button className="btn" disabled={w.busy || q.isFetching || !q.data || start + 25 >= q.data.releases.count} onClick={() => setStart(value => value + 25)}>Older releases</button><button className="btn" disabled={w.busy || q.isFetching} onClick={() => void q.refetch()}>Refresh releases</button></div></div>
72
+ {q.data && !eligible.length && <p className="dim">No eligible rollback target on this page.</p>}
73
+ {selected && <label>Type {app.slug}/{selected.version}<input value={confirmation} disabled={w.busy} onChange={e => setConfirmation(e.target.value)}/></label>}
74
+ </>}
75
+ {uncertain && <div className="deployment-confirm"><p>A previous rollback may have persisted. Observe it before another decision.</p><button className="btn" disabled={w.busy} onClick={() => void reconcile()}>Observe uncertain rollback</button>{reconciled && <><p>No unique matching attempt was observed in bounded history. A new rollback can overlap the earlier operation. Acknowledge this unknown outcome, then review and type the app/version again to consent to a new operation.</p><label>Type {app.slug}/unknown-outcome<input value={acknowledgement} disabled={w.busy} onChange={e => setAcknowledgement(e.target.value)}/></label><button className="btn" disabled={w.busy || acknowledgement !== `${app.slug}/unknown-outcome`} onClick={acknowledge}>Acknowledge unknown outcome</button></>}</div>}
76
+ {w.error && <ErrorNotice>{w.error}</ErrorNotice>}
77
+ {succeeded ? <div className="deployment-result" role="status"><strong>{outcome.version || selected?.version || 'Selected release'}</strong> <Status value="live"/><p>Deployment completed.</p></div> : tracked && <p role="status">Deployment {tracked.deployment}: <Status value={outcome?.status ?? 'unknown'}/></p>}
78
+ {['error', 'stopped'].includes(observation.state) && <p className="text-warn">{succeeded ? 'Latest refresh unavailable. Showing the last observed result.' : 'Observation unavailable; outcome unconfirmed. Refresh only observes.'}</p>}
79
+ {!succeeded && outcome?.detail && <p>{outcome.detail}</p>}
80
+ <div className="modal-actions"><button className="btn" disabled={w.busy} onClick={onClose}>{tracked ? 'Close' : 'Cancel'}</button>{tracked ? <button className="btn" disabled={w.busy || observation.state === 'observing'} onClick={observation.refresh}>Refresh exact deployment</button> : <button className="btn btn-primary" disabled={w.busy || q.isFetching || q.isError || !q.data || !selected || !rollbackEligible(selected, q.data.summary.current_release) || confirmation !== `${app.slug}/${selected.version}`} onClick={() => void execute()}>Confirm rollback</button>}</div>
81
+ </div>;
82
+ }
@@ -0,0 +1,132 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { usingMockTransport, useMe } from '../../client/runtime';
3
+ import { completeUpload, getDeployment, reconcileUploadCompletion, resolveAppAuthority, getWebApp, registerUpload } from './api';
4
+ import { isInteractive } from './permissions';
5
+ import { buildManifest, manifestEqual, putReleaseFiles } from './upload';
6
+ import { makeReceipt, receiptsFor, saveReceipt, settleReceipt } from './receipts';
7
+ import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
8
+ import { DeploymentHelp, ErrorNotice, Status, useWrite } from './shared';
9
+ import type { DeploymentStatus, WebAppObject } from './types';
10
+
11
+ export function WebAppUploadPanel({ app, onClose, onBusy, onChanged }: {
12
+ app: WebAppObject;
13
+ onClose?: () => void;
14
+ onBusy?: (v: boolean) => void;
15
+ onChanged?: () => void;
16
+ }) {
17
+ const s = useDeploymentOwner(), me = useMe(), w = useWrite(s.owner, s.signal, onBusy);
18
+ const files = useRef<File[]>([]);
19
+ const existing = receiptsFor(s.owner).find(r => r.kind === 'upload' && r.app === app.id && !r.settled);
20
+ const [version, setVersion] = useState(existing?.target ?? '');
21
+ const [fileCount, setFileCount] = useState(0), [progress, setProgress] = useState('');
22
+ const [tracked, setTracked] = useState<{ deployment: number; release: number } | null>(
23
+ existing?.deployment && existing.release ? { deployment: existing.deployment, release: existing.release } : null,
24
+ );
25
+ const [reconciliation, setReconciliation] = useState<Awaited<ReturnType<typeof reconcileUploadCompletion>> | null>(null);
26
+ const [consent, setConsent] = useState('');
27
+ const [lastTerminal, setLastTerminal] = useState<DeploymentStatus | null>(null);
28
+ const observe = useDeploymentObservation(s.owner, tracked ? String(tracked.deployment) : null,
29
+ signal => getDeployment(s.owner, tracked!.deployment, app.id, tracked!.release, signal), d => d.terminal);
30
+ const completionSubmitted = !!existing && ['completion_submitted', 'observing'].includes(existing.uploadPhase ?? '');
31
+
32
+ const checkAuthority = async () => {
33
+ const object = await getWebApp(s.owner, app.id, s.signal);
34
+ const authority = await resolveAppAuthority(s.owner, me.data, object, 'release', s.signal);
35
+ if (!isInteractive() || object.group.id !== app.group.id || !authority.release)
36
+ throw new Error('Exact app release authority unavailable');
37
+ };
38
+ const upload = () => w.run(async () => {
39
+ if (completionSubmitted) throw new Error('Completion was already submitted. Observe its deployment before a new decision.');
40
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(version))
41
+ throw new Error('Use a version of 1–128 letters, numbers, dots, dashes or underscores');
42
+ await checkAuthority();
43
+ setProgress('Hashing selected files…');
44
+ const manifest = await buildManifest(files.current, s.signal);
45
+ const receipt = makeReceipt(s.owner, 'upload', `${app.id}:${version}`, app.id, version);
46
+ if (receipt.manifest && !manifestEqual(receipt.manifest, manifest))
47
+ throw new Error('Reselect the identical normalized files and checksums for this pending version');
48
+ const frozen = saveReceipt({ ...receipt, manifest });
49
+ setProgress('Registering immutable release…');
50
+ const registration = await registerUpload(s.owner, frozen, app.id, app.group.id, version, manifest, s.signal);
51
+ const registered = saveReceipt({ ...frozen, release: registration.release, uploadPhase: 'registered' });
52
+ setProgress('Uploading checksum-bound files…');
53
+ const put = usingMockTransport() ? (await import('../../client/deployments-mock')).deploymentMockStorageFetch : fetch;
54
+ await putReleaseFiles(files.current, manifest, registration.uploads, s.signal,
55
+ n => setProgress(`${n}/${manifest.length} files uploaded`), put);
56
+ setProgress('Verifying release and starting deployment…');
57
+ const result = await completeUpload(s.owner, registered, registration.release, app.id, app.group.id, s.signal);
58
+ setTracked({ deployment: result.deployment, release: registration.release });
59
+ files.current = []; setFileCount(0);
60
+ setProgress('Completion accepted; observing exact deployment.');
61
+ });
62
+ const reconcile = () => w.run(async () => {
63
+ const receipt = existing ?? receiptsFor(s.owner).find(r => r.kind === 'upload' && r.app === app.id && r.target === version);
64
+ if (!receipt) throw new Error('No submitted completion receipt is available');
65
+ const result = await reconcileUploadCompletion(s.owner, receipt, s.signal);
66
+ setReconciliation(result); setConsent('');
67
+ if (result.status) setTracked({ deployment: result.status.deployment, release: result.status.release });
68
+ setProgress(result.status ? 'Matching exact deployment observed.' : 'No unique matching attempt was established. Completion outcome remains unknown.');
69
+ });
70
+ const renewConsent = () => w.run(async () => {
71
+ if (!reconciliation || consent !== `${app.slug}/${version}`) return;
72
+ await checkAuthority();
73
+ const fresh = await reconcileUploadCompletion(s.owner, reconciliation.receipt, s.signal);
74
+ if (fresh.signature !== reconciliation.signature || (fresh.status && !fresh.status.terminal)) {
75
+ setReconciliation(fresh); setConsent('');
76
+ if (fresh.status) setTracked({ deployment: fresh.status.deployment, release: fresh.status.release });
77
+ throw new Error('Deployment evidence changed or is active. Review it before another deployment decision.');
78
+ }
79
+ saveReceipt({ ...fresh.receipt, operation: crypto.randomUUID(), uploadPhase: 'registered',
80
+ completionSubmittedAt: undefined, completionBaseline: undefined, deployment: undefined,
81
+ submitted: false, submittedAt: undefined, settled: false });
82
+ setTracked(null); setReconciliation(null); setLastTerminal(null); setConsent('');
83
+ files.current = []; setFileCount(0);
84
+ setProgress('New deployment consent recorded. Reselect the identical artifact and deliberately upload/deploy again.');
85
+ });
86
+ const forTracked = (value: DeploymentStatus | null | undefined) => value && tracked && value.deployment === tracked.deployment && value.release === tracked.release && value.webapp === app.id ? value : null;
87
+ // A later exact terminal observation outranks an earlier reconciliation read.
88
+ const outcome = forTracked(observe.value) ?? forTracked(lastTerminal) ?? forTracked(reconciliation?.status);
89
+ const succeeded = outcome?.terminal === true && outcome.success === true && outcome.status === 'live';
90
+ const notified = useRef(new Set<number>());
91
+ useEffect(() => { if (observe.value?.terminal) setLastTerminal(observe.value); }, [observe.value]);
92
+ useEffect(() => {
93
+ if (!observe.value?.terminal) return;
94
+ const receipt = receiptsFor(s.owner).find(r => r.kind === 'upload' && r.app === app.id && r.deployment === observe.value?.deployment);
95
+ if (receipt && !receipt.settled) settleReceipt(receipt);
96
+ if (!notified.current.has(observe.value.deployment)) {
97
+ notified.current.add(observe.value.deployment); onChanged?.();
98
+ }
99
+ }, [observe.value?.deployment, observe.value?.terminal, onChanged]);
100
+ const lockedFiles = w.busy || !!tracked || completionSubmitted;
101
+ const mayRenew = !!reconciliation && (!reconciliation.status ||
102
+ (reconciliation.status.terminal && !reconciliation.status.success));
103
+ if (succeeded) return <div className="deployment-stack">
104
+ <div className="deployment-result" role="status"><strong>{outcome.version || version}</strong> <Status value="live"/><p>Deployment completed.</p></div>
105
+ {['error', 'stopped'].includes(observe.state) && <p className="text-warn">Latest refresh unavailable. Showing the last observed result.</p>}
106
+ <div className="modal-actions">{onClose && <button className="btn" disabled={w.busy} onClick={onClose}>Close upload</button>}<button className="btn" disabled={w.busy || observe.state === 'observing'} onClick={observe.refresh}>Refresh exact deployment</button></div>
107
+ </div>;
108
+ return <div>
109
+ <label>Release version<input value={version} disabled={w.busy || !!tracked || !!existing} onChange={e => setVersion(e.target.value)} /></label>
110
+ <label>Built files<input type="file" multiple disabled={lockedFiles} onChange={e => { files.current = Array.from(e.target.files ?? []); setFileCount(files.current.length); }} /></label>
111
+ <label>Built folder<input type="file" multiple {...{ webkitdirectory: '', directory: '' }} disabled={lockedFiles} onChange={e => {
112
+ files.current = Array.from(e.target.files ?? []).map(file => {
113
+ const copy = new File([file], file.name, { type: file.type, lastModified: file.lastModified });
114
+ Object.defineProperty(copy, 'webkitRelativePath', { value: (file.webkitRelativePath || file.name).split('/').slice(1).join('/') || file.name });
115
+ return copy;
116
+ }); setFileCount(files.current.length);
117
+ }} /></label>
118
+ <p>{fileCount} files selected <DeploymentHelp label="Upload limits and resume">Default limits are 5000 files and 1 GiB; this installation may set lower limits. Reselect and rehash the identical files to resume an upload that has not submitted completion.</DeploymentHelp></p>
119
+ {existing && !tracked && !completionSubmitted && <p>Pending version {existing.target}. Signed URLs were discarded; identical manifest registration remints them.</p>}
120
+ {completionSubmitted && !tracked && <p role="status">Completion may already have started a deployment. Re-registering or sending completion again can start another rollout. Reconcile the recorded release first.</p>}
121
+ {w.error && <ErrorNotice>{w.error}</ErrorNotice>}
122
+ {progress && !tracked && <p role="status">{progress}</p>}
123
+ {tracked ? <><p>Deployment {tracked.deployment}: <Status value={outcome?.status ?? 'unknown'} />{['error', 'stopped'].includes(observe.state) && ' · Observation unavailable; outcome unconfirmed'}</p><button className="btn" disabled={w.busy} onClick={observe.refresh}>Refresh exact deployment</button></> :
124
+ !completionSubmitted && <button className="btn btn-primary" disabled={w.busy || !fileCount || !version} onClick={() => void upload()}>Upload and deploy verified release</button>}
125
+ {(completionSubmitted || tracked) && <button className="btn" disabled={w.busy} onClick={() => void reconcile()}>Reconcile submitted completion</button>}
126
+ {onClose && <div className="modal-actions"><button className="btn" disabled={w.busy} onClick={onClose}>Close upload</button></div>}
127
+ {mayRenew && <div className="deployment-confirm"><p>The prior completion may have run. Starting again is a new deployment decision and can overlap an unobserved attempt. This does not erase or retry the earlier operation.</p>
128
+ <label>New deployment consent: type {app.slug}/{version}<input value={consent} disabled={w.busy} onChange={e => setConsent(e.target.value)} /></label>
129
+ <button className="btn" disabled={w.busy || consent !== `${app.slug}/${version}`} onClick={() => void renewConsent()}>Consent to a new deployment attempt</button>
130
+ </div>}
131
+ </div>;
132
+ }
@@ -0,0 +1,158 @@
1
+ import { mojoCall, isReauthRequired, requestFreshAuth } from '../../client/runtime';
2
+ import { MojoError } from '../../client/errors';
3
+ import { appActionsRefused, refuseAppActions } from './authority-state';
4
+ import type { FetchOpts } from '../../client/client';
5
+ import { projectAttempt, projectBootstrap, projectDeployment, projectOperation, projectOptions, projectPlatform, projectPlatformAttempt, projectPrecheck, projectRelease, projectSummaries, projectSummary, projectWebAppObject, record, text, positiveId, list, uuid } from './sanitize';
6
+ import { assertOwner, webAppAuthority, type DeploymentOwner } from './permissions';
7
+ import { appUnsettled, receiptsFor, saveReceipt, singleFlight, type ActionReceipt } from './receipts';
8
+ import type { BackendGlobalUser } from '../fleet/access';
9
+ import type { WebAppObject } from './types';
10
+ import type { ApiAction, CreateProfile, ManifestEntry, OnboardingOperation } from './types';
11
+ const ROOT = '/api/edge';
12
+ function privateError(error: unknown): Error { if (error instanceof DOMException && error.name === 'AbortError')
13
+ return new DOMException('Deployment owner changed', 'AbortError'); const status = error && typeof error === 'object' && 'status' in error && typeof error.status === 'number' ? error.status : 0; return new MojoError(status === 440 ? 'Fresh authentication required' : status === 403 ? 'Deployment observation or action denied' : 'Deployment request unavailable; outcome may be unknown', status); }
14
+ export async function safeCall<T>(owner: DeploymentOwner, path: string, project: (v: unknown) => T, opts: FetchOpts = {}): Promise<T> { assertOwner(owner, opts.signal); try {
15
+ const body = await mojoCall(path, {
16
+ ...opts, unscoped: true, beforeSend: () => { assertOwner(owner, opts.signal); opts.beforeSend?.(); }
17
+ });
18
+ assertOwner(owner, opts.signal);
19
+ return project(body.data);
20
+ }
21
+ catch (error) {
22
+ throw privateError(error);
23
+ } }
24
+ export const getBootstrap = (o: DeploymentOwner, signal?: AbortSignal) => safeCall(o, '/api/account/admin/bootstrap', projectBootstrap, { signal });
25
+ export const getPlatform = (o: DeploymentOwner, signal?: AbortSignal) => safeCall(o, '/api/account/admin/platform', projectPlatform, { params: { sections: 'deployments,api' }, signal });
26
+ export const getWebApps = (o: DeploymentOwner, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/summaries`, projectSummaries, { signal });
27
+ export const getSummary = (o: DeploymentOwner, id: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/summary`, v => { const s = projectSummary(v); if (s.webapp.id !== id)
28
+ throw new Error('App identity changed'); return s; }, { params: { webapp: id }, signal });
29
+ export const getWebApp = (o: DeploymentOwner, id: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/${id}`, v => { const s = projectWebAppObject(v); if (s.id !== id || !s.group.id)
30
+ throw new Error('App ownership unavailable'); return s; }, { params: { graph: 'default' }, signal });
31
+ export const getMember = (o: DeploymentOwner, group: number, signal?: AbortSignal) => safeCall(o, `/api/group/${group}/member`, v => { const r = record(v), p = record(r.permissions); return { permissions: Object.fromEntries(['view_dns', 'manage_dns', 'security', 'manage_webapp'].filter(k => Object.hasOwn(p, k)).map(k => [k, p[k]])) }; }, { signal });
32
+ export async function resolveAppAuthority(o: DeploymentOwner, user: BackendGlobalUser | undefined, app: WebAppObject, kind: 'custom' | 'workflow' | 'release', signal?: AbortSignal) { const base = webAppAuthority(user, app); return base[kind] ? base : webAppAuthority(user, app, await getMember(o, app.group.id, signal)); }
33
+ export async function getHistory(o: DeploymentOwner, app: number, kind: 'release' | 'deployment', start = 0, signal?: AbortSignal, size = kind === 'release' ? 25 : 15) { const body = await mojoCall(`${ROOT}/${kind}`, {
34
+ unscoped: true, signal, params: {
35
+ webapp: app, graph: 'list', sort: '-created', start, size: Math.max(1, Math.min(100, Math.floor(size)))
36
+ }, beforeSend: () => assertOwner(o, signal)
37
+ }).catch(error => { throw privateError(error); }); assertOwner(o, signal); return { items: list(body.data, Math.max(1, Math.min(100, Math.floor(size)))).map(v => kind === 'release' ? projectRelease(v) : projectAttempt(v)), count: typeof body.count === 'number' ? body.count : 0 }; }
38
+ export const getDeployment = (o: DeploymentOwner, id: number, app: number, release: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/release/deployment/${id}`, v => { const d = projectDeployment(v); if (d.deployment !== id || d.webapp !== app || d.release !== release)
39
+ throw new Error('Deployment identity mismatch'); return d; }, { signal });
40
+ /** Generic graphs omit the parent: bind the read through both exact server filters. */
41
+ export const getAttempt = (o: DeploymentOwner, id: number, app: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/deployment`, v => {
42
+ const rows = list(v, 2); const row = projectAttempt(rows[0]);
43
+ if (!positiveId(id) || !positiveId(app) || rows.length !== 1 || row.id !== id || !row.release?.id) throw new Error('Attempt unavailable in this WebApp');
44
+ return row;
45
+ }, { params: { webapp: app, id, graph: 'default', start: 0, size: 1 }, signal });
46
+ export const getRelease = (o: DeploymentOwner, id: number, app: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/release`, v => {
47
+ const rows = list(v, 2); const row = projectRelease(rows[0]);
48
+ if (!positiveId(id) || !positiveId(app) || rows.length !== 1 || row.id !== id) throw new Error('Release unavailable in this WebApp');
49
+ return row;
50
+ }, { params: { webapp: app, id, graph: 'default', start: 0, size: 1 }, signal });
51
+ export async function write<T>(o: DeploymentOwner, r: ActionReceipt, path: string, body: Record<string, unknown>, project: (v: unknown) => T, signal?: AbortSignal): Promise<T> { assertOwner(o, signal); if (appActionsRefused(o, r.app))
52
+ throw new Error('App actions were refused. Refresh safe state and establish a new session before another mutation.'); if (appUnsettled(o, r.app, r.key))
53
+ throw new Error('Another app operation has an unknown outcome. Refresh its exact status before continuing.'); if (['rollback', 'profile'].includes(r.kind) && r.submitted && !r.settled)
54
+ throw new Error(r.kind === 'profile' ? 'Settings save outcome is unknown. Reload saved settings before making another save.' : 'Rollback delivery is unknown. Observe summary/history; no rollback was replayed.'); saveReceipt({
55
+ ...r, submitted: true, submittedAt: r.submittedAt ?? Date.now()
56
+ }); return singleFlight(`${o.actor}:${r.app || r.key}`, async () => { try {
57
+ return await safeCall(o, path, project, {
58
+ method: 'POST', body, signal, ...(r.kind === 'api' ? { idempotencyKey: r.operation } : {})
59
+ });
60
+ }
61
+ catch (error) {
62
+ if (isReauthRequired(error)) {
63
+ saveReceipt({ ...r, submitted: false, ...(r.kind === 'profile' ? { settled: true } : {}) });
64
+ await requestFreshAuth();
65
+ throw new Error('Authentication renewed or cancelled. Refresh authoritative state and explicitly confirm again; nothing was replayed.');
66
+ }
67
+ if (error instanceof MojoError && error.status === 403 && r.app)
68
+ refuseAppActions(o, r.app);
69
+ if (error && typeof error === 'object' && 'status' in error && [400, 401, 403, 404, 409, 440].includes(Number(error.status)))
70
+ saveReceipt({ ...r, settled: r.kind==='upload'&&r.uploadPhase==='completion_submitted'?false:true });
71
+ throw new Error(error instanceof DOMException && error.name === 'AbortError' ? 'Session or view changed. The submitted operation may persist. Resume to reconcile.' : 'The operation was refused or delivery is uncertain. Refresh authoritative state before retrying.');
72
+ } }); }
73
+ export type WebAppProfilePatch = { display_name: string; environment: string; github_repository: string; deployment_ref: string; build_output: string };
74
+ export function saveWebAppProfile(o: DeploymentOwner, r: ActionReceipt, app: WebAppObject, patch: WebAppProfilePatch, signal?: AbortSignal) {
75
+ const fields = ['display_name', 'environment', 'github_repository', 'deployment_ref', 'build_output'] as const;
76
+ if (r.kind !== 'profile' || r.app !== app.id || Object.keys(patch).some(key => !fields.includes(key as typeof fields[number])) || fields.some(key => typeof patch[key] !== 'string')) throw new Error('Unsupported WebApp profile fields');
77
+ return write(o, r, `${ROOT}/webapp/${app.id}`, Object.fromEntries(fields.map(key => [key, patch[key]])), v => {
78
+ const saved = projectWebAppObject(v);
79
+ if (saved.id !== app.id || saved.group.id !== app.group.id) throw new Error('WebApp ownership changed');
80
+ return saved;
81
+ }, signal);
82
+ }
83
+ export async function recoverApi(o: DeploymentOwner, r: ActionReceipt, id: string, action: ApiAction, signal?: AbortSignal) { if (!uuid(id))
84
+ throw new Error('Invalid deployment ID'); return write(o, r, `/api/account/admin/platform/deploy/${action}`, { deployment: id }, v => { const d = projectPlatformAttempt(record(v).deployment); if (!d.id || (action !== 'retry' && d.id !== id))
85
+ throw new Error('Deployment identity mismatch'); return d; }, signal); }
86
+ export const rollback = (o: DeploymentOwner, r: ActionReceipt, app: number, release: number, group: number, signal?: AbortSignal) => write(o, r, `${ROOT}/webapp/rollback`, {
87
+ webapp: app, release, group
88
+ }, v => { const d = projectDeployment(v); if (!d.deployment || d.webapp !== app || d.release !== release)
89
+ throw new Error('Rollback identity mismatch'); return d; }, signal);
90
+ export const getOptions = (o: DeploymentOwner, intent: {
91
+ group: number;
92
+ } | {
93
+ group_intent: 'new';
94
+ }, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/onboarding/options`, projectOptions, { params: intent, signal });
95
+ export const precheckAddress = (o: DeploymentOwner, intent: {
96
+ group: number;
97
+ } | {
98
+ group_intent: 'new';
99
+ }, url: string, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/onboarding/precheck`, projectPrecheck, { params: { ...intent, url }, signal });
100
+ export const getOperation = (o: DeploymentOwner, id: string, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/onboarding/detail`, v => { const p = projectOperation(v); if (p.operation_id !== id)
101
+ throw new Error('Operation identity mismatch'); return p; }, { params: { operation: id }, signal });
102
+ export const createApp = (o: DeploymentOwner, r: ActionReceipt, p: CreateProfile, signal?: AbortSignal) => write(o, r, `${ROOT}/webapp/onboarding/create`, { ...p }, v => { const operation = projectOperation(record(v).operation); if (operation.operation_id !== p.operation_id)
103
+ throw new Error('Operation identity mismatch'); return operation; }, signal);
104
+ export const chooseOnboarding = (o: DeploymentOwner, r: ActionReceipt, p: OnboardingOperation, choice: Record<string, unknown>, signal?: AbortSignal) => write(o, r, `${ROOT}/webapp/onboarding/choose`, {
105
+ operation: p.operation_id, revision: p.revision, step: p.cursor, choice
106
+ }, v => { const next = projectOperation(v); if (next.operation_id !== p.operation_id)
107
+ throw new Error('Operation identity mismatch'); return next; }, signal);
108
+ export const cancelOnboarding = (o: DeploymentOwner, r: ActionReceipt, id: string, signal?: AbortSignal) => write(o, r, `${ROOT}/webapp/onboarding/cancel`, { operation: id }, projectOperation, signal);
109
+ export const getWorkflow = (o: DeploymentOwner, app: number, group: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/onboarding/workflow`, v => { const r = record(v); return {
110
+ filename: '.github/workflows/deploy-webapp.yml', repository: text(r.repository), yaml: text(r.yaml, 32000)
111
+ }; }, {
112
+ method: 'POST', body: { webapp: app, group }, signal
113
+ }).catch(async (error) => { if (isReauthRequired(error)) {
114
+ await requestFreshAuth();
115
+ throw new Error('Authentication prompted. Review and explicitly generate the workflow again; nothing was replayed.');
116
+ } throw error; });
117
+ /** Secret never crosses a Query/Mutation cache or error object. */
118
+ export const changeKey = (o: DeploymentOwner, r: ActionReceipt, app: number, group: number, action: 'mint' | 'rotate' | 'revoke', signal?: AbortSignal) => write(o, r, `${ROOT}/webapp/${action === 'revoke' ? 'revoke_key' : 'link_key'}`, {
119
+ webapp: app, group, operation_id: r.operation, ...(action === 'revoke' ? {} : { action })
120
+ }, v => { const x = record(v); if (positiveId(x.webapp) !== app)
121
+ throw new Error('Key receipt identity mismatch'); return { token: typeof x.token === 'string' ? x.token.slice(0, 8192) : null, replayed: x.replayed === true }; }, signal);
122
+ export const getKeyStatus = (o: DeploymentOwner, app: number, group: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/key_status`, v => { const r = record(v), s = record(r.status); if (positiveId(r.webapp) !== app)
123
+ throw new Error('Key identity mismatch'); return { linked: s.linked === true, active: s.active === true }; }, { params: { webapp: app, group }, signal });
124
+ export const getHealth = (o: DeploymentOwner, app: number, group: number, signal?: AbortSignal) => safeCall(o, `${ROOT}/webapp/health`, v => { const r = record(v); if (positiveId(r.webapp) !== app)
125
+ throw new Error('Health identity mismatch'); return {
126
+ status: text(r.status) || 'unknown', detail: text(r.detail, 500), checked: text(r.checked)
127
+ }; }, { params: { webapp: app, group }, signal });
128
+ export async function registerUpload(o: DeploymentOwner, r: ActionReceipt, app: number, group: number, version: string, manifest: ManifestEntry[], signal?: AbortSignal) { const current=receiptsFor(o).find(x=>x.key===r.key)??r;if(['completion_submitted','observing'].includes(current.uploadPhase??''))throw new Error('Completion was already submitted. Reconcile its exact deployment before a new explicit deployment decision.');return write(o, r, `${ROOT}/release`, {
129
+ webapp: app, group, version, manifest
130
+ }, v => { const d = record(v); if (!positiveId(d.release) || text(d.version) !== version)
131
+ throw new Error('Release identity mismatch'); return {
132
+ release: positiveId(d.release), status: text(d.status), uploads: list(d.uploads, 5000).map(v => { const u = record(v), h = record(u.headers); return {
133
+ path: text(u.path, 1024), url: text(u.url, 8192), headers: { 'x-amz-checksum-sha256': text(h['x-amz-checksum-sha256'], 64) }
134
+ }; })
135
+ }; }, signal); }
136
+ /** Completion is a deployment request, not an idempotent verification read. */
137
+ export async function completeUpload(o:DeploymentOwner,r:ActionReceipt,release:number,app:number,group:number,signal?:AbortSignal){
138
+ return singleFlight(`upload-completion:${o.actor}:${app}`,async()=>{
139
+ const current=receiptsFor(o).find(x=>x.key===r.key)??r;
140
+ if(['completion_submitted','observing'].includes(current.uploadPhase??''))throw new Error('Completion delivery is unsettled or already observed. Reconcile before consenting to another deployment.');
141
+ if(current.release!==release||current.app!==app)throw new Error('Upload receipt identity changed');
142
+ const history=await getHistory(o,app,'deployment',0,signal,100);
143
+ const submitted=saveReceipt({...current,uploadPhase:'completion_submitted',completionSubmittedAt:Date.now(),completionBaseline:history.items.map(x=>x.id)});
144
+ const result=await write(o,submitted,`${ROOT}/release/complete`,{release,group},v=>{const d=record(v);if(positiveId(d.release)!==release||positiveId(d.webapp)!==app||!positiveId(d.deployment))throw new Error('Completion identity mismatch');return {deployment:positiveId(d.deployment),release,app};},signal);
145
+ saveReceipt({...submitted,uploadPhase:'observing',deployment:result.deployment});return result;
146
+ });
147
+ }
148
+ export async function reconcileUploadCompletion(o:DeploymentOwner,r:ActionReceipt,signal?:AbortSignal){
149
+ const current=receiptsFor(o).find(x=>x.key===r.key)??r;
150
+ if(current.kind!=='upload'||!current.release||!['completion_submitted','observing'].includes(current.uploadPhase??''))throw new Error('No submitted completion receipt is available');
151
+ const [summary,history]=await Promise.all([getSummary(o,current.app,signal),getHistory(o,current.app,'deployment',0,signal,100)]);
152
+ const candidates=history.items.filter(x=>{const a=x as import('./types').Attempt;return !!a.release&&a.release.id===current.release&&a.release.version===current.target&&!(current.completionBaseline??[]).includes(a.id);});
153
+ const id=current.deployment??(candidates.length===1?candidates[0]!.id:0);
154
+ const status=id?await getDeployment(o,id,current.app,current.release,signal):null;
155
+ const signature=JSON.stringify([summary.current_release?.id??null,history.items.map(x=>[x.id,x.status,(x as import('./types').Attempt).release?.id])]);
156
+ if(status)saveReceipt({...current,uploadPhase:'observing',deployment:status.deployment,settled:status.terminal});
157
+ return {receipt:receiptsFor(o).find(x=>x.key===current.key)!,status,signature};
158
+ }
@@ -0,0 +1,9 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { ownerKey, type DeploymentOwner } from './permissions';
3
+ const refused = new Set<string>();
4
+ const listeners = new Set<() => void>();
5
+ const key = (owner: DeploymentOwner, app: number) => `${ownerKey(owner)}:${app}`;
6
+ export function refuseAppActions(owner: DeploymentOwner, app: number): void { refused.add(key(owner, app)); for (const fn of listeners)
7
+ fn(); }
8
+ export function appActionsRefused(owner: DeploymentOwner, app: number): boolean { return refused.has(key(owner, app)); }
9
+ export function useAppActionsRefused(owner: DeploymentOwner, app: number): boolean { return useSyncExternalStore(fn => { listeners.add(fn); return () => listeners.delete(fn); }, () => appActionsRefused(owner, app), () => true); }
@@ -0,0 +1,13 @@
1
+ import type { ApiAction, PlatformAttempt, Release } from './types';
2
+ export function apiActions(status: string): ApiAction[] { switch (status) {
3
+ case 'failed': return ['retry', 'verify'];
4
+ case 'verified':
5
+ case 'partial':
6
+ case 'unknown': return ['verify', 'converge'];
7
+ case 'converged': return ['verify'];
8
+ default: return [];
9
+ } }
10
+ export function rollbackEligible(release: Release, current: Release | null): boolean { return release.id > 0 && release.id !== current?.id && ['uploaded', 'live', 'superseded'].includes(release.status); }
11
+ export function platformProof(attempt: PlatformAttempt): string { const n = attempt.node_summary; if (attempt.status === 'converged' && n.expected > 0 && n.proven === n.expected)
12
+ return `${n.proven}/${n.expected} restart proofs at observation`; return `${n.proven}/${n.expected} restart proofs; ${n.reported} reported, ${n.failed} failed (${attempt.status})`; }
13
+ export function deploymentTone(status: string): string { return ['live', 'converged'].includes(status) ? 'ok' : ['failed', 'rolled_back'].includes(status) ? 'bad' : ['requested', 'queued', 'deploying', 'canary', 'fleet', 'partial', 'verified'].includes(status) ? 'warn' : 'dim'; }
@@ -0,0 +1,4 @@
1
+ export { DeploymentsPage } from './DeploymentsPage';
2
+ export { WebAppDetail } from './WebAppDetail';
3
+ export { DEPLOYMENTS_PERMISSIONS } from './permissions';
4
+ export type { WebAppSummary, PlatformAttempt, OnboardingOperation, DeploymentStatus, DeploymentsDestinations } from './types';
@@ -0,0 +1,11 @@
1
+ import type { OnboardingOperation } from './types';
2
+ export const onboardingTerminal = (o: OnboardingOperation) => ['succeeded', 'failed', 'cancelled'].includes(o.status);
3
+ export function onboardingPollable(o: OnboardingOperation): boolean { return !onboardingTerminal(o) && ['app', 'address', 'github', 'verify'].includes(o.cursor) && (o.status === 'active' || (o.status === 'waiting' && !!o.next_attempt_at)); }
4
+ export function onboardingGeneration(o: OnboardingOperation): string { return `${o.operation_id}:${o.revision}:${o.cursor}`; }
5
+ export function managedChoice(o: OnboardingOperation, domain: number, label: string): Record<string, unknown> | null { switch (o.cursor) {
6
+ case 'app': return {};
7
+ case 'address': return domain > 0 && /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) ? { domain, label } : null;
8
+ case 'github': return { skip: true };
9
+ case 'verify': return {};
10
+ default: return null;
11
+ } }
@@ -0,0 +1,37 @@
1
+ import { backendGlobalCan, type BackendGlobalUser } from '../fleet/access';
2
+ import { decodeJwt } from '../../client/jwt';
3
+ import { getAccessToken, getAuthSessionRevision, getAuthSnapshot } from '../../client/auth';
4
+ import type { Bootstrap, WebAppObject } from './types';
5
+ export const DEPLOYMENTS_PERMISSIONS = ['sys.view_platform', 'sys.manage_platform', 'sys.admin', 'sys.view_dns', 'sys.manage_dns', 'sys.security'];
6
+ const active = (u: BackendGlobalUser | null | undefined) => !!u && (!('is_active' in u) || u.is_active !== false);
7
+ export const canReadApi = (u: BackendGlobalUser | null | undefined) => active(u) && backendGlobalCan(u, ['view_platform', 'manage_platform', 'admin']);
8
+ export const canRecoverApi = (u: BackendGlobalUser | null | undefined) => active(u) && backendGlobalCan(u, ['manage_platform', 'admin']);
9
+ export const canReadWebApps = (u: BackendGlobalUser | null | undefined) => active(u) && backendGlobalCan(u, ['view_dns', 'manage_dns', 'security']);
10
+ export const canReadBootstrap = (u: BackendGlobalUser | null | undefined) => active(u) && backendGlobalCan(u, ['view_admin', 'manage_users', 'manage_settings', 'admin']);
11
+ export function isInteractive(): boolean { const s = getAuthSnapshot(), c = decodeJwt(getAccessToken()); return s.authenticated && !!c && c.credential_kind !== 'api_key' && c.credential_kind !== 'group_scoped_token'; }
12
+ export interface DeploymentOwner {
13
+ actor: string;
14
+ origin: string;
15
+ session: number;
16
+ signal?: AbortSignal;
17
+ }
18
+ export function deploymentOwner(): DeploymentOwner { return {
19
+ actor: getAuthSnapshot().uid ?? '', origin: typeof window === 'undefined' ? '' : window.location.origin, session: getAuthSessionRevision()
20
+ }; }
21
+ export function ownerKey(o: DeploymentOwner): string { return `${o.origin}:${o.actor}:${o.session}`; }
22
+ export function assertOwner(o: DeploymentOwner, signal?: AbortSignal): void { signal?.throwIfAborted(); o.signal?.throwIfAborted(); const now = deploymentOwner(); if (!getAuthSnapshot().authenticated || !o.actor || ownerKey(o) !== ownerKey(now))
23
+ throw new DOMException('Session changed; refresh and review again', 'AbortError'); }
24
+ /** SAVE_PERMS is the first configured RestMeta clause for writes; VIEW is read-only. */
25
+ export function webAppAuthority(user: BackendGlobalUser | null | undefined, app: WebAppObject, member?: {
26
+ permissions?: Record<string, unknown>;
27
+ } | null) {
28
+ const local = member ? { permissions: member.permissions } : null;
29
+ const can = (grants: readonly string[]) => backendGlobalCan(user, grants) || backendGlobalCan(local, grants);
30
+ const object = active(user) && app.id > 0 && app.group.id > 0;
31
+ const view = object && can(['view_dns', 'manage_dns', 'security']);
32
+ const save = object && can(['manage_dns', 'security']);
33
+ return {
34
+ read: view, custom: save && can(['manage_webapp']), release: save, workflow: active(user) && app.group.id > 0 && (can(['security']) || (can(['manage_webapp']) && can(['manage_dns'])))
35
+ };
36
+ }
37
+ export function canOnboard(bootstrap: Bootstrap | undefined): boolean { return !!bootstrap && (bootstrap.groups.length > 0 || bootstrap.can_create_webapp_group); }
@@ -0,0 +1,78 @@
1
+ import type { CreateProfile, ManifestEntry } from './types';
2
+ import { record, text, uuid, positiveId, list } from './sanitize';
3
+ import type { DeploymentOwner } from './permissions';
4
+ export interface ActionReceipt {
5
+ version: 1;
6
+ actor: string;
7
+ origin: string;
8
+ key: string;
9
+ operation: string;
10
+ kind: 'create' | 'api' | 'key' | 'rollback' | 'upload' | 'profile';
11
+ app: number;
12
+ target: string;
13
+ settled: boolean;
14
+ submitted?: boolean;
15
+ submittedAt?: number;
16
+ acknowledged?: boolean;
17
+ address?: {
18
+ domain: number;
19
+ label: string;
20
+ };
21
+ onboardingGroup?: number;
22
+ profile?: CreateProfile;
23
+ manifest?: ManifestEntry[];
24
+ release?: number;
25
+ deployment?: number;
26
+ uploadPhase?: 'registered' | 'completion_submitted' | 'observing';
27
+ completionSubmittedAt?: number;
28
+ completionBaseline?: number[];
29
+ }
30
+ const STORAGE = 'mojo.deployment.receipts.v1';
31
+ const memory = new Map<string, ActionReceipt>();
32
+ const active = new Set<string>();
33
+ function project(v: unknown): ActionReceipt | null { const r = record(v); if (r.version !== 1 || !['create', 'api', 'key', 'rollback', 'upload', 'profile'].includes(String(r.kind)) || !uuid(r.operation) || !text(r.actor) || !text(r.origin) || !text(r.key))
34
+ return null; const receipt: ActionReceipt = {
35
+ version: 1, actor: text(r.actor), origin: text(r.origin), key: text(r.key), operation: uuid(r.operation), kind: r.kind as ActionReceipt['kind'], app: positiveId(r.app), target: text(r.target), settled: r.settled === true, submitted: r.submitted === true, submittedAt: typeof r.submittedAt === 'number' ? r.submittedAt : undefined, acknowledged: r.acknowledged === true, release: positiveId(r.release) || undefined, deployment: positiveId(r.deployment) || undefined
36
+ }; if(positiveId(r.onboardingGroup))receipt.onboardingGroup=positiveId(r.onboardingGroup);if(r.kind==='upload'){
37
+ if(['registered','completion_submitted','observing'].includes(String(r.uploadPhase)))receipt.uploadPhase=r.uploadPhase as ActionReceipt['uploadPhase'];
38
+ if(typeof r.completionSubmittedAt==='number'&&Number.isFinite(r.completionSubmittedAt))receipt.completionSubmittedAt=r.completionSubmittedAt;
39
+ if(Array.isArray(r.completionBaseline))receipt.completionBaseline=list(r.completionBaseline,100).map(positiveId).filter(Boolean);
40
+ } if (r.address) {
41
+ const a = record(r.address);
42
+ receipt.address = { domain: positiveId(a.domain), label: text(a.label, 63) };
43
+ } if (r.kind === 'create' && r.profile) {
44
+ const p = record(r.profile);
45
+ receipt.profile = {
46
+ operation_id: receipt.operation, ...(positiveId(p.group) ? { group: positiveId(p.group) } : { group_intent: 'new' as const }), slug: text(p.slug, 64), display_name: text(p.display_name, 120), environment: text(p.environment, 16), bucket: text(p.bucket), github_repository: text(p.github_repository), deployment_ref: text(p.deployment_ref), build_output: text(p.build_output)
47
+ };
48
+ } if (r.kind === 'upload' && Array.isArray(r.manifest))
49
+ receipt.manifest = list(r.manifest, 5000).map(v => { const p = record(v); return {
50
+ path: text(p.path, 1024), sha256: text(p.sha256, 64), size: typeof p.size === 'number' ? p.size : 0
51
+ }; }); return receipt; }
52
+ function restore() { try {
53
+ for (const raw of JSON.parse(sessionStorage.getItem(STORAGE) ?? '[]')) {
54
+ const r = project(raw);
55
+ if (r && !memory.has(r.key))
56
+ memory.set(r.key, r);
57
+ }
58
+ }
59
+ catch { /* session-only fallback */ } }
60
+ function persist() { try {
61
+ sessionStorage.setItem(STORAGE, JSON.stringify([...memory.values()].slice(-32)));
62
+ }
63
+ catch { /* receipt remains for this tab */ } }
64
+ export function receiptsFor(owner: DeploymentOwner): ActionReceipt[] { restore(); return [...memory.values()].filter(r => r.actor === owner.actor && r.origin === owner.origin).map(r => ({ ...r })); }
65
+ export function saveReceipt(receipt: ActionReceipt) { const safe = project(receipt); if (!safe)
66
+ throw new Error('Invalid deployment receipt'); memory.set(safe.key, safe); persist(); return safe; }
67
+ export function makeReceipt(owner: DeploymentOwner, kind: ActionReceipt['kind'], scope: string, app = 0, target = ''): ActionReceipt { const key = `${owner.origin}:${owner.actor}:${kind}:${scope}`; const previous = receiptsFor(owner).find(r => r.key === key); return previous && (!previous.settled || (previous.kind==='upload'&&['completion_submitted','observing'].includes(previous.uploadPhase??''))) ? previous : saveReceipt({
68
+ version: 1, actor: owner.actor, origin: owner.origin, key, operation: crypto.randomUUID(), kind, app, target, settled: false
69
+ }); }
70
+ export function appUnsettled(owner: DeploymentOwner, app: number, except?: string): ActionReceipt | undefined { return receiptsFor(owner).find(r => r.app === app && app > 0 && !r.settled && r.key !== except); }
71
+ export function settleReceipt(r: ActionReceipt) { saveReceipt({ ...r, settled: true }); }
72
+ export async function singleFlight<T>(key: string, fn: () => Promise<T>): Promise<T> { if (active.has(key))
73
+ throw new Error('An operation for this target is already pending'); active.add(key); try {
74
+ return await fn();
75
+ }
76
+ finally {
77
+ active.delete(key);
78
+ } }