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,249 @@
1
+ /**
2
+ * @file person-notes.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the client record's private notes and the member
5
+ * agent grant reset (#8514, spec
6
+ * docs/specs/8514-redesign-the-person-record-page-spec.md D1, D2, D2b; AC-2
7
+ * and the migration half of AC-3). Requires DATABASE_URL pointing at a
8
+ * Postgres with the migrations applied — every live suite SKIPS when
9
+ * DATABASE_URL is unset (the repo convention; the `apps/api Route Tests
10
+ * (Postgres)` workflow exercises it).
11
+ *
12
+ * Uses the `pg` driver directly, as `person-contact.db.test.ts` does. What it
13
+ * proves that a mock cannot:
14
+ * 1. The notes column's width and the people table's four new CHECKs (the
15
+ * editor's user-id format, the agent's id format, the edit pair, and an
16
+ * agent edit naming its person).
17
+ * 2. The migration's grant-reset half (its own text) removes people-read and
18
+ * member-pieces-manage from an agent of a member only, and a second run
19
+ * changes no row.
20
+ */
21
+ import { readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+
24
+ import { Client } from "pg";
25
+
26
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
27
+
28
+ const MIGRATION = join(
29
+ __dirname,
30
+ "..",
31
+ "migrations",
32
+ "20261015120000_person_notes",
33
+ "migration.sql",
34
+ );
35
+ const RESET_MARK =
36
+ "-- ===== #8514 D2b member agent grant reset (data only; runs after the DDL above) =====";
37
+
38
+ /** The grant-reset half of the single person_notes migration. */
39
+ function resetSql(): string {
40
+ const text = readFileSync(MIGRATION, "utf8");
41
+ const at = text.indexOf(RESET_MARK);
42
+ if (at < 0) throw new Error("the grant-reset marker is missing from the migration");
43
+ return text.slice(at);
44
+ }
45
+
46
+ const USER_ID = "user_01920000-8514-7000-8000-0000000000a1";
47
+ const AGENT_ID = "agent_01920000-8514-7000-8000-0000000000a2";
48
+
49
+ describe("the person_notes migration file (#8514 D2, D2b)", () => {
50
+ it("is the DDL, then the grant reset: data only, touching the agents table's grants alone", () => {
51
+ const text = readFileSync(MIGRATION, "utf8");
52
+ const at = text.indexOf(RESET_MARK);
53
+ expect(at).toBeGreaterThan(0);
54
+ expect(text.slice(0, at)).toMatch(/ALTER TABLE "people"/);
55
+ expect(text.slice(0, at)).not.toMatch(/UPDATE /);
56
+ const reset = resetSql()
57
+ .split("\n")
58
+ .filter((line) => !line.startsWith("--"))
59
+ .join("\n");
60
+ expect(reset).not.toMatch(/ALTER TABLE|DROP |CREATE /);
61
+ expect(reset).toMatch(/UPDATE "agents"/);
62
+ });
63
+ });
64
+
65
+ describeDb("the notes columns (#8514 D1, D2, AC-2, live DB)", () => {
66
+ let db: Client;
67
+ const WS = "wksp_01920000-8514-7000-8000-0000000000c1";
68
+
69
+ async function cleanup(): Promise<void> {
70
+ await db.query(`DELETE FROM "people" WHERE "workspace_id" = $1`, [WS]);
71
+ }
72
+
73
+ beforeAll(async () => {
74
+ db = new Client({ connectionString: process.env.DATABASE_URL });
75
+ await db.connect();
76
+ await cleanup();
77
+ });
78
+
79
+ afterAll(async () => {
80
+ await cleanup();
81
+ await db.end();
82
+ });
83
+
84
+ let counter = 0;
85
+ const email = () => `p8514-${(counter += 1)}@example.com`;
86
+
87
+ /** Insert one Person with the given notes columns on top of the required pair. */
88
+ const insertPerson = async (cols: Record<string, unknown> = {}) => {
89
+ const all: Record<string, unknown> = {
90
+ workspace_id: WS,
91
+ email: email(),
92
+ updated_at: new Date(),
93
+ ...cols,
94
+ };
95
+ const names = Object.keys(all);
96
+ return db.query<{ id: string }>(
97
+ `INSERT INTO "people" (${names.map((n) => `"${n}"`).join(", ")})
98
+ VALUES (${names.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING "id"`,
99
+ names.map((n) => all[n]),
100
+ );
101
+ };
102
+
103
+ const AT = new Date("2026-10-15T12:00:00.000Z");
104
+
105
+ it("a Person without notes reads all four columns as NULL", async () => {
106
+ const { rows } = await insertPerson();
107
+ const read = await db.query(
108
+ `SELECT "notes", "notes_updated_at", "notes_updated_by", "notes_updated_by_agent" FROM "people" WHERE "id" = $1`,
109
+ [rows[0]!.id],
110
+ );
111
+ expect(read.rows[0]).toEqual({
112
+ notes: null,
113
+ notes_updated_at: null,
114
+ notes_updated_by: null,
115
+ notes_updated_by_agent: null,
116
+ });
117
+ });
118
+
119
+ it("a 5000-character note saves and a 5001-character one fails", async () => {
120
+ await expect(
121
+ insertPerson({ notes: "n".repeat(5000), notes_updated_at: AT, notes_updated_by: USER_ID }),
122
+ ).resolves.toBeDefined();
123
+ await expect(
124
+ insertPerson({ notes: "n".repeat(5001), notes_updated_at: AT, notes_updated_by: USER_ID }),
125
+ ).rejects.toThrow(/value too long/);
126
+ });
127
+
128
+ it("a user_ editor with a timestamp succeeds; an agent_ editor fails the format CHECK", async () => {
129
+ await expect(
130
+ insertPerson({ notes: "x", notes_updated_at: AT, notes_updated_by: USER_ID }),
131
+ ).resolves.toBeDefined();
132
+ await expect(
133
+ insertPerson({ notes: "x", notes_updated_at: AT, notes_updated_by: AGENT_ID }),
134
+ ).rejects.toThrow(/people_notes_updated_by_format/);
135
+ });
136
+
137
+ it("a timestamp without an editor, or the reverse, fails the edit pair", async () => {
138
+ await expect(insertPerson({ notes: "x", notes_updated_at: AT })).rejects.toThrow(
139
+ /people_notes_edit_pair/,
140
+ );
141
+ await expect(insertPerson({ notes: "x", notes_updated_by: USER_ID })).rejects.toThrow(
142
+ /people_notes_edit_pair/,
143
+ );
144
+ });
145
+
146
+ it("an agent edit carries an agent_ id and its person", async () => {
147
+ await expect(
148
+ insertPerson({
149
+ notes: "x",
150
+ notes_updated_at: AT,
151
+ notes_updated_by: USER_ID,
152
+ notes_updated_by_agent: AGENT_ID,
153
+ }),
154
+ ).resolves.toBeDefined();
155
+ await expect(
156
+ insertPerson({
157
+ notes: "x",
158
+ notes_updated_at: AT,
159
+ notes_updated_by: USER_ID,
160
+ notes_updated_by_agent: USER_ID,
161
+ }),
162
+ ).rejects.toThrow(/people_notes_updated_by_agent_format/);
163
+ await expect(insertPerson({ notes_updated_by_agent: AGENT_ID })).rejects.toThrow(
164
+ /people_notes_agent_has_person/,
165
+ );
166
+ });
167
+ });
168
+
169
+ describeDb("the member agent grant reset (#8514 D2b, AC-3, live DB)", () => {
170
+ let db: Client;
171
+ let organizationId: string;
172
+ let workspaceId: string;
173
+ let ownerUserId: string;
174
+ let ownerMembershipId: string;
175
+ let memberMembershipId: string;
176
+ let adminMembershipId: string;
177
+ let hashSeq = 0;
178
+
179
+ const nextHash = () => (hashSeq += 1).toString(16).padStart(64, "0");
180
+
181
+ async function one(sql: string, params: unknown[]): Promise<string> {
182
+ return (await db.query<{ id: string }>(sql, params)).rows[0]!.id;
183
+ }
184
+
185
+ const insertUser = (tag: string) =>
186
+ one(`INSERT INTO "users" ("email", "updated_at") VALUES ($1, now()) RETURNING "id"`, [
187
+ `grants8514-${tag}@example.test`,
188
+ ]);
189
+
190
+ const insertMembership = (userId: string, role: string) =>
191
+ one(
192
+ `INSERT INTO "memberships" ("organization_id", "user_id", "role") VALUES ($1, $2, $3::"Role") RETURNING "id"`,
193
+ [organizationId, userId, role],
194
+ );
195
+
196
+ const insertAgent = (responsibleMembershipId: string, grants: string[]) =>
197
+ one(
198
+ `INSERT INTO "agents" ("organization_id", "workspace_id", "responsible_membership_id", "created_by_user_id",
199
+ "key_issued_to_membership_id", "credential_hash", "grants", "name", "credential_prefix", "updated_at")
200
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'client helper', 'ailk_agt_abcdefgh', now()) RETURNING "id"`,
201
+ [organizationId, workspaceId, responsibleMembershipId, ownerUserId, ownerMembershipId, nextHash(), grants],
202
+ );
203
+
204
+ const grantsOf = async (id: string) =>
205
+ (await db.query<{ grants: string[] }>(`SELECT "grants" FROM "agents" WHERE "id" = $1`, [id])).rows[0]!.grants;
206
+
207
+ beforeAll(async () => {
208
+ db = new Client({ connectionString: process.env.DATABASE_URL });
209
+ await db.connect();
210
+ organizationId = await one(
211
+ `INSERT INTO "organizations" ("name", "slug", "updated_at") VALUES ($1, $1, now()) RETURNING "id"`,
212
+ ["grants8514-org"],
213
+ );
214
+ workspaceId = await one(
215
+ `INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at") VALUES ($1, $2, $2, now()) RETURNING "id"`,
216
+ [organizationId, "grants8514-ws"],
217
+ );
218
+ ownerUserId = await insertUser("owner");
219
+ ownerMembershipId = await insertMembership(ownerUserId, "owner");
220
+ memberMembershipId = await insertMembership(await insertUser("member"), "member");
221
+ adminMembershipId = await insertMembership(await insertUser("admin"), "admin");
222
+ });
223
+
224
+ afterAll(async () => {
225
+ await db.query(`DELETE FROM "organizations" WHERE "slug" LIKE 'grants8514-%'`);
226
+ await db.query(`DELETE FROM "users" WHERE "email" LIKE 'grants8514-%'`);
227
+ await db.end();
228
+ });
229
+
230
+ it("removes the two client grants from a member's agent only, and is idempotent", async () => {
231
+ const membersAgent = await insertAgent(memberMembershipId, [
232
+ "people-read",
233
+ "member-pieces-manage",
234
+ "content-edit",
235
+ ]);
236
+ const adminsAgent = await insertAgent(adminMembershipId, ["people-read"]);
237
+ const sql = resetSql();
238
+
239
+ const first = await db.query(sql);
240
+ expect(first.rowCount).toBeGreaterThanOrEqual(1);
241
+ expect(await grantsOf(membersAgent)).toEqual(["content-edit"]);
242
+ expect(await grantsOf(adminsAgent)).toEqual(["people-read"]);
243
+
244
+ const second = await db.query(sql);
245
+ expect(second.rowCount).toBe(0);
246
+ expect(await grantsOf(membersAgent)).toEqual(["content-edit"]);
247
+ expect(await grantsOf(adminsAgent)).toEqual(["people-read"]);
248
+ });
249
+ });
@@ -129,7 +129,9 @@ describe("the Person backfill files (#852 D2)", () => {
129
129
  const schema = readFileSync(join(__dirname, "..", "inbox", "schema.prisma"), "utf8");
130
130
  const block = /^model Person \{[\s\S]*?^\}/m.exec(schema)?.[0] ?? "";
131
131
  expect(block).not.toBe("");
132
- expect(block).not.toMatch(/phone|traits|brief|merge|secondary/);
132
+ // #8435: `telephone` is a deliberate column now; the bare word `phone`
133
+ // (the #852 deferral this guards) stays absent.
134
+ expect(block).not.toMatch(/\bphone\b|traits|brief|merge|secondary/);
133
135
  expect(readFileSync(PERSON_MIGRATION, "utf8")).not.toMatch(/"(phone|traits|brief)"/);
134
136
  });
135
137
  });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @file piece-kind-migration.test.ts
