create-ailk 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -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__/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/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- 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 +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- 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-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- 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 +115 -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/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- 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/public-site-resolver.ts +21 -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 +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- 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__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- 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 +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- 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-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- 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__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- 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/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* 400 validation-error — `:id` fails the shape check
|
|
28
28
|
* 404 unknown-session — Stripe raised `resource_missing`
|
|
29
29
|
* 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty
|
|
30
|
-
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
|
|
30
|
+
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
|
|
31
31
|
* 500 config-error — STRIPE_SECRET_KEY invalid configuration
|
|
32
32
|
* 502 checkout-unavailable — Stripe SDK threw an unexpected error
|
|
33
33
|
*/
|
|
@@ -35,7 +35,7 @@ import type { FastifyReply, FastifyRequest } from "fastify";
|
|
|
35
35
|
import { z } from "zod";
|
|
36
36
|
|
|
37
37
|
import {
|
|
38
|
-
|
|
38
|
+
assertAdmittedStripeKey,
|
|
39
39
|
retrieveCheckoutSession,
|
|
40
40
|
StripeConfigError,
|
|
41
41
|
UnknownSessionError,
|
|
@@ -80,7 +80,7 @@ export async function checkoutSessionReadHandler(
|
|
|
80
80
|
return reply.status(500).send({ error: "stripe-not-configured" });
|
|
81
81
|
}
|
|
82
82
|
try {
|
|
83
|
-
|
|
83
|
+
assertAdmittedStripeKey(stripeSecretKey);
|
|
84
84
|
} catch {
|
|
85
85
|
return reply.status(500).send({ error: "live-mode-forbidden" });
|
|
86
86
|
}
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* price opens a subscription session), so this route serves both (#4398).
|
|
8
8
|
*
|
|
9
9
|
* Security invariants:
|
|
10
|
-
* -
|
|
10
|
+
* - Admitted key only (#8450): before any Stripe call, a test-mode key or
|
|
11
|
+
* the restricted live key the process admitted at boot passes
|
|
12
|
+
* (`assertAdmittedStripeKey`); every other live key is refused.
|
|
11
13
|
* - No PII in logs: successUrl/cancelUrl and full session IDs are never logged.
|
|
12
14
|
* Only the first 8 chars of the session ID appear in log lines.
|
|
13
15
|
* - Error responses never echo user input (productCode, URLs).
|
|
@@ -48,7 +50,7 @@
|
|
|
48
50
|
* unauthenticated redirect is off the site origin (#6877)
|
|
49
51
|
* 404 unknown-product-code — no active Stripe price for the given productCode
|
|
50
52
|
* 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty (no Stripe call)
|
|
51
|
-
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
|
|
53
|
+
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
|
|
52
54
|
* 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper),
|
|
53
55
|
* or no trusted browser origin is configured when the origin
|
|
54
56
|
* lock applies (#6877, #7395)
|
|
@@ -62,7 +64,7 @@ import { z } from "zod";
|
|
|
62
64
|
import { config } from "../../config/index.js";
|
|
63
65
|
import { siteStamp } from "../../lib/site.js";
|
|
64
66
|
import {
|
|
65
|
-
|
|
67
|
+
assertAdmittedStripeKey,
|
|
66
68
|
createCheckoutSession,
|
|
67
69
|
StripeConfigError,
|
|
68
70
|
UnknownCustomerError,
|
|
@@ -248,7 +250,7 @@ export async function checkoutSessionsHandler(
|
|
|
248
250
|
return reply.status(500).send({ error: "stripe-not-configured" });
|
|
249
251
|
}
|
|
250
252
|
try {
|
|
251
|
-
|
|
253
|
+
assertAdmittedStripeKey(stripeSecretKey);
|
|
252
254
|
} catch {
|
|
253
255
|
return reply.status(500).send({ error: "live-mode-forbidden" });
|
|
254
256
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cookie banner's log (#8438, spec
|
|
3
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md D5).
|
|
4
|
+
*
|
|
5
|
+
* POST /v1/consent/cookie
|
|
6
|
+
* `{ visitorId, choice: "accepted" | "declined", locale }` — one row
|
|
7
|
+
* in the consent ledger against the visitor id, source
|
|
8
|
+
* `cookie_banner`. Answers `201 { recorded: true }`, or
|
|
9
|
+
* `200 { recorded: false }` when the site declares no cookie purpose
|
|
10
|
+
* or the visitor's newest row already records the same choice.
|
|
11
|
+
*
|
|
12
|
+
* The workspace comes from `resolvePublicWorkspace(request)` — the spoke
|
|
13
|
+
* key, else the one-workspace deployment — and NEVER from the body, which is
|
|
14
|
+
* strict and names no site, workspace, person or user. An unresolved
|
|
15
|
+
* workspace is `503 tenant_unresolved`; no database `503
|
|
16
|
+
* consent_store_unavailable`; a failed write the same.
|
|
17
|
+
*
|
|
18
|
+
* Registered at the top level in its own rate-limited scope, outside every
|
|
19
|
+
* module gate: consent logging is part of the never-gated privacy floor.
|
|
20
|
+
* The browser cookie `ailk-consent` stays the operative switch (C3) — this
|
|
21
|
+
* route only records the choice.
|
|
22
|
+
*
|
|
23
|
+
* Logs carry `{ event, outcome }` only: never a visitor id or a statement.
|
|
24
|
+
*
|
|
25
|
+
* MCP parity note: no MCP twin, a recorded exception — it logs a browser's
|
|
26
|
+
* banner choice against a browser-held id, and an agent holds neither
|
|
27
|
+
* (tests/parity/README.md).
|
|
28
|
+
*/
|
|
29
|
+
import { consentCookieBodySchema } from "@working-theory/validation";
|
|
30
|
+
import type { FastifyPluginAsync } from "fastify";
|
|
31
|
+
|
|
32
|
+
import { recordCookieChoice } from "../../services/consent.js";
|
|
33
|
+
import { resolvePublicWorkspace } from "../../services/public-site-resolver.js";
|
|
34
|
+
|
|
35
|
+
const EVENT = "consent-cookie";
|
|
36
|
+
|
|
37
|
+
export const consentCookieRoute: FastifyPluginAsync = async (fastify) => {
|
|
38
|
+
fastify.post("/v1/consent/cookie", async (request, reply) => {
|
|
39
|
+
const body = consentCookieBodySchema.safeParse(request.body);
|
|
40
|
+
if (!body.success) {
|
|
41
|
+
return reply.status(400).send({
|
|
42
|
+
error: "invalid_input",
|
|
43
|
+
message: "The request was not valid.",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (!process.env["DATABASE_URL"]) {
|
|
47
|
+
return reply.status(503).send({
|
|
48
|
+
error: "consent_store_unavailable",
|
|
49
|
+
message: "Consent cannot be recorded on this site.",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const site = await resolvePublicWorkspace(request);
|
|
54
|
+
if (site.kind !== "resolved") {
|
|
55
|
+
request.log.warn(
|
|
56
|
+
{ event: EVENT, outcome: "tenant_unresolved" },
|
|
57
|
+
"[consent] refused",
|
|
58
|
+
);
|
|
59
|
+
return reply.status(503).send({
|
|
60
|
+
error: "tenant_unresolved",
|
|
61
|
+
message: "This site is not configured to record consent.",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const result = await recordCookieChoice({
|
|
67
|
+
workspaceId: site.workspaceId,
|
|
68
|
+
visitorId: body.data.visitorId,
|
|
69
|
+
choice: body.data.choice,
|
|
70
|
+
locale: body.data.locale,
|
|
71
|
+
});
|
|
72
|
+
request.log.info(
|
|
73
|
+
{ event: EVENT, outcome: result.recorded ? "recorded" : "unchanged" },
|
|
74
|
+
"[consent] cookie choice",
|
|
75
|
+
);
|
|
76
|
+
return reply
|
|
77
|
+
.status(result.recorded ? 201 : 200)
|
|
78
|
+
.send({ recorded: result.recorded });
|
|
79
|
+
} catch {
|
|
80
|
+
request.log.error(
|
|
81
|
+
{ event: EVENT, outcome: "failed" },
|
|
82
|
+
"[consent] cookie choice failed",
|
|
83
|
+
);
|
|
84
|
+
return reply.status(503).send({
|
|
85
|
+
error: "consent_store_unavailable",
|
|
86
|
+
message: "Consent cannot be recorded right now.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
};
|
|
@@ -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,
|
|
@@ -249,6 +253,17 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
249
253
|
// The mark can only remove the spoke-checkout marker, never grant it.
|
|
250
254
|
sourceMeta.relayed = request.headers["x-ailk-relayed"] !== undefined;
|
|
251
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
|
+
|
|
252
267
|
const result = await routeLead(
|
|
253
268
|
buildInput(kind, body, workspaceId),
|
|
254
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");
|
|
@@ -163,13 +163,41 @@ through `metadata.site`.
|
|
|
163
163
|
open (a session lives up to 24 hours). A session whose stamp no longer
|
|
164
164
|
matches is acked as foreign and never retried, so its hub side effects are
|
|
165
165
|
lost; the `stamp` / `ownSite` pair in the log line is how to spot that drift.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
those
|
|
169
|
-
|
|
166
|
+
A subscription keeps its stamp for its whole life and each finalized
|
|
167
|
+
invoice keeps an immutable copy of it, so a slug collision or a mis-stamp
|
|
168
|
+
on those lasts as long as the subscription — not the 24 hours a session
|
|
169
|
+
lives.
|
|
170
|
+
- **Billing events (#8024).** One gate before the event switch applies the
|
|
171
|
+
same test to every `customer.*`, `customer.subscription.*` and `invoice.*`
|
|
172
|
+
event, handled type or not. It reads the stamp from the event object's own
|
|
173
|
+
metadata — the Subscription's (where spoke-checkout writes `site` through
|
|
174
|
+
`subscription_data.metadata`) or the Customer's — and, for an invoice, from
|
|
175
|
+
its own metadata, then the subscription snapshot under
|
|
176
|
+
`parent.subscription_details.metadata` (the pinned API version), then the
|
|
177
|
+
top-level `subscription_details.metadata` (an endpoint on a pre-`basil`
|
|
178
|
+
version). Any foreign source makes the event foreign; the first is logged.
|
|
179
|
+
A source is read only when `data.object.object` is the family's own type
|
|
180
|
+
(`subscription`, `customer`, `invoice`), so a `customer.source.*` card or a
|
|
181
|
+
`customer.tax_id.*` object is never read. A foreign event answers 200,
|
|
182
|
+
logs `outcome: "foreign_site"` (the same five fields), writes nothing — no
|
|
183
|
+
billing bind, request-paid stamp or dunning record — and is marked seen.
|
|
184
|
+
The decision uses the payload alone: no Stripe call.
|
|
185
|
+
- **What stays unstamped.** The filter cannot see a stamp nobody wrote, so
|
|
186
|
+
these are still handled as the hub's own, exactly as before: spoke
|
|
187
|
+
customers (Checkout cannot put metadata on a customer it creates); quoted
|
|
188
|
+
spoke subscriptions and their invoices (`createQuotedCheckoutSession`
|
|
189
|
+
sends no `subscription_data` yet); a fixed spoke subscription whose `site`
|
|
190
|
+
was later removed; spoke subscriptions created before the stamp shipped,
|
|
191
|
+
or through a Payment Link or the Dashboard; and the billing app's sessions
|
|
192
|
+
and subscriptions on the shared account. Where such a subscription sits on
|
|
193
|
+
a hub User's own Stripe customer (a known buyer reused by a spoke), its
|
|
194
|
+
events keep reaching that User's dunning record.
|
|
170
195
|
- **A label, not a boundary.** The stamp is written by whoever holds the
|
|
171
196
|
account's secret key. It separates cooperating sites on one account, not
|
|
172
|
-
tenants.
|
|
197
|
+
tenants. Whoever holds a write key on the account can also make the hub
|
|
198
|
+
skip one of its own objects' billing events by stamping it with another
|
|
199
|
+
site, which silences the hub's dunning record for that subscription — the
|
|
200
|
+
same key can already cancel or re-price it outright.
|
|
173
201
|
|
|
174
202
|
## Dunning (#6034)
|
|
175
203
|
|
|
@@ -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
|
+
});
|