@tokenoftrust/storefront-runner 1.3.4-rc.4 → 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 (224) 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/previewStatus.ts +112 -0
  94. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  95. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  96. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  97. package/apps/storefront/src/lib/env.ts +12 -0
  98. package/apps/storefront/src/lib/jsonld.ts +12 -17
  99. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  100. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  101. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  102. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  103. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  104. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  105. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  106. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  107. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  108. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  109. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  110. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  111. package/apps/storefront/src/lib/tot/query.ts +32 -0
  112. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  113. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  116. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  117. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  118. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  119. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  120. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  121. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  122. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  123. package/apps/storefront/src/middleware/index.ts +28 -13
  124. package/apps/storefront/src/pages/404.astro +21 -9
  125. package/apps/storefront/src/pages/[...slug].astro +36 -2
  126. package/apps/storefront/src/pages/admin.astro +29 -3
  127. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  128. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  129. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  130. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  131. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  132. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  133. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  134. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  135. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  137. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  138. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  139. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  140. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  142. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  143. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  144. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  146. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  147. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  149. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  150. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  151. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  152. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  153. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  154. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  155. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  156. package/apps/storefront/src/pages/auth/login.astro +197 -41
  157. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  158. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  159. package/apps/storefront/src/pages/blog/index.astro +98 -0
  160. package/apps/storefront/src/pages/capabilities.astro +8 -0
  161. package/apps/storefront/src/pages/cockpit.astro +433 -69
  162. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  163. package/apps/storefront/src/pages/collections/index.astro +10 -2
  164. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  166. package/apps/storefront/src/pages/index.astro +57 -0
  167. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  168. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  169. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  170. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  171. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  175. package/apps/storefront/src/styles/fonts.css +54 -0
  176. package/apps/storefront/src/styles/global.css +22 -2
  177. package/apps/storefront/src/themes/schema.ts +3 -25
  178. package/apps/storefront/tsconfig.json +1 -1
  179. package/apps/storefront/vitest.config.ts +4 -1
  180. package/package.json +1 -1
  181. package/packages/public-runtime/src/candidate-index.ts +311 -0
  182. package/packages/public-runtime/src/checkout.ts +94 -2
  183. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  184. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  185. package/packages/public-runtime/src/compliance/index.ts +22 -0
  186. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  187. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  188. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  189. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  190. package/packages/public-runtime/src/csp.ts +27 -3
  191. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  192. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  193. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  194. package/packages/public-runtime/src/extension-contract.ts +2 -1
  195. package/packages/public-runtime/src/hash.ts +25 -0
  196. package/packages/public-runtime/src/index.ts +6 -0
  197. package/packages/public-runtime/src/membership.ts +353 -0
  198. package/packages/public-runtime/src/product.ts +24 -2
  199. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  200. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  201. package/packages/public-runtime/src/tenant.ts +236 -0
  202. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  203. package/scripts/dev/publish.mjs +158 -0
  204. package/scripts/dev/transient-files.mjs +2 -1
  205. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  206. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  223. package/tenants/home/public/pages/storefront.css +23 -0
  224. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -0,0 +1,579 @@
