@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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { ManagedCheckoutConfig } from "./checkout.js";
|
|
9
9
|
import type { CapabilityConfig } from "./capabilities.js";
|
|
10
|
+
import type { AutopilotConfig, MembershipProgramConfig } from "./membership.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* The shared semantic COLOR contract — the widget library's public API.
|
|
@@ -63,6 +64,22 @@ export interface ThemeColorTokens {
|
|
|
63
64
|
"primary-hover"?: string;
|
|
64
65
|
/** Hover/active shade of `accent`. Default: a derived darken of `accent`. */
|
|
65
66
|
"accent-hover"?: string;
|
|
67
|
+
|
|
68
|
+
// --- Interaction/quality-bar extensions (unit F6). Same "always resolvable"
|
|
69
|
+
// contract: each carries a derived @theme-bridge default so a component may
|
|
70
|
+
// read it unconditionally; a theme sets one only to brand-tune it. ---
|
|
71
|
+
/** Keyboard focus-ring color (`:focus-visible` outline). Default: `accent`. */
|
|
72
|
+
"focus-ring"?: string;
|
|
73
|
+
/** Fill for a disabled/loading control (buttons, inputs). Default: a muted
|
|
74
|
+
* greige mix of `muted`+`surface`. */
|
|
75
|
+
disabled?: string;
|
|
76
|
+
/** Readable text/icon color ON a `disabled` control. Default: `muted`. */
|
|
77
|
+
"disabled-contrast"?: string;
|
|
78
|
+
/** Form-field border — a stronger, ≥3:1 outline for inputs/selects than the
|
|
79
|
+
* hairline `border`. Default: a mix of `border`+`text`. */
|
|
80
|
+
"field-border"?: string;
|
|
81
|
+
/** Review/rating star fill (trust/UGC surfaces). Default: `accent`. */
|
|
82
|
+
star?: string;
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
export interface ThemeFontFamily {
|
|
@@ -92,6 +109,13 @@ export interface ThemeShapeTokens {
|
|
|
92
109
|
radius: { sm: string; md: string; lg: string; full: string };
|
|
93
110
|
borderWeights: { thin: string; medium: string; thick: string };
|
|
94
111
|
shadow: { sm: string; md: string; lg: string };
|
|
112
|
+
/**
|
|
113
|
+
* Minimum interactive tap-target size (WCAG 2.2 AA §2.5.8 · 24px floor, 44px
|
|
114
|
+
* recommended for mobile commerce). Serialized to `--tap-target`; components
|
|
115
|
+
* size touch controls with `min-height/min-width: var(--tap-target)`. Optional
|
|
116
|
+
* — the `:root` base default (44px) applies when a theme omits it. (Unit F6.)
|
|
117
|
+
*/
|
|
118
|
+
tapTarget?: string;
|
|
95
119
|
}
|
|
96
120
|
|
|
97
121
|
export interface ThemeBrandTokens {
|
|
@@ -247,6 +271,29 @@ export interface ComplianceConfig {
|
|
|
247
271
|
*/
|
|
248
272
|
export type SiteType = "commerce" | "marketing";
|
|
249
273
|
|
|
274
|
+
/**
|
|
275
|
+
* The storefront paywall mode (decision `golive-paywall-storefront-mode-enabled`).
|
|
276
|
+
* `"enabled"` is the ONLY value that permits go-live; `"disabled"` (or an absent
|
|
277
|
+
* `storefront.mode`) fails the paywall closed. Kept OPEN as a string union with an
|
|
278
|
+
* `enabled` member so a future billing state (e.g. "trial", "past_due") can be added
|
|
279
|
+
* without changing the gate — {@link StorefrontModeAllowed} stays the single source
|
|
280
|
+
* of the allow test.
|
|
281
|
+
*/
|
|
282
|
+
export type StorefrontMode = "enabled" | "disabled";
|
|
283
|
+
|
|
284
|
+
/** The one storefront mode value that permits go-live. The gate tests strict equality. */
|
|
285
|
+
export const STOREFRONT_MODE_ENABLED = "enabled" as const;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* PURE paywall predicate: does this storefront mode permit go-live? True iff the
|
|
289
|
+
* mode is exactly `"enabled"` (decision `golive-paywall-storefront-mode-enabled`).
|
|
290
|
+
* Generic — takes the mode value, NEVER a domain — so there is no per-store
|
|
291
|
+
* special-case. Undefined/unknown → false (fail-closed).
|
|
292
|
+
*/
|
|
293
|
+
export function storefrontModeAllowsGoLive(mode: string | undefined | null): boolean {
|
|
294
|
+
return mode === STOREFRONT_MODE_ENABLED;
|
|
295
|
+
}
|
|
296
|
+
|
|
250
297
|
/**
|
|
251
298
|
* Per-tenant capability switches. These gate whole surfaces so the same
|
|
252
299
|
* component library renders both a store and a marketing site without any
|
|
@@ -355,6 +402,20 @@ export interface TenantConfig {
|
|
|
355
402
|
*/
|
|
356
403
|
commerce?: {
|
|
357
404
|
checkout?: ManagedCheckoutConfig;
|
|
405
|
+
/**
|
|
406
|
+
* Autopilot (subscription) program visibility (F10). Absent → subscription
|
|
407
|
+
* purchase options render wherever a product authors them (framework/dev
|
|
408
|
+
* default). `enabled: false` HIDES Autopilot at launch — the first-class
|
|
409
|
+
* hide-at-launch state for a tenant that isn't selling recurring orders yet.
|
|
410
|
+
*/
|
|
411
|
+
autopilot?: AutopilotConfig;
|
|
412
|
+
/**
|
|
413
|
+
* Membership pricing program (F10): member-vs-list pricing + how a member
|
|
414
|
+
* discount stacks with a subscription discount. Absent/`enabled:false` → no
|
|
415
|
+
* member pricing renders (the launch default; shopper membership accounts are
|
|
416
|
+
* a Phase-2 managed-checkout capability).
|
|
417
|
+
*/
|
|
418
|
+
membership?: MembershipProgramConfig;
|
|
358
419
|
};
|
|
359
420
|
/**
|
|
360
421
|
* PR-gated capability switches (cart/checkout, age/identity verification,
|
|
@@ -364,6 +425,181 @@ export interface TenantConfig {
|
|
|
364
425
|
* Undefined entries default from siteType/compliance — see resolveCapabilities.
|
|
365
426
|
*/
|
|
366
427
|
capabilities?: Partial<CapabilityConfig>;
|
|
428
|
+
/**
|
|
429
|
+
* Static-publish target (T5, tokenoftrust-com-static-publish epic). This
|
|
430
|
+
* tenant's PUBLIC site is materialized to an external static host
|
|
431
|
+
* (S3/CloudFront) OFF the Worker and republished on go-live. A per-tenant
|
|
432
|
+
* PROPERTY, not a global dark flag: presence opts the tenant in (default ON);
|
|
433
|
+
* `enabled: false` opts back out without dropping the target coordinates. The
|
|
434
|
+
* dispatch credentials/coordinates live in env/secrets, NEVER here. `target` is
|
|
435
|
+
* an opaque, TARGET-NEUTRAL label (S3 now, R2 later) forwarded verbatim into the
|
|
436
|
+
* publish job so ONE reconcile publish-sink drives any target.
|
|
437
|
+
*/
|
|
438
|
+
staticPublish?: {
|
|
439
|
+
/** Target-neutral label, e.g. "www-s3". Forwarded into the dispatch payload. */
|
|
440
|
+
target: string;
|
|
441
|
+
/** Opt back out without removing the target. Default true when present. */
|
|
442
|
+
enabled?: boolean;
|
|
443
|
+
};
|
|
444
|
+
/**
|
|
445
|
+
* Storefront product/subscription state — the paywall the platform gates GO-LIVE
|
|
446
|
+
* on (decision `golive-paywall-storefront-mode-enabled`). This is the storefront's
|
|
447
|
+
* analog of the ToT platform SDK's `apiClient.options.storefront.mode`: a per-store
|
|
448
|
+
* PROPERTY set from the merchant's account/subscription, NEVER a per-domain
|
|
449
|
+
* special-case in code. Go-live (the public LIVE publish, `POST /api/changes/accept`)
|
|
450
|
+
* is permitted ONLY when `mode === "enabled"`; any other value — including undefined
|
|
451
|
+
* (never provisioned) — refuses go-live (fail-closed paywall). Preview/dev never
|
|
452
|
+
* consult it. See `storefrontModeGate` (`lib/change/route.ts`).
|
|
453
|
+
*/
|
|
454
|
+
storefront?: {
|
|
455
|
+
/**
|
|
456
|
+
* The storefront paywall mode. `"enabled"` = the store has an active storefront
|
|
457
|
+
* subscription and MAY go live. `"disabled"` (or absent) = not entitled to
|
|
458
|
+
* go-live yet.
|
|
459
|
+
*/
|
|
460
|
+
mode?: StorefrontMode;
|
|
461
|
+
};
|
|
462
|
+
/**
|
|
463
|
+
* Internal test/e2e tenant — never a real client. A tenant carrying this
|
|
464
|
+
* marker (or an `.e2e.test` appDomain) is excluded from real/prospect-facing
|
|
465
|
+
* listings, HARD-blocked from go-live/promote, and excluded from
|
|
466
|
+
* billing/analytics. It exists only to host a destructive full-cycle e2e
|
|
467
|
+
* (candidate create/ship/rollback) against a CLONE of a real tenant.
|
|
468
|
+
*/
|
|
469
|
+
classification?: "internal-test";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* True when a tenant is an internal test/e2e tenant — either explicitly marked
|
|
474
|
+
* `classification: "internal-test"` or resolving to an `.e2e.test` appDomain
|
|
475
|
+
* (fail-safe belt-and-suspenders so a clone missing the marker is still caught).
|
|
476
|
+
* Callers use this to keep such tenants out of real listings and off the
|
|
477
|
+
* go-live/promote path.
|
|
478
|
+
*/
|
|
479
|
+
export function isInternalTestTenant(
|
|
480
|
+
t: Pick<TenantConfig, "classification" | "appDomain">,
|
|
481
|
+
): boolean {
|
|
482
|
+
return t.classification === "internal-test" || t.appDomain.endsWith(".e2e.test");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
// Non-premium (self-serve) tenant identity — D4.
|
|
487
|
+
//
|
|
488
|
+
// A NEW self-serve / non-premium shop is minted with its identity anchored to a
|
|
489
|
+
// per-tenant subdomain on our own Cloudflare zone: `<label>.tokenoftrust.store`.
|
|
490
|
+
// That value is the tenant_id AND the appDomain AND the canonicalDomain AND the
|
|
491
|
+
// tot_data_scope (the dir==tenant_id==appDomain==scope invariant the
|
|
492
|
+
// `unify-tenant-id-equals-appdomain` decision established) — a REAL dotted
|
|
493
|
+
// appDomain, NOT a reintroduced bare slug (bare slugs were deliberately retired).
|
|
494
|
+
// A custom `.com` is layered on LATER as an optional premium alias/upgrade
|
|
495
|
+
// (`rebindPremiumDomain`), never a precondition for a preview.
|
|
496
|
+
//
|
|
497
|
+
// See docs/epics/proposed/invite-gated-auto-build.md §D4.
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The Cloudflare zone that hosts every non-premium (self-serve) store as a
|
|
502
|
+
* per-tenant subdomain. The `*.tokenoftrust.store` wildcard route already exists
|
|
503
|
+
* in apps/storefront/wrangler.toml, so routing a freshly minted store is free.
|
|
504
|
+
*/
|
|
505
|
+
export const HOSTED_STORE_ZONE = "tokenoftrust.store";
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A single DNS label — the `<label>` in `<label>.tokenoftrust.store`: lowercase
|
|
509
|
+
* alphanumerics with internal hyphens, 1–63 chars, no leading/trailing hyphen and
|
|
510
|
+
* NO dots (a dot would create a deeper subdomain the wildcard route can't reach).
|
|
511
|
+
*/
|
|
512
|
+
const HOSTED_STORE_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The identity subset of a {@link TenantConfig} — the fields that establish WHO a
|
|
516
|
+
* tenant is (as opposed to how it looks or what it sells). {@link mintHostedStoreIdentity}
|
|
517
|
+
* produces exactly this; the rest of a config (theme, storyblok, compliance, …) is
|
|
518
|
+
* layered on by the app-level minter / build workstream.
|
|
519
|
+
*/
|
|
520
|
+
export type TenantIdentity = Pick<
|
|
521
|
+
TenantConfig,
|
|
522
|
+
"tenant_id" | "appDomain" | "canonicalDomain" | "tot_data_scope" | "domains"
|
|
523
|
+
>;
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The hosted-store hostname for a label: `<label>.tokenoftrust.store`.
|
|
527
|
+
* @throws if `label` is not a valid single DNS label (see {@link HOSTED_STORE_LABEL_RE}).
|
|
528
|
+
*/
|
|
529
|
+
export function hostedStoreDomain(label: string): string {
|
|
530
|
+
const clean = String(label ?? "").toLowerCase().trim();
|
|
531
|
+
if (!HOSTED_STORE_LABEL_RE.test(clean)) {
|
|
532
|
+
throw new Error(
|
|
533
|
+
`hostedStoreDomain: invalid store label "${label}" — want a single DNS label ` +
|
|
534
|
+
`(lowercase alnum + internal hyphens, 1–63 chars, no dots), e.g. "acme-vapor".`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
return `${clean}.${HOSTED_STORE_ZONE}`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** True when `domain` is a `<label>.tokenoftrust.store` hosted-store hostname. */
|
|
541
|
+
export function isHostedStoreDomain(domain: string): boolean {
|
|
542
|
+
const clean = String(domain ?? "").toLowerCase().split(":")[0]?.trim() ?? "";
|
|
543
|
+
const suffix = `.${HOSTED_STORE_ZONE}`;
|
|
544
|
+
if (!clean.endsWith(suffix)) return false;
|
|
545
|
+
return HOSTED_STORE_LABEL_RE.test(clean.slice(0, -suffix.length));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Mint the identity for a NEW self-serve / non-premium tenant (D4).
|
|
550
|
+
*
|
|
551
|
+
* tenant_id == appDomain == canonicalDomain == tot_data_scope ==
|
|
552
|
+
* `<label>.tokenoftrust.store`, with that single hostname as the sole `domains`
|
|
553
|
+
* entry. A premium custom domain is added later via {@link rebindPremiumDomain}.
|
|
554
|
+
*
|
|
555
|
+
* @throws if `label` is not a valid single DNS label.
|
|
556
|
+
*/
|
|
557
|
+
export function mintHostedStoreIdentity(label: string): TenantIdentity {
|
|
558
|
+
const appDomain = hostedStoreDomain(label); // validates the label
|
|
559
|
+
return {
|
|
560
|
+
tenant_id: appDomain,
|
|
561
|
+
appDomain,
|
|
562
|
+
canonicalDomain: appDomain,
|
|
563
|
+
tot_data_scope: appDomain,
|
|
564
|
+
domains: [appDomain],
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Layer an optional PREMIUM custom domain onto an existing hosted-store tenant —
|
|
570
|
+
* the upgrade path in D4. Returns a NEW config (the input is not mutated).
|
|
571
|
+
*
|
|
572
|
+
* The tenant's IDENTITY stays anchored to its `.tokenoftrust.store` value:
|
|
573
|
+
* `tenant_id` AND `appDomain` are STABLE. This is the recorded recommendation for
|
|
574
|
+
* D4's open question — keep the id stable and treat the custom domain purely as an
|
|
575
|
+
* alias — which avoids repo-rename + registry-re-key churn. What changes:
|
|
576
|
+
* - the custom domain (and its `www.` host) is ADDED to `domains` (idempotent);
|
|
577
|
+
* - `canonicalDomain` FLIPS to the custom domain (SEO canonical → premium domain).
|
|
578
|
+
*
|
|
579
|
+
* NOTE (open human gate — invite-gated-auto-build.md §D4): whether a rebind should
|
|
580
|
+
* instead CHURN tenant_id/appDomain to the custom domain is an unresolved product
|
|
581
|
+
* decision that would need repo-rename + registry-re-key infrastructure which does
|
|
582
|
+
* not exist yet. This helper deliberately implements ONLY the churn-free alias form.
|
|
583
|
+
*
|
|
584
|
+
* @throws if `customDomain` is not a plausible dotted hostname.
|
|
585
|
+
*/
|
|
586
|
+
export function rebindPremiumDomain(
|
|
587
|
+
config: TenantConfig,
|
|
588
|
+
customDomain: string,
|
|
589
|
+
): TenantConfig {
|
|
590
|
+
const clean = String(customDomain ?? "").toLowerCase().trim();
|
|
591
|
+
if (!clean || clean.includes("/") || clean.includes(" ") || !clean.includes(".")) {
|
|
592
|
+
throw new Error(
|
|
593
|
+
`rebindPremiumDomain: invalid custom domain "${customDomain}" — want a dotted hostname, e.g. "acme.com".`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const domains = [...config.domains];
|
|
597
|
+
const add = (host: string): void => {
|
|
598
|
+
if (!domains.some((d) => d.toLowerCase() === host)) domains.push(host);
|
|
599
|
+
};
|
|
600
|
+
add(clean);
|
|
601
|
+
if (!clean.startsWith("www.")) add(`www.${clean}`);
|
|
602
|
+
return { ...config, domains, canonicalDomain: clean };
|
|
367
603
|
}
|
|
368
604
|
|
|
369
605
|
export type DeepPartial<T> = {
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-side postMessage bridge for sandboxed widget iframes — PrivateApps
|
|
3
|
+
* epic, D5 (Chunk E). The widget sandbox (`allow-scripts`, no
|
|
4
|
+
* `allow-same-origin` — `customization-scripts.ts`'s `widgetFrameAttrs()`)
|
|
5
|
+
* blocks DOM/cookie/storage access outright; this is the ONLY channel a
|
|
6
|
+
* widget has back to the page, so it gets the same fail-closed discipline as
|
|
7
|
+
* the sandbox itself: a message is honored only when BOTH `event.source` is
|
|
8
|
+
* a TRACKED iframe's `contentWindow` AND `event.origin` is the OPAQUE sandbox
|
|
9
|
+
* origin `"null"` (see {@link OPAQUE_SANDBOX_ORIGIN} — a frame sandboxed
|
|
10
|
+
* without `allow-same-origin` has no origin of its own, so every browser
|
|
11
|
+
* reports its postMessage origin as the literal string `"null"`, never its
|
|
12
|
+
* real HTTP(S) endpoint; verified against a real sandboxed cross-origin
|
|
13
|
+
* iframe, not assumed — jsdom can't observe this, only a real browser can).
|
|
14
|
+
* Anything else — an untracked window, a non-opaque origin, an
|
|
15
|
+
* unrecognized/malformed `type` — is a silent no-op: no reply, no state
|
|
16
|
+
* change, so a probing widget can't learn whether its message was rejected
|
|
17
|
+
* for being unknown-type vs a weakened sandbox (no oracle).
|
|
18
|
+
*
|
|
19
|
+
* Dependency-free + Workers/browser-portable: nothing here touches
|
|
20
|
+
* `window`/`document` at module scope, only inside `mountWidgetPostMessageBridge`
|
|
21
|
+
* — so importing this module (via `@tot/public-runtime`'s barrel, which also
|
|
22
|
+
* loads server-side into the SSR bundle) is safe outside a browser; only
|
|
23
|
+
* calling the mount function needs one.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Own-height clamp — a widget can't grow the iframe past a sane bound, never the width. */
|
|
27
|
+
const MIN_HEIGHT_PX = 20;
|
|
28
|
+
const MAX_HEIGHT_PX = 2000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The `event.origin` a browser reports for a message from an opaque-origin
|
|
32
|
+
* frame — i.e. every widget iframe, since all of them are rendered with
|
|
33
|
+
* `WIDGET_SANDBOX` (`allow-scripts`, no `allow-same-origin`). This is what
|
|
34
|
+
* the origin check below actually compares against — NOT the widget's real
|
|
35
|
+
* endpoint origin (`data-tot-widget-origin`, kept for install identification
|
|
36
|
+
* / diagnostics only). If the sandbox were ever weakened to add
|
|
37
|
+
* `allow-same-origin`, the frame would start reporting its real origin
|
|
38
|
+
* instead of `"null"`, and this check would then reject every message,
|
|
39
|
+
* turning a silent security downgrade into a loud, visible break.
|
|
40
|
+
*/
|
|
41
|
+
const OPAQUE_SANDBOX_ORIGIN = "null";
|
|
42
|
+
|
|
43
|
+
/** Same-tenant relative path only: a leading `/`, never `//` (protocol-relative), never a scheme. */
|
|
44
|
+
const RELATIVE_PATH = /^\/(?!\/)[a-zA-Z0-9\-._~!$&'()*+,;=:@%/?#]*$/;
|
|
45
|
+
|
|
46
|
+
/** The closed set of message types a widget may send. Everything else is a no-op. */
|
|
47
|
+
export type WidgetMessageType = "resize" | "navigate" | "analytics";
|
|
48
|
+
|
|
49
|
+
export interface WidgetAnalyticsEvent {
|
|
50
|
+
installId: string;
|
|
51
|
+
name: string;
|
|
52
|
+
props: Record<string, string | number | boolean>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Minimal DOM surface this module needs — a real `HTMLIFrameElement` satisfies it structurally. */
|
|
56
|
+
export interface WidgetFrameHost {
|
|
57
|
+
contentWindow: unknown;
|
|
58
|
+
style: { height: string };
|
|
59
|
+
getAttribute(name: string): string | null;
|
|
60
|
+
}
|
|
61
|
+
export interface WidgetFrameRoot {
|
|
62
|
+
querySelectorAll(selector: string): Iterable<WidgetFrameHost>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Minimal message-event surface — real `MessageEvent` satisfies it structurally. */
|
|
66
|
+
export interface WidgetMessageEvent {
|
|
67
|
+
source: unknown;
|
|
68
|
+
origin: string;
|
|
69
|
+
data: unknown;
|
|
70
|
+
}
|
|
71
|
+
/** Minimal event-target surface — real `Window` satisfies it structurally. */
|
|
72
|
+
export interface WidgetMessageTarget {
|
|
73
|
+
addEventListener(type: "message", listener: (event: WidgetMessageEvent) => void): void;
|
|
74
|
+
removeEventListener(type: "message", listener: (event: WidgetMessageEvent) => void): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface WidgetPostMessageBridgeOptions {
|
|
78
|
+
/** Sink for validated `analytics` messages. Defaults to a no-op (Phase 1 has no wired sink yet). */
|
|
79
|
+
onAnalytics?: (event: WidgetAnalyticsEvent) => void;
|
|
80
|
+
/** Sink for validated `navigate` messages. Defaults to `window.location.assign` (same-tab). */
|
|
81
|
+
onNavigate?: (path: string) => void;
|
|
82
|
+
/** Element scanned for tracked iframes. Defaults to `document`. */
|
|
83
|
+
root?: WidgetFrameRoot;
|
|
84
|
+
/** Event target the bridge listens on. Defaults to `window`. */
|
|
85
|
+
target?: WidgetMessageTarget;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface TrackedFrame {
|
|
89
|
+
expectedOrigin: string;
|
|
90
|
+
installId: string;
|
|
91
|
+
el: WidgetFrameHost;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
95
|
+
return typeof v === "object" && v !== null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Scan `root` for `iframe[data-tot-widget-origin]` — `AppWidgetFrame`'s
|
|
100
|
+
* render output — and index each by its `contentWindow`. Re-scanned on every
|
|
101
|
+
* message rather than cached once, so a widget that mounts after the initial
|
|
102
|
+
* page load (e.g. a `loading="lazy"` frame near the viewport) is still found.
|
|
103
|
+
*/
|
|
104
|
+
function collectTrackedFrames(root: WidgetFrameRoot): Map<unknown, TrackedFrame> {
|
|
105
|
+
const frames = new Map<unknown, TrackedFrame>();
|
|
106
|
+
for (const el of root.querySelectorAll("iframe[data-tot-widget-origin]")) {
|
|
107
|
+
const expectedOrigin = el.getAttribute("data-tot-widget-origin");
|
|
108
|
+
const installId = el.getAttribute("data-tot-install-id");
|
|
109
|
+
if (!expectedOrigin || !installId || !el.contentWindow) continue;
|
|
110
|
+
frames.set(el.contentWindow, { expectedOrigin, installId, el });
|
|
111
|
+
}
|
|
112
|
+
return frames;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function warn(message: string, detail: Record<string, unknown>): void {
|
|
116
|
+
console.warn(`[diag:widget-postmessage] ${message}`, detail);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Mount the parent-side bridge for the lifetime of the page. Returns an
|
|
121
|
+
* unmount function (removes the listener) — mainly for tests; the browser
|
|
122
|
+
* bootstrap script mounts once and never unmounts.
|
|
123
|
+
*/
|
|
124
|
+
export function mountWidgetPostMessageBridge(
|
|
125
|
+
options: WidgetPostMessageBridgeOptions = {},
|
|
126
|
+
): () => void {
|
|
127
|
+
const root = options.root ?? (document as unknown as WidgetFrameRoot);
|
|
128
|
+
const target = options.target ?? (window as unknown as WidgetMessageTarget);
|
|
129
|
+
const onAnalytics = options.onAnalytics ?? (() => {});
|
|
130
|
+
const onNavigate = options.onNavigate ?? ((path: string) => window.location.assign(path));
|
|
131
|
+
|
|
132
|
+
function handleMessage(event: WidgetMessageEvent): void {
|
|
133
|
+
try {
|
|
134
|
+
const frame = collectTrackedFrames(root).get(event.source);
|
|
135
|
+
if (!frame) {
|
|
136
|
+
warn("rejected — source is not a tracked widget iframe", { origin: event.origin });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (event.origin !== OPAQUE_SANDBOX_ORIGIN) {
|
|
140
|
+
warn("rejected — origin is not the opaque sandbox origin (sandbox may have been weakened)", {
|
|
141
|
+
installId: frame.installId,
|
|
142
|
+
expected: OPAQUE_SANDBOX_ORIGIN,
|
|
143
|
+
got: event.origin,
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const data = event.data;
|
|
149
|
+
if (!isRecord(data) || typeof data.type !== "string") {
|
|
150
|
+
warn("rejected — malformed message", { installId: frame.installId });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
switch (data.type as WidgetMessageType | string) {
|
|
155
|
+
case "resize": {
|
|
156
|
+
if (typeof data.height !== "number" || !Number.isFinite(data.height)) {
|
|
157
|
+
warn("rejected — non-numeric resize height", { installId: frame.installId });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const clamped = Math.min(MAX_HEIGHT_PX, Math.max(MIN_HEIGHT_PX, data.height));
|
|
161
|
+
// Own height only — width is never touched from a widget message.
|
|
162
|
+
frame.el.style.height = `${clamped}px`;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
case "navigate": {
|
|
166
|
+
if (typeof data.path !== "string" || !RELATIVE_PATH.test(data.path)) {
|
|
167
|
+
warn("rejected — navigate target is not a same-tenant relative path", {
|
|
168
|
+
installId: frame.installId,
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
onNavigate(data.path);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
case "analytics": {
|
|
176
|
+
if (typeof data.name !== "string") {
|
|
177
|
+
warn("rejected — analytics event missing a name", { installId: frame.installId });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const rawProps = isRecord(data.props) ? data.props : {};
|
|
181
|
+
const props: Record<string, string | number | boolean> = {};
|
|
182
|
+
for (const [key, value] of Object.entries(rawProps)) {
|
|
183
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
184
|
+
props[key] = value;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
onAnalytics({ installId: frame.installId, name: data.name, props });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
default:
|
|
191
|
+
warn("rejected — unknown message type", { installId: frame.installId, type: data.type });
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// A hostile `event.data` (e.g. a throwing getter on `type` or a
|
|
195
|
+
// property read inside the analytics props loop) must never escape as
|
|
196
|
+
// an uncaught exception out of the message listener — treat it exactly
|
|
197
|
+
// like any other malformed message: a silent no-op, no reply, no state
|
|
198
|
+
// change, no oracle for what specifically was invalid.
|
|
199
|
+
warn("rejected — reading the message threw", { origin: event.origin });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
target.addEventListener("message", handleMessage);
|
|
204
|
+
return () => target.removeEventListener("message", handleMessage);
|
|
205
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* publish.mjs — server-side logic for C5's "Publish" action: open/update a
|
|
3
|
+
* PR-backed candidate (g1b `candidate_open`) for the developer's current
|
|
4
|
+
* on-disk draft of one writable file (`POST /__tot/publish {path}`).
|
|
5
|
+
*
|
|
6
|
+
* Follows the file-writer.mjs / file-browser.mjs delegation pattern: all
|
|
7
|
+
* logic lives here, dependency-free (global fetch + node builtins) and
|
|
8
|
+
* testable under `node --test`; `dev-plugins/dev-publish.mjs` maps req/res
|
|
9
|
+
* onto `handlePublish`. Local mode has no draft-overlay concept — C5's writer
|
|
10
|
+
* already writes straight to the developer's real checkout (file-writer.mjs),
|
|
11
|
+
* so the "draft" IS whatever's currently on disk: this reads it fresh
|
|
12
|
+
* (file-browser.mjs's `readFile`) rather than tracking a separate store.
|
|
13
|
+
*
|
|
14
|
+
* `deriveChangeId` mirrors `tot submit`'s own scheme
|
|
15
|
+
* (packages/cli/src/commands/submit.mjs) so repeat publishes from this local
|
|
16
|
+
* editor update ONE PR instead of opening a new one each time — keyed on the
|
|
17
|
+
* signed-in DEVELOPER's identity (their email), same as the CLI's own
|
|
18
|
+
* `actorKeyFor`.
|
|
19
|
+
*
|
|
20
|
+
* Auth: the developer's own cached OAuth session (`tot login`,
|
|
21
|
+
* `~/.tot/credentials.json`), via `packages/cli/src/auth.mjs`'s
|
|
22
|
+
* `resolveDeveloperSession` — reads, silently refreshes, and attaches the
|
|
23
|
+
* Bearer exactly as `tot submit` does. NOT the storefront-operator
|
|
24
|
+
* TOT_API_KEY/TOT_SECRET_KEY path this module used before 2026-07-26:
|
|
25
|
+
* tot-mcp went OAuth-first on 2026-07-23 ("Require OAuth before remote MCP
|
|
26
|
+
* initialize"), which retired the old no-browser operator/credential_validate
|
|
27
|
+
* trust path entirely — every caller, including this one, now needs a real
|
|
28
|
+
* OAuth bearer just to complete `initialize`. There is no service-account /
|
|
29
|
+
* client-credentials grant on this MCP (confirmed against its OAuth
|
|
30
|
+
* authorization-server metadata), so a human `tot login` (one-time; refresh
|
|
31
|
+
* is silent afterward, ~30-day refresh tokens) is the only way in.
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { readFile } from "./file-browser.mjs";
|
|
35
|
+
import { matchesWriteAllowlist } from "./safe-path.mjs";
|
|
36
|
+
import { resolveTenantSourceRoot } from "./tenant-source-root.mjs";
|
|
37
|
+
import { createMcpClient } from "../../packages/cli/src/mcp.mjs";
|
|
38
|
+
import { resolveDeveloperSession, AuthUnavailableError } from "../../packages/cli/src/auth.mjs";
|
|
39
|
+
|
|
40
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A stable per-developer-per-tenant candidate handle, matching `tot submit`'s
|
|
44
|
+
* own scheme (`deriveChangeId` in packages/cli/src/commands/submit.mjs) so
|
|
45
|
+
* repeat local publishes update the SAME PR (`candidate_open` is idempotent
|
|
46
|
+
* on `changeId`) instead of piling up duplicates. Pure — unit-tested.
|
|
47
|
+
* @param {string} tenant
|
|
48
|
+
* @param {string} actorKey
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function deriveChangeId(tenant, actorKey) {
|
|
52
|
+
const hash = createHash("sha256").update(`${tenant}|${actorKey}`).digest("hex").slice(0, 16);
|
|
53
|
+
return `local-${hash}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Open/update the PR-backed candidate via MCP `candidate_open` for one file's
|
|
58
|
+
* current on-disk content. `deps.mcpClient`/`deps.resolveSession` are
|
|
59
|
+
* injectable for tests (else a real client + `resolveDeveloperSession` are
|
|
60
|
+
* used, against `env.MCP_BASE_URL`).
|
|
61
|
+
* @param {NodeJS.ProcessEnv} env
|
|
62
|
+
* @param {string} sourceRoot
|
|
63
|
+
* @param {string} tenantId
|
|
64
|
+
* @param {string} relPath
|
|
65
|
+
* @param {{mcpClient?: ReturnType<typeof createMcpClient>, resolveSession?: typeof resolveDeveloperSession}} [deps]
|
|
66
|
+
* @returns {Promise<{status:number, json:object}>}
|
|
67
|
+
*/
|
|
68
|
+
export async function publishFile(env, sourceRoot, tenantId, relPath, deps = {}) {
|
|
69
|
+
if (!matchesWriteAllowlist(relPath)) {
|
|
70
|
+
return {
|
|
71
|
+
status: 403,
|
|
72
|
+
json: { ok: false, status: "not_writable", message: `not in the write allowlist: ${relPath}` },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const file = readFile(sourceRoot, relPath);
|
|
77
|
+
if (!file.ok) {
|
|
78
|
+
return { status: file.status === "not_found" ? 404 : 400, json: file };
|
|
79
|
+
}
|
|
80
|
+
if (file.binary || file.content == null) {
|
|
81
|
+
return {
|
|
82
|
+
status: 400,
|
|
83
|
+
json: { ok: false, status: "not_publishable", message: `${relPath} has no text content to publish` },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const client = deps.mcpClient || createMcpClient(env.MCP_BASE_URL || DEFAULT_MCP_URL);
|
|
88
|
+
|
|
89
|
+
let session;
|
|
90
|
+
try {
|
|
91
|
+
// Developer handshake order (auth.mjs establishSession): attach the Bearer
|
|
92
|
+
// via setToken() BEFORE initialize() — the server binds the session to
|
|
93
|
+
// this identity at initialize time; init-then-attach leaves it anonymous.
|
|
94
|
+
session = await (deps.resolveSession || resolveDeveloperSession)(client, env);
|
|
95
|
+
await client.initialize();
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (e instanceof AuthUnavailableError) {
|
|
98
|
+
return {
|
|
99
|
+
status: 503,
|
|
100
|
+
json: { ok: false, status: "not_signed_in", message: `${e.message}${e.hint ? ` — ${e.hint}` : ""}` },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { status: 502, json: { ok: false, status: "error", message: String(e?.message || e) } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const repo = `${tenantId}-main`;
|
|
107
|
+
const changeId = deriveChangeId(tenantId, session.email || session.token);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const result = await client.callTool("candidate_open", {
|
|
111
|
+
repo,
|
|
112
|
+
changeId,
|
|
113
|
+
// Bounded to candidate_open's own schema limits (title<=200, body<=4000),
|
|
114
|
+
// same guard the hosted publish route applies.
|
|
115
|
+
title: `Cockpit draft: ${relPath}`.slice(0, 200),
|
|
116
|
+
body: `Submitted from the local \`tot dev\` cockpit for \`${relPath}\`.`.slice(0, 4000),
|
|
117
|
+
patch: [{ path: relPath, content: file.content }],
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
status: 200,
|
|
121
|
+
json: {
|
|
122
|
+
ok: true,
|
|
123
|
+
status: "submitted",
|
|
124
|
+
path: relPath,
|
|
125
|
+
changeId: result?.changeId || changeId,
|
|
126
|
+
prNumber: result?.prNumber ?? null,
|
|
127
|
+
branch: result?.branch ?? null,
|
|
128
|
+
url: result?.url ?? null,
|
|
129
|
+
mergeable: result?.mergeable ?? null,
|
|
130
|
+
state: result?.state ?? null,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return { status: 502, json: { ok: false, status: "error", message: String(e?.message || e) } };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- request-level handler (env + body → {status, json}) -------------------
|
|
139
|
+
// The deferred dev-plugins/dev-publish.mjs wrapper maps the HTTP req/res onto
|
|
140
|
+
// this; keeping it here makes the endpoint contract unit-testable without a
|
|
141
|
+
// running dev server (same pattern as file-writer.mjs's handleWrite).
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* POST /__tot/publish — body {path}.
|
|
145
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
146
|
+
* @param {{path?:string}} [body]
|
|
147
|
+
*/
|
|
148
|
+
export async function handlePublish(env = process.env, body = {}) {
|
|
149
|
+
const root = resolveTenantSourceRoot(env);
|
|
150
|
+
if (!root.ok) return { status: root.status === "no_tenant" ? 403 : 500, json: root };
|
|
151
|
+
|
|
152
|
+
const relPath = body && body.path;
|
|
153
|
+
if (!relPath) {
|
|
154
|
+
return { status: 400, json: { ok: false, status: "no_path", message: "path is required" } };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return publishFile(env, root.sourceRoot, root.tenantId, relPath);
|
|
158
|
+
}
|
|
@@ -29,6 +29,7 @@ export function isTransientWatchFile(name) {
|
|
|
29
29
|
/^#.*#$/.test(name) || // emacs autosave (#file#)
|
|
30
30
|
name === ".DS_Store" ||
|
|
31
31
|
name === "Thumbs.db" ||
|
|
32
|
-
name === "4913" // vim's write-probe file
|
|
32
|
+
name === "4913" || // vim's write-probe file
|
|
33
|
+
name.startsWith(".") // any dotfile — not store content, catches the rest (vim swaps included)
|
|
33
34
|
);
|
|
34
35
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|