@tokenoftrust/storefront-runner 1.3.4-rc.3 → 1.4.0
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/apps/storefront/astro.config.mjs +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
- package/apps/storefront/package.json +13 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- package/apps/storefront/src/lib/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
- package/apps/storefront/src/lib/auth/route.ts +16 -1
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/obstacleStore.ts +214 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +12 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
- package/apps/storefront/src/middleware/index.ts +28 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin.astro +29 -3
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +197 -41
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +496 -74
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/candidate-index.ts +311 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,1959 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Publish tab — the go-live surface (handoffs 2026-08-05-admin-golive-surface
|
|
4
|
+
* + 2026-08-05-domain-connect-ux). Replaces the terminal runbook for a
|
|
5
|
+
* permissioned operator:
|
|
6
|
+
*
|
|
7
|
+
* - OPEN CHANGE QUEUE from GET /api/changes ({ current, changes }) with
|
|
8
|
+
* per-change reconcile/compliance state, preview link, head sha, and age.
|
|
9
|
+
* - GO LIVE (POST /api/changes/accept with expectedHeadSha — the stale-head
|
|
10
|
+
* "superseded" refusal and the audit record are surfaced legibly) and
|
|
11
|
+
* REJECT (with a note) per change.
|
|
12
|
+
* - LIVE STATE: the current live version (live-channel pointer) + the
|
|
13
|
+
* static-republish dispatch status for static-publish tenants (fired /
|
|
14
|
+
* skipped / sink-unbound notice).
|
|
15
|
+
* - DOMAIN section (static-publish tenants): apex connect + rollback via the
|
|
16
|
+
* www-domain dispatch endpoints — typed-confirmation connect (owner-only),
|
|
17
|
+
* always-visible rollback with the captured-records timestamp, in-flight
|
|
18
|
+
* run + legible failures.
|
|
19
|
+
*
|
|
20
|
+
* PERMISSIONS (server-authoritative; mirrored here for UX only):
|
|
21
|
+
* - Ship actions require an owner/admin session (`resolveOwnerSession`'s
|
|
22
|
+
* OWNER_CAPABILITIES) — others see the queue read-only with a sign-in note.
|
|
23
|
+
* - Non-premium tenants see the go-live control as a PAYWALL UPSELL — never
|
|
24
|
+
* hidden, never "coming soon" (recorded product direction). The
|
|
25
|
+
* entitlement signal is the existing premium model (D4): a custom-domain
|
|
26
|
+
* tenant is premium/pilot; a `<label>.tokenoftrust.store` self-serve store
|
|
27
|
+
* is not. TODO(paywall workstream): replace with the real billing
|
|
28
|
+
* entitlement when one exists.
|
|
29
|
+
*/
|
|
30
|
+
import { readViewerSession } from "@/lib/auth/route";
|
|
31
|
+
import { withBase } from "@/lib/basePath";
|
|
32
|
+
import { readEnv } from "@/lib/env";
|
|
33
|
+
import { resolveViewerShipCapability } from "@/lib/change";
|
|
34
|
+
import { isHostedStoreDomain, isInternalTestTenant } from "@tot/public-runtime";
|
|
35
|
+
|
|
36
|
+
const { envelope } = Astro.props;
|
|
37
|
+
const { tenant, basePath, cspNonce } = Astro.locals;
|
|
38
|
+
|
|
39
|
+
// Viewer capability, mirrored from the same session model the API routes gate
|
|
40
|
+
// on (resolveOwnerSession admits capability owner|admin). UX-only: the server
|
|
41
|
+
// re-authorizes every action.
|
|
42
|
+
const viewerSession = await readViewerSession(Astro);
|
|
43
|
+
// Session capability counts only when the session's resource IS this tenant.
|
|
44
|
+
const sessionCapability =
|
|
45
|
+
viewerSession?.resource === tenant.appDomain ? viewerSession.capability : undefined;
|
|
46
|
+
const viewerCapability =
|
|
47
|
+
Astro.locals.viewer?.capability ?? sessionCapability ?? "none";
|
|
48
|
+
// A signed-in DEVELOPER (not owner/admin) may hold an explicit, owner-revocable
|
|
49
|
+
// ship-on-behalf grant. Resolve it LIVE from the MCP subject-capability store —
|
|
50
|
+
// NEVER the coarse role, NEVER a client header — so a ship-granted developer sees
|
|
51
|
+
// the queue + Go-live/Reject/Retire. This MIRRORS the server (resolveShipSession /
|
|
52
|
+
// effectiveShipCapability); the server re-authorizes every action. UX-only.
|
|
53
|
+
let viewerCanShip = viewerCapability === "owner" || viewerCapability === "admin";
|
|
54
|
+
if (!viewerCanShip && Astro.locals.viewer?.email) {
|
|
55
|
+
const shipGrant = await resolveViewerShipCapability(
|
|
56
|
+
Astro.locals.viewer.email,
|
|
57
|
+
tenant.appDomain,
|
|
58
|
+
);
|
|
59
|
+
if (shipGrant === "ship-on-behalf") viewerCanShip = true;
|
|
60
|
+
}
|
|
61
|
+
const viewerIsOwner = viewerCapability === "owner";
|
|
62
|
+
// Apex/domain cutover is granted to owner OR admin (decision: apex-cutover =
|
|
63
|
+
// owner+admin). This MIRRORS the server, whose `apexCutover` = `isOwner` over
|
|
64
|
+
// OWNER_CAPABILITIES {owner, admin} (see change/route.ts decideIsOwner +
|
|
65
|
+
// change/shipCapabilities.ts) — this UI gate was wrongly owner-only, stricter
|
|
66
|
+
// than the gate the server actually enforces. UX-only: the server re-authorizes.
|
|
67
|
+
const viewerCanApexCutover = viewerCapability === "owner" || viewerCapability === "admin";
|
|
68
|
+
|
|
69
|
+
// Entitlement: the existing premium signal (custom-domain tenant = premium).
|
|
70
|
+
// TODO(paywall workstream): swap for the real billing entitlement.
|
|
71
|
+
const goLiveEntitled = !isHostedStoreDomain(tenant.appDomain);
|
|
72
|
+
const internalTestTenant = isInternalTestTenant(tenant);
|
|
73
|
+
|
|
74
|
+
const staticPublishEnabled = Boolean(
|
|
75
|
+
tenant.staticPublish && tenant.staticPublish.enabled !== false,
|
|
76
|
+
);
|
|
77
|
+
// Presence-only signal for the T5 dispatch sink (never the value).
|
|
78
|
+
const dispatchConfigured = Boolean((await readEnv("WWW_PUBLISH_DISPATCH_TOKEN"))?.trim());
|
|
79
|
+
|
|
80
|
+
const changesEndpoint = withBase(basePath || "", "/api/changes");
|
|
81
|
+
const acceptEndpoint = withBase(basePath || "", "/api/changes/accept");
|
|
82
|
+
// b11 — the branch-integration lifecycle SPLIT of accept from publish:
|
|
83
|
+
// - integrateEndpoint = b08's "accept = integrate into the protected preview
|
|
84
|
+
// aggregate" (POST /api/changes/integrate → TenantIntegrationQueue.enqueue).
|
|
85
|
+
// This is the candidate-queue "Accept to preview" action — it advances the
|
|
86
|
+
// shared aggregate, it does NOT go live. Returns an IntegrateOutcome
|
|
87
|
+
// (queueState/runState/statusMessage), never a bare merged:true.
|
|
88
|
+
// - shipEndpoint = b10's exact-digest go-live (POST /api/changes/ship →
|
|
89
|
+
// b09 AggregateShipOrchestrator.plan/ship). The aggregate card's "Publish"
|
|
90
|
+
// action: it ships the reviewed pinned SHA + digest, double-gated
|
|
91
|
+
// (paywall + human confirm). The read-only plan is served on GET /api/changes
|
|
92
|
+
// (`plan`), so the confirm surface never has to POST to preview it.
|
|
93
|
+
// If b08/b10's exact paths differ at fold, the integrator reconciles here —
|
|
94
|
+
// these are the agreed contract paths (see the unit brief).
|
|
95
|
+
const integrateEndpoint = withBase(basePath || "", "/api/changes/integrate");
|
|
96
|
+
const shipEndpoint = withBase(basePath || "", "/api/changes/ship");
|
|
97
|
+
// U7 — retire/GC: evict a candidate's ReviewEnvironment + version (rebuildable),
|
|
98
|
+
// DISTINCT from reject (which closes the change). Gated server-side by retireGate.
|
|
99
|
+
const retireEndpoint = withBase(basePath || "", "/api/preview/retire");
|
|
100
|
+
// dg7 — the ship-operations workspace woven into this tab: versions/promotion
|
|
101
|
+
// history, per-candidate diff, and rollback (through the ONE rollback
|
|
102
|
+
// orchestrator). Read endpoints mirror the change-queue's auth/scope.
|
|
103
|
+
const versionsEndpoint = withBase(basePath || "", "/api/changes/versions");
|
|
104
|
+
const rollbackEndpoint = withBase(basePath || "", "/api/changes/rollback");
|
|
105
|
+
// The client builds `${diffEndpointBase}/<changeId>/diff` per candidate.
|
|
106
|
+
const diffEndpointBase = changesEndpoint;
|
|
107
|
+
const domainStatusEndpoint = withBase(basePath || "", "/api/domain/status");
|
|
108
|
+
const domainDispatchEndpoint = withBase(basePath || "", "/api/domain/dispatch");
|
|
109
|
+
// u9: the machine-verifiable apex-cutover readiness gate — the SAME server gate the
|
|
110
|
+
// `tot go-live` CLI reads, so the admin display and the CLI never diverge.
|
|
111
|
+
const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness");
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
<!-- agent-hook:publish-tab -->
|
|
115
|
+
<section
|
|
116
|
+
id="publish"
|
|
117
|
+
class="band publish-tab"
|
|
118
|
+
aria-labelledby="admin-tab-publish publish-title"
|
|
119
|
+
data-admin-panel
|
|
120
|
+
data-agent-scope="publish"
|
|
121
|
+
role="tabpanel"
|
|
122
|
+
data-changes-endpoint={changesEndpoint}
|
|
123
|
+
data-accept-endpoint={acceptEndpoint}
|
|
124
|
+
data-integrate-endpoint={integrateEndpoint}
|
|
125
|
+
data-ship-endpoint={shipEndpoint}
|
|
126
|
+
data-retire-endpoint={retireEndpoint}
|
|
127
|
+
data-versions-endpoint={versionsEndpoint}
|
|
128
|
+
data-rollback-endpoint={rollbackEndpoint}
|
|
129
|
+
data-diff-endpoint-base={diffEndpointBase}
|
|
130
|
+
data-domain-status-endpoint={domainStatusEndpoint}
|
|
131
|
+
data-domain-dispatch-endpoint={domainDispatchEndpoint}
|
|
132
|
+
data-domain-readiness-endpoint={domainReadinessEndpoint}
|
|
133
|
+
data-repo={tenant.appDomain}
|
|
134
|
+
data-app-domain={tenant.appDomain}
|
|
135
|
+
data-can-ship={viewerCanShip ? "true" : "false"}
|
|
136
|
+
data-is-owner={viewerIsOwner ? "true" : "false"}
|
|
137
|
+
data-entitled={goLiveEntitled ? "true" : "false"}
|
|
138
|
+
data-static-publish={staticPublishEnabled ? "true" : "false"}
|
|
139
|
+
data-dispatch-configured={dispatchConfigured ? "true" : "false"}
|
|
140
|
+
>
|
|
141
|
+
<div class="section-title">
|
|
142
|
+
<div>
|
|
143
|
+
<h2 id="publish-title">Publish</h2>
|
|
144
|
+
<p>Accept changes into the preview aggregate, then publish the reviewed build live.</p>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="actions">
|
|
147
|
+
<button class="btn" type="button" data-publish-refresh>
|
|
148
|
+
<svg><use href="#i-refresh"></use></svg>Refresh
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{internalTestTenant && (
|
|
154
|
+
<div class="ship-gate amber">
|
|
155
|
+
<div class="ship-gate-main">
|
|
156
|
+
<span class="seal"><svg><use href="#i-alert"></use></svg></span>
|
|
157
|
+
<div>
|
|
158
|
+
<b>Internal test store</b>
|
|
159
|
+
<span>This store exists for testing only and is blocked from going live.</span>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
{!goLiveEntitled && (
|
|
166
|
+
<div class="ship-gate amber" data-publish-paywall>
|
|
167
|
+
<div class="ship-gate-main">
|
|
168
|
+
<span class="seal"><svg><use href="#i-card"></use></svg></span>
|
|
169
|
+
<div>
|
|
170
|
+
<b>Publishing to your live store is part of the paid plan</b>
|
|
171
|
+
<span>
|
|
172
|
+
Previewing and reviewing changes stays free. Upgrade to take changes live
|
|
173
|
+
and publish your public site.
|
|
174
|
+
</span>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
<a class="btn primary" href="mailto:success@tokenoftrust.com?subject=Upgrade%20store%20publishing">
|
|
178
|
+
Upgrade to publish
|
|
179
|
+
</a>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{goLiveEntitled && !viewerCanShip && (
|
|
184
|
+
<div class="ship-gate amber">
|
|
185
|
+
<div class="ship-gate-main">
|
|
186
|
+
<span class="seal"><svg><use href="#i-shield"></use></svg></span>
|
|
187
|
+
<div>
|
|
188
|
+
<b>Read-only view</b>
|
|
189
|
+
<span>Going live requires an owner or admin sign-in for this store.</span>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
<div class="grid cols-3">
|
|
196
|
+
<div class="metric">
|
|
197
|
+
<span>Live version</span>
|
|
198
|
+
<strong data-publish-live-version>—</strong>
|
|
199
|
+
<small data-publish-live-source>What shoppers see right now</small>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="metric">
|
|
202
|
+
<span>Published</span>
|
|
203
|
+
<strong data-publish-live-at>—</strong>
|
|
204
|
+
<small>When the live version last changed</small>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="metric">
|
|
207
|
+
<span>Open changes</span>
|
|
208
|
+
<strong data-publish-queue-count>—</strong>
|
|
209
|
+
<small>Waiting for review or go-live</small>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{staticPublishEnabled && !dispatchConfigured && (
|
|
214
|
+
<div class="risk-alert" data-publish-sink-unbound>
|
|
215
|
+
<div class="risk-alert-main">
|
|
216
|
+
<svg><use href="#i-alert"></use></svg>
|
|
217
|
+
<div>
|
|
218
|
+
<b>Automatic public-site republish is not yet enabled on this environment</b>
|
|
219
|
+
<span>
|
|
220
|
+
Going live updates the hosted store immediately; the public-site republish
|
|
221
|
+
job is dispatched once publishing is enabled here.
|
|
222
|
+
</span>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
<div class="panel">
|
|
229
|
+
<div class="panel-head">
|
|
230
|
+
<h3><svg><use href="#i-export"></use></svg>Candidate queue</h3>
|
|
231
|
+
<span class="chip" data-publish-queue-chip>Loading</span>
|
|
232
|
+
</div>
|
|
233
|
+
<div class="panel-body grid">
|
|
234
|
+
<p class="note publish-status" data-publish-status role="status" aria-live="polite"></p>
|
|
235
|
+
<div class="table-wrap">
|
|
236
|
+
<table class="publish-table">
|
|
237
|
+
<thead>
|
|
238
|
+
<tr>
|
|
239
|
+
<th>Change</th>
|
|
240
|
+
<th>Status</th>
|
|
241
|
+
<th>Checks</th>
|
|
242
|
+
<th>Head</th>
|
|
243
|
+
<th>Updated</th>
|
|
244
|
+
<th class="action">Actions</th>
|
|
245
|
+
</tr>
|
|
246
|
+
</thead>
|
|
247
|
+
<tbody data-publish-queue-body>
|
|
248
|
+
<tr data-publish-placeholder>
|
|
249
|
+
<td colspan="6"><span class="subtle">Loading the change queue…</span></td>
|
|
250
|
+
</tr>
|
|
251
|
+
</tbody>
|
|
252
|
+
</table>
|
|
253
|
+
</div>
|
|
254
|
+
{staticPublishEnabled && (
|
|
255
|
+
<p class="note" data-publish-dispatch-status>
|
|
256
|
+
{dispatchConfigured
|
|
257
|
+
? "Public-site republish: dispatched automatically when a change goes live."
|
|
258
|
+
: "Public-site republish: not yet enabled on this environment."}
|
|
259
|
+
</p>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<!-- b11: the AGGREGATE card — the protected `preview` aggregate the accepted
|
|
265
|
+
candidates integrate into, its combined evidence (the last integration
|
|
266
|
+
run's honest state), and the exact-digest Publish (b09/b10 ship the
|
|
267
|
+
reviewed pinned SHA + digest, never a rebuild). -->
|
|
268
|
+
<div class="panel" data-publish-aggregate>
|
|
269
|
+
<div class="panel-head">
|
|
270
|
+
<h3><svg><use href="#i-box"></use></svg>Preview aggregate</h3>
|
|
271
|
+
<span class="chip" data-aggregate-chip>Loading</span>
|
|
272
|
+
</div>
|
|
273
|
+
<div class="panel-body grid">
|
|
274
|
+
<p class="note publish-status" data-aggregate-status role="status" aria-live="polite"></p>
|
|
275
|
+
|
|
276
|
+
<div class="grid cols-3">
|
|
277
|
+
<div class="field">
|
|
278
|
+
<label>Aggregate build</label>
|
|
279
|
+
<strong class="publish-mono" data-aggregate-sha>—</strong>
|
|
280
|
+
<p data-aggregate-run-link>The current green preview build for this store.</p>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="field">
|
|
283
|
+
<label>Combined evidence</label>
|
|
284
|
+
<strong data-aggregate-evidence>—</strong>
|
|
285
|
+
<p data-aggregate-evidence-detail>The last integration run's verdict.</p>
|
|
286
|
+
</div>
|
|
287
|
+
<div class="field">
|
|
288
|
+
<label>Reviewed digest</label>
|
|
289
|
+
<strong class="publish-mono" data-aggregate-digest>—</strong>
|
|
290
|
+
<p>The content digest that will be published — exactly what you reviewed.</p>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<!-- Included PRs — the batch this aggregate composes (b01 includedPrs). -->
|
|
295
|
+
<div class="aggregate-prs" data-aggregate-prs-wrap hidden>
|
|
296
|
+
<b>Included changes</b>
|
|
297
|
+
<ul class="aggregate-prs-list" data-aggregate-prs></ul>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<!-- The exact-digest Publish plan (b09 plan()): pinned SHA + digest +
|
|
301
|
+
included PRs + rollback target + paywall verdict + run link. Shown
|
|
302
|
+
inline (from GET /api/changes `plan`) so the confirm never POSTs to
|
|
303
|
+
preview. Publish is refused until the plan is `ok`. -->
|
|
304
|
+
<div class="aggregate-plan" data-aggregate-plan hidden>
|
|
305
|
+
<div class="aggregate-plan-head">
|
|
306
|
+
<b>Publish plan</b>
|
|
307
|
+
<span class="chip" data-aggregate-plan-chip></span>
|
|
308
|
+
</div>
|
|
309
|
+
<dl class="aggregate-plan-grid" data-aggregate-plan-grid></dl>
|
|
310
|
+
<p class="note" data-aggregate-plan-note></p>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<div class="check-row">
|
|
314
|
+
<div>
|
|
315
|
+
<b>Publish the reviewed build to your live store</b>
|
|
316
|
+
<p data-aggregate-publish-detail>
|
|
317
|
+
Publishes the exact reviewed digest — no rebuild. Available once the
|
|
318
|
+
aggregate is green and its integration run passed.
|
|
319
|
+
</p>
|
|
320
|
+
</div>
|
|
321
|
+
<button class="btn primary" type="button" data-aggregate-publish disabled>
|
|
322
|
+
<svg><use href="#i-export"></use></svg>Publish live
|
|
323
|
+
</button>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<!-- dg7: published versions + promotion history + rollback (u2/u3 promotion_status,
|
|
329
|
+
u5 immutable /rev links, u8 rollback through the one orchestrator). -->
|
|
330
|
+
<div class="panel" data-publish-versions>
|
|
331
|
+
<div class="panel-head">
|
|
332
|
+
<h3><svg><use href="#i-refresh"></use></svg>Published versions</h3>
|
|
333
|
+
<span class="chip" data-versions-chip>Loading</span>
|
|
334
|
+
</div>
|
|
335
|
+
<div class="panel-body grid">
|
|
336
|
+
<p class="note publish-status" data-versions-status role="status" aria-live="polite"></p>
|
|
337
|
+
<p class="note" data-versions-rollback-note hidden></p>
|
|
338
|
+
<div class="table-wrap">
|
|
339
|
+
<table class="publish-table versions-table">
|
|
340
|
+
<thead>
|
|
341
|
+
<tr>
|
|
342
|
+
<th>Version</th>
|
|
343
|
+
<th>Digest</th>
|
|
344
|
+
<th>Promoted</th>
|
|
345
|
+
<th>Preview</th>
|
|
346
|
+
<th>State</th>
|
|
347
|
+
<th class="action">Actions</th>
|
|
348
|
+
</tr>
|
|
349
|
+
</thead>
|
|
350
|
+
<tbody data-versions-body>
|
|
351
|
+
<tr data-versions-placeholder>
|
|
352
|
+
<td colspan="6"><span class="subtle">Loading published versions…</span></td>
|
|
353
|
+
</tr>
|
|
354
|
+
</tbody>
|
|
355
|
+
</table>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
{staticPublishEnabled && (
|
|
361
|
+
<div class="panel" data-publish-domain>
|
|
362
|
+
<div class="panel-head">
|
|
363
|
+
<h3><svg><use href="#i-shield"></use></svg>Public domain</h3>
|
|
364
|
+
<span class="chip" data-domain-state-chip>Loading</span>
|
|
365
|
+
</div>
|
|
366
|
+
<div class="panel-body grid">
|
|
367
|
+
<div class="grid cols-3">
|
|
368
|
+
<div class="field">
|
|
369
|
+
<label>Serving from</label>
|
|
370
|
+
<strong data-domain-state>—</strong>
|
|
371
|
+
<p data-domain-since>Current host for {tenant.appDomain}</p>
|
|
372
|
+
</div>
|
|
373
|
+
<div class="field">
|
|
374
|
+
<label>Rollback records</label>
|
|
375
|
+
<strong data-domain-capture>None captured</strong>
|
|
376
|
+
<p>DNS records captured before connect — rollback restores these.</p>
|
|
377
|
+
</div>
|
|
378
|
+
<div class="field">
|
|
379
|
+
<label>Last run</label>
|
|
380
|
+
<strong data-domain-run>None</strong>
|
|
381
|
+
<p data-domain-run-detail>Connect and rollback runs appear here.</p>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
<p class="note publish-status" data-domain-status role="status" aria-live="polite"></p>
|
|
386
|
+
|
|
387
|
+
<!-- u9: apex-cutover readiness — the itemized preconditions the server gate
|
|
388
|
+
requires before a connect is permitted. Populated from
|
|
389
|
+
/api/domain/readiness (the SAME gate `tot go-live` reads). -->
|
|
390
|
+
<div class="domain-readiness" data-domain-readiness hidden>
|
|
391
|
+
<div class="domain-readiness-head">
|
|
392
|
+
<b>Cutover readiness</b>
|
|
393
|
+
<span class="chip" data-domain-readiness-chip>Checking…</span>
|
|
394
|
+
</div>
|
|
395
|
+
<ul class="domain-readiness-list" data-domain-readiness-list></ul>
|
|
396
|
+
<p class="note" data-domain-readiness-note></p>
|
|
397
|
+
</div>
|
|
398
|
+
|
|
399
|
+
<div class="check-row">
|
|
400
|
+
<div>
|
|
401
|
+
<b>Connect {tenant.appDomain} to the storefront</b>
|
|
402
|
+
<p>
|
|
403
|
+
Updates two DNS names — <code>{tenant.appDomain}</code> and <code>www.{tenant.appDomain}</code> —
|
|
404
|
+
to serve the published site. Takes effect in about a minute. Type the
|
|
405
|
+
domain to confirm.
|
|
406
|
+
</p>
|
|
407
|
+
{!viewerCanApexCutover && (
|
|
408
|
+
<p><span class="chip amber">Owner/admin only</span> Connecting the domain requires an owner or admin sign-in.</p>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
411
|
+
<div class="domain-connect-controls">
|
|
412
|
+
<input
|
|
413
|
+
type="text"
|
|
414
|
+
placeholder={tenant.appDomain}
|
|
415
|
+
autocomplete="off"
|
|
416
|
+
spellcheck="false"
|
|
417
|
+
aria-label="Type the domain to confirm"
|
|
418
|
+
data-domain-confirm
|
|
419
|
+
disabled={!viewerCanApexCutover}
|
|
420
|
+
/>
|
|
421
|
+
<label class="domain-rehearsal">
|
|
422
|
+
<input type="checkbox" data-domain-rehearsal disabled={!viewerCanApexCutover} />
|
|
423
|
+
Rehearsal (dry run, no DNS change)
|
|
424
|
+
</label>
|
|
425
|
+
<button
|
|
426
|
+
class="btn primary"
|
|
427
|
+
type="button"
|
|
428
|
+
data-domain-connect
|
|
429
|
+
data-owner-locked={!viewerCanApexCutover ? "true" : undefined}
|
|
430
|
+
disabled={!viewerCanApexCutover}
|
|
431
|
+
>
|
|
432
|
+
<svg><use href="#i-check"></use></svg>Connect domain
|
|
433
|
+
</button>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
<div class="check-row">
|
|
438
|
+
<div>
|
|
439
|
+
<b>Roll back to the previous host</b>
|
|
440
|
+
<p data-domain-rollback-detail>
|
|
441
|
+
Restores the DNS records captured before connect. Available once records
|
|
442
|
+
have been captured.
|
|
443
|
+
</p>
|
|
444
|
+
</div>
|
|
445
|
+
<button class="btn" type="button" data-domain-rollback disabled={!viewerCanShip}>
|
|
446
|
+
<svg><use href="#i-refresh"></use></svg>Roll back
|
|
447
|
+
</button>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
</section>
|
|
453
|
+
|
|
454
|
+
<script is:inline nonce={cspNonce}>
|
|
455
|
+
(() => {
|
|
456
|
+
const root = document.getElementById("publish");
|
|
457
|
+
if (!root) return;
|
|
458
|
+
const ds = root.dataset;
|
|
459
|
+
const canShip = ds.canShip === "true";
|
|
460
|
+
const repo = ds.repo || "";
|
|
461
|
+
const appDomain = ds.appDomain || "";
|
|
462
|
+
|
|
463
|
+
const statusEl = root.querySelector("[data-publish-status]");
|
|
464
|
+
const queueBody = root.querySelector("[data-publish-queue-body]");
|
|
465
|
+
const queueChip = root.querySelector("[data-publish-queue-chip]");
|
|
466
|
+
const queueCount = root.querySelector("[data-publish-queue-count]");
|
|
467
|
+
|
|
468
|
+
const setStatus = (el, message, state) => {
|
|
469
|
+
if (!el) return;
|
|
470
|
+
el.textContent = message || "";
|
|
471
|
+
if (state) el.dataset.state = state;
|
|
472
|
+
else delete el.dataset.state;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const shortSha = (sha) => (sha ? String(sha).slice(0, 8) : "—");
|
|
476
|
+
|
|
477
|
+
const age = (iso) => {
|
|
478
|
+
if (!iso) return "—";
|
|
479
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
480
|
+
if (!Number.isFinite(ms) || ms < 0) return "—";
|
|
481
|
+
const mins = Math.floor(ms / 60000);
|
|
482
|
+
if (mins < 1) return "just now";
|
|
483
|
+
if (mins < 60) return mins + "m ago";
|
|
484
|
+
const hours = Math.floor(mins / 60);
|
|
485
|
+
if (hours < 48) return hours + "h ago";
|
|
486
|
+
return Math.floor(hours / 24) + "d ago";
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const STATUS_CHIP = {
|
|
490
|
+
queued: "",
|
|
491
|
+
reconciling: "blue",
|
|
492
|
+
ready: "green",
|
|
493
|
+
blocked: "red",
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const chip = (text, tone) => {
|
|
497
|
+
const el = document.createElement("span");
|
|
498
|
+
el.className = "chip" + (tone ? " " + tone : "");
|
|
499
|
+
el.textContent = text;
|
|
500
|
+
return el;
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const cell = (...children) => {
|
|
504
|
+
const td = document.createElement("td");
|
|
505
|
+
for (const child of children) {
|
|
506
|
+
if (child == null) continue;
|
|
507
|
+
td.append(typeof child === "string" ? document.createTextNode(child) : child);
|
|
508
|
+
}
|
|
509
|
+
return td;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const subtle = (text) => {
|
|
513
|
+
const el = document.createElement("span");
|
|
514
|
+
el.className = "subtle";
|
|
515
|
+
el.textContent = text;
|
|
516
|
+
return el;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// ---- Open change queue -------------------------------------------------
|
|
520
|
+
|
|
521
|
+
const renderQueue = (payload) => {
|
|
522
|
+
const changes = Array.isArray(payload.changes) ? payload.changes : [];
|
|
523
|
+
queueBody.textContent = "";
|
|
524
|
+
if (queueCount) queueCount.textContent = String(changes.length);
|
|
525
|
+
if (queueChip) {
|
|
526
|
+
queueChip.textContent = changes.length === 1 ? "1 open" : changes.length + " open";
|
|
527
|
+
queueChip.className = "chip" + (changes.length ? " blue" : "");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!changes.length) {
|
|
531
|
+
const tr = document.createElement("tr");
|
|
532
|
+
const td = cell(
|
|
533
|
+
subtle(
|
|
534
|
+
"No open changes. New store changes appear here when a developer submits them for review.",
|
|
535
|
+
),
|
|
536
|
+
);
|
|
537
|
+
td.colSpan = 6;
|
|
538
|
+
tr.append(td);
|
|
539
|
+
queueBody.append(tr);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
for (const change of changes) {
|
|
544
|
+
const tr = document.createElement("tr");
|
|
545
|
+
|
|
546
|
+
// Change id + branch / PR + preview link.
|
|
547
|
+
const idCell = document.createElement("td");
|
|
548
|
+
const idStrong = document.createElement("strong");
|
|
549
|
+
idStrong.textContent = change.changeId || "—";
|
|
550
|
+
idCell.append(idStrong);
|
|
551
|
+
const meta = [];
|
|
552
|
+
if (change.branch) meta.push(change.branch);
|
|
553
|
+
if (change.prNumber != null) meta.push("PR #" + change.prNumber);
|
|
554
|
+
if (meta.length) idCell.append(subtle(meta.join(" · ")));
|
|
555
|
+
if (change.previewUrl) {
|
|
556
|
+
const preview = document.createElement("a");
|
|
557
|
+
preview.href = change.previewUrl;
|
|
558
|
+
preview.target = "_blank";
|
|
559
|
+
preview.rel = "noopener";
|
|
560
|
+
preview.textContent = "Open preview";
|
|
561
|
+
const wrap = document.createElement("span");
|
|
562
|
+
wrap.className = "subtle";
|
|
563
|
+
wrap.append(preview);
|
|
564
|
+
idCell.append(wrap);
|
|
565
|
+
}
|
|
566
|
+
tr.append(idCell);
|
|
567
|
+
|
|
568
|
+
// Reconcile status.
|
|
569
|
+
const statusCell = cell(
|
|
570
|
+
chip(change.status || "unknown", STATUS_CHIP[change.status] ?? "amber"),
|
|
571
|
+
);
|
|
572
|
+
if (change.lastError) statusCell.append(subtle(change.lastError));
|
|
573
|
+
tr.append(statusCell);
|
|
574
|
+
|
|
575
|
+
// Evidence / compliance verdict.
|
|
576
|
+
const checks = (change.evidence && change.evidence.checks) || [];
|
|
577
|
+
const failing = checks.filter((c) => c.status === "fail");
|
|
578
|
+
const promotable = Boolean(change.evidence && change.evidence.promotable);
|
|
579
|
+
const checksCell = cell(
|
|
580
|
+
promotable ? chip("Checks passed", "green") : chip("Checks blocked", "amber"),
|
|
581
|
+
);
|
|
582
|
+
if (failing.length) {
|
|
583
|
+
checksCell.append(subtle("Failing: " + failing.map((c) => c.label || c.id).join(", ")));
|
|
584
|
+
} else if (!checks.length) {
|
|
585
|
+
checksCell.append(subtle("No checks reported yet"));
|
|
586
|
+
}
|
|
587
|
+
tr.append(checksCell);
|
|
588
|
+
|
|
589
|
+
// Head sha + age.
|
|
590
|
+
const headCell = cell(shortSha(change.headSha));
|
|
591
|
+
headCell.classList.add("publish-mono");
|
|
592
|
+
tr.append(headCell);
|
|
593
|
+
tr.append(cell(age(change.updatedAt)));
|
|
594
|
+
|
|
595
|
+
// Actions.
|
|
596
|
+
const actions = document.createElement("td");
|
|
597
|
+
actions.className = "action publish-actions";
|
|
598
|
+
// dg7 Diff: read-only, available to any viewer who can see the queue.
|
|
599
|
+
const diffBtn = document.createElement("button");
|
|
600
|
+
diffBtn.type = "button";
|
|
601
|
+
diffBtn.className = "btn";
|
|
602
|
+
diffBtn.textContent = "Diff";
|
|
603
|
+
diffBtn.title = "Show what changed vs the live version";
|
|
604
|
+
diffBtn.addEventListener("click", () => toggleDiff(change, diffBtn, tr));
|
|
605
|
+
actions.append(diffBtn);
|
|
606
|
+
// b11 SPLIT: the candidate-queue primary action is now ACCEPT TO PREVIEW
|
|
607
|
+
// (b08 integrate = advance the shared preview aggregate), NOT go-live.
|
|
608
|
+
// Integrate flips NO live channel, so it has NO paywall (entitled) gate —
|
|
609
|
+
// it holds only the ship-on-behalf capability floor. Going live is the
|
|
610
|
+
// aggregate card's Publish action, once the aggregate is green.
|
|
611
|
+
const accept = document.createElement("button");
|
|
612
|
+
accept.type = "button";
|
|
613
|
+
accept.className = "btn primary";
|
|
614
|
+
accept.textContent = "Accept to preview";
|
|
615
|
+
accept.disabled = !canShip || !change.mergeable;
|
|
616
|
+
accept.title = !canShip
|
|
617
|
+
? "Requires an owner or admin sign-in"
|
|
618
|
+
: !change.mergeable
|
|
619
|
+
? "This change is not ready to integrate"
|
|
620
|
+
: "Integrate this change into the preview aggregate (does not go live)";
|
|
621
|
+
accept.addEventListener("click", () => integrateCandidate(change, accept));
|
|
622
|
+
actions.append(accept);
|
|
623
|
+
const reject = document.createElement("button");
|
|
624
|
+
reject.type = "button";
|
|
625
|
+
reject.className = "btn";
|
|
626
|
+
reject.textContent = "Reject";
|
|
627
|
+
reject.disabled = !canShip;
|
|
628
|
+
reject.title = canShip ? "Close this change without publishing" : "Requires an owner or admin sign-in";
|
|
629
|
+
reject.addEventListener("click", () => rejectChange(change, reject));
|
|
630
|
+
actions.append(reject);
|
|
631
|
+
|
|
632
|
+
// U7 Retire/GC: evict this candidate's preview environment + version to
|
|
633
|
+
// reclaim space. DISTINCT from Reject — retire is reversible-by-rebuild
|
|
634
|
+
// (U1 build-on-demand + the U2 fallback page rematerialize it), so a
|
|
635
|
+
// retired PR degrades to "not built yet", not a dead 404.
|
|
636
|
+
const retire = document.createElement("button");
|
|
637
|
+
retire.type = "button";
|
|
638
|
+
retire.className = "btn";
|
|
639
|
+
retire.textContent = "Retire";
|
|
640
|
+
retire.disabled = !canShip;
|
|
641
|
+
retire.title = canShip
|
|
642
|
+
? "Evict this preview to reclaim space — rebuildable on demand (not the same as Reject)"
|
|
643
|
+
: "Requires an owner or admin sign-in";
|
|
644
|
+
retire.addEventListener("click", () => retireChange(change, retire));
|
|
645
|
+
actions.append(retire);
|
|
646
|
+
tr.append(actions);
|
|
647
|
+
|
|
648
|
+
queueBody.append(tr);
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const renderCurrent = (current) => {
|
|
653
|
+
const versionEl = root.querySelector("[data-publish-live-version]");
|
|
654
|
+
const sourceEl = root.querySelector("[data-publish-live-source]");
|
|
655
|
+
const atEl = root.querySelector("[data-publish-live-at]");
|
|
656
|
+
if (current && current.versionId) {
|
|
657
|
+
if (versionEl) versionEl.textContent = shortSha(current.versionId);
|
|
658
|
+
if (sourceEl) {
|
|
659
|
+
sourceEl.textContent =
|
|
660
|
+
(current.source === "commit" ? "Published from a change" : "Published version") +
|
|
661
|
+
(current.artifactCount != null ? " · " + current.artifactCount + " files" : "");
|
|
662
|
+
}
|
|
663
|
+
if (atEl) atEl.textContent = current.createdAt ? age(current.createdAt) : "—";
|
|
664
|
+
} else {
|
|
665
|
+
if (versionEl) versionEl.textContent = "Not live yet";
|
|
666
|
+
if (sourceEl) sourceEl.textContent = "Nothing has been published for this store";
|
|
667
|
+
if (atEl) atEl.textContent = "—";
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const loadQueue = async () => {
|
|
672
|
+
try {
|
|
673
|
+
const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
|
|
674
|
+
if (res.status === 401 || res.status === 403) {
|
|
675
|
+
if (queueChip) queueChip.textContent = "Sign-in required";
|
|
676
|
+
queueBody.textContent = "";
|
|
677
|
+
const tr = document.createElement("tr");
|
|
678
|
+
const td = cell(subtle("Sign in as a store owner or admin to view the publish queue."));
|
|
679
|
+
td.colSpan = 6;
|
|
680
|
+
tr.append(td);
|
|
681
|
+
queueBody.append(tr);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const data = await res.json();
|
|
685
|
+
if (!res.ok) {
|
|
686
|
+
setStatus(statusEl, data.error || "Could not load the change queue (" + res.status + ")", "err");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
renderCurrent(data.current);
|
|
690
|
+
renderQueue(data);
|
|
691
|
+
renderAggregate(data);
|
|
692
|
+
} catch {
|
|
693
|
+
setStatus(statusEl, "Could not load the change queue — network error", "err");
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// ---- b11: the aggregate card + exact-digest Publish ---------------------
|
|
698
|
+
// Reads the aggregate/runs/plan projection GET /api/changes now returns
|
|
699
|
+
// (AggregateCardView + AggregateShipPlanView, built by aggregateView.ts):
|
|
700
|
+
// - the aggregate's queueState + build sha + reviewed digest,
|
|
701
|
+
// - the COMBINED EVIDENCE = the last integration run's honest state +
|
|
702
|
+
// statusMessage (never re-derived),
|
|
703
|
+
// - the included-PR batch,
|
|
704
|
+
// - the b09 ship PLAN (pinned SHA/digest/PRs/rollback/paywall + run link),
|
|
705
|
+
// shown inline so the confirm never has to POST to preview it.
|
|
706
|
+
|
|
707
|
+
const aggPanel = root.querySelector("[data-publish-aggregate]");
|
|
708
|
+
const AGG_STATE_CHIP = {
|
|
709
|
+
empty: "",
|
|
710
|
+
queued: "amber",
|
|
711
|
+
integrating: "blue",
|
|
712
|
+
green: "green",
|
|
713
|
+
red: "red",
|
|
714
|
+
shipped: "green",
|
|
715
|
+
};
|
|
716
|
+
// The plan we last rendered — the exact-digest guard the Publish POST sends
|
|
717
|
+
// back so a stale plan (queue moved / artifact changed) is refused, never
|
|
718
|
+
// shipped blind.
|
|
719
|
+
let currentPlan = null;
|
|
720
|
+
|
|
721
|
+
const runLink = (revHref, label) => {
|
|
722
|
+
if (!revHref) return null;
|
|
723
|
+
const a = document.createElement("a");
|
|
724
|
+
a.href = revHref;
|
|
725
|
+
a.target = "_blank";
|
|
726
|
+
a.rel = "noopener";
|
|
727
|
+
a.textContent = label || "Open build";
|
|
728
|
+
return a;
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const renderAggregate = (data) => {
|
|
732
|
+
if (!aggPanel) return;
|
|
733
|
+
const agg = data.aggregate || null;
|
|
734
|
+
const plan = data.plan || null;
|
|
735
|
+
currentPlan = plan && plan.ok ? plan : null;
|
|
736
|
+
|
|
737
|
+
const chipEl = aggPanel.querySelector("[data-aggregate-chip]");
|
|
738
|
+
const shaEl = aggPanel.querySelector("[data-aggregate-sha]");
|
|
739
|
+
const runLinkEl = aggPanel.querySelector("[data-aggregate-run-link]");
|
|
740
|
+
const evEl = aggPanel.querySelector("[data-aggregate-evidence]");
|
|
741
|
+
const evDetailEl = aggPanel.querySelector("[data-aggregate-evidence-detail]");
|
|
742
|
+
const digestEl = aggPanel.querySelector("[data-aggregate-digest]");
|
|
743
|
+
const prsWrap = aggPanel.querySelector("[data-aggregate-prs-wrap]");
|
|
744
|
+
const prsList = aggPanel.querySelector("[data-aggregate-prs]");
|
|
745
|
+
const publishBtn = aggPanel.querySelector("[data-aggregate-publish]");
|
|
746
|
+
const publishDetail = aggPanel.querySelector("[data-aggregate-publish-detail]");
|
|
747
|
+
|
|
748
|
+
const state = (agg && agg.queueState) || "empty";
|
|
749
|
+
if (chipEl) {
|
|
750
|
+
chipEl.textContent = state;
|
|
751
|
+
chipEl.className = "chip " + (AGG_STATE_CHIP[state] || "");
|
|
752
|
+
}
|
|
753
|
+
if (shaEl) shaEl.textContent = agg && agg.aggregateSha ? shortSha(agg.aggregateSha) : "—";
|
|
754
|
+
if (runLinkEl) {
|
|
755
|
+
runLinkEl.textContent = "";
|
|
756
|
+
const link = agg && runLink(agg.revHref, "Open build");
|
|
757
|
+
if (link) runLinkEl.append(link);
|
|
758
|
+
else runLinkEl.append(document.createTextNode("The current green preview build for this store."));
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Combined evidence = the last (or in-flight) integration run's honest state.
|
|
762
|
+
const lastRun = agg && (agg.activeRun || agg.lastRun);
|
|
763
|
+
if (evEl) {
|
|
764
|
+
if (!lastRun) {
|
|
765
|
+
evEl.textContent = "No runs yet";
|
|
766
|
+
} else if (lastRun.state === "passed") {
|
|
767
|
+
evEl.textContent = "Passed";
|
|
768
|
+
} else if (lastRun.state === "failed") {
|
|
769
|
+
evEl.textContent = "Failed";
|
|
770
|
+
} else {
|
|
771
|
+
evEl.textContent = "Integrating…";
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (evDetailEl) {
|
|
775
|
+
evDetailEl.textContent = "";
|
|
776
|
+
if (lastRun) {
|
|
777
|
+
const parts = [];
|
|
778
|
+
if (lastRun.statusMessage) parts.push(lastRun.statusMessage);
|
|
779
|
+
else if (lastRun.state === "passed") parts.push("Combined evidence passed.");
|
|
780
|
+
else if (lastRun.state === "failed") parts.push("Combined evidence did not pass.");
|
|
781
|
+
if (lastRun.finishedAt) parts.push(age(lastRun.finishedAt));
|
|
782
|
+
evDetailEl.append(document.createTextNode(parts.join(" · ")));
|
|
783
|
+
const rl = runLink(lastRun.revHref, "Run build");
|
|
784
|
+
if (rl) {
|
|
785
|
+
evDetailEl.append(document.createTextNode(" · "));
|
|
786
|
+
evDetailEl.append(rl);
|
|
787
|
+
}
|
|
788
|
+
} else {
|
|
789
|
+
evDetailEl.append(document.createTextNode("The last integration run's verdict."));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Reviewed digest: only known once the plan pins it (b09). Never fabricated.
|
|
794
|
+
if (digestEl) {
|
|
795
|
+
digestEl.textContent = currentPlan ? shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) : "—";
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Included PRs.
|
|
799
|
+
const prs = (agg && agg.includedPrs) || [];
|
|
800
|
+
if (prsWrap) prsWrap.hidden = prs.length === 0;
|
|
801
|
+
if (prsList) {
|
|
802
|
+
prsList.textContent = "";
|
|
803
|
+
for (const pr of prs) {
|
|
804
|
+
const li = document.createElement("li");
|
|
805
|
+
const label = document.createElement("span");
|
|
806
|
+
label.textContent = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
|
|
807
|
+
const sha = document.createElement("span");
|
|
808
|
+
sha.className = "publish-mono subtle";
|
|
809
|
+
sha.textContent = shortSha(pr.headSha);
|
|
810
|
+
li.append(label, sha);
|
|
811
|
+
prsList.append(li);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
renderPlan(data.plan);
|
|
816
|
+
|
|
817
|
+
// The Publish button: enabled only for a shippable, paywall-cleared plan and
|
|
818
|
+
// a viewer who can ship. A paywalled plan is SHOWN (upsell), never hidden.
|
|
819
|
+
if (publishBtn) {
|
|
820
|
+
const shippable = Boolean(currentPlan);
|
|
821
|
+
const paywalled = currentPlan && currentPlan.paywall && !currentPlan.paywall.allowed;
|
|
822
|
+
publishBtn.disabled = !canShip || !shippable || paywalled || currentPlan.alreadyShipped;
|
|
823
|
+
publishBtn.title = !canShip
|
|
824
|
+
? "Requires an owner or admin sign-in"
|
|
825
|
+
: !shippable
|
|
826
|
+
? "The aggregate is not green / has no passed run to publish yet"
|
|
827
|
+
: paywalled
|
|
828
|
+
? "Publishing to your live store is part of the paid plan"
|
|
829
|
+
: currentPlan.alreadyShipped
|
|
830
|
+
? "This reviewed build is already live"
|
|
831
|
+
: "Publish the exact reviewed digest to your live store";
|
|
832
|
+
}
|
|
833
|
+
if (publishDetail) {
|
|
834
|
+
if (currentPlan && currentPlan.alreadyShipped) {
|
|
835
|
+
publishDetail.textContent = "This reviewed build (" + shortSha(currentPlan.pinnedSha) + ") is already live.";
|
|
836
|
+
} else if (currentPlan) {
|
|
837
|
+
publishDetail.textContent =
|
|
838
|
+
"Publishes the exact reviewed digest " + shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) +
|
|
839
|
+
" (build " + shortSha(currentPlan.pinnedSha) + ") — no rebuild.";
|
|
840
|
+
} else {
|
|
841
|
+
publishDetail.textContent =
|
|
842
|
+
"Publishes the exact reviewed digest — no rebuild. Available once the aggregate is green and its integration run passed.";
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
const renderPlan = (plan) => {
|
|
848
|
+
const planBox = aggPanel && aggPanel.querySelector("[data-aggregate-plan]");
|
|
849
|
+
if (!planBox) return;
|
|
850
|
+
const chipEl = planBox.querySelector("[data-aggregate-plan-chip]");
|
|
851
|
+
const grid = planBox.querySelector("[data-aggregate-plan-grid]");
|
|
852
|
+
const note = planBox.querySelector("[data-aggregate-plan-note]");
|
|
853
|
+
if (grid) grid.textContent = "";
|
|
854
|
+
|
|
855
|
+
// No plan at all (degenerate: no store bound) — hide the box.
|
|
856
|
+
if (!plan) {
|
|
857
|
+
planBox.hidden = true;
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
planBox.hidden = false;
|
|
861
|
+
|
|
862
|
+
if (!plan.ok) {
|
|
863
|
+
// An honest refusal — show WHY it isn't shippable, never a fake plan.
|
|
864
|
+
if (chipEl) {
|
|
865
|
+
chipEl.textContent = "Not shippable";
|
|
866
|
+
chipEl.className = "chip amber";
|
|
867
|
+
}
|
|
868
|
+
if (note) note.textContent = plan.message || ("Not shippable (" + (plan.reason || "refused") + ").");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const paywalled = plan.paywall && !plan.paywall.allowed;
|
|
873
|
+
if (chipEl) {
|
|
874
|
+
chipEl.textContent = plan.alreadyShipped ? "Already live" : paywalled ? "Upgrade required" : "Ready to publish";
|
|
875
|
+
chipEl.className = "chip " + (plan.alreadyShipped ? "green" : paywalled ? "amber" : "green");
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const row = (term, value, mono) => {
|
|
879
|
+
const dt = document.createElement("dt");
|
|
880
|
+
dt.textContent = term;
|
|
881
|
+
const dd = document.createElement("dd");
|
|
882
|
+
if (value instanceof Node) dd.append(value);
|
|
883
|
+
else {
|
|
884
|
+
dd.textContent = value;
|
|
885
|
+
if (mono) dd.className = "publish-mono";
|
|
886
|
+
}
|
|
887
|
+
grid.append(dt, dd);
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
row("Reviewed build", shortSha(plan.pinnedSha), true);
|
|
891
|
+
row("Reviewed digest", shortSha(plan.artifactDigest.replace(/^sha256:/, "")), true);
|
|
892
|
+
row("Included changes", String((plan.includedPrs || []).length) + " change" + ((plan.includedPrs || []).length === 1 ? "" : "s"));
|
|
893
|
+
const runCell = document.createElement("span");
|
|
894
|
+
runCell.append(document.createTextNode(plan.integrationRunId));
|
|
895
|
+
const planRunLink = runLink(plan.pinnedRevHref, "Open build");
|
|
896
|
+
if (planRunLink) {
|
|
897
|
+
runCell.append(document.createTextNode(" · "));
|
|
898
|
+
runCell.append(planRunLink);
|
|
899
|
+
}
|
|
900
|
+
row("Integration run", runCell);
|
|
901
|
+
if (plan.rollbackTarget) {
|
|
902
|
+
row("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true);
|
|
903
|
+
} else {
|
|
904
|
+
row("Rollback to", "None (first publish)");
|
|
905
|
+
}
|
|
906
|
+
row("Subscription", plan.paywall && plan.paywall.allowed ? "Enabled" : "Not enabled");
|
|
907
|
+
|
|
908
|
+
if (note) {
|
|
909
|
+
note.textContent = paywalled
|
|
910
|
+
? (plan.paywall.message || "Publishing to your live store requires the paid plan.")
|
|
911
|
+
: plan.alreadyShipped
|
|
912
|
+
? "This reviewed build is already live — nothing to publish."
|
|
913
|
+
: "Publish sends the exact reviewed digest above to your live store. No rebuild.";
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
// The exact-digest Publish (b10 ship = b09 orchestrator.ship). Confirms the
|
|
918
|
+
// pinned plan, then POSTs { confirmGoLive, expectedAggregateSha,
|
|
919
|
+
// expectedArtifactDigest } — the reviewed-digest guard: a stale plan (queue
|
|
920
|
+
// moved / artifact changed) is refused by the server, never shipped blind.
|
|
921
|
+
const buildPublishPlanText = (plan) => {
|
|
922
|
+
const lines = ["Publish plan (exact reviewed digest — no rebuild):"];
|
|
923
|
+
lines.push(" tenant: " + (appDomain || repo));
|
|
924
|
+
lines.push(" build: " + shortSha(plan.pinnedSha));
|
|
925
|
+
lines.push(" digest: " + plan.artifactDigest);
|
|
926
|
+
lines.push(" changes: " + (plan.includedPrs || []).length + " included");
|
|
927
|
+
if (plan.rollbackTarget) lines.push(" rollback: " + shortSha(plan.rollbackTarget.aggregateSha));
|
|
928
|
+
lines.push(" effect: publish this exact digest to your LIVE store.");
|
|
929
|
+
return lines.join("\n");
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
const publishAggregate = async (button) => {
|
|
933
|
+
if (!currentPlan) return;
|
|
934
|
+
const plan = currentPlan;
|
|
935
|
+
if (!window.confirm(buildPublishPlanText(plan) + "\n\nPublish live?")) return;
|
|
936
|
+
button.disabled = true;
|
|
937
|
+
setStatus(aggStatusEl, "Publishing the reviewed build (" + shortSha(plan.pinnedSha) + ") live…");
|
|
938
|
+
try {
|
|
939
|
+
const res = await fetch(ds.shipEndpoint, {
|
|
940
|
+
method: "POST",
|
|
941
|
+
headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
|
|
942
|
+
body: JSON.stringify({
|
|
943
|
+
aggregateId: plan.aggregateId,
|
|
944
|
+
// The HUMAN go-live gate + the reviewed-digest guards (b09 ship input).
|
|
945
|
+
confirmGoLive: true,
|
|
946
|
+
expectedAggregateSha: plan.pinnedSha,
|
|
947
|
+
expectedArtifactDigest: plan.artifactDigest,
|
|
948
|
+
}),
|
|
949
|
+
});
|
|
950
|
+
const data = await res.json().catch(() => ({}));
|
|
951
|
+
// b09 AggregateShipResult: the ONLY live claim is state === "shipped".
|
|
952
|
+
if (data.state === "shipped") {
|
|
953
|
+
setStatus(
|
|
954
|
+
aggStatusEl,
|
|
955
|
+
"Published live · build " + shortSha(data.pinnedSha) + " (receipt " + (data.receiptId || "—") + ").",
|
|
956
|
+
"ok",
|
|
957
|
+
);
|
|
958
|
+
} else if (data.state === "promote_failed" || data.state === "record_failed") {
|
|
959
|
+
// Honest partial-failure truth — recoverable, never a false "shipped".
|
|
960
|
+
setStatus(aggStatusEl, data.message || "Publish did not complete (" + data.state + "). Retry.", "err");
|
|
961
|
+
} else if (data.reason === "paywall") {
|
|
962
|
+
setStatus(aggStatusEl, data.message || "Publishing to your live store is part of the paid plan.", "err");
|
|
963
|
+
} else {
|
|
964
|
+
setStatus(
|
|
965
|
+
aggStatusEl,
|
|
966
|
+
data.message || data.error || "Publish refused (" + (data.reason || res.status) + ")",
|
|
967
|
+
"err",
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
} catch {
|
|
971
|
+
setStatus(aggStatusEl, "Publish failed — network error", "err");
|
|
972
|
+
} finally {
|
|
973
|
+
button.disabled = false;
|
|
974
|
+
loadQueue();
|
|
975
|
+
loadVersions();
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const aggStatusEl = aggPanel && aggPanel.querySelector("[data-aggregate-status]");
|
|
980
|
+
aggPanel
|
|
981
|
+
?.querySelector("[data-aggregate-publish]")
|
|
982
|
+
?.addEventListener("click", (event) => publishAggregate(event.currentTarget));
|
|
983
|
+
|
|
984
|
+
// b11 — the ACCEPT-TO-PREVIEW plan affordance (decision
|
|
985
|
+
// operator-verb-and-hosting-model): state EXACTLY what accept does now.
|
|
986
|
+
// `POST /api/changes/integrate` (b08) = integrate this candidate into the
|
|
987
|
+
// protected `preview` aggregate (b07's TenantIntegrationQueue.enqueue), then
|
|
988
|
+
// rebuild + combined-evidence the aggregate (b05). It flips NO live channel —
|
|
989
|
+
// going live is the aggregate card's Publish. So the plan says "integrate into
|
|
990
|
+
// preview", never "merge into main / deploy live".
|
|
991
|
+
const buildIntegratePlan = (change) => {
|
|
992
|
+
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
993
|
+
const lines = ["Accept-to-preview plan:", " tenant: " + (appDomain || repo)];
|
|
994
|
+
if (change.prNumber != null) lines.push(" PR: #" + change.prNumber);
|
|
995
|
+
lines.push(" change id: " + change.changeId);
|
|
996
|
+
if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
|
|
997
|
+
lines.push(
|
|
998
|
+
" effect: integrate " + target + " into the preview aggregate, then rebuild + " +
|
|
999
|
+
"re-evidence it. Does NOT go live — publish the reviewed build separately.",
|
|
1000
|
+
);
|
|
1001
|
+
return lines.join("\n");
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
// b08 integrate returns an IntegrateOutcome — { ok, queueState, runState,
|
|
1005
|
+
// aggregateSha, pointerMoved, statusMessage, reason? } — NEVER a bare
|
|
1006
|
+
// merged:true. Render it honestly: green advances the aggregate; red left it
|
|
1007
|
+
// exactly where it was and names the failing stage.
|
|
1008
|
+
const integrateCandidate = async (change, button) => {
|
|
1009
|
+
if (!window.confirm(buildIntegratePlan(change) + "\n\nProceed?")) return;
|
|
1010
|
+
button.disabled = true;
|
|
1011
|
+
setStatus(statusEl, "Integrating " + change.changeId + " into preview…");
|
|
1012
|
+
try {
|
|
1013
|
+
const res = await fetch(ds.integrateEndpoint, {
|
|
1014
|
+
method: "POST",
|
|
1015
|
+
headers: {
|
|
1016
|
+
"content-type": "application/json",
|
|
1017
|
+
// Mirror of the server-side capability model (UX): the server
|
|
1018
|
+
// re-derives capability and stays authoritative.
|
|
1019
|
+
"x-tot-capability": "ship-on-behalf",
|
|
1020
|
+
},
|
|
1021
|
+
body: JSON.stringify({
|
|
1022
|
+
repo,
|
|
1023
|
+
changeId: change.changeId,
|
|
1024
|
+
prNumber: change.prNumber ?? undefined,
|
|
1025
|
+
expectedHeadSha: change.headSha || undefined,
|
|
1026
|
+
}),
|
|
1027
|
+
});
|
|
1028
|
+
const data = await res.json().catch(() => ({}));
|
|
1029
|
+
if (res.ok && data.ok) {
|
|
1030
|
+
setStatus(
|
|
1031
|
+
statusEl,
|
|
1032
|
+
"Integrated " + change.changeId + " — preview aggregate is green (" +
|
|
1033
|
+
shortSha(data.aggregateSha) + "). Review the aggregate, then Publish to go live.",
|
|
1034
|
+
"ok",
|
|
1035
|
+
);
|
|
1036
|
+
} else if (res.ok && data.queueState === "red") {
|
|
1037
|
+
// The queue ran but the aggregate did not go green — the candidate did
|
|
1038
|
+
// NOT advance the shippable set. Name the honest failing stage.
|
|
1039
|
+
setStatus(
|
|
1040
|
+
statusEl,
|
|
1041
|
+
"Integration of " + change.changeId + " failed (" + (data.reason || "red") + "): " +
|
|
1042
|
+
(data.statusMessage || "the aggregate did not pass its combined evidence.") +
|
|
1043
|
+
" The aggregate is unchanged.",
|
|
1044
|
+
"err",
|
|
1045
|
+
);
|
|
1046
|
+
} else if (data.reason === "not_preview_base") {
|
|
1047
|
+
setStatus(
|
|
1048
|
+
statusEl,
|
|
1049
|
+
"Not integrated: " + change.changeId + " does not target the preview branch " +
|
|
1050
|
+
"(base is not \"preview\"). " + (data.statusMessage || ""),
|
|
1051
|
+
"err",
|
|
1052
|
+
);
|
|
1053
|
+
} else {
|
|
1054
|
+
setStatus(
|
|
1055
|
+
statusEl,
|
|
1056
|
+
data.statusMessage || data.error || "Integrate failed (" + res.status + ")",
|
|
1057
|
+
"err",
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
} catch {
|
|
1061
|
+
setStatus(statusEl, "Integrate failed — network error", "err");
|
|
1062
|
+
} finally {
|
|
1063
|
+
button.disabled = false;
|
|
1064
|
+
loadQueue();
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
const rejectChange = async (change, button) => {
|
|
1069
|
+
const reason = window.prompt(
|
|
1070
|
+
"Reject " + change.changeId + " — add a short note for the submitter:",
|
|
1071
|
+
);
|
|
1072
|
+
if (reason == null) return;
|
|
1073
|
+
if (!reason.trim()) {
|
|
1074
|
+
setStatus(statusEl, "A reject note is required.", "err");
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
button.disabled = true;
|
|
1078
|
+
setStatus(statusEl, "Rejecting " + change.changeId + "…");
|
|
1079
|
+
try {
|
|
1080
|
+
const res = await fetch(
|
|
1081
|
+
ds.changesEndpoint + "/" + encodeURIComponent(change.changeId) + "/reject",
|
|
1082
|
+
{
|
|
1083
|
+
method: "POST",
|
|
1084
|
+
headers: {
|
|
1085
|
+
"content-type": "application/json",
|
|
1086
|
+
"x-tot-capability": "ship-on-behalf",
|
|
1087
|
+
},
|
|
1088
|
+
body: JSON.stringify({ repo, reason: reason.trim() }),
|
|
1089
|
+
},
|
|
1090
|
+
);
|
|
1091
|
+
const data = await res.json().catch(() => ({}));
|
|
1092
|
+
if (res.ok) {
|
|
1093
|
+
setStatus(statusEl, "Change " + change.changeId + " rejected.", "ok");
|
|
1094
|
+
} else {
|
|
1095
|
+
setStatus(statusEl, data.error || "Reject failed (" + res.status + ")", "err");
|
|
1096
|
+
}
|
|
1097
|
+
} catch {
|
|
1098
|
+
setStatus(statusEl, "Reject failed — network error", "err");
|
|
1099
|
+
} finally {
|
|
1100
|
+
button.disabled = false;
|
|
1101
|
+
loadQueue();
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
// U7 — retire/GC. Mirror of the accept plan affordance (decision
|
|
1106
|
+
// operator-verb-and-hosting-model): state EXACTLY what retire does — evict the
|
|
1107
|
+
// candidate's preview environment + immutable version — and that it is
|
|
1108
|
+
// REBUILDABLE (degrades to "not built yet", not a 404), so an operator can't
|
|
1109
|
+
// confuse it with Reject. Same shape as `tot retire`'s CLI plan for parity.
|
|
1110
|
+
const buildRetirePlan = (change) => {
|
|
1111
|
+
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
1112
|
+
const lines = ["Retire plan:", " tenant: " + (appDomain || repo)];
|
|
1113
|
+
if (change.prNumber != null) lines.push(" PR: #" + change.prNumber);
|
|
1114
|
+
lines.push(" change id: " + change.changeId);
|
|
1115
|
+
if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
|
|
1116
|
+
lines.push(
|
|
1117
|
+
" effect: evict " + target + "'s preview environment + version to reclaim space.",
|
|
1118
|
+
);
|
|
1119
|
+
lines.push(
|
|
1120
|
+
" rebuild: REVERSIBLE — rebuild it any time (this is NOT Reject; the change stays open).",
|
|
1121
|
+
);
|
|
1122
|
+
return lines.join("\n");
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
const retireChange = async (change, button) => {
|
|
1126
|
+
if (!window.confirm(buildRetirePlan(change) + "\n\nProceed?")) return;
|
|
1127
|
+
button.disabled = true;
|
|
1128
|
+
setStatus(statusEl, "Retiring " + change.changeId + "…");
|
|
1129
|
+
try {
|
|
1130
|
+
const res = await fetch(ds.retireEndpoint, {
|
|
1131
|
+
method: "POST",
|
|
1132
|
+
headers: {
|
|
1133
|
+
"content-type": "application/json",
|
|
1134
|
+
// Mirror of the server-side capability model (UX); the server
|
|
1135
|
+
// re-derives capability and stays authoritative.
|
|
1136
|
+
"x-tot-capability": "ship-on-behalf",
|
|
1137
|
+
},
|
|
1138
|
+
body: JSON.stringify({
|
|
1139
|
+
changeId: change.changeId,
|
|
1140
|
+
pr: change.prNumber ?? undefined,
|
|
1141
|
+
}),
|
|
1142
|
+
});
|
|
1143
|
+
const data = await res.json().catch(() => ({}));
|
|
1144
|
+
if (res.ok && data.ok) {
|
|
1145
|
+
setStatus(
|
|
1146
|
+
statusEl,
|
|
1147
|
+
data.evicted
|
|
1148
|
+
? "Retired " + change.changeId + " — the preview was evicted (rebuildable on demand)."
|
|
1149
|
+
: "Retire " + change.changeId + ": nothing to evict (already retired).",
|
|
1150
|
+
"ok",
|
|
1151
|
+
);
|
|
1152
|
+
} else {
|
|
1153
|
+
setStatus(statusEl, data.error || "Retire failed (" + res.status + ")", "err");
|
|
1154
|
+
}
|
|
1155
|
+
} catch {
|
|
1156
|
+
setStatus(statusEl, "Retire failed — network error", "err");
|
|
1157
|
+
} finally {
|
|
1158
|
+
button.disabled = false;
|
|
1159
|
+
loadQueue();
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
// ---- dg7: diff view ----------------------------------------------------
|
|
1164
|
+
// For a pending candidate, show what changed vs the current live version,
|
|
1165
|
+
// tied to the exact tenant Gitea head SHA, with the u5 immutable /rev link
|
|
1166
|
+
// and the dg3 evidence AUTHORITY (authoritative / stale / missing). Reads
|
|
1167
|
+
// GET /api/changes/<id>/diff; never invents a diff for a non-renderable
|
|
1168
|
+
// candidate — it shows the honest blocked reason.
|
|
1169
|
+
|
|
1170
|
+
const diffBase = ds.diffEndpointBase;
|
|
1171
|
+
|
|
1172
|
+
const diffRowFor = (tr) => {
|
|
1173
|
+
const next = tr.nextElementSibling;
|
|
1174
|
+
return next && next.dataset && next.dataset.publishDiffRow === tr.dataset.changeRowId
|
|
1175
|
+
? next
|
|
1176
|
+
: null;
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
const closeDiff = (tr, button) => {
|
|
1180
|
+
const existing = diffRowFor(tr);
|
|
1181
|
+
if (existing) existing.remove();
|
|
1182
|
+
if (button) button.textContent = "Diff";
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
const toggleDiff = async (change, button, tr) => {
|
|
1186
|
+
if (!tr.dataset.changeRowId) tr.dataset.changeRowId = change.changeId;
|
|
1187
|
+
if (diffRowFor(tr)) {
|
|
1188
|
+
closeDiff(tr, button);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
button.disabled = true;
|
|
1192
|
+
button.textContent = "Loading…";
|
|
1193
|
+
const detailTr = document.createElement("tr");
|
|
1194
|
+
detailTr.dataset.publishDiffRow = tr.dataset.changeRowId;
|
|
1195
|
+
const td = document.createElement("td");
|
|
1196
|
+
td.colSpan = 6;
|
|
1197
|
+
td.className = "publish-diff-cell";
|
|
1198
|
+
detailTr.append(td);
|
|
1199
|
+
tr.after(detailTr);
|
|
1200
|
+
try {
|
|
1201
|
+
const res = await fetch(diffBase + "/" + encodeURIComponent(change.changeId) + "/diff", {
|
|
1202
|
+
headers: { accept: "application/json" },
|
|
1203
|
+
});
|
|
1204
|
+
const data = await res.json().catch(() => ({}));
|
|
1205
|
+
if (!res.ok) {
|
|
1206
|
+
td.append(subtle(data.error || "Could not load the diff (" + res.status + ")"));
|
|
1207
|
+
} else {
|
|
1208
|
+
renderDiff(td, data.diff || {});
|
|
1209
|
+
}
|
|
1210
|
+
button.textContent = "Hide diff";
|
|
1211
|
+
} catch {
|
|
1212
|
+
td.append(subtle("Could not load the diff — network error"));
|
|
1213
|
+
button.textContent = "Hide diff";
|
|
1214
|
+
} finally {
|
|
1215
|
+
button.disabled = false;
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
const CHANGE_MARK = { added: "+", changed: "~", removed: "−" };
|
|
1220
|
+
|
|
1221
|
+
const renderDiff = (td, diff) => {
|
|
1222
|
+
td.textContent = "";
|
|
1223
|
+
const head = document.createElement("div");
|
|
1224
|
+
head.className = "publish-diff-head";
|
|
1225
|
+
|
|
1226
|
+
// Authority: honest label of whether the diff describes the exact head.
|
|
1227
|
+
const authority = diff.authority || {};
|
|
1228
|
+
const authChip = chip(
|
|
1229
|
+
authority.authoritative
|
|
1230
|
+
? "Reviewed at head"
|
|
1231
|
+
: authority.stale
|
|
1232
|
+
? "Last-good preview (stale vs head)"
|
|
1233
|
+
: authority.missing
|
|
1234
|
+
? "No evidence yet"
|
|
1235
|
+
: "Head",
|
|
1236
|
+
authority.authoritative ? "green" : authority.stale ? "amber" : "",
|
|
1237
|
+
);
|
|
1238
|
+
head.append(authChip);
|
|
1239
|
+
|
|
1240
|
+
if (diff.headSha) {
|
|
1241
|
+
const sha = document.createElement("span");
|
|
1242
|
+
sha.className = "publish-mono subtle";
|
|
1243
|
+
sha.textContent = "head " + shortSha(diff.headSha);
|
|
1244
|
+
head.append(sha);
|
|
1245
|
+
}
|
|
1246
|
+
if (diff.revHref) {
|
|
1247
|
+
const rev = document.createElement("a");
|
|
1248
|
+
rev.href = diff.revHref;
|
|
1249
|
+
rev.target = "_blank";
|
|
1250
|
+
rev.rel = "noopener";
|
|
1251
|
+
rev.textContent = "Open reviewed revision";
|
|
1252
|
+
head.append(rev);
|
|
1253
|
+
}
|
|
1254
|
+
td.append(head);
|
|
1255
|
+
|
|
1256
|
+
if (!diff.renderable) {
|
|
1257
|
+
// Honest: a candidate that can't render has NO diff — say why (dg3), no fake.
|
|
1258
|
+
const reason = document.createElement("p");
|
|
1259
|
+
reason.className = "note publish-diff-blocked";
|
|
1260
|
+
reason.textContent =
|
|
1261
|
+
diff.blockedReason || "This candidate has no renderable version to diff yet.";
|
|
1262
|
+
td.append(reason);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const files = Array.isArray(diff.files) ? diff.files : [];
|
|
1267
|
+
if (!files.length) {
|
|
1268
|
+
td.append(subtle("No file changes vs the current live version."));
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const list = document.createElement("ul");
|
|
1272
|
+
list.className = "publish-diff-list";
|
|
1273
|
+
for (const f of files) {
|
|
1274
|
+
const li = document.createElement("li");
|
|
1275
|
+
li.className = "publish-diff-" + f.change;
|
|
1276
|
+
const mark = document.createElement("span");
|
|
1277
|
+
mark.className = "publish-diff-mark";
|
|
1278
|
+
mark.textContent = CHANGE_MARK[f.change] || "•";
|
|
1279
|
+
const path = document.createElement("code");
|
|
1280
|
+
path.textContent = f.path;
|
|
1281
|
+
li.append(mark, path);
|
|
1282
|
+
list.append(li);
|
|
1283
|
+
}
|
|
1284
|
+
td.append(list);
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
// ---- dg7: versions / promotion history + rollback ----------------------
|
|
1288
|
+
// The promotion history (u2/u3 promotion_status) with the tenant Gitea SHA,
|
|
1289
|
+
// its /rev link (u5), and the current-live marker; a prior version rolls back
|
|
1290
|
+
// THROUGH the one orchestrator (/api/changes/rollback → u8), rendering only the
|
|
1291
|
+
// HONEST terminal state (rolled_back only when verified).
|
|
1292
|
+
|
|
1293
|
+
const versionsPanel = root.querySelector("[data-publish-versions]");
|
|
1294
|
+
const versionsBody = versionsPanel?.querySelector("[data-versions-body]");
|
|
1295
|
+
const versionsChip = versionsPanel?.querySelector("[data-versions-chip]");
|
|
1296
|
+
const versionsStatus = versionsPanel?.querySelector("[data-versions-status]");
|
|
1297
|
+
const versionsRollbackNote = versionsPanel?.querySelector("[data-versions-rollback-note]");
|
|
1298
|
+
let rollbackMode = "control-plane";
|
|
1299
|
+
|
|
1300
|
+
const renderVersions = (view) => {
|
|
1301
|
+
if (!versionsBody) return;
|
|
1302
|
+
const versions = Array.isArray(view.versions) ? view.versions : [];
|
|
1303
|
+
rollbackMode = view.rollbackMode || "control-plane";
|
|
1304
|
+
versionsBody.textContent = "";
|
|
1305
|
+
|
|
1306
|
+
if (versionsChip) {
|
|
1307
|
+
versionsChip.textContent = versions.length === 1 ? "1 version" : versions.length + " versions";
|
|
1308
|
+
versionsChip.className = "chip" + (versions.length ? " blue" : "");
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Static tenants: per-version rollback is honestly deferred (the console
|
|
1312
|
+
// can't enumerate the S3 versions archive digest /api/changes/rollback needs).
|
|
1313
|
+
if (versionsRollbackNote) {
|
|
1314
|
+
if (rollbackMode === "static-archive") {
|
|
1315
|
+
versionsRollbackNote.hidden = false;
|
|
1316
|
+
versionsRollbackNote.textContent =
|
|
1317
|
+
"This store publishes to a static public site. Rolling back to a prior version " +
|
|
1318
|
+
"resolves the target from the versions archive — run the ship CLI (tot rollback), " +
|
|
1319
|
+
"which enumerates it; the console can't list archived object digests.";
|
|
1320
|
+
} else {
|
|
1321
|
+
versionsRollbackNote.hidden = true;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (!versions.length) {
|
|
1326
|
+
const tr = document.createElement("tr");
|
|
1327
|
+
const td = cell(subtle("No versions have been promoted to live yet."));
|
|
1328
|
+
td.colSpan = 6;
|
|
1329
|
+
tr.append(td);
|
|
1330
|
+
versionsBody.append(tr);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
for (const v of versions) {
|
|
1335
|
+
const tr = document.createElement("tr");
|
|
1336
|
+
|
|
1337
|
+
// Version identity: the tenant Gitea SHA (content authority) + source.
|
|
1338
|
+
const idCell = document.createElement("td");
|
|
1339
|
+
const strong = document.createElement("strong");
|
|
1340
|
+
strong.className = "publish-mono";
|
|
1341
|
+
strong.textContent = shortSha(v.versionId);
|
|
1342
|
+
idCell.append(strong);
|
|
1343
|
+
if (v.source) idCell.append(subtle(v.source === "commit" ? "From a commit" : "Content hash"));
|
|
1344
|
+
tr.append(idCell);
|
|
1345
|
+
|
|
1346
|
+
// Digest (static current only — never fabricated for priors).
|
|
1347
|
+
const digestCell = cell(v.digest ? shortSha(v.digest) : "—");
|
|
1348
|
+
digestCell.classList.add("publish-mono");
|
|
1349
|
+
if (!v.digest) digestCell.classList.add("subtle");
|
|
1350
|
+
tr.append(digestCell);
|
|
1351
|
+
|
|
1352
|
+
tr.append(cell(age(v.promotedAt)));
|
|
1353
|
+
|
|
1354
|
+
// Preview: the u5 immutable /rev/<sha> link.
|
|
1355
|
+
const revCell = document.createElement("td");
|
|
1356
|
+
if (v.revHref) {
|
|
1357
|
+
const a = document.createElement("a");
|
|
1358
|
+
a.href = v.revHref;
|
|
1359
|
+
a.target = "_blank";
|
|
1360
|
+
a.rel = "noopener";
|
|
1361
|
+
a.textContent = "Revision";
|
|
1362
|
+
revCell.append(a);
|
|
1363
|
+
} else {
|
|
1364
|
+
revCell.append(subtle("—"));
|
|
1365
|
+
}
|
|
1366
|
+
tr.append(revCell);
|
|
1367
|
+
|
|
1368
|
+
// State: current live vs prior.
|
|
1369
|
+
tr.append(cell(v.isCurrent ? chip("Current live", "green") : chip("Prior", "")));
|
|
1370
|
+
|
|
1371
|
+
// Action: roll back to a PRIOR version (never the current one).
|
|
1372
|
+
const act = document.createElement("td");
|
|
1373
|
+
act.className = "action publish-actions";
|
|
1374
|
+
if (!v.isCurrent) {
|
|
1375
|
+
const rollbackBtn = document.createElement("button");
|
|
1376
|
+
rollbackBtn.type = "button";
|
|
1377
|
+
rollbackBtn.className = "btn";
|
|
1378
|
+
rollbackBtn.textContent = "Roll back";
|
|
1379
|
+
const staticDeferred = rollbackMode === "static-archive";
|
|
1380
|
+
rollbackBtn.disabled = !canShip || staticDeferred;
|
|
1381
|
+
rollbackBtn.title = !canShip
|
|
1382
|
+
? "Requires an owner or ship-on-behalf sign-in"
|
|
1383
|
+
: staticDeferred
|
|
1384
|
+
? "Static rollback runs through the versions archive — use the ship CLI (tot rollback)"
|
|
1385
|
+
: "Roll the live site back to this version";
|
|
1386
|
+
rollbackBtn.addEventListener("click", () => rollbackVersion(v, rollbackBtn));
|
|
1387
|
+
act.append(rollbackBtn);
|
|
1388
|
+
}
|
|
1389
|
+
tr.append(act);
|
|
1390
|
+
|
|
1391
|
+
versionsBody.append(tr);
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
const loadVersions = async () => {
|
|
1396
|
+
if (!versionsBody) return;
|
|
1397
|
+
try {
|
|
1398
|
+
const res = await fetch(ds.versionsEndpoint, { headers: { accept: "application/json" } });
|
|
1399
|
+
if (res.status === 401 || res.status === 403) {
|
|
1400
|
+
if (versionsChip) versionsChip.textContent = "Sign-in required";
|
|
1401
|
+
versionsBody.textContent = "";
|
|
1402
|
+
const tr = document.createElement("tr");
|
|
1403
|
+
const td = cell(subtle("Sign in as a store owner or admin to view published versions."));
|
|
1404
|
+
td.colSpan = 6;
|
|
1405
|
+
tr.append(td);
|
|
1406
|
+
versionsBody.append(tr);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const data = await res.json().catch(() => ({}));
|
|
1410
|
+
if (!res.ok) {
|
|
1411
|
+
setStatus(versionsStatus, data.error || "Could not load versions (" + res.status + ")", "err");
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
renderVersions(data.view || {});
|
|
1415
|
+
} catch {
|
|
1416
|
+
setStatus(versionsStatus, "Could not load versions — network error", "err");
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
// Interpret the rollback response into ONE honest terminal state (mirrors the
|
|
1421
|
+
// pure lib/publish/shipWorkspace `interpretRollbackResponse`; duplicated in
|
|
1422
|
+
// plain JS because is:inline scripts can't import modules). `rolledBack` is
|
|
1423
|
+
// true ONLY for a verified `rolled_back` — a `publish_pending` is NEVER a
|
|
1424
|
+
// rolled-back claim.
|
|
1425
|
+
const interpretRollback = (httpOk, body) => {
|
|
1426
|
+
const toVersionId = typeof body.toVersionId === "string" ? body.toVersionId : null;
|
|
1427
|
+
if (typeof body.state === "string") {
|
|
1428
|
+
const state = ["rolled_back", "publish_pending", "publish_failed", "blocked"].includes(body.state)
|
|
1429
|
+
? body.state
|
|
1430
|
+
: "error";
|
|
1431
|
+
return {
|
|
1432
|
+
state,
|
|
1433
|
+
rolledBack: state === "rolled_back",
|
|
1434
|
+
message: body.message || body.error || "Rollback returned state " + state + ".",
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
if (httpOk && body.rolledBack === true) {
|
|
1438
|
+
return {
|
|
1439
|
+
state: "rolled_back",
|
|
1440
|
+
rolledBack: true,
|
|
1441
|
+
message: toVersionId ? "Rolled back live to " + shortSha(toVersionId) + "." : "Rolled back live.",
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
return {
|
|
1445
|
+
state: "blocked",
|
|
1446
|
+
rolledBack: false,
|
|
1447
|
+
message: (body.error || "Rollback failed") + (body.reason ? " (" + body.reason + ")" : ""),
|
|
1448
|
+
};
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
const rollbackVersion = async (version, button) => {
|
|
1452
|
+
if (
|
|
1453
|
+
!window.confirm(
|
|
1454
|
+
"Roll the live store back to version " + shortSha(version.versionId) +
|
|
1455
|
+
"? This publishes that prior version to the live site.",
|
|
1456
|
+
)
|
|
1457
|
+
) {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
button.disabled = true;
|
|
1461
|
+
setStatus(versionsStatus, "Rolling back to " + shortSha(version.versionId) + "…");
|
|
1462
|
+
try {
|
|
1463
|
+
const payload = { targetVersionId: version.versionId };
|
|
1464
|
+
// A static tenant additionally needs the archive digest — the button is
|
|
1465
|
+
// disabled in that mode, but pass it through when we honestly have it.
|
|
1466
|
+
if (version.digest) payload.targetDigest = version.digest;
|
|
1467
|
+
const res = await fetch(ds.rollbackEndpoint, {
|
|
1468
|
+
method: "POST",
|
|
1469
|
+
headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
|
|
1470
|
+
body: JSON.stringify(payload),
|
|
1471
|
+
});
|
|
1472
|
+
const data = await res.json().catch(() => ({}));
|
|
1473
|
+
const outcome = interpretRollback(res.ok, data);
|
|
1474
|
+
setStatus(versionsStatus, outcome.message, outcome.rolledBack ? "ok" : outcome.state === "publish_pending" ? "ok" : "err");
|
|
1475
|
+
} catch {
|
|
1476
|
+
setStatus(versionsStatus, "Rollback failed — network error", "err");
|
|
1477
|
+
} finally {
|
|
1478
|
+
button.disabled = false;
|
|
1479
|
+
loadVersions();
|
|
1480
|
+
loadQueue();
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
// ---- Domain section ----------------------------------------------------
|
|
1485
|
+
|
|
1486
|
+
const domainPanel = root.querySelector("[data-publish-domain]");
|
|
1487
|
+
let domainPollTimer = null;
|
|
1488
|
+
|
|
1489
|
+
const renderDomain = (payload) => {
|
|
1490
|
+
if (!domainPanel) return;
|
|
1491
|
+
const domain = payload.domain || {};
|
|
1492
|
+
const stateEl = domainPanel.querySelector("[data-domain-state]");
|
|
1493
|
+
const stateChip = domainPanel.querySelector("[data-domain-state-chip]");
|
|
1494
|
+
const sinceEl = domainPanel.querySelector("[data-domain-since]");
|
|
1495
|
+
const captureEl = domainPanel.querySelector("[data-domain-capture]");
|
|
1496
|
+
const runEl = domainPanel.querySelector("[data-domain-run]");
|
|
1497
|
+
const runDetailEl = domainPanel.querySelector("[data-domain-run-detail]");
|
|
1498
|
+
const rollbackBtn = domainPanel.querySelector("[data-domain-rollback]");
|
|
1499
|
+
const rollbackDetail = domainPanel.querySelector("[data-domain-rollback-detail]");
|
|
1500
|
+
const connected = domain.state === "cloudfront";
|
|
1501
|
+
|
|
1502
|
+
if (stateEl) {
|
|
1503
|
+
stateEl.textContent = connected
|
|
1504
|
+
? "Storefront CDN (cloudfront)"
|
|
1505
|
+
: "Previous host (pantheon)";
|
|
1506
|
+
}
|
|
1507
|
+
if (stateChip) {
|
|
1508
|
+
stateChip.textContent = connected ? "Connected" : "Not connected";
|
|
1509
|
+
stateChip.className = "chip " + (connected ? "green" : "amber");
|
|
1510
|
+
}
|
|
1511
|
+
if (sinceEl) {
|
|
1512
|
+
sinceEl.textContent = domain.since
|
|
1513
|
+
? "Since " + new Date(domain.since).toLocaleString()
|
|
1514
|
+
: "Current host for " + (payload.appDomain || ds.appDomain);
|
|
1515
|
+
}
|
|
1516
|
+
if (captureEl) {
|
|
1517
|
+
captureEl.textContent = domain.priorCapturedAt
|
|
1518
|
+
? "Captured " + new Date(domain.priorCapturedAt).toLocaleString()
|
|
1519
|
+
: "None captured";
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const run = domain.lastRun;
|
|
1523
|
+
// "dispatched" = awaiting the CI completion callback (terminal-only wire).
|
|
1524
|
+
const inFlight = run && run.status === "dispatched";
|
|
1525
|
+
if (runEl) {
|
|
1526
|
+
runEl.textContent = run
|
|
1527
|
+
? run.action + (run.rehearsal ? " (rehearsal)" : "") + " · " + run.status
|
|
1528
|
+
: "None";
|
|
1529
|
+
}
|
|
1530
|
+
if (runDetailEl) {
|
|
1531
|
+
runDetailEl.textContent = "";
|
|
1532
|
+
if (run) {
|
|
1533
|
+
const parts = [];
|
|
1534
|
+
if (run.actor) parts.push("by " + run.actor);
|
|
1535
|
+
if (run.startedAt) parts.push(age(run.startedAt));
|
|
1536
|
+
runDetailEl.append(document.createTextNode(parts.join(" · ")));
|
|
1537
|
+
if (run.runUrl) {
|
|
1538
|
+
const link = document.createElement("a");
|
|
1539
|
+
link.href = run.runUrl;
|
|
1540
|
+
link.target = "_blank";
|
|
1541
|
+
link.rel = "noopener";
|
|
1542
|
+
link.textContent = "View run";
|
|
1543
|
+
runDetailEl.append(document.createTextNode(" · "));
|
|
1544
|
+
runDetailEl.append(link);
|
|
1545
|
+
}
|
|
1546
|
+
if (run.status === "failed" && run.error) {
|
|
1547
|
+
const err = document.createElement("span");
|
|
1548
|
+
err.className = "publish-error-detail";
|
|
1549
|
+
err.textContent = run.error;
|
|
1550
|
+
runDetailEl.append(document.createElement("br"));
|
|
1551
|
+
runDetailEl.append(err);
|
|
1552
|
+
}
|
|
1553
|
+
} else {
|
|
1554
|
+
runDetailEl.textContent = "Connect and rollback runs appear here.";
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
if (rollbackBtn) {
|
|
1558
|
+
rollbackBtn.disabled =
|
|
1559
|
+
!canShip || inFlight || !domain.priorRecordsKey;
|
|
1560
|
+
}
|
|
1561
|
+
if (rollbackDetail && domain.priorCapturedAt) {
|
|
1562
|
+
rollbackDetail.textContent =
|
|
1563
|
+
"Restores the DNS records captured " +
|
|
1564
|
+
new Date(domain.priorCapturedAt).toLocaleString() + ".";
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// Poll while a run is in flight so completion lands without a manual refresh.
|
|
1568
|
+
if (domainPollTimer) {
|
|
1569
|
+
clearTimeout(domainPollTimer);
|
|
1570
|
+
domainPollTimer = null;
|
|
1571
|
+
}
|
|
1572
|
+
if (inFlight) domainPollTimer = setTimeout(loadDomain, 15000);
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1575
|
+
const loadDomain = async () => {
|
|
1576
|
+
if (!domainPanel) return;
|
|
1577
|
+
try {
|
|
1578
|
+
const res = await fetch(ds.domainStatusEndpoint, {
|
|
1579
|
+
headers: { accept: "application/json" },
|
|
1580
|
+
});
|
|
1581
|
+
if (!res.ok) return;
|
|
1582
|
+
renderDomain(await res.json());
|
|
1583
|
+
} catch {
|
|
1584
|
+
/* leave the last rendered state */
|
|
1585
|
+
}
|
|
1586
|
+
// u9: refresh the readiness itemization alongside the status.
|
|
1587
|
+
loadReadiness();
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
// ---- Cutover readiness (u9) --------------------------------------------
|
|
1591
|
+
// Reads the SAME server gate `tot go-live` reads (/api/domain/readiness) and
|
|
1592
|
+
// itemizes the preconditions. The Connect button additionally requires the
|
|
1593
|
+
// server to report ready — the display and the enforcement can't diverge.
|
|
1594
|
+
const readinessBox = domainPanel?.querySelector("[data-domain-readiness]");
|
|
1595
|
+
const connectBtn = domainPanel?.querySelector("[data-domain-connect]");
|
|
1596
|
+
let domainReady = false;
|
|
1597
|
+
|
|
1598
|
+
const renderReadiness = (payload) => {
|
|
1599
|
+
if (!readinessBox) return;
|
|
1600
|
+
const readiness = (payload && payload.readiness) || {};
|
|
1601
|
+
const checks = Array.isArray(readiness.checks) ? readiness.checks : [];
|
|
1602
|
+
domainReady = readiness.ready === true;
|
|
1603
|
+
readinessBox.hidden = false;
|
|
1604
|
+
|
|
1605
|
+
const chip = readinessBox.querySelector("[data-domain-readiness-chip]");
|
|
1606
|
+
if (chip) {
|
|
1607
|
+
chip.textContent = domainReady ? "Ready" : "Not ready";
|
|
1608
|
+
chip.className = "chip " + (domainReady ? "green" : "amber");
|
|
1609
|
+
}
|
|
1610
|
+
const list = readinessBox.querySelector("[data-domain-readiness-list]");
|
|
1611
|
+
if (list) {
|
|
1612
|
+
list.textContent = "";
|
|
1613
|
+
for (const c of checks) {
|
|
1614
|
+
const li = document.createElement("li");
|
|
1615
|
+
li.className = c.ok ? "ok" : "blocked";
|
|
1616
|
+
const mark = document.createElement("span");
|
|
1617
|
+
mark.className = "domain-readiness-mark";
|
|
1618
|
+
mark.textContent = c.ok ? "✓" : "✗";
|
|
1619
|
+
const label = document.createElement("b");
|
|
1620
|
+
label.textContent = c.label || c.id || "";
|
|
1621
|
+
li.append(mark, label);
|
|
1622
|
+
if (c.detail) {
|
|
1623
|
+
const detail = document.createElement("span");
|
|
1624
|
+
detail.className = "domain-readiness-detail";
|
|
1625
|
+
detail.textContent = c.detail;
|
|
1626
|
+
li.append(detail);
|
|
1627
|
+
}
|
|
1628
|
+
list.append(li);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
const note = readinessBox.querySelector("[data-domain-readiness-note]");
|
|
1632
|
+
if (note) {
|
|
1633
|
+
note.textContent = payload && payload.ownerCapable === false
|
|
1634
|
+
? "Apex cutover is owner-only — sign in as the store owner to connect the domain."
|
|
1635
|
+
: domainReady
|
|
1636
|
+
? "All preconditions are green — the cutover is permitted."
|
|
1637
|
+
: "Clear the failing preconditions above before connecting the apex.";
|
|
1638
|
+
}
|
|
1639
|
+
// Gate the Connect button on server readiness in ADDITION to owner (the
|
|
1640
|
+
// markup already disables it for non-owners); never enable it when blocked.
|
|
1641
|
+
if (connectBtn && !connectBtn.dataset.ownerLocked) {
|
|
1642
|
+
connectBtn.disabled = !domainReady;
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
const loadReadiness = async () => {
|
|
1647
|
+
if (!readinessBox || !ds.domainReadinessEndpoint) return;
|
|
1648
|
+
try {
|
|
1649
|
+
const res = await fetch(ds.domainReadinessEndpoint, {
|
|
1650
|
+
headers: { accept: "application/json", "x-tot-capability": "ship-on-behalf" },
|
|
1651
|
+
});
|
|
1652
|
+
if (!res.ok) return;
|
|
1653
|
+
renderReadiness(await res.json());
|
|
1654
|
+
} catch {
|
|
1655
|
+
/* leave the last rendered readiness */
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
const domainStatusEl = domainPanel?.querySelector("[data-domain-status]");
|
|
1660
|
+
|
|
1661
|
+
const domainAction = async (body, button) => {
|
|
1662
|
+
button.disabled = true;
|
|
1663
|
+
setStatus(domainStatusEl, (body.action === "connect" ? "Connecting" : "Rolling back") + "…");
|
|
1664
|
+
try {
|
|
1665
|
+
const res = await fetch(ds.domainDispatchEndpoint, {
|
|
1666
|
+
method: "POST",
|
|
1667
|
+
headers: {
|
|
1668
|
+
"content-type": "application/json",
|
|
1669
|
+
"x-tot-capability": "ship-on-behalf",
|
|
1670
|
+
},
|
|
1671
|
+
body: JSON.stringify(body),
|
|
1672
|
+
});
|
|
1673
|
+
const data = await res.json().catch(() => ({}));
|
|
1674
|
+
if (res.ok && data.dispatched) {
|
|
1675
|
+
setStatus(
|
|
1676
|
+
domainStatusEl,
|
|
1677
|
+
(body.action === "connect" ? "Connect" : "Rollback") +
|
|
1678
|
+
(body.rehearsal ? " rehearsal" : "") +
|
|
1679
|
+
" dispatched — the run status updates below as it progresses.",
|
|
1680
|
+
"ok",
|
|
1681
|
+
);
|
|
1682
|
+
} else {
|
|
1683
|
+
setStatus(domainStatusEl, data.error || "Request failed (" + res.status + ")", "err");
|
|
1684
|
+
}
|
|
1685
|
+
} catch {
|
|
1686
|
+
setStatus(domainStatusEl, "Request failed — network error", "err");
|
|
1687
|
+
} finally {
|
|
1688
|
+
button.disabled = false;
|
|
1689
|
+
loadDomain();
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
domainPanel?.querySelector("[data-domain-connect]")?.addEventListener("click", (event) => {
|
|
1694
|
+
const confirmInput = domainPanel.querySelector("[data-domain-confirm]");
|
|
1695
|
+
const rehearsal = Boolean(domainPanel.querySelector("[data-domain-rehearsal]")?.checked);
|
|
1696
|
+
domainAction(
|
|
1697
|
+
{
|
|
1698
|
+
action: "connect",
|
|
1699
|
+
confirmDomain: (confirmInput?.value || "").trim(),
|
|
1700
|
+
rehearsal,
|
|
1701
|
+
},
|
|
1702
|
+
event.currentTarget,
|
|
1703
|
+
);
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
domainPanel?.querySelector("[data-domain-rollback]")?.addEventListener("click", (event) => {
|
|
1707
|
+
if (
|
|
1708
|
+
!window.confirm(
|
|
1709
|
+
"Roll back " + ds.appDomain + " to the previous host? DNS returns to the captured records in about a minute.",
|
|
1710
|
+
)
|
|
1711
|
+
) {
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
domainAction({ action: "rollback" }, event.currentTarget);
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
// ---- Wiring ------------------------------------------------------------
|
|
1718
|
+
|
|
1719
|
+
root.querySelector("[data-publish-refresh]")?.addEventListener("click", () => {
|
|
1720
|
+
setStatus(statusEl, "");
|
|
1721
|
+
loadQueue();
|
|
1722
|
+
loadVersions();
|
|
1723
|
+
loadDomain();
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
loadQueue();
|
|
1727
|
+
loadVersions();
|
|
1728
|
+
loadDomain();
|
|
1729
|
+
})();
|
|
1730
|
+
</script>
|
|
1731
|
+
|
|
1732
|
+
<style>
|
|
1733
|
+
.publish-table {
|
|
1734
|
+
min-width: 900px;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
.publish-mono {
|
|
1738
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1739
|
+
font-size: 12px;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
.publish-actions {
|
|
1743
|
+
white-space: nowrap;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
.publish-actions .btn {
|
|
1747
|
+
margin-left: 6px;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
.publish-status {
|
|
1751
|
+
min-height: 0;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
.publish-status:empty {
|
|
1755
|
+
display: none;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
.publish-status[data-state="ok"] {
|
|
1759
|
+
color: var(--admin-green);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
.publish-status[data-state="err"] {
|
|
1763
|
+
color: var(--admin-red);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
.publish-error-detail {
|
|
1767
|
+
color: var(--admin-red);
|
|
1768
|
+
font-size: 12px;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
.domain-connect-controls {
|
|
1772
|
+
display: grid;
|
|
1773
|
+
gap: 8px;
|
|
1774
|
+
justify-items: end;
|
|
1775
|
+
min-width: 220px;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
.domain-connect-controls input[type="text"] {
|
|
1779
|
+
width: 100%;
|
|
1780
|
+
min-height: 36px;
|
|
1781
|
+
padding: 0 10px;
|
|
1782
|
+
border: 1px solid var(--admin-line-strong);
|
|
1783
|
+
border-radius: var(--admin-radius-sm);
|
|
1784
|
+
background: var(--admin-surface);
|
|
1785
|
+
color: var(--admin-ink);
|
|
1786
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1787
|
+
font-size: 12px;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
.domain-rehearsal {
|
|
1791
|
+
display: inline-flex;
|
|
1792
|
+
align-items: center;
|
|
1793
|
+
gap: 6px;
|
|
1794
|
+
color: var(--admin-muted);
|
|
1795
|
+
font-size: 12px;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
@media (max-width: 720px) {
|
|
1799
|
+
.domain-connect-controls {
|
|
1800
|
+
justify-items: stretch;
|
|
1801
|
+
min-width: 0;
|
|
1802
|
+
width: 100%;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/* u9: apex-cutover readiness itemization */
|
|
1807
|
+
.domain-readiness {
|
|
1808
|
+
margin: 0.75rem 0;
|
|
1809
|
+
padding: 0.75rem 1rem;
|
|
1810
|
+
border: 1px solid var(--hairline, rgba(0, 0, 0, 0.12));
|
|
1811
|
+
border-radius: 0.5rem;
|
|
1812
|
+
}
|
|
1813
|
+
.domain-readiness-head {
|
|
1814
|
+
display: flex;
|
|
1815
|
+
align-items: center;
|
|
1816
|
+
gap: 0.5rem;
|
|
1817
|
+
margin-bottom: 0.5rem;
|
|
1818
|
+
}
|
|
1819
|
+
.domain-readiness-list {
|
|
1820
|
+
list-style: none;
|
|
1821
|
+
margin: 0;
|
|
1822
|
+
padding: 0;
|
|
1823
|
+
display: grid;
|
|
1824
|
+
gap: 0.25rem;
|
|
1825
|
+
}
|
|
1826
|
+
.domain-readiness-list li {
|
|
1827
|
+
display: flex;
|
|
1828
|
+
align-items: baseline;
|
|
1829
|
+
gap: 0.4rem;
|
|
1830
|
+
font-size: 0.9em;
|
|
1831
|
+
}
|
|
1832
|
+
.domain-readiness-mark {
|
|
1833
|
+
font-weight: 700;
|
|
1834
|
+
width: 1em;
|
|
1835
|
+
}
|
|
1836
|
+
.domain-readiness-list li.ok .domain-readiness-mark {
|
|
1837
|
+
color: var(--good, #1a7f37);
|
|
1838
|
+
}
|
|
1839
|
+
.domain-readiness-list li.blocked .domain-readiness-mark {
|
|
1840
|
+
color: var(--bad, #b42318);
|
|
1841
|
+
}
|
|
1842
|
+
.domain-readiness-detail {
|
|
1843
|
+
opacity: 0.75;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/* b11: aggregate card + ship plan */
|
|
1847
|
+
.aggregate-prs {
|
|
1848
|
+
display: grid;
|
|
1849
|
+
gap: 6px;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
.aggregate-prs-list {
|
|
1853
|
+
list-style: none;
|
|
1854
|
+
margin: 0;
|
|
1855
|
+
padding: 0;
|
|
1856
|
+
display: grid;
|
|
1857
|
+
gap: 4px;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
.aggregate-prs-list li {
|
|
1861
|
+
display: flex;
|
|
1862
|
+
align-items: baseline;
|
|
1863
|
+
gap: 10px;
|
|
1864
|
+
font-size: 13px;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
.aggregate-plan {
|
|
1868
|
+
padding: 0.75rem 1rem;
|
|
1869
|
+
border: 1px solid var(--hairline, rgba(0, 0, 0, 0.12));
|
|
1870
|
+
border-radius: 0.5rem;
|
|
1871
|
+
display: grid;
|
|
1872
|
+
gap: 8px;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
.aggregate-plan-head {
|
|
1876
|
+
display: flex;
|
|
1877
|
+
align-items: center;
|
|
1878
|
+
gap: 0.5rem;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
.aggregate-plan-grid {
|
|
1882
|
+
display: grid;
|
|
1883
|
+
grid-template-columns: max-content 1fr;
|
|
1884
|
+
gap: 4px 16px;
|
|
1885
|
+
margin: 0;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
.aggregate-plan-grid dt {
|
|
1889
|
+
color: var(--admin-muted);
|
|
1890
|
+
font-size: 12px;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
.aggregate-plan-grid dd {
|
|
1894
|
+
margin: 0;
|
|
1895
|
+
font-size: 13px;
|
|
1896
|
+
overflow-wrap: anywhere;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/* dg7: versions + diff */
|
|
1900
|
+
.versions-table {
|
|
1901
|
+
min-width: 780px;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
.publish-diff-cell {
|
|
1905
|
+
padding: 12px 14px;
|
|
1906
|
+
background: var(--admin-surface-soft);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
.publish-diff-head {
|
|
1910
|
+
display: flex;
|
|
1911
|
+
flex-wrap: wrap;
|
|
1912
|
+
align-items: center;
|
|
1913
|
+
gap: 10px;
|
|
1914
|
+
margin-bottom: 8px;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
.publish-diff-blocked {
|
|
1918
|
+
color: var(--admin-amber);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
.publish-diff-list {
|
|
1922
|
+
list-style: none;
|
|
1923
|
+
margin: 0;
|
|
1924
|
+
padding: 0;
|
|
1925
|
+
display: grid;
|
|
1926
|
+
gap: 3px;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
.publish-diff-list li {
|
|
1930
|
+
display: flex;
|
|
1931
|
+
align-items: baseline;
|
|
1932
|
+
gap: 8px;
|
|
1933
|
+
font-size: 12px;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
.publish-diff-mark {
|
|
1937
|
+
width: 1em;
|
|
1938
|
+
font-weight: 700;
|
|
1939
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
.publish-diff-added .publish-diff-mark {
|
|
1943
|
+
color: var(--admin-green);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
.publish-diff-changed .publish-diff-mark {
|
|
1947
|
+
color: var(--admin-blue);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
.publish-diff-removed .publish-diff-mark {
|
|
1951
|
+
color: var(--admin-red);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
.publish-diff-list code {
|
|
1955
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1956
|
+
font-size: 12px;
|
|
1957
|
+
overflow-wrap: anywhere;
|
|
1958
|
+
}
|
|
1959
|
+
</style>
|