@tokenoftrust/storefront-runner 2.0.3 → 2.0.5

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 (178) hide show
  1. package/apps/storefront/.astro/content.d.ts +159 -0
  2. package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +1 -0
  3. package/apps/storefront/.astro/types.d.ts +3 -0
  4. package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
  5. package/apps/storefront/node_modules/.bin/acorn +43 -0
  6. package/apps/storefront/node_modules/.bin/astro +43 -0
  7. package/apps/storefront/node_modules/.bin/astro-check +43 -0
  8. package/apps/storefront/node_modules/.bin/browserslist +43 -0
  9. package/apps/storefront/node_modules/.bin/cf-wrangler +43 -0
  10. package/apps/storefront/node_modules/.bin/depcruise +43 -0
  11. package/apps/storefront/node_modules/.bin/depcruise-baseline +43 -0
  12. package/apps/storefront/node_modules/.bin/depcruise-fmt +43 -0
  13. package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +43 -0
  14. package/apps/storefront/node_modules/.bin/dependency-cruise +43 -0
  15. package/apps/storefront/node_modules/.bin/dependency-cruiser +43 -0
  16. package/apps/storefront/node_modules/.bin/drizzle-kit +43 -0
  17. package/apps/storefront/node_modules/.bin/esbuild +43 -0
  18. package/apps/storefront/node_modules/.bin/jiti +43 -0
  19. package/apps/storefront/node_modules/.bin/playwright +43 -0
  20. package/apps/storefront/node_modules/.bin/playwright-core +43 -0
  21. package/apps/storefront/node_modules/.bin/prettier +43 -0
  22. package/apps/storefront/node_modules/.bin/rollup +43 -0
  23. package/apps/storefront/node_modules/.bin/tsc +43 -0
  24. package/apps/storefront/node_modules/.bin/tsserver +43 -0
  25. package/apps/storefront/node_modules/.bin/tsx +43 -0
  26. package/apps/storefront/node_modules/.bin/vite +43 -0
  27. package/apps/storefront/node_modules/.bin/vitest +43 -0
  28. package/apps/storefront/node_modules/.bin/workerd +43 -0
  29. package/apps/storefront/node_modules/.bin/wrangler +43 -0
  30. package/apps/storefront/node_modules/.bin/wrangler2 +43 -0
  31. package/apps/storefront/node_modules/.bin/yaml +43 -0
  32. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +58 -0
  33. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +1 -0
  34. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +741 -0
  35. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +1 -0
  36. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +2549 -0
  37. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +1 -0
  38. package/apps/storefront/node_modules/.vite/deps/_metadata.json +150 -0
  39. package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +434 -0
  40. package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +1 -0
  41. package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  42. package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  43. package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +2705 -0
  44. package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  45. package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  46. package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  47. package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  48. package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  49. package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +1535 -0
  50. package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +1 -0
  51. package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +48 -0
  52. package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +1 -0
  53. package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +42 -0
  54. package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +1 -0
  55. package/apps/storefront/node_modules/.vite/deps/package.json +3 -0
  56. package/apps/storefront/node_modules/.vite/deps/preact.js +310 -0
  57. package/apps/storefront/node_modules/.vite/deps/preact.js.map +1 -0
  58. package/apps/storefront/node_modules/.vite/deps/preact_compat.js +420 -0
  59. package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +1 -0
  60. package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +4 -0
  61. package/apps/storefront/node_modules/.vite/deps/preact_debug.js +236 -0
  62. package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +1 -0
  63. package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +12 -0
  64. package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +1 -0
  65. package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +192 -0
  66. package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +1 -0
  67. package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +3 -0
  68. package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +92 -0
  69. package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +1 -0
  70. package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +59 -0
  71. package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +1 -0
  72. package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +24 -0
  73. package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +189 -0
  74. package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +1 -0
  75. package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +13 -0
  76. package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +1 -0
  77. package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +458 -0
  78. package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +1 -0
  79. package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +1016 -0
  80. package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +1 -0
  81. package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +32 -0
  82. package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +1 -0
  83. package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +113 -0
  84. package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +1 -0
  85. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +8 -0
  86. package/apps/storefront/node_modules/.vite/deps_ssr/package.json +3 -0
  87. package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
  88. package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
  89. package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
  90. package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
  91. package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
  92. package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
  93. package/apps/storefront/src/layouts/Layout.astro +65 -7
  94. package/apps/storefront/src/lib/apps/registryAccess.ts +45 -0
  95. package/apps/storefront/src/lib/auth/adminEntry.ts +4 -4
  96. package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
  97. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
  98. package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
  99. package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
  100. package/apps/storefront/src/lib/rawChrome.ts +73 -14
  101. package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
  102. package/apps/storefront/src/lib/shared/capability.ts +68 -0
  103. package/apps/storefront/src/lib/shared/developerInvites.ts +1250 -0
  104. package/apps/storefront/src/lib/shared/invitePreflight.ts +123 -0
  105. package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
  106. package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
  107. package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
  108. package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
  109. package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
  110. package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
  111. package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
  112. package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
  113. package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
  114. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
  115. package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
  116. package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
  117. package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
  118. package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
  119. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +38 -4
  120. package/apps/storefront/src/lib/webhooks/emit.ts +1 -1
  121. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +1 -1
  122. package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
  123. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -1
  124. package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
  125. package/apps/storefront/src/pages/index.astro +1 -1
  126. package/apps/storefront/src/pages/products/[handle].astro +1 -1
  127. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
  128. package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
  129. package/package.json +1 -1
  130. package/packages/public-runtime/node_modules/.bin/tsc +43 -0
  131. package/packages/public-runtime/node_modules/.bin/tsserver +43 -0
  132. package/packages/public-runtime/node_modules/.bin/vitest +43 -0
  133. package/packages/public-runtime/src/chrome.ts +18 -7
  134. package/packages/public-runtime/src/index.ts +10 -0
  135. package/packages/public-runtime/src/integration/index.ts +1 -0
  136. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
  137. package/packages/public-runtime/src/interaction-tracking.ts +364 -0
  138. package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
  139. package/pnpm-workspace.yaml +3 -0
  140. package/scripts/tenant/validate.mjs +306 -1
  141. package/scripts/tot-dev.mjs +59 -3
  142. package/tenants/home/public/js/storefront.js +6 -87
  143. package/apps/storefront/src/lib/apps/adminService.ts +0 -116
  144. package/apps/storefront/src/lib/apps/adminSession.ts +0 -214
  145. package/apps/storefront/src/lib/apps/apiAuth.ts +0 -91
  146. package/apps/storefront/src/lib/apps/apiRoute.ts +0 -35
  147. package/apps/storefront/src/lib/apps/catalogMapper.ts +0 -39
  148. package/apps/storefront/src/lib/apps/consentConfigStore.ts +0 -228
  149. package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +0 -447
  150. package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +0 -367
  151. package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +0 -315
  152. package/apps/storefront/src/lib/apps/healthAggregate.ts +0 -66
  153. package/apps/storefront/src/lib/apps/integrationLifecycle.ts +0 -614
  154. package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +0 -125
  155. package/apps/storefront/src/lib/apps/integrationReadModel.ts +0 -563
  156. package/apps/storefront/src/lib/apps/integrationsTab.ts +0 -547
  157. package/apps/storefront/src/lib/apps/integrationsView.ts +0 -106
  158. package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +0 -599
  159. package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +0 -170
  160. package/apps/storefront/src/lib/apps/tokenIssuer.ts +0 -121
  161. package/apps/storefront/src/lib/apps/tokenVerifier.ts +0 -148
  162. package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +0 -16
  163. package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +0 -105
  164. package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +0 -177
  165. package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +0 -251
  166. package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +0 -241
  167. package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +0 -211
  168. package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +0 -409
  169. package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +0 -368
  170. package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +0 -188
  171. package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +0 -291
  172. package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +0 -95
  173. package/apps/storefront/src/lib/content-edit/applyDiff.ts +0 -91
  174. package/apps/storefront/src/lib/content-edit/client.ts +0 -286
  175. package/apps/storefront/src/lib/content-edit/index.ts +0 -14
  176. package/apps/storefront/src/lib/content-edit/route.ts +0 -25
  177. package/apps/storefront/src/lib/content-edit/types.ts +0 -19
  178. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +0 -164
