@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.
Files changed (225) hide show
  1. package/apps/storefront/astro.config.mjs +15 -0
  2. package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
  3. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
  4. package/apps/storefront/drizzle.config.apps.ts +13 -0
  5. package/apps/storefront/env.d.ts +10 -0
  6. package/apps/storefront/migrations/README.md +13 -7
  7. package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
  8. package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
  10. package/apps/storefront/package.json +13 -1
  11. package/apps/storefront/perf/README.md +64 -0
  12. package/apps/storefront/perf/assert-budgets.ts +159 -0
  13. package/apps/storefront/playwright.config.ts +23 -0
  14. package/apps/storefront/public/js/dashboard-apps.js +173 -0
  15. package/apps/storefront/public/shared/commerce-marketing.css +221 -0
  16. package/apps/storefront/src/components/CollectionCard.astro +1 -0
  17. package/apps/storefront/src/components/ProductCard.astro +1 -0
  18. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
  19. package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
  20. package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
  21. package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
  22. package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
  23. package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
  24. package/apps/storefront/src/components/content/Callout.astro +75 -0
  25. package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
  26. package/apps/storefront/src/components/content/ProseSections.astro +121 -0
  27. package/apps/storefront/src/components/content/ProseToc.astro +34 -0
  28. package/apps/storefront/src/components/content/RichText.astro +44 -0
  29. package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
  30. package/apps/storefront/src/components/home/Hero.astro +14 -0
  31. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
  32. package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
  33. package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
  34. package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
  35. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
  36. package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
  37. package/apps/storefront/src/config/storeName.ts +34 -0
  38. package/apps/storefront/src/layouts/Layout.astro +97 -23
  39. package/apps/storefront/src/lib/analytics/budgets.json +69 -0
  40. package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
  41. package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
  42. package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
  43. package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
  44. package/apps/storefront/src/lib/apps/adminService.ts +106 -0
  45. package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
  46. package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
  47. package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
  48. package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
  49. package/apps/storefront/src/lib/apps/credentials.ts +153 -0
  50. package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
  51. package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
  52. package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
  53. package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
  54. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
  55. package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
  56. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
  57. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
  58. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
  59. package/apps/storefront/src/lib/apps/registryService.ts +579 -0
  60. package/apps/storefront/src/lib/apps/scopes.ts +79 -0
  61. package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
  62. package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
  63. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
  64. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
  65. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
  66. package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
  67. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
  68. package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
  69. package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
  70. package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
  71. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
  72. package/apps/storefront/src/lib/auth/route.ts +16 -1
  73. package/apps/storefront/src/lib/auth/session.ts +8 -0
  74. package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
  75. package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
  76. package/apps/storefront/src/lib/blog/provider.ts +39 -0
  77. package/apps/storefront/src/lib/blog/types.ts +26 -0
  78. package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
  79. package/apps/storefront/src/lib/chrome/model.ts +11 -0
  80. package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
  81. package/apps/storefront/src/lib/content/callout.ts +78 -0
  82. package/apps/storefront/src/lib/content/index.ts +27 -0
  83. package/apps/storefront/src/lib/content/needsReview.ts +58 -0
  84. package/apps/storefront/src/lib/content/prose.ts +186 -0
  85. package/apps/storefront/src/lib/content/richtext.ts +76 -0
  86. package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
  87. package/apps/storefront/src/lib/content-edit/client.ts +70 -14
  88. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  89. package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
  90. package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
  91. package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
  92. package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
  93. package/apps/storefront/src/lib/dev/obstacleStore.ts +214 -0
  94. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  95. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  96. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  97. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  98. package/apps/storefront/src/lib/env.ts +12 -0
  99. package/apps/storefront/src/lib/jsonld.ts +12 -17
  100. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  101. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  102. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  103. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  104. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  105. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  106. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  107. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  108. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  109. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  110. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  111. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  112. package/apps/storefront/src/lib/tot/query.ts +32 -0
  113. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  116. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  117. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  118. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  119. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  120. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  121. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  122. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  123. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  124. package/apps/storefront/src/middleware/index.ts +28 -13
  125. package/apps/storefront/src/pages/404.astro +21 -9
  126. package/apps/storefront/src/pages/[...slug].astro +36 -2
  127. package/apps/storefront/src/pages/admin.astro +29 -3
  128. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  129. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  130. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  131. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  132. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  133. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  134. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  135. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  137. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  138. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  139. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  142. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  143. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  144. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  146. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  147. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  149. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  150. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  151. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  152. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  153. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  154. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  155. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  156. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  157. package/apps/storefront/src/pages/auth/login.astro +197 -41
  158. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  159. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  160. package/apps/storefront/src/pages/blog/index.astro +98 -0
  161. package/apps/storefront/src/pages/capabilities.astro +8 -0
  162. package/apps/storefront/src/pages/cockpit.astro +496 -74
  163. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  164. package/apps/storefront/src/pages/collections/index.astro +10 -2
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  166. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  167. package/apps/storefront/src/pages/index.astro +57 -0
  168. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  169. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  170. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  171. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  175. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  176. package/apps/storefront/src/styles/fonts.css +54 -0
  177. package/apps/storefront/src/styles/global.css +22 -2
  178. package/apps/storefront/src/themes/schema.ts +3 -25
  179. package/apps/storefront/tsconfig.json +1 -1
  180. package/apps/storefront/vitest.config.ts +4 -1
  181. package/package.json +1 -1
  182. package/packages/public-runtime/src/candidate-index.ts +311 -0
  183. package/packages/public-runtime/src/checkout.ts +94 -2
  184. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  185. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  186. package/packages/public-runtime/src/compliance/index.ts +22 -0
  187. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  188. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  189. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  190. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  191. package/packages/public-runtime/src/csp.ts +27 -3
  192. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  193. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  194. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  195. package/packages/public-runtime/src/extension-contract.ts +2 -1
  196. package/packages/public-runtime/src/hash.ts +25 -0
  197. package/packages/public-runtime/src/index.ts +6 -0
  198. package/packages/public-runtime/src/membership.ts +353 -0
  199. package/packages/public-runtime/src/product.ts +24 -2
  200. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  201. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  202. package/packages/public-runtime/src/tenant.ts +236 -0
  203. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  204. package/scripts/dev/publish.mjs +158 -0
  205. package/scripts/dev/transient-files.mjs +2 -1
  206. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  223. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  224. package/tenants/home/public/pages/storefront.css +23 -0
  225. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -0,0 +1,146 @@
