create-ailk 0.19.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/dist/add-module.js +7 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +9 -3
- package/dist/programmatic.js +8 -1
- package/dist/site-roster.js +21 -4
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +31 -1
- package/dist/topology.js +29 -1
- package/package.json +3 -3
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +10 -4
- 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 +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/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +11 -3
- 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 +15 -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 +111 -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 +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/consent.ts +488 -3
- 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/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- 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 +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
- 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 +122 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
- 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 +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
- 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 +66 -4
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
- 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 +41 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +54 -14
- package/templates/database/CHANGELOG.md +16 -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__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/inbox/schema.prisma +76 -10
- 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 +1 -1
- package/versions.json +35 -35
|
@@ -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",
|
|
@@ -55,6 +55,10 @@ import {
|
|
|
55
55
|
import { tokenRoutes } from "./routes/auth/tokens.js";
|
|
56
56
|
import { transactionalEmailRoute } from "./routes/auth/transactional-email.js";
|
|
57
57
|
import { userRoutes } from "./routes/auth/user.js";
|
|
58
|
+
import {
|
|
59
|
+
consentCookieRoute,
|
|
60
|
+
consentUnsubscribeRoute,
|
|
61
|
+
} from "./routes/consent/index.js";
|
|
58
62
|
import { getPageRoute } from "./routes/content/get-page.js";
|
|
59
63
|
import { listPagesRoute } from "./routes/content/list-pages.js";
|
|
60
64
|
import { listTypesRoute } from "./routes/content/list-types.js";
|
|
@@ -76,6 +80,7 @@ import {
|
|
|
76
80
|
registerTestAuthRoute,
|
|
77
81
|
shouldMountTestAuth,
|
|
78
82
|
} from "./routes/test/auth/session.js";
|
|
83
|
+
import { consentLinkSecretConfigured } from "./services/consent.js";
|
|
79
84
|
|
|
80
85
|
/**
|
|
81
86
|
* What a request looks like in a log line — Fastify's own default shape, minus
|
|
@@ -111,12 +116,18 @@ import {
|
|
|
111
116
|
*/
|
|
112
117
|
const SESSION_ID_LOG_SEGMENT = /^cs_[A-Za-z0-9_]+$/;
|
|
113
118
|
const LEAD_ID_LOG_SEGMENT = idRegex("lead");
|
|
119
|
+
// #8438 SEC-8438-10 — a third arm for `person_…` segments. The consents route
|
|
120
|
+
// (and every other `…/people/:personId…` route) would otherwise write a full
|
|
121
|
+
// Person id into the request log line, beside `remoteAddress`.
|
|
122
|
+
const PERSON_ID_LOG_SEGMENT = idRegex("person");
|
|
114
123
|
|
|
115
124
|
function redactSensitiveIdSegments(url: string): string {
|
|
116
125
|
return url
|
|
117
126
|
.split("/")
|
|
118
127
|
.map((segment) =>
|
|
119
|
-
SESSION_ID_LOG_SEGMENT.test(segment) ||
|
|
128
|
+
SESSION_ID_LOG_SEGMENT.test(segment) ||
|
|
129
|
+
LEAD_ID_LOG_SEGMENT.test(segment) ||
|
|
130
|
+
PERSON_ID_LOG_SEGMENT.test(segment)
|
|
120
131
|
? `${segment.slice(0, 8)}…`
|
|
121
132
|
: segment,
|
|
122
133
|
)
|
|
@@ -252,6 +263,11 @@ const MEMBER_ARTIFACT_REVIEW = "./routes/member/artifact-review.js";
|
|
|
252
263
|
const MEMBER_PROFILE = "./routes/member/profile.js";
|
|
253
264
|
const MEMBER_DELETION_REQUEST = "./routes/member/deletion-request.js";
|
|
254
265
|
const MEMBER_BOOKINGS = "./routes/member/bookings.js";
|
|
266
|
+
// #8438 D6 — the member's own consents, under the tenancy predicate beside the
|
|
267
|
+
// other member routes. D8 — the Admin owner's consents to Working Theory,
|
|
268
|
+
// under multi-tenant alone (the module that contributes the Admin app).
|
|
269
|
+
const MEMBER_CONSENTS = "./routes/member/consents.js";
|
|
270
|
+
const CONSENT_PLATFORM = "./routes/consent/platform.js";
|
|
255
271
|
// #3591 PR-B — the agent seat's management routes and an agent's own
|
|
256
272
|
// context, siblings of WORKSPACES_MEMBERS in the same workspace/multi-tenant
|
|
257
273
|
// scope.
|
|
@@ -406,6 +422,16 @@ const SLICE_PLUGINS = {
|
|
|
406
422
|
specifier: MEMBER_DELETION_REQUEST,
|
|
407
423
|
load: () => import(MEMBER_DELETION_REQUEST),
|
|
408
424
|
},
|
|
425
|
+
// #8438 D6 — the member's own consents, the SAME scope as the writes above.
|
|
426
|
+
memberConsentRoutes: {
|
|
427
|
+
specifier: MEMBER_CONSENTS,
|
|
428
|
+
load: () => import(MEMBER_CONSENTS),
|
|
429
|
+
},
|
|
430
|
+
// #8438 D8 — the owner's consents to Working Theory (multi-tenant only).
|
|
431
|
+
consentPlatformRoutes: {
|
|
432
|
+
specifier: CONSENT_PLATFORM,
|
|
433
|
+
load: () => import(CONSENT_PLATFORM),
|
|
434
|
+
},
|
|
409
435
|
// The one that reaches services/scheduling/ (scheduling-owned, D11).
|
|
410
436
|
memberBookingRoutes: {
|
|
411
437
|
specifier: MEMBER_BOOKINGS,
|
|
@@ -643,24 +669,29 @@ async function tenantPreHandlerForBuild(): Promise<FastifyPluginCallback> {
|
|
|
643
669
|
}
|
|
644
670
|
|
|
645
671
|
/**
|
|
646
|
-
* The commerce boot-time
|
|
672
|
+
* The commerce boot-time key admission — the one other slice entry that is
|
|
647
673
|
* not a route plugin, mirroring `tenantPreHandlerSlicePlugin()` above (D3).
|
|
648
|
-
* `lib/stripe.ts` is commerce-owned (D5) and its `
|
|
674
|
+
* `lib/stripe.ts` is commerce-owned (D5) and its `admitApiStripeKey` is the
|
|
649
675
|
* sole `apps/api` reach into the Stripe SDK wrapper package, so it can no
|
|
650
676
|
* longer be a static import (spec D1) once the module is stripped. The
|
|
651
677
|
* widened `const specifier: string` annotation is what defeats `tsc`
|
|
652
678
|
* resolution, exactly as it does above.
|
|
679
|
+
*
|
|
680
|
+
* #8450 D4: called with NO argument, so the admission covers the same
|
|
681
|
+
* `process.env` key `getStripeClient()` later reads. `STRIPE_MODE` unset is
|
|
682
|
+
* today's sandbox-only check; only a restricted live key beside
|
|
683
|
+
* `STRIPE_MODE=live` on a production build comes back `"live"`.
|
|
653
684
|
*/
|
|
654
|
-
async function
|
|
685
|
+
async function admitCommerceStripeKey(): Promise<"test" | "live"> {
|
|
655
686
|
const specifier: string = "./lib/stripe.js";
|
|
656
687
|
const mod = (await loadSlice(specifier, () => import(specifier))) as {
|
|
657
|
-
|
|
688
|
+
admitApiStripeKey?: () => "test" | "live";
|
|
658
689
|
};
|
|
659
|
-
const
|
|
660
|
-
if (!
|
|
661
|
-
throw new Error("slice export
|
|
690
|
+
const admitApiStripeKeyFn = mod.admitApiStripeKey;
|
|
691
|
+
if (!admitApiStripeKeyFn) {
|
|
692
|
+
throw new Error("slice export admitApiStripeKey missing from " + specifier);
|
|
662
693
|
}
|
|
663
|
-
|
|
694
|
+
return admitApiStripeKeyFn();
|
|
664
695
|
}
|
|
665
696
|
|
|
666
697
|
/**
|
|
@@ -676,6 +707,18 @@ async function assertCommerceSandboxKey(key: string): Promise<void> {
|
|
|
676
707
|
* carries a localhost default.
|
|
677
708
|
*/
|
|
678
709
|
function warnOnUnsetProductionProtections(): void {
|
|
710
|
+
// #8438 D9 — AILK_CONSENT_LINK_SECRET is optional, but a value shorter than
|
|
711
|
+
// 32 bytes is a misconfiguration worth an error at boot: it is treated as
|
|
712
|
+
// unset, so no unsubscribe link can be minted or honored. Never the value.
|
|
713
|
+
if (
|
|
714
|
+
process.env["AILK_CONSENT_LINK_SECRET"] !== undefined &&
|
|
715
|
+
process.env["AILK_CONSENT_LINK_SECRET"] !== "" &&
|
|
716
|
+
!consentLinkSecretConfigured()
|
|
717
|
+
) {
|
|
718
|
+
logger.error(
|
|
719
|
+
"[startup] AILK_CONSENT_LINK_SECRET is set but shorter than 32 bytes — treated as unset; no unsubscribe link can be minted or honored",
|
|
720
|
+
);
|
|
721
|
+
}
|
|
679
722
|
if (config.NODE_ENV !== "production") return;
|
|
680
723
|
if (!process.env["FRONTEND_URL"]) {
|
|
681
724
|
logger.warn(
|
|
@@ -713,14 +756,17 @@ export async function build() {
|
|
|
713
756
|
// site renewed. Never throws; without DATABASE_URL the file store stays.
|
|
714
757
|
await registerLicenseKeyStore();
|
|
715
758
|
|
|
716
|
-
// Runtime
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
// since D-64 (#5796): the
|
|
720
|
-
// tree now, so the key's
|
|
721
|
-
// question. A build with no
|
|
759
|
+
// Runtime key admission (#8450 D4): refuse, before any webhook or checkout
|
|
760
|
+
// call can exercise it, every live Stripe key this process cannot vouch
|
|
761
|
+
// for — all of them unless STRIPE_MODE=live sits beside a restricted live
|
|
762
|
+
// key on a production build. UNCONDITIONAL since D-64 (#5796): the
|
|
763
|
+
// commerce primitives and lib/stripe.ts are base tree now, so the key's
|
|
764
|
+
// presence — not a module flag — is the whole question. A build with no
|
|
765
|
+
// STRIPE_SECRET_KEY has nothing to admit.
|
|
766
|
+
let stripeMode: "test" | "live" = "test";
|
|
722
767
|
if (config.STRIPE_SECRET_KEY) {
|
|
723
|
-
await
|
|
768
|
+
stripeMode = await admitCommerceStripeKey();
|
|
769
|
+
logger.info({ stripeMode }, "[startup] stripe key admitted");
|
|
724
770
|
}
|
|
725
771
|
|
|
726
772
|
// trustProxy: true (#4730) — the Vercel serverless handler
|
|
@@ -933,6 +979,10 @@ export async function build() {
|
|
|
933
979
|
await authScope.register(
|
|
934
980
|
await sliceRoutePlugin("workspaceContextRoutes"),
|
|
935
981
|
);
|
|
982
|
+
// #8438 D8 — the Admin owner's consents to Working Theory. Auth only,
|
|
983
|
+
// NOT tenant-guarded (the owner role is checked inside the route), and
|
|
984
|
+
// deliberately NOT in `HUB_SESSION_ROUTES`: a cookie session only.
|
|
985
|
+
await authScope.register(await sliceRoutePlugin("consentPlatformRoutes"));
|
|
936
986
|
}
|
|
937
987
|
|
|
938
988
|
// The leads INBOX read (#5678) — auth only, NOT tenant-guarded, for the
|
|
@@ -1016,6 +1066,8 @@ export async function build() {
|
|
|
1016
1066
|
await authScope.register(await sliceRoutePlugin("memberArtifactReviewRoutes"));
|
|
1017
1067
|
await authScope.register(await sliceRoutePlugin("memberProfileRoutes"));
|
|
1018
1068
|
await authScope.register(await sliceRoutePlugin("memberDeletionRequestRoutes"));
|
|
1069
|
+
// #8438 D6 — the member's own consents, under the same predicate.
|
|
1070
|
+
await authScope.register(await sliceRoutePlugin("memberConsentRoutes"));
|
|
1019
1071
|
}
|
|
1020
1072
|
// #8169 D9 — the two member reads that reach services/scheduling/, which
|
|
1021
1073
|
// only the scheduling module delivers: a scaffold with a tenancy module
|
|
@@ -1325,7 +1377,25 @@ export async function build() {
|
|
|
1325
1377
|
// `GET /v1/checkout/sessions/:id` — the GET in its own nested,
|
|
1326
1378
|
// rate-limited sub-scope (D16), so this preHandler applies to both while
|
|
1327
1379
|
// the GET's own limiter applies to it alone.
|
|
1380
|
+
//
|
|
1381
|
+
// #8450 § 9 SEC-14 — a LIVE process gives both routes their own per-IP
|
|
1382
|
+
// `checkout` bucket on top of the global limiter, because live, the public
|
|
1383
|
+
// GET spends one read of the shared live Stripe account per request. It
|
|
1384
|
+
// lowers the per-IP ceiling per instance (the POST's from the global 20 to
|
|
1385
|
+
// 10; the POST and GET now also share one count); like every limiter here
|
|
1386
|
+
// it is in-memory, so it does not bound the total across serverless
|
|
1387
|
+
// instances. `onRequest`, so it counts before the claim gate. Accepted
|
|
1388
|
+
// residual: a server-side caller — apps/web's server actions, an MCP host's
|
|
1389
|
+
// `purchase` tool — puts all its buyers on its own egress IP, so they share
|
|
1390
|
+
// one bucket; `AILK_RATE_LIMIT_CHECKOUT_PER_MINUTE` raises the ceiling. A
|
|
1391
|
+
// test-mode process registers nothing new.
|
|
1328
1392
|
await server.register(async (checkoutScope) => {
|
|
1393
|
+
if (stripeMode === "live") {
|
|
1394
|
+
await checkoutScope.register(rateLimitMiddleware, {
|
|
1395
|
+
scope: "checkout",
|
|
1396
|
+
perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1329
1399
|
checkoutScope.addHook("preHandler", requireCommerceClaim);
|
|
1330
1400
|
await checkoutScope.register(await sliceRoutePlugin("checkoutRoutes"));
|
|
1331
1401
|
});
|
|
@@ -1464,6 +1534,31 @@ export async function build() {
|
|
|
1464
1534
|
// 10/min on form submissions. The global unauth ceiling above applies.
|
|
1465
1535
|
await server.register(leadCaptureStatusRoute);
|
|
1466
1536
|
|
|
1537
|
+
// The consent floor (#8438 D5, D9) — the cookie banner's log and the
|
|
1538
|
+
// unsubscribe landing. Top level, outside every module gate: consent
|
|
1539
|
+
// logging is part of the never-gated privacy floor. Each is in its OWN
|
|
1540
|
+
// rate-limited scope (`consent`, `unsubscribe`) so filling one never blocks
|
|
1541
|
+
// the other; with a forward key set, a spoke's visitors are counted one by
|
|
1542
|
+
// one (both routes are on FORWARDED_VISITOR_ROUTES). Without one, the
|
|
1543
|
+
// server's own IP is the bucket — the posture of the lead routes. Neither
|
|
1544
|
+
// is behind `authScope`: the caller is a visitor, or a person in their mail.
|
|
1545
|
+
await server.register(async (consentScope) => {
|
|
1546
|
+
consentScope.register(rateLimitMiddleware, {
|
|
1547
|
+
perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
1548
|
+
scope: "consent",
|
|
1549
|
+
trustedKeyDigests: visitorForwardKeyDigests,
|
|
1550
|
+
});
|
|
1551
|
+
await consentScope.register(consentCookieRoute);
|
|
1552
|
+
});
|
|
1553
|
+
await server.register(async (unsubscribeScope) => {
|
|
1554
|
+
unsubscribeScope.register(rateLimitMiddleware, {
|
|
1555
|
+
perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
1556
|
+
scope: "unsubscribe",
|
|
1557
|
+
trustedKeyDigests: visitorForwardKeyDigests,
|
|
1558
|
+
});
|
|
1559
|
+
await unsubscribeScope.register(consentUnsubscribeRoute);
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1467
1562
|
// Leads routes — rate-limit middleware scoped to this sub-router. Own
|
|
1468
1563
|
// default (10/min, pre-#4730) rather than inheriting the wider
|
|
1469
1564
|
// flows-sized default (#5111, DEF-004 — #4730's shared-default bump was
|