create-ailk 0.19.0 → 0.20.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 (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,345 @@
1
+ /**
2
+ * @file consent-ledger.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the consent ledger (#8438, spec
5
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md D2, AC-2). Requires
6
+ * DATABASE_URL pointing at a Postgres with the migrations applied — the suite
7
+ * SKIPS when DATABASE_URL is unset, keeping the workspace Jest run DB-free
8
+ * (the repo convention; the `apps/api Route Tests (Postgres)` workflow runs it).
9
+ *
10
+ * What it proves that a mock cannot:
11
+ * 1. consent_grants and consent_visitor_links refuse UPDATE, DELETE and
12
+ * TRUNCATE, each raising `<table> is append-only`.
13
+ * 2. A new ledger row must name its source and, unless legacy, its policy
14
+ * version; the source vocabulary is closed.
15
+ * 3. A row stored before the ledger reads source = 'legacy' (written here
16
+ * the only way the table allows) and the column has no default for a
17
+ * writer that forgets.
18
+ * 4. consent_visitor_links takes only a v4 UUID visitor id, ignores a
19
+ * repeat link, and carries no network or browser evidence column.
20
+ *
21
+ * Rows are append-only, so this suite cannot clean up after itself: every run
22
+ * stamps fresh workspace and person ids and leaves its rows behind.
23
+ */
24
+ import { randomUUID } from "node:crypto";
25
+ import { readFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ import { Client } from "pg";
29
+
30
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
31
+
32
+ /** A well-formed `<prefix>_<uuid v7-shaped>` id, fresh per call. */
33
+ function id(prefix: string): string {
34
+ const hex = randomUUID().replace(/-/g, "");
35
+ return `${prefix}_${hex.slice(0, 8)}-${hex.slice(8, 12)}-7${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
36
+ }
37
+
38
+ describeDb("the consent ledger (#8438 D2, AC-2, live DB)", () => {
39
+ let db: Client;
40
+ const WS = id("wksp");
41
+ const PERSON = id("person");
42
+ const VISITOR = randomUUID();
43
+
44
+ beforeAll(async () => {
45
+ db = new Client({ connectionString: process.env.DATABASE_URL });
46
+ await db.connect();
47
+ });
48
+
49
+ afterAll(async () => {
50
+ await db.end();
51
+ });
52
+
53
+ const insertGrant = (
54
+ cols: {
55
+ source?: string | null;
56
+ policyVersion?: number | null;
57
+ personId?: string | null;
58
+ omitSource?: boolean;
59
+ } = {},
60
+ ) => {
61
+ const names = [
62
+ "site_id",
63
+ "workspace_id",
64
+ "subject_type",
65
+ "subject_id",
66
+ "purpose_id",
67
+ "granted",
68
+ "locale",
69
+ "statement",
70
+ "statement_hash",
71
+ "person_id",
72
+ "policy_version",
73
+ ];
74
+ const values: unknown[] = [
75
+ WS,
76
+ WS,
77
+ "lead",
78
+ "lead_1",
79
+ "service_terms",
80
+ true,
81
+ "en",
82
+ "You agree.",
83
+ "hash",
84
+ cols.personId === undefined ? PERSON : cols.personId,
85
+ cols.policyVersion === undefined ? 1 : cols.policyVersion,
86
+ ];
87
+ if (!cols.omitSource) {
88
+ names.push("source");
89
+ values.push(cols.source === undefined ? "magnet" : cols.source);
90
+ }
91
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
92
+ return db.query(
93
+ `INSERT INTO "consent_grants" (${names.map((n) => `"${n}"`).join(", ")}) VALUES (${placeholders}) RETURNING "id"`,
94
+ values,
95
+ );
96
+ };
97
+
98
+ const insertLink = (
99
+ cols: { visitorId?: string; source?: string; personId?: string } = {},
100
+ ) =>
101
+ db.query(
102
+ `INSERT INTO "consent_visitor_links" ("workspace_id", "visitor_id", "person_id", "source")
103
+ VALUES ($1, $2, $3, $4) RETURNING "id"`,
104
+ [
105
+ WS,
106
+ cols.visitorId ?? VISITOR,
107
+ cols.personId ?? PERSON,
108
+ cols.source ?? "magnet",
109
+ ],
110
+ );
111
+
112
+ describe("consent_grants", () => {
113
+ it("accepts a row that names its source and policy version", async () => {
114
+ const result = await insertGrant({ source: "account_settings" });
115
+ expect(result.rows[0].id).toMatch(/^cnsnt_/);
116
+ });
117
+
118
+ it("refuses an UPDATE, a row DELETE and a TRUNCATE with `consent_grants is append-only`", async () => {
119
+ await insertGrant();
120
+ await expect(
121
+ db.query(`UPDATE "consent_grants" SET "granted" = NOT "granted"`),
122
+ ).rejects.toThrow(/consent_grants is append-only/);
123
+ await expect(db.query(`DELETE FROM "consent_grants"`)).rejects.toThrow(
124
+ /consent_grants is append-only/,
125
+ );
126
+ await expect(db.query(`TRUNCATE "consent_grants"`)).rejects.toThrow(
127
+ /consent_grants is append-only/,
128
+ );
129
+ });
130
+
131
+ it("refuses a new row with source 'magnet' and no policy_version", async () => {
132
+ await expect(
133
+ insertGrant({ source: "magnet", policyVersion: null }),
134
+ ).rejects.toThrow(/consent_grants_policy_version_present/);
135
+ });
136
+
137
+ it("refuses a source outside the vocabulary and a zero policy_version", async () => {
138
+ await expect(insertGrant({ source: "telepathy" })).rejects.toThrow(
139
+ /consent_grants_source_known/,
140
+ );
141
+ await expect(insertGrant({ policyVersion: 0 })).rejects.toThrow(
142
+ /consent_grants_policy_version_positive/,
143
+ );
144
+ });
145
+
146
+ it("has no default source: a writer that forgets is refused", async () => {
147
+ await expect(insertGrant({ omitSource: true })).rejects.toThrow(
148
+ /null value in column "source"/,
149
+ );
150
+ });
151
+
152
+ it("lets a legacy row carry no policy_version, and reads it back as legacy", async () => {
153
+ const legacyPerson = id("person");
154
+ await insertGrant({
155
+ source: "legacy",
156
+ policyVersion: null,
157
+ personId: legacyPerson,
158
+ });
159
+ const read = await db.query(
160
+ `SELECT "source", "policy_version" FROM "consent_grants" WHERE "person_id" = $1`,
161
+ [legacyPerson],
162
+ );
163
+ expect(read.rows).toEqual([{ source: "legacy", policy_version: null }]);
164
+ });
165
+
166
+ it("refuses a person_id that is not a person id", async () => {
167
+ await expect(insertGrant({ personId: "someone" })).rejects.toThrow(
168
+ /consent_grants_person_id_format/,
169
+ );
170
+ });
171
+ });
172
+
173
+ describe("consent_visitor_links", () => {
174
+ it("accepts a link and ignores a repeat of it", async () => {
175
+ await insertLink();
176
+ const again = await db.query(
177
+ `INSERT INTO "consent_visitor_links" ("workspace_id", "visitor_id", "person_id", "source")
178
+ VALUES ($1, $2, $3, 'magnet') ON CONFLICT DO NOTHING`,
179
+ [WS, VISITOR, PERSON],
180
+ );
181
+ expect(again.rowCount).toBe(0);
182
+ });
183
+
184
+ it("refuses an UPDATE, a row DELETE and a TRUNCATE with `consent_visitor_links is append-only`", async () => {
185
+ await expect(
186
+ db.query(`UPDATE "consent_visitor_links" SET "source" = 'api'`),
187
+ ).rejects.toThrow(/consent_visitor_links is append-only/);
188
+ await expect(
189
+ db.query(`DELETE FROM "consent_visitor_links"`),
190
+ ).rejects.toThrow(/consent_visitor_links is append-only/);
191
+ await expect(
192
+ db.query(`TRUNCATE "consent_visitor_links"`),
193
+ ).rejects.toThrow(/consent_visitor_links is append-only/);
194
+ });
195
+
196
+ it("refuses a visitor id that is not a v4 UUID, a bad source and a bad person id", async () => {
197
+ await expect(
198
+ insertLink({ visitorId: "01920000-8168-7000-8000-0000000000b1" }),
199
+ ).rejects.toThrow(/consent_visitor_links_visitor_id_format/);
200
+ await expect(insertLink({ visitorId: "visitor-1" })).rejects.toThrow(
201
+ /consent_visitor_links_visitor_id_format/,
202
+ );
203
+ await expect(
204
+ insertLink({ visitorId: randomUUID(), source: "cookie_banner" }),
205
+ ).rejects.toThrow(/consent_visitor_links_source_known/);
206
+ await expect(
207
+ insertLink({ visitorId: randomUUID(), personId: "nobody" }),
208
+ ).rejects.toThrow(/consent_visitor_links_person_id_format/);
209
+ });
210
+
211
+ it("gives the table an id with the registered cvlink prefix", async () => {
212
+ const result = await insertLink({ visitorId: randomUUID() });
213
+ expect(result.rows[0].id).toMatch(/^cvlink_/);
214
+ });
215
+ });
216
+
217
+ describe("the store's own SQL against this database (SEC-8438-2 flood case)", () => {
218
+ /**
219
+ * Pull one `$queryRaw` template out of `apps/api/src/lib/consent-store.ts`
220
+ * and number its `${…}` holes, so the very SQL the API runs is what this
221
+ * suite runs: the readers cannot drift from what is proved here.
222
+ */
223
+ function storeSql(fn: string, nth = 0): { text: string; holes: string[] } {
224
+ const source = readFileSync(
225
+ join(__dirname, "../../apps/api/src/lib/consent-store.ts"),
226
+ "utf8",
227
+ );
228
+ const start = source.indexOf(`export async function ${fn}(`);
229
+ expect(start).toBeGreaterThan(-1);
230
+ let open = source.indexOf("$queryRaw", start);
231
+ for (let i = 0; i < nth; i += 1) open = source.indexOf("$queryRaw", open + 1);
232
+ const tick = source.indexOf("`", open);
233
+ const end = source.indexOf("`", tick + 1);
234
+ const holes: string[] = [];
235
+ const text = source
236
+ .slice(tick + 1, end)
237
+ .replace(/\$\{([^}]+)\}/g, (_match, expr: string) => {
238
+ let at = holes.indexOf(expr);
239
+ if (at === -1) {
240
+ holes.push(expr);
241
+ at = holes.length - 1;
242
+ }
243
+ return `$${at + 1}`;
244
+ });
245
+ return { text, holes };
246
+ }
247
+ function runStore(fn: string, values: Record<string, unknown>, nth = 0) {
248
+ const { text, holes } = storeSql(fn, nth);
249
+ return db.query(
250
+ text,
251
+ holes.map((hole) => values[hole]),
252
+ );
253
+ }
254
+
255
+ it("a Person's withdrawal survives 600 magnet grants and 600 linked-visitor cookie rows, and every row pages out once", async () => {
256
+ const ws = id("wksp");
257
+ const person = id("person");
258
+ const visitor = randomUUID();
259
+ const insertMany = (cols: {
260
+ n: number;
261
+ subjectType: string;
262
+ subjectId: string;
263
+ personId: string | null;
264
+ granted: boolean;
265
+ source: string;
266
+ }) =>
267
+ db.query(
268
+ `INSERT INTO "consent_grants"
269
+ ("site_id", "workspace_id", "subject_type", "subject_id", "purpose_id", "granted",
270
+ "locale", "statement", "statement_hash", "person_id", "source", "policy_version")
271
+ SELECT $1, $1, $2, $3, 'share_data', $4, 'en', 'Share usage data.', 'h', $5, $6, 1
272
+ FROM generate_series(1, $7::int)`,
273
+ [ws, cols.subjectType, cols.subjectId, cols.granted, cols.personId, cols.source, cols.n],
274
+ );
275
+
276
+ await insertMany({
277
+ n: 1,
278
+ subjectType: "person",
279
+ subjectId: person,
280
+ personId: person,
281
+ granted: false,
282
+ source: "account_settings",
283
+ });
284
+ await db.query(
285
+ `INSERT INTO "consent_visitor_links" ("workspace_id", "visitor_id", "person_id", "source")
286
+ VALUES ($1, $2, $3, 'magnet')`,
287
+ [ws, visitor, person],
288
+ );
289
+ await insertMany({
290
+ n: 600,
291
+ subjectType: "lead",
292
+ subjectId: "lead_1",
293
+ personId: person,
294
+ granted: true,
295
+ source: "magnet",
296
+ });
297
+ await insertMany({
298
+ n: 600,
299
+ subjectType: "visitor",
300
+ subjectId: visitor,
301
+ personId: null,
302
+ granted: true,
303
+ source: "cookie_banner",
304
+ });
305
+
306
+ // The OWN reading: still withdrawn.
307
+ const own = await runStore("ownConsentRows", { workspaceId: ws, personId: person });
308
+ expect(own.rows.map((r) => [r.purposeId, r.granted, r.source])).toEqual([
309
+ ["share_data", false, "account_settings"],
310
+ ]);
311
+
312
+ // The ANY-SOURCE reading: a flood row is newest, granted, and unverified.
313
+ const any = await runStore("anySourceConsentRows", { workspaceId: ws, personId: person });
314
+ expect(any.rows).toHaveLength(1);
315
+ expect(any.rows[0].granted).toBe(true);
316
+ expect(["magnet", "cookie_banner"]).toContain(any.rows[0].source);
317
+
318
+ // History: 1,201 rows, 500 a page, each exactly once.
319
+ const seen: string[] = [];
320
+ let cursor: string | null = null;
321
+ for (let pages = 0; pages < 5; pages += 1) {
322
+ const page = await runStore("personConsentHistory", {
323
+ workspaceId: ws,
324
+ personId: person,
325
+ cursorId: cursor,
326
+ "CONSENT_HISTORY_PAGE_SIZE + 1": 501,
327
+ // the page query is the function's second `$queryRaw`
328
+ }, 1);
329
+ const rows = page.rows.slice(0, 500);
330
+ seen.push(...rows.map((r) => r.id as string));
331
+ if (page.rows.length <= 500) break;
332
+ cursor = rows[rows.length - 1].id as string;
333
+ }
334
+ expect(seen).toHaveLength(1201);
335
+ expect(new Set(seen).size).toBe(1201);
336
+
337
+ // Another workspace's reading of the same Person sees nothing.
338
+ const elsewhere = await runStore("anySourceConsentRows", {
339
+ workspaceId: id("wksp"),
340
+ personId: person,
341
+ });
342
+ expect(elsewhere.rows).toHaveLength(0);
343
+ });
344
+ });
345
+ });
@@ -138,6 +138,8 @@ const TABLES: FenceTable[] = [
138
138
  "locale",
139
139
  "statement",
140
140
  "statement_hash",
141
+ "source",
142
+ "policy_version",
141
143
  ],
