create-ailk 0.21.0 → 0.22.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/component-catalog.json +37 -3
- package/component-catalog.md +6 -5
- package/dist/add-module.js +9 -5
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-package.js +7 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +19 -5
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/surfaces.js +19 -14
- package/dist/sync-routes.js +3 -2
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.js +5 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/templates/apps/api/CLAUDE.md +15 -6
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +120 -9
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +5 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +6 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +24 -7
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +38 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +34 -1
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +34 -3
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's one lead writer (#8747): every lead carries `payload.site` (from
|
|
3
|
+
* `AILK_LEAD_SITE`) and `payload.page`, spread last so no caller field can
|
|
4
|
+
* override them; no `siteId` is sent (the hub names the tenant itself); only
|
|
5
|
+
* the visitor and consent-visitor headers are forwarded; a thrown submit
|
|
6
|
+
* resolves as `{ ok: false, status: 0 }` and logs no field value.
|
|
7
|
+
*/
|
|
8
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
9
|
+
leads: { submit: jest.fn() },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { leads } from "@working-theory/api-client";
|
|
13
|
+
|
|
14
|
+
import { writeLead } from "../lead-write";
|
|
15
|
+
|
|
16
|
+
const mockSubmit = leads.submit as jest.Mock;
|
|
17
|
+
const priorSite = process.env.AILK_LEAD_SITE;
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
jest.clearAllMocks();
|
|
21
|
+
if (priorSite === undefined) delete process.env.AILK_LEAD_SITE;
|
|
22
|
+
else process.env.AILK_LEAD_SITE = priorSite;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
type Body = { siteId?: unknown; source: string; email: string; payload: Record<string, unknown> };
|
|
26
|
+
|
|
27
|
+
function sentBody(): Body {
|
|
28
|
+
return mockSubmit.mock.calls[0][0] as Body;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("writeLead — the site marker", () => {
|
|
32
|
+
it("marks payload.site from AILK_LEAD_SITE and payload.page from the caller, and sends no siteId", async () => {
|
|
33
|
+
process.env.AILK_LEAD_SITE = "wendihenry";
|
|
34
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
35
|
+
|
|
36
|
+
const res = await writeLead({
|
|
37
|
+
source: "contact-form",
|
|
38
|
+
email: "jane@example.com",
|
|
39
|
+
page: "contact",
|
|
40
|
+
payload: { name: "Jane" },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(res.ok).toBe(true);
|
|
44
|
+
expect(sentBody()).toEqual({
|
|
45
|
+
source: "contact-form",
|
|
46
|
+
email: "jane@example.com",
|
|
47
|
+
payload: { name: "Jane", site: "wendihenry", page: "contact" },
|
|
48
|
+
});
|
|
49
|
+
expect(sentBody()).not.toHaveProperty("siteId");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("a caller's own site or page field never overrides the marker", async () => {
|
|
53
|
+
process.env.AILK_LEAD_SITE = "wendihenry";
|
|
54
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
|
|
55
|
+
|
|
56
|
+
await writeLead({
|
|
57
|
+
source: "contact-form",
|
|
58
|
+
email: "jane@example.com",
|
|
59
|
+
page: "contact",
|
|
60
|
+
payload: { site: "attacker", page: "checkout" },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(sentBody().payload).toEqual({ site: "wendihenry", page: "contact" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("trims AILK_LEAD_SITE", async () => {
|
|
67
|
+
process.env.AILK_LEAD_SITE = " wendihenry\n";
|
|
68
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
|
|
69
|
+
|
|
70
|
+
await writeLead({ source: "contact-form", email: "jane@example.com", page: "contact" });
|
|
71
|
+
|
|
72
|
+
expect(sentBody().payload.site).toBe("wendihenry");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it.each([
|
|
76
|
+
["unset", undefined],
|
|
77
|
+
["empty", ""],
|
|
78
|
+
["blank", " "],
|
|
79
|
+
])("with AILK_LEAD_SITE %s, writes no site — and drops a caller's own site", async (_label, value) => {
|
|
80
|
+
if (value === undefined) delete process.env.AILK_LEAD_SITE;
|
|
81
|
+
else process.env.AILK_LEAD_SITE = value;
|
|
82
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
|
|
83
|
+
|
|
84
|
+
await writeLead({
|
|
85
|
+
source: "contact-form",
|
|
86
|
+
email: "jane@example.com",
|
|
87
|
+
page: "contact",
|
|
88
|
+
payload: { name: "Jane", site: "attacker" },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(sentBody().payload).toEqual({ name: "Jane", page: "contact" });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("writeLead — forwarded headers", () => {
|
|
96
|
+
it("forwards only the visitor and consent-visitor headers", async () => {
|
|
97
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
|
|
98
|
+
|
|
99
|
+
await writeLead(
|
|
100
|
+
{ source: "contact-form", email: "jane@example.com", page: "contact" },
|
|
101
|
+
{
|
|
102
|
+
headers: {
|
|
103
|
+
"x-ailk-visitor-ip": "203.0.113.5",
|
|
104
|
+
"x-ailk-visitor-forward-key": "k",
|
|
105
|
+
"x-ailk-consent-visitor": "6f1e2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
|
|
106
|
+
"x-ailk-spoke-key": "forged",
|
|
107
|
+
"content-type": "text/plain",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(mockSubmit.mock.calls[0][1]).toEqual({
|
|
113
|
+
headers: {
|
|
114
|
+
"x-ailk-visitor-ip": "203.0.113.5",
|
|
115
|
+
"x-ailk-visitor-forward-key": "k",
|
|
116
|
+
"x-ailk-consent-visitor": "6f1e2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("calls leads.submit with one argument when no header survives", async () => {
|
|
122
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
|
|
123
|
+
|
|
124
|
+
await writeLead(
|
|
125
|
+
{ source: "contact-form", email: "jane@example.com", page: "contact" },
|
|
126
|
+
{ headers: { "x-ailk-spoke-key": "forged" } },
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(mockSubmit.mock.calls[0]).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("writeLead — a thrown submit", () => {
|
|
134
|
+
it("resolves as status 0 and logs the error name only", async () => {
|
|
135
|
+
mockSubmit.mockRejectedValueOnce(new TypeError("fetch failed for jane@example.com"));
|
|
136
|
+
const spy = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
137
|
+
|
|
138
|
+
const res = await writeLead({
|
|
139
|
+
source: "contact-form",
|
|
140
|
+
email: "jane@example.com",
|
|
141
|
+
page: "contact",
|
|
142
|
+
payload: { name: "Jane" },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(res).toEqual({ ok: false, status: 0, body: null });
|
|
146
|
+
const logged = spy.mock.calls.flat().map(String).join(" ");
|
|
147
|
+
expect(logged).toContain("TypeError");
|
|
148
|
+
expect(logged).not.toContain("jane@example.com");
|
|
149
|
+
expect(logged).not.toContain("Jane");
|
|
150
|
+
spy.mockRestore();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("passes the api-client's own failure through unchanged", async () => {
|
|
154
|
+
const failure = { ok: false, status: 503, body: { error: "lead_capture_not_configured" } };
|
|
155
|
+
mockSubmit.mockResolvedValueOnce(failure);
|
|
156
|
+
|
|
157
|
+
expect(
|
|
158
|
+
await writeLead({ source: "contact-form", email: "jane@example.com", page: "contact" }),
|
|
159
|
+
).toEqual(failure);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("writeLead — terms acceptance (#8730)", () => {
|
|
164
|
+
it("sends consent: true at the top level when the caller says the terms were accepted", async () => {
|
|
165
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
|
|
166
|
+
|
|
167
|
+
await writeLead({
|
|
168
|
+
source: "contact-form",
|
|
169
|
+
email: "jane@example.com",
|
|
170
|
+
page: "contact",
|
|
171
|
+
consent: true,
|
|
172
|
+
payload: { name: "Jane" },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const body = mockSubmit.mock.calls[0][0] as Record<string, unknown>;
|
|
176
|
+
expect(body["consent"]).toBe(true);
|
|
177
|
+
expect((body["payload"] as Record<string, unknown>)["consent"]).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("sends no consent key at all when the caller does not set it", async () => {
|
|
181
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
|
|
182
|
+
|
|
183
|
+
await writeLead({ source: "contact-form", email: "jane@example.com", page: "contact" });
|
|
184
|
+
|
|
185
|
+
expect(mockSubmit.mock.calls[0][0]).not.toHaveProperty("consent");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-visitor limiter the /contact action runs before it writes a lead
|
|
3
|
+
* (#8747): a fixed window per key, refusing the call past the limit, and a
|
|
4
|
+
* client address read from the platform's own headers.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
__resetRateLimit,
|
|
8
|
+
checkRateLimit,
|
|
9
|
+
clientIpFromHeaders,
|
|
10
|
+
} from "../rate-limit";
|
|
11
|
+
|
|
12
|
+
const WINDOW_MS = 10 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
function headersOf(values: Record<string, string>): { get(name: string): string | null } {
|
|
15
|
+
return { get: (name) => values[name] ?? null };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
__resetRateLimit();
|
|
20
|
+
jest.useRealTimers();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("checkRateLimit", () => {
|
|
24
|
+
it("admits exactly `limit` calls in a window and refuses the next", () => {
|
|
25
|
+
for (let i = 0; i < 30; i += 1) {
|
|
26
|
+
expect(checkRateLimit("contact:203.0.113.5", 30, WINDOW_MS)).toBe(true);
|
|
27
|
+
}
|
|
28
|
+
expect(checkRateLimit("contact:203.0.113.5", 30, WINDOW_MS)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("counts each key on its own", () => {
|
|
32
|
+
for (let i = 0; i < 30; i += 1) checkRateLimit("contact:a", 30, WINDOW_MS);
|
|
33
|
+
expect(checkRateLimit("contact:a", 30, WINDOW_MS)).toBe(false);
|
|
34
|
+
expect(checkRateLimit("contact:b", 30, WINDOW_MS)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("opens a new window once the old one ends", () => {
|
|
38
|
+
jest.useFakeTimers();
|
|
39
|
+
jest.setSystemTime(new Date("2026-10-15T10:00:00Z"));
|
|
40
|
+
for (let i = 0; i < 30; i += 1) checkRateLimit("contact:a", 30, WINDOW_MS);
|
|
41
|
+
expect(checkRateLimit("contact:a", 30, WINDOW_MS)).toBe(false);
|
|
42
|
+
|
|
43
|
+
jest.setSystemTime(new Date("2026-10-15T10:10:00Z"));
|
|
44
|
+
expect(checkRateLimit("contact:a", 30, WINDOW_MS)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("clientIpFromHeaders", () => {
|
|
49
|
+
it("prefers x-vercel-forwarded-for, first value", () => {
|
|
50
|
+
expect(
|
|
51
|
+
clientIpFromHeaders(
|
|
52
|
+
headersOf({
|
|
53
|
+
"x-vercel-forwarded-for": "198.51.100.7, 10.0.0.1",
|
|
54
|
+
"x-forwarded-for": "203.0.113.5",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
).toBe("198.51.100.7");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to x-forwarded-for, then x-real-ip, then unknown", () => {
|
|
61
|
+
expect(clientIpFromHeaders(headersOf({ "x-forwarded-for": "203.0.113.5, 10.0.0.1" }))).toBe(
|
|
62
|
+
"203.0.113.5",
|
|
63
|
+
);
|
|
64
|
+
expect(clientIpFromHeaders(headersOf({ "x-real-ip": "192.0.2.9" }))).toBe("192.0.2.9");
|
|
65
|
+
expect(clientIpFromHeaders(headersOf({}))).toBe("unknown");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lead-write.ts — this site's lead writer (#8747): the /contact action writes
|
|
3
|
+
* through it, and so can any other page that writes a lead.
|
|
4
|
+
*
|
|
5
|
+
* A lead goes to `POST /v1/leads/submit` through `@working-theory/api-client`.
|
|
6
|
+
* The API resolves the lead's workspace itself — from the spoke key on a hub,
|
|
7
|
+
* from its own site alias otherwise — and ignores a body `siteId`, so none is
|
|
8
|
+
* sent. The site marks itself in the payload instead: `payload.site` is
|
|
9
|
+
* `AILK_LEAD_SITE` (the same variable `apps/mcp`'s `capture_lead` reads,
|
|
10
|
+
* `LEAD_SITE_MARKER_KEY`) and `payload.page` the page key, both spread LAST so
|
|
11
|
+
* no caller field can override them. With `AILK_LEAD_SITE` unset or blank the
|
|
12
|
+
* lead carries no `site` at all — a caller's own `site` is dropped, never sent.
|
|
13
|
+
*
|
|
14
|
+
* The marker is attribution, not authorization: the route is public and a
|
|
15
|
+
* direct caller can write any `payload.site`. Nothing may gate on it.
|
|
16
|
+
*
|
|
17
|
+
* Terms acceptance (#8730): a caller that collected the visitor's acceptance
|
|
18
|
+
* passes `consent: true`, sent as the body's top-level `consent`. The API
|
|
19
|
+
* refuses a contact lead without it and records `payload.consent` itself, so
|
|
20
|
+
* nothing about the acceptance is written into the payload here.
|
|
21
|
+
*
|
|
22
|
+
* Forwarded headers: only the two `visitorForwardHeaders` builds (#7394) and
|
|
23
|
+
* the consent-visitor id (#8438 D12) reach the api-client, so no caller can
|
|
24
|
+
* set its own `x-ailk-spoke-key` or `content-type`. With none of the three,
|
|
25
|
+
* `leads.submit` is called with one argument.
|
|
26
|
+
*
|
|
27
|
+
* Never throws. The api-client's own result passes through unchanged, so a
|
|
28
|
+
* caller still reads the API's 503 `lead_capture_not_configured`. A thrown
|
|
29
|
+
* submit — the API could not be reached — resolves as
|
|
30
|
+
* `{ ok: false, status: 0, body: null }` and logs the error's NAME only: its
|
|
31
|
+
* message could echo the payload.
|
|
32
|
+
*/
|
|
33
|
+
import { leads } from "@working-theory/api-client";
|
|
34
|
+
import { LEAD_SITE_MARKER_KEY } from "@working-theory/validation";
|
|
35
|
+
|
|
36
|
+
export interface LeadWriteInput {
|
|
37
|
+
source: string;
|
|
38
|
+
email: string;
|
|
39
|
+
/** The page key the marker carries, e.g. `contact` or `checkout`. */
|
|
40
|
+
page: string;
|
|
41
|
+
/** The visitor accepted the terms (#8730). Sent only when `true`. */
|
|
42
|
+
consent?: boolean;
|
|
43
|
+
payload?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type SubmitInit = Parameters<typeof leads.submit>[1];
|
|
47
|
+
type SubmitResult = Awaited<ReturnType<typeof leads.submit>>;
|
|
48
|
+
|
|
49
|
+
/** The only request headers a lead write forwards. */
|
|
50
|
+
const FORWARDED_HEADERS = [
|
|
51
|
+
"x-ailk-visitor-ip",
|
|
52
|
+
"x-ailk-visitor-forward-key",
|
|
53
|
+
"x-ailk-consent-visitor",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
function forwardOnly(init: SubmitInit): SubmitInit {
|
|
57
|
+
const headers: Record<string, string> = {};
|
|
58
|
+
for (const name of FORWARDED_HEADERS) {
|
|
59
|
+
const value = init?.headers?.[name];
|
|
60
|
+
if (typeof value === "string") headers[name] = value;
|
|
61
|
+
}
|
|
62
|
+
return Object.keys(headers).length > 0 ? { headers } : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The site marker from configuration, or `undefined` when unset or blank. */
|
|
66
|
+
function leadSite(): string | undefined {
|
|
67
|
+
return process.env.AILK_LEAD_SITE?.trim() || undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function writeLead(input: LeadWriteInput, init?: SubmitInit): Promise<SubmitResult> {
|
|
71
|
+
const site = leadSite();
|
|
72
|
+
const { [LEAD_SITE_MARKER_KEY]: _callerSite, ...callerPayload } = input.payload ?? {};
|
|
73
|
+
const body = {
|
|
74
|
+
source: input.source,
|
|
75
|
+
email: input.email,
|
|
76
|
+
...(input.consent === true && { consent: true }),
|
|
77
|
+
payload: {
|
|
78
|
+
...callerPayload,
|
|
79
|
+
...(site && { [LEAD_SITE_MARKER_KEY]: site }),
|
|
80
|
+
page: input.page,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const forward = forwardOnly(init);
|
|
84
|
+
try {
|
|
85
|
+
return forward ? await leads.submit(body, forward) : await leads.submit(body);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(`[lead-write] submit failed: ${err instanceof Error ? err.name : "unknown"}`);
|
|
88
|
+
return { ok: false, status: 0, body: null };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rate-limit.ts — a best-effort, in-memory, per-instance limiter (#8747).
|
|
3
|
+
*
|
|
4
|
+
* The /contact Server Action runs it before it writes a lead. From a server
|
|
5
|
+
* action the hub sees this site's own egress address, so without a limit
|
|
6
|
+
* here one visitor could drain the hub's per-IP budget for every visitor of
|
|
7
|
+
* the site. Brought upstream from the consumer sites' copies
|
|
8
|
+
* (salessmyth/samhenry, salessmyth/wendihenry `apps/web/src/lib/rate-limit.ts`).
|
|
9
|
+
*
|
|
10
|
+
* NOT a distributed limiter: state lives in one serverless instance's memory
|
|
11
|
+
* and resets on a cold start. It raises the bar against a flood on one warm
|
|
12
|
+
* instance; it does not stop one spread across many. The hub keeps its own
|
|
13
|
+
* per-IP limit on the route.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
type Bucket = { count: number; resetAt: number };
|
|
17
|
+
|
|
18
|
+
const buckets = new Map<string, Bucket>();
|
|
19
|
+
|
|
20
|
+
// Bound the map so a caller rotating keys (spoofed addresses) cannot grow it
|
|
21
|
+
// without limit between resets.
|
|
22
|
+
const MAX_TRACKED_KEYS = 5000;
|
|
23
|
+
|
|
24
|
+
/** Test-only reset. Not reachable from a request. */
|
|
25
|
+
export function __resetRateLimit(): void {
|
|
26
|
+
buckets.clear();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fixed-window check. `true` when the caller is under `limit` in the current
|
|
31
|
+
* window (and counts the call); `false` once it has used `limit` calls.
|
|
32
|
+
*/
|
|
33
|
+
export function checkRateLimit(key: string, limit: number, windowMs: number): boolean {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const existing = buckets.get(key);
|
|
36
|
+
|
|
37
|
+
if (!existing || existing.resetAt <= now) {
|
|
38
|
+
if (buckets.size >= MAX_TRACKED_KEYS) pruneExpired(now);
|
|
39
|
+
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (existing.count >= limit) return false;
|
|
44
|
+
|
|
45
|
+
existing.count += 1;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pruneExpired(now: number): void {
|
|
50
|
+
for (const [key, bucket] of buckets) {
|
|
51
|
+
if (bucket.resetAt <= now) buckets.delete(key);
|
|
52
|
+
}
|
|
53
|
+
// Nothing expired (keys rotated inside one window): evict the oldest entry
|
|
54
|
+
// — `Map` iterates in insertion order — so the bound always holds.
|
|
55
|
+
if (buckets.size >= MAX_TRACKED_KEYS) {
|
|
56
|
+
const oldestKey = buckets.keys().next().value;
|
|
57
|
+
if (oldestKey !== undefined) buckets.delete(oldestKey);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The subset of Next's `headers()` the address read needs. */
|
|
62
|
+
export interface HeaderReader {
|
|
63
|
+
get(name: string): string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The visitor's address, coarse: `x-vercel-forwarded-for`, else
|
|
68
|
+
* `x-forwarded-for`, else `x-real-ip` (first value of each), else
|
|
69
|
+
* `"unknown"`. Vercel's edge overwrites these with the connecting address;
|
|
70
|
+
* behind an edge that appends instead, a caller chooses its own key. An
|
|
71
|
+
* abuse-throttling key only — never an authorization decision.
|
|
72
|
+
*/
|
|
73
|
+
export function clientIpFromHeaders(headers: HeaderReader): string {
|
|
74
|
+
for (const name of ["x-vercel-forwarded-for", "x-forwarded-for"]) {
|
|
75
|
+
const value = headers.get(name);
|
|
76
|
+
if (value) return value.split(",")[0]?.trim() || "unknown";
|
|
77
|
+
}
|
|
78
|
+
return headers.get("x-real-ip") || "unknown";
|
|
79
|
+
}
|
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# @working-theory/database
|
|
2
2
|
|
|
3
|
+
## 0.43.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#8775](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8775) [`ee8e713`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/ee8e713b8194e8d65052f4b1dca928ca0c970318) Thanks [@claudio-planck](https://github.com/claudio-planck)! - A per-magnet completion setting — lead only, invite to account, or continue to checkout ([#8757](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8757), Phase 1).
|
|
8
|
+
|
|
9
|
+
- **`@working-theory/database`** — `flow_definitions` gains `completion` (`lead` | `invite` | `checkout`, default `lead`) and `checkout_offering`, closed by three CHECKs. The migration backfills `onboarding`, `request-quote` and `job-application` to `invite` by slug; `contact` and every other magnet stay `lead`. Deploy the migration before the API.
|
|
10
|
+
- **`@working-theory/validation`** — `flow-completion.ts`: `FLOW_COMPLETIONS`, `flowCompletionSettingSchema`, `flowCheckoutOfferingSchema`, `STANDARD_MAGNET_COMPLETIONS`, `leadSubmitNextSchema`, `accountEmailHintSchema`, `accountInvitePath`, the invite caps and the email defaults.
|
|
11
|
+
- **`@working-theory/api-client`** — `Magnet` carries `completion` and `checkoutOffering`, `MagnetUpdateInput` can set them, and `LeadWriteResponse` gains an optional `next`.
|
|
12
|
+
- **`@working-theory/ui`** — `AuthCard` takes an optional `defaultEmail`; `LeadCaptureFormState`'s success state takes an optional `next` link.
|
|
13
|
+
- **`@working-theory/analytics`** — `email` and `login_hint` are scrubbed from URLs like `session_id`.
|
|
14
|
+
- **`create-ailk`** — the API: the magnet PATCH writes the setting alone (`checkout` needs a person, the `commerce` claim and a published offering) and every full row serves it; a `flow:<slug>` submit to an `invite` magnet answers `next` and sends one capped confirmation email whose link starts sign-in with the address prefilled. The web: /contact's thank-you shows "Track this in your account", and sign-in opens with a hinted email on a site and through the hub. The Admin: one "When someone submits" control on the magnet page.
|
|
15
|
+
|
|
16
|
+
- [#8807](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8807) [`956bfe6`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/956bfe60fff4bd9a43135e6a2fb79e6f012e14f8) Thanks [@claudio-planck](https://github.com/claudio-planck)! - Every hosted Stripe Checkout session collects terms acceptance, and a completed checkout records a dated grant for the buyer ([#8765](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8765), PR B).
|
|
17
|
+
|
|
18
|
+
- **`@working-theory/stripe-client`** — both session creators set `consent_collection.terms_of_service: "required"` and a `custom_text.terms_of_service_acceptance` message: the site's account-terms sentence with its Terms, Privacy Policy and Working Agreement links resolved on the session's own return origin. Every session is stamped `metadata.ailk_terms` (`account_terms@2` by default). An optional `termsOfService` input overrides the sentence and its links. A Stripe refusal for a missing Terms URL is rethrown as `TermsOfServiceUrlMissingError`. **Precondition:** the seller's Stripe account needs a Terms of Service URL in its Dashboard's Public details, or Stripe refuses every session with the box.
|
|
19
|
+
- **`@working-theory/validation`** — `CONSENT_SOURCES` gains `checkout`.
|
|
20
|
+
- **`@working-theory/database`** — a migration widens the `consent_grants_source_known` CHECK to the eight sources. It writes no row.
|
|
21
|
+
- **`create-ailk`** — the API: the hub's `checkout.session.completed` webhook records one `checkout` grant per session for the buyer's Person when the session carries the terms stamp and Stripe recorded the consent as accepted. A checkout row is an unverified (any-source) row. The Admin labels the source "Checkout".
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- [#8767](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8767) [`5ff341d`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/5ff341d683d35a024fc1073c1b010aa56956a1d2) Thanks [@claudio-planck](https://github.com/claudio-planck)! - A JobPosting names where to apply, and a standard Job application magnet is seeded like the Contact form and drawn on every job page ([#8746](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8746)).
|
|
26
|
+
|
|
27
|
+
- **`@working-theory/schema`** — `ContentPageInput` gains an optional `origin` (the site's origin, passed by the caller; the package never reads the environment for it). When it is present, the JobPosting builder puts a `potentialAction` `ApplyAction` on the JobPosting whose `target` is the job page's own URL plus `#apply`: `<origin>/<locale>/careers/<slug>#apply`, the same form the page's BreadcrumbList uses. `buildJobPostingNode` takes an optional `applyUrl`. Every other builder ignores `origin`, so CareersList keeps its ItemList unchanged.
|
|
28
|
+
- **`@working-theory/page-renderer`** — `toContentPageInput` passes the render's origin (the one the BreadcrumbList is built from).
|
|
29
|
+
- **`@working-theory/validation`** — the magnet's one source: `JOB_APPLICATION_FLOW_SLUG` (`job-application`), `JOB_APPLICATION_FLOW_TITLE`, `JOB_APPLICATION_LANDING_PAGE` (`/careers`) and `jobApplicationFlowConfig` — one field step of First name, Last name and Email (required), Phone (`tel`) and LinkedIn URL (`url`). `RESERVED_FLOW_SLUGS` gains `job-application`, so no flow-engine write, spoke seed or new listing takes it.
|
|
30
|
+
- **`@working-theory/database`** — the database seed writes the default workspace's Job application magnet: `diagnostic`, `live`, landing page `/careers`, insert-only.
|
|
31
|
+
- **`create-ailk`** — the API seeds the magnet into every workspace insert-only (after `POST /v1/workspaces` and from the seed-presets CLI for existing workspaces), a magnet can be neither renamed from nor to `job-application`, and `GET /v1/content/:type/:slug` passes `_site.mdx`'s `baseUrl` as the origin, so the API's JobPosting JSON-LD carries the ApplyAction too. Each job page `/careers/<slug>` renders the magnet's field step under an `#apply` anchor, falling back to the same built-in form when the magnet is absent or off; a submission is a `flow:job-application` lead whose `name` is first + last and whose hidden `job` is the posting's slug, and the form thanks the applicant with the `careers` catalog's own `applicationSuccess` line. The `/careers` index carries no form.
|
|
32
|
+
|
|
33
|
+
- [#8788](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8788) [`3a89742`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/3a89742f7b816deca6cd604863f2fd041a79abfa) Thanks [@claudio-planck](https://github.com/claudio-planck)! - The standard magnets are seeded with their completion setting ([#8759](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8759)), so a workspace created after the [#8757](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8757) migration no longer depends on its backfill.
|
|
34
|
+
|
|
35
|
+
- **`@working-theory/database`** — the database seed writes `completion` on the default workspace's magnets: `invite` on `request-quote` and `job-application`, `lead` on `contact`. On a fresh install the migration's backfill runs before the seed and finds no rows.
|
|
36
|
+
- **`create-ailk`** — the API's insert-only seed writes `lead` on the Contact form and `invite` on the onboarding and Job application magnets, and `POST /v1/workspaces` creates the `request-quote` magnet at `invite`.
|
|
37
|
+
|
|
38
|
+
- Updated dependencies [[`5032b3e`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/5032b3eefb05bbe67ebddf1b4b07904717100472), [`5ff341d`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/5ff341d683d35a024fc1073c1b010aa56956a1d2), [`ee8e713`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/ee8e713b8194e8d65052f4b1dca928ca0c970318), [`c74bd72`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/c74bd7270fa2cb82bebedfb1b53f89ca6e09917f), [`956bfe6`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/956bfe60fff4bd9a43135e6a2fb79e6f012e14f8), [`97237f2`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/97237f278006a43e17d58709ed9ac3b4f62ae76f), [`1eb9688`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/1eb9688708dbc65cd4e2fdb6efeb40880e1606f0), [`b001413`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/b001413238973f4ecb3d4a5a18968cd7732210d8)]:
|
|
39
|
+
- @working-theory/validation@0.43.0
|
|
40
|
+
|
|
3
41
|
## 0.42.0
|
|
4
42
|
|
|
5
43
|
### Patch Changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file consent-source-checkout-migration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* DB-free proof of the consent_source_checkout migration's shape (#8765, spec
|
|
5
|
+
* docs/specs/8765-sign-up-refuses-without-the-spec.md D13, AC-8). Reads the
|
|
6
|
+
* migration's own text: it drops `consent_grants_source_known` and re-adds it
|
|
7
|
+
* `NOT VALID` with exactly the values of `CONSENT_SOURCES`, then `VALIDATE`s
|
|
8
|
+
* it — with no `UPDATE`, `INSERT` or `DELETE`, so the append-only ledger is
|
|
9
|
+
* never written.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { CONSENT_SOURCES } from "@working-theory/validation";
|
|
15
|
+
|
|
16
|
+
const MIGRATION = join(
|
|
17
|
+
__dirname,
|
|
18
|
+
"..",
|
|
19
|
+
"migrations",
|
|
20
|
+
"20261019120000_consent_source_checkout",
|
|
21
|
+
"migration.sql",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/** The migration's statements, comments stripped, whitespace collapsed. */
|
|
25
|
+
function statements(): string[] {
|
|
26
|
+
const text = readFileSync(MIGRATION, "utf8")
|
|
27
|
+
.split("\n")
|
|
28
|
+
.filter((line) => !line.trimStart().startsWith("--"))
|
|
29
|
+
.join("\n");
|
|
30
|
+
return text
|
|
31
|
+
.split(";")
|
|
32
|
+
.map((s) => s.replace(/\s+/g, " ").trim())
|
|
33
|
+
.filter((s) => s.length > 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("the consent_source_checkout migration (#8765 D13, AC-8)", () => {
|
|
37
|
+
it("drops the source CHECK, re-adds it NOT VALID, then VALIDATEs it — nothing else", () => {
|
|
38
|
+
const shapes = statements().map((s) => {
|
|
39
|
+
if (
|
|
40
|
+
s ===
|
|
41
|
+
'ALTER TABLE "consent_grants" DROP CONSTRAINT "consent_grants_source_known"'
|
|
42
|
+
) {
|
|
43
|
+
return "drop";
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
/^ALTER TABLE "consent_grants" ADD CONSTRAINT "consent_grants_source_known" CHECK \(.*\) NOT VALID$/.test(
|
|
47
|
+
s,
|
|
48
|
+
)
|
|
49
|
+
) {
|
|
50
|
+
return "add";
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
s ===
|
|
54
|
+
'ALTER TABLE "consent_grants" VALIDATE CONSTRAINT "consent_grants_source_known"'
|
|
55
|
+
) {
|
|
56
|
+
return "validate";
|
|
57
|
+
}
|
|
58
|
+
return `unexpected: ${s}`;
|
|
59
|
+
});
|
|
60
|
+
expect(shapes).toEqual(["drop", "add", "validate"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("names exactly the eight values of CONSENT_SOURCES, checkout among them", () => {
|
|
64
|
+
const add = statements().find((s) => s.includes("ADD CONSTRAINT"));
|
|
65
|
+
const list = add?.match(/CHECK \("source" IN \(([^)]*)\)\)/)?.[1];
|
|
66
|
+
expect(list).toBeDefined();
|
|
67
|
+
const values = (list ?? "")
|
|
68
|
+
.split(",")
|
|
69
|
+
.map((v) => v.trim().replace(/^'|'$/g, ""));
|
|
70
|
+
expect(values).toHaveLength(8);
|
|
71
|
+
expect([...values].sort()).toEqual([...CONSENT_SOURCES].sort());
|
|
72
|
+
expect(values).toContain("checkout");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("writes no row: no UPDATE, INSERT or DELETE", () => {
|
|
76
|
+
for (const s of statements())
|
|
77
|
+
expect(s).not.toMatch(/\b(UPDATE|INSERT|DELETE)\b/i);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file flow-completion-migration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* DB-free proof of the flow_completion migration's shape (#8757, spec
|
|
5
|
+
* docs/specs/8757-a-per-magnet-completion-setting-spec.md D2, AC-2). Reads
|
|
6
|
+
* the migration's own text: it adds exactly the two columns and three named
|
|
7
|
+
* CHECKs, each added `NOT VALID` then `VALIDATE`d, and its one `UPDATE` is the
|
|
8
|
+
* slug backfill between its markers — naming exactly the standard magnets
|
|
9
|
+
* whose default is `invite` (`STANDARD_MAGNET_COMPLETIONS`). It inserts and
|
|
10
|
+
* deletes nothing. The live-database half is `flow-completion.db.test.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { STANDARD_MAGNET_COMPLETIONS } from "@working-theory/validation";
|
|
16
|
+
|
|
17
|
+
const MIGRATION = join(
|
|
18
|
+
__dirname,
|
|
19
|
+
"..",
|
|
20
|
+
"migrations",
|
|
21
|
+
"20261018120000_flow_completion",
|
|
22
|
+
"migration.sql",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const CHECKS = [
|
|
26
|
+
"flow_definitions_completion_domain",
|
|
27
|
+
"flow_definitions_checkout_offering_pair",
|
|
28
|
+
"flow_definitions_checkout_offering_format",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** The migration's statements, comments stripped, whitespace collapsed. */
|
|
32
|
+
function statements(): string[] {
|
|
33
|
+
const text = readFileSync(MIGRATION, "utf8")
|
|
34
|
+
.split("\n")
|
|
35
|
+
.filter((line) => !line.trimStart().startsWith("--"))
|
|
36
|
+
.join("\n");
|
|
37
|
+
return text
|
|
38
|
+
.split(";")
|
|
39
|
+
.map((s) => s.replace(/\s+/g, " ").trim())
|
|
40
|
+
.filter((s) => s.length > 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The text between the `-- BEGIN completion backfill` / `-- END …` markers. */
|
|
44
|
+
function backfillBlock(): string {
|
|
45
|
+
const match = /-- BEGIN completion backfill\n([\s\S]*?)-- END completion backfill/.exec(
|
|
46
|
+
readFileSync(MIGRATION, "utf8"),
|
|
47
|
+
);
|
|
48
|
+
if (!match) throw new Error("completion backfill markers not found");
|
|
49
|
+
return match[1]!;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("the flow_completion migration (#8757 D2, AC-2)", () => {
|
|
53
|
+
it("adds exactly the two columns, the completion default lead", () => {
|
|
54
|
+
const adds = statements().filter((s) => /^ALTER TABLE "flow_definitions" ADD COLUMN /.test(s));
|
|
55
|
+
expect(adds).toHaveLength(1);
|
|
56
|
+
const columns = adds[0]!.split(/ADD COLUMN /).slice(1).map((c) => c.trim().replace(/,$/, ""));
|
|
57
|
+
expect(columns).toEqual([
|
|
58
|
+
`"completion" VARCHAR(16) NOT NULL DEFAULT 'lead'`,
|
|
59
|
+
`"checkout_offering" VARCHAR(40)`,
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("adds the three named CHECKs NOT VALID, then VALIDATEs each", () => {
|
|
64
|
+
const all = statements();
|
|
65
|
+
const added = all
|
|
66
|
+
.map((s) => /^ALTER TABLE "flow_definitions" ADD CONSTRAINT "([a-z_]+)" CHECK \(.*\) NOT VALID$/.exec(s)?.[1])
|
|
67
|
+
.filter((name): name is string => name !== undefined);
|
|
68
|
+
const validated = all
|
|
69
|
+
.map((s) => /^ALTER TABLE "flow_definitions" VALIDATE CONSTRAINT "([a-z_]+)"$/.exec(s)?.[1])
|
|
70
|
+
.filter((name): name is string => name !== undefined);
|
|
71
|
+
expect(added).toEqual(CHECKS);
|
|
72
|
+
expect(validated).toEqual(CHECKS);
|
|
73
|
+
for (const name of CHECKS) {
|
|
74
|
+
expect(all.findIndex((s) => s.includes(`VALIDATE CONSTRAINT "${name}"`))).toBeGreaterThan(
|
|
75
|
+
all.findIndex((s) => s.includes(`ADD CONSTRAINT "${name}"`)),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("holds exactly one UPDATE, inside the backfill markers, naming the invite defaults", () => {
|
|
81
|
+
const updates = statements().filter((s) => /\bUPDATE\b/i.test(s));
|
|
82
|
+
expect(updates).toHaveLength(1);
|
|
83
|
+
const block = backfillBlock().replace(/\s+/g, " ").trim();
|
|
84
|
+
expect(block).toBe(updates[0] + ";");
|
|
85
|
+
const list = /WHERE "slug" IN \(([^)]*)\)/.exec(block)?.[1];
|
|
86
|
+
const slugs = (list ?? "").split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
87
|
+
const invite = Object.entries(STANDARD_MAGNET_COMPLETIONS)
|
|
88
|
+
.filter(([, completion]) => completion === "invite")
|
|
89
|
+
.map(([slug]) => slug);
|
|
90
|
+
expect([...slugs].sort()).toEqual([...invite].sort());
|
|
91
|
+
expect(block).toContain(`SET "completion" = 'invite'`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("inserts and deletes nothing", () => {
|
|
95
|
+
for (const s of statements()) expect(s).not.toMatch(/\b(INSERT|DELETE)\b/i);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("every statement is one of the expected shapes", () => {
|
|
99
|
+
const unexpected = statements().filter(
|
|
100
|
+
(s) =>
|
|
101
|
+
!/^ALTER TABLE "flow_definitions" (ADD COLUMN|ADD CONSTRAINT|VALIDATE CONSTRAINT) /.test(s) &&
|
|
102
|
+
!/^UPDATE "flow_definitions" SET "completion" = 'invite' WHERE "slug" IN \(/.test(s),
|
|
103
|
+
);
|
|
104
|
+
expect(unexpected).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("schema.prisma declares both fields on FlowDefinition", () => {
|
|
108
|
+
const prisma = readFileSync(join(__dirname, "..", "inbox", "schema.prisma"), "utf8");
|
|
109
|
+
const model = /model FlowDefinition \{([\s\S]*?)\n\}/.exec(prisma)?.[1] ?? "";
|
|
110
|
+
expect(model).toMatch(/\n\s*completion String @default\("lead"\) @db\.VarChar\(16\)\n/);
|
|
111
|
+
expect(model).toMatch(
|
|
112
|
+
/\n\s*checkoutOffering String\? @map\("checkout_offering"\) @db\.VarChar\(40\)\n/,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|