1
+ /**
2
+ * App registry service — PrivateApps epic, D2 (Chunk A). Owns the
3
+ * `app_installs` / `app_audit_log` rows described in `schema.ts` and
4
+ * orchestrates credential lifecycle through the {@link CredentialPort} seam
5
+ * (Chunk B implements it; Chunk A only depends on the interface, mirroring
6
+ * how `D1VersionStore` implements `@tot/private-controlplane`'s `VersionStore`
7
+ * contract without either side importing the other's internals).
8
+ *
9
+ * Pure over the `Queryable` seam (see `../d1/catalog.ts`) — the same shape
10
+ * `D1VersionStore` and the catalog read model use, so this runs unchanged
11
+ * against real D1 in the Worker and an in-memory `node:sqlite` in tests.
12
+ *
13
+ * Rules enforced HERE (not just at a future route/edge layer):
14
+ * - `install` validates scopes via `scopes.ts` BEFORE writing; a denied
15
+ * request writes no row.
16
+ * - `install`/`update` validate a non-null `webhookEndpoint` via
17
+ * `../webhooks/endpointGuard.ts` BEFORE writing, for the same reason:
18
+ * the manifest contract promises https-only, and an app-supplied SSRF
19
+ * target must never reach the database, let alone `deliveryEngine.ts`'s
20
+ * `fetch()`.
21
+ * - `install`/`update` validate a non-empty `widgetPlacements` the same
22
+ * way (deny-by-default): every placement must name one of the 3
23
+ * platform-owned slots, the granted scopes must include
24
+ * `widgets:launch`, and each `endpoint` runs through the SAME SSRF
25
+ * guard the webhook endpoint uses — a widget is a sandboxed iframe
26
+ * `src`, not merchant-pasted script, but its origin is still
27
+ * app-supplied and gets the same fail-closed treatment.
28
+ * - `tenantId` is only ever taken from the caller's auth context, never
29
+ * from install input — there is no path to override it from manifest
30
+ * content.
31
+ * - Every mutating method appends exactly one `app_audit_log` row in the
32
+ * same operation; audit rows are never deleted, including on uninstall.
33
+ * - Fail-closed: an unknown `installId` is reported as generic "not found"
34
+ * (no detail that would let a caller distinguish "wrong tenant" from
35
+ * "never existed"); a suspended/uninstalled install rejects further
36
+ * mutation with `invalid_state`.
37
+ */
38
+ import { validateScopes, type ScopeDenial } from "./scopes.js";
39
+ import { validateWebhookEndpoint } from "../webhooks/endpointGuard.js";
40
+ import type { Queryable } from "../d1/catalog.js";
41
+
42
+ export type AppInstallStatus = "active" | "suspended" | "uninstalled";
43
+
44
+ export interface AppInstall {
45
+ installId: string;
46
+ appId: string;
47
+ appVersion: string;
48
+ tenantId: string;
49
+ env: string;
50
+ status: AppInstallStatus;
51
+ scopes: string[];
52
+ /** The full tot-app.json snapshot captured at install/update time. */
53
+ manifest: unknown;
54
+ webhookEndpoint: string | null;
55
+ widgetPlacements: WidgetPlacementInput[];
56
+ installActor: string;
57
+ createdAt: string;
58
+ updatedAt: string;
59
+ }
60
+
61
+ /** Audit event vocabulary — one row appended per mutating call. */
62
+ export type AppAuditEvent =
63
+ | "installed"
64
+ | "updated"
65
+ | "suspended"
66
+ | "resumed"
67
+ | "uninstalled"
68
+ | "credentials_rotated";
69
+
70
+ /** The auth-derived context every install is scoped to — never caller input. */
71
+ export interface InstallActorContext {
72
+ tenantId: string;
73
+ actor: string;
74
+ }
75
+
76
+ export interface InstallInput {
77
+ appId: string;
78
+ appVersion: string;
79
+ env: string;
80
+ scopes: string[];
81
+ manifest: unknown;
82
+ webhookEndpoint?: string | null;
83
+ widgetPlacements?: unknown[];
84
+ }
85
+
86
+ export interface AppInstallPatch {
87
+ appVersion?: string;
88
+ scopes?: string[];
89
+ manifest?: unknown;
90
+ webhookEndpoint?: string | null;
91
+ widgetPlacements?: unknown[];
92
+ }
93
+
94
+ export type RegistryErrorCode =
95
+ | "scope_denied"
96
+ | "not_found"
97
+ | "already_installed"
98
+ | "invalid_state"
99
+ | "invalid_webhook_endpoint"
100
+ | "invalid_widget_placement";
101
+
102
+ /**
103
+ * `install()`'s return: the install row PLUS the one-time client credential
104
+ * `credentials.issue()` just minted. Surfaced here — rather than discarded,
105
+ * as an earlier shape of this method did — because the plaintext `secret` is
106
+ * gone for good after this call returns; there is no later "show it again".
107
+ */
108
+ export interface InstallResult {
109
+ install: AppInstall;
110
+ credential: { clientId: string; secret: string };
111
+ }
112
+
113
+ /** Thrown by every rule violation above — callers branch on `.code`. */
114
+ export class RegistryError extends Error {
115
+ constructor(
116
+ message: string,
117
+ readonly code: RegistryErrorCode,
118
+ readonly denials?: ScopeDenial[],
119
+ ) {
120
+ super(message);
121
+ this.name = "RegistryError";
122
+ }
123
+ }
124
+
125
+ /** What `install()` returns from a fresh credential issue. */
126
+ export interface IssuedCredential {
127
+ installId: string;
128
+ clientId: string;
129
+ jwksKid: string;
130
+ }
131
+
132
+ /**
133
+ * `IssuedCredential` plus the one-time plaintext secret. `issue`/`rotate` mint
134
+ * the secret, hash it for storage (Chunk B: `sha256Hex`), and hand the
135
+ * plaintext back exactly once here — there is no "show secret again" path by
136
+ * design, so a caller that discards this return has lost it for good (see
137
+ * `AppRegistryService.install`'s `InstallResult`, which is why this seam
138
+ * exists at all).
139
+ */
140
+ export type IssuedCredentialSecret = IssuedCredential & { secret: string };
141
+
142
+ /**
143
+ * Minimal seam for the credential lifecycle a later chunk (D2 Chunk B)
144
+ * implements against `app_credentials`. `AppRegistryService` orchestrates
145
+ * through this interface only — it never touches credential storage directly,
146
+ * so Chunk A can ship and be tested (with a fake) without Chunk B existing.
147
+ */
148
+ export interface CredentialPort {
149
+ /** Issue a fresh client-credential set for a newly installed app. */
150
+ issue(installId: string): Promise<IssuedCredentialSecret>;
151
+ /** Rotate the secret for an existing install, keeping the same clientId. */
152
+ rotate(installId: string): Promise<IssuedCredentialSecret>;
153
+ /** Revoke/clear credential material for an install (uninstall). Idempotent. */
154
+ clear(installId: string): Promise<void>;
155
+ }
156
+
157
+ /**
158
+ * The 3 platform-owned widget slots (D5 Chunk A). Mirrors the `widgets[].placement`
159
+ * enum in `docs/private-apps/contract/tot-app.schema.json` — widgets render in a
160
+ * platform-managed sandboxed iframe at one of these fixed slots, never as
161
+ * merchant-pasted script at an app-chosen location.
162
+ */
163
+ export const WIDGET_PLACEMENTS = ["product.aside", "home.section", "global.footer"] as const;
164
+ export type WidgetPlacement = (typeof WIDGET_PLACEMENTS)[number];
165
+ const WIDGET_PLACEMENT_SET: ReadonlySet<string> = new Set(WIDGET_PLACEMENTS);
166
+
167
+ /** One validated widget placement request — `endpoint` has already passed `validateWebhookEndpoint`. */
168
+ export interface WidgetPlacementInput {
169
+ placement: WidgetPlacement;
170
+ endpoint: string;
171
+ title?: string;
172
+ }
173
+
174
+ export type WidgetPlacementValidationResult =
175
+ | { ok: true; placements: WidgetPlacementInput[] }
176
+ | { ok: false; reason: string };
177
+
178
+ /**
179
+ * Validate a requested `widgetPlacements` array. Deny-by-default and
180
+ * all-or-nothing, same discipline as `validateScopes`: an empty/absent array
181
+ * needs no scope, but ANY non-empty request is refused whole if the granted
182
+ * `scopes` lack `widgets:launch`, any item's `placement` isn't one of the 3
183
+ * platform-owned slots, or any item's `endpoint` fails the SAME SSRF guard
184
+ * `validateWebhookEndpoint` applies to webhook endpoints (https-only, no
185
+ * private/loopback/link-local target).
186
+ */
187
+ export function validateWidgetPlacements(
188
+ placements: unknown,
189
+ scopes: readonly string[],
190
+ ): WidgetPlacementValidationResult {
191
+ if (!Array.isArray(placements) || placements.length === 0) return { ok: true, placements: [] };
192
+
193
+ if (!scopes.includes("widgets:launch")) {
194
+ return { ok: false, reason: "widgetPlacements requires the widgets:launch scope" };
195
+ }
196
+
197
+ const validated: WidgetPlacementInput[] = [];
198
+ for (const item of placements) {
199
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
200
+ return { ok: false, reason: "each widget placement must be an object" };
201
+ }
202
+ const { placement, endpoint, title } = item as Record<string, unknown>;
203
+ if (typeof placement !== "string" || !WIDGET_PLACEMENT_SET.has(placement)) {
204
+ return { ok: false, reason: `placement must be one of: ${WIDGET_PLACEMENTS.join(", ")}` };
205
+ }
206
+ if (typeof endpoint !== "string") {
207
+ return { ok: false, reason: "endpoint is required" };
208
+ }
209
+ const endpointValidation = validateWebhookEndpoint(endpoint);
210
+ if (!endpointValidation.ok) {
211
+ return { ok: false, reason: `invalid widget endpoint: ${endpointValidation.reason}` };
212
+ }
213
+ if (title !== undefined && typeof title !== "string") {
214
+ return { ok: false, reason: "title, when given, must be a string" };
215
+ }
216
+ validated.push({
217
+ placement: placement as WidgetPlacement,
218
+ endpoint,
219
+ ...(title !== undefined ? { title: title as string } : {}),
220
+ });
221
+ }
222
+ return { ok: true, placements: validated };
223
+ }
224
+
225
+ /** Raw `app_installs` row shape (snake_case columns, JSON-as-text). */
226
+ interface AppInstallRow {
227
+ install_id: string;
228
+ app_id: string;
229
+ app_version: string;
230
+ tenant_id: string;
231
+ env: string;
232
+ status: string;
233
+ scopes: string;
234
+ manifest: string;
235
+ webhook_endpoint: string | null;
236
+ widget_placements: string;
237
+ install_actor: string;
238
+ created_at: string;
239
+ updated_at: string;
240
+ }
241
+
242
+ function toDomain(row: AppInstallRow): AppInstall {
243
+ return {
244
+ installId: row.install_id,
245
+ appId: row.app_id,
246
+ appVersion: row.app_version,
247
+ tenantId: row.tenant_id,
248
+ env: row.env,
249
+ status: row.status as AppInstallStatus,
250
+ scopes: JSON.parse(row.scopes) as string[],
251
+ manifest: JSON.parse(row.manifest) as unknown,
252
+ webhookEndpoint: row.webhook_endpoint,
253
+ widgetPlacements: JSON.parse(row.widget_placements) as WidgetPlacementInput[],
254
+ installActor: row.install_actor,
255
+ createdAt: row.created_at,
256
+ updatedAt: row.updated_at,
257
+ };
258
+ }
259
+
260
+ export class AppRegistryService {
261
+ constructor(
262
+ private readonly db: Queryable,
263
+ private readonly credentials: CredentialPort,
264
+ private readonly now: () => string = () => new Date().toISOString(),
265
+ ) {}
266
+
267
+ /**
268
+ * Install an app for one (tenant, env). Validates scopes BEFORE writing
269
+ * anything; a `scope_denied` request leaves no trace in `app_installs`.
270
+ * Issues credentials through {@link CredentialPort} and appends the
271
+ * `installed` audit row in the same call.
272
+ */
273
+ async install(input: InstallInput, ctx: InstallActorContext): Promise<InstallResult> {
274
+ const validation = validateScopes(input.scopes);
275
+ if (!validation.ok) {
276
+ throw new RegistryError(
277
+ `Install refused: scopes include forbidden or unknown ids.`,
278
+ "scope_denied",
279
+ validation.denials,
280
+ );
281
+ }
282
+
283
+ if (input.webhookEndpoint != null) this.assertValidWebhookEndpoint(input.webhookEndpoint);
284
+ const widgetPlacements = this.assertValidWidgetPlacements(input.widgetPlacements, validation.scopes);
285
+
286
+ const existing = await this.findRowByAppTenantEnv(input.appId, ctx.tenantId, input.env);
287
+ if (existing) {
288
+ throw new RegistryError(
289
+ `App "${input.appId}" is already installed for this tenant/env.`,
290
+ "already_installed",
291
+ );
292
+ }
293
+
294
+ const installId = crypto.randomUUID();
295
+ const now = this.now();
296
+ await this.db.run(
297
+ `INSERT INTO app_installs
298
+ (install_id, app_id, app_version, tenant_id, env, status, scopes, manifest,
299
+ webhook_endpoint, widget_placements, install_actor, created_at, updated_at)
300
+ VALUES (?,?,?,?,?,'active',?,?,?,?,?,?,?)`,
301
+ installId,
302
+ input.appId,
303
+ input.appVersion,
304
+ ctx.tenantId,
305
+ input.env,
306
+ JSON.stringify(validation.scopes),
307
+ JSON.stringify(input.manifest),
308
+ input.webhookEndpoint ?? null,
309
+ JSON.stringify(widgetPlacements),
310
+ ctx.actor,
311
+ now,
312
+ now,
313
+ );
314
+
315
+ let issued: IssuedCredentialSecret;
316
+ try {
317
+ issued = await this.credentials.issue(installId);
318
+ } catch (cause) {
319
+ // Compensate: the install row above is already written, but credential
320
+ // issuance failed AFTER that — delete it (and skip the audit row) so no
321
+ // orphaned "active" install with no credential ever survives. Matches
322
+ // the scope-denied path's guarantee: a failed install leaves no trace.
323
+ await this.db.run(`DELETE FROM app_installs WHERE install_id = ?`, installId);
324
+ throw cause;
325
+ }
326
+ await this.appendAudit({
327
+ installId,
328
+ tenantId: ctx.tenantId,
329
+ event: "installed",
330
+ actor: ctx.actor,
331
+ detail: { appId: input.appId, appVersion: input.appVersion, env: input.env },
332
+ });
333
+
334
+ return {
335
+ install: await this.getOrThrow(installId),
336
+ credential: { clientId: issued.clientId, secret: issued.secret },
337
+ };
338
+ }
339
+
340
+ /** Read one install by id, or `null` if it doesn't exist. */
341
+ async get(installId: string): Promise<AppInstall | null> {
342
+ const row = await this.db.first<AppInstallRow>(
343
+ `SELECT * FROM app_installs WHERE install_id = ?`,
344
+ installId,
345
+ );
346
+ return row ? toDomain(row) : null;
347
+ }
348
+
349
+ /** All installs for a tenant, newest first. */
350
+ async listByTenant(tenantId: string): Promise<AppInstall[]> {
351
+ const rows = await this.db.all<AppInstallRow>(
352
+ `SELECT * FROM app_installs WHERE tenant_id = ? ORDER BY created_at DESC`,
353
+ tenantId,
354
+ );
355
+ return rows.map(toDomain);
356
+ }
357
+
358
+ /**
359
+ * Patch install metadata. Rejects on an uninstalled app (dead installs
360
+ * can't be mutated); re-validates scopes when the patch touches them, and
361
+ * re-validates widgetPlacements (against the PATCHED scopes, when both
362
+ * change in the same call) when the patch touches them.
363
+ */
364
+ async update(installId: string, patch: AppInstallPatch, actor: string): Promise<AppInstall> {
365
+ const row = await this.mustFindRow(installId);
366
+ if (row.status === "uninstalled") {
367
+ throw new RegistryError(`Cannot update an uninstalled app.`, "invalid_state");
368
+ }
369
+
370
+ let scopesJson = row.scopes;
371
+ let effectiveScopes = JSON.parse(row.scopes) as string[];
372
+ if (patch.scopes) {
373
+ const validation = validateScopes(patch.scopes);
374
+ if (!validation.ok) {
375
+ throw new RegistryError(
376
+ `Update refused: scopes include forbidden or unknown ids.`,
377
+ "scope_denied",
378
+ validation.denials,
379
+ );
380
+ }
381
+ scopesJson = JSON.stringify(validation.scopes);
382
+ effectiveScopes = validation.scopes;
383
+ }
384
+
385
+ if (patch.webhookEndpoint != null) this.assertValidWebhookEndpoint(patch.webhookEndpoint);
386
+ const widgetPlacements =
387
+ patch.widgetPlacements !== undefined
388
+ ? this.assertValidWidgetPlacements(patch.widgetPlacements, effectiveScopes)
389
+ : undefined;
390
+
391
+ const now = this.now();
392
+ const appVersion = patch.appVersion ?? row.app_version;
393
+ const manifestJson = patch.manifest !== undefined ? JSON.stringify(patch.manifest) : row.manifest;
394
+ const webhookEndpoint =
395
+ patch.webhookEndpoint !== undefined ? patch.webhookEndpoint : row.webhook_endpoint;
396
+ const widgetPlacementsJson =
397
+ widgetPlacements !== undefined ? JSON.stringify(widgetPlacements) : row.widget_placements;
398
+
399
+ await this.db.run(
400
+ `UPDATE app_installs
401
+ SET app_version = ?, scopes = ?, manifest = ?, webhook_endpoint = ?,
402
+ widget_placements = ?, updated_at = ?
403
+ WHERE install_id = ?`,
404
+ appVersion,
405
+ scopesJson,
406
+ manifestJson,
407
+ webhookEndpoint,
408
+ widgetPlacementsJson,
409
+ now,
410
+ installId,
411
+ );
412
+
413
+ await this.appendAudit({
414
+ installId,
415
+ tenantId: row.tenant_id,
416
+ event: "updated",
417
+ actor,
418
+ detail: { fields: Object.keys(patch) },
419
+ });
420
+
421
+ return this.getOrThrow(installId);
422
+ }
423
+
424
+ /** Suspend an active install: disables API/webhook/widget access. */
425
+ async suspend(installId: string, actor: string): Promise<AppInstall> {
426
+ const row = await this.mustFindRow(installId);
427
+ if (row.status !== "active") {
428
+ throw new RegistryError(
429
+ `Install is not active (status=${row.status}); cannot suspend.`,
430
+ "invalid_state",
431
+ );
432
+ }
433
+ await this.setStatus(installId, "suspended");
434
+ await this.appendAudit({ installId, tenantId: row.tenant_id, event: "suspended", actor });
435
+ return this.getOrThrow(installId);
436
+ }
437
+
438
+ /** Resume a suspended install back to active. */
439
+ async resume(installId: string, actor: string): Promise<AppInstall> {
440
+ const row = await this.mustFindRow(installId);
441
+ if (row.status !== "suspended") {
442
+ throw new RegistryError(
443
+ `Install is not suspended (status=${row.status}); cannot resume.`,
444
+ "invalid_state",
445
+ );
446
+ }
447
+ await this.setStatus(installId, "active");
448
+ await this.appendAudit({ installId, tenantId: row.tenant_id, event: "resumed", actor });
449
+ return this.getOrThrow(installId);
450
+ }
451
+
452
+ /**
453
+ * Rotate an install's client secret via {@link CredentialPort.rotate},
454
+ * keeping the same `clientId`, and append the `credentials_rotated` audit
455
+ * row — the same one-audit-row-per-mutation guarantee every other method
456
+ * here upholds. Rejects an uninstalled install: its credential row is
457
+ * already gone (`uninstall()` cleared it), so there is nothing to rotate.
458
+ */
459
+ async rotateCredentials(installId: string, actor: string): Promise<IssuedCredentialSecret> {
460
+ const row = await this.mustFindRow(installId);
461
+ if (row.status === "uninstalled") {
462
+ throw new RegistryError(`Cannot rotate credentials for an uninstalled app.`, "invalid_state");
463
+ }
464
+ const issued = await this.credentials.rotate(installId);
465
+ await this.appendAudit({ installId, tenantId: row.tenant_id, event: "credentials_rotated", actor });
466
+ return issued;
467
+ }
468
+
469
+ /**
470
+ * Uninstall: marks the row `uninstalled`, clears its `widgetPlacements`
471
+ * (in the SAME write as the status flip — an uninstalled app's widgets
472
+ * must stop rendering immediately, not just its API/webhook access, unlike
473
+ * `suspend`/`resume` which only ever touch `status`), and clears
474
+ * credential material via {@link CredentialPort.clear}. Never deletes the
475
+ * install row or any `app_audit_log` entry.
476
+ */
477
+ async uninstall(installId: string, actor: string): Promise<AppInstall> {
478
+ const row = await this.mustFindRow(installId);
479
+ if (row.status === "uninstalled") {
480
+ throw new RegistryError(`Install is already uninstalled.`, "invalid_state");
481
+ }
482
+ await this.db.run(
483
+ `UPDATE app_installs SET status = 'uninstalled', widget_placements = '[]', updated_at = ? WHERE install_id = ?`,
484
+ this.now(),
485
+ installId,
486
+ );
487
+ await this.credentials.clear(installId);
488
+ await this.appendAudit({ installId, tenantId: row.tenant_id, event: "uninstalled", actor });
489
+ return this.getOrThrow(installId);
490
+ }
491
+
492
+ /**
493
+ * Shared gate for `install`/`update`: refuse a non-https or private/
494
+ * loopback/link-local `webhookEndpoint` BEFORE any write, same fail-closed
495
+ * guarantee as `validateScopes` above. Never called for a `null`/absent
496
+ * endpoint — that's still allowed (a webhook target is optional).
497
+ */
498
+ private assertValidWebhookEndpoint(webhookEndpoint: string): void {
499
+ const validation = validateWebhookEndpoint(webhookEndpoint);
500
+ if (!validation.ok) {
501
+ throw new RegistryError(`Invalid webhookEndpoint: ${validation.reason}.`, "invalid_webhook_endpoint");
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Shared gate for `install`/`update`: refuse an unrequested-scope, an
507
+ * unknown placement slot, or an SSRF-unsafe `endpoint`, BEFORE any write —
508
+ * same fail-closed guarantee as {@link assertValidWebhookEndpoint}. An
509
+ * empty/absent `placements` is always allowed (widgets are optional).
510
+ */
511
+ private assertValidWidgetPlacements(
512
+ placements: unknown,
513
+ scopes: readonly string[],
514
+ ): WidgetPlacementInput[] {
515
+ const validation = validateWidgetPlacements(placements, scopes);
516
+ if (!validation.ok) {
517
+ throw new RegistryError(`Invalid widgetPlacements: ${validation.reason}.`, "invalid_widget_placement");
518
+ }
519
+ return validation.placements;
520
+ }
521
+
522
+ private async setStatus(installId: string, status: AppInstallStatus): Promise<void> {
523
+ await this.db.run(
524
+ `UPDATE app_installs SET status = ?, updated_at = ? WHERE install_id = ?`,
525
+ status,
526
+ this.now(),
527
+ installId,
528
+ );
529
+ }
530
+
531
+ private async findRowByAppTenantEnv(
532
+ appId: string,
533
+ tenantId: string,
534
+ env: string,
535
+ ): Promise<AppInstallRow | null> {
536
+ return this.db.first<AppInstallRow>(
537
+ `SELECT * FROM app_installs WHERE app_id = ? AND tenant_id = ? AND env = ?`,
538
+ appId,
539
+ tenantId,
540
+ env,
541
+ );
542
+ }
543
+
544
+ /** Fetch a row or throw the generic fail-closed `not_found` — no enumeration detail. */
545
+ private async mustFindRow(installId: string): Promise<AppInstallRow> {
546
+ const row = await this.db.first<AppInstallRow>(
547
+ `SELECT * FROM app_installs WHERE install_id = ?`,
548
+ installId,
549
+ );
550
+ if (!row) throw new RegistryError(`App install not found.`, "not_found");
551
+ return row;
552
+ }
553
+
554
+ private async getOrThrow(installId: string): Promise<AppInstall> {
555
+ return toDomain(await this.mustFindRow(installId));
556
+ }
557
+
558
+ private async appendAudit(params: {
559
+ installId: string;
560
+ tenantId: string;
561
+ event: AppAuditEvent;
562
+ actor: string;
563
+ detail?: Record<string, unknown>;
564
+ traceId?: string;
565
+ }): Promise<void> {
566
+ await this.db.run(
567
+ `INSERT INTO app_audit_log (id, install_id, tenant_id, event, actor, trace_id, detail, at)
568
+ VALUES (?,?,?,?,?,?,?,?)`,
569
+ crypto.randomUUID(),
570
+ params.installId,
571
+ params.tenantId,
572
+ params.event,
573
+ params.actor,
574
+ params.traceId ?? null,
575
+ JSON.stringify(params.detail ?? {}),
576
+ this.now(),
577
+ );
578
+ }
579
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Scope validation — the compliance-floor gate for Storefront Private Apps
3
+ * (PrivateApps epic, D2). See `docs/private-apps/feature-brief.md` §"The
4
+ * compliance floor" and the machine-readable contract:
5
+ * - `docs/private-apps/contract/scopes.json` — the 8-scope V1 catalog.
6
+ * - `docs/private-apps/contract/forbidden-scopes.json` — the 8 ids that map
7
+ * to floor clauses (`checkout:mutate`, `tax:mutate`, …) and must NEVER be
8
+ * grantable.
9
+ *
10
+ * The catalog is hand-kept here (Workers can't read `docs/` off disk at
11
+ * runtime) rather than imported at build time; `scopes.test.ts` asserts
12
+ * structural equivalence against the actual contract JSON files, the same
13
+ * discipline `schema.test.ts` uses for the D1 migrations vs. their read-model
14
+ * consts — so this list and the contract can't silently drift.
15
+ *
16
+ * `validateScopes` is the ONE gate `AppRegistryService.install`/`update` call
17
+ * before writing a row: any requested scope outside the V1 catalog, or any
18
+ * scope in the forbidden list, denies the whole request (no partial grant).
19
+ */
20
+
21
+ /** The V1 scope catalog (`contract/scopes.json`). Least-privilege, mostly READ. */
22
+ export const V1_SCOPES = [
23
+ "catalog:read",
24
+ "orders:read:minimal",
25
+ "orders:webhook",
26
+ "customers:read:minimal",
27
+ "inventory:read",
28
+ "reports:read",
29
+ "attribution:write",
30
+ "widgets:launch",
31
+ ] as const;
32
+ export type V1Scope = (typeof V1_SCOPES)[number];
33
+
34
+ /**
35
+ * Denylist proving the compliance floor is unreachable (`contract/forbidden-scopes.json`).
36
+ * Never part of the V1 catalog; requesting any of these is always `scope_denied`.
37
+ */
38
+ export const FORBIDDEN_SCOPES = [
39
+ "checkout:mutate",
40
+ "tax:mutate",
41
+ "identity:mutate",
42
+ "warnings:mutate",
43
+ "jurisdiction:approve",
44
+ "signed-cart:mutate",
45
+ "promotion:publish",
46
+ "pii:unrestricted",
47
+ ] as const;
48
+ export type ForbiddenScope = (typeof FORBIDDEN_SCOPES)[number];
49
+
50
+ const V1_SCOPE_SET: ReadonlySet<string> = new Set(V1_SCOPES);
51
+ const FORBIDDEN_SCOPE_SET: ReadonlySet<string> = new Set(FORBIDDEN_SCOPES);
52
+
53
+ /** One requested scope that failed validation, and why. */
54
+ export interface ScopeDenial {
55
+ scope: string;
56
+ reason: "forbidden" | "unknown";
57
+ }
58
+
59
+ export type ScopeValidationResult =
60
+ | { ok: true; scopes: V1Scope[] }
61
+ | { ok: false; code: "scope_denied"; denials: ScopeDenial[] };
62
+
63
+ /**
64
+ * Validate a requested scope array against the V1 catalog + forbidden list.
65
+ * Deny-by-default and all-or-nothing: ANY scope that is forbidden or simply
66
+ * not in the V1 catalog denies the entire request (never a partial grant).
67
+ */
68
+ export function validateScopes(requested: readonly string[]): ScopeValidationResult {
69
+ const denials: ScopeDenial[] = [];
70
+ for (const scope of requested) {
71
+ if (FORBIDDEN_SCOPE_SET.has(scope)) {
72
+ denials.push({ scope, reason: "forbidden" });
73
+ } else if (!V1_SCOPE_SET.has(scope)) {
74
+ denials.push({ scope, reason: "unknown" });
75
+ }
76
+ }
77
+ if (denials.length) return { ok: false, code: "scope_denied", denials };
78
+ return { ok: true, scopes: requested as V1Scope[] };
79
+ }