@@ -0,0 +1,1250 @@
1
+ /**
2
+ * DeveloperInviteClient — the storefront's seam to tot20's operator-auth
3
+ * developer-invite endpoint (#8425): a keyless, self-serve invite path.
4
+ *
5
+ * An authenticated OPERATOR — storefront, presenting its OWN per-tier ToT
6
+ * credentials — invites a developer EMAIL onto a tenant it OPERATES (not owns).
7
+ * tot20 authorizes via its own operator-grant check (`hasOperatorGrant`), writes
8
+ * the `developer` eligibility role onto the TARGET tenant tenant-internally (no
9
+ * owner keys, no leaf-token dance), then sends the branded sign-in email.
10
+ *
11
+ * POST /api/clients/developerInvites { tenant, email, sendEmail? }
12
+ * → 200 { code:'developerInvites:created', inviteId, tenant, developer,
13
+ * roles:['developer'], changed, emailed, signInEligible, capability }
14
+ * → 403 developerInvites:notGranted (storefront holds no operator grant here)
15
+ * → 422 developerInvites:{tenantRequired|invalidEmail|tenantNotFound}
16
+ *
17
+ * AUTH WIRE (mirrors `@/lib/auth/totAccessClient`): tot20's swagger
18
+ * `normalizeSecureUserRequest` extracts machine creds
19
+ * from the request BODY fields `totApiKey`/`totSecretKey`, NOT custom `x-tot-*`
20
+ * headers. That is the ONLY integration-sensitive spot ({@link credentialFields}).
21
+ * tot20 wraps every response in a `{ content, metadata }` envelope.
22
+ *
23
+ * TIER: the host + operator credential are per-DEPLOY env (`TOT_CORE_URL` +
24
+ * `TOT_API_KEY`/`TOT_SECRET_KEY`) — each tier's deploy is configured with its
25
+ * tier-matching values (test/qa → https://qa.tokenoftrust.com, prod →
26
+ * https://app.tokenoftrust.com). This is the SAME env the sibling forge + access
27
+ * clients read; there are no hardcoded hosts and no secrets in code.
28
+ *
29
+ * Built like `@/lib/forge` and `@/lib/auth/totAccessClient` (interface + Http impl
30
+ * + Mock + env factory) so the route develops/tests against `MockDeveloperInviteClient`
31
+ * and swaps in the Http client with no call-site change. The tiny HTTP transport
32
+ * seam is duplicated locally rather than imported from `@/lib/auth` so the grants
33
+ * control-plane cluster stays self-contained (see `.dependency-cruiser.cjs`).
34
+ */
35
+ import { readEnv } from "@/lib/env";
36
+ import { diagEmail } from "@/lib/diag/redact";
37
+ import { checkStorePreviewReady, type PreflightDecision } from "@/lib/shared/invitePreflight";
38
+
39
+ /**
40
+ * The header carrying the storefront-signed staff BROKER ASSERTION to tot20
41
+ * (#8453). The literal is duplicated locally (rather than
42
+ * imported from `@/lib/auth/brokerAssertion`) so the grants control-plane cluster
43
+ * stays self-contained (see `.dependency-cruiser.cjs`); the MINTER lives in
44
+ * `@/lib/auth` and the ROUTE mints + passes the compact JWT down as a string.
45
+ */
46
+ const BROKER_ASSERTION_HEADER = "X-ToT-Broker-Assertion";
47
+
48
+ /** Narrow tot20's `signInToken` payload to the expected shape (fail-closed). */
49
+ function isSignInToken(v: unknown): v is { token: string; expiresAt: string; audience: string } {
50
+ if (!v || typeof v !== "object") return false;
51
+ const o = v as Record<string, unknown>;
52
+ return typeof o.token === "string" && typeof o.expiresAt === "string" && typeof o.audience === "string";
53
+ }
54
+
55
+ /** Host of a URL, or null — for diagnostics (never throws). */
56
+ function safeHost(url?: string): string | null {
57
+ if (!url) return null;
58
+ try {
59
+ return new URL(url).host;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Secret-safe diagnostic for the storefront→tot20 invite interaction — the
67
+ * storefront half of the cross-repo email-send trace. Logs the request shape +
68
+ * tot20's decision (esp. `emailed`) so email-send outcomes are observable on both
69
+ * sides. NEVER logs creds/assertions.
70
+ * Emitted at info level → lands in `logs/dev.log` (pnpm dev:log) and worker logs.
71
+ */
72
+ function logInviteDiag(phase: "request" | "response", fields: Record<string, unknown>): void {
73
+ try {
74
+ console.info(`[diag:invite:${phase}]`, JSON.stringify(fields));
75
+ } catch {
76
+ /* diagnostics must never break the call */
77
+ }
78
+ }
79
+
80
+ /**
81
+ * tot20's team-role vocabulary (mirrors `ASSIGNABLE_ROLES` in tot20's
82
+ * `DeveloperInviteManager`). `contributor` is the ONE role a plain, non-staff
83
+ * operator/member invite may request directly — a strictly-weaker self-downgrade
84
+ * from `developer`, never an escalation; `appAdmin`/`appOwner` still require a
85
+ * VERIFIED staff broker assertion (#8453). Exported so UI role pickers (e.g. the
86
+ * dashboard Team page) stay in sync with what tot20 actually honors.
87
+ */
88
+ export const DEVELOPER_INVITE_ROLES = [
89
+ "developer",
90
+ "appAdmin",
91
+ "appOwner",
92
+ "contributor",
93
+ ] as const;
94
+ export type DeveloperInviteRole = (typeof DEVELOPER_INVITE_ROLES)[number];
95
+
96
+ /** The one team role a plain (non-staff) operator/member may self-request. */
97
+ export const CONTRIBUTOR_ROLE: DeveloperInviteRole = "contributor";
98
+
99
+ /** Operator input to a developer invite. `tenant` is resolved from the session. */
100
+ export interface DeveloperInviteInput {
101
+ /** Target tenant appDomain the developer is invited onto (session-derived). */
102
+ tenant: string;
103
+ /** The developer's email (lowercased on the wire; tot20 keys eligibility on it). */
104
+ email: string;
105
+ /** Send the branded sign-in email (tot20 default: true). */
106
+ sendEmail?: boolean;
107
+ /**
108
+ * A fresh storefront-signed BROKER ASSERTION (compact RS256 JWT) PROVING the
109
+ * signed-in staff actor (#8453). Minted
110
+ * server-side by `@/lib/auth/brokerAssertion` from the verified `session.email`
111
+ * and presented to tot20 in the `X-ToT-Broker-Assertion` header. tot20 verifies
112
+ * signature + replay and derives the staff actor from the VERIFIED `sub`, then
113
+ * permits invite/list on ANY tenant (bypassing the operator-grant). Omitted →
114
+ * the operator-grant-only path applies. NEVER a plaintext staff email (which
115
+ * would be spoofable).
116
+ */
117
+ brokerAssertion?: string;
118
+ /**
119
+ * The team-member role to write. tot20's vocabulary: `developer` (default) |
120
+ * `appAdmin` | `appOwner` | `contributor`.
121
+ *
122
+ * `appAdmin`/`appOwner` are HONORED only for a VERIFIED STAFF actor (a
123
+ * `brokerAssertion` that verifies to ToT staff, #8453);
124
+ * for a plain operator they are IGNORED and the role stays hard-pinned to
125
+ * `developer`.
126
+ *
127
+ * `contributor` is the ONE exception: tot20 honors it for a plain (non-staff)
128
+ * operator too — it is a strictly-weaker self-downgrade from `developer`, never an
129
+ * escalation (`DeveloperInviteManager._resolveInviteRole` in tot20). So a normal,
130
+ * non-staff invite call MAY request `contributor` directly — no broker assertion
131
+ * needed. Omitted → developer.
132
+ */
133
+ role?: string;
134
+ /**
135
+ * Optional personal note from the inviter, shown to the developer in the branded
136
+ * invite email. Free-text — tot20 HTML-escapes it on render (injection guard).
137
+ * Omitted → the email has no note block.
138
+ */
139
+ note?: string;
140
+ /**
141
+ * Absolute sign-in URL on the INVITING storefront (its own origin + a post-login
142
+ * `next`), so tot20 can render a "Sign in to <store>" call-to-action button in the
143
+ * invite email — without it the email is code-only and the developer has nowhere to
144
+ * enter the code (#8425). tot20 must allowlist/validate the
145
+ * URL before rendering it. Omitted → tot20 falls back to the code-only copy.
146
+ */
147
+ signInUrl?: string;
148
+ /**
149
+ * MAGIC-LINK mint (storefront-owns-the-email model). When true, tot20 does NOT send
150
+ * its own template — instead it returns a high-entropy single-use `signInToken` (see
151
+ * {@link DeveloperInviteCreated.signInToken}) for the storefront to embed in its OWN
152
+ * branded email. Pair with `sendEmail:false`. The token is returned only over this
153
+ * server-to-server operator channel, never to the browser.
154
+ */
155
+ issueSignInToken?: boolean;
156
+ /**
157
+ * MAGIC-LINK lifetime (seconds) — the storefront owns the link's TTL. tot20 clamps to
158
+ * [60, 604800] (1 min .. 7 days); absent → tot20's 30-min default. The effective value
159
+ * comes back as `signInToken.ttlSeconds`.
160
+ */
161
+ signInTokenTtlSeconds?: number;
162
+ }
163
+
164
+ /** A successful invite — tot20 wrote the `developer` eligibility on the tenant. */
165
+ export interface DeveloperInviteCreated {
166
+ ok: true;
167
+ /** Opaque handle encoding (tenant, appSpec-user-id) for GET-one / DELETE. */
168
+ inviteId: string;
169
+ tenant: string;
170
+ /** The invited developer email, echoed back. */
171
+ developer: string;
172
+ /** Roles written — hard-pinned to `['developer']` by tot20. */
173
+ roles: string[];
174
+ /** `false` when the email already held the developer role (idempotent no-op). */
175
+ changed: boolean;
176
+ /** Whether the branded sign-in email was actually sent. */
177
+ emailed: boolean;
178
+ /** Whether tot20 now resolves the email as access-code eligible. */
179
+ signInEligible: boolean;
180
+ /** Effective capability tot20 resolved for the email (usually `developer`). */
181
+ capability?: string;
182
+ /**
183
+ * Why the email did (or didn't) send — tot20's structured reason:
184
+ * `sent | rateLimited | notEligible | mailError | sendError | skipped`. Makes an
185
+ * `emailed:false` self-explaining instead of a silent false.
186
+ */
187
+ emailReason?: string;
188
+ /** Human detail when not sent (rate-limit window, transport/Mandrill error, …). */
189
+ emailDetail?: string;
190
+ /**
191
+ * MAGIC-LINK token — present only when the request set `issueSignInToken:true`. A
192
+ * high-entropy, single-use, short-TTL secret bound to `(email, audience)`, for the
193
+ * storefront to embed in its own branded email's CTA (`/auth/magic?token=…`).
194
+ * Exchanged (POST-only, never GET) via {@link TotAccessClient.exchangeSignInToken}.
195
+ */
196
+ signInToken?: { token: string; expiresAt: string; audience: string; ttlSeconds?: number };
197
+ }
198
+
199
+ /** Typed failure reasons mapped from tot20's error codes / HTTP status. */
200
+ export type DeveloperInviteFailureReason =
201
+ | "notGranted" // 403 developerInvites:notGranted — no operator grant on tenant
202
+ | "notAuthenticated" // 403 operatorGrants:notAuthenticated — bad/absent operator cred
203
+ | "tenantRequired" // 422
204
+ | "invalidEmail" // 422
205
+ | "tenantNotFound" // 422
206
+ | "storeNotProvisioned" // 409 — LOCAL preflight block: the store repo isn't provisioned here
207
+ | "error"; // transport / unexpected upstream fault
208
+
209
+ export interface DeveloperInviteFailed {
210
+ ok: false;
211
+ /** Upstream HTTP status (0 for a transport failure before any response). */
212
+ status: number;
213
+ reason: DeveloperInviteFailureReason;
214
+ message: string;
215
+ /** tot20's machine-readable `code`, when present. */
216
+ code?: string;
217
+ }
218
+
219
+ export type DeveloperInviteResult = DeveloperInviteCreated | DeveloperInviteFailed;
220
+
221
+ /** One developer invite in a tenant's listing (the team member list). */
222
+ export interface DeveloperInviteSummary {
223
+ inviteId: string;
224
+ tenant: string;
225
+ developer: string;
226
+ roles: string[];
227
+ }
228
+
229
+ /** Result of listing a tenant's developer invites (typed failure, fail-closed). */
230
+ export type DeveloperInviteListResult =
231
+ | { ok: true; tenant: string; invites: DeveloperInviteSummary[] }
232
+ | {
233
+ ok: false;
234
+ status: number;
235
+ reason: DeveloperInviteFailureReason;
236
+ message: string;
237
+ code?: string;
238
+ };
239
+
240
+ /** Result of revoking a developer invite (typed failure, fail-closed). */
241
+ export type DeveloperInviteRevokeResult =
242
+ | { ok: true; tenant: string; inviteId: string }
243
+ | {
244
+ ok: false;
245
+ status: number;
246
+ reason: DeveloperInviteFailureReason;
247
+ message: string;
248
+ code?: string;
249
+ };
250
+
251
+ export interface DeveloperInviteClient {
252
+ /** Invite a developer email onto a tenant storefront operates. */
253
+ createInvite(input: DeveloperInviteInput): Promise<DeveloperInviteResult>;
254
+ /**
255
+ * List the developer invites (team members) on a tenant storefront operates.
256
+ * `brokerAssertion` (a storefront-signed staff proof) is presented to tot20 in
257
+ * the `X-ToT-Broker-Assertion` header; when it verifies to ToT staff server-side,
258
+ * the list is permitted on ANY tenant (#8453).
259
+ */
260
+ listInvites(
261
+ tenant: string,
262
+ brokerAssertion?: string,
263
+ ): Promise<DeveloperInviteListResult>;
264
+ /**
265
+ * Revoke one developer invite by its opaque `inviteId` (as returned by
266
+ * `createInvite`/`listInvites`). Idempotent on tot20's side (revoking an already-
267
+ * gone invite is a success no-op). Same `brokerAssertion` staff carve-out as
268
+ * `listInvites`.
269
+ */
270
+ revokeInvite(
271
+ inviteId: string,
272
+ brokerAssertion?: string,
273
+ ): Promise<DeveloperInviteRevokeResult>;
274
+ }
275
+
276
+ // --- HTTP transport seam (injected by tests) --------------------------------
277
+
278
+ export interface HttpResponseLike {
279
+ status: number;
280
+ json(): Promise<unknown>;
281
+ }
282
+ export interface HttpTransport {
283
+ post(
284
+ url: string,
285
+ headers: Record<string, string>,
286
+ body: unknown,
287
+ ): Promise<HttpResponseLike>;
288
+ /** Optional GET (used by listInvites). Optional so post-only test stubs still satisfy the seam. */
289
+ get?(url: string, headers: Record<string, string>): Promise<HttpResponseLike>;
290
+ /** Optional DELETE (used by revokeInvite). Optional so existing test stubs still satisfy the seam. */
291
+ delete?(url: string, headers: Record<string, string>): Promise<HttpResponseLike>;
292
+ }
293
+
294
+ /** Default transport: JSON `fetch` POST/GET/DELETE. */
295
+ export class FetchHttpTransport implements HttpTransport {
296
+ async post(url: string, headers: Record<string, string>, body: unknown) {
297
+ return fetch(url, {
298
+ method: "POST",
299
+ headers: {
300
+ "content-type": "application/json",
301
+ accept: "application/json",
302
+ ...headers,
303
+ },
304
+ body: JSON.stringify(body),
305
+ });
306
+ }
307
+ async get(url: string, headers: Record<string, string>) {
308
+ return fetch(url, {
309
+ method: "GET",
310
+ headers: { accept: "application/json", ...headers },
311
+ });
312
+ }
313
+ async delete(url: string, headers: Record<string, string>) {
314
+ return fetch(url, {
315
+ method: "DELETE",
316
+ headers: { accept: "application/json", ...headers },
317
+ });
318
+ }
319
+ }
320
+
321
+ export interface HttpDeveloperInviteClientConfig {
322
+ /** tot20 core API base (no trailing slash), e.g. https://qa.tokenoftrust.com */
323
+ baseUrl: string;
324
+ /** Storefront's OWN per-tier ToT credentials (machine auth to the operator API). */
325
+ apiKey: string;
326
+ secretKey?: string;
327
+ transport?: HttpTransport;
328
+ }
329
+
330
+ export class DeveloperInviteClientError extends Error {}
331
+
332
+ // --- HTTP impl --------------------------------------------------------------
333
+
334
+ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
335
+ private readonly baseUrl: string;
336
+ private readonly apiKey: string;
337
+ private readonly secretKey?: string;
338
+ private readonly transport: HttpTransport;
339
+
340
+ constructor(config: HttpDeveloperInviteClientConfig) {
341
+ if (!config.baseUrl) {
342
+ throw new DeveloperInviteClientError(
343
+ "developerInvites: TOT_CORE_URL is not configured",
344
+ );
345
+ }
346
+ if (!config.apiKey) {
347
+ throw new DeveloperInviteClientError(
348
+ "developerInvites: storefront ToT credentials are not configured",
349
+ );
350
+ }
351
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
352
+ this.apiKey = config.apiKey;
353
+ this.secretKey = config.secretKey;
354
+ this.transport = config.transport ?? new FetchHttpTransport();
355
+ }
356
+
357
+ /**
358
+ * The one integration-sensitive spot: tot20 reads `totApiKey`/`totSecretKey`
359
+ * from the request body (`normalizeSecureUserRequest`), so storefront's own
360
+ * credentials are merged into the POST body. Swap for a machine-JWT
361
+ * `Authorization: Bearer` here if tot20 ever requires it — the ONLY place to change.
362
+ */
363
+ private credentialFields(): Record<string, string> {
364
+ const f: Record<string, string> = { totApiKey: this.apiKey };
365
+ if (this.secretKey) f.totSecretKey = this.secretKey;
366
+ return f;
367
+ }
368
+
369
+ async createInvite(
370
+ input: DeveloperInviteInput,
371
+ ): Promise<DeveloperInviteResult> {
372
+ const body: Record<string, unknown> = {
373
+ tenant: input.tenant,
374
+ email: input.email.toLowerCase(),
375
+ ...this.credentialFields(),
376
+ };
377
+ if (input.sendEmail !== undefined) body.sendEmail = input.sendEmail;
378
+ // #8453: the requested role (tot20 honors it only for a VERIFIED staff actor;
379
+ // ignored for a plain operator, which stays developer-only).
380
+ if (input.role) body.role = input.role;
381
+ // Optional inviter note → shown in the branded invite email (tot20 HTML-escapes it).
382
+ if (input.note) body.note = input.note;
383
+ // Optional storefront sign-in URL → tot20 renders it as the email's CTA button
384
+ // (#8425). Harmless if tot20 doesn't yet consume it.
385
+ if (input.signInUrl) body.signInUrl = input.signInUrl;
386
+ // MAGIC-LINK mint: ask tot20 to return a single-use signInToken instead of sending
387
+ // its own template (the storefront sends its own branded email). Pair with sendEmail:false.
388
+ if (input.issueSignInToken) body.issueSignInToken = true;
389
+ if (input.signInTokenTtlSeconds != null) body.signInTokenTtlSeconds = input.signInTokenTtlSeconds;
390
+
391
+ // #8453: the staff actor is proven by the
392
+ // storefront-signed assertion in the header — NEVER a plaintext email field.
393
+ const headers: Record<string, string> = {};
394
+ if (input.brokerAssertion) headers[BROKER_ASSERTION_HEADER] = input.brokerAssertion;
395
+
396
+ // DIAGNOSTIC: what the storefront is about to send to tot20 (the request half of
397
+ // the email-send trace). `signInUrlHost` is the top allowlist suspect.
398
+ logInviteDiag("request", {
399
+ endpoint: `${this.baseUrl}/api/clients/developerInvites`,
400
+ tenant: input.tenant,
401
+ email: diagEmail(input.email.toLowerCase()),
402
+ sendEmail: input.sendEmail ?? "(unset → tot20 default)",
403
+ hasSignInUrl: !!input.signInUrl,
404
+ signInUrlHost: safeHost(input.signInUrl),
405
+ hasBrokerAssertion: !!input.brokerAssertion,
406
+ role: input.role ?? "(unset → developer)",
407
+ });
408
+
409
+ let res: HttpResponseLike;
410
+ try {
411
+ res = await this.transport.post(
412
+ `${this.baseUrl}/api/clients/developerInvites`,
413
+ headers,
414
+ body,
415
+ );
416
+ } catch (cause) {
417
+ return {
418
+ ok: false,
419
+ status: 0,
420
+ reason: "error",
421
+ message:
422
+ "developerInvites: transport failure calling tot20 " +
423
+ (cause instanceof Error ? cause.message : String(cause)),
424
+ };
425
+ }
426
+
427
+ let parsed: unknown;
428
+ try {
429
+ parsed = await res.json();
430
+ } catch {
431
+ return {
432
+ ok: false,
433
+ status: res.status,
434
+ reason: "error",
435
+ message: `developerInvites: tot20 returned a non-JSON body (status ${res.status})`,
436
+ };
437
+ }
438
+ const result = normalizeInviteResponse(res.status, parsed);
439
+ // DIAGNOSTIC: tot20's decision — the crux is `emailed`. `changed:false, emailed:false`
440
+ // means tot20 authorized + already had the role but did NOT send (the gate we're hunting).
441
+ logInviteDiag("response", {
442
+ tenant: input.tenant,
443
+ email: diagEmail(input.email.toLowerCase()),
444
+ httpStatus: res.status,
445
+ ok: result.ok,
446
+ ...(result.ok
447
+ ? {
448
+ changed: result.changed,
449
+ emailed: result.emailed,
450
+ emailReason: result.emailReason ?? "(none)",
451
+ emailDetail: result.emailDetail,
452
+ signInEligible: result.signInEligible,
453
+ }
454
+ : { reason: result.reason, code: result.code }),
455
+ });
456
+ return result;
457
+ }
458
+
459
+ async listInvites(
460
+ tenant: string,
461
+ brokerAssertion?: string,
462
+ ): Promise<DeveloperInviteListResult> {
463
+ if (!this.transport.get) {
464
+ return {
465
+ ok: false,
466
+ status: 0,
467
+ reason: "error",
468
+ message: "developerInvites: transport does not support GET",
469
+ };
470
+ }
471
+ // GET → creds go in the query string (tot20 reads totApiKey/totSecretKey from
472
+ // body OR query; a GET has no body). tenant is a normal query param.
473
+ const q = new URLSearchParams({ tenant, ...this.credentialFields() });
474
+ // #8453: the staff actor is proven by the signed
475
+ // assertion header — NEVER a plaintext email query param.
476
+ const headers: Record<string, string> = {};
477
+ if (brokerAssertion) headers[BROKER_ASSERTION_HEADER] = brokerAssertion;
478
+ let res: HttpResponseLike;
479
+ try {
480
+ res = await this.transport.get(
481
+ `${this.baseUrl}/api/clients/developerInvites?${q.toString()}`,
482
+ headers,
483
+ );
484
+ } catch (cause) {
485
+ return {
486
+ ok: false,
487
+ status: 0,
488
+ reason: "error",
489
+ message:
490
+ "developerInvites: transport failure listing invites " +
491
+ (cause instanceof Error ? cause.message : String(cause)),
492
+ };
493
+ }
494
+ let parsed: unknown;
495
+ try {
496
+ parsed = await res.json();
497
+ } catch {
498
+ return {
499
+ ok: false,
500
+ status: res.status,
501
+ reason: "error",
502
+ message: `developerInvites: tot20 returned a non-JSON body (status ${res.status})`,
503
+ };
504
+ }
505
+ return normalizeInvitesListResponse(res.status, tenant, parsed);
506
+ }
507
+
508
+ async revokeInvite(
509
+ inviteId: string,
510
+ brokerAssertion?: string,
511
+ ): Promise<DeveloperInviteRevokeResult> {
512
+ if (!this.transport.delete) {
513
+ return {
514
+ ok: false,
515
+ status: 0,
516
+ reason: "error",
517
+ message: "developerInvites: transport does not support DELETE",
518
+ };
519
+ }
520
+ // DELETE has no body — creds ride the query string, matching listInvites.
521
+ const q = new URLSearchParams(this.credentialFields());
522
+ const headers: Record<string, string> = {};
523
+ if (brokerAssertion) headers[BROKER_ASSERTION_HEADER] = brokerAssertion;
524
+ let res: HttpResponseLike;
525
+ try {
526
+ res = await this.transport.delete(
527
+ `${this.baseUrl}/api/clients/developerInvites/${encodeURIComponent(inviteId)}?${q.toString()}`,
528
+ headers,
529
+ );
530
+ } catch (cause) {
531
+ return {
532
+ ok: false,
533
+ status: 0,
534
+ reason: "error",
535
+ message:
536
+ "developerInvites: transport failure revoking invite " +
537
+ (cause instanceof Error ? cause.message : String(cause)),
538
+ };
539
+ }
540
+ let parsed: unknown;
541
+ try {
542
+ parsed = await res.json();
543
+ } catch {
544
+ return {
545
+ ok: false,
546
+ status: res.status,
547
+ reason: "error",
548
+ message: `developerInvites: tot20 returned a non-JSON body (status ${res.status})`,
549
+ };
550
+ }
551
+ return normalizeInviteRevokeResponse(res.status, parsed);
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Parse tot20's `{ content, metadata }` REVOKE response (`developerInvites:revokeOk`)
557
+ * into a typed result (fail closed). Same envelope-unwrap + error-mapping as
558
+ * {@link normalizeInviteResponse}.
559
+ */
560
+ export function normalizeInviteRevokeResponse(
561
+ status: number,
562
+ body: unknown,
563
+ ): DeveloperInviteRevokeResult {
564
+ const raw = (body ?? {}) as Record<string, unknown>;
565
+ const c = (
566
+ raw.content && typeof raw.content === "object" ? raw.content : raw
567
+ ) as Record<string, unknown>;
568
+
569
+ const code = typeof c.code === "string" ? c.code : undefined;
570
+ const isOk =
571
+ code === "developerInvites:revokeOk" || (status >= 200 && status < 300);
572
+
573
+ if (isOk) {
574
+ return {
575
+ ok: true,
576
+ tenant: String(c.tenant ?? ""),
577
+ inviteId: String(c.inviteId ?? ""),
578
+ };
579
+ }
580
+
581
+ const message =
582
+ typeof c.message === "string"
583
+ ? c.message
584
+ : typeof raw.message === "string"
585
+ ? raw.message
586
+ : undefined;
587
+ const reason = reasonFor(code, status);
588
+ return {
589
+ ok: false,
590
+ status,
591
+ reason,
592
+ message: message ?? defaultMessage(reason, status),
593
+ ...(code ? { code } : {}),
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Parse tot20's `{ content, metadata }` invite response into a typed result
599
+ * (fail closed). Success is `code === 'developerInvites:created'` (or an
600
+ * `inviteId` on a 2xx); every other shape maps to a typed failure by `code`,
601
+ * falling back to the HTTP status.
602
+ */
603
+ export function normalizeInviteResponse(
604
+ status: number,
605
+ body: unknown,
606
+ ): DeveloperInviteResult {
607
+ const raw = (body ?? {}) as Record<string, unknown>;
608
+ // Unwrap the swagger `{ content, metadata }` envelope when present; fall back
609
+ // to the raw body (the Mock + tests return the un-enveloped shape).
610
+ const c = (
611
+ raw.content && typeof raw.content === "object" ? raw.content : raw
612
+ ) as Record<string, unknown>;
613
+
614
+ const code = typeof c.code === "string" ? c.code : undefined;
615
+ const message =
616
+ typeof c.message === "string"
617
+ ? c.message
618
+ : typeof raw.message === "string"
619
+ ? raw.message
620
+ : undefined;
621
+
622
+ const isCreated =
623
+ code === "developerInvites:created" ||
624
+ (status >= 200 && status < 300 && typeof c.inviteId === "string");
625
+
626
+ if (isCreated) {
627
+ return {
628
+ ok: true,
629
+ inviteId: String(c.inviteId ?? ""),
630
+ tenant: String(c.tenant ?? ""),
631
+ developer: String(c.developer ?? ""),
632
+ roles: Array.isArray(c.roles)
633
+ ? c.roles.filter((r): r is string => typeof r === "string")
634
+ : ["developer"],
635
+ changed: c.changed === true,
636
+ emailed: c.emailed === true,
637
+ signInEligible: c.signInEligible === true,
638
+ ...(typeof c.capability === "string" ? { capability: c.capability } : {}),
639
+ ...(typeof c.emailReason === "string" ? { emailReason: c.emailReason } : {}),
640
+ ...(typeof c.emailDetail === "string" ? { emailDetail: c.emailDetail } : {}),
641
+ ...(isSignInToken(c.signInToken) ? { signInToken: c.signInToken } : {}),
642
+ };
643
+ }
644
+
645
+ const reason = reasonFor(code, status);
646
+ return {
647
+ ok: false,
648
+ status,
649
+ reason,
650
+ message: message ?? defaultMessage(reason, status),
651
+ ...(code ? { code } : {}),
652
+ };
653
+ }
654
+
655
+ /**
656
+ * Parse tot20's `{ content, metadata }` LIST response (`developerInvites:listOk`)
657
+ * into a typed result (fail closed). Same envelope-unwrap + error-mapping as
658
+ * {@link normalizeInviteResponse}.
659
+ */
660
+ export function normalizeInvitesListResponse(
661
+ status: number,
662
+ tenant: string,
663
+ body: unknown,
664
+ ): DeveloperInviteListResult {
665
+ const raw = (body ?? {}) as Record<string, unknown>;
666
+ const c = (
667
+ raw.content && typeof raw.content === "object" ? raw.content : raw
668
+ ) as Record<string, unknown>;
669
+
670
+ const code = typeof c.code === "string" ? c.code : undefined;
671
+ const isOk =
672
+ code === "developerInvites:listOk" ||
673
+ (status >= 200 && status < 300 && Array.isArray(c.invites));
674
+
675
+ if (isOk) {
676
+ const invites: DeveloperInviteSummary[] = (
677
+ Array.isArray(c.invites) ? c.invites : []
678
+ ).map((raw2) => {
679
+ const r = (raw2 ?? {}) as Record<string, unknown>;
680
+ return {
681
+ inviteId: String(r.inviteId ?? ""),
682
+ tenant: String(r.tenant ?? tenant),
683
+ developer: String(r.developer ?? ""),
684
+ roles: Array.isArray(r.roles)
685
+ ? r.roles.filter((x): x is string => typeof x === "string")
686
+ : [],
687
+ };
688
+ });
689
+ return { ok: true, tenant, invites };
690
+ }
691
+
692
+ const message =
693
+ typeof c.message === "string"
694
+ ? c.message
695
+ : typeof raw.message === "string"
696
+ ? raw.message
697
+ : undefined;
698
+ const reason = reasonFor(code, status);
699
+ return {
700
+ ok: false,
701
+ status,
702
+ reason,
703
+ message: message ?? defaultMessage(reason, status),
704
+ ...(code ? { code } : {}),
705
+ };
706
+ }
707
+
708
+ /** Map tot20's error `code` (authoritative) then the HTTP status onto a reason. */
709
+ function reasonFor(
710
+ code: string | undefined,
711
+ status: number,
712
+ ): DeveloperInviteFailureReason {
713
+ switch (code) {
714
+ case "developerInvites:notGranted":
715
+ return "notGranted";
716
+ case "operatorGrants:notAuthenticated":
717
+ return "notAuthenticated";
718
+ case "developerInvites:tenantRequired":
719
+ return "tenantRequired";
720
+ case "developerInvites:invalidEmail":
721
+ return "invalidEmail";
722
+ case "developerInvites:tenantNotFound":
723
+ return "tenantNotFound";
724
+ default:
725
+ // No recognised code — infer from status so a 403/422 still fails typed.
726
+ if (status === 403 || status === 401) return "notGranted";
727
+ return "error";
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Map a typed invite/list failure onto the HTTP status a storefront endpoint
733
+ * should return: notGranted/notAuthenticated → 403; the 422 validation reasons →
734
+ * 422; a transport/unexpected fault → the upstream status if it's an error, else
735
+ * 502 (bad gateway to tot20). Shared by every route that surfaces the client.
736
+ */
737
+ export function developerInviteHttpStatus(
738
+ reason: DeveloperInviteFailureReason,
739
+ upstreamStatus: number,
740
+ ): number {
741
+ switch (reason) {
742
+ case "notGranted":
743
+ case "notAuthenticated":
744
+ return 403;
745
+ case "tenantRequired":
746
+ case "invalidEmail":
747
+ case "tenantNotFound":
748
+ return 422;
749
+ default:
750
+ return upstreamStatus >= 400 ? upstreamStatus : 502;
751
+ }
752
+ }
753
+
754
+ function defaultMessage(
755
+ reason: DeveloperInviteFailureReason,
756
+ status: number,
757
+ ): string {
758
+ switch (reason) {
759
+ case "notGranted":
760
+ return "storefront isn't authorized to invite on this tenant";
761
+ case "notAuthenticated":
762
+ return "storefront's operator credential was rejected";
763
+ case "tenantRequired":
764
+ return "a target tenant is required";
765
+ case "invalidEmail":
766
+ return "a valid developer email is required";
767
+ case "tenantNotFound":
768
+ return "the target tenant is not a resolvable Token of Trust app";
769
+ default:
770
+ return `developerInvites: tot20 returned status ${status}`;
771
+ }
772
+ }
773
+
774
+ // --- Mock (tests + local dev) -----------------------------------------------
775
+
776
+ /**
777
+ * In-memory DeveloperInviteClient. Seeded with the set of tenants storefront is
778
+ * authorized to operate, it drives route + client tests deterministically and
779
+ * lets local dev exercise the flow before/without the live tot20 endpoint.
780
+ */
781
+ export class MockDeveloperInviteClient implements DeveloperInviteClient {
782
+ /** Tenants this operator holds a grant on (anything else → notGranted). */
783
+ private readonly granted: Set<string>;
784
+ /** Emails already holding the developer role per tenant (idempotency). */
785
+ private readonly existing = new Set<string>();
786
+ /** Recorded invites, for assertions. */
787
+ invited: DeveloperInviteInput[] = [];
788
+
789
+ constructor(grantedTenants: string[] = []) {
790
+ this.granted = new Set(grantedTenants.map((t) => t.toLowerCase()));
791
+ }
792
+
793
+ async createInvite(
794
+ input: DeveloperInviteInput,
795
+ ): Promise<DeveloperInviteResult> {
796
+ const tenant = (input.tenant ?? "").trim().toLowerCase();
797
+ const email = (input.email ?? "").trim().toLowerCase();
798
+ if (!tenant) {
799
+ return {
800
+ ok: false,
801
+ status: 422,
802
+ reason: "tenantRequired",
803
+ message: "a target tenant is required",
804
+ };
805
+ }
806
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
807
+ return {
808
+ ok: false,
809
+ status: 422,
810
+ reason: "invalidEmail",
811
+ message: "a valid developer email is required",
812
+ };
813
+ }
814
+ // #8453: a broker assertion stands in for a
815
+ // VERIFIED ToT-STAFF actor (tot20 verifies + derives staff server-side); staff
816
+ // bypass the operator-grant check on ANY tenant. Without an assertion the legacy
817
+ // granted-set behavior applies. (The Mock treats presence as proof; the real
818
+ // tot20 verifies the signature.)
819
+ const staffActor = !!(input.brokerAssertion && input.brokerAssertion.trim());
820
+ if (!staffActor && !this.granted.has(tenant)) {
821
+ return {
822
+ ok: false,
823
+ status: 403,
824
+ reason: "notGranted",
825
+ message: "storefront isn't authorized to invite on this tenant",
826
+ code: "developerInvites:notGranted",
827
+ };
828
+ }
829
+ this.invited.push({
830
+ tenant,
831
+ email,
832
+ sendEmail: input.sendEmail,
833
+ ...(input.brokerAssertion ? { brokerAssertion: input.brokerAssertion } : {}),
834
+ ...(input.role ? { role: input.role } : {}),
835
+ ...(input.signInUrl ? { signInUrl: input.signInUrl } : {}),
836
+ });
837
+ const key = `${tenant} ${email}`;
838
+ const changed = !this.existing.has(key);
839
+ this.existing.add(key);
840
+ // A staff actor may write any role (tot20 honors it). A plain operator is
841
+ // developer-only, with ONE carve-out: it may deliberately request `contributor`
842
+ // (a strictly-weaker self-downgrade, honored directly — mirrors tot20's
843
+ // `DeveloperInviteManager._resolveInviteRole`). Every other non-staff role
844
+ // request is ignored (stays developer).
845
+ const effectiveRole = staffActor
846
+ ? input.role || "developer"
847
+ : input.role === CONTRIBUTOR_ROLE
848
+ ? CONTRIBUTOR_ROLE
849
+ : "developer";
850
+ return {
851
+ ok: true,
852
+ inviteId: Buffer.from(`${tenant}|${email}`, "utf8").toString("base64url"),
853
+ tenant,
854
+ developer: email,
855
+ roles: [effectiveRole],
856
+ changed,
857
+ // Magic-link mint: no tot20 email when a token was requested (storefront sends its own).
858
+ emailed: input.issueSignInToken ? false : input.sendEmail !== false,
859
+ ...(input.issueSignInToken ? { emailReason: "tokenIssued" as const } : {}),
860
+ signInEligible: true,
861
+ capability: "developer",
862
+ ...(input.issueSignInToken
863
+ ? {
864
+ signInToken: {
865
+ token: Buffer.from(`mock|${tenant}|${email}`, "utf8").toString("base64url"),
866
+ expiresAt: "2099-01-01T00:00:00.000Z",
867
+ audience: tenant,
868
+ ttlSeconds: input.signInTokenTtlSeconds ?? 1800,
869
+ },
870
+ }
871
+ : {}),
872
+ };
873
+ }
874
+
875
+ async listInvites(
876
+ tenant: string,
877
+ brokerAssertion?: string,
878
+ ): Promise<DeveloperInviteListResult> {
879
+ const t = (tenant ?? "").trim().toLowerCase();
880
+ if (!t) {
881
+ return { ok: false, status: 422, reason: "tenantRequired", message: "a target tenant is required" };
882
+ }
883
+ // #8453: a staff broker assertion bypasses the operator-grant on ANY tenant.
884
+ const staffActor = !!(brokerAssertion && brokerAssertion.trim());
885
+ if (!staffActor && !this.granted.has(t)) {
886
+ return {
887
+ ok: false,
888
+ status: 403,
889
+ reason: "notGranted",
890
+ message: "storefront isn't authorized to list invites on this tenant",
891
+ code: "developerInvites:notGranted",
892
+ };
893
+ }
894
+ const invites: DeveloperInviteSummary[] = [...this.existing]
895
+ .filter((k) => k.startsWith(`${t} `))
896
+ .map((k) => {
897
+ const email = k.slice(t.length + 1);
898
+ return {
899
+ inviteId: Buffer.from(`${t}|${email}`, "utf8").toString("base64url"),
900
+ tenant: t,
901
+ developer: email,
902
+ roles: ["developer"],
903
+ };
904
+ });
905
+ return { ok: true, tenant: t, invites };
906
+ }
907
+
908
+ async revokeInvite(
909
+ inviteId: string,
910
+ brokerAssertion?: string,
911
+ ): Promise<DeveloperInviteRevokeResult> {
912
+ let decoded: { tenant: string; email: string } | null = null;
913
+ try {
914
+ const raw = Buffer.from(inviteId.replace(/-/g, "+").replace(/_/g, "/"), "base64url").toString("utf8");
915
+ const idx = raw.indexOf("|");
916
+ if (idx > 0) decoded = { tenant: raw.slice(0, idx), email: raw.slice(idx + 1) };
917
+ } catch {
918
+ decoded = null;
919
+ }
920
+ if (!decoded) {
921
+ return { ok: false, status: 400, reason: "error", message: "malformed inviteId" };
922
+ }
923
+ const staffActor = !!(brokerAssertion && brokerAssertion.trim());
924
+ if (!staffActor && !this.granted.has(decoded.tenant)) {
925
+ return {
926
+ ok: false,
927
+ status: 403,
928
+ reason: "notGranted",
929
+ message: "storefront isn't authorized to revoke on this tenant",
930
+ code: "developerInvites:notGranted",
931
+ };
932
+ }
933
+ this.existing.delete(`${decoded.tenant} ${decoded.email}`);
934
+ return { ok: true, tenant: decoded.tenant, inviteId };
935
+ }
936
+ }
937
+
938
+ // --- Factory ----------------------------------------------------------------
939
+
940
+ /**
941
+ * Build the env-configured client:
942
+ * - `TOT_CORE_URL` — tot20 core API base (tier-matching per deploy).
943
+ * - `TOT_API_KEY` (+ optional `TOT_SECRET_KEY`) — storefront's own operator creds.
944
+ * Throws {@link DeveloperInviteClientError} when unconfigured (the route maps that to 503).
945
+ */
946
+ export async function createDeveloperInviteClient(
947
+ overrides: Partial<HttpDeveloperInviteClientConfig> = {},
948
+ ): Promise<HttpDeveloperInviteClient> {
949
+ const baseUrl = overrides.baseUrl ?? (await readEnv("TOT_CORE_URL")) ?? "";
950
+ const apiKey = overrides.apiKey ?? (await readEnv("TOT_API_KEY")) ?? "";
951
+ const secretKey = overrides.secretKey ?? (await readEnv("TOT_SECRET_KEY"));
952
+ return new HttpDeveloperInviteClient({
953
+ ...overrides,
954
+ baseUrl,
955
+ apiKey,
956
+ secretKey,
957
+ });
958
+ }
959
+
960
+ // --- Option B app-binding seam (#8425) --------------------------------------
961
+
962
+ /** What to record: the developer's subject-capability app-binding in the MCP. */
963
+ export interface DeveloperAppBinding {
964
+ /** Subject — the developer email (the MCP subject-capability store key). */
965
+ email: string;
966
+ /** Tenant the capability is scoped to. */
967
+ tenant: string;
968
+ /**
969
+ * The capability literal to write. Omitted → `"preview-access"` (today's default
970
+ * for every plain developer invite). A `contributor` team-role invite writes
971
+ * `"blog-contribute"` here (see {@link inviteDeveloperToTenant}) so
972
+ * `resolveViewerContributeCapability` (`@/lib/change/route/viewer-ship-grant`) sees a real,
973
+ * live, owner-revocable grant over the SAME subject-capability store it reads.
974
+ */
975
+ capability?: "preview-access" | "blog-contribute" | "ship-on-behalf";
976
+ }
977
+
978
+ /** Best-effort outcome of the app-binding write (never fails the invite). */
979
+ export type AppBindingResult =
980
+ | { recorded: true }
981
+ // Skipped fail-closed: the write was not attempted/counted because the seam is
982
+ // unconfigured OR the MCP route isn't mounted yet (401). Not an error.
983
+ | { recorded: false; skipped: true; reason: string }
984
+ // Attempted but failed for a real reason (bad body / store fault / transport).
985
+ | { recorded: false; skipped: false; reason: string };
986
+
987
+ /**
988
+ * Record the developer's Option-B subject-capability app-binding in the MCP: the
989
+ * MCP store is the app-binding source of truth; the writer is this keyless flow.
990
+ *
991
+ * WIRED (confirmed contract):
992
+ * POST `${MCP_APP_BINDING_URL}` — the keyless subject-capability writer,
993
+ * e.g. https://mcp.qa.tokenoftrust.com/api/internal/dev-access/subject-capability
994
+ * Auth: header `x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>` (compared
995
+ * constant-time to the MCP's shared secret) — NOT an Authorization bearer.
996
+ * Body: { subject:<email>, tenant, capability:'preview-access', app:'storefront' }
997
+ * (capability/app are optional MCP defaults but sent explicitly). Idempotent.
998
+ * → 200 { status:'recorded', ... } | 401 bad/missing secret | 400 bad body | 502 store.
999
+ *
1000
+ * FAIL-CLOSED: the MCP route is only mounted once the shared secret is set on the
1001
+ * MCP deploy, so an unconfigured seam (no URL/secret) OR a 401 is treated as
1002
+ * `{ skipped: true }`, not a hard failure. A 400/502/transport fault is a real
1003
+ * (non-skipped) failure the caller surfaces as partial success.
1004
+ *
1005
+ * BEST-EFFORT by contract: this NEVER throws and NEVER blocks the invite. The
1006
+ * developer's tenant eligibility (the invite) is already written by tot20 regardless.
1007
+ */
1008
+ export async function recordDeveloperAppBinding(
1009
+ binding: DeveloperAppBinding,
1010
+ deps: { transport?: HttpTransport } = {},
1011
+ ): Promise<AppBindingResult> {
1012
+ const url = await readEnv("MCP_APP_BINDING_URL");
1013
+ const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
1014
+ if (!url || !secret) {
1015
+ // Seam not fully configured (the MCP route isn't mounted until the shared
1016
+ // secret exists). Skip cleanly rather than fail the invite.
1017
+ return { recorded: false, skipped: true, reason: "not-configured" };
1018
+ }
1019
+ const transport = deps.transport ?? new FetchHttpTransport();
1020
+ try {
1021
+ const res = await transport.post(
1022
+ url,
1023
+ { "x-mcp-service-secret": secret },
1024
+ {
1025
+ // Option B fixed fields — the subject-capability binding for storefront.
1026
+ subject: binding.email.toLowerCase(),
1027
+ tenant: binding.tenant,
1028
+ capability: binding.capability ?? "preview-access",
1029
+ app: "storefront",
1030
+ },
1031
+ );
1032
+ if (res.status >= 200 && res.status < 300) return { recorded: true };
1033
+ if (res.status === 401) {
1034
+ // Bad/missing secret or the MCP route isn't mounted yet — fail-closed as skipped.
1035
+ return {
1036
+ recorded: false,
1037
+ skipped: true,
1038
+ reason: "unauthorized (mcp service secret rejected or route unmounted)",
1039
+ };
1040
+ }
1041
+ return {
1042
+ recorded: false,
1043
+ skipped: false,
1044
+ reason: `mcp app-binding write returned status ${res.status}`,
1045
+ };
1046
+ } catch (cause) {
1047
+ return {
1048
+ recorded: false,
1049
+ skipped: false,
1050
+ reason:
1051
+ "mcp app-binding write failed: " +
1052
+ (cause instanceof Error ? cause.message : String(cause)),
1053
+ };
1054
+ }
1055
+ }
1056
+
1057
+ // --- Accept-before-sign-in seam (#8425) -------------------------------------
1058
+
1059
+ /** Best-effort outcome of marking a developer invite accepted (never fails sign-in). */
1060
+ export type AcceptInviteResult =
1061
+ | { accepted: true; changed: boolean }
1062
+ // Not attempted (seam unconfigured) or no invite to accept — not a hard failure.
1063
+ | { accepted: false; skipped: true; reason: string }
1064
+ // Attempted but failed for a real reason (auth / store fault / transport).
1065
+ | { accepted: false; skipped: false; reason: string };
1066
+
1067
+ /**
1068
+ * Mark a developer invite ACCEPTED at the magic-link confirm (#8425 accept-before-
1069
+ * sign-in). Called server-to-server from the magic-exchange handler the moment the
1070
+ * invitee affirmatively confirms — so the tot20 invite roster advances pending →
1071
+ * accepted as the developer signs in. This confers NO grant (tot20's createInvite
1072
+ * already wrote the role); it only advances the visible status, so it is authed by
1073
+ * the storefront's OWN operator creds (mirrors {@link HttpDeveloperInviteClient})
1074
+ * and tot20 scopes it to an invite THIS app issued.
1075
+ *
1076
+ * BEST-EFFORT by contract: NEVER throws, NEVER blocks sign-in. A non-invitee magic
1077
+ * sign-in (no pending invite) simply comes back skipped/404; an already-accepted
1078
+ * invite is an idempotent no-op (changed:false).
1079
+ */
1080
+ export async function acceptDeveloperInvite(
1081
+ tenant: string,
1082
+ email: string,
1083
+ deps: { transport?: HttpTransport } = {},
1084
+ ): Promise<AcceptInviteResult> {
1085
+ const t = (tenant ?? "").trim().toLowerCase();
1086
+ const e = (email ?? "").trim().toLowerCase();
1087
+ if (!t || !e) {
1088
+ return { accepted: false, skipped: true, reason: "no-tenant-or-email" };
1089
+ }
1090
+ const baseUrl = ((await readEnv("TOT_CORE_URL")) ?? "").replace(/\/+$/, "");
1091
+ const apiKey = (await readEnv("TOT_API_KEY")) ?? "";
1092
+ if (!baseUrl || !apiKey) {
1093
+ return { accepted: false, skipped: true, reason: "not-configured" };
1094
+ }
1095
+ const secretKey = await readEnv("TOT_SECRET_KEY");
1096
+ const body: Record<string, string> = { tenant: t, email: e, totApiKey: apiKey };
1097
+ if (secretKey) body.totSecretKey = secretKey;
1098
+ const transport = deps.transport ?? new FetchHttpTransport();
1099
+ try {
1100
+ const res = await transport.post(
1101
+ `${baseUrl}/api/clients/developerInvites/accept`,
1102
+ {},
1103
+ body,
1104
+ );
1105
+ if (res.status >= 200 && res.status < 300) {
1106
+ let changed = false;
1107
+ try {
1108
+ const parsed = (await res.json()) as Record<string, unknown>;
1109
+ const c = (parsed?.content && typeof parsed.content === "object"
1110
+ ? parsed.content
1111
+ : parsed) as Record<string, unknown>;
1112
+ changed = c.changed === true;
1113
+ } catch {
1114
+ /* a 2xx with an unparseable body is still an accept success */
1115
+ }
1116
+ return { accepted: true, changed };
1117
+ }
1118
+ // 404 = no pending invite for this email (a non-invitee magic sign-in) — skip cleanly.
1119
+ if (res.status === 404) {
1120
+ return { accepted: false, skipped: true, reason: "no-invite-for-email" };
1121
+ }
1122
+ return {
1123
+ accepted: false,
1124
+ skipped: false,
1125
+ reason: `tot20 accept returned status ${res.status}`,
1126
+ };
1127
+ } catch (cause) {
1128
+ return {
1129
+ accepted: false,
1130
+ skipped: false,
1131
+ reason:
1132
+ "developerInvites accept transport failure: " +
1133
+ (cause instanceof Error ? cause.message : String(cause)),
1134
+ };
1135
+ }
1136
+ }
1137
+
1138
+ // --- Canonical "grant a developer access to a tenant" method -----------------
1139
+
1140
+ /** Outcome of the canonical invite: the tot20 result + the best-effort binding. */
1141
+ export interface InviteDeveloperOutcome {
1142
+ /** The tot20 developer-invite result (ok or a typed failure). */
1143
+ invite: DeveloperInviteResult;
1144
+ /**
1145
+ * The Option-B app-binding outcome. Only attempted when the invite SUCCEEDED
1146
+ * (a failed invite has nothing to bind); undefined otherwise.
1147
+ */
1148
+ appBinding?: AppBindingResult;
1149
+ }
1150
+
1151
+ /** Injectable collaborators (tests); both default to the env-configured impls. */
1152
+ export interface InviteDeveloperDeps {
1153
+ /** The tot20 invite client. Defaults to {@link createDeveloperInviteClient}. */
1154
+ client?: DeveloperInviteClient;
1155
+ /** The Option-B recorder. Defaults to {@link recordDeveloperAppBinding}. */
1156
+ recordAppBinding?: (
1157
+ binding: DeveloperAppBinding,
1158
+ ) => Promise<AppBindingResult>;
1159
+ /**
1160
+ * The store-provisioned preflight. Defaults to {@link checkStorePreviewReady}.
1161
+ * Runs BEFORE the invite: a definitive not-provisioned answer BLOCKS the send;
1162
+ * any inconclusive answer fails open (see invitePreflight).
1163
+ */
1164
+ preflight?: (tenant: string) => Promise<PreflightDecision>;
1165
+ }
1166
+
1167
+ /**
1168
+ * THE canonical, single-source-of-truth method for "grant a developer (email)
1169
+ * access to a tenant" (#8425). Every storefront surface that gives a developer
1170
+ * EMAIL access/eligibility on a tenant routes through here: it invites via tot20's
1171
+ * operator endpoint AND records the Option-B app-binding (best-effort), so the two
1172
+ * halves of "developer has access" never drift between call sites.
1173
+ *
1174
+ * Keyed on EMAIL — the identifier tot20 eligibility joins on. A caller holding a
1175
+ * different identifier (e.g. a forge `principal`/appDomain) must resolve the email
1176
+ * before calling; see the DEV-ACCESS note below for why the repo-grant
1177
+ * fan-out is a SEPARATE axis and does NOT route through here.
1178
+ *
1179
+ * The app-binding is attempted only on a successful invite and NEVER changes the
1180
+ * invite's own success/failure — partial success is reported via the returned
1181
+ * `appBinding`, not by throwing.
1182
+ *
1183
+ * DEV-ACCESS (fan-out) — why the team/repo fan-out does not call this:
1184
+ * `TeamGrantService.grantTeam`'s tenant-tier step (`forge.grantDeveloperAccess`)
1185
+ * is the FORGE repo-collaboration PREREQUISITE (`grantRepoAccess` is refused
1186
+ * without it — enforced by MockForgeClient and, per its comment, the real MCP).
1187
+ * That grant lives on the MCP/forge backend and is keyed on `principal`/appDomain;
1188
+ * this invite writes tenant ELIGIBILITY on the tot20-core backend keyed on email.
1189
+ * Routing the fan-out through here would leave the forge precondition unmet (repo
1190
+ * grants would fail) AND has no email to supply — a genuinely different axis.
1191
+ */
1192
+ export async function inviteDeveloperToTenant(
1193
+ input: DeveloperInviteInput,
1194
+ deps: InviteDeveloperDeps = {},
1195
+ ): Promise<InviteDeveloperOutcome> {
1196
+ // Preflight: BLOCK the send when the target store's repo isn't provisioned on this
1197
+ // environment, so the error surfaces to the INVITER now instead of dead-ending in
1198
+ // the invitee's terminal later. Every role that leads to a `tot start`/checkout
1199
+ // (developer | appAdmin | appOwner) routes through here, so gating here covers all
1200
+ // of them. Fail-open (see invitePreflight): only a definitive not-provisioned
1201
+ // answer blocks; an unconfigured/errored/timed-out seam allows.
1202
+ const preflight = deps.preflight ?? checkStorePreviewReady;
1203
+ const ready = await preflight(input.tenant);
1204
+ if (ready.block) {
1205
+ console.warn(
1206
+ `[developerInvites] invite BLOCKED by preflight for ${input.tenant}: ${ready.reason}`,
1207
+ );
1208
+ return {
1209
+ invite: {
1210
+ ok: false,
1211
+ status: 409,
1212
+ reason: "storeNotProvisioned",
1213
+ message: ready.message,
1214
+ code: "store-not-provisioned",
1215
+ },
1216
+ };
1217
+ }
1218
+
1219
+ const client = deps.client ?? (await createDeveloperInviteClient());
1220
+ const invite = await client.createInvite({
1221
+ tenant: input.tenant,
1222
+ email: input.email,
1223
+ sendEmail: input.sendEmail,
1224
+ ...(input.brokerAssertion ? { brokerAssertion: input.brokerAssertion } : {}),
1225
+ ...(input.role ? { role: input.role } : {}),
1226
+ ...(input.note ? { note: input.note } : {}),
1227
+ ...(input.signInUrl ? { signInUrl: input.signInUrl } : {}),
1228
+ ...(input.issueSignInToken ? { issueSignInToken: true } : {}),
1229
+ ...(input.signInTokenTtlSeconds != null ? { signInTokenTtlSeconds: input.signInTokenTtlSeconds } : {}),
1230
+ });
1231
+ if (!invite.ok) return { invite };
1232
+
1233
+ // A `contributor` team-role invite ALSO writes the `blog-contribute` capability
1234
+ // into the SAME MCP subject-capability store `resolveViewerContributeCapability`
1235
+ // reads. Every other role keeps
1236
+ // today's default (`preview-access`). tot20's own role write (above) already
1237
+ // happened regardless — this is the best-effort second half of "the developer has
1238
+ // contributor access", exactly like the existing preview-access binding.
1239
+ const capability = invite.roles.includes(CONTRIBUTOR_ROLE)
1240
+ ? "blog-contribute"
1241
+ : "preview-access";
1242
+
1243
+ const record = deps.recordAppBinding ?? recordDeveloperAppBinding;
1244
+ const appBinding = await record({
1245
+ email: invite.developer,
1246
+ tenant: invite.tenant,
1247
+ capability,
1248
+ });
1249
+ return { invite, appBinding };
1250
+ }