create-ailk 0.20.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/README.md +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- 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/project-listings/__tests__/drafts.test.ts +26 -0
- 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/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- 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]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -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 +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- 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]/layout.tsx +23 -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/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- 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/jest.config.cjs +1 -0
- 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 +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- 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 +192 -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 +40 -12
- 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 +47 -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 +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- 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 +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file magnet-completion.test.ts
|
|
3
|
+
*
|
|
4
|
+
* #8757 D6 (spec docs/specs/8757-a-per-magnet-completion-setting-spec.md,
|
|
5
|
+
* AC-5's origin, link and copy): where a submitter's account link points,
|
|
6
|
+
* what the invite email says, and when a completion degrades to `lead`.
|
|
7
|
+
*
|
|
8
|
+
* Mocked at their own boundaries: the completion store, the sign-in client
|
|
9
|
+
* read, the commerce claim, the offerings lister and `@working-theory/email`.
|
|
10
|
+
*/
|
|
11
|
+
const mockLiveCompletionBySlug = jest.fn();
|
|
12
|
+
const mockInviteSendCounts = jest.fn();
|
|
13
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
14
|
+
jest.mock("../../lib/flow-completion-store.js", () => ({
|
|
15
|
+
liveCompletionBySlug: (...args: unknown[]) => mockLiveCompletionBySlug(...args),
|
|
16
|
+
inviteSendCounts: (...args: unknown[]) => mockInviteSendCounts(...args),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const mockFindClientByClientId = jest.fn();
|
|
20
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
21
|
+
jest.mock("../../lib/hub-sign-in-store.js", () => ({
|
|
22
|
+
findClientByClientId: (...args: unknown[]) => mockFindClientByClientId(...args),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const mockResolveCommerceClaim = jest.fn();
|
|
26
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
27
|
+
jest.mock("../../lib/require-commerce-claim.js", () => ({
|
|
28
|
+
resolveCommerceClaim: () => mockResolveCommerceClaim(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const mockListPlanOfferings = jest.fn();
|
|
32
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
33
|
+
jest.mock("../plan-offerings.js", () => ({
|
|
34
|
+
listPlanOfferings: (...args: unknown[]) => mockListPlanOfferings(...args),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const mockSendDeliverableEmail = jest.fn();
|
|
38
|
+
jest.mock("@working-theory/email", () => ({
|
|
39
|
+
sendDeliverableEmail: (...args: unknown[]) => mockSendDeliverableEmail(...args),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import { deliverableSenderAddress } from "../deliverable-fulfillment.js";
|
|
43
|
+
import {
|
|
44
|
+
accountTargetFor,
|
|
45
|
+
inviteSenderAddress,
|
|
46
|
+
MAGNET_COMPLETION_BUDGET_MS,
|
|
47
|
+
magnetCompletionFor,
|
|
48
|
+
magnetCompletionWithinBudget,
|
|
49
|
+
sendInviteConfirmation,
|
|
50
|
+
type MagnetCompletion,
|
|
51
|
+
} from "../magnet-completion.js";
|
|
52
|
+
|
|
53
|
+
const WS = "wksp_00000000-0000-7000-8000-0000000000c1";
|
|
54
|
+
const S1 = "spoke_00000000-0000-7000-8000-0000000000d1";
|
|
55
|
+
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
|
56
|
+
const originalEnv = process.env;
|
|
57
|
+
|
|
58
|
+
const client = (redirectUri: string, enabled = true) => ({
|
|
59
|
+
id: "oauthc_1",
|
|
60
|
+
clientId: S1,
|
|
61
|
+
clientSecret: "x",
|
|
62
|
+
redirectUris: [redirectUri],
|
|
63
|
+
enabled,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const invite = (config: unknown = { defaultLocale: "en" }): MagnetCompletion => ({
|
|
67
|
+
next: { completion: "invite" },
|
|
68
|
+
title: "Request a quote",
|
|
69
|
+
config,
|
|
70
|
+
target: { origin: "https://site.example", provider: "site" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
jest.clearAllMocks();
|
|
75
|
+
process.env = {
|
|
76
|
+
...originalEnv,
|
|
77
|
+
FRONTEND_URL: "https://site.example/app",
|
|
78
|
+
AILK_ENABLED_MODULES: "multi-tenant",
|
|
79
|
+
AILK_DELIVERABLE_EMAIL_FROM: "hello@site.example",
|
|
80
|
+
};
|
|
81
|
+
mockInviteSendCounts.mockResolvedValue({ recipientDay: 1, siteHour: 1 });
|
|
82
|
+
mockSendDeliverableEmail.mockResolvedValue({ ok: true, messageId: "m1" });
|
|
83
|
+
mockResolveCommerceClaim.mockResolvedValue("claimed");
|
|
84
|
+
mockListPlanOfferings.mockResolvedValue([{ key: "retainer", name: "Retainer", type: "service" }]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(() => {
|
|
88
|
+
process.env = originalEnv;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("accountTargetFor — the link's origin is never the request's", () => {
|
|
92
|
+
it("a keyless caller on a build with an account area gets FRONTEND_URL's origin and the site path", async () => {
|
|
93
|
+
expect(await accountTargetFor({ kind: "anonymous" })).toEqual({
|
|
94
|
+
origin: "https://site.example",
|
|
95
|
+
provider: "site",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("a keyless caller on a build with no account area gets nothing", async () => {
|
|
100
|
+
process.env.AILK_ENABLED_MODULES = "signal-collectors";
|
|
101
|
+
expect(await accountTargetFor({ kind: "anonymous" })).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("a spoke gets its enabled sign-in client's callback origin and the hub path", async () => {
|
|
105
|
+
mockFindClientByClientId.mockResolvedValue(client("https://spoke.example/api/hub-session/callback"));
|
|
106
|
+
expect(await accountTargetFor({ kind: "spoke", spokeId: S1, workspaceId: WS })).toEqual({
|
|
107
|
+
origin: "https://spoke.example",
|
|
108
|
+
provider: "hub",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("a spoke with no client, a disabled one, or an http callback in production gets nothing", async () => {
|
|
113
|
+
const spoke = { kind: "spoke", spokeId: S1, workspaceId: WS } as const;
|
|
114
|
+
mockFindClientByClientId.mockResolvedValueOnce(null);
|
|
115
|
+
expect(await accountTargetFor(spoke)).toBeNull();
|
|
116
|
+
mockFindClientByClientId.mockResolvedValueOnce(client("https://spoke.example/cb", false));
|
|
117
|
+
expect(await accountTargetFor(spoke)).toBeNull();
|
|
118
|
+
process.env.NODE_ENV = "production";
|
|
119
|
+
mockFindClientByClientId.mockResolvedValueOnce(client("http://localhost:3000/api/hub-session/callback"));
|
|
120
|
+
expect(await accountTargetFor(spoke)).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("an http loopback callback outside production is allowed (local development)", async () => {
|
|
124
|
+
process.env.NODE_ENV = "development";
|
|
125
|
+
mockFindClientByClientId.mockResolvedValue(client("http://localhost:3000/api/hub-session/callback"));
|
|
126
|
+
expect(await accountTargetFor({ kind: "spoke", spokeId: S1, workspaceId: WS })).toEqual({
|
|
127
|
+
origin: "http://localhost:3000",
|
|
128
|
+
provider: "hub",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("inviteSenderAddress — every completion email's sender", () => {
|
|
134
|
+
it.each([
|
|
135
|
+
[{ AILK_DELIVERABLE_EMAIL_FROM: "a@x.example", AILK_LEAD_NOTIFICATION_FROM: "b@x.example" }],
|
|
136
|
+
[{ AILK_DELIVERABLE_EMAIL_FROM: "", AILK_LEAD_NOTIFICATION_FROM: "b@x.example" }],
|
|
137
|
+
[{}],
|
|
138
|
+
])("answers what deliverableSenderAddress answers for %j", (env) => {
|
|
139
|
+
delete process.env.AILK_DELIVERABLE_EMAIL_FROM;
|
|
140
|
+
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
141
|
+
Object.assign(process.env, env);
|
|
142
|
+
expect(inviteSenderAddress()).toBe(deliverableSenderAddress());
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("magnetCompletionWithinBudget — a slow read never holds the 201", () => {
|
|
147
|
+
it("answers null once the budget passes", async () => {
|
|
148
|
+
jest.useFakeTimers();
|
|
149
|
+
try {
|
|
150
|
+
mockLiveCompletionBySlug.mockReturnValue(new Promise(() => undefined));
|
|
151
|
+
const pending = magnetCompletionWithinBudget(WS, "contact", async () => null);
|
|
152
|
+
jest.advanceTimersByTime(MAGNET_COMPLETION_BUDGET_MS);
|
|
153
|
+
expect(await pending).toBeNull();
|
|
154
|
+
} finally {
|
|
155
|
+
jest.useRealTimers();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("magnetCompletionFor", () => {
|
|
161
|
+
const target = { origin: "https://site.example", provider: "site" as const };
|
|
162
|
+
const live = (completion: string, checkoutOffering: string | null = null) =>
|
|
163
|
+
mockLiveCompletionBySlug.mockResolvedValue({
|
|
164
|
+
completion,
|
|
165
|
+
checkoutOffering,
|
|
166
|
+
title: "Contact form",
|
|
167
|
+
config: {},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("lead, or no live magnet, is null and never resolves a target", async () => {
|
|
171
|
+
const resolveTarget = jest.fn().mockResolvedValue(target);
|
|
172
|
+
live("lead");
|
|
173
|
+
expect(await magnetCompletionFor(WS, "contact", resolveTarget)).toBeNull();
|
|
174
|
+
mockLiveCompletionBySlug.mockResolvedValue(null);
|
|
175
|
+
expect(await magnetCompletionFor(WS, "contact", resolveTarget)).toBeNull();
|
|
176
|
+
expect(resolveTarget).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("invite with a target answers next: invite; with none degrades to lead", async () => {
|
|
180
|
+
live("invite");
|
|
181
|
+
expect(await magnetCompletionFor(WS, "contact", async () => target)).toMatchObject({
|
|
182
|
+
next: { completion: "invite" },
|
|
183
|
+
title: "Contact form",
|
|
184
|
+
target,
|
|
185
|
+
});
|
|
186
|
+
expect(await magnetCompletionFor(WS, "contact", async () => null)).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("checkout re-checks the claim and the listed offering, and never resolves a target", async () => {
|
|
190
|
+
const resolveTarget = jest.fn();
|
|
191
|
+
live("checkout", "retainer");
|
|
192
|
+
expect(await magnetCompletionFor(WS, "contact", resolveTarget)).toMatchObject({
|
|
193
|
+
next: { completion: "checkout", checkoutOffering: "retainer" },
|
|
194
|
+
});
|
|
195
|
+
expect(mockListPlanOfferings).toHaveBeenCalledWith(WS);
|
|
196
|
+
expect(resolveTarget).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("sendInviteConfirmation — copy and link", () => {
|
|
201
|
+
it("sends the defaults with the title filled in and the prefilled sign-in link", async () => {
|
|
202
|
+
expect(
|
|
203
|
+
await sendInviteConfirmation({
|
|
204
|
+
workspaceId: WS,
|
|
205
|
+
slug: "request-quote",
|
|
206
|
+
email: "a+b@x.example",
|
|
207
|
+
locale: undefined,
|
|
208
|
+
completion: invite(),
|
|
209
|
+
now: NOW,
|
|
210
|
+
}),
|
|
211
|
+
).toBe("sent");
|
|
212
|
+
expect(mockInviteSendCounts).toHaveBeenCalledWith(WS, "request-quote", "a+b@x.example", NOW);
|
|
213
|
+
expect(mockSendDeliverableEmail).toHaveBeenCalledWith({
|
|
214
|
+
to: "a+b@x.example",
|
|
215
|
+
fromAddress: "hello@site.example",
|
|
216
|
+
subject: "We received your Request a quote",
|
|
217
|
+
body: "Thanks — we have your Request a quote. You can follow it in your account.",
|
|
218
|
+
cta: "Track this in your account",
|
|
219
|
+
downloadLink: "https://site.example/sign-in?next=%2Faccount&email=a%2Bb%40x.example",
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("uses the magnet's completionEmail in the submitter's locale, else its default locale", async () => {
|
|
224
|
+
const config = {
|
|
225
|
+
defaultLocale: "en",
|
|
226
|
+
completionEmail: {
|
|
227
|
+
subject: { en: "Got it", fr: "Bien reçu" },
|
|
228
|
+
body: { en: "Thanks.", fr: "Merci." },
|
|
229
|
+
cta: { en: "Follow it", fr: "Suivre" },
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
await sendInviteConfirmation({
|
|
233
|
+
workspaceId: WS,
|
|
234
|
+
slug: "contact",
|
|
235
|
+
email: "a@b.example",
|
|
236
|
+
locale: "fr",
|
|
237
|
+
completion: invite(config),
|
|
238
|
+
now: NOW,
|
|
239
|
+
});
|
|
240
|
+
expect(mockSendDeliverableEmail.mock.calls[0][0]).toMatchObject({
|
|
241
|
+
subject: "Bien reçu",
|
|
242
|
+
body: "Merci.",
|
|
243
|
+
cta: "Suivre",
|
|
244
|
+
});
|
|
245
|
+
await sendInviteConfirmation({
|
|
246
|
+
workspaceId: WS,
|
|
247
|
+
slug: "contact",
|
|
248
|
+
email: "a@b.example",
|
|
249
|
+
locale: "de",
|
|
250
|
+
completion: invite(config),
|
|
251
|
+
now: NOW,
|
|
252
|
+
});
|
|
253
|
+
expect(mockSendDeliverableEmail.mock.calls[1][0]).toMatchObject({ subject: "Got it" });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it.each(["constructor", "toString", "__proto__"])(
|
|
257
|
+
"a payload locale of %s reads the default locale's copy, never an inherited member",
|
|
258
|
+
async (locale) => {
|
|
259
|
+
const config = {
|
|
260
|
+
defaultLocale: "en",
|
|
261
|
+
completionEmail: { subject: { en: "Got it" }, body: { en: "Thanks." } },
|
|
262
|
+
};
|
|
263
|
+
expect(
|
|
264
|
+
await sendInviteConfirmation({
|
|
265
|
+
workspaceId: WS,
|
|
266
|
+
slug: "contact",
|
|
267
|
+
email: "a@b.example",
|
|
268
|
+
locale,
|
|
269
|
+
completion: invite(config),
|
|
270
|
+
now: NOW,
|
|
271
|
+
}),
|
|
272
|
+
).toBe("sent");
|
|
273
|
+
expect(mockSendDeliverableEmail.mock.calls[0][0]).toMatchObject({
|
|
274
|
+
subject: "Got it",
|
|
275
|
+
body: "Thanks.",
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
it("an email the hint schema refuses gets no email (no_link)", async () => {
|
|
281
|
+
expect(
|
|
282
|
+
await sendInviteConfirmation({
|
|
283
|
+
workspaceId: WS,
|
|
284
|
+
slug: "contact",
|
|
285
|
+
email: "x\r\n@y",
|
|
286
|
+
locale: undefined,
|
|
287
|
+
completion: invite(),
|
|
288
|
+
now: NOW,
|
|
289
|
+
}),
|
|
290
|
+
).toBe("no_link");
|
|
291
|
+
expect(mockSendDeliverableEmail).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it.each([
|
|
295
|
+
[{ recipientDay: 2, siteHour: 2 }, "capped_recipient"],
|
|
296
|
+
[{ recipientDay: 1, siteHour: 51 }, "capped_site"],
|
|
297
|
+
])("counts %j cap the send (%s)", async (counts, outcome) => {
|
|
298
|
+
mockInviteSendCounts.mockResolvedValue(counts);
|
|
299
|
+
expect(
|
|
300
|
+
await sendInviteConfirmation({
|
|
301
|
+
workspaceId: WS,
|
|
302
|
+
slug: "contact",
|
|
303
|
+
email: "a@b.example",
|
|
304
|
+
locale: undefined,
|
|
305
|
+
completion: invite(),
|
|
306
|
+
now: NOW,
|
|
307
|
+
}),
|
|
308
|
+
).toBe(outcome);
|
|
309
|
+
expect(mockSendDeliverableEmail).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("a send that fails or throws is send_failed, never a throw", async () => {
|
|
313
|
+
mockSendDeliverableEmail.mockResolvedValueOnce({ ok: false, error: { kind: "send_failed" } });
|
|
314
|
+
mockSendDeliverableEmail.mockRejectedValueOnce(new Error("down"));
|
|
315
|
+
const input = {
|
|
316
|
+
workspaceId: WS,
|
|
317
|
+
slug: "contact",
|
|
318
|
+
email: "a@b.example",
|
|
319
|
+
locale: undefined,
|
|
320
|
+
completion: invite(),
|
|
321
|
+
now: NOW,
|
|
322
|
+
};
|
|
323
|
+
expect(await sendInviteConfirmation(input)).toBe("send_failed");
|
|
324
|
+
expect(await sendInviteConfirmation(input)).toBe("send_failed");
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Read as text, never imported: an app does not import a package's source.
|
|
329
|
+
const monorepoFs = jest.requireActual<typeof import("node:fs")>("node:fs");
|
|
330
|
+
const monorepoPath = jest.requireActual<typeof import("node:path")>("node:path");
|
|
331
|
+
const MONOREPO = monorepoPath.resolve(__dirname, "../../../../..");
|
|
332
|
+
const SLICES = "packages/create-ailk/src/module-architecture.ts";
|
|
333
|
+
// Monorepo-only: a scaffolded site carries no packages/create-ailk, and the
|
|
334
|
+
// scaffold matrix runs this suite inside one (#8780), so the slice assertion
|
|
335
|
+
// skips where the slice map does not exist rather than failing to load.
|
|
336
|
+
const describeInMonorepo = monorepoFs.existsSync(monorepoPath.join(MONOREPO, SLICES))
|
|
337
|
+
? describe
|
|
338
|
+
: describe.skip;
|
|
339
|
+
|
|
340
|
+
describeInMonorepo("#8757 AC-3 / NFR6 — the lead path reaches only base-tree files", () => {
|
|
341
|
+
const fs = monorepoFs;
|
|
342
|
+
const path = monorepoPath;
|
|
343
|
+
const REPO = MONOREPO;
|
|
344
|
+
const read = (rel: string) => fs.readFileSync(path.join(REPO, rel), "utf8");
|
|
345
|
+
// Lazy: describe.skip still runs this body at collection time, so the slice
|
|
346
|
+
// map is read only inside a test, never while the suite loads.
|
|
347
|
+
let slicesCache: string | undefined;
|
|
348
|
+
const slicesText = () => (slicesCache ??= read(SLICES));
|
|
349
|
+
const sliceGlobs = () =>
|
|
350
|
+
[...slicesText().matchAll(/"(apps\/api\/src\/[^"]*)\/\*\*"/g)].map((m) => m[1]!);
|
|
351
|
+
const named = (rel: string) =>
|
|
352
|
+
slicesText().includes(`"${rel}"`) || sliceGlobs().some((dir) => rel.startsWith(`${dir}/`));
|
|
353
|
+
|
|
354
|
+
/** Every apps/api source file `entry` reaches through relative imports. */
|
|
355
|
+
function closure(entry: string): string[] {
|
|
356
|
+
const seen = new Set<string>();
|
|
357
|
+
const walk = (rel: string) => {
|
|
358
|
+
if (seen.has(rel)) return;
|
|
359
|
+
seen.add(rel);
|
|
360
|
+
for (const m of read(rel).matchAll(/^(?:import|export)[^;]*?from\s+["'](\.[^"']+)["']/gm)) {
|
|
361
|
+
const target = path.posix.normalize(
|
|
362
|
+
path.posix.join(path.posix.dirname(rel), m[1]!.replace(/\.js$/, ".ts")),
|
|
363
|
+
);
|
|
364
|
+
if (fs.existsSync(path.join(REPO, target))) walk(target);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
walk(entry);
|
|
368
|
+
return [...seen];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const NEW_BASE_FILES = [
|
|
372
|
+
"apps/api/src/lib/flow-completion-store.ts",
|
|
373
|
+
"apps/api/src/services/plan-offerings.ts",
|
|
374
|
+
"apps/api/src/services/magnet-completion.ts",
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
it("the three new files carry no oss: false marker and are named by no module slice", () => {
|
|
378
|
+
for (const rel of NEW_BASE_FILES) {
|
|
379
|
+
expect(read(rel).split("\n", 1)[0]).not.toMatch(/oss:\s*false/);
|
|
380
|
+
expect(named(rel)).toBe(false);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("routes/leads/index.ts imports, directly or not, no file a module slice owns", () => {
|
|
385
|
+
const reached = closure("apps/api/src/routes/leads/index.ts");
|
|
386
|
+
for (const rel of NEW_BASE_FILES) expect(reached).toContain(rel);
|
|
387
|
+
expect(reached.filter(named)).toEqual([]);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file checkout-buyer.ts
|
|
3
3
|
* @description Resolves a signed-in user into the Checkout buyer
|
|
4
|
-
* `POST /v1/checkout/sessions` pins and stamps (#6866 D3)
|
|
4
|
+
* `POST /v1/checkout/sessions` pins and stamps (#6866 D3), and — the other
|
|
5
|
+
* way round — a completed Checkout Session into its buyer's Person, the
|
|
6
|
+
* subject of the terms grant the hub webhook records (#8765 D14,
|
|
7
|
+
* `checkoutBuyerPerson`).
|
|
5
8
|
*
|
|
6
9
|
* A buyer is a user whose email is VERIFIED — the customer created for them
|
|
7
10
|
* carries the account email, and it must never carry an unproven one — and
|
|
@@ -11,13 +14,18 @@
|
|
|
11
14
|
* never re-points a non-null `billingCustomerId` (#6113), so a new stamped
|
|
12
15
|
* customer could only be an orphan.
|
|
13
16
|
*
|
|
14
|
-
* Base tree (N4): imports only `verified-email.ts
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* Base tree (N4): imports only `verified-email.ts`, `person.ts`,
|
|
18
|
+
* `lib/site.ts`, `lib/proposal-paid-store.ts` and `lib/prisma.ts` — all base
|
|
19
|
+
* tree — never the commerce
|
|
20
|
+
* overlay's `services/subscription.ts`. The billing read selects no email, so
|
|
21
|
+
* `tools/lint-email-authz.ts` has nothing to flag.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
import { prisma } from "../lib/prisma.js";
|
|
25
|
+
import { proposalPersonId } from "../lib/proposal-paid-store.js";
|
|
26
|
+
import { resolveSiteWorkspaceId } from "../lib/site.js";
|
|
20
27
|
|
|
28
|
+
import { findOrCreatePerson } from "./person.js";
|
|
21
29
|
import { findVerifiedEmail } from "./verified-email.js";
|
|
22
30
|
|
|
23
31
|
export type CheckoutBuyer = {
|
|
@@ -70,3 +78,91 @@ export async function resolveCheckoutBuyer(
|
|
|
70
78
|
},
|
|
71
79
|
};
|
|
72
80
|
}
|
|
81
|
+
|
|
82
|
+
// ─── The buyer's Person (#8765 D14) ──────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/** What `checkoutBuyerPerson` reads of a signed `checkout.session.completed`. */
|
|
85
|
+
export type CheckoutBuyerSession = {
|
|
86
|
+
payment_status?: string | null;
|
|
87
|
+
customer_details?: { email?: string | null } | null;
|
|
88
|
+
metadata?: Record<string, string> | null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type CheckoutBuyerPerson = { workspaceId: string; personId: string };
|
|
92
|
+
|
|
93
|
+
function metadataString(
|
|
94
|
+
metadata: Record<string, string> | null | undefined,
|
|
95
|
+
key: string,
|
|
96
|
+
): string | null {
|
|
97
|
+
const value: unknown = metadata?.[key];
|
|
98
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The Person a completed Checkout Session's terms grant belongs to, or
|
|
103
|
+
* `null` (the webhook's `person_unresolved`). In order:
|
|
104
|
+
*
|
|
105
|
+
* - a proposal session (`metadata.ailk_proposal` + `ailk_workspace`): that
|
|
106
|
+
* piece's Person, when the proposal exists in that workspace;
|
|
107
|
+
* - a flow checkout (`metadata.flowCheckoutId`): `flowPerson`, the Person
|
|
108
|
+
* the webhook's Person step found or created for it — read, never
|
|
109
|
+
* created here.
|
|
110
|
+
* Neither falls through to the payer's typed email below;
|
|
111
|
+
* - a signed-in buyer (`metadata.ailk_user_id`, which
|
|
112
|
+
* `POST /v1/checkout/sessions` stamps only for a verified buyer, #6866):
|
|
113
|
+
* the Person of that user's VERIFIED email in the site workspace, found
|
|
114
|
+
* or created — in test mode too, since a verified email is the user's
|
|
115
|
+
* own; a user with no verified email is `null`, never the payer's typed
|
|
116
|
+
* email (SEC-8765-F);
|
|
117
|
+
* - any other paid LIVE-mode session with `customer_details.email`: that
|
|
118
|
+
* email's Person in the site workspace, found or created. A test-mode
|
|
119
|
+
* session never takes this branch, so a sandbox cannot mint a Person
|
|
120
|
+
* from a typed email.
|
|
121
|
+
*
|
|
122
|
+
* The site workspace is `resolveSiteWorkspaceId()`; `null` there is `null`
|
|
123
|
+
* here. Never throws — a store failure is `null`.
|
|
124
|
+
*
|
|
125
|
+
* @param session the signature-verified event's session object.
|
|
126
|
+
* @param livemode the signed event's `livemode`.
|
|
127
|
+
* @param flowPerson a flow checkout's Person, from `resolveCheckoutPerson`.
|
|
128
|
+
*/
|
|
129
|
+
export async function checkoutBuyerPerson(
|
|
130
|
+
session: CheckoutBuyerSession,
|
|
131
|
+
livemode: boolean,
|
|
132
|
+
flowPerson: CheckoutBuyerPerson | null = null,
|
|
133
|
+
): Promise<CheckoutBuyerPerson | null> {
|
|
134
|
+
const metadata = session.metadata;
|
|
135
|
+
try {
|
|
136
|
+
const pieceId = metadataString(metadata, "ailk_proposal");
|
|
137
|
+
const proposalWorkspaceId = metadataString(metadata, "ailk_workspace");
|
|
138
|
+
if (pieceId !== null && proposalWorkspaceId !== null) {
|
|
139
|
+
const personId = await proposalPersonId(proposalWorkspaceId, pieceId);
|
|
140
|
+
return personId === null
|
|
141
|
+
? null
|
|
142
|
+
: { workspaceId: proposalWorkspaceId, personId };
|
|
143
|
+
}
|
|
144
|
+
if (metadataString(metadata, "flowCheckoutId") !== null) return flowPerson;
|
|
145
|
+
|
|
146
|
+
const buyerUserId = metadataString(metadata, "ailk_user_id");
|
|
147
|
+
let email: string;
|
|
148
|
+
if (buyerUserId !== null) {
|
|
149
|
+
const verified = await findVerifiedEmail(buyerUserId);
|
|
150
|
+
if (!verified.ok) return null;
|
|
151
|
+
email = verified.value.email;
|
|
152
|
+
} else {
|
|
153
|
+
const payerEmail = session.customer_details?.email;
|
|
154
|
+
const paid =
|
|
155
|
+
session.payment_status === "paid" ||
|
|
156
|
+
session.payment_status === "no_payment_required";
|
|
157
|
+
if (!livemode || !paid || typeof payerEmail !== "string") return null;
|
|
158
|
+
email = payerEmail;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const workspaceId = await resolveSiteWorkspaceId();
|
|
162
|
+
if (workspaceId === null) return null;
|
|
163
|
+
const person = await findOrCreatePerson(workspaceId, { email });
|
|
164
|
+
return person.ok ? { workspaceId, personId: person.value.personId } : null;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -34,6 +34,7 @@ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
|
34
34
|
import type { Prisma } from "@working-theory/database";
|
|
35
35
|
import { logger } from "@working-theory/observability";
|
|
36
36
|
import {
|
|
37
|
+
ACCOUNT_TERMS_PURPOSE_ID,
|
|
37
38
|
consentPolicyHref,
|
|
38
39
|
consentPurposeById,
|
|
39
40
|
consentPurposeKind,
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
effectiveConsent,
|
|
53
54
|
latestVisitorChoice,
|
|
54
55
|
ownConsentRows,
|
|
56
|
+
personHasCheckoutGrant,
|
|
55
57
|
userPlatformRows,
|
|
56
58
|
type PurposeConsentState,
|
|
57
59
|
} from "../lib/consent-store.js";
|
|
@@ -507,6 +509,99 @@ export async function recordPersonConsent(
|
|
|
507
509
|
});
|
|
508
510
|
}
|
|
509
511
|
|
|
512
|
+
export type RecordCheckoutConsentOutcome =
|
|
513
|
+
"recorded" | "unchanged" | "purpose_unknown" | "version_changed";
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* `<purposeId>@<policyVersion>` of the account terms as the registry reads
|
|
517
|
+
* them now, and that version — the only stamp a checkout grant is recorded
|
|
518
|
+
* for (SEC-8765-E). A spoke purchase's lead names the stamp it accepted
|
|
519
|
+
* (#8778); derived, never written down, so a version bump retires every
|
|
520
|
+
* older stamp.
|
|
521
|
+
*/
|
|
522
|
+
export function currentAccountTermsStamp(): {
|
|
523
|
+
stamp: string;
|
|
524
|
+
policyVersion: number;
|
|
525
|
+
} | null {
|
|
526
|
+
const purpose = consentPurposeById(
|
|
527
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
528
|
+
ACCOUNT_TERMS_PURPOSE_ID,
|
|
529
|
+
);
|
|
530
|
+
return purpose
|
|
531
|
+
? {
|
|
532
|
+
stamp: `${purpose.id}@${purpose.policyVersion}`,
|
|
533
|
+
policyVersion: purpose.policyVersion,
|
|
534
|
+
}
|
|
535
|
+
: null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The buyer's grant from a completed Stripe Checkout whose terms box was
|
|
540
|
+
* accepted (#8765 D14): ONE `account_terms` row, subject `person` /
|
|
541
|
+
* `personId`, source `checkout`, `sourceRef` the Checkout Session id, locale
|
|
542
|
+
* `en`, and the registry's current statement, hash and policy version — the
|
|
543
|
+
* statement the box showed once its link keys were resolved (D12).
|
|
544
|
+
*
|
|
545
|
+
* One transaction whose first statement is an advisory lock on
|
|
546
|
+
* `ailk:consent:<workspaceId>:checkout:<sessionId>`; then, when this Person
|
|
547
|
+
* already holds this session's `checkout` row, nothing is written (NFR7) — so
|
|
548
|
+
* a webhook redelivery, even after a restart, adds no row. The row goes
|
|
549
|
+
* through `recordConsent` on the transaction client. A `checkout` row is
|
|
550
|
+
* unverified (D13): it reaches only the operator's any-source reading.
|
|
551
|
+
* When the site registry's `account_terms` version is not the version the
|
|
552
|
+
* session was stamped with, nothing is written (`version_changed`): the row
|
|
553
|
+
* would otherwise record terms the box never showed.
|
|
554
|
+
*
|
|
555
|
+
* @param input.workspaceId the server-resolved workspace of the buyer's Person.
|
|
556
|
+
* @param input.personId the buyer's Person, resolved by `checkoutBuyerPerson`.
|
|
557
|
+
* @param input.sessionId the signed event's Checkout Session id.
|
|
558
|
+
* @param input.policyVersion the version the session's `ailk_terms` stamp names.
|
|
559
|
+
*/
|
|
560
|
+
export async function recordCheckoutConsent(input: {
|
|
561
|
+
workspaceId: string;
|
|
562
|
+
personId: string;
|
|
563
|
+
sessionId: string;
|
|
564
|
+
policyVersion: number;
|
|
565
|
+
}): Promise<RecordCheckoutConsentOutcome> {
|
|
566
|
+
const config = await consentConfigForSite(input.workspaceId);
|
|
567
|
+
const purpose = consentPurposeById(config, ACCOUNT_TERMS_PURPOSE_ID);
|
|
568
|
+
if (!purpose) return "purpose_unknown";
|
|
569
|
+
if (purpose.policyVersion !== input.policyVersion) return "version_changed";
|
|
570
|
+
|
|
571
|
+
return prisma.$transaction(async (tx) => {
|
|
572
|
+
await lockConsent(
|
|
573
|
+
tx,
|
|
574
|
+
`ailk:consent:${input.workspaceId}:checkout:${input.sessionId}`,
|
|
575
|
+
);
|
|
576
|
+
if (
|
|
577
|
+
await personHasCheckoutGrant(
|
|
578
|
+
input.workspaceId,
|
|
579
|
+
input.personId,
|
|
580
|
+
purpose.id,
|
|
581
|
+
input.sessionId,
|
|
582
|
+
tx,
|
|
583
|
+
)
|
|
584
|
+
) {
|
|
585
|
+
return "unchanged" as const;
|
|
586
|
+
}
|
|
587
|
+
await recordConsent(
|
|
588
|
+
{
|
|
589
|
+
siteId: input.workspaceId,
|
|
590
|
+
subjectType: "person",
|
|
591
|
+
subjectId: input.personId,
|
|
592
|
+
personId: input.personId,
|
|
593
|
+
source: "checkout",
|
|
594
|
+
sourceRef: input.sessionId,
|
|
595
|
+
locale: "en",
|
|
596
|
+
config,
|
|
597
|
+
consents: { [purpose.id]: true },
|
|
598
|
+
},
|
|
599
|
+
tx,
|
|
600
|
+
);
|
|
601
|
+
return "recorded" as const;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
510
605
|
/**
|
|
511
606
|
* One registry purpose as a settings screen reads it (D6): the wording in
|
|
512
607
|
* `locale`, the policy link, and the caller's OWN state. `state` is `null`
|