3
+ *
4
+ * DB-free proof of the piece_kind migration's shape (#8516, spec
5
+ * docs/specs/8516-proposals-as-an-artifact-kind-spec.md D2, AC-1). Reads the
6
+ * migration's own text: it is DDL only — eight `ADD COLUMN`s on "pieces", one
7
+ * unique index and CHECKs added `NOT VALID` then `VALIDATE`d — with no
8
+ * `UPDATE`, `INSERT` or `DELETE`, so no existing row is rewritten, and `kind`
9
+ * defaults to `deliverable` as a constant. The live-database half is
10
+ * `piece-kind.db.test.ts`.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ const MIGRATION = join(__dirname, "..", "migrations", "20261016120000_piece_kind", "migration.sql");
16
+
17
+ /** The migration's statements, comments stripped, whitespace collapsed. */
18
+ function statements(): string[] {
19
+ const text = readFileSync(MIGRATION, "utf8")
20
+ .split("\n")
21
+ .filter((line) => !line.trimStart().startsWith("--"))
22
+ .join("\n");
23
+ return text
24
+ .split(";")
25
+ .map((s) => s.replace(/\s+/g, " ").trim())
26
+ .filter((s) => s.length > 0);
27
+ }
28
+
29
+ describe("the piece_kind migration (#8516 D2, AC-1)", () => {
30
+ it("is only ADD COLUMN on pieces, one unique index, and NOT VALID CHECKs then VALIDATE", () => {
31
+ const shapes = statements().map((s) => {
32
+ if (/^ALTER TABLE "pieces" ADD COLUMN /.test(s)) return "add-column";
33
+ if (/^CREATE UNIQUE INDEX "pieces_checkout_session_id_key" ON "pieces"/.test(s)) return "index";
34
+ if (/^ALTER TABLE "pieces" ADD CONSTRAINT "pieces_[a-z_]+" CHECK \(.*\) NOT VALID$/.test(s)) {
35
+ return "check";
36
+ }
37
+ if (/^ALTER TABLE "pieces" VALIDATE CONSTRAINT "pieces_[a-z_]+"$/.test(s)) return "validate";
38
+ return `unexpected: ${s}`;
39
+ });
40
+ expect(shapes.filter((s) => s.startsWith("unexpected"))).toEqual([]);
41
+ expect(shapes.filter((s) => s === "add-column")).toHaveLength(1);
42
+ expect(shapes.filter((s) => s === "index")).toHaveLength(1);
43
+ expect(shapes.filter((s) => s === "check")).toHaveLength(11);
44
+ expect(shapes.filter((s) => s === "validate")).toHaveLength(11);
45
+ });
46
+
47
+ it("writes no row: no UPDATE, INSERT or DELETE", () => {
48
+ for (const s of statements()) expect(s).not.toMatch(/\b(UPDATE|INSERT|DELETE)\b/i);
49
+ });
50
+
51
+ it("adds kind as VARCHAR(16) NOT NULL DEFAULT 'deliverable' and seven nullable columns", () => {
52
+ const add = statements().find((s) => s.startsWith('ALTER TABLE "pieces" ADD COLUMN'));
53
+ expect(add).toContain(`"kind" VARCHAR(16) NOT NULL DEFAULT 'deliverable'`);
54
+ for (const column of [
55
+ `"price_amount" INTEGER`,
56
+ `"price_currency" CHAR(3)`,
57
+ `"checkout_session_id" TEXT`,
58
+ `"checkout_url" VARCHAR(2048)`,
59
+ `"checkout_expires_at" TIMESTAMP(3)`,
60
+ `"paid_at" TIMESTAMP(3)`,
61
+ `"paid_session_id" TEXT`,
62
+ ]) {
63
+ expect(add).toMatch(new RegExp(`${column.replace(/[()]/g, "\\$&")}(,|$)`));
64
+ }
65
+ expect(add).not.toMatch(/DEFAULT 'proposal'/);
66
+ });
67
+ });
@@ -0,0 +1,208 @@
1
+ /**
2
+ * @file piece-kind.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the piece kind and a proposal's price and payment
5
+ * columns (#8516, spec docs/specs/8516-proposals-as-an-artifact-kind-spec.md
6
+ * D1, D2, AC-1). Requires DATABASE_URL pointing at a Postgres with the
7
+ * migrations applied — SKIPS when DATABASE_URL is unset (the repo convention;
8
+ * the `apps/api Route Tests (Postgres)` workflow exercises it).
9
+ *
10
+ * Uses the `pg` driver directly, in ONE transaction that is rolled back, so
11
+ * dropping and re-adding the columns never outlives the suite. What it proves
12
+ * that a mock cannot:
13
+ * 1. Re-running the migration's own text over a piece written with the
14
+ * pre-#8516 column set reads it back as a `deliverable`, the seven other
15
+ * columns NULL and every pre-existing column (`updated_at` included)
16
+ * byte-equal — no row is rewritten.
17
+ * 2. Each CHECK and the unique index refuse what they name.
18
+ */
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ import { Client } from "pg";
23
+
24
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
25
+
26
+ const MIGRATION = join(__dirname, "..", "migrations", "20261016120000_piece_kind", "migration.sql");
27
+
28
+ const WS = "wksp_01920000-8516-7000-8000-0000000000c1";
29
+ const USER = "user_01920000-8516-7000-8000-0000000000a1";
30
+ const NEW_COLUMNS = [
31
+ "kind",
32
+ "price_amount",
33
+ "price_currency",
34
+ "checkout_session_id",
35
+ "checkout_url",
36
+ "checkout_expires_at",
37
+ "paid_at",
38
+ "paid_session_id",
39
+ ] as const;
40
+
41
+ describeDb("the piece kind columns (#8516 D1, D2, AC-1, live DB)", () => {
42
+ let db: Client;
43
+ let personId: string;
44
+
45
+ beforeAll(async () => {
46
+ db = new Client({ connectionString: process.env.DATABASE_URL });
47
+ await db.connect();
48
+ await db.query("BEGIN");
49
+ personId = (
50
+ await db.query<{ id: string }>(
51
+ `INSERT INTO "people" ("workspace_id", "email", "updated_at") VALUES ($1, $2, now()) RETURNING "id"`,
52
+ [WS, "p8516@example.com"],
53
+ )
54
+ ).rows[0]!.id;
55
+ });
56
+
57
+ afterAll(async () => {
58
+ await db.query("ROLLBACK");
59
+ await db.end();
60
+ });
61
+
62
+ /** Insert one piece with the pre-#8516 column set plus any of the new ones. */
63
+ const insertPiece = (cols: Record<string, unknown> = {}) => {
64
+ const all: Record<string, unknown> = {
65
+ workspace_id: WS,
66
+ person_id: personId,
67
+ title: "Site redesign",
68
+ stage: "requested",
69
+ updated_by: USER,
70
+ updated_at: new Date("2026-10-01T09:00:00.000Z"),
71
+ ...cols,
72
+ };
73
+ const names = Object.keys(all);
74
+ return db.query<{ id: string }>(
75
+ `INSERT INTO "pieces" (${names.map((n) => `"${n}"`).join(", ")})
76
+ VALUES (${names.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING "id"`,
77
+ names.map((n) => all[n]),
78
+ );
79
+ };
80
+
81
+ /** Run `sql` inside a savepoint; resolve to the violated constraint's name, or null. */
82
+ const violation = async (run: () => Promise<unknown>): Promise<string | null> => {
83
+ await db.query("SAVEPOINT attempt");
84
+ try {
85
+ await run();
86
+ await db.query("RELEASE SAVEPOINT attempt");
87
+ return null;
88
+ } catch (err) {
89
+ await db.query("ROLLBACK TO SAVEPOINT attempt");
90
+ return (err as { constraint?: string }).constraint ?? String(err);
91
+ }
92
+ };
93
+
94
+ it("re-running the migration over a pre-#8516 row reads it as a deliverable, nothing else changed", async () => {
95
+ await db.query(
96
+ `ALTER TABLE "pieces" ${NEW_COLUMNS.map((c) => `DROP COLUMN "${c}"`).join(", ")}`,
97
+ );
98
+ const pieceId = (await insertPiece()).rows[0]!.id;
99
+ await db.query(
100
+ `INSERT INTO "piece_versions" ("workspace_id", "person_id", "piece_id", "version", "link", "delivered_by")
101
+ VALUES ($1, $2, $3, 1, 'https://example.com/v1.pdf', $4)`,
102
+ [WS, personId, pieceId, USER],
103
+ );
104
+ const before = (
105
+ await db.query<{ row: Record<string, unknown> }>(
106
+ `SELECT row_to_json(p) AS row FROM "pieces" p WHERE "id" = $1`,
107
+ [pieceId],
108
+ )
109
+ ).rows[0]!.row;
110
+
111
+ await db.query(readFileSync(MIGRATION, "utf8"));
112
+
113
+ const after = (
114
+ await db.query<{ row: Record<string, unknown> }>(
115
+ `SELECT row_to_json(p) AS row FROM "pieces" p WHERE "id" = $1`,
116
+ [pieceId],
117
+ )
118
+ ).rows[0]!.row;
119
+ expect(after.kind).toBe("deliverable");
120
+ for (const column of NEW_COLUMNS.slice(1)) expect(after[column]).toBeNull();
121
+ const kept = Object.fromEntries(
122
+ Object.entries(after).filter(([key]) => !(NEW_COLUMNS as readonly string[]).includes(key)),
123
+ );
124
+ expect(kept).toEqual(before);
125
+ });
126
+
127
+ it("refuses a kind outside the two", async () => {
128
+ expect(await violation(() => insertPiece({ kind: "other" }))).toBe("pieces_kind_allowed");
129
+ });
130
+
131
+ it("refuses a price on a deliverable, half a price, and an amount out of range", async () => {
132
+ expect(
133
+ await violation(() => insertPiece({ price_amount: 1000, price_currency: "usd" })),
134
+ ).toBe("pieces_price_proposal_only");
135
+ expect(await violation(() => insertPiece({ kind: "proposal", price_amount: 1000 }))).toBe(
136
+ "pieces_price_pair",
137
+ );
138
+ for (const amount of [0, 100000001]) {
139
+ expect(
140
+ await violation(() =>
141
+ insertPiece({ kind: "proposal", price_amount: amount, price_currency: "usd" }),
142
+ ),
143
+ ).toBe("pieces_price_range");
144
+ }
145
+ expect(
146
+ await violation(() =>
147
+ insertPiece({ kind: "proposal", price_amount: 1, price_currency: "usd" }),
148
+ ),
149
+ ).toBeNull();
150
+ });
151
+
152
+ const SESSION = {
153
+ checkout_session_id: "cs_test_a1",
154
+ checkout_url: "https://checkout.stripe.com/c/pay/cs_test_a1",
155
+ checkout_expires_at: new Date("2026-10-16T13:00:00.000Z"),
156
+ };
157
+
158
+ it("refuses a checkout session on an unapproved or unpriced piece, and a partial session", async () => {
159
+ expect(
160
+ await violation(() =>
161
+ insertPiece({ kind: "proposal", price_amount: 5000, price_currency: "usd", ...SESSION }),
162
+ ),
163
+ ).toBe("pieces_checkout_needs_approved_price");
164
+ expect(
165
+ await violation(() => insertPiece({ kind: "proposal", approved_at: new Date(), ...SESSION })),
166
+ ).toBe("pieces_checkout_needs_approved_price");
167
+ expect(
168
+ await violation(() =>
169
+ insertPiece({
170
+ kind: "proposal",
171
+ price_amount: 5000,
172
+ price_currency: "usd",
173
+ approved_at: new Date(),
174
+ checkout_session_id: "cs_test_a2",
175
+ }),
176
+ ),
177
+ ).toBe("pieces_checkout_triple");
178
+ });
179
+
180
+ it("refuses paid_at without the session that paid it", async () => {
181
+ expect(
182
+ await violation(() =>
183
+ insertPiece({
184
+ kind: "proposal",
185
+ price_amount: 5000,
186
+ price_currency: "usd",
187
+ approved_at: new Date(),
188
+ paid_at: new Date(),
189
+ }),
190
+ ),
191
+ ).toBe("pieces_paid_pair");
192
+ });
193
+
194
+ it("refuses two pieces holding one checkout session", async () => {
195
+ const approvedPriced = {
196
+ kind: "proposal",
197
+ price_amount: 5000,
198
+ price_currency: "usd",
199
+ approved_at: new Date(),
200
+ ...SESSION,
201
+ checkout_session_id: "cs_test_dup",
202
+ };
203
+ expect(await violation(() => insertPiece(approvedPriced))).toBeNull();
204
+ expect(await violation(() => insertPiece(approvedPriced))).toBe(
205
+ "pieces_checkout_session_id_key",
206
+ );
207
+ });
208
+ });
@@ -0,0 +1,8 @@
1
+ # Generated Prisma Client
2
+ generated/
3
+
4
+ # Compiled output (tsc emit from generated/)
5
+ dist/
6
+
7
+ # Node modules
8
+ node_modules/