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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /v1/webhooks/stripe — the proposal paid stamp (#8516 D13; spec
|
|
3
|
+
* docs/specs/8516-proposals-as-an-artifact-kind-spec.md AC-6, AC-7).
|
|
4
|
+
*
|
|
5
|
+
* The signed-event harness is `stripe-request-paid.test.ts`'s (that suite is
|
|
6
|
+
* left unedited). The Prisma boundary is the in-memory, where-honouring
|
|
7
|
+
* double, so the stamp's two statements in `lib/proposal-paid-store.ts` run
|
|
8
|
+
* against real rows seeded in two workspaces, and the audit row is the one
|
|
9
|
+
* the real `recordWorkspaceAuditAction` writes through `auditLog.create`.
|
|
10
|
+
*/
|
|
11
|
+
import type { FastifyInstance } from "fastify";
|
|
12
|
+
import Fastify from "fastify";
|
|
13
|
+
|
|
14
|
+
// Commerce claim (D-64, #5796): every case here is a COMMERCE-CLAIMED
|
|
15
|
+
// deployment, the same posture the sibling webhook suites take.
|
|
16
|
+
jest.mock("@working-theory/license", () => ({
|
|
17
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
18
|
+
tier: "pro",
|
|
19
|
+
plan: "pro-monthly",
|
|
20
|
+
features: ["commerce"],
|
|
21
|
+
expiresAt: null,
|
|
22
|
+
revoked: false,
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
jest.mock("@working-theory/analytics/server", () => ({
|
|
27
|
+
track: jest.fn().mockResolvedValue(undefined),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); the in-memory double is the repo-standard where-honouring test double for it.
|
|
31
|
+
jest.mock("../../../lib/prisma.js", () =>
|
|
32
|
+
jest
|
|
33
|
+
.requireActual<typeof import("../../../__tests__/factories/in-memory-prisma")>(
|
|
34
|
+
"../../../__tests__/factories/in-memory-prisma",
|
|
35
|
+
)
|
|
36
|
+
.createInMemoryPrisma(),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const mockVerifyWebhookSignature = jest.fn();
|
|
40
|
+
class MockStripeWebhookSignatureError extends Error {
|
|
41
|
+
constructor(message: string) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "StripeWebhookSignatureError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
jest.mock("@working-theory/stripe-client", () => ({
|
|
47
|
+
verifyWebhookSignature: (...args: unknown[]) =>
|
|
48
|
+
mockVerifyWebhookSignature(...args),
|
|
49
|
+
StripeWebhookSignatureError: MockStripeWebhookSignatureError,
|
|
50
|
+
assertSandboxKey: jest.fn(),
|
|
51
|
+
createCheckoutSession: jest.fn(),
|
|
52
|
+
getStripeClient: jest.fn(),
|
|
53
|
+
STRIPE_API_VERSION: "2024-06-20",
|
|
54
|
+
StripeConfigError: class StripeConfigError extends Error {},
|
|
55
|
+
UnknownProductCodeError: class UnknownProductCodeError extends Error {},
|
|
56
|
+
WEBHOOK_TOLERANCE_SECONDS: 300,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
import * as configModule from "../../../config/index.js";
|
|
60
|
+
import { stripeWebhookHandler } from "../stripe.js";
|
|
61
|
+
import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
|
|
62
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
63
|
+
|
|
64
|
+
type Row = Record<string, unknown>;
|
|
65
|
+
|
|
66
|
+
// eslint-disable-next-line no-restricted-syntax -- the in-memory double exposes its store, call log and jest.fn delegates on keys the real client type does not declare
|
|
67
|
+
const fake = prisma as unknown as {
|
|
68
|
+
__db: Record<string, Row[]>;
|
|
69
|
+
__calls: Array<{ model: string; op: string }>;
|
|
70
|
+
auditLog: { create: jest.Mock };
|
|
71
|
+
piece: { updateMany: jest.Mock; findFirst: jest.Mock };
|
|
72
|
+
};
|
|
73
|
+
const store = fake.__db;
|
|
74
|
+
const calls = fake.__calls;
|
|
75
|
+
const auditCreate = fake.auditLog.create;
|
|
76
|
+
|
|
77
|
+
const WS_A1 = "wksp_0190f3a2-7c1d-7e4a-9b2c-0000000000a1";
|
|
78
|
+
const WS_A2 = "wksp_0190f3a2-7c1d-7e4a-9b2c-0000000000a2";
|
|
79
|
+
const PERSON_A1 = "person_0190f3a2-7c1d-7e4a-9b2c-0000000000c1";
|
|
80
|
+
const PERSON_A2 = "person_0190f3a2-7c1d-7e4a-9b2c-0000000000c2";
|
|
81
|
+
const PIECE_A1 = "piece_0190f3a2-7c1d-7e4a-9b2c-0000000000b1";
|
|
82
|
+
const PIECE_A2 = "piece_0190f3a2-7c1d-7e4a-9b2c-0000000000b2";
|
|
83
|
+
const SESSION_ID = "cs_test_proposal_first_session";
|
|
84
|
+
const OTHER_SESSION_ID = "cs_test_proposal_second_session";
|
|
85
|
+
|
|
86
|
+
/** An approved, priced, unpaid proposal of `workspaceId`. */
|
|
87
|
+
function proposal(id: string, workspaceId: string, personId: string): Row {
|
|
88
|
+
return {
|
|
89
|
+
id,
|
|
90
|
+
workspaceId,
|
|
91
|
+
personId,
|
|
92
|
+
title: "Patio proposal",
|
|
93
|
+
stage: "in-review",
|
|
94
|
+
needsClient: true,
|
|
95
|
+
kind: "proposal",
|
|
96
|
+
approvedAt: new Date("2026-10-13T10:00:00.000Z"),
|
|
97
|
+
priceAmount: 125000,
|
|
98
|
+
priceCurrency: "usd",
|
|
99
|
+
checkoutSessionId: SESSION_ID,
|
|
100
|
+
checkoutUrl: "https://checkout.stripe.com/c/pay/cs_test_proposal",
|
|
101
|
+
checkoutExpiresAt: new Date("2026-10-14T13:00:00.000Z"),
|
|
102
|
+
paidAt: null,
|
|
103
|
+
paidSessionId: null,
|
|
104
|
+
createdAt: new Date("2026-10-13T09:00:00.000Z"),
|
|
105
|
+
updatedAt: new Date("2026-10-13T10:00:00.000Z"),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function piece(id: string): Row {
|
|
110
|
+
const row = store["piece"]!.find((r) => r["id"] === id);
|
|
111
|
+
if (!row) throw new Error(`no seeded piece ${id}`);
|
|
112
|
+
return row;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function auditRows(): Row[] {
|
|
116
|
+
return store["auditLog"] ?? [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pieceCalls(): Array<{ model: string; op: string }> {
|
|
120
|
+
return calls.filter((c) => c.model === "piece");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function paidEvent(
|
|
124
|
+
eventId: string,
|
|
125
|
+
object: Record<string, unknown> = {},
|
|
126
|
+
): Record<string, unknown> {
|
|
127
|
+
return {
|
|
128
|
+
id: eventId,
|
|
129
|
+
type: "checkout.session.completed",
|
|
130
|
+
data: {
|
|
131
|
+
object: {
|
|
132
|
+
id: SESSION_ID,
|
|
133
|
+
payment_status: "paid",
|
|
134
|
+
amount_total: 125000,
|
|
135
|
+
currency: "usd",
|
|
136
|
+
payment_link: null,
|
|
137
|
+
metadata: { ailk_proposal: PIECE_A1, ailk_workspace: WS_A1 },
|
|
138
|
+
...object,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function buildTestServer(): Promise<FastifyInstance> {
|
|
145
|
+
const server = Fastify({ logger: false });
|
|
146
|
+
await server.register(async (webhooksScope) => {
|
|
147
|
+
webhooksScope.addContentTypeParser(
|
|
148
|
+
"application/json",
|
|
149
|
+
{ parseAs: "buffer" },
|
|
150
|
+
(_req, body, done) => {
|
|
151
|
+
done(null, body);
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
155
|
+
});
|
|
156
|
+
await server.ready();
|
|
157
|
+
return server;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
|
|
161
|
+
const HEADERS = {
|
|
162
|
+
"content-type": "application/json",
|
|
163
|
+
"stripe-signature": "t=1234,v1=abc",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
let server: FastifyInstance;
|
|
167
|
+
|
|
168
|
+
beforeEach(async () => {
|
|
169
|
+
jest.clearAllMocks();
|
|
170
|
+
__resetIdempotencyStore();
|
|
171
|
+
for (const key of Object.keys(store)) delete store[key];
|
|
172
|
+
calls.length = 0;
|
|
173
|
+
store["piece"] = [
|
|
174
|
+
proposal(PIECE_A1, WS_A1, PERSON_A1),
|
|
175
|
+
proposal(PIECE_A2, WS_A2, PERSON_A2),
|
|
176
|
+
];
|
|
177
|
+
jest.replaceProperty(configModule, "config", {
|
|
178
|
+
...configModule.config,
|
|
179
|
+
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
180
|
+
});
|
|
181
|
+
server = await buildTestServer();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterEach(async () => {
|
|
185
|
+
await server.close();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
async function post(event: Record<string, unknown>) {
|
|
189
|
+
mockVerifyWebhookSignature.mockReturnValue(event);
|
|
190
|
+
return server.inject({
|
|
191
|
+
method: "POST",
|
|
192
|
+
url: "/v1/webhooks/stripe",
|
|
193
|
+
payload: VALID_BODY,
|
|
194
|
+
headers: HEADERS,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── A paid session stamps once and is audited as Stripe (AC-7) ─────────────
|
|
199
|
+
|
|
200
|
+
describe("a signed, paid proposal session", () => {
|
|
201
|
+
it("stamps paid_at and paid_session_id once and writes one people.proposal.paid succeeded row", async () => {
|
|
202
|
+
const res = await post(paidEvent("evt_test_proposal_paid"));
|
|
203
|
+
expect(res.statusCode).toBe(200);
|
|
204
|
+
expect(JSON.parse(res.body)).toEqual({ received: true });
|
|
205
|
+
|
|
206
|
+
const stamped = piece(PIECE_A1);
|
|
207
|
+
expect(stamped["paidAt"]).toBeInstanceOf(Date);
|
|
208
|
+
expect(stamped["paidSessionId"]).toBe(SESSION_ID);
|
|
209
|
+
// The other workspace's proposal is untouched.
|
|
210
|
+
expect(piece(PIECE_A2)["paidAt"]).toBeNull();
|
|
211
|
+
expect(piece(PIECE_A2)["paidSessionId"]).toBeNull();
|
|
212
|
+
|
|
213
|
+
expect(auditCreate).toHaveBeenCalledTimes(1);
|
|
214
|
+
expect(auditRows()).toHaveLength(1);
|
|
215
|
+
expect(auditRows()[0]).toMatchObject({
|
|
216
|
+
workspaceId: WS_A1,
|
|
217
|
+
actorType: "system",
|
|
218
|
+
actorId: "stripe",
|
|
219
|
+
action: "people.proposal.paid",
|
|
220
|
+
targetType: "piece",
|
|
221
|
+
targetId: PIECE_A1,
|
|
222
|
+
metadata: {
|
|
223
|
+
v: 2,
|
|
224
|
+
outcome: "succeeded",
|
|
225
|
+
tool: "proposal.paid",
|
|
226
|
+
idempotencyKey: "evt_test_proposal_paid",
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
expect(auditRows()[0]!["metadata"]).not.toHaveProperty("failureReason");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("stamps a zero-due completion too (no_payment_required)", async () => {
|
|
233
|
+
await post(
|
|
234
|
+
paidEvent("evt_test_proposal_zero", { payment_status: "no_payment_required" }),
|
|
235
|
+
);
|
|
236
|
+
expect(piece(PIECE_A1)["paidSessionId"]).toBe(SESSION_ID);
|
|
237
|
+
expect(auditRows()).toHaveLength(1);
|
|
238
|
+
expect(auditRows()[0]!["metadata"]).toMatchObject({ outcome: "succeeded" });
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ── Replays and second charges (AC-7, SEC-8516-F) ──────────────────────────
|
|
243
|
+
|
|
244
|
+
describe("a repeated or second payment", () => {
|
|
245
|
+
it("the same event after a restart (hasSeen cleared) leaves the row unchanged and writes nothing", async () => {
|
|
246
|
+
const event = paidEvent("evt_test_proposal_replay");
|
|
247
|
+
await post(event);
|
|
248
|
+
const paidAt = piece(PIECE_A1)["paidAt"] as Date;
|
|
249
|
+
const rowsAfterFirst = auditRows().map((r) => ({ ...r }));
|
|
250
|
+
expect(rowsAfterFirst).toHaveLength(1);
|
|
251
|
+
|
|
252
|
+
// A fresh process has an empty in-memory dedup store.
|
|
253
|
+
__resetIdempotencyStore();
|
|
254
|
+
auditCreate.mockClear();
|
|
255
|
+
|
|
256
|
+
const res = await post(event);
|
|
257
|
+
expect(res.statusCode).toBe(200);
|
|
258
|
+
expect(piece(PIECE_A1)["paidAt"]).toBe(paidAt);
|
|
259
|
+
expect(piece(PIECE_A1)["paidSessionId"]).toBe(SESSION_ID);
|
|
260
|
+
expect(auditCreate).not.toHaveBeenCalled();
|
|
261
|
+
expect(auditRows()).toEqual(rowsAfterFirst);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("a second, different paid session for the same proposal writes one failed row (already_paid)", async () => {
|
|
265
|
+
await post(paidEvent("evt_test_proposal_first"));
|
|
266
|
+
const paidAt = piece(PIECE_A1)["paidAt"] as Date;
|
|
267
|
+
auditCreate.mockClear();
|
|
268
|
+
|
|
269
|
+
const res = await post(
|
|
270
|
+
paidEvent("evt_test_proposal_second", { id: OTHER_SESSION_ID }),
|
|
271
|
+
);
|
|
272
|
+
expect(res.statusCode).toBe(200);
|
|
273
|
+
|
|
274
|
+
// The first payment's stamp stands.
|
|
275
|
+
expect(piece(PIECE_A1)["paidAt"]).toBe(paidAt);
|
|
276
|
+
expect(piece(PIECE_A1)["paidSessionId"]).toBe(SESSION_ID);
|
|
277
|
+
|
|
278
|
+
expect(auditCreate).toHaveBeenCalledTimes(1);
|
|
279
|
+
expect(auditRows()).toHaveLength(2);
|
|
280
|
+
expect(auditRows()[1]).toMatchObject({
|
|
281
|
+
workspaceId: WS_A1,
|
|
282
|
+
actorType: "system",
|
|
283
|
+
actorId: "stripe",
|
|
284
|
+
action: "people.proposal.paid",
|
|
285
|
+
targetType: "piece",
|
|
286
|
+
targetId: PIECE_A1,
|
|
287
|
+
metadata: {
|
|
288
|
+
outcome: "failed",
|
|
289
|
+
failureReason: "already_paid",
|
|
290
|
+
idempotencyKey: "evt_test_proposal_second",
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ── A session that is not paid (AC-7) ──────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
describe("a completed session that is not paid", () => {
|
|
299
|
+
it("writes one failed row (not_paid) and stamps nothing", async () => {
|
|
300
|
+
const res = await post(
|
|
301
|
+
paidEvent("evt_test_proposal_unpaid", { payment_status: "unpaid" }),
|
|
302
|
+
);
|
|
303
|
+
expect(res.statusCode).toBe(200);
|
|
304
|
+
|
|
305
|
+
expect(piece(PIECE_A1)["paidAt"]).toBeNull();
|
|
306
|
+
expect(piece(PIECE_A1)["paidSessionId"]).toBeNull();
|
|
307
|
+
expect(pieceCalls()).toEqual([]);
|
|
308
|
+
|
|
309
|
+
expect(auditRows()).toHaveLength(1);
|
|
310
|
+
expect(auditRows()[0]).toMatchObject({
|
|
311
|
+
workspaceId: WS_A1,
|
|
312
|
+
actorType: "system",
|
|
313
|
+
actorId: "stripe",
|
|
314
|
+
action: "people.proposal.paid",
|
|
315
|
+
targetId: PIECE_A1,
|
|
316
|
+
metadata: {
|
|
317
|
+
outcome: "failed",
|
|
318
|
+
failureReason: "not_paid",
|
|
319
|
+
idempotencyKey: "evt_test_proposal_unpaid",
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ── No server metadata, no query (AC-7) ────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
describe("a session without the server's proposal metadata", () => {
|
|
328
|
+
it.each([
|
|
329
|
+
["no metadata at all", undefined],
|
|
330
|
+
["an empty metadata object", {}],
|
|
331
|
+
["only ailk_proposal", { ailk_proposal: PIECE_A1 }],
|
|
332
|
+
["only ailk_workspace", { ailk_workspace: WS_A1 }],
|
|
333
|
+
["malformed ids", { ailk_proposal: "piece_nope", ailk_workspace: "wksp_nope" }],
|
|
334
|
+
])("makes no piece query and writes no row (%s)", async (_label, metadata) => {
|
|
335
|
+
const res = await post(paidEvent("evt_test_proposal_nometa", { metadata }));
|
|
336
|
+
expect(res.statusCode).toBe(200);
|
|
337
|
+
expect(pieceCalls()).toEqual([]);
|
|
338
|
+
expect(auditRows()).toHaveLength(0);
|
|
339
|
+
expect(piece(PIECE_A1)["paidAt"]).toBeNull();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ── Workspace A never reaches workspace B (AC-6) ───────────────────────────
|
|
344
|
+
|
|
345
|
+
describe("a paid event whose metadata pairs WS_A1 with a piece of WS_A2", () => {
|
|
346
|
+
it("stamps nothing and writes no row", async () => {
|
|
347
|
+
const res = await post(
|
|
348
|
+
paidEvent("evt_test_proposal_cross", {
|
|
349
|
+
metadata: { ailk_proposal: PIECE_A2, ailk_workspace: WS_A1 },
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
expect(res.statusCode).toBe(200);
|
|
353
|
+
|
|
354
|
+
expect(piece(PIECE_A2)["paidAt"]).toBeNull();
|
|
355
|
+
expect(piece(PIECE_A2)["paidSessionId"]).toBeNull();
|
|
356
|
+
expect(piece(PIECE_A1)["paidAt"]).toBeNull();
|
|
357
|
+
expect(auditRows()).toHaveLength(0);
|
|
358
|
+
|
|
359
|
+
// Every piece statement was scoped by the metadata's workspace.
|
|
360
|
+
const pieceDelegate = fake.piece;
|
|
361
|
+
expect(pieceDelegate.updateMany).toHaveBeenCalledTimes(1);
|
|
362
|
+
expect(pieceDelegate.findFirst).toHaveBeenCalledTimes(1);
|
|
363
|
+
for (const mock of [pieceDelegate.updateMany, pieceDelegate.findFirst]) {
|
|
364
|
+
for (const [args] of mock.mock.calls as Array<[{ where: Row }]>) {
|
|
365
|
+
expect(args.where).toMatchObject({ id: PIECE_A2, workspaceId: WS_A1 });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
});
|
|
@@ -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";
|