@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.
- package/apps/storefront/.astro/content.d.ts +159 -0
- package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +1 -0
- package/apps/storefront/.astro/types.d.ts +3 -0
- package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
- package/apps/storefront/node_modules/.bin/acorn +43 -0
- package/apps/storefront/node_modules/.bin/astro +43 -0
- package/apps/storefront/node_modules/.bin/astro-check +43 -0
- package/apps/storefront/node_modules/.bin/browserslist +43 -0
- package/apps/storefront/node_modules/.bin/cf-wrangler +43 -0
- package/apps/storefront/node_modules/.bin/depcruise +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-baseline +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-fmt +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +43 -0
- package/apps/storefront/node_modules/.bin/dependency-cruise +43 -0
- package/apps/storefront/node_modules/.bin/dependency-cruiser +43 -0
- package/apps/storefront/node_modules/.bin/drizzle-kit +43 -0
- package/apps/storefront/node_modules/.bin/esbuild +43 -0
- package/apps/storefront/node_modules/.bin/jiti +43 -0
- package/apps/storefront/node_modules/.bin/playwright +43 -0
- package/apps/storefront/node_modules/.bin/playwright-core +43 -0
- package/apps/storefront/node_modules/.bin/prettier +43 -0
- package/apps/storefront/node_modules/.bin/rollup +43 -0
- package/apps/storefront/node_modules/.bin/tsc +43 -0
- package/apps/storefront/node_modules/.bin/tsserver +43 -0
- package/apps/storefront/node_modules/.bin/tsx +43 -0
- package/apps/storefront/node_modules/.bin/vite +43 -0
- package/apps/storefront/node_modules/.bin/vitest +43 -0
- package/apps/storefront/node_modules/.bin/workerd +43 -0
- package/apps/storefront/node_modules/.bin/wrangler +43 -0
- package/apps/storefront/node_modules/.bin/wrangler2 +43 -0
- package/apps/storefront/node_modules/.bin/yaml +43 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +58 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +741 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +2549 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/_metadata.json +150 -0
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +434 -0
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +2705 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +1535 -0
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +48 -0
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +42 -0
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/package.json +3 -0
- package/apps/storefront/node_modules/.vite/deps/preact.js +310 -0
- package/apps/storefront/node_modules/.vite/deps/preact.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js +420 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +4 -0
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js +236 -0
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +12 -0
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +192 -0
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +3 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +92 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +59 -0
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +24 -0
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +189 -0
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +13 -0
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +458 -0
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +1016 -0
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +32 -0
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +113 -0
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +8 -0
- package/apps/storefront/node_modules/.vite/deps_ssr/package.json +3 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
- package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
- package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
- package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
- package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
- package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
- package/apps/storefront/src/layouts/Layout.astro +65 -7
- package/apps/storefront/src/lib/apps/registryAccess.ts +45 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +4 -4
- package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
- package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
- package/apps/storefront/src/lib/rawChrome.ts +73 -14
- package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
- package/apps/storefront/src/lib/shared/capability.ts +68 -0
- package/apps/storefront/src/lib/shared/developerInvites.ts +1250 -0
- package/apps/storefront/src/lib/shared/invitePreflight.ts +123 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
- package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
- package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
- package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
- package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
- package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
- package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
- package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
- package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
- package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
- package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
- package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
- package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +38 -4
- package/apps/storefront/src/lib/webhooks/emit.ts +1 -1
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +1 -1
- package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -1
- package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
- package/apps/storefront/src/pages/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +1 -1
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
- package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
- package/package.json +1 -1
- package/packages/public-runtime/node_modules/.bin/tsc +43 -0
- package/packages/public-runtime/node_modules/.bin/tsserver +43 -0
- package/packages/public-runtime/node_modules/.bin/vitest +43 -0
- package/packages/public-runtime/src/chrome.ts +18 -7
- package/packages/public-runtime/src/index.ts +10 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
- package/packages/public-runtime/src/interaction-tracking.ts +364 -0
- package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
- package/pnpm-workspace.yaml +3 -0
- package/scripts/tenant/validate.mjs +306 -1
- package/scripts/tot-dev.mjs +59 -3
- package/tenants/home/public/js/storefront.js +6 -87
- package/apps/storefront/src/lib/apps/adminService.ts +0 -116
- package/apps/storefront/src/lib/apps/adminSession.ts +0 -214
- package/apps/storefront/src/lib/apps/apiAuth.ts +0 -91
- package/apps/storefront/src/lib/apps/apiRoute.ts +0 -35
- package/apps/storefront/src/lib/apps/catalogMapper.ts +0 -39
- package/apps/storefront/src/lib/apps/consentConfigStore.ts +0 -228
- package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +0 -447
- package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +0 -367
- package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +0 -315
- package/apps/storefront/src/lib/apps/healthAggregate.ts +0 -66
- package/apps/storefront/src/lib/apps/integrationLifecycle.ts +0 -614
- package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +0 -125
- package/apps/storefront/src/lib/apps/integrationReadModel.ts +0 -563
- package/apps/storefront/src/lib/apps/integrationsTab.ts +0 -547
- package/apps/storefront/src/lib/apps/integrationsView.ts +0 -106
- package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +0 -599
- package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +0 -170
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +0 -121
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +0 -148
- package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +0 -16
- package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +0 -105
- package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +0 -177
- package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +0 -251
- package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +0 -241
- package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +0 -211
- package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +0 -409
- package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +0 -368
- package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +0 -188
- package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +0 -291
- package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +0 -95
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +0 -91
- package/apps/storefront/src/lib/content-edit/client.ts +0 -286
- package/apps/storefront/src/lib/content-edit/index.ts +0 -14
- package/apps/storefront/src/lib/content-edit/route.ts +0 -25
- package/apps/storefront/src/lib/content-edit/types.ts +0 -19
- 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
|
+
}
|