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.
Files changed (175) hide show
  1. package/component-catalog.json +37 -3
  2. package/component-catalog.md +6 -5
  3. package/dist/add-module.js +9 -5
  4. package/dist/derive-architecture.d.ts +8 -1
  5. package/dist/derive-architecture.js +38 -3
  6. package/dist/design-package.js +7 -2
  7. package/dist/lib/env-catalog.js +5 -1
  8. package/dist/module-architecture.js +4 -2
  9. package/dist/programmatic.js +19 -5
  10. package/dist/site-chrome.d.ts +8 -0
  11. package/dist/site-chrome.js +17 -1
  12. package/dist/spoke-checkout.d.ts +39 -18
  13. package/dist/spoke-checkout.js +177 -236
  14. package/dist/surfaces.js +19 -14
  15. package/dist/sync-routes.js +3 -2
  16. package/dist/template-record.d.ts +4 -3
  17. package/dist/template-record.js +6 -3
  18. package/dist/topology.js +25 -1
  19. package/dist/update.js +5 -0
  20. package/package.json +3 -3
  21. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  22. package/templates/apps/api/CLAUDE.md +15 -6
  23. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  24. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  25. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  26. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  27. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  28. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  29. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  30. package/templates/apps/api/src/middleware/auth.ts +13 -6
  31. package/templates/apps/api/src/openapi/spec.ts +30 -3
  32. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  33. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  34. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  35. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  36. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  37. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  38. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  39. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  40. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  41. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  42. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  43. package/templates/apps/api/src/server.ts +28 -0
  44. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  45. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  46. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  47. package/templates/apps/api/src/services/consent.ts +95 -0
  48. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  49. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  50. package/templates/apps/api/src/services/person.ts +40 -8
  51. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  52. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  53. package/templates/apps/web/.env.example +14 -0
  54. package/templates/apps/web/CLAUDE.md +120 -9
  55. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  56. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  57. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  58. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  59. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  60. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  61. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  62. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  63. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  64. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  65. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  67. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  68. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  69. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  70. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  71. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  72. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  78. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  80. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  81. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  82. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  83. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  85. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  86. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  87. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  88. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  89. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  90. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  91. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  92. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  93. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  94. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  95. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  96. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  97. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  98. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  99. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  100. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  101. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  102. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  103. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
  104. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
  105. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  106. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
  107. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  108. package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
  109. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  110. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  112. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  113. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  114. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  115. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  116. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  117. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  118. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  119. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  120. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  122. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  123. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  124. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  125. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  126. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  127. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  128. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  129. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  130. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  131. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  132. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  133. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  134. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  135. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  136. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  137. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  138. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  139. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  140. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  141. package/templates/apps/web/auth.ts +28 -2
  142. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  143. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  144. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  145. package/templates/apps/web/lib/__tests__/deck-page.test.ts +5 -0
  146. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
  147. package/templates/apps/web/lib/account-invite.ts +37 -0
  148. package/templates/apps/web/lib/checkout-config.ts +27 -0
  149. package/templates/apps/web/lib/checkout.ts +93 -0
  150. package/templates/apps/web/lib/consent-policies.ts +22 -0
  151. package/templates/apps/web/lib/deck-page.ts +6 -0
  152. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  153. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  154. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  155. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  156. package/templates/apps/web/lib/site-chrome.ts +2 -0
  157. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  158. package/templates/apps/web/messages/en.json +24 -7
  159. package/templates/apps/web/package.json +1 -0
  160. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  161. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  162. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  163. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  164. package/templates/database/CHANGELOG.md +38 -0
  165. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  166. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  167. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  168. package/templates/database/__tests__/seed.test.ts +34 -1
  169. package/templates/database/inbox/schema.prisma +17 -2
  170. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  171. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  172. package/templates/database/package.json +1 -1
  173. package/templates/database/seed.ts +34 -3
  174. package/versions.json +34 -34
  175. 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(2);
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. Written only by
1287
- /// apps/api/src/services/consent.ts.
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";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@working-theory/database",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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. An existing
83
- * deployment (a workspace already present) gets its row from the seed-presets
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.42.0",
3
- "@working-theory/aeo": "0.42.0",
4
- "@working-theory/agent-context": "0.42.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.42.0",
9
- "@working-theory/api-client": "0.42.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.42.0",
12
- "@working-theory/common": "0.42.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.42.0",
15
- "@working-theory/content-projection": "0.42.0",
16
- "@working-theory/email": "0.42.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.42.0",
19
- "@working-theory/fumadocs-schema": "0.42.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.42.0",
22
- "@working-theory/license": "0.42.0",
23
- "@working-theory/metadata": "0.42.0",
24
- "@working-theory/observability": "0.42.0",
25
- "@working-theory/page-renderer": "0.42.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.42.0",
28
- "@working-theory/schema-pack-registry": "0.42.0",
29
- "@working-theory/schema-validator": "0.42.0",
30
- "@working-theory/sections-registry": "0.42.0",
31
- "@working-theory/site-config": "0.42.0",
32
- "@working-theory/spoke-checkout": "0.5.3",
33
- "@working-theory/stripe-client": "0.42.0",
34
- "@working-theory/templates": "0.42.0",
35
- "@working-theory/theme": "0.42.0",
36
- "@working-theory/ui": "0.42.0",
37
- "@working-theory/validation": "0.42.0",
38
- "@working-theory/web-i18n": "0.42.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.20",
46
- "@working-theory/ui-docs-blog": "0.23.20",
47
- "@working-theory/ui-scheduling": "0.24.0",
48
- "@working-theory/ui-signal-collectors": "0.25.9",
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.42.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
- }