142
144
  vals: () => [
143
145
  "subscriber",
@@ -147,6 +149,8 @@ const TABLES: FenceTable[] = [
147
149
  "en",
148
150
  "I agree",
149
151
  `hash-${RUN}`,
152
+ "api",
153
+ 1,
150
154
  ],
151
155
  },
152
156
  {
@@ -694,8 +698,8 @@ describeDb("the #6374 fence fan-out constraints (live DB)", () => {
694
698
  await db.query(
695
699
  `INSERT INTO "consent_grants"
696
700
  ("site_id", "workspace_id", "subject_type", "subject_id", "purpose_id",
697
- "granted", "locale", "statement", "statement_hash")
698
- VALUES ($1, $1, 'subscriber', $2, $3, true, 'en', 'I agree', $4)`,
701
+ "granted", "locale", "statement", "statement_hash", "source", "policy_version")
702
+ VALUES ($1, $1, 'subscriber', $2, $3, true, 'en', 'I agree', $4, 'api', 1)`,
699
703
  [workspaceCId, `cascade-${RUN}`, `purpose-${RUN}`, `hash-${RUN}`],
700
704
  );
701
705
 
@@ -0,0 +1,182 @@
1
+ {
2
+ "_comment": "#8435 AC-3 — the contact backfill fixture. person-contact.db.test.ts seeds `input` into live Postgres, runs database/migrations/20261014121000_person_contact_backfill/migration.sql, and asserts the people rows equal `expected.people` and every subscriber and callback is stamped as `expected.stamps` says (a null stamp means the row stays NULL). Timestamps are UTC.",
3
+ "workspaces": {
4
+ "A": "wksp_01920000-8435-7000-8000-00000000000a",
5
+ "B": "wksp_01920000-8435-7000-8000-00000000000b"
6
+ },
7
+ "input": {
8
+ "people": [
9
+ {
10
+ "key": "pre",
11
+ "workspace": "A",
12
+ "email": "lead-person@example.com",
13
+ "name": "Pat Lead",
14
+ "user": null
15
+ },
16
+ {
17
+ "key": "linked",
18
+ "workspace": "A",
19
+ "email": "linked@example.com",
20
+ "name": null,
21
+ "user": "user_01920000-8435-7000-8000-0000000000e1"
22
+ }
23
+ ],
24
+ "subscribers": [
25
+ {
26
+ "key": "S1",
27
+ "workspace": "A",
28
+ "email": " Sub@Example.com ",
29
+ "createdAt": "2026-02-01T00:00:00.000Z"
30
+ },
31
+ {
32
+ "key": "S2",
33
+ "workspace": "A",
34
+ "email": "sub@example.com",
35
+ "createdAt": "2026-02-02T00:00:00.000Z"
36
+ },
37
+ {
38
+ "key": "S3",
39
+ "workspace": "B",
40
+ "email": "Sub@Example.com",
41
+ "createdAt": "2026-02-03T00:00:00.000Z"
42
+ },
43
+ {
44
+ "key": "S4",
45
+ "workspace": "A",
46
+ "email": "lead-person@example.com",
47
+ "createdAt": "2026-02-04T00:00:00.000Z"
48
+ },
49
+ {
50
+ "key": "S5",
51
+ "workspace": "A",
52
+ "email": "not an email",
53
+ "createdAt": "2026-02-05T00:00:00.000Z"
54
+ }
55
+ ],
56
+ "callbacks": [
57
+ {
58
+ "key": "C1",
59
+ "workspace": "A",
60
+ "name": "Cal",
61
+ "phone": "+1 415-555-0123",
62
+ "payload": { "email": "Both@Example.com" },
63
+ "buyerEmail": "other@example.com",
64
+ "createdAt": "2026-03-01T00:00:00.000Z"
65
+ },
66
+ {
67
+ "key": "C1b",
68
+ "workspace": "A",
69
+ "name": "Cal Again",
70
+ "phone": "+442071234567",
71
+ "payload": { "email": "both@example.com" },
72
+ "buyerEmail": null,
73
+ "createdAt": "2026-03-02T00:00:00.000Z"
74
+ },
75
+ {
76
+ "key": "C2",
77
+ "workspace": "A",
78
+ "name": "Bea",
79
+ "phone": "0207 123 4567",
80
+ "payload": {},
81
+ "buyerEmail": "Buyer@Example.com",
82
+ "createdAt": "2026-03-03T00:00:00.000Z"
83
+ },
84
+ {
85
+ "key": "C3",
86
+ "workspace": "A",
87
+ "name": "Nobody",
88
+ "phone": "+14155550000",
89
+ "payload": {},
90
+ "buyerEmail": null,
91
+ "createdAt": "2026-03-04T00:00:00.000Z"
92
+ },
93
+ {
94
+ "key": "C4",
95
+ "workspace": "A",
96
+ "name": "Bad Email",
97
+ "phone": "+14155550001",
98
+ "payload": { "email": "bad email" },
99
+ "buyerEmail": null,
100
+ "createdAt": "2026-03-05T00:00:00.000Z"
101
+ },
102
+ {
103
+ "key": "C5",
104
+ "workspace": "A",
105
+ "name": "Lin",
106
+ "phone": "+442079460000",
107
+ "payload": { "email": "linked@example.com" },
108
+ "buyerEmail": null,
109
+ "createdAt": "2026-03-06T00:00:00.000Z"
110
+ },
111
+ {
112
+ "key": "C6",
113
+ "workspace": "A",
114
+ "name": "Numeric",
115
+ "phone": "+14155550002",
116
+ "payload": { "email": 12345 },
117
+ "buyerEmail": "fallback@example.com",
118
+ "createdAt": "2026-03-07T00:00:00.000Z"
119
+ }
120
+ ]
121
+ },
122
+ "expected": {
123
+ "people": [
124
+ {
125
+ "workspace": "A",
126
+ "email": "both@example.com",
127
+ "name": "Cal",
128
+ "telephone": "+14155550123"
129
+ },
130
+ {
131
+ "workspace": "A",
132
+ "email": "buyer@example.com",
133
+ "name": "Bea",
134
+ "telephone": null
135
+ },
136
+ {
137
+ "workspace": "A",
138
+ "email": "fallback@example.com",
139
+ "name": "Numeric",
140
+ "telephone": "+14155550002"
141
+ },
142
+ {
143
+ "workspace": "A",
144
+ "email": "lead-person@example.com",
145
+ "name": "Pat Lead",
146
+ "telephone": null
147
+ },
148
+ {
149
+ "workspace": "A",
150
+ "email": "linked@example.com",
151
+ "name": null,
152
+ "telephone": null
153
+ },
154
+ {
155
+ "workspace": "A",
156
+ "email": "sub@example.com",
157
+ "name": null,
158
+ "telephone": null
159
+ },
160
+ {
161
+ "workspace": "B",
162
+ "email": "sub@example.com",
163
+ "name": null,
164
+ "telephone": null
165
+ }
166
+ ],
167
+ "stamps": {
168
+ "S1": { "workspace": "A", "email": "sub@example.com" },
169
+ "S2": { "workspace": "A", "email": "sub@example.com" },
170
+ "S3": { "workspace": "B", "email": "sub@example.com" },
171
+ "S4": { "workspace": "A", "email": "lead-person@example.com" },
172
+ "S5": null,
173
+ "C1": { "workspace": "A", "email": "both@example.com" },
174
+ "C1b": { "workspace": "A", "email": "both@example.com" },
175
+ "C2": { "workspace": "A", "email": "buyer@example.com" },
176
+ "C3": null,
177
+ "C4": null,
178
+ "C5": { "workspace": "A", "email": "linked@example.com" },
179
+ "C6": { "workspace": "A", "email": "fallback@example.com" }
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @file outbound-message-action.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the replaced `interactions_outbound_shape` CHECK
5
+ * (#8515, spec docs/specs/8515-standard-client-onboarding-diagnostic-and-spec.md
6
+ * D6; AC-4). Requires DATABASE_URL pointing at a Postgres with the migrations
7
+ * applied — the suite SKIPS when DATABASE_URL is unset, keeping the workspace
8
+ * Jest run DB-free (the repo convention; the `apps/api Route Tests (Postgres)`
9
+ * workflow exercises it).
10
+ *
11
+ * Uses the `pg` driver directly, as `member-contract.db.test.ts` does. An
12
+ * outbound `message` may carry exactly `{"action": "onboarding"}` and no other
13
+ * payload; a payload-free outbound row still lands, and the payload-by-kind
14
+ * rule for inbound rows is unchanged.
15
+ */
16
+ import { Client } from "pg";
17
+
18
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
19
+
20
+ describeDb("the outbound message action (#8515 D6, AC-4, live DB)", () => {
21
+ let db: Client;
22
+ const WS = "wksp_01920000-8515-7000-8000-0000000000c1";
23
+ const USER = "user_01920000-8515-7000-8000-0000000000c1";
24
+ let person: string;
25
+
26
+ const cleanup = async () => {
27
+ await db.query(`DELETE FROM "interactions" WHERE "workspace_id" = $1`, [WS]);
28
+ await db.query(`DELETE FROM "people" WHERE "workspace_id" = $1`, [WS]);
29
+ };
30
+
31
+ beforeAll(async () => {
32
+ db = new Client({ connectionString: process.env.DATABASE_URL });
33
+ await db.connect();
34
+ await cleanup();
35
+ person = (
36
+ await db.query<{ id: string }>(
37
+ `INSERT INTO "people" ("workspace_id", "email", "updated_at") VALUES ($1, 'cleo@example.com', now()) RETURNING "id"`,
38
+ [WS],
39
+ )
40
+ ).rows[0]!.id;
41
+ });
42
+
43
+ afterAll(async () => {
44
+ await cleanup();
45
+ await db.end();
46
+ });
47
+
48
+ const insert = (direction: string, kind: string, payload: unknown) =>
49
+ db.query<{ id: string }>(
50
+ `INSERT INTO "interactions" ("workspace_id", "person_id", "channel", "direction", "kind", "subject", "body", "payload", "created_by")
51
+ VALUES ($1, $2, 'member', $3, $4, 'Your onboarding questions', 'Please answer a few questions.', $5, $6) RETURNING "id"`,
52
+ [WS, person, direction, kind, payload === null ? null : JSON.stringify(payload), USER],
53
+ );
54
+
55
+ it("an outbound message carrying the onboarding action lands", async () => {
56
+ const row = await insert("outbound", "message", { action: "onboarding" });
57
+ expect(row.rows[0]!.id).toMatch(/^intx_/);
58
+ });
59
+
60
+ it("an outbound payload-free message still lands", async () => {
61
+ const row = await insert("outbound", "message", null);
62
+ expect(row.rows[0]!.id).toMatch(/^intx_/);
63
+ });
64
+
65
+ it.each([
66
+ ["another action", { action: "invoice" }],
67
+ ["a link", { link: "https://x.example" }],
68
+ ["the action with an extra key", { action: "onboarding", href: "https://x.example" }],
69
+ ])("an outbound message carrying %s fails interactions_outbound_shape", async (_label, payload) => {
70
+ await expect(insert("outbound", "message", payload)).rejects.toMatchObject({
71
+ constraint: "interactions_outbound_shape",
72
+ });
73
+ });
74
+
75
+ it("an inbound quote without a payload still fails interactions_payload_by_kind", async () => {
76
+ await expect(insert("inbound", "quote", null)).rejects.toMatchObject({
77
+ constraint: "interactions_payload_by_kind",
78
+ });
79
+ });
80
+ });