@tokenoftrust/storefront-runner 1.3.2
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/LICENSE +58 -0
- package/README.md +42 -0
- package/apps/storefront/astro.config.mjs +66 -0
- package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
- package/apps/storefront/env.d.ts +162 -0
- package/apps/storefront/migrations/0000_baseline.sql +54 -0
- package/apps/storefront/migrations/0001_products_fts.sql +9 -0
- package/apps/storefront/migrations/README.md +54 -0
- package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/_journal.json +20 -0
- package/apps/storefront/package.json +42 -0
- package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
- package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
- package/apps/storefront/public/brand/northwind-og.svg +13 -0
- package/apps/storefront/public/favicon.svg +5 -0
- package/apps/storefront/public/js/dashboard-team.js +147 -0
- package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
- package/apps/storefront/public/shared/commerce-chrome.css +369 -0
- package/apps/storefront/src/components/Badge.astro +34 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
- package/apps/storefront/src/components/CollectionCard.astro +56 -0
- package/apps/storefront/src/components/EmptyState.astro +55 -0
- package/apps/storefront/src/components/Img.astro +68 -0
- package/apps/storefront/src/components/ProductCard.astro +228 -0
- package/apps/storefront/src/components/Section.astro +39 -0
- package/apps/storefront/src/components/Seo.astro +70 -0
- package/apps/storefront/src/components/WidgetFrame.astro +34 -0
- package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +531 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
- package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
- package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
- package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
- package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -0
- package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
- package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
- package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
- package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
- package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -0
- package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
- package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
- package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
- package/apps/storefront/src/components/commerce/ReviewList.astro +54 -0
- package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
- package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
- package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
- package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
- package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
- package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
- package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
- package/apps/storefront/src/components/home/Hero.astro +164 -0
- package/apps/storefront/src/components/islands/AgeGate.tsx +109 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
- package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
- package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
- package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
- package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +375 -0
- package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
- package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
- package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
- package/apps/storefront/src/components/marketing/Faq.astro +37 -0
- package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
- package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
- package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
- package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
- package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
- package/apps/storefront/src/components/marketing/Steps.astro +36 -0
- package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
- package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
- package/apps/storefront/src/components/membership/IllustrativeLabel.astro +56 -0
- package/apps/storefront/src/components/membership/TierCards.astro +164 -0
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
- package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
- package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -0
- package/apps/storefront/src/components/plp/Pagination.astro +99 -0
- package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
- package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +95 -0
- package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
- package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
- package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
- package/apps/storefront/src/config/devTenantSeed.ts +78 -0
- package/apps/storefront/src/config/devTenants.ts +16 -0
- package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
- package/apps/storefront/src/config/resolver.ts +102 -0
- package/apps/storefront/src/config/tenants.ts +87 -0
- package/apps/storefront/src/layouts/Layout.astro +368 -0
- package/apps/storefront/src/lib/admin/README.md +122 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
- package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
- package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
- package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
- package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
- package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
- package/apps/storefront/src/lib/admin/envelope.ts +10 -0
- package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
- package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
- package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
- package/apps/storefront/src/lib/admin/seed.ts +54 -0
- package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
- package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
- package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
- package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
- package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
- package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
- package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
- package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
- package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
- package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
- package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
- package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
- package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
- package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
- package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
- package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -0
- package/apps/storefront/src/lib/analytics/index.ts +47 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
- package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
- package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +119 -0
- package/apps/storefront/src/lib/auth/route.ts +146 -0
- package/apps/storefront/src/lib/auth/session.ts +203 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
- package/apps/storefront/src/lib/basePath.ts +41 -0
- package/apps/storefront/src/lib/cfImage.ts +55 -0
- package/apps/storefront/src/lib/chrome/model.ts +244 -0
- package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
- package/apps/storefront/src/lib/colors.ts +97 -0
- package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -0
- package/apps/storefront/src/lib/content-edit/client.ts +230 -0
- package/apps/storefront/src/lib/content-edit/index.ts +14 -0
- package/apps/storefront/src/lib/content-edit/route.ts +25 -0
- package/apps/storefront/src/lib/content-edit/types.ts +19 -0
- package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
- package/apps/storefront/src/lib/d1/catalog.ts +366 -0
- package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
- package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
- package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
- package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
- package/apps/storefront/src/lib/demoGate.ts +113 -0
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +78 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
- package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
- package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
- package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
- package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
- package/apps/storefront/src/lib/dev/storeStatus.ts +142 -0
- package/apps/storefront/src/lib/diag/redact.ts +20 -0
- package/apps/storefront/src/lib/edgeCache.ts +128 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
- package/apps/storefront/src/lib/env.ts +60 -0
- package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
- package/apps/storefront/src/lib/format.ts +38 -0
- package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
- package/apps/storefront/src/lib/imageAttrs.ts +55 -0
- package/apps/storefront/src/lib/jsonld.ts +166 -0
- package/apps/storefront/src/lib/maintenance.ts +71 -0
- package/apps/storefront/src/lib/membership/model.ts +97 -0
- package/apps/storefront/src/lib/memoryKv.ts +32 -0
- package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
- package/apps/storefront/src/lib/placeholder.ts +99 -0
- package/apps/storefront/src/lib/previewReload.ts +125 -0
- package/apps/storefront/src/lib/priceBuckets.ts +33 -0
- package/apps/storefront/src/lib/rawChrome.ts +505 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -0
- package/apps/storefront/src/lib/search/index.ts +74 -0
- package/apps/storefront/src/lib/sections.ts +129 -0
- package/apps/storefront/src/lib/securityHeaders.ts +31 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +607 -0
- package/apps/storefront/src/lib/subscription/model.ts +68 -0
- package/apps/storefront/src/lib/tenantRouting.ts +91 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
- package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
- package/apps/storefront/src/lib/tot/query.ts +249 -0
- package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
- package/apps/storefront/src/lib/url.ts +142 -0
- package/apps/storefront/src/lib/wishlist.ts +63 -0
- package/apps/storefront/src/middleware/index.ts +455 -0
- package/apps/storefront/src/pages/404.astro +61 -0
- package/apps/storefront/src/pages/[...slug].astro +90 -0
- package/apps/storefront/src/pages/admin.astro +1675 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
- package/apps/storefront/src/pages/api/admin/orders.ts +71 -0
- package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
- package/apps/storefront/src/pages/api/auth/send.ts +85 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
- package/apps/storefront/src/pages/api/checkout-style.css.ts +43 -0
- package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -0
- package/apps/storefront/src/pages/api/csp-report.ts +25 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
- package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
- package/apps/storefront/src/pages/api/dev/activity.ts +124 -0
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
- package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
- package/apps/storefront/src/pages/api/dev/file.ts +57 -0
- package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
- package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
- package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
- package/apps/storefront/src/pages/api/dev/write.ts +116 -0
- package/apps/storefront/src/pages/api/newsletter.ts +104 -0
- package/apps/storefront/src/pages/api/request-access.ts +172 -0
- package/apps/storefront/src/pages/auth/login.astro +149 -0
- package/apps/storefront/src/pages/auth/magic.astro +105 -0
- package/apps/storefront/src/pages/capabilities.astro +292 -0
- package/apps/storefront/src/pages/collections/[handle].astro +177 -0
- package/apps/storefront/src/pages/collections/index.astro +59 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
- package/apps/storefront/src/pages/dashboard/index.astro +160 -0
- package/apps/storefront/src/pages/index.astro +290 -0
- package/apps/storefront/src/pages/llms.txt.ts +92 -0
- package/apps/storefront/src/pages/products/[handle].astro +402 -0
- package/apps/storefront/src/pages/robots.txt.ts +24 -0
- package/apps/storefront/src/pages/saved.astro +35 -0
- package/apps/storefront/src/pages/search.astro +150 -0
- package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +1280 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
- package/apps/storefront/src/pages/style-guide/index.astro +98 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
- package/apps/storefront/src/styles/global.css +292 -0
- package/apps/storefront/src/themes/reference.ts +68 -0
- package/apps/storefront/src/themes/schema.ts +111 -0
- package/apps/storefront/src/themes/tenants/index.ts +51 -0
- package/apps/storefront/tsconfig.json +16 -0
- package/apps/storefront/vitest.config.ts +22 -0
- package/package.json +26 -0
- package/packages/public-runtime/LICENSE +58 -0
- package/packages/public-runtime/package.json +22 -0
- package/packages/public-runtime/src/binary-path.ts +68 -0
- package/packages/public-runtime/src/capabilities.ts +89 -0
- package/packages/public-runtime/src/catalog-api.ts +81 -0
- package/packages/public-runtime/src/catalog-d1.ts +192 -0
- package/packages/public-runtime/src/checkout-style-publish.ts +200 -0
- package/packages/public-runtime/src/checkout-style.ts +375 -0
- package/packages/public-runtime/src/csp.ts +109 -0
- package/packages/public-runtime/src/customization-preview.ts +197 -0
- package/packages/public-runtime/src/customization-reconcile.ts +63 -0
- package/packages/public-runtime/src/customization-runtime.ts +157 -0
- package/packages/public-runtime/src/customization-scripts.ts +64 -0
- package/packages/public-runtime/src/customization-versioning.ts +95 -0
- package/packages/public-runtime/src/foxy-orders.ts +136 -0
- package/packages/public-runtime/src/foxy.ts +196 -0
- package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
- package/packages/public-runtime/src/index.ts +34 -0
- package/packages/public-runtime/src/product.ts +159 -0
- package/packages/public-runtime/src/search.ts +45 -0
- package/packages/public-runtime/src/tenant-assets.ts +444 -0
- package/packages/public-runtime/src/tenant.ts +384 -0
- package/packages/public-runtime/tsconfig.json +8 -0
- package/packages/public-runtime/vitest.config.ts +8 -0
- package/pnpm-workspace.yaml +7 -0
- package/scripts/build/copy-tenant-assets.mjs +69 -0
- package/scripts/dev/ai-edit.mjs +183 -0
- package/scripts/dev/checkout-watch.mjs +116 -0
- package/scripts/dev/file-browser.mjs +159 -0
- package/scripts/dev/file-writer.mjs +141 -0
- package/scripts/dev/git-status.mjs +171 -0
- package/scripts/dev/locate-handler.mjs +163 -0
- package/scripts/dev/locate.mjs +112 -0
- package/scripts/dev/port-check.mjs +48 -0
- package/scripts/dev/safe-path.mjs +109 -0
- package/scripts/dev/shot.mjs +46 -0
- package/scripts/dev/tenant-source-root.mjs +76 -0
- package/scripts/dev/unified-diff.mjs +81 -0
- package/scripts/tot-dev.mjs +376 -0
- package/tenants/README.md +14 -0
- package/tenants/home/content/chrome.json +7 -0
- package/tenants/home/content/home.html +58 -0
- package/tenants/home/content/home.json +11 -0
- package/tenants/home/public/js/storefront.js +210 -0
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +212 -0
- package/tenants/home/theme.json +6 -0
- package/tsconfig.base.json +20 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a browserless "get going" command for an ALREADY-AUTHENTICATED developer — the
|
|
3
|
+
* second half of the invite story. The developer accepted the magic link (OTP-free web
|
|
4
|
+
* sign-in) and landed on /dev; this hands them a single-copy, TWO-LINE paste — line 1 signs
|
|
5
|
+
* the terminal in with the one-time code, line 2 runs `start` (checkout → run → open the
|
|
6
|
+
* browser on the working site) — so they go from web sign-in to a running store with NO
|
|
7
|
+
* second browser round-trip and NO OTP.
|
|
8
|
+
*
|
|
9
|
+
* The token is minted by the MCP (POST /api/internal/dev-access/cli-signin-code), bound
|
|
10
|
+
* to the MCP's OWN audience, so it's redeemable only by that same MCP's /oauth/redeem-code
|
|
11
|
+
* — exactly what `tot login --code` does. We reuse the SAME seam as the app-binding writer:
|
|
12
|
+
* - MCP host ← the ORIGIN of MCP_APP_BINDING_URL (one source, so the mint host and the
|
|
13
|
+
* --mcp host the developer runs are provably the same MCP tier).
|
|
14
|
+
* - auth ← header x-mcp-service-secret: MCP_SERVICE_SHARED_SECRET (the storefront
|
|
15
|
+
* never holds the MCP's core credential).
|
|
16
|
+
*
|
|
17
|
+
* Line 1 appends `--mcp <origin>` ONLY when the MCP is not the CLI's built-in prod default —
|
|
18
|
+
* on prod the shortest form (`npx @tokenoftrust/cli login --code <token>`) is emitted.
|
|
19
|
+
*
|
|
20
|
+
* `activityBridge` (optional) piggybacks the local→hosted activity-bridge credential
|
|
21
|
+
* (see lib/dev/activityIngestToken.ts) onto the SAME pasted line as two extra `tot
|
|
22
|
+
* login` flags — no second browser round-trip, no MCP-repo change. `tot login`
|
|
23
|
+
* caches it in ~/.tot/credentials.json, and `tot dev` threads it into the spawned
|
|
24
|
+
* dev-loop process so it can report file saves to the developer's own hosted /dev
|
|
25
|
+
* panel. Omitted entirely when the caller has no SESSION KV to mint against — the
|
|
26
|
+
* local loop then simply runs with no hosted signal, same as today.
|
|
27
|
+
*/
|
|
28
|
+
import { readEnv } from "@/lib/env";
|
|
29
|
+
|
|
30
|
+
// The CLI's built-in default MCP (packages/cli login.mjs DEFAULT_MCP_URL). When the derived
|
|
31
|
+
// MCP origin equals this, --mcp is redundant noise, so we omit it.
|
|
32
|
+
const PROD_MCP_ORIGIN = "https://mcp.tokenoftrust.com";
|
|
33
|
+
|
|
34
|
+
export interface HttpTransport {
|
|
35
|
+
post(
|
|
36
|
+
url: string,
|
|
37
|
+
headers: Record<string, string>,
|
|
38
|
+
body: unknown,
|
|
39
|
+
): Promise<{ status: number; json(): Promise<unknown> }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class FetchHttpTransport implements HttpTransport {
|
|
43
|
+
async post(url: string, headers: Record<string, string>, body: unknown) {
|
|
44
|
+
const res = await fetch(url, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
47
|
+
body: JSON.stringify(body),
|
|
48
|
+
});
|
|
49
|
+
return { status: res.status, json: () => res.json() };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type CliSignInCodeResult =
|
|
54
|
+
| { ok: true; command: string; expiresAt: string | null }
|
|
55
|
+
| { ok: false; status: 502 | 503; reason: string };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mint the ready-to-paste CLI sign-in command for `email`. The caller MUST pass the
|
|
59
|
+
* VERIFIED session email (never a client-supplied value) — a signed-in developer can only
|
|
60
|
+
* mint a code for themselves. `tenant` (the developer's own vendor, session-derived) is
|
|
61
|
+
* threaded to the MCP so the code is TENANT-SCOPED: the redeemed CLI session lands
|
|
62
|
+
* already bound to that store (no client_list round-trip; see the route header). Older
|
|
63
|
+
* MCP deploys ignore the extra field — additive, never breaking. Fail-closed: an
|
|
64
|
+
* unconfigured seam (no URL/secret) or a 401 (MCP route unmounted / bad secret) is a 503;
|
|
65
|
+
* any other non-2xx / transport / parse fault is a 502. Never throws.
|
|
66
|
+
*/
|
|
67
|
+
export async function mintCliSignInCommand(
|
|
68
|
+
email: string,
|
|
69
|
+
tenant: string | null = null,
|
|
70
|
+
deps: { transport?: HttpTransport } = {},
|
|
71
|
+
activityBridge: { token: string; url: string } | null = null,
|
|
72
|
+
): Promise<CliSignInCodeResult> {
|
|
73
|
+
const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
|
|
74
|
+
const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
|
|
75
|
+
if (!bindingUrl || !secret) {
|
|
76
|
+
return { ok: false, status: 503, reason: "not-configured" };
|
|
77
|
+
}
|
|
78
|
+
let mcpOrigin: string;
|
|
79
|
+
let endpoint: string;
|
|
80
|
+
try {
|
|
81
|
+
mcpOrigin = new URL(bindingUrl).origin;
|
|
82
|
+
// Mounted OUTSIDE /api on the MCP (the /api surface there is session-guarded); use the
|
|
83
|
+
// origin of MCP_APP_BINDING_URL, not its full path.
|
|
84
|
+
endpoint = `${mcpOrigin}/internal/dev-access/cli-signin-code`;
|
|
85
|
+
} catch {
|
|
86
|
+
return { ok: false, status: 503, reason: "bad-mcp-url" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const transport = deps.transport ?? new FetchHttpTransport();
|
|
90
|
+
let res: { status: number; json(): Promise<unknown> };
|
|
91
|
+
try {
|
|
92
|
+
res = await transport.post(
|
|
93
|
+
endpoint,
|
|
94
|
+
{ "x-mcp-service-secret": secret },
|
|
95
|
+
{
|
|
96
|
+
email: email.toLowerCase(),
|
|
97
|
+
...(tenant ? { tenant: tenant.toLowerCase() } : {}),
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
} catch (cause) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
status: 502,
|
|
104
|
+
reason: "mcp request failed: " + (cause instanceof Error ? cause.message : String(cause)),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (res.status < 200 || res.status >= 300) {
|
|
109
|
+
// 401 = MCP route unmounted or bad shared secret → treat as not-configured (503).
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
status: res.status === 401 ? 503 : 502,
|
|
113
|
+
reason: `mcp cli-signin-code returned status ${res.status}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let payload: { token?: string; expiresAt?: string };
|
|
118
|
+
try {
|
|
119
|
+
payload = (await res.json()) as typeof payload;
|
|
120
|
+
} catch {
|
|
121
|
+
return { ok: false, status: 502, reason: "unparseable mcp response" };
|
|
122
|
+
}
|
|
123
|
+
if (!payload.token) {
|
|
124
|
+
return { ok: false, status: 502, reason: "mcp returned no token" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const mcpFlag = mcpOrigin === PROD_MCP_ORIGIN ? "" : ` --mcp ${mcpOrigin}`;
|
|
128
|
+
// Piggyback the activity-bridge credential onto the SAME login line — additive
|
|
129
|
+
// flags, ignored by older CLI builds that don't yet recognize them.
|
|
130
|
+
const activityFlags = activityBridge
|
|
131
|
+
? ` --activity-token ${activityBridge.token} --activity-url ${activityBridge.url}`
|
|
132
|
+
: "";
|
|
133
|
+
// A single copy → a three-line paste that takes the developer all the way to a running
|
|
134
|
+
// store, no browser, AND leaves `tot` on their PATH so every later command is a bare
|
|
135
|
+
// `tot …` (not `npx @tokenoftrust/cli …`):
|
|
136
|
+
// 1. `npm i -g @tokenoftrust/cli` — install the `tot` command once.
|
|
137
|
+
// 2. `tot login --code <token>` — sign the terminal in with the single-use code.
|
|
138
|
+
// 3. `tot start` — checkout + run + open the browser on the working site, reusing the
|
|
139
|
+
// session step 2 just cached (so `start` never opens its own sign-in).
|
|
140
|
+
//
|
|
141
|
+
// The trailing `&&` chains them: each step runs ONLY if the previous succeeded. In
|
|
142
|
+
// particular a stale/burned code (single-use, short-TTL) fails step 2 and stops there
|
|
143
|
+
// with `login`'s clear "ask for a fresh invite" error — it never falls through to
|
|
144
|
+
// `start`'s BROWSER sign-in (the surprise we're avoiding).
|
|
145
|
+
const command = [
|
|
146
|
+
`npm i -g @tokenoftrust/cli &&`,
|
|
147
|
+
`tot login --code ${payload.token}${mcpFlag}${activityFlags} &&`,
|
|
148
|
+
`tot start`,
|
|
149
|
+
].join("\n");
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
command,
|
|
153
|
+
expiresAt: payload.expiresAt ?? null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-developer DRAFT overlay for the /dev live preview — the signed token + KV key
|
|
3
|
+
* contract behind decision `draft-overlay-same-origin` (workstream 8500-dev-live-preview).
|
|
4
|
+
*
|
|
5
|
+
* WHAT A DRAFT IS: when the /dev page's onscreen AI loop edits the developer's site
|
|
6
|
+
* (`/api/content-edit/ai-edit`), the edited artifacts land in TENANT_CACHE KV under
|
|
7
|
+
* `dev-draft:<tenantId>:<draftId>:<path>` and the developer gets a signed, tenant-bound,
|
|
8
|
+
* short-TTL `tot_dev_draft` cookie. The middleware overlays those artifacts onto content
|
|
9
|
+
* + theme ONLY for requests carrying a verifying cookie for the resolved tenant — so the
|
|
10
|
+
* editing developer sees (and can interact with) their change in the same-origin live
|
|
11
|
+
* preview instantly, while live/test channels and every other viewer are untouched.
|
|
12
|
+
* Compliance gating stays at publish/submit (gate-at-publish), not at edit.
|
|
13
|
+
*
|
|
14
|
+
* TRUST MODEL: identical to the existing preview token (customization-preview.ts) —
|
|
15
|
+
* HMAC-SHA256 over the claims payload with CUSTOMIZATION_PREVIEW_SECRET, expiry checked
|
|
16
|
+
* against a caller-supplied clock, tenant-bound so a token for tenant A can never
|
|
17
|
+
* surface tenant B's draft. Pure Web Crypto; renderer/Workers-safe; never throws.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Cookie carrying the signed draft token. HttpOnly; never readable by page script. */
|
|
21
|
+
export const DEV_DRAFT_COOKIE = "tot_dev_draft";
|
|
22
|
+
|
|
23
|
+
/** Draft artifacts live this long (KV TTL + token TTL + cookie max-age), seconds. */
|
|
24
|
+
export const DEV_DRAFT_TTL_SECONDS = 7 * 24 * 3600;
|
|
25
|
+
|
|
26
|
+
/** Claims carried by a draft token. */
|
|
27
|
+
export interface DevDraftClaims {
|
|
28
|
+
tenantId: string;
|
|
29
|
+
/** Opaque per-draft id (random UUID) — partitions one developer's draft from another's. */
|
|
30
|
+
draftId: string;
|
|
31
|
+
/** Expiry, epoch seconds. */
|
|
32
|
+
exp: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* KV key for one draft artifact. `path` mirrors the tenant repo layout the rest of the
|
|
37
|
+
* customization store uses: "content/home.html", "content/pages-html/<slug>.html",
|
|
38
|
+
* "theme.json".
|
|
39
|
+
*/
|
|
40
|
+
export function devDraftKey(tenantId: string, draftId: string, path: string): string {
|
|
41
|
+
return `dev-draft:${tenantId}:${draftId}:${path}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const textEncoder = new TextEncoder();
|
|
45
|
+
|
|
46
|
+
function bytesToB64Url(bytes: Uint8Array): string {
|
|
47
|
+
let bin = "";
|
|
48
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
49
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function b64UrlToBytes(s: string): Uint8Array {
|
|
53
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
|
|
54
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
55
|
+
const out = new Uint8Array(bin.length);
|
|
56
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function hmacKey(secret: string): Promise<CryptoKey> {
|
|
61
|
+
return crypto.subtle.importKey(
|
|
62
|
+
"raw",
|
|
63
|
+
textEncoder.encode(secret) as unknown as BufferSource,
|
|
64
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
65
|
+
false,
|
|
66
|
+
["sign", "verify"],
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Mint a draft token: `<payloadB64Url>.<sigB64Url>` (same wire shape as preview tokens). */
|
|
71
|
+
export async function signDevDraftToken(
|
|
72
|
+
secret: string,
|
|
73
|
+
tenantId: string,
|
|
74
|
+
draftId: string,
|
|
75
|
+
ttlSeconds: number,
|
|
76
|
+
nowSeconds: number,
|
|
77
|
+
): Promise<string> {
|
|
78
|
+
const claims: DevDraftClaims = { tenantId, draftId, exp: nowSeconds + ttlSeconds };
|
|
79
|
+
const payload = bytesToB64Url(textEncoder.encode(JSON.stringify(claims)));
|
|
80
|
+
const key = await hmacKey(secret);
|
|
81
|
+
const sig = await crypto.subtle.sign(
|
|
82
|
+
"HMAC",
|
|
83
|
+
key,
|
|
84
|
+
textEncoder.encode(payload) as unknown as BufferSource,
|
|
85
|
+
);
|
|
86
|
+
return `${payload}.${bytesToB64Url(new Uint8Array(sig))}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Verify a draft token; returns the claims or null (bad shape, bad signature, expired,
|
|
91
|
+
* malformed claims). Constant-time signature check before anything else is trusted.
|
|
92
|
+
*/
|
|
93
|
+
export async function verifyDevDraftToken(
|
|
94
|
+
secret: string,
|
|
95
|
+
token: string,
|
|
96
|
+
nowSeconds: number,
|
|
97
|
+
): Promise<DevDraftClaims | null> {
|
|
98
|
+
const dot = token.indexOf(".");
|
|
99
|
+
if (dot <= 0 || dot === token.length - 1) return null;
|
|
100
|
+
const payload = token.slice(0, dot);
|
|
101
|
+
|
|
102
|
+
let sig: Uint8Array;
|
|
103
|
+
try {
|
|
104
|
+
sig = b64UrlToBytes(token.slice(dot + 1));
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const key = await hmacKey(secret);
|
|
109
|
+
const valid = await crypto.subtle.verify(
|
|
110
|
+
"HMAC",
|
|
111
|
+
key,
|
|
112
|
+
sig as unknown as BufferSource,
|
|
113
|
+
textEncoder.encode(payload) as unknown as BufferSource,
|
|
114
|
+
);
|
|
115
|
+
if (!valid) return null;
|
|
116
|
+
|
|
117
|
+
let claims: DevDraftClaims;
|
|
118
|
+
try {
|
|
119
|
+
claims = JSON.parse(new TextDecoder().decode(b64UrlToBytes(payload)));
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
!claims ||
|
|
125
|
+
typeof claims.tenantId !== "string" ||
|
|
126
|
+
typeof claims.draftId !== "string" ||
|
|
127
|
+
typeof claims.exp !== "number" ||
|
|
128
|
+
claims.exp <= nowSeconds
|
|
129
|
+
) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return claims;
|
|
133
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared auth + tenant + provider resolution for the hosted /dev cockpit's
|
|
3
|
+
* /api/dev/* routes (cp-tree, cp-file, cp-write, and cp-diff/cp-locate landing
|
|
4
|
+
* on top). Mirrors /api/content-edit/ai-edit's gate exactly: a signed-in
|
|
5
|
+
* developer session with a real capability, resolving only the session's OWN
|
|
6
|
+
* tenant — never a caller-supplied one. See decision
|
|
7
|
+
* architecture-environment-aware-cockpit (workstream tot-lime-cloud-dev-cockpit).
|
|
8
|
+
*
|
|
9
|
+
* Also carries the path resolution + draft-overlay helpers shared by
|
|
10
|
+
* cp-file/cp-write/cp-diff, so the three routes agree on exactly one
|
|
11
|
+
* definition of "the writable surface" and "the developer's current value for
|
|
12
|
+
* a path" (draft-if-present else published) rather than three copies drifting.
|
|
13
|
+
*/
|
|
14
|
+
import type { APIContext } from "astro";
|
|
15
|
+
import { readViewerSession } from "@/lib/auth/route";
|
|
16
|
+
import { json } from "@/lib/grants/route.js";
|
|
17
|
+
import { readEnv, readKv } from "@/lib/env";
|
|
18
|
+
import { resolvePreviewTenant } from "@/config/previewTenantLookup";
|
|
19
|
+
import { createContentProvider, type ContentProvider } from "@/lib/storyblok/provider";
|
|
20
|
+
import { readPublishedArtifact, themeArtifactPath } from "@tot/public-runtime";
|
|
21
|
+
import type { TenantConfig } from "@tot/public-runtime";
|
|
22
|
+
import {
|
|
23
|
+
DEV_DRAFT_COOKIE,
|
|
24
|
+
DEV_DRAFT_TTL_SECONDS,
|
|
25
|
+
devDraftKey,
|
|
26
|
+
signDevDraftToken,
|
|
27
|
+
verifyDevDraftToken,
|
|
28
|
+
} from "@/lib/dev/devDraft";
|
|
29
|
+
|
|
30
|
+
export interface CockpitContext {
|
|
31
|
+
tenant: TenantConfig;
|
|
32
|
+
provider: ContentProvider;
|
|
33
|
+
kvGet: (key: string) => Promise<string | null>;
|
|
34
|
+
kvPut: ((key: string, value: string, opts: { expirationTtl: number }) => Promise<void>) | null;
|
|
35
|
+
draftSecret: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Resolved context, or a Response the caller should return as-is (401/403). */
|
|
39
|
+
export async function resolveCockpitContext(
|
|
40
|
+
context: APIContext,
|
|
41
|
+
): Promise<CockpitContext | Response> {
|
|
42
|
+
const session = await readViewerSession({ cookies: context.cookies });
|
|
43
|
+
if (!session) return json({ ok: false, message: "sign in required" }, 401);
|
|
44
|
+
if (!session.capability || session.capability === "none") {
|
|
45
|
+
return json({ ok: false, message: "no developer capability on this account" }, 403);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The tenant is the session's OWN store (first membership, else the session's
|
|
49
|
+
// primary resource) — the same resolution /dev renders with and ai-edit.ts
|
|
50
|
+
// authenticates edits against. No caller-supplied tenant is ever honored.
|
|
51
|
+
const wanted = (session.tenants?.[0]?.resource ?? session.resource ?? "").toLowerCase();
|
|
52
|
+
const tenant = wanted ? await resolvePreviewTenant(wanted) : null;
|
|
53
|
+
if (!tenant) return json({ ok: false, message: "no store linked to this account" }, 403);
|
|
54
|
+
|
|
55
|
+
const kv = await readKv("TENANT_CACHE");
|
|
56
|
+
const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
|
|
57
|
+
const kvPut = kv
|
|
58
|
+
? (key: string, value: string, opts: { expirationTtl: number }) => kv.put(key, value, opts)
|
|
59
|
+
: null;
|
|
60
|
+
const draftSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
|
|
61
|
+
const provider = createContentProvider(tenant, kv ? { get: kvGet, env: "live" } : undefined);
|
|
62
|
+
|
|
63
|
+
return { tenant, provider, kvGet, kvPut, draftSecret };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isCockpitContext(v: CockpitContext | Response): v is CockpitContext {
|
|
67
|
+
return !(v instanceof Response);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// The writable surface: content/home.html, content/pages-html/<slug>.html,
|
|
72
|
+
// theme.json — exactly scripts/dev/safe-path.mjs's WRITE_ALLOWLIST, mirrored
|
|
73
|
+
// here since the hosted Worker has no filesystem to check it against.
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const PAGE_HTML_RE = /^content\/pages-html\/([^\0]+)\.html$/;
|
|
77
|
+
|
|
78
|
+
export function pageSlugForPath(path: string): string | null {
|
|
79
|
+
return PAGE_HTML_RE.exec(path)?.[1] ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isWritablePath(path: string): boolean {
|
|
83
|
+
return path === "content/home.html" || path === "theme.json" || PAGE_HTML_RE.test(path);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function languageForPath(path: string): string | null {
|
|
87
|
+
if (path === "theme.json") return "json";
|
|
88
|
+
if (path === "content/home.html" || PAGE_HTML_RE.test(path)) return "html";
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The PUBLISHED value for one of the three writable-surface paths, or null if unknown/absent. */
|
|
93
|
+
export async function readPublishedPath(ctx: CockpitContext, path: string): Promise<string | null> {
|
|
94
|
+
if (path === "content/home.html") return ctx.provider.getHomeHtml();
|
|
95
|
+
if (path === "theme.json") {
|
|
96
|
+
return readPublishedArtifact(ctx.kvGet, ctx.tenant.tenant_id, "live", themeArtifactPath(ctx.tenant.tenant_id));
|
|
97
|
+
}
|
|
98
|
+
const slug = pageSlugForPath(path);
|
|
99
|
+
if (slug) return ctx.provider.getPageHtml(slug);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Deterministic small hash, standing in for the local file-writer's mtimeMs as
|
|
105
|
+
* an opaque "version" the client round-trips as `baseVersion` (C6 stale-write
|
|
106
|
+
* guard) — there's no filesystem here to stat. null in, null out (mirrors the
|
|
107
|
+
* local contract's `current: null` for "doesn't exist yet").
|
|
108
|
+
*/
|
|
109
|
+
export function contentVersion(content: string | null): number | null {
|
|
110
|
+
if (content === null) return null;
|
|
111
|
+
let h = 0x811c9dc5;
|
|
112
|
+
for (let i = 0; i < content.length; i++) {
|
|
113
|
+
h ^= content.charCodeAt(i);
|
|
114
|
+
h = Math.imul(h, 0x01000193);
|
|
115
|
+
}
|
|
116
|
+
return h >>> 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface DraftState {
|
|
120
|
+
/** The developer's existing draft id if their cookie verifies for this tenant, else a fresh one. */
|
|
121
|
+
draftId: string;
|
|
122
|
+
/** Draft-overlay value for `path` if one exists, else null. */
|
|
123
|
+
draftContent: string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve (or mint) this request's draft id + this path's current draft-overlay value, if any. */
|
|
127
|
+
export async function resolveDraftState(
|
|
128
|
+
context: APIContext,
|
|
129
|
+
ctx: CockpitContext,
|
|
130
|
+
path: string,
|
|
131
|
+
): Promise<DraftState> {
|
|
132
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
133
|
+
const priorCookie = context.cookies.get(DEV_DRAFT_COOKIE)?.value ?? null;
|
|
134
|
+
const priorClaims =
|
|
135
|
+
priorCookie && ctx.draftSecret ? await verifyDevDraftToken(ctx.draftSecret, priorCookie, nowSeconds) : null;
|
|
136
|
+
const draftId =
|
|
137
|
+
priorClaims?.tenantId === ctx.tenant.tenant_id ? priorClaims.draftId : crypto.randomUUID();
|
|
138
|
+
const draftContent = await ctx.kvGet(devDraftKey(ctx.tenant.tenant_id, draftId, path));
|
|
139
|
+
return { draftId, draftContent };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The value the developer currently sees for `path`: their draft if present, else published. */
|
|
143
|
+
export async function readEffectivePath(
|
|
144
|
+
context: APIContext,
|
|
145
|
+
ctx: CockpitContext,
|
|
146
|
+
path: string,
|
|
147
|
+
): Promise<{ content: string | null; draftId: string }> {
|
|
148
|
+
const { draftId, draftContent } = await resolveDraftState(context, ctx, path);
|
|
149
|
+
if (draftContent !== null) return { content: draftContent, draftId };
|
|
150
|
+
return { content: await readPublishedPath(ctx, path), draftId };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** (Re)issue the signed draft cookie so the middleware overlay + this route's own reads see it. */
|
|
154
|
+
export async function setDraftCookie(
|
|
155
|
+
context: APIContext,
|
|
156
|
+
ctx: CockpitContext,
|
|
157
|
+
draftId: string,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
if (!ctx.draftSecret) return;
|
|
160
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
161
|
+
const token = await signDevDraftToken(ctx.draftSecret, ctx.tenant.tenant_id, draftId, DEV_DRAFT_TTL_SECONDS, nowSeconds);
|
|
162
|
+
context.cookies.set(DEV_DRAFT_COOKIE, token, {
|
|
163
|
+
httpOnly: true,
|
|
164
|
+
secure: true,
|
|
165
|
+
sameSite: "lax",
|
|
166
|
+
path: "/",
|
|
167
|
+
maxAge: DEV_DRAFT_TTL_SECONDS,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the /dev "live tenant preview" plan — whether the page embeds a real, scaled-down
|
|
3
|
+
* iframe of the developer's own storefront (click-to-zoom into a fully interactive view),
|
|
4
|
+
* and the exact same-origin src to frame. Pure and renderer-safe: reads only public tenant
|
|
5
|
+
* shape + the request host + a config flag, never a secret.
|
|
6
|
+
*
|
|
7
|
+
* WHY AN IFRAME WORKS (no screenshots, no new infra): tenant routing is host-first with a
|
|
8
|
+
* `/<appDomain>/…` path-prefix fallback on the same worker (src/lib/tenantRouting.ts), so
|
|
9
|
+
* the tenant's real site is reachable at a RELATIVE path from /dev. CSP already permits
|
|
10
|
+
* same-origin framing both directions (`frame-ancestors 'self'` + `frame-src 'self'`,
|
|
11
|
+
* packages/public-runtime/src/csp.ts) alongside `X-Frame-Options: SAMEORIGIN` — no header
|
|
12
|
+
* changes. The iframe carries the viewer's own cookies, so a signed-in developer framing
|
|
13
|
+
* their own store passes the login gate by construction; /dev only renders the iframe for
|
|
14
|
+
* that viewer (the gated/signed-out states keep the static mock).
|
|
15
|
+
*
|
|
16
|
+
* CROSS-ORIGIN GOTCHA: never frame `hostedStoreUrl` (`*.tokenoftrust.store` subdomains) —
|
|
17
|
+
* those are cross-origin from the /dev host and blocked by `frame-src 'self'`. The src here
|
|
18
|
+
* is always relative: `/` when the request host itself owns the tenant (host-first routing
|
|
19
|
+
* skips the path prefix there), else `/${appDomain}/`.
|
|
20
|
+
*
|
|
21
|
+
* CONFIG TOGGLE: `DEV_LIVE_PREVIEW` (read via the readEnv seam in dev.astro). UNSET ⇒ ON.
|
|
22
|
+
* Set to "off" / "0" / "false" to fall back to the static mock per environment without a
|
|
23
|
+
* code change — the just-in-case lever if framing misbehaves somewhere.
|
|
24
|
+
*/
|
|
25
|
+
import type { TenantConfig } from "@tot/public-runtime";
|
|
26
|
+
|
|
27
|
+
/** Natural (unscaled) viewport the preview iframe renders at — a standard desktop. */
|
|
28
|
+
export const PREVIEW_VIEWPORT = { width: 1280, height: 800 } as const;
|
|
29
|
+
|
|
30
|
+
/** Fallback thumbnail scale before script measures the real container width. */
|
|
31
|
+
export const PREVIEW_FALLBACK_SCALE = 0.3;
|
|
32
|
+
|
|
33
|
+
export interface LivePreviewPlan {
|
|
34
|
+
/** Relative, same-origin iframe src for the tenant's real site (never a full URL). */
|
|
35
|
+
src: string;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
/** Initial mini-state scale; the page script refines it to fit the container. */
|
|
39
|
+
scale: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `DEV_LIVE_PREVIEW` semantics: default ON; only an explicit off flips it. */
|
|
43
|
+
export function livePreviewEnabled(envValue: string | undefined | null): boolean {
|
|
44
|
+
const v = (envValue ?? "").trim().toLowerCase();
|
|
45
|
+
return v !== "off" && v !== "0" && v !== "false";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The same-origin src for `tenant` as seen from `requestHost` (port ignored): `/` when the
|
|
50
|
+
* host itself owns the tenant (host-first routing serves it at the root there), else the
|
|
51
|
+
* path-prefix form `/${appDomain}/`.
|
|
52
|
+
*/
|
|
53
|
+
export function livePreviewSrc(tenant: TenantConfig | null, requestHost: string): string | null {
|
|
54
|
+
if (!tenant?.appDomain) return null;
|
|
55
|
+
const host = requestHost.split(":")[0]?.trim().toLowerCase() ?? "";
|
|
56
|
+
const owned = [tenant.appDomain, tenant.canonicalDomain, ...(tenant.domains ?? [])].some(
|
|
57
|
+
(d) => d?.toLowerCase() === host,
|
|
58
|
+
);
|
|
59
|
+
return owned ? "/" : `/${tenant.appDomain}/`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The full plan, or null when the preview shouldn't render (flag off, no tenant, or the
|
|
64
|
+
* viewer isn't an admitted developer — those states keep the static mock).
|
|
65
|
+
*/
|
|
66
|
+
export function deriveLivePreview(opts: {
|
|
67
|
+
envValue: string | undefined | null;
|
|
68
|
+
tenant: TenantConfig | null;
|
|
69
|
+
requestHost: string;
|
|
70
|
+
/** The viewer is signed in with a developer capability (dev.astro's hasStoreAccess gate). */
|
|
71
|
+
admitted: boolean;
|
|
72
|
+
}): LivePreviewPlan | null {
|
|
73
|
+
if (!opts.admitted || !livePreviewEnabled(opts.envValue)) return null;
|
|
74
|
+
const src = livePreviewSrc(opts.tenant, opts.requestHost);
|
|
75
|
+
if (!src) return null;
|
|
76
|
+
return { src, ...PREVIEW_VIEWPORT, scale: PREVIEW_FALLBACK_SCALE };
|
|
77
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-vendor "local dev runtime" latest-state — the LIVE half of the
|
|
3
|
+
* local→hosted activity bridge (G1). Where activityStore.ts holds a ring buffer
|
|
4
|
+
* of file-SAVE events, this holds ONE record per vendor: the most recent
|
|
5
|
+
* heartbeat the developer's LOCAL `tot dev`/`tot start` sent (see the CLI's
|
|
6
|
+
* dev-heartbeat.mjs). The hosted cockpit reads it to show a LIVE, clickable
|
|
7
|
+
* local-dev link + the running CLI version.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately SEPARATE from the file-activity buffer: a heartbeat is
|
|
10
|
+
* latest-wins state, not an appended event, so it lives under its own
|
|
11
|
+
* `dev-runtime:<tenant>` key and never touches the ring buffer. No file CONTENT
|
|
12
|
+
* is ever stored — `url` is only the local `http://localhost:<port>/<tenant>/`
|
|
13
|
+
* (low-sensitivity, per decision).
|
|
14
|
+
*
|
|
15
|
+
* Liveness is computed SERVER-side from `aliveAt` (the ingest-time server clock,
|
|
16
|
+
* NOT the client's `at`) so a skewed local clock can't make a dead loop look
|
|
17
|
+
* live or vice-versa. Same KV binding as the activity buffer (TENANT_CACHE).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The latest local-dev runtime state reported by a vendor's heartbeat. */
|
|
21
|
+
export interface DevRuntime {
|
|
22
|
+
/** The `tot` CLI version driving the local loop, when the heartbeat carried it. */
|
|
23
|
+
cliVersion?: string;
|
|
24
|
+
/** The @tokenoftrust/storefront-runner version, when resolvable at heartbeat time. */
|
|
25
|
+
runnerVersion?: string;
|
|
26
|
+
/** The local store URL — `http://localhost:<port>/<tenant>/`. Clickable in the cockpit. */
|
|
27
|
+
url?: string;
|
|
28
|
+
/** Epoch ms of the latest heartbeat, stamped by the SERVER at ingest (skew-proof). */
|
|
29
|
+
aliveAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The runtime as the GET feed exposes it — the stored record plus a computed `live` flag. */
|
|
33
|
+
export interface DevRuntimeView extends DevRuntime {
|
|
34
|
+
/** True when the last heartbeat landed within LIVE_WINDOW_MS of `now`. */
|
|
35
|
+
live: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const KEY_PREFIX = "dev-runtime:";
|
|
39
|
+
/**
|
|
40
|
+
* How recent the last heartbeat must be to read as "live". The CLI beats every
|
|
41
|
+
* ~10s, so 30s tolerates a couple of missed/slow beats before we call the loop
|
|
42
|
+
* stale rather than flapping the badge on one dropped POST.
|
|
43
|
+
*/
|
|
44
|
+
export const LIVE_WINDOW_MS = 30_000;
|
|
45
|
+
/**
|
|
46
|
+
* Keep a stopped loop's LAST runtime around a while so the cockpit can still
|
|
47
|
+
* show "last seen … / CLI vX.Y" (with live:false) after the developer quits —
|
|
48
|
+
* but not forever. Refreshed on every heartbeat, so an active loop never lapses.
|
|
49
|
+
*/
|
|
50
|
+
const TTL_SECONDS = 3600;
|
|
51
|
+
|
|
52
|
+
/** Cap the free-text fields a bridge-token holder can push into KV. */
|
|
53
|
+
const MAX_VERSION_LEN = 64;
|
|
54
|
+
const MAX_URL_LEN = 256;
|
|
55
|
+
|
|
56
|
+
function key(appDomain: string): string {
|
|
57
|
+
return KEY_PREFIX + appDomain;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function clip(v: unknown, max: number): string | undefined {
|
|
61
|
+
return typeof v === "string" && v.length > 0 ? v.slice(0, max) : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Accept ONLY a local http URL for the clickable cockpit link. The cockpit
|
|
66
|
+
* renders this as an `<a href>`, so a bounded string isn't enough — a
|
|
67
|
+
* non-localhost host or a non-http scheme (a phishing URL, `javascript:` …)
|
|
68
|
+
* must never be stored/rendered. Self-targeted today (the ingest token is the
|
|
69
|
+
* developer's own), but validating at the store closes the vector at the source.
|
|
70
|
+
*/
|
|
71
|
+
function localUrl(v: unknown): string | undefined {
|
|
72
|
+
const s = clip(v, MAX_URL_LEN);
|
|
73
|
+
return s && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(s) ? s : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Normalize a raw heartbeat body into a stored DevRuntime — dropping anything
|
|
78
|
+
* that isn't a bounded string and stamping the SERVER's `aliveAt`. Pure +
|
|
79
|
+
* exported so the route's shaping is unit-tested without KV.
|
|
80
|
+
*/
|
|
81
|
+
export function shapeRuntime(
|
|
82
|
+
body: { cliVersion?: unknown; runnerVersion?: unknown; url?: unknown },
|
|
83
|
+
aliveAt: number,
|
|
84
|
+
): DevRuntime {
|
|
85
|
+
const rt: DevRuntime = { aliveAt };
|
|
86
|
+
const cliVersion = clip(body.cliVersion, MAX_VERSION_LEN);
|
|
87
|
+
const runnerVersion = clip(body.runnerVersion, MAX_VERSION_LEN);
|
|
88
|
+
const url = localUrl(body.url);
|
|
89
|
+
if (cliVersion) rt.cliVersion = cliVersion;
|
|
90
|
+
if (runnerVersion) rt.runnerVersion = runnerVersion;
|
|
91
|
+
if (url) rt.url = url;
|
|
92
|
+
return rt;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Read the vendor's latest runtime, or null when none/malformed. Never throws. */
|
|
96
|
+
export async function readRuntime(kv: KVNamespace, appDomain: string): Promise<DevRuntime | null> {
|
|
97
|
+
try {
|
|
98
|
+
const raw = await kv.get(key(appDomain));
|
|
99
|
+
if (!raw) return null;
|
|
100
|
+
const parsed = JSON.parse(raw);
|
|
101
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.aliveAt !== "number") return null;
|
|
102
|
+
return parsed as DevRuntime;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Overwrite the vendor's runtime with the latest heartbeat (latest-wins, TTL'd). */
|
|
109
|
+
export async function writeRuntime(
|
|
110
|
+
kv: KVNamespace,
|
|
111
|
+
appDomain: string,
|
|
112
|
+
rt: DevRuntime,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
await kv.put(key(appDomain), JSON.stringify(rt), { expirationTtl: TTL_SECONDS });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Attach the computed `live` flag (aliveAt within LIVE_WINDOW_MS of `now`). Pure. */
|
|
118
|
+
export function toRuntimeView(rt: DevRuntime, now: number = Date.now()): DevRuntimeView {
|
|
119
|
+
return { ...rt, live: now - rt.aliveAt <= LIVE_WINDOW_MS };
|
|
120
|
+
}
|