create-ailk 0.18.0 → 0.19.1
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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +11 -3
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +19 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +218 -16
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +135 -14
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consent routes (#8438, spec
|
|
3
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md D5, D8, D9).
|
|
4
|
+
*
|
|
5
|
+
* cookie.ts POST /v1/consent/cookie — the banner's log
|
|
6
|
+
* unsubscribe.ts POST /v1/consent/unsubscribe — a signed link's landing
|
|
7
|
+
* platform.ts GET/POST /v1/consent/platform — the Admin owner's
|
|
8
|
+
* consents to Working Theory (multi-tenant module only)
|
|
9
|
+
*
|
|
10
|
+
* The first two are the never-gated privacy floor: registered at the top
|
|
11
|
+
* level, each in its OWN rate-limited scope (`consent`, `unsubscribe`) so
|
|
12
|
+
* filling one never blocks the other, and both on the forwarded-visitor
|
|
13
|
+
* route list (`middleware/rate-limit.ts`). This barrel re-exports exactly
|
|
14
|
+
* those two; the platform routes belong to the multi-tenant slice and are
|
|
15
|
+
* loaded only through `server.ts`'s slice table.
|
|
16
|
+
*/
|
|
17
|
+
export { consentCookieRoute } from "./cookie.js";
|
|
18
|
+
export { consentUnsubscribeRoute } from "./unsubscribe.js";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one unsubscribe landing every email link will call (#8438, spec
|
|
3
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md D9, SEC-8438-9).
|
|
4
|
+
*
|
|
5
|
+
* POST /v1/consent/unsubscribe
|
|
6
|
+
* `{ token }` — a signed token minted by `consentUnsubscribeToken`.
|
|
7
|
+
* Withdraws the communications purpose it names for the Person it
|
|
8
|
+
* names, source `email_unsubscribe`, unless the Person's own and
|
|
9
|
+
* any-source readings both already say "not granted". Answers `200 { purpose: { id, description },
|
|
10
|
+
* changed }`; a second click is `changed: false` with nothing written.
|
|
11
|
+
*
|
|
12
|
+
* The token is the only authority: it names no email, and a forged one fails
|
|
13
|
+
* its HMAC. Any defect — a flipped character, another workspace's Person, a
|
|
14
|
+
* purpose that is not a communications consent or preference, a token older
|
|
15
|
+
* than 400 days, a malformed body — is ONE uniform `400 invalid_link`. With
|
|
16
|
+
* `AILK_CONSENT_LINK_SECRET` unset or under 32 bytes the route answers `503
|
|
17
|
+
* link_secret_not_configured` (the 503/400 split tells an outsider only
|
|
18
|
+
* whether the feature is configured, which the absence of links in mail
|
|
19
|
+
* already shows — accepted in SEC-8438-9).
|
|
20
|
+
*
|
|
21
|
+
* Registered at the top level in its own rate-limited scope (`unsubscribe`),
|
|
22
|
+
* outside every module gate, beside the cookie route. The page that calls it
|
|
23
|
+
* (apps/web `/unsubscribe`) performs no write on GET, so a mail scanner's
|
|
24
|
+
* prefetch changes nothing.
|
|
25
|
+
*
|
|
26
|
+
* Logs carry `{ event, outcome }` only: never a token, an id or a statement.
|
|
27
|
+
*
|
|
28
|
+
* MCP parity note: no MCP twin — the route is a capability link's own
|
|
29
|
+
* landing, the `/v1/deliverables/:grantId` footing (tests/parity/README.md).
|
|
30
|
+
*/
|
|
31
|
+
import {
|
|
32
|
+
consentPurposeById,
|
|
33
|
+
consentPurposeKind,
|
|
34
|
+
consentUnsubscribeBodySchema,
|
|
35
|
+
resolveLocalizedString,
|
|
36
|
+
} from "@working-theory/validation";
|
|
37
|
+
import type { FastifyPluginAsync } from "fastify";
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
consentConfigForSite,
|
|
41
|
+
consentLinkSecretConfigured,
|
|
42
|
+
consentPersonExists,
|
|
43
|
+
recordPersonConsent,
|
|
44
|
+
verifyConsentUnsubscribeToken,
|
|
45
|
+
} from "../../services/consent.js";
|
|
46
|
+
|
|
47
|
+
const EVENT = "consent-unsubscribe";
|
|
48
|
+
|
|
49
|
+
const INVALID_LINK = {
|
|
50
|
+
error: "invalid_link",
|
|
51
|
+
message: "This link isn't valid.",
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
export const consentUnsubscribeRoute: FastifyPluginAsync = async (fastify) => {
|
|
55
|
+
fastify.post("/v1/consent/unsubscribe", async (request, reply) => {
|
|
56
|
+
if (!consentLinkSecretConfigured()) {
|
|
57
|
+
request.log.warn(
|
|
58
|
+
{ event: EVENT, outcome: "secret_missing" },
|
|
59
|
+
"[consent] refused",
|
|
60
|
+
);
|
|
61
|
+
return reply.status(503).send({
|
|
62
|
+
error: "link_secret_not_configured",
|
|
63
|
+
message: "Unsubscribing isn't available on this site.",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const refuse = (outcome: string) => {
|
|
67
|
+
request.log.warn({ event: EVENT, outcome }, "[consent] refused");
|
|
68
|
+
return reply.status(400).send(INVALID_LINK);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const body = consentUnsubscribeBodySchema.safeParse(request.body);
|
|
72
|
+
if (!body.success) return refuse("invalid_body");
|
|
73
|
+
const claims = verifyConsentUnsubscribeToken(body.data.token);
|
|
74
|
+
if (claims === null) return refuse("invalid_token");
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const config = await consentConfigForSite(claims.workspaceId);
|
|
78
|
+
const purpose = consentPurposeById(config, claims.purposeId);
|
|
79
|
+
if (
|
|
80
|
+
!purpose ||
|
|
81
|
+
purpose.area !== "communications" ||
|
|
82
|
+
consentPurposeKind(purpose) === "agreement"
|
|
83
|
+
) {
|
|
84
|
+
return refuse("purpose_not_unsubscribable");
|
|
85
|
+
}
|
|
86
|
+
if (!(await consentPersonExists(claims.workspaceId, claims.personId))) {
|
|
87
|
+
return refuse("person_unknown");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = await recordPersonConsent({
|
|
91
|
+
workspaceId: claims.workspaceId,
|
|
92
|
+
personId: claims.personId,
|
|
93
|
+
purposeId: purpose.id,
|
|
94
|
+
granted: false,
|
|
95
|
+
source: "email_unsubscribe",
|
|
96
|
+
locale: "en",
|
|
97
|
+
config,
|
|
98
|
+
subject: { subjectType: "person", subjectId: claims.personId },
|
|
99
|
+
});
|
|
100
|
+
if (!result.ok) return refuse(result.code);
|
|
101
|
+
|
|
102
|
+
request.log.info(
|
|
103
|
+
{ event: EVENT, outcome: result.changed ? "withdrawn" : "unchanged" },
|
|
104
|
+
"[consent] unsubscribe",
|
|
105
|
+
);
|
|
106
|
+
return reply.send({
|
|
107
|
+
purpose: {
|
|
108
|
+
id: purpose.id,
|
|
109
|
+
description:
|
|
110
|
+
purpose.description === undefined
|
|
111
|
+
? purpose.id
|
|
112
|
+
: resolveLocalizedString(purpose.description, "en", "en"),
|
|
113
|
+
},
|
|
114
|
+
changed: result.changed,
|
|
115
|
+
});
|
|
116
|
+
} catch {
|
|
117
|
+
request.log.error(
|
|
118
|
+
{ event: EVENT, outcome: "failed" },
|
|
119
|
+
"[consent] unsubscribe failed",
|
|
120
|
+
);
|
|
121
|
+
return reply.status(503).send({
|
|
122
|
+
error: "consent_store_unavailable",
|
|
123
|
+
message: "Unsubscribing isn't available right now.",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
};
|
|
@@ -71,6 +71,10 @@ import type { FastifyPluginAsync } from "fastify";
|
|
|
71
71
|
|
|
72
72
|
import { acceptsKind, resolveLeadDelivery } from "../../lib/lead-delivery.js";
|
|
73
73
|
import { currentSiteId, resolveSiteWorkspaceId } from "../../lib/site.js";
|
|
74
|
+
import {
|
|
75
|
+
CONSENT_VISITOR_HEADER,
|
|
76
|
+
consentVisitorFromHeader,
|
|
77
|
+
} from "../../services/consent.js";
|
|
74
78
|
import {
|
|
75
79
|
type LeadKind,
|
|
76
80
|
type RouteLeadInput,
|
|
@@ -245,6 +249,21 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
245
249
|
}
|
|
246
250
|
}
|
|
247
251
|
|
|
252
|
+
// #8185 D3 — a spoke's same-origin relay marks every forwarded call.
|
|
253
|
+
// The mark can only remove the spoke-checkout marker, never grant it.
|
|
254
|
+
sourceMeta.relayed = request.headers["x-ailk-relayed"] !== undefined;
|
|
255
|
+
|
|
256
|
+
// #8438 D11 — the cookie banner's visitor id, forwarded by the site's
|
|
257
|
+
// own server from its first-party cookie. Only a v4 UUID counts; the
|
|
258
|
+
// header is never echoed or logged. It links the visitor to the
|
|
259
|
+
// lead's Person after the write, and changes nothing about the answer.
|
|
260
|
+
const consentVisitorId = consentVisitorFromHeader(
|
|
261
|
+
request.headers[CONSENT_VISITOR_HEADER],
|
|
262
|
+
);
|
|
263
|
+
if (consentVisitorId !== undefined) {
|
|
264
|
+
sourceMeta.consentVisitorId = consentVisitorId;
|
|
265
|
+
}
|
|
266
|
+
|
|
248
267
|
const result = await routeLead(
|
|
249
268
|
buildInput(kind, body, workspaceId),
|
|
250
269
|
sourceMeta,
|
|
@@ -516,6 +516,10 @@ describe("the draft lifecycle (#5036 D1)", () => {
|
|
|
516
516
|
locale: "en",
|
|
517
517
|
statement,
|
|
518
518
|
statementHash: sha256(statement),
|
|
519
|
+
// #8438 D3 — a listing names no magnet slug, so the source is `api`.
|
|
520
|
+
source: "api",
|
|
521
|
+
personId: null,
|
|
522
|
+
policyVersion: 1,
|
|
519
523
|
});
|
|
520
524
|
expect(data[0]!["statement"]).not.toBe(UNRECOVERABLE_CONSENT_STATEMENT);
|
|
521
525
|
// The row's own consentAt is untouched by the grant write (spec §2.3).
|
|
@@ -539,6 +543,7 @@ describe("the draft lifecycle (#5036 D1)", () => {
|
|
|
539
543
|
locale: "und",
|
|
540
544
|
statement: UNRECOVERABLE_CONSENT_STATEMENT,
|
|
541
545
|
statementHash: sha256(UNRECOVERABLE_CONSENT_STATEMENT),
|
|
546
|
+
source: "api",
|
|
542
547
|
});
|
|
543
548
|
// Never today's wording for a legacy call — that would fabricate evidence (D11).
|
|
544
549
|
expect(data[0]!["statement"]).not.toContain("You agree to being contacted");
|
|
@@ -52,6 +52,15 @@ jest.mock("@working-theory/stripe-client", () => ({
|
|
|
52
52
|
WEBHOOK_TOLERANCE_SECONDS: 300,
|
|
53
53
|
}));
|
|
54
54
|
|
|
55
|
+
// #8450 D10 — the handler drops an event whose livemode is not the process's,
|
|
56
|
+
// so the Person cases below run the process in their event's own mode.
|
|
57
|
+
let mockKeyMode: "test" | "live" = "test";
|
|
58
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
59
|
+
jest.mock("../../../lib/stripe.js", () => ({
|
|
60
|
+
...jest.requireActual("../../../lib/stripe.js"),
|
|
61
|
+
stripeKeyMode: () => mockKeyMode,
|
|
62
|
+
}));
|
|
63
|
+
|
|
55
64
|
import * as configModule from "../../../config/index.js";
|
|
56
65
|
import { stripeWebhookHandler } from "../stripe.js";
|
|
57
66
|
import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
|
|
@@ -115,6 +124,7 @@ let server: FastifyInstance;
|
|
|
115
124
|
|
|
116
125
|
beforeEach(async () => {
|
|
117
126
|
jest.clearAllMocks();
|
|
127
|
+
mockKeyMode = "test";
|
|
118
128
|
logCalls.length = 0;
|
|
119
129
|
__resetIdempotencyStore();
|
|
120
130
|
jest.replaceProperty(configModule, "config", {
|
|
@@ -349,10 +359,13 @@ describe("the hub checkout's Person (#852 D6, AC-3)", () => {
|
|
|
349
359
|
logCalls.filter(
|
|
350
360
|
(c) => typeof c[0] === "object" && c[0] !== null && String((c[0] as { outcome?: string }).outcome).startsWith("person_"),
|
|
351
361
|
);
|
|
352
|
-
const liveEvent = (livemode: boolean, object: Record<string, unknown> = {}) =>
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
362
|
+
const liveEvent = (livemode: boolean, object: Record<string, unknown> = {}) => {
|
|
363
|
+
mockKeyMode = livemode ? "live" : "test";
|
|
364
|
+
return {
|
|
365
|
+
...sessionEvent("checkout.session.completed", { customer: "cus_9", ...object }),
|
|
366
|
+
livemode,
|
|
367
|
+
};
|
|
368
|
+
};
|
|
356
369
|
|
|
357
370
|
beforeEach(() => {
|
|
358
371
|
(prisma.flowCheckout.findFirst as jest.Mock).mockResolvedValue({ leadId: LEAD, workspaceId: WORKSPACE });
|
|
@@ -57,6 +57,8 @@ jest.mock("../../../lib/stripe.js", () => ({
|
|
|
57
57
|
verifyWebhookSignature: (...args: unknown[]) =>
|
|
58
58
|
mockVerifyWebhookSignature(...args),
|
|
59
59
|
StripeWebhookSignatureError: MockStripeWebhookSignatureError,
|
|
60
|
+
// #8450 D10 — the handler compares each event's livemode with this.
|
|
61
|
+
stripeKeyMode: () => "test",
|
|
60
62
|
}));
|
|
61
63
|
|
|
62
64
|
import { stripeWebhookHandler } from "../stripe.js";
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/webhooks/stripe — the livemode guard, live-mode process (#8450
|
|
3
|
+
* D10; #6852 SEC-03). The mirror of `stripe-livemode.test.ts`.
|
|
4
|
+
*
|
|
5
|
+
* `lib/stripe.ts` runs as itself except `stripeKeyMode`, which answers
|
|
6
|
+
* `"live"` — the state `build()` leaves a process in after admitting a
|
|
7
|
+
* restricted live key. A signed event whose `livemode` is `false` (a
|
|
8
|
+
* test-mode event, forgeable by anyone holding the test key) is answered
|
|
9
|
+
* 200, writes nothing, is not marked seen and logs one `livemode_mismatch`
|
|
10
|
+
* warn; the same event with `livemode: true` reaches its arm.
|
|
11
|
+
*
|
|
12
|
+
* The stripe-site-filter.test.ts harness: the commerce claim mocked present,
|
|
13
|
+
* the prisma manual mock, the idempotency store reset per test.
|
|
14
|
+
*/
|
|
15
|
+
import type { FastifyInstance } from "fastify";
|
|
16
|
+
import Fastify from "fastify";
|
|
17
|
+
|
|
18
|
+
jest.mock("@working-theory/license", () => ({
|
|
19
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
20
|
+
tier: "pro",
|
|
21
|
+
plan: "pro-monthly",
|
|
22
|
+
features: ["commerce"],
|
|
23
|
+
expiresAt: null,
|
|
24
|
+
revoked: false,
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
jest.mock("@working-theory/analytics/server", () => ({
|
|
29
|
+
track: jest.fn().mockResolvedValue(undefined),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
33
|
+
jest.mock("../../../lib/prisma.js");
|
|
34
|
+
|
|
35
|
+
const mockVerifyWebhookSignature = jest.fn();
|
|
36
|
+
jest.mock("@working-theory/stripe-client", () => ({
|
|
37
|
+
...jest.requireActual("@working-theory/stripe-client"),
|
|
38
|
+
verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
42
|
+
jest.mock("../../../lib/stripe.js", () => ({
|
|
43
|
+
...jest.requireActual("../../../lib/stripe.js"),
|
|
44
|
+
stripeKeyMode: () => "live",
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
import * as configModule from "../../../config/index.js";
|
|
48
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
49
|
+
import { __resetIdempotencyStore, hasSeen } from "../../../lib/webhook-idempotency.js";
|
|
50
|
+
import { stripeWebhookHandler } from "../stripe.js";
|
|
51
|
+
|
|
52
|
+
const EVENT_ID = "evt_livemode_12345678";
|
|
53
|
+
|
|
54
|
+
function customerCreated(livemode: boolean) {
|
|
55
|
+
return {
|
|
56
|
+
id: EVENT_ID,
|
|
57
|
+
type: "customer.created",
|
|
58
|
+
livemode,
|
|
59
|
+
data: {
|
|
60
|
+
object: {
|
|
61
|
+
id: "cus_livemode_fixture",
|
|
62
|
+
email: "client@example.com",
|
|
63
|
+
metadata: { ailk_user_id: "usr_livemode" },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type LogCall = [unknown, string?];
|
|
70
|
+
const logCalls: LogCall[] = [];
|
|
71
|
+
|
|
72
|
+
async function buildTestServer(): Promise<FastifyInstance> {
|
|
73
|
+
const server = Fastify({ logger: false });
|
|
74
|
+
await server.register(async (webhooksScope) => {
|
|
75
|
+
webhooksScope.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
|
|
76
|
+
done(null, body);
|
|
77
|
+
});
|
|
78
|
+
webhooksScope.addHook("onRequest", async (request) => {
|
|
79
|
+
for (const level of ["info", "warn", "error"] as const) {
|
|
80
|
+
const original = request.log[level].bind(request.log);
|
|
81
|
+
request.log[level] = ((...args: unknown[]) => {
|
|
82
|
+
logCalls.push(args as LogCall);
|
|
83
|
+
return (original as (...a: unknown[]) => void)(...args);
|
|
84
|
+
}) as typeof request.log.info;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
88
|
+
});
|
|
89
|
+
await server.ready();
|
|
90
|
+
return server;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mismatchLogs(): LogCall[] {
|
|
94
|
+
return logCalls.filter(
|
|
95
|
+
([obj]) =>
|
|
96
|
+
typeof obj === "object" && obj !== null && (obj as Record<string, unknown>)["outcome"] === "livemode_mismatch",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let server: FastifyInstance;
|
|
101
|
+
|
|
102
|
+
beforeEach(async () => {
|
|
103
|
+
jest.clearAllMocks();
|
|
104
|
+
logCalls.length = 0;
|
|
105
|
+
__resetIdempotencyStore();
|
|
106
|
+
jest.replaceProperty(configModule, "config", {
|
|
107
|
+
...configModule.config,
|
|
108
|
+
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
109
|
+
});
|
|
110
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
|
|
111
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
|
|
112
|
+
server = await buildTestServer();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(async () => {
|
|
116
|
+
await server.close();
|
|
117
|
+
jest.restoreAllMocks();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
async function post() {
|
|
121
|
+
return server.inject({
|
|
122
|
+
method: "POST",
|
|
123
|
+
url: "/v1/webhooks/stripe",
|
|
124
|
+
payload: Buffer.from(JSON.stringify({ type: "test" })),
|
|
125
|
+
headers: { "content-type": "application/json", "stripe-signature": "t=1234,v1=abc" },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe("stripe webhook — livemode guard in a live-mode process (#8450 D10)", () => {
|
|
130
|
+
it("drops a test-mode event: 200, no write, not marked seen, one warn", async () => {
|
|
131
|
+
mockVerifyWebhookSignature.mockReturnValue(customerCreated(false));
|
|
132
|
+
|
|
133
|
+
const res = await post();
|
|
134
|
+
|
|
135
|
+
expect(res.statusCode).toBe(200);
|
|
136
|
+
expect(res.json()).toEqual({ received: true });
|
|
137
|
+
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
|
138
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
139
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
140
|
+
expect(hasSeen(EVENT_ID)).toBe(false);
|
|
141
|
+
expect(mismatchLogs()).toHaveLength(1);
|
|
142
|
+
expect(mismatchLogs()[0]?.[0]).toEqual({
|
|
143
|
+
eventType: "customer.created",
|
|
144
|
+
eventIdPrefix: EVENT_ID.substring(0, 8),
|
|
145
|
+
outcome: "livemode_mismatch",
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("handles a live-mode event: it reaches its arm and is marked seen", async () => {
|
|
150
|
+
mockVerifyWebhookSignature.mockReturnValue(customerCreated(true));
|
|
151
|
+
|
|
152
|
+
const res = await post();
|
|
153
|
+
|
|
154
|
+
expect(res.statusCode).toBe(200);
|
|
155
|
+
expect(prisma.user.findFirst).toHaveBeenCalled();
|
|
156
|
+
expect(hasSeen(EVENT_ID)).toBe(true);
|
|
157
|
+
expect(mismatchLogs()).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/webhooks/stripe — the livemode guard, test-mode process (#8450
|
|
3
|
+
* D10; #6852 SEC-03).
|
|
4
|
+
*
|
|
5
|
+
* The real `lib/stripe.ts` runs over the package mocked for
|
|
6
|
+
* `verifyWebhookSignature` only, and nothing admits a live key, so the
|
|
7
|
+
* process is in test mode: a signed event whose `livemode` is `true` is
|
|
8
|
+
* answered 200, writes nothing, is not marked seen and logs one
|
|
9
|
+
* `livemode_mismatch` warn; the same event with `livemode: false` reaches
|
|
10
|
+
* its arm. The live-mode mirror is `stripe-livemode-live.test.ts`.
|
|
11
|
+
*
|
|
12
|
+
* The stripe-site-filter.test.ts harness: the commerce claim mocked present,
|
|
13
|
+
* the prisma manual mock, the idempotency store reset per test.
|
|
14
|
+
*/
|
|
15
|
+
import type { FastifyInstance } from "fastify";
|
|
16
|
+
import Fastify from "fastify";
|
|
17
|
+
|
|
18
|
+
jest.mock("@working-theory/license", () => ({
|
|
19
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
20
|
+
tier: "pro",
|
|
21
|
+
plan: "pro-monthly",
|
|
22
|
+
features: ["commerce"],
|
|
23
|
+
expiresAt: null,
|
|
24
|
+
revoked: false,
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
jest.mock("@working-theory/analytics/server", () => ({
|
|
29
|
+
track: jest.fn().mockResolvedValue(undefined),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
33
|
+
jest.mock("../../../lib/prisma.js");
|
|
34
|
+
|
|
35
|
+
const mockVerifyWebhookSignature = jest.fn();
|
|
36
|
+
jest.mock("@working-theory/stripe-client", () => ({
|
|
37
|
+
...jest.requireActual("@working-theory/stripe-client"),
|
|
38
|
+
verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
import * as configModule from "../../../config/index.js";
|
|
42
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
43
|
+
import { stripeKeyMode } from "../../../lib/stripe.js";
|
|
44
|
+
import { __resetIdempotencyStore, hasSeen } from "../../../lib/webhook-idempotency.js";
|
|
45
|
+
import { stripeWebhookHandler } from "../stripe.js";
|
|
46
|
+
|
|
47
|
+
const EVENT_ID = "evt_livemode_12345678";
|
|
48
|
+
|
|
49
|
+
function customerCreated(livemode: boolean) {
|
|
50
|
+
return {
|
|
51
|
+
id: EVENT_ID,
|
|
52
|
+
type: "customer.created",
|
|
53
|
+
livemode,
|
|
54
|
+
data: {
|
|
55
|
+
object: {
|
|
56
|
+
id: "cus_livemode_fixture",
|
|
57
|
+
email: "client@example.com",
|
|
58
|
+
metadata: { ailk_user_id: "usr_livemode" },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type LogCall = [unknown, string?];
|
|
65
|
+
const logCalls: LogCall[] = [];
|
|
66
|
+
|
|
67
|
+
async function buildTestServer(): Promise<FastifyInstance> {
|
|
68
|
+
const server = Fastify({ logger: false });
|
|
69
|
+
await server.register(async (webhooksScope) => {
|
|
70
|
+
webhooksScope.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
|
|
71
|
+
done(null, body);
|
|
72
|
+
});
|
|
73
|
+
webhooksScope.addHook("onRequest", async (request) => {
|
|
74
|
+
for (const level of ["info", "warn", "error"] as const) {
|
|
75
|
+
const original = request.log[level].bind(request.log);
|
|
76
|
+
request.log[level] = ((...args: unknown[]) => {
|
|
77
|
+
logCalls.push(args as LogCall);
|
|
78
|
+
return (original as (...a: unknown[]) => void)(...args);
|
|
79
|
+
}) as typeof request.log.info;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
83
|
+
});
|
|
84
|
+
await server.ready();
|
|
85
|
+
return server;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mismatchLogs(): LogCall[] {
|
|
89
|
+
return logCalls.filter(
|
|
90
|
+
([obj]) =>
|
|
91
|
+
typeof obj === "object" && obj !== null && (obj as Record<string, unknown>)["outcome"] === "livemode_mismatch",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let server: FastifyInstance;
|
|
96
|
+
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
jest.clearAllMocks();
|
|
99
|
+
logCalls.length = 0;
|
|
100
|
+
__resetIdempotencyStore();
|
|
101
|
+
jest.replaceProperty(configModule, "config", {
|
|
102
|
+
...configModule.config,
|
|
103
|
+
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
104
|
+
});
|
|
105
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
|
|
106
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
|
|
107
|
+
server = await buildTestServer();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(async () => {
|
|
111
|
+
await server.close();
|
|
112
|
+
jest.restoreAllMocks();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
async function post() {
|
|
116
|
+
return server.inject({
|
|
117
|
+
method: "POST",
|
|
118
|
+
url: "/v1/webhooks/stripe",
|
|
119
|
+
payload: Buffer.from(JSON.stringify({ type: "test" })),
|
|
120
|
+
headers: { "content-type": "application/json", "stripe-signature": "t=1234,v1=abc" },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("stripe webhook — livemode guard in a test-mode process (#8450 D10)", () => {
|
|
125
|
+
it("the process is in test mode", () => {
|
|
126
|
+
expect(stripeKeyMode()).toBe("test");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("drops a live-mode event: 200, no write, not marked seen, one warn", async () => {
|
|
130
|
+
mockVerifyWebhookSignature.mockReturnValue(customerCreated(true));
|
|
131
|
+
|
|
132
|
+
const res = await post();
|
|
133
|
+
|
|
134
|
+
expect(res.statusCode).toBe(200);
|
|
135
|
+
expect(res.json()).toEqual({ received: true });
|
|
136
|
+
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
|
137
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
138
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
139
|
+
expect(hasSeen(EVENT_ID)).toBe(false);
|
|
140
|
+
expect(mismatchLogs()).toHaveLength(1);
|
|
141
|
+
expect(mismatchLogs()[0]?.[0]).toEqual({
|
|
142
|
+
eventType: "customer.created",
|
|
143
|
+
eventIdPrefix: EVENT_ID.substring(0, 8),
|
|
144
|
+
outcome: "livemode_mismatch",
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("handles a test-mode event as before: it reaches its arm and is marked seen", async () => {
|
|
149
|
+
mockVerifyWebhookSignature.mockReturnValue(customerCreated(false));
|
|
150
|
+
|
|
151
|
+
const res = await post();
|
|
152
|
+
|
|
153
|
+
expect(res.statusCode).toBe(200);
|
|
154
|
+
expect(prisma.user.findFirst).toHaveBeenCalled();
|
|
155
|
+
expect(hasSeen(EVENT_ID)).toBe(true);
|
|
156
|
+
expect(mismatchLogs()).toHaveLength(0);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -60,6 +60,8 @@ jest.mock("../../../lib/stripe.js", () => ({
|
|
|
60
60
|
verifyWebhookSignature: (...args: unknown[]) =>
|
|
61
61
|
mockVerifyWebhookSignature(...args),
|
|
62
62
|
StripeWebhookSignatureError: MockStripeWebhookSignatureError,
|
|
63
|
+
// #8450 D10 — the handler compares each event's livemode with this.
|
|
64
|
+
stripeKeyMode: () => "test",
|
|
63
65
|
}));
|
|
64
66
|
|
|
65
67
|
import { webhooksRoutes } from "../index.js";
|
|
@@ -104,6 +104,7 @@ import {
|
|
|
104
104
|
SITE_STAMP_KEY,
|
|
105
105
|
} from "../../lib/site.js";
|
|
106
106
|
import {
|
|
107
|
+
stripeKeyMode,
|
|
107
108
|
StripeWebhookSignatureError,
|
|
108
109
|
toSubscriptionLifecycleStatus,
|
|
109
110
|
verifyWebhookSignature,
|
|
@@ -274,6 +275,22 @@ export const stripeWebhookHandler = async (
|
|
|
274
275
|
|
|
275
276
|
const event = settled.event;
|
|
276
277
|
const eventIdPrefix = event.id.substring(0, 8);
|
|
278
|
+
|
|
279
|
+
// #8450 D10 (#6852 SEC-03) — an event whose livemode is not the key class
|
|
280
|
+
// this process admitted is never one of its own: a live hub must not act on
|
|
281
|
+
// a test-mode event anyone with the test key can forge, nor a test hub on a
|
|
282
|
+
// live one. Dropped before the idempotency check and any write; 200 so
|
|
283
|
+
// Stripe stops retrying, and NOT marked seen, so a corrected deployment
|
|
284
|
+
// still accepts a redelivery.
|
|
285
|
+
if ((event.livemode === true) !== (stripeKeyMode() === "live")) {
|
|
286
|
+
request.log.warn(
|
|
287
|
+
{ eventType: event.type, eventIdPrefix, outcome: "livemode_mismatch" },
|
|
288
|
+
"stripe webhook event dropped — livemode differs from the admitted key class",
|
|
289
|
+
);
|
|
290
|
+
await reply.code(200).send({ received: true });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
277
294
|
request.log.info(
|
|
278
295
|
{ eventType: event.type, eventIdPrefix },
|
|
279
296
|
"stripe webhook event received",
|