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,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file flow-completion.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of a magnet's completion columns (#8757, spec
|
|
5
|
+
* docs/specs/8757-a-per-magnet-completion-setting-spec.md D2, AC-2). Requires
|
|
6
|
+
* DATABASE_URL pointing at a Postgres with the migrations applied — SKIPS
|
|
7
|
+
* when DATABASE_URL is unset (the repo convention; the `apps/api Route Tests
|
|
8
|
+
* (Postgres)` workflow exercises it).
|
|
9
|
+
*
|
|
10
|
+
* Uses the `pg` driver directly, in ONE transaction that is rolled back. What
|
|
11
|
+
* it proves that a mock cannot:
|
|
12
|
+
* 1. The migration's backfill block, run over magnets at the five slugs,
|
|
13
|
+
* leaves `contact` and `form` at `lead` and sets the three process
|
|
14
|
+
* magnets to `invite`; a second run changes nothing.
|
|
15
|
+
* 2. Each CHECK refuses what it names.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { Client } from "pg";
|
|
21
|
+
|
|
22
|
+
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
23
|
+
|
|
24
|
+
const MIGRATION_SQL = readFileSync(
|
|
25
|
+
join(__dirname, "..", "migrations", "20261018120000_flow_completion", "migration.sql"),
|
|
26
|
+
"utf8",
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/** The text between the `-- BEGIN completion backfill` / `-- END …` markers. */
|
|
30
|
+
function backfillBlock(sql: string): string {
|
|
31
|
+
const match = /-- BEGIN completion backfill\n([\s\S]*?)-- END completion backfill/.exec(sql);
|
|
32
|
+
if (!match?.[1]) throw new Error("completion backfill markers not found");
|
|
33
|
+
return match[1];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const WS = "wksp_01920000-8757-7000-8000-0000000000c1";
|
|
37
|
+
const SLUGS = ["contact", "onboarding", "request-quote", "job-application", "form"] as const;
|
|
38
|
+
|
|
39
|
+
describeDb("the magnet completion columns (#8757 D2, AC-2, live DB)", () => {
|
|
40
|
+
let db: Client;
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
44
|
+
await db.connect();
|
|
45
|
+
await db.query("BEGIN");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await db.query("ROLLBACK");
|
|
50
|
+
await db.end();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** Insert one magnet at `slug`, with any of the completion columns. */
|
|
54
|
+
const insertMagnet = (slug: string, cols: Record<string, unknown> = {}) => {
|
|
55
|
+
const all: Record<string, unknown> = {
|
|
56
|
+
site_id: WS,
|
|
57
|
+
workspace_id: WS,
|
|
58
|
+
slug,
|
|
59
|
+
config: JSON.stringify({ steps: [] }),
|
|
60
|
+
title: `Magnet ${slug}`,
|
|
61
|
+
status: "draft",
|
|
62
|
+
updated_at: new Date("2026-10-01T09:00:00.000Z"),
|
|
63
|
+
...cols,
|
|
64
|
+
};
|
|
65
|
+
const names = Object.keys(all);
|
|
66
|
+
return db.query<{ id: string }>(
|
|
67
|
+
`INSERT INTO "flow_definitions" (${names.map((n) => `"${n}"`).join(", ")})
|
|
68
|
+
VALUES (${names.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING "id"`,
|
|
69
|
+
names.map((n) => all[n]),
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Run `run` inside a savepoint; resolve to the violated constraint's name, or null. */
|
|
74
|
+
const violation = async (run: () => Promise<unknown>): Promise<string | null> => {
|
|
75
|
+
await db.query("SAVEPOINT attempt");
|
|
76
|
+
try {
|
|
77
|
+
await run();
|
|
78
|
+
await db.query("RELEASE SAVEPOINT attempt");
|
|
79
|
+
return null;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
await db.query("ROLLBACK TO SAVEPOINT attempt");
|
|
82
|
+
return (err as { constraint?: string }).constraint ?? String(err);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const completions = async () =>
|
|
87
|
+
Object.fromEntries(
|
|
88
|
+
(
|
|
89
|
+
await db.query<{ slug: string; completion: string; checkout_offering: string | null }>(
|
|
90
|
+
`SELECT "slug", "completion", "checkout_offering" FROM "flow_definitions" WHERE "workspace_id" = $1`,
|
|
91
|
+
[WS],
|
|
92
|
+
)
|
|
93
|
+
).rows.map((r) => [r.slug, [r.completion, r.checkout_offering]]),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
it("the backfill sets the three process magnets to invite and leaves contact and form at lead, idempotently", async () => {
|
|
97
|
+
for (const slug of SLUGS) await insertMagnet(slug);
|
|
98
|
+
const before = await completions();
|
|
99
|
+
for (const slug of SLUGS) expect(before[slug]).toEqual(["lead", null]);
|
|
100
|
+
|
|
101
|
+
await db.query(backfillBlock(MIGRATION_SQL));
|
|
102
|
+
const after = await completions();
|
|
103
|
+
expect(after).toEqual({
|
|
104
|
+
contact: ["lead", null],
|
|
105
|
+
form: ["lead", null],
|
|
106
|
+
onboarding: ["invite", null],
|
|
107
|
+
"request-quote": ["invite", null],
|
|
108
|
+
"job-application": ["invite", null],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const rerun = await db.query(backfillBlock(MIGRATION_SQL));
|
|
112
|
+
expect(await completions()).toEqual(after);
|
|
113
|
+
// The re-run matches the same rows but stores the same values.
|
|
114
|
+
expect(rerun.rowCount).toBeGreaterThanOrEqual(3);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each([
|
|
118
|
+
["checkout with no offering", { completion: "checkout", checkout_offering: null }, "flow_definitions_checkout_offering_pair"],
|
|
119
|
+
["an offering beside lead", { completion: "lead", checkout_offering: "retainer" }, "flow_definitions_checkout_offering_pair"],
|
|
120
|
+
["a capitalised key", { completion: "checkout", checkout_offering: "Bad" }, "flow_definitions_checkout_offering_format"],
|
|
121
|
+
["an underscored key", { completion: "checkout", checkout_offering: "a_b" }, "flow_definitions_checkout_offering_format"],
|
|
122
|
+
["a fourth value", { completion: "other", checkout_offering: null }, "flow_definitions_completion_domain"],
|
|
123
|
+
])("refuses %s", async (_label, cols, constraint) => {
|
|
124
|
+
expect(await violation(() => insertMagnet(`refused-${constraint.length}-${String(cols.completion)}`, cols))).toBe(
|
|
125
|
+
constraint,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("accepts checkout with a listed-shape offering", async () => {
|
|
130
|
+
expect(
|
|
131
|
+
await violation(() =>
|
|
132
|
+
insertMagnet("paid-waitlist", { completion: "checkout", checkout_offering: "retainer" }),
|
|
133
|
+
),
|
|
134
|
+
).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
import {
|
|
14
14
|
CONTACT_FORM_FLOW_SLUG,
|
|
15
15
|
contactFormFlowConfig,
|
|
16
|
+
JOB_APPLICATION_FLOW_SLUG,
|
|
17
|
+
jobApplicationFlowConfig,
|
|
16
18
|
REQUEST_QUOTE_FLOW_SLUG,
|
|
17
19
|
REQUEST_QUOTE_FLOW_TITLE,
|
|
18
20
|
requestQuoteFlowConfig,
|
|
21
|
+
STANDARD_MAGNET_COMPLETIONS,
|
|
19
22
|
} from "@working-theory/validation";
|
|
20
23
|
|
|
21
24
|
import {
|
|
@@ -109,7 +112,7 @@ describe("provisionDefaultWorkspace — the request-quote magnet (#8168 D8, C4,
|
|
|
109
112
|
|
|
110
113
|
await provisionDefaultWorkspace(prisma);
|
|
111
114
|
|
|
112
|
-
expect(flowDefinitionUpsert).toHaveBeenCalledTimes(
|
|
115
|
+
expect(flowDefinitionUpsert).toHaveBeenCalledTimes(3);
|
|
113
116
|
expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(1, {
|
|
114
117
|
where: {
|
|
115
118
|
siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: REQUEST_QUOTE_FLOW_SLUG },
|
|
@@ -123,6 +126,7 @@ describe("provisionDefaultWorkspace — the request-quote magnet (#8168 D8, C4,
|
|
|
123
126
|
title: REQUEST_QUOTE_FLOW_TITLE,
|
|
124
127
|
status: "draft",
|
|
125
128
|
customersEnabled: true,
|
|
129
|
+
completion: STANDARD_MAGNET_COMPLETIONS[REQUEST_QUOTE_FLOW_SLUG],
|
|
126
130
|
},
|
|
127
131
|
// Empty: an existing (site_id, slug) row is never overwritten (D8, C6).
|
|
128
132
|
update: {},
|
|
@@ -160,6 +164,7 @@ describe("provisionDefaultWorkspace — the Contact form magnet (#8661 D3, AC-1)
|
|
|
160
164
|
title: "Contact form",
|
|
161
165
|
status: "live",
|
|
162
166
|
customersEnabled: false,
|
|
167
|
+
completion: STANDARD_MAGNET_COMPLETIONS[CONTACT_FORM_FLOW_SLUG],
|
|
163
168
|
landingPage: "/contact",
|
|
164
169
|
},
|
|
165
170
|
// Empty: a re-run never overwrites the owner's edits.
|
|
@@ -175,3 +180,31 @@ describe("provisionDefaultWorkspace — the Contact form magnet (#8661 D3, AC-1)
|
|
|
175
180
|
expect(flowDefinitionUpsert).not.toHaveBeenCalled();
|
|
176
181
|
});
|
|
177
182
|
});
|
|
183
|
+
|
|
184
|
+
describe("provisionDefaultWorkspace — the Job application magnet (#8746 D3, AC-2)", () => {
|
|
185
|
+
it("upserts the default workspace's live Job application, insert-if-absent", async () => {
|
|
186
|
+
const { prisma, flowDefinitionUpsert } = makeStubPrisma(0);
|
|
187
|
+
|
|
188
|
+
await provisionDefaultWorkspace(prisma);
|
|
189
|
+
|
|
190
|
+
expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(3, {
|
|
191
|
+
where: {
|
|
192
|
+
siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: JOB_APPLICATION_FLOW_SLUG },
|
|
193
|
+
},
|
|
194
|
+
create: {
|
|
195
|
+
siteId: DEFAULT_WORKSPACE_ID,
|
|
196
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
197
|
+
slug: "job-application",
|
|
198
|
+
config: jobApplicationFlowConfig,
|
|
199
|
+
kind: "diagnostic",
|
|
200
|
+
title: "Job application",
|
|
201
|
+
status: "live",
|
|
202
|
+
customersEnabled: false,
|
|
203
|
+
completion: STANDARD_MAGNET_COMPLETIONS[JOB_APPLICATION_FLOW_SLUG],
|
|
204
|
+
landingPage: "/careers",
|
|
205
|
+
},
|
|
206
|
+
// Empty: a re-run never overwrites the owner's edits.
|
|
207
|
+
update: {},
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -473,6 +473,18 @@ model FlowDefinition {
|
|
|
473
473
|
/// `request-quote` magnet is `draft` (never served anonymously) with
|
|
474
474
|
/// `customersEnabled = true`.
|
|
475
475
|
customersEnabled Boolean @default(false) @map("customers_enabled")
|
|
476
|
+
/// #8757 D1/D2 — what happens after someone submits the magnet: `lead`
|
|
477
|
+
/// (the thank-you only), `invite` (an account link with the email
|
|
478
|
+
/// prefilled) or `checkout` (continue to `checkoutOffering`). A column, on
|
|
479
|
+
/// the `customersEnabled` footing: no seed or config names it. Closed by
|
|
480
|
+
/// the `flow_definitions_completion_domain` CHECK; the standard defaults are
|
|
481
|
+
/// the migration's backfill by slug (`STANDARD_MAGNET_COMPLETIONS`).
|
|
482
|
+
completion String @default("lead") @db.VarChar(16)
|
|
483
|
+
/// #8757 D1/D2 — the offering key a `checkout` magnet continues to; NULL
|
|
484
|
+
/// exactly when `completion` is not `checkout`
|
|
485
|
+
/// (`flow_definitions_checkout_offering_pair`), in the spoke checkout slug
|
|
486
|
+
/// format (`flow_definitions_checkout_offering_format`).
|
|
487
|
+
checkoutOffering String? @map("checkout_offering") @db.VarChar(40)
|
|
476
488
|
createdAt DateTime @default(now()) @map("created_at")
|
|
477
489
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
478
490
|
|
|
@@ -1283,8 +1295,11 @@ model ProjectListing {
|
|
|
1283
1295
|
/// would prove nothing once that row is edited, and `statementHash` groups
|
|
1284
1296
|
/// identical wordings. `locale` is the locale the statement was actually
|
|
1285
1297
|
/// served in, not the one requested. `purposeId` is the site-config registry
|
|
1286
|
-
/// id — the join key across surfaces.
|
|
1287
|
-
/// apps/
|
|
1298
|
+
/// id — the join key across surfaces. Two writers: apps/api/src/services/consent.ts
|
|
1299
|
+
/// (every source), and the web sign-up hook apps/web/lib/sign-up-agreements.ts
|
|
1300
|
+
/// (#8765 D4), which writes only `sign_up` rows (subject `user`) of a user it
|
|
1301
|
+
/// creates in the same Better Auth transaction, from evidence the API built
|
|
1302
|
+
/// with consent.ts's own functions.
|
|
1288
1303
|
model ConsentGrant {
|
|
1289
1304
|
id String @id @default(dbgenerated("('cnsnt_'::text || (uuidv7())::text)")) @db.Text
|
|
1290
1305
|
siteId String @map("site_id")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- #8757 — a per-magnet completion setting (spec
|
|
2
|
+
-- docs/specs/8757-a-per-magnet-completion-setting-spec.md D1, D2).
|
|
3
|
+
--
|
|
4
|
+
-- flow_definitions (2 columns) — what happens after someone submits the
|
|
5
|
+
-- magnet (`completion`: `lead`, `invite` or `checkout`),
|
|
6
|
+
-- and, for `checkout` only, the offering key it continues
|
|
7
|
+
-- to. The vocabulary is `FLOW_COMPLETIONS` and
|
|
8
|
+
-- `flowCheckoutOfferingSchema` in
|
|
9
|
+
-- packages/validation/src/schemas/flow-completion.ts.
|
|
10
|
+
--
|
|
11
|
+
-- Every existing row reads `completion = 'lead'` from the constant default —
|
|
12
|
+
-- Postgres stores the default in the catalog and rewrites no row for it, so
|
|
13
|
+
-- `contact` and every waitlist get `lead` with no statement. Each CHECK below
|
|
14
|
+
-- is added NOT VALID and then VALIDATEd; every existing row passes each one
|
|
15
|
+
-- (`lead` with a NULL offering).
|
|
16
|
+
--
|
|
17
|
+
-- The one UPDATE is the standard-defaults backfill BY SLUG (the issue's D2,
|
|
18
|
+
-- D7): `onboarding`, `request-quote` and `job-application` → `invite`
|
|
19
|
+
-- (`STANDARD_MAGNET_COMPLETIONS`). A test re-reads the block between the
|
|
20
|
+
-- markers. `updated_at` is Prisma-managed, so the backfill leaves it alone.
|
|
21
|
+
-- A magnet created after this migration takes the column default until the
|
|
22
|
+
-- seed constants carry `completion` (#8759).
|
|
23
|
+
--
|
|
24
|
+
-- Rollout: deploy this migration BEFORE the API code that reads and writes the
|
|
25
|
+
-- columns; code without them fails every magnet read.
|
|
26
|
+
--
|
|
27
|
+
-- Rollback: ALTER TABLE "flow_definitions" DROP COLUMN "completion", DROP
|
|
28
|
+
-- COLUMN "checkout_offering"; (the CHECKs go with them).
|
|
29
|
+
|
|
30
|
+
-- AlterTable
|
|
31
|
+
ALTER TABLE "flow_definitions" ADD COLUMN "completion" VARCHAR(16) NOT NULL DEFAULT 'lead',
|
|
32
|
+
ADD COLUMN "checkout_offering" VARCHAR(40);
|
|
33
|
+
|
|
34
|
+
-- A magnet completes as a lead, an invite or a checkout, nothing else.
|
|
35
|
+
ALTER TABLE "flow_definitions" ADD CONSTRAINT "flow_definitions_completion_domain"
|
|
36
|
+
CHECK ("completion" IN ('lead', 'invite', 'checkout')) NOT VALID;
|
|
37
|
+
ALTER TABLE "flow_definitions" VALIDATE CONSTRAINT "flow_definitions_completion_domain";
|
|
38
|
+
|
|
39
|
+
-- An offering is named exactly when the magnet continues to checkout.
|
|
40
|
+
ALTER TABLE "flow_definitions" ADD CONSTRAINT "flow_definitions_checkout_offering_pair"
|
|
41
|
+
CHECK (("completion" = 'checkout') = ("checkout_offering" IS NOT NULL)) NOT VALID;
|
|
42
|
+
ALTER TABLE "flow_definitions" VALIDATE CONSTRAINT "flow_definitions_checkout_offering_pair";
|
|
43
|
+
|
|
44
|
+
-- An offering key the spoke checkout route can serve.
|
|
45
|
+
ALTER TABLE "flow_definitions" ADD CONSTRAINT "flow_definitions_checkout_offering_format"
|
|
46
|
+
CHECK ("checkout_offering" IS NULL OR "checkout_offering" ~ '^[a-z0-9][a-z0-9-]{0,39}$') NOT VALID;
|
|
47
|
+
ALTER TABLE "flow_definitions" VALIDATE CONSTRAINT "flow_definitions_checkout_offering_format";
|
|
48
|
+
|
|
49
|
+
-- BEGIN completion backfill
|
|
50
|
+
UPDATE "flow_definitions" SET "completion" = 'invite'
|
|
51
|
+
WHERE "slug" IN ('onboarding', 'request-quote', 'job-application');
|
|
52
|
+
-- END completion backfill
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- #8765 — a `checkout` consent source (spec
|
|
2
|
+
-- docs/specs/8765-sign-up-refuses-without-the-spec.md D13).
|
|
3
|
+
--
|
|
4
|
+
-- consent_grants — the source vocabulary gains `checkout`: the buyer's
|
|
5
|
+
-- terms box on a Stripe Checkout session, recorded by the
|
|
6
|
+
-- hub webhook against the buyer's Person (D14).
|
|
7
|
+
--
|
|
8
|
+
-- DDL only. The CHECK is replaced, never relaxed: the old one is dropped and
|
|
9
|
+
-- the new one, the seven existing values plus `checkout`, is added NOT VALID
|
|
10
|
+
-- and then VALIDATEd, so every existing row is checked against it. No row is
|
|
11
|
+
-- written, and the append-only triggers do not fire on DDL.
|
|
12
|
+
--
|
|
13
|
+
-- The source vocabulary is packages/validation CONSENT_SOURCES.
|
|
14
|
+
--
|
|
15
|
+
-- Rollout: deploy this migration BEFORE the API code that writes a
|
|
16
|
+
-- `checkout` row; the webhook's insert otherwise fails the CHECK (it never
|
|
17
|
+
-- throws, so the order still completes, but no grant is recorded).
|
|
18
|
+
--
|
|
19
|
+
-- Rollback: ALTER TABLE "consent_grants" DROP CONSTRAINT
|
|
20
|
+
-- "consent_grants_source_known"; then re-add it with the seven values of
|
|
21
|
+
-- 20261014120000_consent_ledger — which fails while any `checkout` row
|
|
22
|
+
-- exists, and the ledger is append-only.
|
|
23
|
+
|
|
24
|
+
ALTER TABLE "consent_grants" DROP CONSTRAINT "consent_grants_source_known";
|
|
25
|
+
ALTER TABLE "consent_grants" ADD CONSTRAINT "consent_grants_source_known"
|
|
26
|
+
CHECK ("source" IN ('cookie_banner', 'sign_up', 'magnet', 'account_settings', 'email_unsubscribe', 'api', 'legacy', 'checkout')) NOT VALID;
|
|
27
|
+
ALTER TABLE "consent_grants" VALIDATE CONSTRAINT "consent_grants_source_known";
|
|
@@ -30,6 +30,10 @@ import {
|
|
|
30
30
|
CONTACT_FORM_FLOW_TITLE,
|
|
31
31
|
CONTACT_FORM_LANDING_PAGE,
|
|
32
32
|
contactFormFlowConfig,
|
|
33
|
+
JOB_APPLICATION_FLOW_SLUG,
|
|
34
|
+
JOB_APPLICATION_FLOW_TITLE,
|
|
35
|
+
JOB_APPLICATION_LANDING_PAGE,
|
|
36
|
+
jobApplicationFlowConfig,
|
|
33
37
|
REQUEST_QUOTE_FLOW_SLUG,
|
|
34
38
|
REQUEST_QUOTE_FLOW_TITLE,
|
|
35
39
|
requestQuoteFlowConfig,
|
|
@@ -79,9 +83,12 @@ export type DefaultWorkspacePrismaClient = Pick<
|
|
|
79
83
|
* workspace's magnet comes from. Insert-if-absent: an `upsert` with an empty
|
|
80
84
|
* `update`, so an existing `(site_id, slug)` row is never overwritten.
|
|
81
85
|
*
|
|
82
|
-
* #8661 D3 — and its live Contact form magnet, the same way
|
|
83
|
-
*
|
|
84
|
-
* CLI instead.
|
|
86
|
+
* #8661 D3 — and its live Contact form magnet, the same way, and (#8746 D3)
|
|
87
|
+
* its live Job application magnet. An existing deployment (a workspace
|
|
88
|
+
* already present) gets its rows from the seed-presets CLI instead.
|
|
89
|
+
*
|
|
90
|
+
* #8759 — each names its #8757 D2 completion: the migration's backfill ran
|
|
91
|
+
* before this seed and found no rows, so the seed writes the value itself.
|
|
85
92
|
*/
|
|
86
93
|
export async function provisionDefaultWorkspace(
|
|
87
94
|
prisma: DefaultWorkspacePrismaClient,
|
|
@@ -133,6 +140,7 @@ export async function provisionDefaultWorkspace(
|
|
|
133
140
|
title: REQUEST_QUOTE_FLOW_TITLE,
|
|
134
141
|
status: "draft",
|
|
135
142
|
customersEnabled: true,
|
|
143
|
+
completion: "invite",
|
|
136
144
|
},
|
|
137
145
|
update: {},
|
|
138
146
|
});
|
|
@@ -153,11 +161,34 @@ export async function provisionDefaultWorkspace(
|
|
|
153
161
|
title: CONTACT_FORM_FLOW_TITLE,
|
|
154
162
|
status: "live",
|
|
155
163
|
customersEnabled: false,
|
|
164
|
+
completion: "lead",
|
|
156
165
|
landingPage: CONTACT_FORM_LANDING_PAGE,
|
|
157
166
|
},
|
|
158
167
|
update: {},
|
|
159
168
|
});
|
|
160
169
|
|
|
170
|
+
await prisma.flowDefinition.upsert({
|
|
171
|
+
where: {
|
|
172
|
+
siteId_slug: {
|
|
173
|
+
siteId: DEFAULT_WORKSPACE_ID,
|
|
174
|
+
slug: JOB_APPLICATION_FLOW_SLUG,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
create: {
|
|
178
|
+
siteId: DEFAULT_WORKSPACE_ID,
|
|
179
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
180
|
+
slug: JOB_APPLICATION_FLOW_SLUG,
|
|
181
|
+
config: jobApplicationFlowConfig,
|
|
182
|
+
kind: "diagnostic",
|
|
183
|
+
title: JOB_APPLICATION_FLOW_TITLE,
|
|
184
|
+
status: "live",
|
|
185
|
+
customersEnabled: false,
|
|
186
|
+
completion: "invite",
|
|
187
|
+
landingPage: JOB_APPLICATION_LANDING_PAGE,
|
|
188
|
+
},
|
|
189
|
+
update: {},
|
|
190
|
+
});
|
|
191
|
+
|
|
161
192
|
return { created: true };
|
|
162
193
|
}
|
|
163
194
|
|
package/versions.json
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@working-theory/admin-ui": "0.
|
|
3
|
-
"@working-theory/aeo": "0.
|
|
4
|
-
"@working-theory/agent-context": "0.
|
|
2
|
+
"@working-theory/admin-ui": "0.43.0",
|
|
3
|
+
"@working-theory/aeo": "0.43.0",
|
|
4
|
+
"@working-theory/agent-context": "0.43.0",
|
|
5
5
|
"@working-theory/agent-contract": "0.1.0",
|
|
6
6
|
"@working-theory/agent-memory": "0.0.0",
|
|
7
7
|
"@working-theory/ai": "0.1.0",
|
|
8
|
-
"@working-theory/analytics": "0.
|
|
9
|
-
"@working-theory/api-client": "0.
|
|
8
|
+
"@working-theory/analytics": "0.43.0",
|
|
9
|
+
"@working-theory/api-client": "0.43.0",
|
|
10
10
|
"@working-theory/cda-filesystem": "0.0.0",
|
|
11
|
-
"@working-theory/cli": "0.
|
|
12
|
-
"@working-theory/common": "0.
|
|
11
|
+
"@working-theory/cli": "0.43.0",
|
|
12
|
+
"@working-theory/common": "0.43.0",
|
|
13
13
|
"@working-theory/consent": "0.1.0",
|
|
14
|
-
"@working-theory/content-adapters": "0.
|
|
15
|
-
"@working-theory/content-projection": "0.
|
|
16
|
-
"@working-theory/email": "0.
|
|
14
|
+
"@working-theory/content-adapters": "0.43.0",
|
|
15
|
+
"@working-theory/content-projection": "0.43.0",
|
|
16
|
+
"@working-theory/email": "0.43.0",
|
|
17
17
|
"@working-theory/env": "0.0.0",
|
|
18
|
-
"@working-theory/eslint-config": "0.
|
|
19
|
-
"@working-theory/fumadocs-schema": "0.
|
|
18
|
+
"@working-theory/eslint-config": "0.43.0",
|
|
19
|
+
"@working-theory/fumadocs-schema": "0.43.0",
|
|
20
20
|
"@working-theory/ids": "0.14.0",
|
|
21
|
-
"@working-theory/jsonld": "0.
|
|
22
|
-
"@working-theory/license": "0.
|
|
23
|
-
"@working-theory/metadata": "0.
|
|
24
|
-
"@working-theory/observability": "0.
|
|
25
|
-
"@working-theory/page-renderer": "0.
|
|
21
|
+
"@working-theory/jsonld": "0.43.0",
|
|
22
|
+
"@working-theory/license": "0.43.0",
|
|
23
|
+
"@working-theory/metadata": "0.43.0",
|
|
24
|
+
"@working-theory/observability": "0.43.0",
|
|
25
|
+
"@working-theory/page-renderer": "0.43.0",
|
|
26
26
|
"@working-theory/plugin-mcp": "0.1.3",
|
|
27
|
-
"@working-theory/schema": "0.
|
|
28
|
-
"@working-theory/schema-pack-registry": "0.
|
|
29
|
-
"@working-theory/schema-validator": "0.
|
|
30
|
-
"@working-theory/sections-registry": "0.
|
|
31
|
-
"@working-theory/site-config": "0.
|
|
32
|
-
"@working-theory/spoke-checkout": "0.
|
|
33
|
-
"@working-theory/stripe-client": "0.
|
|
34
|
-
"@working-theory/templates": "0.
|
|
35
|
-
"@working-theory/theme": "0.
|
|
36
|
-
"@working-theory/ui": "0.
|
|
37
|
-
"@working-theory/validation": "0.
|
|
38
|
-
"@working-theory/web-i18n": "0.
|
|
27
|
+
"@working-theory/schema": "0.43.0",
|
|
28
|
+
"@working-theory/schema-pack-registry": "0.43.0",
|
|
29
|
+
"@working-theory/schema-validator": "0.43.0",
|
|
30
|
+
"@working-theory/sections-registry": "0.43.0",
|
|
31
|
+
"@working-theory/site-config": "0.43.0",
|
|
32
|
+
"@working-theory/spoke-checkout": "0.6.0",
|
|
33
|
+
"@working-theory/stripe-client": "0.43.0",
|
|
34
|
+
"@working-theory/templates": "0.43.0",
|
|
35
|
+
"@working-theory/theme": "0.43.0",
|
|
36
|
+
"@working-theory/ui": "0.43.0",
|
|
37
|
+
"@working-theory/validation": "0.43.0",
|
|
38
|
+
"@working-theory/web-i18n": "0.43.0",
|
|
39
39
|
"@working-theory/billing-client": "0.0.0",
|
|
40
40
|
"@working-theory/branding": "0.0.0",
|
|
41
41
|
"@working-theory/license-client": "0.0.0",
|
|
42
42
|
"@working-theory/license-manifest": "0.0.0",
|
|
43
43
|
"@working-theory/partner-session": "0.0.0",
|
|
44
44
|
"@working-theory/preview-protocol": "0.0.0",
|
|
45
|
-
"@working-theory/ui-commerce": "0.23.
|
|
46
|
-
"@working-theory/ui-docs-blog": "0.23.
|
|
47
|
-
"@working-theory/ui-scheduling": "0.24.
|
|
48
|
-
"@working-theory/ui-signal-collectors": "0.
|
|
45
|
+
"@working-theory/ui-commerce": "0.23.21",
|
|
46
|
+
"@working-theory/ui-docs-blog": "0.23.21",
|
|
47
|
+
"@working-theory/ui-scheduling": "0.24.1",
|
|
48
|
+
"@working-theory/ui-signal-collectors": "0.26.0",
|
|
49
49
|
"@working-theory/web": "0.0.0",
|
|
50
50
|
"@working-theory/api": "1.0.0",
|
|
51
51
|
"@working-theory/mcp": "0.0.0",
|
|
52
|
-
"@working-theory/database": "0.
|
|
52
|
+
"@working-theory/database": "0.43.0"
|
|
53
53
|
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
"use server";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* sign-up.actions.ts — mints the sign-up card's carried-agreement cookie
|
|
5
|
-
* (#8438 D13, spec
|
|
6
|
-
* docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
|
|
7
|
-
*
|
|
8
|
-
* `apps/web` never touches the database (the ledger writer is `apps/api`),
|
|
9
|
-
* and Better Auth creates the user from THIS app before any Person exists to
|
|
10
|
-
* record a grant against. So the card remembers which agreements the person
|
|
11
|
-
* ticked, in a short-lived, signed, email-bound cookie — the account home's
|
|
12
|
-
* first signed-in visit (`(authed)/account/actions.ts`'s
|
|
13
|
-
* `acceptCarriedAgreementsAction`) consumes it, one write per agreement,
|
|
14
|
-
* then deletes it either way (single-use).
|
|
15
|
-
*
|
|
16
|
-
* The CONSUME side lives in the account area, not here, deliberately:
|
|
17
|
-
* `(auth)/**` is one of the sign-in seeds a sign-in-less spoke drops
|
|
18
|
-
* (`SPOKE_REMOVAL_SEEDS`, `packages/create-ailk/src/topology.ts` D4), so a
|
|
19
|
-
* file OUTSIDE `(auth)` (the account home's `agreements-prompt.tsx`) must
|
|
20
|
-
* never import FROM here — doing so once failed the scaffolder's own
|
|
21
|
-
* dangling-import check (`spokeExclusions`) for exactly this reason. This
|
|
22
|
-
* file is reached only from `sign-up-card.tsx`, its own directory sibling,
|
|
23
|
-
* so it carries no such obligation. The two files duplicate the small
|
|
24
|
-
* cookie codec (encode here, decode there) rather than share a module,
|
|
25
|
-
* precisely so each side's dependencies stay native to where it lives.
|
|
26
|
-
*
|
|
27
|
-
* Cookie value: `<id>@<version>,…|<issuedAtMs>|<sha256(lowercased email)>|
|
|
28
|
-
* <HMAC-SHA256 over the rest>`, keyed by `BETTER_AUTH_SECRET` — the same
|
|
29
|
-
* dev fallback `apps/web/auth.ts`'s own `resolveSecret()` publishes when it
|
|
30
|
-
* is unset; production refuses to mint without one. That secret is read
|
|
31
|
-
* directly HERE rather than importing `resolveSecret` from `auth.ts`: that
|
|
32
|
-
* module statically imports the `better-auth` ESM package, which every
|
|
33
|
-
* broad page-level test suite that renders `/sign-up` would then have to
|
|
34
|
-
* add to its `../../../../auth` mock. `__Host-` prefixed, httpOnly,
|
|
35
|
-
* `Secure`, one hour, `SameSite=Lax` — the `__Host-` prefix needs `Secure`,
|
|
36
|
-
* so development over plain `http://localhost` uses the unprefixed name
|
|
37
|
-
* instead (SEC-8438-5).
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import { createHash, createHmac } from "node:crypto";
|
|
41
|
-
|
|
42
|
-
import { consentPurposeById, SITE_CONSENT_CONFIG_DEFAULTS } from "@working-theory/validation";
|
|
43
|
-
import { cookies } from "next/headers";
|
|
44
|
-
|
|
45
|
-
const SECURE_COOKIE_NAME = "__Host-ailk-signup-agreements";
|
|
46
|
-
const DEV_COOKIE_NAME = "ailk-signup-agreements";
|
|
47
|
-
const MAX_AGE_SECONDS = 60 * 60;
|
|
48
|
-
|
|
49
|
-
/** The string `auth.ts`'s own `resolveSecret()` publishes — see file header. */
|
|
50
|
-
const DEV_FALLBACK_SECRET =
|
|
51
|
-
"ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
|
|
52
|
-
|
|
53
|
-
/** Mirrors `auth.ts`'s `resolveSecret()` exactly, without importing it. */
|
|
54
|
-
function signUpAgreementsSecret(): string {
|
|
55
|
-
const configured = process.env.BETTER_AUTH_SECRET;
|
|
56
|
-
if (configured) return configured;
|
|
57
|
-
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
58
|
-
if (process.env.NODE_ENV === "production" && !isBuildPhase) {
|
|
59
|
-
throw new Error(
|
|
60
|
-
"BETTER_AUTH_SECRET is required in production: it signs the carried sign-up-agreement cookie.",
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
return DEV_FALLBACK_SECRET;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function cookieName(): string {
|
|
67
|
-
return process.env.NODE_ENV === "production" ? SECURE_COOKIE_NAME : DEV_COOKIE_NAME;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function emailHash(email: string): string {
|
|
71
|
-
return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function sign(payload: string): string {
|
|
75
|
-
return createHmac("sha256", signUpAgreementsSecret()).update(payload).digest("hex");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface CarriedPurpose {
|
|
79
|
-
id: string;
|
|
80
|
-
version: number;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function encodeCarriedAgreements(
|
|
84
|
-
purposes: CarriedPurpose[],
|
|
85
|
-
email: string,
|
|
86
|
-
issuedAtMs: number,
|
|
87
|
-
): string {
|
|
88
|
-
const purposesPart = purposes.map((p) => `${p.id}@${p.version}`).join(",");
|
|
89
|
-
const payload = `${purposesPart}|${issuedAtMs}|${emailHash(email)}`;
|
|
90
|
-
return `${payload}|${sign(payload)}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Mints the carried-agreement cookie from the ticked `signUp` agreements,
|
|
95
|
-
* read back from the registry for their CURRENT version — never trusting a
|
|
96
|
-
* version the client might assert. Purposes the registry no longer marks
|
|
97
|
-
* `signUp` are dropped. Never throws: a failed mint just means the account
|
|
98
|
-
* home's first visit asks explicitly instead (D13's "Otherwise" card).
|
|
99
|
-
*/
|
|
100
|
-
export async function rememberSignUpAgreementsAction(
|
|
101
|
-
email: string,
|
|
102
|
-
purposeIds: string[],
|
|
103
|
-
): Promise<void> {
|
|
104
|
-
try {
|
|
105
|
-
const config = SITE_CONSENT_CONFIG_DEFAULTS;
|
|
106
|
-
const purposes: CarriedPurpose[] = [];
|
|
107
|
-
for (const id of purposeIds) {
|
|
108
|
-
const purpose = consentPurposeById(config, id);
|
|
109
|
-
if (purpose?.signUp) purposes.push({ id: purpose.id, version: purpose.policyVersion });
|
|
110
|
-
}
|
|
111
|
-
if (purposes.length === 0) return;
|
|
112
|
-
|
|
113
|
-
const value = encodeCarriedAgreements(purposes, email, Date.now());
|
|
114
|
-
const cookieStore = await cookies();
|
|
115
|
-
cookieStore.set(cookieName(), value, {
|
|
116
|
-
httpOnly: true,
|
|
117
|
-
secure: process.env.NODE_ENV === "production",
|
|
118
|
-
sameSite: "lax",
|
|
119
|
-
path: "/",
|
|
120
|
-
maxAge: MAX_AGE_SECONDS,
|
|
121
|
-
});
|
|
122
|
-
} catch {
|
|
123
|
-
// Convenience only — see file header.
|
|
124
|
-
}
|
|
125
|
-
}
|