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
@@ -52,8 +52,15 @@ jest.mock("@working-theory/license", () => ({
52
52
  }),
53
53
  }));
54
54
 
55
+ import {
56
+ resolveConsentStatement,
57
+ SITE_CONSENT_CONFIG_DEFAULTS,
58
+ } from "@working-theory/validation";
59
+
55
60
  import { prisma } from "../../../lib/prisma.js";
56
61
  import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
62
+ import {
63
+ MAX_CONSENT_KEYS, UNRECOVERABLE_CONSENT_STATEMENT } from "../../../services/consent.js";
57
64
  import { patchProjectListingDraftRoute } from "../patch-draft.js";
58
65
  import { startProjectListingRoute } from "../start.js";
59
66
  import { submitProjectListingRoute } from "../submit.js";
@@ -164,6 +171,11 @@ function installStore(): void {
164
171
  },
165
172
  );
166
173
 
174
+ // #5275 — `start` writes the row and its grant in one interactive
175
+ // transaction; the mock hands the same store-backed client through.
176
+ (prisma.$transaction as jest.Mock).mockImplementation(
177
+ async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
178
+ );
167
179
  (prisma.projectListing.create as jest.Mock).mockImplementation(
168
180
  ({ data }: { data: Row }) => {
169
181
  const now = new Date();
@@ -226,6 +238,12 @@ function installStore(): void {
226
238
  return Promise.resolve({ count: matched.length });
227
239
  },
228
240
  );
241
+
242
+ // #5275 — the grant write `start` now makes. Append-only, so the mock only
243
+ // needs to resolve; the start tests below assert on what it was called with.
244
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
245
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
246
+ );
229
247
  }
230
248
 
231
249
  // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -335,6 +353,115 @@ describe("the draft lifecycle (#5036 D1)", () => {
335
353
  expect(rows).toHaveLength(0);
336
354
  });
337
355
 
