create-ailk 0.1.1 → 0.2.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 (40) hide show
  1. package/component-catalog.md +175 -14
  2. package/dist/cli.js +0 -0
  3. package/package.json +14 -15
  4. package/templates/apps/api/CLAUDE.md +4 -1
  5. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  6. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +11 -0
  7. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +127 -0
  8. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
  9. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
  10. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
  11. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +11 -0
  12. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
  13. package/templates/apps/api/src/routes/project-listings/index.ts +15 -0
  14. package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
  15. package/templates/apps/api/src/routes/project-listings/start.ts +8 -0
  16. package/templates/apps/api/src/server.ts +14 -0
  17. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  18. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  19. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
  20. package/templates/apps/api/src/services/consent.ts +236 -0
  21. package/templates/apps/api/src/services/flow-engine.ts +18 -0
  22. package/templates/apps/api/src/services/listing-promotion.ts +342 -0
  23. package/templates/apps/api/src/services/project-listing-decision.ts +21 -1
  24. package/templates/apps/api/src/services/project-listings.ts +373 -25
  25. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  26. package/templates/apps/web/jest.config.cjs +6 -0
  27. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  28. package/templates/apps/web/lib/site-brand.tsx +4 -1
  29. package/templates/apps/web/lib/site-theme.ts +74 -0
  30. package/templates/apps/web/package.json +1 -0
  31. package/templates/content/_site.mdx +12 -0
  32. package/templates/database/CHANGELOG.md +61 -0
  33. package/templates/database/inbox/schema.prisma +98 -0
  34. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  35. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  36. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  37. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  38. package/templates/database/package.json +1 -1
  39. package/templates/database/scripts/db-generate-locked.sh +0 -0
  40. package/templates/package.json +1 -1
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @file consent-migration.test.ts
3
+ * @description Drift guard between the consent_grants migration's BACKFILL
4
+ * and the consent service (#5275, spec §5.1 last bullet + §5.3 second
5
+ * boundary): every pre-existing listing gets one grant marked
6
+ * wording-UNRECOVERABLE — the exact `UNRECOVERABLE_CONSENT_STATEMENT`
7
+ * literal, so a later reader can match it — stamped at the listing's own
8
+ * consent_at, and project_listings itself is never touched.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+
13
+ import { SITE_CONSENT_CONFIG_DEFAULTS } from "@working-theory/validation";
14
+
15
+ import { UNRECOVERABLE_CONSENT_STATEMENT } from "../consent";
16
+
17
+ const MIGRATION = join(
18
+ resolve(__dirname, "..", "..", "..", "..", ".."),
19
+ "database",
20
+ "migrations",
21
+ "20260911180000_consent_grants",
22
+ "migration.sql",
23
+ );
24
+
25
+ describe("consent_grants migration backfill (#5275 D11)", () => {
26
+ const sql = readFileSync(MIGRATION, "utf-8");
27
+
28
+ it("creates the table with its two indexes", () => {
29
+ expect(sql).toContain('CREATE TABLE "consent_grants"');
30
+ expect(sql).toContain('("site_id", "subject_type", "subject_id")');
31
+ expect(sql).toContain('("site_id", "purpose_id")');
32
+ });
33
+
34
+ it("inserts one row per pre-existing project_listings row", () => {
35
+ expect(sql).toContain('INSERT INTO "consent_grants"');
36
+ expect(sql).toMatch(/FROM "project_listings"/);
37
+ expect(sql).toContain("'project_listing'");
38
+ expect(sql).toContain("'service_terms'");
39
+ });
40
+
41
+ it("marks every backfilled row with the exact unrecoverable sentinel and a COMPUTED hash", () => {
42
+ expect(sql).toContain(`'${UNRECOVERABLE_CONSENT_STATEMENT}'`);
43
+ expect(sql).toContain(
44
+ `encode(sha256(convert_to('${UNRECOVERABLE_CONSENT_STATEMENT}', 'UTF8')), 'hex')`,
45
+ );
46
+ expect(sql).toContain("'und'");
47
+ });
48
+
49
+ it("sources created_at from the listing's own consent_at", () => {
50
+ const insert = sql.slice(sql.indexOf('INSERT INTO "consent_grants"'));
51
+ expect(insert).toContain('"consent_at"');
52
+ expect(insert).toContain('"created_at"');
53
+ });
54
+
55
+ it("never UPDATEs or ALTERs project_listings — consent_at stays as it was", () => {
56
+ expect(sql).not.toMatch(/UPDATE\s+"project_listings"/i);
57
+ expect(sql).not.toMatch(/ALTER\s+TABLE\s+"project_listings"/i);
58
+ });
59
+
60
+ it("never stamps today's default wording onto a grant nobody read", () => {
61
+ expect(sql).not.toContain("You agree to being contacted");
62
+ const defaultStatement = SITE_CONSENT_CONFIG_DEFAULTS.purposes[0]!.label["en"]!;
63
+ expect(sql).not.toContain(defaultStatement);
64
+ });
65
+ });
@@ -0,0 +1,282 @@
1
+ /**
2
+ * @file consent.test.ts
3
+ * @description Unit tests for the consent-grant writer (#5275 Part B) —
4
+ * `recordConsent`, `recordLegacyConsent`, `consentStatementHash`,
5
+ * `consentConfigForSite`.
6
+ *
7
+ * External dependency boundaries mocked:
8
+ * - @working-theory/database (via moduleNameMapper -> prisma-client.js mock; prisma singleton via __mocks__/prisma.ts)
9
+ *
10
+ * The evidence rule under test: a row carries the statement the registry
11
+ * RESOLVED (the same function the renderer used), in the locale it was
12
+ * actually served, or the unrecoverable sentinel — never anything a request
13
+ * body said and never today's wording for a grant nobody read.
14
+ */
15
+
16
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
17
+ jest.mock("../../lib/prisma.js");
18
+
19
+ import { createHash } from "node:crypto";
20
+
21
+ import {
22
+ DEFAULT_CONSENT_PURPOSE_ID,
23
+ resolveConsentStatement,
24
+ SITE_CONSENT_CONFIG_DEFAULTS,
25
+ siteConsentConfigSchema,
26
+ } from "@working-theory/validation";
27
+
28
+ import { prisma } from "../../lib/prisma.js";
29
+
30
+ import {
31
+ consentConfigForSite,
32
+ consentStatementHash,
33
+ recordConsent,
34
+ recordLegacyConsent,
35
+ UNRECOVERABLE_CONSENT_LOCALE,
36
+ UNRECOVERABLE_CONSENT_STATEMENT,
37
+ requiredConsentUnmet,
38
+ } from "../consent";
39
+
40
+ const SITE_ID = "site-1";
41
+ const SUBJECT = { subjectType: "project_listing", subjectId: "prjl_00000000-0000-7000-8000-000000000001" };
42
+
43
+ /** Two purposes, one of them bilingual — the fixture every locale case reads from. */
44
+ const CONFIG = siteConsentConfigSchema.parse({
45
+ policies: { terms: "/terms", privacy: "/privacy" },
46
+ purposes: [
47
+ {
48
+ id: "service_terms",
49
+ required: true,
50
+ label: {
51
+ en: "You agree to our [Terms](terms).",
52
+ fr: "Vous acceptez nos [Conditions](terms).",
53
+ },
54
+ },
55
+ {
56
+ id: "marketing_email",
57
+ required: false,
58
+ label: { en: "Send me occasional product news." },
59
+ },
60
+ ],
61
+ });
62
+
63
+ type Row = Record<string, unknown>;
64
+
65
+ function writtenRows(call = 0): Row[] {
66
+ const arg = (prisma.consentGrant.createMany as jest.Mock).mock.calls[call]![0] as { data: Row[] };
67
+ return arg.data;
68
+ }
69
+
70
+ describe("recordConsent (#5275)", () => {
71
+ beforeEach(() => {
72
+ jest.clearAllMocks();
73
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
74
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
75
+ );
76
+ });
77
+
78
+ it("one granted + one declined purpose → ONE createMany with two rows, one granted:true and one granted:false (D8)", async () => {
79
+ const written = await recordConsent({
80
+ siteId: SITE_ID,
81
+ ...SUBJECT,
82
+ locale: "en",
83
+ config: CONFIG,
84
+ consents: { service_terms: true, marketing_email: false },
85
+ });
86
+
87
+ expect(written).toBe(2);
88
+ expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
89
+ const rows = writtenRows();
90
+ expect(rows).toHaveLength(2);
91
+ expect(rows.find((r) => r["purposeId"] === "service_terms")).toMatchObject({
92
+ siteId: SITE_ID,
93
+ ...SUBJECT,
94
+ granted: true,
95
+ });
96
+ expect(rows.find((r) => r["purposeId"] === "marketing_email")).toMatchObject({
97
+ siteId: SITE_ID,
98
+ ...SUBJECT,
99
+ granted: false,
100
+ });
101
+ });
102
+
103
+ it("each row carries the RESOLVED statement text and the locale it was actually served in (D7)", async () => {
104
+ // `fr-CA` is asked for; the bilingual purpose serves `fr` (language-subtag
105
+ // fallback), the English-only purpose serves `en` (default-locale
106
+ // fallback). The row records the locale READ, not the one requested.
107
+ await recordConsent({
108
+ siteId: SITE_ID,
109
+ ...SUBJECT,
110
+ locale: "fr-CA",
111
+ config: CONFIG,
112
+ consents: { service_terms: true, marketing_email: true },
113
+ });
114
+
115
+ const rows = writtenRows();
116
+ const terms = rows.find((r) => r["purposeId"] === "service_terms")!;
117
+ const marketing = rows.find((r) => r["purposeId"] === "marketing_email")!;
118
+
119
+ const expectedTerms = resolveConsentStatement(CONFIG.purposes[0]!, "fr-CA");
120
+ expect(expectedTerms.locale).toBe("fr");
121
+ expect(terms["statement"]).toBe("Vous acceptez nos [Conditions](terms).");
122
+ expect(terms["statement"]).toBe(expectedTerms.statement);
123
+ expect(terms["locale"]).toBe("fr");
124
+ expect(terms["statementHash"]).toBe(consentStatementHash(expectedTerms.statement));
125
+
126
+ expect(marketing["statement"]).toBe("Send me occasional product news.");
127
+ expect(marketing["locale"]).toBe("en");
128
+ expect(marketing["locale"]).not.toBe("fr-CA");
129
+ });
130
+
131
+ it("a second recordConsent for the same subject + purpose APPENDS — createMany again, never update/upsert (D9)", async () => {
132
+ const input = {
133
+ siteId: SITE_ID,
134
+ ...SUBJECT,
135
+ locale: "en",
136
+ config: CONFIG,
137
+ consents: { marketing_email: true },
138
+ };
139
+ await recordConsent(input);
140
+ await recordConsent({ ...input, consents: { marketing_email: false } });
141
+
142
+ expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(2);
143
+ expect(writtenRows(0)[0]).toMatchObject({ purposeId: "marketing_email", granted: true });
144
+ expect(writtenRows(1)[0]).toMatchObject({ purposeId: "marketing_email", granted: false });
145
+ // The mock delegate deliberately exposes no mutating method: the writer
146
+ // has nothing to update or upsert WITH, so append is structural.
147
+ const delegate = prisma.consentGrant as Record<string, unknown>;
148
+ expect(delegate["update"]).toBeUndefined();
149
+ expect(delegate["upsert"]).toBeUndefined();
150
+ expect(delegate["updateMany"]).toBeUndefined();
151
+ });
152
+
153
+ it("an unknown purpose id writes NOTHING for it — no statement was rendered (evidence rule)", async () => {
154
+ const written = await recordConsent({
155
+ siteId: SITE_ID,
156
+ ...SUBJECT,
157
+ locale: "en",
158
+ config: CONFIG,
159
+ consents: { service_terms: true, not_a_purpose: true },
160
+ });
161
+
162
+ expect(written).toBe(1);
163
+ const rows = writtenRows();
164
+ expect(rows).toHaveLength(1);
165
+ expect(rows[0]!["purposeId"]).toBe("service_terms");
166
+ });
167
+
168
+ it("only unknown purpose ids → no createMany at all, returns 0", async () => {
169
+ const written = await recordConsent({
170
+ siteId: SITE_ID,
171
+ ...SUBJECT,
172
+ locale: "en",
173
+ config: CONFIG,
174
+ consents: { not_a_purpose: true },
175
+ });
176
+ expect(written).toBe(0);
177
+ expect(prisma.consentGrant.createMany).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("never writes the unrecoverable sentinel when a statement was resolved", async () => {
181
+ await recordConsent({
182
+ siteId: SITE_ID,
183
+ ...SUBJECT,
184
+ locale: "en",
185
+ config: SITE_CONSENT_CONFIG_DEFAULTS,
186
+ consents: { [DEFAULT_CONSENT_PURPOSE_ID]: true },
187
+ });
188
+ const [row] = writtenRows();
189
+ expect(row!["statement"]).not.toBe(UNRECOVERABLE_CONSENT_STATEMENT);
190
+ expect(row!["locale"]).not.toBe(UNRECOVERABLE_CONSENT_LOCALE);
191
+ });
192
+ });
193
+
194
+ describe("recordLegacyConsent (#5275 D11)", () => {
195
+ beforeEach(() => {
196
+ jest.clearAllMocks();
197
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
198
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
199
+ );
200
+ });
201
+
202
+ it("writes exactly one row: sentinel statement, locale 'und', purpose service_terms under the default config", async () => {
203
+ const written = await recordLegacyConsent({
204
+ siteId: SITE_ID,
205
+ ...SUBJECT,
206
+ config: SITE_CONSENT_CONFIG_DEFAULTS,
207
+ });
208
+
209
+ expect(written).toBe(1);
210
+ expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
211
+ const rows = writtenRows();
212
+ expect(rows).toHaveLength(1);
213
+ expect(rows[0]).toEqual({
214
+ siteId: SITE_ID,
215
+ ...SUBJECT,
216
+ purposeId: DEFAULT_CONSENT_PURPOSE_ID,
217
+ granted: true,
218
+ locale: UNRECOVERABLE_CONSENT_LOCALE,
219
+ statement: UNRECOVERABLE_CONSENT_STATEMENT,
220
+ statementHash: consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT),
221
+ });
222
+ expect(rows[0]!["purposeId"]).toBe("service_terms");
223
+ expect(rows[0]!["locale"]).toBe("und");
224
+ // Never today's wording for a grant nobody read.
225
+ expect(rows[0]!["statement"]).not.toContain("You agree to being contacted");
226
+ });
227
+
228
+ it("targets the first REQUIRED purpose, not merely the first purpose", async () => {
229
+ const config = siteConsentConfigSchema.parse({
230
+ purposes: [
231
+ { id: "marketing_email", required: false, label: { en: "News." } },
232
+ { id: "service_terms", required: true, label: { en: "Terms." } },
233
+ ],
234
+ });
235
+ await recordLegacyConsent({ siteId: SITE_ID, ...SUBJECT, config });
236
+ expect(writtenRows()[0]!["purposeId"]).toBe("service_terms");
237
+ });
238
+
239
+ it("falls back to the first purpose when none is required", async () => {
240
+ const config = siteConsentConfigSchema.parse({
241
+ purposes: [{ id: "marketing_email", required: false, label: { en: "News." } }],
242
+ });
243
+ await recordLegacyConsent({ siteId: SITE_ID, ...SUBJECT, config });
244
+ expect(writtenRows()[0]!["purposeId"]).toBe("marketing_email");
245
+ });
246
+ });
247
+
248
+ describe("consentStatementHash", () => {
249
+ it("is sha256 hex", () => {
250
+ expect(consentStatementHash("abc")).toBe(
251
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
252
+ );
253
+ expect(consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT)).toBe(
254
+ createHash("sha256").update(UNRECOVERABLE_CONSENT_STATEMENT).digest("hex"),
255
+ );
256
+ expect(consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT)).toMatch(/^[0-9a-f]{64}$/);
257
+ });
258
+ });
259
+
260
+ describe("consentConfigForSite", () => {
261
+ it("resolves every site to the platform defaults until a config row exists", async () => {
262
+ await expect(consentConfigForSite(SITE_ID)).resolves.toBe(SITE_CONSENT_CONFIG_DEFAULTS);
263
+ });
264
+ });
265
+
266
+ describe("requiredConsentUnmet (#5275, security review Medium 1)", () => {
267
+ const config = {
268
+ policies: {},
269
+ purposes: [
270
+ { id: "service_terms", required: true, label: { en: "a" } },
271
+ { id: "marketing", required: false, label: { en: "b" } },
272
+ ],
273
+ };
274
+
275
+ it("names a required purpose that is declined OR absent, and passes when every required one is true", () => {
276
+ expect(requiredConsentUnmet(config, { service_terms: false })).toBe("service_terms");
277
+ expect(requiredConsentUnmet(config, {})).toBe("service_terms");
278
+ expect(requiredConsentUnmet(config, { marketing: true })).toBe("service_terms");
279
+ expect(requiredConsentUnmet(config, { service_terms: true })).toBeUndefined();
280
+ expect(requiredConsentUnmet(config, { service_terms: true, marketing: false })).toBeUndefined();
281
+ });
282
+ });