@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,35 @@
1
+ /**
2
+ * Response helpers shared by every `/api/apps/v1/*` route (PrivateApps epic,
3
+ * D3) — a no-store JSON responder (mirrors `../grants/route.ts` /
4
+ * `./adminService.ts`'s pattern) plus the `tot-request-id` / `traceparent`
5
+ * response headers `docs/private-apps/contract/openapi.yaml` documents on
6
+ * every operation.
7
+ */
8
+ import type { APIContext } from "astro";
9
+
10
+ /** No-store JSON response — scoped-app API bodies must never be edge/browser-cached. */
11
+ export function json(body: unknown, status = 200): Response {
12
+ return new Response(JSON.stringify(body), {
13
+ status,
14
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
15
+ });
16
+ }
17
+
18
+ /** The W3C Trace Context shape the contract's `Traceparent` parameter/header pins. */
19
+ const TRACEPARENT_RE = /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/;
20
+
21
+ /**
22
+ * Stamp a fresh `tot-request-id` on every response, and echo `traceparent`
23
+ * back ONLY when the request sent a well-formed one — never fabricate a
24
+ * trace context the caller didn't start. Callers apply this to BOTH success
25
+ * and error responses (including `authorizeAppRequest`'s), so every response
26
+ * off a `/api/apps/v1/*` route carries a request id.
27
+ */
28
+ export function withRequestId(res: Response, context: Pick<APIContext, "request">): Response {
29
+ res.headers.set("tot-request-id", crypto.randomUUID());
30
+ const traceparent = context.request.headers.get("traceparent");
31
+ if (traceparent && TRACEPARENT_RE.test(traceparent)) {
32
+ res.headers.set("traceparent", traceparent);
33
+ }
34
+ return res;
35
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Maps the internal `CatalogProduct` read model to the D3 contract's
3
+ * `Product` shape (`docs/private-apps/contract/openapi.yaml`, schema
4
+ * `Product` — `additionalProperties: false`). Drops every internal field
5
+ * the contract doesn't declare (variants, images, metafields, source, …) so
6
+ * an app only ever sees the minimized public catalog surface.
7
+ */
8
+ import type { CatalogProduct } from "@tot/public-runtime";
9
+
10
+ export interface ContractProduct {
11
+ id: string;
12
+ handle: string;
13
+ title: string;
14
+ status: "active" | "draft" | "archived";
15
+ vendor?: string;
16
+ productType?: string;
17
+ tags?: string[];
18
+ priceRange: { min: number; max: number; currency: string };
19
+ updatedAt?: string;
20
+ }
21
+
22
+ /** Project one `CatalogProduct` into the contract's `Product` — nothing else survives. */
23
+ export function toContractProduct(p: CatalogProduct): ContractProduct {
24
+ return {
25
+ id: p.id,
26
+ handle: p.handle,
27
+ title: p.title,
28
+ status: p.status,
29
+ vendor: p.vendor,
30
+ productType: p.product_type,
31
+ tags: p.tags,
32
+ priceRange: {
33
+ min: p.price_range.min,
34
+ max: p.price_range.max,
35
+ currency: p.price_range.currency,
36
+ },
37
+ updatedAt: p.updated_at,
38
+ };
39
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * D1-backed `CredentialPort` implementation (PrivateApps epic, D2 Chunk B) —
3
+ * issues/rotates/clears the OAuth2 client-credentials for an app install, and
4
+ * verifies a presented `(clientId, secret)` pair for the token endpoint
5
+ * (`tokenIssuer.ts`).
6
+ *
7
+ * Pure over the `Queryable` seam, the same shape `AppRegistryService` and the
8
+ * catalog read model use (`../d1/catalog.ts`). The secret is NEVER stored in
9
+ * plaintext — only its sha256 hash, via `@tot/public-runtime`'s `sha256Hex`
10
+ * (reused, not reinvented — the SAME primitive the customization publish
11
+ * spine's content-addressing uses). Imported from `@tot/public-runtime`
12
+ * rather than `@tot/private-controlplane` because this module lives in the
13
+ * renderer (`src/lib/apps/`), which depcruise's `renderer-no-controlplane-pkg`
14
+ * fence forbids from ever importing the private control-plane package —
15
+ * `sha256Hex` carries no control-plane logic, so it lives in the
16
+ * renderer-safe package instead (see `hash.ts` there). The plaintext secret
17
+ * is returned exactly once, from `issue`/`rotate`, and never again;
18
+ * `verifySecret` only ever compares hashes.
19
+ */
20
+ import { sha256Hex } from "@tot/public-runtime";
21
+ import type { CredentialPort, IssuedCredential } from "./registryService.js";
22
+ import type { Queryable } from "../d1/catalog.js";
23
+
24
+ /** `IssuedCredential` plus the one-time plaintext secret — never stored, never re-returned. */
25
+ export type IssuedCredentialSecret = IssuedCredential & { secret: string };
26
+
27
+ /** Thrown when a credential-lifecycle call targets an install with no credential row. */
28
+ export class CredentialError extends Error {
29
+ constructor(
30
+ message: string,
31
+ readonly code: "not_found",
32
+ ) {
33
+ super(message);
34
+ this.name = "CredentialError";
35
+ }
36
+ }
37
+
38
+ /** Raw `app_credentials` row shape (snake_case columns). */
39
+ interface CredentialRow {
40
+ install_id: string;
41
+ client_id: string;
42
+ secret_hash: string;
43
+ secret_created_at: string;
44
+ rotated_at: string | null;
45
+ jwks_kid: string;
46
+ }
47
+
48
+ /** High-entropy secret bytes (256 bits) before base64url encoding. */
49
+ const SECRET_BYTES = 32;
50
+
51
+ /**
52
+ * Fixed value hashed on the unknown-clientId path so a DB miss costs about
53
+ * the same as a wrong-secret hit — no "does this clientId exist" timing
54
+ * oracle from the miss path skipping the hash entirely.
55
+ */
56
+ const DUMMY_SECRET_FOR_TIMING = "dummy-secret-for-timing-parity";
57
+
58
+ function randomSecret(): string {
59
+ const bytes = new Uint8Array(SECRET_BYTES);
60
+ crypto.getRandomValues(bytes);
61
+ return Buffer.from(bytes).toString("base64url");
62
+ }
63
+
64
+ /**
65
+ * Constant-time compare of two equal-shape hex digests — hashes are fixed
66
+ * length, so this avoids a timing oracle on the secret comparison without
67
+ * needing a dedicated crypto-compare primitive.
68
+ */
69
+ function timingSafeEqualHex(a: string, b: string): boolean {
70
+ if (a.length !== b.length) return false;
71
+ let diff = 0;
72
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
73
+ return diff === 0;
74
+ }
75
+
76
+ export class D1CredentialStore implements CredentialPort {
77
+ constructor(
78
+ private readonly db: Queryable,
79
+ /** The `kid` new credentials are stamped with — the currently-active signing key. */
80
+ private readonly activeJwksKid: string,
81
+ private readonly now: () => string = () => new Date().toISOString(),
82
+ ) {}
83
+
84
+ /** Issue a fresh client-credential set for a newly installed app. */
85
+ async issue(installId: string): Promise<IssuedCredentialSecret> {
86
+ const clientId = crypto.randomUUID();
87
+ const secret = randomSecret();
88
+ const secretHash = await sha256Hex(secret);
89
+ await this.db.run(
90
+ `INSERT INTO app_credentials (install_id, client_id, secret_hash, secret_created_at, rotated_at, jwks_kid)
91
+ VALUES (?,?,?,?,NULL,?)`,
92
+ installId,
93
+ clientId,
94
+ secretHash,
95
+ this.now(),
96
+ this.activeJwksKid,
97
+ );
98
+ return { installId, clientId, jwksKid: this.activeJwksKid, secret };
99
+ }
100
+
101
+ /**
102
+ * Rotate the secret for an existing install: same `clientId`, a fresh
103
+ * secret + hash, `rotated_at` bumped. No grace window — the old secret
104
+ * stops verifying immediately.
105
+ */
106
+ async rotate(installId: string): Promise<IssuedCredentialSecret> {
107
+ const row = await this.mustFindRow(installId);
108
+ const secret = randomSecret();
109
+ const secretHash = await sha256Hex(secret);
110
+ await this.db.run(
111
+ `UPDATE app_credentials SET secret_hash = ?, rotated_at = ? WHERE install_id = ?`,
112
+ secretHash,
113
+ this.now(),
114
+ installId,
115
+ );
116
+ return { installId, clientId: row.client_id, jwksKid: row.jwks_kid, secret };
117
+ }
118
+
119
+ /** Revoke/clear credential material for an install (uninstall). Idempotent. */
120
+ async clear(installId: string): Promise<void> {
121
+ await this.db.run(`DELETE FROM app_credentials WHERE install_id = ?`, installId);
122
+ }
123
+
124
+ /**
125
+ * Verify a presented `(clientId, secret)`. Returns the owning `installId` on
126
+ * a match, `null` otherwise — an unknown `clientId` and a wrong `secret`
127
+ * are indistinguishable to the caller (fail closed, no enumeration oracle).
128
+ */
129
+ async verifySecret(clientId: string, presentedSecret: string): Promise<string | null> {
130
+ const row = await this.db.first<CredentialRow>(
131
+ `SELECT * FROM app_credentials WHERE client_id = ?`,
132
+ clientId,
133
+ );
134
+ if (!row) {
135
+ // Unknown clientId: still hash a fixed dummy value so this path takes
136
+ // about as long as the wrong-secret path below (see DUMMY_SECRET_FOR_TIMING).
137
+ await sha256Hex(DUMMY_SECRET_FOR_TIMING);
138
+ return null;
139
+ }
140
+ const presentedHash = await sha256Hex(presentedSecret);
141
+ if (!timingSafeEqualHex(presentedHash, row.secret_hash)) return null;
142
+ return row.install_id;
143
+ }
144
+
145
+ private async mustFindRow(installId: string): Promise<CredentialRow> {
146
+ const row = await this.db.first<CredentialRow>(
147
+ `SELECT * FROM app_credentials WHERE install_id = ?`,
148
+ installId,
149
+ );
150
+ if (!row) throw new CredentialError(`No credential row for install "${installId}".`, "not_found");
151
+ return row;
152
+ }
153
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * RS256 signing-key loader for the Storefront App Gateway (PrivateApps epic,
3
+ * D2 Chunk B) — the key that mints and verifies the OAuth2 client-credentials
4
+ * JWTs a private app presents to the D3 scoped APIs.
5
+ *
6
+ * Mirrors `../auth/brokerAssertion.ts`'s `loadSigningKey` caching + `readEnv`
7
+ * usage EXACTLY, but over a SEPARATE key (`JWT_APP_GATEWAY_PRIVATE_KEY_B64`):
8
+ * an app must never be able to mint, or verify offline as, a tot20 broker
9
+ * assertion, and vice versa.
10
+ *
11
+ * The private key never leaves this module. `loadGatewayJwks` derives the
12
+ * PUBLIC JWKS from it — an RSA private key already embeds the public modulus
13
+ * + exponent, so no separate public-key env var is needed — and explicitly
14
+ * STRIPS every private field (`d`, `p`, `q`, `dp`, `dq`, `qi`, `oth`) before
15
+ * returning; that redaction is the one thing this module must never get
16
+ * wrong. V1 always publishes exactly one active key, but the return shape
17
+ * (`{ keys: JWK[] }`) is forward-compatible with more (multiple `kid`s) per
18
+ * `d2-design-resolutions`, so a future rotation-with-overlap doesn't need a
19
+ * shape change.
20
+ */
21
+ import { importPKCS8, exportJWK, type CryptoKey, type JWK, type JSONWebKeySet } from "jose";
22
+ import { readEnv } from "@/lib/env";
23
+
24
+ /** The only algorithm the gateway signs with (verifiers reject anything else). */
25
+ export const APP_GATEWAY_ALG = "RS256";
26
+
27
+ /** Default key id (overridable via `JWT_APP_GATEWAY_KID`). Names the published key. */
28
+ export const DEFAULT_APP_GATEWAY_KID = "app-gateway-1";
29
+
30
+ /** Default issuer (overridable via `JWT_APP_GATEWAY_ISSUER`). */
31
+ export const DEFAULT_APP_GATEWAY_ISSUER = "storefront-app-gateway";
32
+
33
+ /** Access-token lifetime — deliberately short, same TTL as `BROKER_TTL_SECONDS`. */
34
+ export const APP_TOKEN_TTL_SECONDS = 300;
35
+
36
+ /**
37
+ * Widget launch-token lifetime (PrivateApps epic, D5 Chunk B) — shorter than
38
+ * {@link APP_TOKEN_TTL_SECONDS}: this token only needs to survive the SSR
39
+ * render → iframe-load round trip, never a standing API session.
40
+ */
41
+ export const WIDGET_LAUNCH_TOKEN_TTL_SECONDS = 120;
42
+
43
+ /** Env keys the loader reads (server-only). */
44
+ export const APP_GATEWAY_PRIVATE_KEY_ENV = "JWT_APP_GATEWAY_PRIVATE_KEY_B64";
45
+ export const APP_GATEWAY_KID_ENV = "JWT_APP_GATEWAY_KID";
46
+ export const APP_GATEWAY_ISSUER_ENV = "JWT_APP_GATEWAY_ISSUER";
47
+
48
+ /**
49
+ * Thrown when the gateway can't load its signing key — the private key env is
50
+ * absent/malformed. Routes map this to a 503 ("integration not configured")
51
+ * and FAIL CLOSED: with no key, no token can be minted or verified.
52
+ */
53
+ export class GatewayKeyError extends Error {}
54
+
55
+ /** Optional test/DI overrides (avoids touching process env in unit tests). */
56
+ export interface GatewayKeyOverrides {
57
+ /** base64 of the PKCS8 PEM private key (defaults to the env value). */
58
+ privateKeyB64?: string;
59
+ kid?: string;
60
+ issuer?: string;
61
+ }
62
+
63
+ export interface GatewaySigningConfig {
64
+ key: CryptoKey;
65
+ kid: string;
66
+ issuer: string;
67
+ }
68
+
69
+ // Import the signing key once per distinct PEM (importPKCS8 is not free), same
70
+ // cache shape as brokerAssertion.ts's `keyCache`. Keyed on the base64 string so
71
+ // a key rotation (new env value) transparently re-imports and re-derives.
72
+ const signingKeyCache = new Map<string, CryptoKey>();
73
+ const publicJwkCache = new Map<string, JWK>();
74
+
75
+ async function importPrivateKey(privateKeyB64: string): Promise<CryptoKey> {
76
+ const cached = signingKeyCache.get(privateKeyB64);
77
+ if (cached) return cached;
78
+ let pem: string;
79
+ try {
80
+ pem = Buffer.from(privateKeyB64, "base64").toString("utf8");
81
+ } catch (cause) {
82
+ throw new GatewayKeyError(
83
+ `app gateway: ${APP_GATEWAY_PRIVATE_KEY_ENV} is not valid base64 ` +
84
+ (cause instanceof Error ? cause.message : String(cause)),
85
+ );
86
+ }
87
+ if (!/-----BEGIN (RSA )?PRIVATE KEY-----/.test(pem)) {
88
+ throw new GatewayKeyError(
89
+ `app gateway: decoded ${APP_GATEWAY_PRIVATE_KEY_ENV} is not a PEM private key`,
90
+ );
91
+ }
92
+ let key: CryptoKey;
93
+ try {
94
+ // extractable: true — required so `loadGatewayJwks` below can derive the
95
+ // public JWK from this same key. The private key itself is still never
96
+ // exported anywhere outside this module.
97
+ key = (await importPKCS8(pem, APP_GATEWAY_ALG, { extractable: true })) as CryptoKey;
98
+ } catch (cause) {
99
+ throw new GatewayKeyError(
100
+ `app gateway: failed to import the RS256 private key ` +
101
+ (cause instanceof Error ? cause.message : String(cause)),
102
+ );
103
+ }
104
+ signingKeyCache.set(privateKeyB64, key);
105
+ return key;
106
+ }
107
+
108
+ /**
109
+ * Resolve the active signing config (key + kid + issuer) from env, or
110
+ * `overrides` in tests. FAILS CLOSED: throws {@link GatewayKeyError} when the
111
+ * private key env is absent/malformed.
112
+ */
113
+ export async function loadGatewaySigningConfig(
114
+ overrides: GatewayKeyOverrides = {},
115
+ ): Promise<GatewaySigningConfig> {
116
+ const privateKeyB64 = overrides.privateKeyB64 ?? (await readEnv(APP_GATEWAY_PRIVATE_KEY_ENV));
117
+ if (!privateKeyB64) {
118
+ throw new GatewayKeyError(
119
+ `app gateway: ${APP_GATEWAY_PRIVATE_KEY_ENV} is not configured (fail closed)`,
120
+ );
121
+ }
122
+ const kid = overrides.kid ?? (await readEnv(APP_GATEWAY_KID_ENV)) ?? DEFAULT_APP_GATEWAY_KID;
123
+ const issuer =
124
+ overrides.issuer ?? (await readEnv(APP_GATEWAY_ISSUER_ENV)) ?? DEFAULT_APP_GATEWAY_ISSUER;
125
+ const key = await importPrivateKey(privateKeyB64);
126
+ return { key, kid, issuer };
127
+ }
128
+
129
+ /**
130
+ * Derive the PUBLIC JWKS for the active key — sanitized (every private RSA
131
+ * field stripped) and cached by the source private-key base64 (so a rotated
132
+ * key transparently re-derives, same invalidation as the signing-key cache).
133
+ * V1 always returns exactly one key; the envelope supports more without a
134
+ * shape change later.
135
+ */
136
+ export async function loadGatewayJwks(
137
+ overrides: GatewayKeyOverrides = {},
138
+ ): Promise<JSONWebKeySet> {
139
+ const privateKeyB64 = overrides.privateKeyB64 ?? (await readEnv(APP_GATEWAY_PRIVATE_KEY_ENV));
140
+ if (!privateKeyB64) {
141
+ throw new GatewayKeyError(
142
+ `app gateway: ${APP_GATEWAY_PRIVATE_KEY_ENV} is not configured (fail closed)`,
143
+ );
144
+ }
145
+ const { key, kid } = await loadGatewaySigningConfig(overrides);
146
+ let jwk = publicJwkCache.get(privateKeyB64);
147
+ if (!jwk) {
148
+ const full = (await exportJWK(key)) as Record<string, unknown>;
149
+ // Strip every private RSA field (RFC 7518 §6.3.2) — only the public parts
150
+ // (kty, n, e) are ever published. This line is the one place that matters.
151
+ const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, oth: _oth, ...publicOnly } = full;
152
+ jwk = { ...publicOnly, kid, alg: APP_GATEWAY_ALG, use: "sig" };
153
+ publicJwkCache.set(privateKeyB64, jwk);
154
+ }
155
+ return { keys: [jwk] };
156
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Admin Health view aggregation — PrivateApps epic, D8 (Chunk A + D). Folds
3
+ * one `AppInstall` (`registryService.ts`) and its `InstallHealth` rollup
4
+ * (`../webhooks/deliveryStore.ts`'s `getInstallHealth`, now including
5
+ * `p95DeliveryMs`) into the single `AppInstallHealthView` shape the admin
6
+ * console renders. Deliberately transport-free — no D1/fetch here, so it's
7
+ * unit-testable with fakes; the caller (the admin health route) owns
8
+ * fetching the install + health and calling this.
9
+ *
10
+ * Data-minimization for the console: `webhookEndpoint` is never surfaced as
11
+ * a raw URL here, only as `hasWebhookEndpoint` presence — the console shows
12
+ * "configured or not", not the app's receiver address.
13
+ *
14
+ * `runtimeMode` is hardcoded to `"external"` for V1: there is no per-install
15
+ * runtime-mode column yet (every install today IS an externally-hosted app;
16
+ * an in-platform runtime mode is a D9+ concern).
17
+ */
18
+ import { validateWebhookEndpoint } from "../webhooks/endpointGuard.js";
19
+ import type { InstallHealth } from "../webhooks/deliveryStore.js";
20
+ import type { AppInstall, AppInstallStatus, WidgetPlacementInput } from "./registryService.js";
21
+
22
+ /**
23
+ * - `no_endpoint` — no `webhookEndpoint` configured at all.
24
+ * - `blocked` — a configured endpoint now fails the same SSRF guard
25
+ * `registryService.ts` enforces at write time (it may have passed then and
26
+ * drifted since), OR the health rollup's most recent failure was
27
+ * classified `endpoint_blocked`.
28
+ * - `ok` — otherwise.
29
+ */
30
+ export type EgressStatus = "ok" | "blocked" | "no_endpoint";
31
+
32
+ export interface AppInstallHealthView {
33
+ id: string;
34
+ appId: string;
35
+ status: AppInstallStatus;
36
+ env: string;
37
+ runtimeMode: "external";
38
+ scopes: string[];
39
+ widgetPlacements: WidgetPlacementInput[];
40
+ hasWebhookEndpoint: boolean;
41
+ health: InstallHealth;
42
+ egressStatus: EgressStatus;
43
+ }
44
+
45
+ function computeEgressStatus(install: AppInstall, health: InstallHealth): EgressStatus {
46
+ if (!install.webhookEndpoint) return "no_endpoint";
47
+ const validation = validateWebhookEndpoint(install.webhookEndpoint);
48
+ if (!validation.ok || health.lastErrorClass === "endpoint_blocked") return "blocked";
49
+ return "ok";
50
+ }
51
+
52
+ /** Build the console's `AppInstallHealthView` for one install. Pure — both inputs are already-fetched domain objects. */
53
+ export function buildInstallHealthView(install: AppInstall, health: InstallHealth): AppInstallHealthView {
54
+ return {
55
+ id: install.installId,
56
+ appId: install.appId,
57
+ status: install.status,
58
+ env: install.env,
59
+ runtimeMode: "external",
60
+ scopes: install.scopes,
61
+ widgetPlacements: install.widgetPlacements,
62
+ hasWebhookEndpoint: install.webhookEndpoint != null,
63
+ health,
64
+ egressStatus: computeEgressStatus(install, health),
65
+ };
66
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * App-owned attribution/commission write model — PrivateApps epic, D6
3
+ * (Chunk C). Owns the `app_attributions` rows `schema.ts` declares: the
4
+ * ledger `POST /attribution` (`docs/private-apps/contract/openapi.yaml`'s
5
+ * `writeAttribution`) writes into. Writes only into the app's own
6
+ * attribution namespace — this service never mutates `orders`, it only
7
+ * READS the referenced order for the tenant-ownership check the contract
8
+ * requires ("the referenced `orderId` MUST belong to the calling
9
+ * tenant/install... rejected with 403, mirroring the cross-tenant read
10
+ * rule").
11
+ *
12
+ * Pure over the `Queryable` seam (see `../../d1/catalog.ts`), same shape
13
+ * `AppRegistryService`/`OrdersStore` use, so this runs unchanged against
14
+ * real D1 in the Worker and an in-memory `node:sqlite` in tests.
15
+ *
16
+ * `attributionId` is the app's own stable id for the event and is the
17
+ * table's primary key alongside `installId` — a SECOND write with the same
18
+ * attributionId is its own idempotency dimension, independent of the
19
+ * `Idempotency-Key` ledger (`idempotency.ts`) the route layers on top: an
20
+ * app that resubmits the same event under a fresh Idempotency-Key should
21
+ * still see it treated as idempotent, so identical content is a stable
22
+ * no-op replay (returns the row already on record) while differing content
23
+ * is a conflict — once recorded, an attributionId's content is immutable.
24
+ */
25
+ import { OrdersStore } from "./ordersStore.js";
26
+ import type { Queryable } from "../../d1/catalog.js";
27
+
28
+ export interface CommissionInput {
29
+ amount?: number;
30
+ currency?: string;
31
+ }
32
+
33
+ /** A `customerHash` value must be a sha256 hex digest — see `customerHash.ts`. */
34
+ const CUSTOMER_HASH_PATTERN = /^[0-9a-f]{64}$/;
35
+
36
+ export interface RecordAttributionInput {
37
+ installId: string;
38
+ /** The caller's OWN tenant, from JWT claims — never trusted from request input. */
39
+ tenantId: string;
40
+ attributionId: string;
41
+ orderId: string;
42
+ campaign?: string;
43
+ referralCode?: string;
44
+ commission?: CommissionInput;
45
+ /**
46
+ * The app's own per-(tenant,install) `customerHash`, as ECHOED BACK from a
47
+ * prior `GET /orders`/`GET /orders/{id}` read — not a raw identifier. This
48
+ * service never hashes it: hashing an already-opaque value again would
49
+ * break the app's ability to correlate this attribution against the order
50
+ * it read the hash from. Validated as a 64-hex sha256 digest (a raw email
51
+ * or name fails that check, preserving minimization) and stored AS-IS.
52
+ */
53
+ customerHash?: string;
54
+ }
55
+
56
+ /** The contract's `AttributionRecord` shape — deliberately excludes `customerHash` (never echoed back). */
57
+ export interface AttributionRecord {
58
+ attributionId: string;
59
+ orderId: string;
60
+ campaign?: string;
61
+ referralCode?: string;
62
+ commission?: { amount?: number; currency?: string };
63
+ recordedAt: string;
64
+ }
65
+
66
+ export type AttributionErrorCode =
67
+ | "attribution_forbidden"
68
+ | "attribution_conflict"
69
+ | "attribution_invalid_customer_hash";
70
+
71
+ /** Thrown by every rule violation below — callers (the route) branch on `.code`. */
72
+ export class AttributionError extends Error {
73
+ constructor(
74
+ message: string,
75
+ readonly code: AttributionErrorCode,
76
+ ) {
77
+ super(message);
78
+ this.name = "AttributionError";
79
+ }
80
+ }
81
+
82
+ /** Raw `app_attributions` row shape (snake_case columns). */
83
+ interface AppAttributionRow {
84
+ install_id: string;
85
+ attribution_id: string;
86
+ tenant_id: string;
87
+ order_id: string;
88
+ campaign: string | null;
89
+ referral_code: string | null;
90
+ commission_amount: number | null;
91
+ commission_currency: string | null;
92
+ customer_hash: string | null;
93
+ recorded_at: string;
94
+ }
95
+
96
+ function toRecord(row: AppAttributionRow): AttributionRecord {
97
+ const hasCommission = row.commission_amount != null || row.commission_currency != null;
98
+ return {
99
+ attributionId: row.attribution_id,
100
+ orderId: row.order_id,
101
+ ...(row.campaign != null ? { campaign: row.campaign } : {}),
102
+ ...(row.referral_code != null ? { referralCode: row.referral_code } : {}),
103
+ ...(hasCommission
104
+ ? {
105
+ commission: {
106
+ ...(row.commission_amount != null ? { amount: row.commission_amount } : {}),
107
+ ...(row.commission_currency != null ? { currency: row.commission_currency } : {}),
108
+ },
109
+ }
110
+ : {}),
111
+ recordedAt: row.recorded_at,
112
+ };
113
+ }
114
+
115
+ /** True when a fresh write would be byte-for-byte the same content as the stored row (a stable replay, not a conflict). */
116
+ function sameContent(row: AppAttributionRow, input: RecordAttributionInput, hashedCustomer: string | null): boolean {
117
+ return (
118
+ row.order_id === input.orderId &&
119
+ (row.campaign ?? undefined) === input.campaign &&
120
+ (row.referral_code ?? undefined) === input.referralCode &&
121
+ (row.commission_amount ?? undefined) === input.commission?.amount &&
122
+ (row.commission_currency ?? undefined) === input.commission?.currency &&
123
+ row.customer_hash === hashedCustomer
124
+ );
125
+ }
126
+
127
+ export class AppAttributionService {
128
+ constructor(
129
+ private readonly db: Queryable,
130
+ private readonly orders: OrdersStore = new OrdersStore(db),
131
+ private readonly now: () => string = () => new Date().toISOString(),
132
+ ) {}
133
+
134
+ /**
135
+ * Record one attribution event. Enforces the D1-contract ownership rule
136
+ * BEFORE any write: `orderId` must resolve to an order owned by the
137
+ * caller's OWN tenant, or this throws `attribution_forbidden` — a foreign
138
+ * order and a nonexistent order get the SAME treatment (no oracle), so a
139
+ * caller can't fish for which orderIds exist on other tenants.
140
+ *
141
+ * A repeat `attributionId` for this install is checked AFTER the
142
+ * ownership gate: identical content is returned as-is (idempotent
143
+ * no-op); any difference is `attribution_conflict`.
144
+ */
145
+ async record(input: RecordAttributionInput): Promise<AttributionRecord> {
146
+ const order = await this.orders.getByExternalId(input.orderId);
147
+ if (!order || order.tenantId !== input.tenantId) {
148
+ throw new AttributionError(`orderId does not belong to this tenant/install.`, "attribution_forbidden");
149
+ }
150
+
151
+ if (input.customerHash !== undefined && !CUSTOMER_HASH_PATTERN.test(input.customerHash)) {
152
+ throw new AttributionError(
153
+ `customerHash, when given, must be the 64-character hex customerHash echoed back from a prior order read — not a raw customer identifier.`,
154
+ "attribution_invalid_customer_hash",
155
+ );
156
+ }
157
+ const hashedCustomer = input.customerHash ?? null;
158
+
159
+ const existing = await this.db.first<AppAttributionRow>(
160
+ `SELECT * FROM app_attributions WHERE install_id = ? AND attribution_id = ?`,
161
+ input.installId,
162
+ input.attributionId,
163
+ );
164
+ if (existing) {
165
+ if (!sameContent(existing, input, hashedCustomer)) {
166
+ throw new AttributionError(
167
+ `attributionId "${input.attributionId}" was already recorded with different content.`,
168
+ "attribution_conflict",
169
+ );
170
+ }
171
+ return toRecord(existing);
172
+ }
173
+
174
+ const recordedAt = this.now();
175
+ const row: AppAttributionRow = {
176
+ install_id: input.installId,
177
+ attribution_id: input.attributionId,
178
+ tenant_id: input.tenantId,
179
+ order_id: input.orderId,
180
+ campaign: input.campaign ?? null,
181
+ referral_code: input.referralCode ?? null,
182
+ commission_amount: input.commission?.amount ?? null,
183
+ commission_currency: input.commission?.currency ?? null,
184
+ customer_hash: hashedCustomer,
185
+ recorded_at: recordedAt,
186
+ };
187
+ await this.db.run(
188
+ `INSERT INTO app_attributions
189
+ (install_id, attribution_id, tenant_id, order_id, campaign, referral_code,
190
+ commission_amount, commission_currency, customer_hash, recorded_at)
191
+ VALUES (?,?,?,?,?,?,?,?,?,?)`,
192
+ row.install_id,
193
+ row.attribution_id,
194
+ row.tenant_id,
195
+ row.order_id,
196
+ row.campaign,
197
+ row.referral_code,
198
+ row.commission_amount,
199
+ row.commission_currency,
200
+ row.customer_hash,
201
+ row.recorded_at,
202
+ );
203
+
204
+ return toRecord(row);
205
+ }
206
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Opaque customer hash — PrivateApps epic, D6 (Chunk A). The `customerHash`
3
+ * every order/attribution surface exposes instead of a raw customer id (see
4
+ * `docs/private-apps/api-event-reference.md`'s PII-minimization matrix and
5
+ * `contract/openapi.yaml`'s `Order.customerHash`).
6
+ *
7
+ * Stable per **(tenant, app-install)**: the same customer always resolves to
8
+ * the same hash within one install, so an app stays internally consistent
9
+ * across its own `order.created`/`order.fulfilled`/`attribution.finalized`
10
+ * events. Deliberately NOT comparable across installs on the same tenant
11
+ * (each install's hash is salted by its own `installId`) — two apps must
12
+ * never be able to correlate the same customer against each other.
13
+ *
14
+ * Reuses `@tot/public-runtime`'s `sha256Hex` (Web Crypto, no `node:*`) rather
15
+ * than a new hash primitive — same primitive `apps/credentials.ts` uses for
16
+ * client-secret hashing.
17
+ */
18
+ import { sha256Hex } from "@tot/public-runtime";
19
+
20
+ /**
21
+ * Hash a raw (tenant-scoped) customer identifier into the opaque, per-install
22
+ * `customerHash` the contract exposes. One-way — never reversible to the raw
23
+ * `rawCustomerId`, and never logged/stored anywhere in its raw form by this
24
+ * module's callers.
25
+ */
26
+ export function customerHash(tenantId: string, installId: string, rawCustomerId: string): Promise<string> {
27
+ return sha256Hex(`${tenantId}:${installId}:${rawCustomerId}`);
28
+ }