1
+ /**
2
+ * RS256 signing-key loader for outbound webhook delivery (PrivateApps epic,
3
+ * D4 Chunk B) — the key `signing.ts` uses to attach HTTP Message Signatures
4
+ * (RFC 9421) to every CloudEvents webhook POST.
5
+ *
6
+ * Mirrors `../apps/gatewayKeys.ts`'s `loadGatewaySigningConfig`/
7
+ * `loadGatewayJwks` structure EXACTLY (import cache keyed on the base64
8
+ * source, JWKS derivation that strips every private RSA field), but over a
9
+ * SEPARATE key (`WEBHOOK_SIGNING_PRIVATE_KEY_B64`) with a separate kid
10
+ * (`webhook-signing-1`) — per `d4-design-resolutions`, this key must never be
11
+ * the same as the D2 gateway key. The gateway key attests requests an app
12
+ * makes INTO the storefront (app → gateway trust); the webhook signing key
13
+ * attests events the storefront pushes OUT to an app (gateway → app trust,
14
+ * the opposite direction). Sharing a key would let a receiver's verification
15
+ * key double as the ability to (offline) verify gateway tokens, and rotating
16
+ * one would force rotating the other.
17
+ *
18
+ * The private key never leaves this module. `loadWebhookJwks` derives the
19
+ * PUBLIC-only JWKS from it, published (Chunk B) via a new field on
20
+ * `/.well-known/tot-app-authorization-metadata`.
21
+ */
22
+ import { importPKCS8, exportJWK, type CryptoKey, type JWK, type JSONWebKeySet } from "jose";
23
+ import { readEnv } from "@/lib/env";
24
+ // Re-exported (not re-defined) — `@tokenoftrust/private-apps-devkit`'s
25
+ // signing.ts is the ONE source of truth for this constant since D6 Chunk B
26
+ // (it's the `alg` its sign/verify functions produce/accept); this module
27
+ // just keeps the historical import path (`./webhookSigningKey.js`) working.
28
+ export { WEBHOOK_SIGNING_HTTPSIG_ALG } from "@tokenoftrust/private-apps-devkit";
29
+
30
+ /** The only algorithm the webhook signer signs with (verifiers reject anything else). */
31
+ export const WEBHOOK_SIGNING_ALG = "RS256";
32
+
33
+ /** Default key id (overridable via `WEBHOOK_SIGNING_KID`). Names the published key. */
34
+ export const DEFAULT_WEBHOOK_SIGNING_KID = "webhook-signing-1";
35
+
36
+ /** Env keys the loader reads (server-only). */
37
+ export const WEBHOOK_SIGNING_PRIVATE_KEY_ENV = "WEBHOOK_SIGNING_PRIVATE_KEY_B64";
38
+ export const WEBHOOK_SIGNING_KID_ENV = "WEBHOOK_SIGNING_KID";
39
+
40
+ /**
41
+ * Thrown when the webhook signer can't load its signing key — the private
42
+ * key env is absent/malformed. Callers FAIL CLOSED: with no key, no delivery
43
+ * can be signed, and the metadata route omits the webhook JWKS field rather
44
+ * than publish a key that will never actually sign anything.
45
+ */
46
+ export class WebhookSigningKeyError extends Error {}
47
+
48
+ /** Optional test/DI overrides (avoids touching process env in unit tests). */
49
+ export interface WebhookSigningKeyOverrides {
50
+ /** base64 of the PKCS8 PEM private key (defaults to the env value). */
51
+ privateKeyB64?: string;
52
+ kid?: string;
53
+ }
54
+
55
+ export interface WebhookSigningConfig {
56
+ key: CryptoKey;
57
+ kid: string;
58
+ }
59
+
60
+ // Same cache shape as gatewayKeys.ts's signingKeyCache/publicJwkCache, keyed
61
+ // on the base64 string so a rotated key transparently re-imports/re-derives.
62
+ const signingKeyCache = new Map<string, CryptoKey>();
63
+ const publicJwkCache = new Map<string, JWK>();
64
+
65
+ async function importPrivateKey(privateKeyB64: string): Promise<CryptoKey> {
66
+ const cached = signingKeyCache.get(privateKeyB64);
67
+ if (cached) return cached;
68
+ let pem: string;
69
+ try {
70
+ pem = Buffer.from(privateKeyB64, "base64").toString("utf8");
71
+ } catch (cause) {
72
+ throw new WebhookSigningKeyError(
73
+ `webhook signing: ${WEBHOOK_SIGNING_PRIVATE_KEY_ENV} is not valid base64 ` +
74
+ (cause instanceof Error ? cause.message : String(cause)),
75
+ );
76
+ }
77
+ if (!/-----BEGIN (RSA )?PRIVATE KEY-----/.test(pem)) {
78
+ throw new WebhookSigningKeyError(
79
+ `webhook signing: decoded ${WEBHOOK_SIGNING_PRIVATE_KEY_ENV} is not a PEM private key`,
80
+ );
81
+ }
82
+ let key: CryptoKey;
83
+ try {
84
+ // extractable: true — required so `loadWebhookJwks` below can derive the
85
+ // public JWK from this same key, and so `signing.ts` can sign raw bytes
86
+ // with `crypto.subtle.sign` directly (no JWS wrapping needed for RFC
87
+ // 9421 — the signature is over the derived signature base, not a JWT).
88
+ key = (await importPKCS8(pem, WEBHOOK_SIGNING_ALG, { extractable: true })) as CryptoKey;
89
+ } catch (cause) {
90
+ throw new WebhookSigningKeyError(
91
+ `webhook signing: failed to import the RS256 private key ` +
92
+ (cause instanceof Error ? cause.message : String(cause)),
93
+ );
94
+ }
95
+ signingKeyCache.set(privateKeyB64, key);
96
+ return key;
97
+ }
98
+
99
+ /**
100
+ * Resolve the active signing config (key + kid) from env, or `overrides` in
101
+ * tests. FAILS CLOSED: throws {@link WebhookSigningKeyError} when the private
102
+ * key env is absent/malformed.
103
+ */
104
+ export async function loadWebhookSigningConfig(
105
+ overrides: WebhookSigningKeyOverrides = {},
106
+ ): Promise<WebhookSigningConfig> {
107
+ const privateKeyB64 =
108
+ overrides.privateKeyB64 ?? (await readEnv(WEBHOOK_SIGNING_PRIVATE_KEY_ENV));
109
+ if (!privateKeyB64) {
110
+ throw new WebhookSigningKeyError(
111
+ `webhook signing: ${WEBHOOK_SIGNING_PRIVATE_KEY_ENV} is not configured (fail closed)`,
112
+ );
113
+ }
114
+ const kid = overrides.kid ?? (await readEnv(WEBHOOK_SIGNING_KID_ENV)) ?? DEFAULT_WEBHOOK_SIGNING_KID;
115
+ const key = await importPrivateKey(privateKeyB64);
116
+ return { key, kid };
117
+ }
118
+
119
+ /**
120
+ * Derive the PUBLIC JWKS for the active webhook signing key — sanitized
121
+ * (every private RSA field stripped) and cached by the source private-key
122
+ * base64. V1 always returns exactly one key; the envelope supports more
123
+ * without a shape change later (rotation-with-overlap).
124
+ */
125
+ export async function loadWebhookJwks(
126
+ overrides: WebhookSigningKeyOverrides = {},
127
+ ): Promise<JSONWebKeySet> {
128
+ const privateKeyB64 =
129
+ overrides.privateKeyB64 ?? (await readEnv(WEBHOOK_SIGNING_PRIVATE_KEY_ENV));
130
+ if (!privateKeyB64) {
131
+ throw new WebhookSigningKeyError(
132
+ `webhook signing: ${WEBHOOK_SIGNING_PRIVATE_KEY_ENV} is not configured (fail closed)`,
133
+ );
134
+ }
135
+ const { key, kid } = await loadWebhookSigningConfig(overrides);
136
+ let jwk = publicJwkCache.get(privateKeyB64);
137
+ if (!jwk) {
138
+ const full = (await exportJWK(key)) as Record<string, unknown>;
139
+ // Strip every private RSA field (RFC 7518 §6.3.2) — only the public parts
140
+ // (kty, n, e) are ever published. This line is the one place that matters.
141
+ const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, oth: _oth, ...publicOnly } = full;
142
+ jwk = { ...publicOnly, kid, alg: WEBHOOK_SIGNING_ALG, use: "sig" };
143
+ publicJwkCache.set(privateKeyB64, jwk);
144
+ }
145
+ return { keys: [jwk] };
146
+ }
@@ -30,6 +30,7 @@ import {
30
30
  buildCsp,
31
31
  STOREFRONT_CSP_ALLOWLIST,
32
32
  checkoutCspAdditions,
33
+ appWidgetCspAdditions,
33
34
  CSP_REPORT_GROUP,
34
35
  CSP_REPORT_PATH,
35
36
  readPublishedArtifact,
@@ -46,13 +47,13 @@ import {
46
47
  parseGateMode,
47
48
  isAuthPath,
48
49
  sessionAdmitsHost,
50
+ sessionHostCapability,
49
51
  PUBLIC_HOME_TENANT_ID,
50
52
  } from "@/lib/auth/loginGate";
51
53
  import { gatedHoldingResponse } from "@/lib/auth/gatePage";
52
54
  import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
53
55
  import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
54
56
  import { HEALTH_PATH, healthResponse } from "@/lib/health";
55
- import { membershipFor } from "@/lib/dashboard/tenantSelection";
56
57
  import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
57
58
 
58
59
  export const onRequest = defineMiddleware(async (context, next) => {
@@ -160,17 +161,18 @@ export const onRequest = defineMiddleware(async (context, next) => {
160
161
  return gatedHoldingResponse(decision.location);
161
162
  }
162
163
  if (admitted && viewerRecord) {
163
- // Surface the viewer's role FOR THIS host's tenant as chrome. A member sees
164
- // their tenants[] role; a ToT-staff viewer admitted via an explicit vendor
165
- // selection sees the staffRoles-scoped capability (distinct path).
166
- const role = membershipFor(viewerRecord.tenants, tenant.appDomain, {
167
- selection: viewerRecord.staffSelection,
168
- nowSeconds,
169
- })?.role;
164
+ // Surface the viewer's standing FOR THIS host's tenant as chrome, BOUND to
165
+ // the host so a capability the session holds for another tenant never leaks
166
+ // in: a member sees their tenants[] role; the owner of their own tenant sees
167
+ // the resource-scoped session's capability; a ToT-staff viewer admitted via
168
+ // an explicit vendor selection sees the staffRoles-scoped capability. The
169
+ // ship gates (u10 decideIsOwner/resolveShipPrincipal) read this capability,
170
+ // so binding it here keeps owner-resolution server-side + tenant-correct.
171
+ const hostCapability = sessionHostCapability(viewerRecord, tenant.appDomain, nowSeconds);
170
172
  locals.viewer = {
171
173
  email: viewerRecord.email,
172
- roles: role ? [role] : viewerRecord.roles,
173
- capability: role ?? viewerRecord.capability,
174
+ roles: hostCapability ? [hostCapability] : viewerRecord.roles,
175
+ capability: hostCapability ?? viewerRecord.capability,
174
176
  };
175
177
  }
176
178
  }
@@ -210,8 +212,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
210
212
  if (hit) return hit;
211
213
  }
212
214
 
213
- // Edge read replica: opt-in via USE_D1_CATALOG=1 (+ a CATALOG_DB binding). Off
214
- // by default, so the bundled fixture snapshot serves reads.
215
+ // Catalog source selection (F17 graduation path docs/architecture/product-catalog.md).
216
+ // A store's product source graduates over its life: JSON fixtures (dev default) ->
217
+ // D1 edge replica (staging) -> hosted ToT catalog (go-live). Here we pick the read
218
+ // backend: USE_D1_CATALOG=1 (+ a CATALOG_DB binding) reads the D1 edge replica;
219
+ // otherwise createToTClient falls back to the bundled JSON fixture snapshot. The
220
+ // hosted catalog graduates the SAME canonical records into the D1 read model
221
+ // (emitCatalogSql), so JSON/D1/hosted render identically — pinned by
222
+ // lib/tot/sourceSelection.test.ts.
215
223
  const d1 =
216
224
  (await readEnv("USE_D1_CATALOG")) === "1" ? await readD1("CATALOG_DB") : undefined;
217
225
  const tot = await createToTClient(tenant, d1);
@@ -360,6 +368,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
360
368
  locals.canonicalBase = canonicalBase;
361
369
  locals.customizationEnv = customization.env;
362
370
  locals.cspNonce = cspNonce;
371
+ // Seeded here, filled in by whichever page/layout renders a widget slot
372
+ // (renderSlot.ts) as the page renders inside next() below — read back out
373
+ // after next() resolves so the CSP frame-src reflects exactly what rendered.
374
+ locals.activeWidgetPlacements = [];
363
375
  // Prefix that path-routed pages live under ("" for host-routed). Pages stay
364
376
  // tenant-agnostic; the Layout uses this to keep root-absolute links inside
365
377
  // the tenant prefix on the client.
@@ -427,6 +439,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
427
439
  // strict policy only when this tenant has a store — so add-to-cart, the
428
440
  // sidecart iframe/XHR, and the cross-origin form POST all pass.
429
441
  const checkoutCsp = commerce ? checkoutCspAdditions(commerce.origin) : null;
442
+ // Private-app widget origins (D5) — read back AFTER next() so this reflects
443
+ // exactly the placements that rendered on THIS page, never a wildcard.
444
+ const widgetCsp = appWidgetCspAdditions(locals.activeWidgetPlacements ?? []);
430
445
  response.headers.set(
431
446
  cspHeader,
432
447
  buildCsp({
@@ -450,7 +465,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
450
465
  ...(checkoutCsp?.connectSrc ?? []),
451
466
  ],
452
467
  imgSrc: [...STOREFRONT_CSP_ALLOWLIST.imgSrc, ...(checkoutCsp?.imgSrc ?? [])],
453
- frameSrc: checkoutCsp?.frameSrc ?? [],
468
+ frameSrc: [...(checkoutCsp?.frameSrc ?? []), ...widgetCsp.frameSrc],
454
469
  formAction: checkoutCsp?.formAction ?? [],
455
470
  reportGroup: CSP_REPORT_GROUP,
456
471
  reportUri: CSP_REPORT_PATH,
@@ -2,15 +2,25 @@
2
2
  /**
3
3
  * 404 — written in the Northwind voice, not a stub. Offers a way home and a
4
4
  * few collections to wander into so a dead link still lands somewhere useful.
5
+ *
6
+ * DELIBERATELY on-demand — do NOT prerender (audit finding "selective-prerender").
7
+ * Fully tenant-varying: it reads Astro.locals.tenant + tot.listCollections() and
8
+ * renders under locals.themeStyle, all set per-request by the middleware from the
9
+ * resolved Host. Prerendering would freeze a single build-time tenant AND strip the
10
+ * middleware-applied CSP + security headers (no static _headers fallback exists).
5
11
  */
12
+ export const prerender = false;
13
+
6
14
  import Layout from "@/layouts/Layout.astro";
7
15
  import CollectionCard from "@/components/CollectionCard.astro";
8
16
  import { withBase } from "@/lib/basePath";
9
17
 
10
- const { tenant, tot, basePath } = Astro.locals;
18
+ const { tenant, tot, basePath, features } = Astro.locals;
11
19
 
12
- // A handful of collections to redirect lost visitors toward.
13
- const collections = (await tot.listCollections()).slice(0, 3);
20
+ // A handful of collections to redirect lost visitors toward. Marketing tenants
21
+ // (features.catalog === false) have no /collections route at all — skip the
22
+ // fetch entirely rather than trust it to come back empty.
23
+ const collections = features.catalog ? (await tot.listCollections()).slice(0, 3) : [];
14
24
  ---
15
25
 
16
26
  <Layout
@@ -36,12 +46,14 @@ const collections = (await tot.listCollections()).slice(0, 3);
36
46
  >
37
47
  Back home
38
48
  </a>
39
- <a
40
- href={withBase(basePath, "/collections")}
41
- class="inline-flex items-center rounded-full border border-[var(--color-border)] px-6 py-3 font-display text-sm font-medium text-[var(--color-text)] transition-colors hover:border-[var(--color-primary)]"
42
- >
43
- Browse collections
44
- </a>
49
+ {features.catalog && (
50
+ <a
51
+ href={withBase(basePath, "/collections")}
52
+ class="inline-flex items-center rounded-full border border-[var(--color-border)] px-6 py-3 font-display text-sm font-medium text-[var(--color-text)] transition-colors hover:border-[var(--color-primary)]"
53
+ >
54
+ Browse collections
55
+ </a>
56
+ )}
45
57
  </div>
46
58
  </div>
47
59
  </section>
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import Layout from "@/layouts/Layout.astro";
9
9
  import Breadcrumbs from "@/components/Breadcrumbs.astro";
10
+ import ProseSections from "@/components/content/ProseSections.astro";
11
+ import ProseToc from "@/components/content/ProseToc.astro";
10
12
  import { breadcrumbLd } from "@/lib/jsonld";
11
13
  import { truncate } from "@/lib/format";
12
14
  import { rawHtmlPolicy, rawHtmlResponse } from "@/lib/rawMarketingHtml";
@@ -56,6 +58,27 @@ const crumbs = [
56
58
  const description =
57
59
  page.seoDescription ||
58
60
  (page.intro ? truncate(page.intro, 160) : undefined);
61
+
62
+ // Structured pages (F4) drive the body from `sections`; legacy pages keep the
63
+ // sanitized `body_html`. Prefer structured whenever a page ships sections.
64
+ const structured = (page.sections?.length ?? 0) > 0;
65
+
66
+ // Reviewer-only needs-review flags render in preview/dev only — mirrors the
67
+ // middleware's "production read" definition (no preview host / token / cookie),
68
+ // so a real shopper never sees scaffolding but a reviewer does.
69
+ const isPreview =
70
+ import.meta.env.DEV ||
71
+ Astro.url.hostname.toLowerCase().startsWith("preview.") ||
72
+ Astro.url.searchParams.has("__preview") ||
73
+ Astro.cookies.has("tot_preview");
74
+
75
+ const updatedLabel = page.updated
76
+ ? new Date(page.updated).toLocaleDateString("en-US", {
77
+ year: "numeric",
78
+ month: "long",
79
+ day: "numeric",
80
+ })
81
+ : null;
59
82
  ---
60
83
 
61
84
  <Layout
@@ -80,10 +103,21 @@ const description =
80
103
  {page.intro}
81
104
  </p>
82
105
  )}
106
+ {updatedLabel && (
107
+ <p class="mt-3 text-sm text-[var(--color-muted)]">Last updated {updatedLabel}</p>
108
+ )}
109
+ {structured && <ProseToc sections={page.sections!} />}
83
110
  </header>
84
111
 
85
- {/* Body — constrained to a readable measure. */}
112
+ {/* Body — constrained to a readable measure. Structured pages (F4) render
113
+ framework widgets from `sections`; legacy pages keep the sanitized HTML. */}
86
114
  <div class="container-prose pb-[clamp(3rem,7vw,6rem)] pt-8">
87
- <div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={page.body_html} />
115
+ {structured ? (
116
+ <div class="mx-auto max-w-[68ch]">
117
+ <ProseSections sections={page.sections!} includeNeedsReview={isPreview} />
118
+ </div>
119
+ ) : (
120
+ <div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={page.body_html} />
121
+ )}
88
122
  </div>
89
123
  </Layout>
@@ -13,7 +13,7 @@
13
13
  * SHELL CONTRACT — every AdminXTab.astro component mounted below must preserve:
14
14
  * - A `<section id="{tab}" data-admin-panel data-agent-scope="{tab}">` root,
15
15
  * where {tab} is one of: orders, products, subscriptions, customers, fulfillment,
16
- * reporting, ai-workflows, settings. The shell's hash router shows/hides
16
+ * reporting, ai-workflows, publish, settings. The shell's hash router shows/hides
17
17
  * panels by matching `html[data-admin-tab]` to this id, and the AI Ask
18
18
  * envelope reads `data-agent-scope` to label its active context.
19
19
  * - No tab-switching chrome of its own — `#{tab}` deep links and the shell's
@@ -37,9 +37,11 @@ import OrdersWorkspace from "../components/admin/orders/OrdersWorkspace.astro";
37
37
  import AdminProductsTab from "../components/admin/AdminProductsTab.astro";
38
38
  import AdminSubscriptionsTab from "../components/admin/AdminSubscriptionsTab.astro";
39
39
  import AdminReportingTab from "../components/admin/AdminReportingTab.astro";
40
+ import AdminPublishTab from "../components/admin/AdminPublishTab.astro";
40
41
  import AdminSettingsTab from "../components/admin/AdminSettingsTab.astro";
41
42
  import { readViewerSession } from "@/lib/auth/route";
42
43
  import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
44
+ import { resolveAdminEntry } from "@/lib/auth/adminEntry";
43
45
 
44
46
  const { tenant, basePath, cspNonce } = Astro.locals;
45
47
  const adminPath = `${basePath || ""}/admin`;
@@ -49,6 +51,19 @@ const viewerEmail = (Astro.locals.viewer?.email ?? viewerSession?.email ?? "").t
49
51
  const viewerDomain = viewerEmail.split("@").at(-1) ?? "";
50
52
  const canViewInternalGuide = viewerDomain === "tokenoftrust.com";
51
53
 
54
+ // dg6 — admin session ENTRY resolved SERVER-SIDE against the resolved tenant's
55
+ // appDomain (never client input, never a bearer secret): an authenticated
56
+ // owner/team member for THIS tenant reaches the admin context; a non-member /
57
+ // anonymous visitor was already held at the sign-in gate (middleware). Consumes
58
+ // u10's owner + ship-capability model; the go-live tabs (AdminPublishTab) read
59
+ // the same host-bound `Astro.locals.viewer.capability`, so the shell just stamps
60
+ // the authoritative principal for chrome + tests — it does not re-gate.
61
+ const adminEntry = resolveAdminEntry({
62
+ record: viewerSession,
63
+ hostResource: tenant.appDomain,
64
+ nowSeconds: Math.floor(Date.now() / 1000),
65
+ });
66
+
52
67
  // Internal admin-app tenant mode (baseline doc §Admin App As Tenant,
53
68
  // tenant-envelope-contract): every mounted tab receives an explicit
54
69
  // { adminAppTenant, targetTenant } envelope. Only a real ToT-staff session
@@ -72,6 +87,11 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
72
87
  data-admin-target-tenant={tenantEnvelope.targetTenant}
73
88
  data-admin-operator={tenantEnvelope.operator ?? ""}
74
89
  data-admin-is-staff={isStaff ? "true" : "false"}
90
+ data-admin-viewer-authenticated={viewerSession ? "true" : "false"}
91
+ data-admin-entry-principal={adminEntry.principal}
92
+ data-admin-is-owner={adminEntry.isOwnerViewer ? "true" : "false"}
93
+ data-admin-can-ship={adminEntry.shipCapabilities.shipOnBehalf ? "true" : "false"}
94
+ data-admin-can-apex-cutover={adminEntry.shipCapabilities.apexCutover ? "true" : "false"}
75
95
  >
76
96
  <head>
77
97
  <meta charset="utf-8" />
@@ -80,7 +100,7 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
80
100
  <title>{tenantLabel} Admin</title>
81
101
  <script is:inline nonce={cspNonce}>
82
102
  (() => {
83
- const adminTabs = ["orders", "products", "subscriptions", "customers", "fulfillment", "reporting", "ai-workflows", "settings"];
103
+ const adminTabs = ["orders", "products", "subscriptions", "customers", "fulfillment", "reporting", "ai-workflows", "publish", "settings"];
84
104
  const canViewInternalGuide = document.documentElement.dataset.internalAdminGuide === "true";
85
105
  const defaultTab = canViewInternalGuide ? "orders" : "settings";
86
106
  const hash = window.location.hash.slice(1);
@@ -508,6 +528,7 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
508
528
  html.admin-tabs-ready[data-admin-mode="work-area"][data-admin-tab="fulfillment"] #fulfillment,
509
529
  html.admin-tabs-ready[data-admin-mode="work-area"][data-admin-tab="reporting"] #reporting,
510
530
  html.admin-tabs-ready[data-admin-mode="work-area"][data-admin-tab="ai-workflows"] #ai-workflows,
531
+ html.admin-tabs-ready[data-admin-mode="work-area"][data-admin-tab="publish"] #publish,
511
532
  html.admin-tabs-ready[data-admin-mode="work-area"][data-admin-tab="settings"] #settings {
512
533
  display: grid;
513
534
  }
@@ -1133,6 +1154,7 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
1133
1154
  <a href="#fulfillment" data-admin-tab-link><svg><use href="#i-truck"></use></svg><span>Fulfillment</span></a>
1134
1155
  <a href="#reporting" data-admin-tab-link><svg><use href="#i-chart"></use></svg><span>Reporting</span></a>
1135
1156
  <a href="#ai-workflows" data-admin-tab-link><svg><use href="#i-workflows"></use></svg><span>AI Workflows</span></a>
1157
+ <a href="#publish" data-admin-tab-link><svg><use href="#i-export"></use></svg><span>Publish</span></a>
1136
1158
  <a href="#settings" data-admin-tab-link><svg><use href="#i-gear"></use></svg><span>Settings</span></a>
1137
1159
  </nav>
1138
1160
 
@@ -1159,6 +1181,7 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
1159
1181
  <a class="tab" href="#fulfillment" role="tab" aria-controls="fulfillment" aria-selected="false" data-admin-tab-link>Fulfillment</a>
1160
1182
  <a class="tab" href="#reporting" role="tab" aria-controls="reporting" aria-selected="false" data-admin-tab-link>Reporting</a>
1161
1183
  <a class="tab" href="#ai-workflows" role="tab" aria-controls="ai-workflows" aria-selected="false" data-admin-tab-link>AI Workflows</a>
1184
+ <a class="tab" href="#publish" role="tab" aria-controls="publish" aria-selected="false" data-admin-tab-link>Publish</a>
1162
1185
  <a class="tab" href="#settings" role="tab" aria-controls="settings" aria-selected="false" data-admin-tab-link>Settings</a>
1163
1186
  </div>
1164
1187
 
@@ -1206,6 +1229,7 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
1206
1229
  <a id="admin-tab-fulfillment" class="tab" href="#fulfillment" role="tab" aria-controls="fulfillment" aria-selected="false" data-admin-tab-link>Fulfillment</a>
1207
1230
  <a id="admin-tab-reporting" class="tab" href="#reporting" role="tab" aria-controls="reporting" aria-selected="false" data-admin-tab-link>Reporting</a>
1208
1231
  <a id="admin-tab-ai-workflows" class="tab" href="#ai-workflows" role="tab" aria-controls="ai-workflows" aria-selected="false" data-admin-tab-link>AI Workflows</a>
1232
+ <a id="admin-tab-publish" class="tab" href="#publish" role="tab" aria-controls="publish" aria-selected="false" data-admin-tab-link>Publish</a>
1209
1233
  <a id="admin-tab-settings" class="tab" href="#settings" role="tab" aria-controls="settings" aria-selected="false" data-admin-tab-link>Settings</a>
1210
1234
  </div>
1211
1235
 
@@ -1331,6 +1355,7 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
1331
1355
  <AdminFulfillmentTab envelope={tenantEnvelope} />
1332
1356
  <AdminReportingTab envelope={tenantEnvelope} />
1333
1357
  <AdminAIWorkflowsTab envelope={tenantEnvelope} />
1358
+ <AdminPublishTab envelope={tenantEnvelope} />
1334
1359
  <AdminSettingsTab envelope={tenantEnvelope} />
1335
1360
 
1336
1361
  {canViewInternalGuide && (
@@ -1447,7 +1472,7 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
1447
1472
 
1448
1473
  <script is:inline nonce={cspNonce}>
1449
1474
  (() => {
1450
- const tabIds = ["orders", "products", "subscriptions", "customers", "fulfillment", "reporting", "ai-workflows", "settings"];
1475
+ const tabIds = ["orders", "products", "subscriptions", "customers", "fulfillment", "reporting", "ai-workflows", "publish", "settings"];
1451
1476
  const tabSet = new Set(tabIds);
1452
1477
  const tabHashPattern = new RegExp(`^#(${tabIds.join("|")})$`);
1453
1478
  const canViewInternalGuide = document.documentElement.dataset.internalAdminGuide === "true";
@@ -1460,6 +1485,7 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
1460
1485
  fulfillment: "Fulfillment",
1461
1486
  reporting: "Reporting",
1462
1487
  "ai-workflows": "AI Workflows",
1488
+ publish: "Publish",
1463
1489
  settings: "Settings",
1464
1490
  };
1465
1491
 
@@ -0,0 +1,60 @@
1
+ /**
2
+ * POST /api/apps/admin/credentials/rotate — rotate an install's client
3
+ * secret, keeping the same `client_id` (PrivateApps epic, D2 Chunk C; D8
4
+ * console-session auth added). The new secret is returned ONCE here and never
5
+ * again — the same one-time-secret guarantee `install()` gives (see
6
+ * `registryService.ts`'s `InstallResult` doc).
7
+ *
8
+ * `registryService.rotateCredentials()` takes a bare `installId` with no
9
+ * tenant check of its own (Chunk A assumption #1) — so this route resolves
10
+ * the install via `requireOwnedInstall` BEFORE calling it, rejecting a
11
+ * foreign-tenant `installId` with the SAME 404 a truly-missing one gets (no
12
+ * cross-tenant existence oracle). Mutating: `requireManageRole` gates a
13
+ * console `developer` role out — the host-bound/Bearer path is exempt (no
14
+ * `role`).
15
+ */
16
+ import type { APIContext } from "astro";
17
+ import { resolveOperatorSession } from "@/lib/apps/adminSession.js";
18
+ import {
19
+ getRegistryService,
20
+ registryErrorResponse,
21
+ requireOwnedInstall,
22
+ requireManageRole,
23
+ parseInstallId,
24
+ json,
25
+ } from "@/lib/apps/adminService.js";
26
+ import { RegistryError } from "@/lib/apps/registryService.js";
27
+
28
+ export const prerender = false;
29
+
30
+ export async function POST(context: APIContext): Promise<Response> {
31
+ const requestedTenant = new URL(context.request.url).searchParams.get("tenant");
32
+ const auth = await resolveOperatorSession(context, requestedTenant);
33
+ if (!auth.ok) return json({ error: auth.message }, auth.status);
34
+ const manageDenied = requireManageRole(auth);
35
+ if (manageDenied) return manageDenied;
36
+
37
+ let body: unknown;
38
+ try {
39
+ body = await context.request.json();
40
+ } catch {
41
+ return json({ error: "invalid JSON body" }, 400);
42
+ }
43
+
44
+ const installId = parseInstallId(body);
45
+ if (!installId) return json({ error: "installId is required" }, 400);
46
+
47
+ const registry = await getRegistryService();
48
+ if (registry instanceof Response) return registry;
49
+
50
+ const owned = await requireOwnedInstall(registry, installId, auth.tenantId);
51
+ if (owned instanceof Response) return owned;
52
+
53
+ try {
54
+ const issued = await registry.rotateCredentials(installId, auth.actor);
55
+ return json({ credential: { clientId: issued.clientId, secret: issued.secret } });
56
+ } catch (err) {
57
+ if (err instanceof RegistryError) return registryErrorResponse(err);
58
+ throw err;
59
+ }
60
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * GET /api/apps/admin/health — the console's per-install health rollup for
3
+ * the resolved operator tenant (PrivateApps epic, D8 Chunk C). Read-only:
4
+ * any membership role admits (mirrors `list.ts`'s `?tenant=`-as-candidate
5
+ * scoping — the tenant actually queried is always `auth.tenantId`, never the
6
+ * raw param).
7
+ *
8
+ * Folds `registryService.listByTenant` with `deliveryStore.getInstallHealth`
9
+ * per install through `healthAggregate.ts`'s `buildInstallHealthView` — that
10
+ * view is what data-minimizes `webhookEndpoint` down to `hasWebhookEndpoint`,
11
+ * so this route never even holds the raw endpoint value to leak.
12
+ */
13
+ import type { APIContext } from "astro";
14
+ import { resolveOperatorSession } from "@/lib/apps/adminSession.js";
15
+ import { getRegistryService, json } from "@/lib/apps/adminService.js";
16
+ import { WebhookDeliveryStore } from "@/lib/webhooks/deliveryStore.js";
17
+ import { buildInstallHealthView, type AppInstallHealthView } from "@/lib/apps/healthAggregate.js";
18
+ import { fromD1 } from "@/lib/d1/catalog.js";
19
+ import { readD1 } from "@/lib/env";
20
+
21
+ export const prerender = false;
22
+
23
+ export async function GET(context: APIContext): Promise<Response> {
24
+ const requestedTenant = new URL(context.request.url).searchParams.get("tenant");
25
+ const auth = await resolveOperatorSession(context, requestedTenant);
26
+ if (!auth.ok) return json({ error: auth.message }, auth.status);
27
+
28
+ const registry = await getRegistryService();
29
+ if (registry instanceof Response) return registry;
30
+
31
+ const d1 = await readD1("STOREFRONT_APPS_DB");
32
+ if (!d1) return json({ error: "webhook store not configured" }, 503);
33
+ const deliveryStore = new WebhookDeliveryStore(fromD1(d1));
34
+
35
+ const installs = await registry.listByTenant(auth.tenantId);
36
+ const apps: AppInstallHealthView[] = await Promise.all(
37
+ installs.map(async (install) => {
38
+ const health = await deliveryStore.getInstallHealth(install.installId);
39
+ return buildInstallHealthView(install, health);
40
+ }),
41
+ );
42
+
43
+ return json({ apps });
44
+ }