356
+ // ── start: the consent grant (#5275 D7/D11) ──
357
+
358
+ it.each([
359
+ ["DECLINES the required purpose", { service_terms: false }],
360
+ ["is EMPTY beside the literal", {}],
361
+ ["OMITS the required purpose", { marketing: true }],
362
+ ])(
363
+ "start: a consents map that %s is a 400 and creates no row and no grant",
364
+ async (_label, consents) => {
365
+ const res = await app.inject({
366
+ method: "POST",
367
+ url: "/v1/project-listings",
368
+ payload: { ...START_PAYLOAD, consents, locale: "en" },
369
+ });
370
+ expect(res.statusCode).toBe(400);
371
+ const body = res.json() as { ok: boolean; error: { code: string; message: string } };
372
+ expect(body.ok).toBe(false);
373
+ expect(body.error.code).toBe("invalid_input");
374
+ expect(body.error.message).toMatch(/service_terms/);
375
+ expect(rows).toHaveLength(0);
376
+ expect(prisma.consentGrant.createMany).not.toHaveBeenCalled();
377
+ },
378
+ );
379
+
380
+ it("start: a consents map with more than MAX_CONSENT_KEYS purposes is a 400", async () => {
381
+ const consents: Record<string, boolean> = { service_terms: true };
382
+ for (let i = 0; i < MAX_CONSENT_KEYS; i += 1) consents[`junk_${i}`] = true;
383
+ const res = await app.inject({
384
+ method: "POST",
385
+ url: "/v1/project-listings",
386
+ payload: { ...START_PAYLOAD, consents, locale: "en" },
387
+ });
388
+ expect(res.statusCode).toBe(400);
389
+ expect(rows).toHaveLength(0);
390
+ });
391
+
392
+ it("start: a grant write that fails takes the row with it — one transaction, no stranded listing", async () => {
393
+ (prisma.$transaction as jest.Mock).mockImplementation(
394
+ async (fn: (tx: typeof prisma) => Promise<unknown>) => {
395
+ const before = rows.length;
396
+ try {
397
+ return await fn(prisma);
398
+ } catch (err) {
399
+ rows.splice(before); // roll back the in-memory store
400
+ throw err;
401
+ }
402
+ },
403
+ );
404
+ (prisma.consentGrant.createMany as jest.Mock).mockRejectedValueOnce(new Error("db down"));
405
+ const res = await app.inject({
406
+ method: "POST",
407
+ url: "/v1/project-listings",
408
+ payload: { ...START_PAYLOAD, consents: { service_terms: true }, locale: "en" },
409
+ });
410
+ expect(res.statusCode).toBe(500);
411
+ expect(rows).toHaveLength(0);
412
+ });
413
+
414
+ it("start: a body carrying consents records one grant per purpose with the DEFAULT statement text", async () => {
415
+ const { id } = await startDraft(app, { consents: { service_terms: true }, locale: "en" });
416
+
417
+ // The expected wording is DERIVED from the registry the server resolves
418
+ // from, never retyped — the whole point is that the row carries the string
419
+ // the renderer showed, not a string this test happens to agree with.
420
+ const purpose = SITE_CONSENT_CONFIG_DEFAULTS.purposes[0]!;
421
+ const { statement } = resolveConsentStatement(purpose, "en");
422
+
423
+ expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
424
+ const { data } = (prisma.consentGrant.createMany as jest.Mock).mock.calls[0]![0] as {
425
+ data: Row[];
426
+ };
427
+ expect(data).toHaveLength(1);
428
+ expect(data[0]).toMatchObject({
429
+ siteId: SITE_ID,
430
+ subjectType: "project_listing",
431
+ subjectId: id,
432
+ purposeId: "service_terms",
433
+ granted: true,
434
+ locale: "en",
435
+ statement,
436
+ statementHash: sha256(statement),
437
+ });
438
+ expect(data[0]!["statement"]).not.toBe(UNRECOVERABLE_CONSENT_STATEMENT);
439
+ // The row's own consentAt is untouched by the grant write (spec §2.3).
440
+ expect(rows[0]!["consentAt"]).toBeInstanceOf(Date);
441
+ });
442
+
443
+ it("start: a body carrying only the legacy literal records ONE grant marked unrecoverable", async () => {
444
+ const { id } = await startDraft(app);
445
+
446
+ expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
447
+ const { data } = (prisma.consentGrant.createMany as jest.Mock).mock.calls[0]![0] as {
448
+ data: Row[];
449
+ };
450
+ expect(data).toHaveLength(1);
451
+ expect(data[0]).toMatchObject({
452
+ siteId: SITE_ID,
453
+ subjectType: "project_listing",
454
+ subjectId: id,
455
+ purposeId: "service_terms",
456
+ granted: true,
457
+ locale: "und",
458
+ statement: UNRECOVERABLE_CONSENT_STATEMENT,
459
+ statementHash: sha256(UNRECOVERABLE_CONSENT_STATEMENT),
460
+ });
461
+ // Never today's wording for a legacy call — that would fabricate evidence (D11).
462
+ expect(data[0]!["statement"]).not.toContain("You agree to being contacted");
463
+ });
464
+
338
465
  // ── patch ──
339
466
 
340
467
  it("patch: a SINGLE-field patch round-trips", async () => {
@@ -0,0 +1,221 @@
1
+ // oss: false
2
+ /**
3
+ * @file me-route-precedence.test.ts
4
+ *
5
+ * The regression net for the one structural risk in #5295: the static
6
+ * `/v1/project-listings/me` and the parametric `/v1/project-listings/:id` share
7
+ * a prefix and are registered in DIFFERENT scopes — `/me` in `server.ts`'s
8
+ * `authScope`, `/:id` inside the encapsulated `tenantScope` behind
9
+ * `tenantPreHandlerPlugin`.
10
+ *
11
+ * `me.test.ts` registers `/me` alone, so nothing there would catch a
12
+ * registration reorder that shadowed `/me` or weakened the tenant guard on
13
+ * `/:id`. The security review for this change verified the behaviour by hand and
14
+ * named the missing net; this file is that net.
15
+ *
16
+ * It asserts three things in ONE Fastify instance with the REAL preHandlers:
17
+ * 1. `/v1/project-listings/me` reaches the `/me` handler — static beats
18
+ * parametric — and runs auth ONLY, no tenant guard.
19
+ * 2. `/v1/project-listings/<id>` still reaches the tenant handler WITH the
20
+ * tenant guard: registering a static sibling in the outer scope does not
21
+ * weaken it. A member below the `owner|admin` floor is still refused.
22
+ * 3. Registering both raises no duplicate-route error.
23
+ *
24
+ * This file is `oss: false` because it imports the multi-tenant slice
25
+ * (`middleware/tenant.js`, `./get.js`), the same reason `tenant-isolation.test.ts`
26
+ * is.
27
+ */
28
+ import Fastify from "fastify";
29
+ import type { FastifyInstance } from "fastify";
30
+
31
+ jest.mock("@working-theory/email", () => ({
32
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
33
+ }));
34
+ jest.mock("@working-theory/license", () => ({
35
+ checkLicense: jest.fn().mockResolvedValue({
36
+ tier: "free",
37
+ plan: null,
38
+ features: [],
39
+ expiresAt: null,
40
+ revoked: false,
41
+ }),
42
+ }));
43
+
44
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
45
+ jest.mock("../../../lib/prisma.js", () => ({
46
+ prisma: {
47
+ session: { findUnique: jest.fn() },
48
+ apiToken: {
49
+ findUnique: jest.fn(),
50
+ update: jest.fn().mockResolvedValue(undefined),
51
+ },
52
+ user: { findUnique: jest.fn() },
53
+ workspace: { findUnique: jest.fn(), count: jest.fn() },
54
+ membership: { findUnique: jest.fn() },
55
+ projectListing: { findMany: jest.fn(), findFirst: jest.fn() },
56
+ waitlistScore: { count: jest.fn() },
57
+ },
58
+ }));
59
+
60
+ import { prisma } from "../../../lib/prisma.js";
61
+ import { authPreHandlerPlugin } from "../../../middleware/auth.js";
62
+ import { tenantPreHandlerPlugin } from "../../../middleware/tenant.js";
63
+ import { projectListingMeRoute, projectListingTenantRoutes } from "../index.js";
64
+
65
+ const FRONTEND = "https://app.example.com";
66
+ const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
67
+
68
+ const ORG = "org_00000000-0000-7000-8000-000000000001";
69
+ const WS = "wksp_00000000-0000-7000-8000-000000000001";
70
+ const LISTING_ID = "prjl_00000000-0000-7000-8000-0000000000d1";
71
+
72
+ /** `owner` clears the floor; `member` does not. Both are real members of ORG. */
73
+ const SESSION_ROWS: Record<string, Record<string, unknown>> = {
74
+ cookie_owner: {
75
+ id: "sess_owner",
76
+ userId: "owner-user",
77
+ expiresAt: FUTURE,
78
+ activeOrganizationId: ORG,
79
+ activeWorkspaceId: WS,
80
+ },
81
+ cookie_member: {
82
+ id: "sess_member",
83
+ userId: "member-user",
84
+ expiresAt: FUTURE,
85
+ activeOrganizationId: ORG,
86
+ activeWorkspaceId: WS,
87
+ },
88
+ };
89
+
90
+ const MEMBERSHIPS: Record<string, string> = {
91
+ "owner-user": "owner",
92
+ "member-user": "member",
93
+ };
94
+
95
+ async function buildApp(): Promise<FastifyInstance> {
96
+ // The exact nesting server.ts uses: /me in the OUTER auth scope, the tenant
97
+ // routes in an INNER scope behind the tenant preHandler.
98
+ const app = Fastify();
99
+ await app.register(async (authScope) => {
100
+ await authScope.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
101
+ await authScope.register(projectListingMeRoute);
102
+ await authScope.register(async (tenantScope) => {
103
+ await tenantScope.register(tenantPreHandlerPlugin);
104
+ await tenantScope.register(projectListingTenantRoutes);
105
+ });
106
+ });
107
+ await app.ready();
108
+ return app;
109
+ }
110
+
111
+ const cookie = (token: string) => ({ cookie: `better-auth.session_token=${token}` });
112
+
113
+ let app: FastifyInstance;
114
+
115
+ beforeEach(async () => {
116
+ jest.clearAllMocks();
117
+ (prisma.session.findUnique as jest.Mock).mockImplementation(
118
+ ({ where }: { where: { token?: string } }) =>
119
+ Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
120
+ );
121
+ (prisma.apiToken.findUnique as jest.Mock).mockResolvedValue(null);
122
+ (prisma.user.findUnique as jest.Mock).mockImplementation(({ where }: { where: { id: string } }) =>
123
+ Promise.resolve({ id: where.id, email: `${where.id}@example.com`, emailVerified: true }),
124
+ );
125
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue({ id: WS, organizationId: ORG });
126
+ // The composite key is `organizationId_userId` — the order `tenant-context.ts`
127
+ // actually queries with.
128
+ (prisma.membership.findUnique as jest.Mock).mockImplementation(
129
+ ({ where }: { where: { organizationId_userId?: { userId: string } } }) => {
130
+ const userId = where.organizationId_userId?.userId;
131
+ const role = userId ? MEMBERSHIPS[userId] : undefined;
132
+ return Promise.resolve(role ? { role } : null);
133
+ },
134
+ );
135
+ // The /me read returns nothing; this file is about routing, not payloads.
136
+ (prisma.projectListing.findMany as jest.Mock).mockResolvedValue([]);
137
+ (prisma.projectListing.findFirst as jest.Mock).mockResolvedValue(null);
138
+ app = await buildApp();
139
+ });
140
+
141
+ afterEach(async () => {
142
+ await app.close();
143
+ });
144
+
145
+ describe("route precedence: static /me beside parametric /:id in one instance", () => {
146
+ it("registering both raises no duplicate-route error", async () => {
147
+ // buildApp() already awaited app.ready(); reaching here is the assertion.
148
+ const tree = app.printRoutes();
149
+ expect(tree).toContain("me");
150
+ expect(tree).toContain(":id");
151
+ });
152
+
153
+ it("/v1/project-listings/me reaches the /me handler, not the parametric one", async () => {
154
+ const res = await app.inject({
155
+ method: "GET",
156
+ url: "/v1/project-listings/me",
157
+ headers: cookie("cookie_owner"),
158
+ });
159
+
160
+ // The /me handler answers its own 200 + empty list. The parametric handler
161
+ // would have answered 400 `validation_failed` — "me" is not a listing id.
162
+ expect(res.statusCode).toBe(200);
163
+ expect(res.json()).toEqual({ ok: true, data: [] });
164
+ // And it read through the owner link, not by id.
165
+ expect(prisma.projectListing.findMany).toHaveBeenCalledWith(
166
+ expect.objectContaining({ where: { ownerUserId: "owner-user" } }),
167
+ );
168
+ expect(prisma.projectListing.findFirst).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it("/me runs auth ONLY — a caller BELOW the owner|admin floor still gets their own rows", async () => {
172
+ const res = await app.inject({
173
+ method: "GET",
174
+ url: "/v1/project-listings/me",
175
+ headers: cookie("cookie_member"),
176
+ });
177
+
178
+ // A `member` is refused by the tenant routes and allowed here: /me is not
179
+ // behind the floor, and it does not need to be — the owner link is the gate.
180
+ expect(res.statusCode).toBe(200);
181
+ expect(res.json()).toEqual({ ok: true, data: [] });
182
+ });
183
+
184
+ it("the parametric /:id still runs the tenant guard — the static sibling does not weaken it", async () => {
185
+ const ownerRes = await app.inject({
186
+ method: "GET",
187
+ url: `/v1/project-listings/${LISTING_ID}`,
188
+ headers: cookie("cookie_owner"),
189
+ });
190
+ // Owner clears the floor and reaches the service, which finds no row.
191
+ expect(ownerRes.statusCode).toBe(404);
192
+
193
+ const memberRes = await app.inject({
194
+ method: "GET",
195
+ url: `/v1/project-listings/${LISTING_ID}`,
196
+ headers: cookie("cookie_member"),
197
+ });
198
+ // Member is still refused BEFORE any read — the floor is intact.
199
+ expect(memberRes.statusCode).toBe(403);
200
+ });
201
+
202
+ it("the tenant LIST route is unaffected and still refuses a member", async () => {
203
+ const res = await app.inject({
204
+ method: "GET",
205
+ url: "/v1/project-listings",
206
+ headers: cookie("cookie_member"),
207
+ });
208
+
209
+ expect(res.statusCode).toBe(403);
210
+ });
211
+
212
+ it("an unauthenticated caller is refused on both paths", async () => {
213
+ const meRes = await app.inject({ method: "GET", url: "/v1/project-listings/me" });
214
+ const idRes = await app.inject({ method: "GET", url: `/v1/project-listings/${LISTING_ID}` });
215
+
216
+ expect(meRes.statusCode).toBe(401);
217
+ expect(idRes.statusCode).toBe(401);
218
+ expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
219
+ expect(prisma.projectListing.findFirst).not.toHaveBeenCalled();
220
+ });
221
+ });