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,468 @@
1
+ /**
2
+ * @file me.test.ts
3
+ *
4
+ * THE ACCEPTANCE GATE for the founder's own listings read (#5295).
5
+ * Registration mirrors server.ts: REAL `authPreHandlerPlugin ->
6
+ * projectListingMeRoute` (auth only, NO tenant guard), mocked `lib/prisma.js`.
7
+ *
8
+ * Proves the security properties the owner link exists for:
9
+ * - two users sharing a `founderEmail` value never see each other's rows;
10
+ * - a row whose owner link was never set is invisible to the person whose
11
+ * address it carries — the read never falls back to an email comparison;
12
+ * - the `where` clause carries `ownerUserId` and nothing resembling an email
13
+ * (a clause carrying one is treated as a contract violation and returns
14
+ * nothing, the `flow-checkouts` test's own discipline);
15
+ * - a signed-in caller who owns nothing gets `200 []`, never a 403;
16
+ * - `PROJECT_LISTING_ROLES` is untouched and this route never consults it.
17
+ */
18
+ import Fastify from "fastify";
19
+ import type { FastifyInstance } from "fastify";
20
+ import { projectListingMeResponseSchema } from "@working-theory/validation";
21
+
22
+ // `services/project-listings.ts` imports `routeLead` from `lead-routing.js` at
23
+ // module scope, which transitively imports `@working-theory/email` and
24
+ // `@working-theory/license` — mocked here purely so that import chain resolves,
25
+ // mirroring `tenant-isolation.test.ts`'s discipline.
26
+ jest.mock("@working-theory/email", () => ({
27
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
28
+ }));
29
+ jest.mock("@working-theory/license", () => ({
30
+ checkLicense: jest.fn().mockResolvedValue({
31
+ tier: "free",
32
+ plan: null,
33
+ features: [],
34
+ expiresAt: null,
35
+ revoked: false,
36
+ }),
37
+ }));
38
+
39
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
40
+ jest.mock("../../../lib/prisma.js", () => ({
41
+ prisma: {
42
+ session: { findUnique: jest.fn() },
43
+ apiToken: {
44
+ findUnique: jest.fn(),
45
+ update: jest.fn().mockResolvedValue(undefined),
46
+ },
47
+ user: { findUnique: jest.fn() },
48
+ projectListing: { findMany: jest.fn() },
49
+ waitlistScore: { count: jest.fn() },
50
+ },
51
+ }));
52
+
53
+ import { prisma } from "../../../lib/prisma.js";
54
+ import { authPreHandlerPlugin } from "../../../middleware/auth.js";
55
+ import { PROJECT_LISTING_ROLES } from "../../../services/project-listings.js";
56
+ import { projectListingMeRoute } from "../index.js";
57
+
58
+ const FRONTEND = "https://app.example.com";
59
+ const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
60
+
61
+ const SITE_A = "wksp_00000000-0000-7000-8000-000000000001";
62
+ const SITE_B = "wksp_00000000-0000-7000-8000-000000000002";
63
+
64
+ /**
65
+ * `ada` and `bruno` are TWO DIFFERENT PEOPLE who typed the SAME address into
66
+ * the anonymous application form. That is the whole point: `founderEmail` is
67
+ * not an identity, and nothing may derive ownership from it.
68
+ */
69
+ const SHARED_EMAIL = "founder@example.com";
70
+
71
+ const USERS: Record<string, { id: string; email: string; emailVerified: boolean }> = {
72
+ ada: { id: "ada", email: SHARED_EMAIL, emailVerified: true },
73
+ bruno: { id: "bruno", email: SHARED_EMAIL, emailVerified: true },
74
+ // Signed in, verified, owns nothing at all.
75
+ cleo: { id: "cleo", email: "cleo@example.com", emailVerified: true },
76
+ // Signed in with an address that was never verified, and whose own listing
77
+ // WAS linked earlier. See the test that covers this case.
78
+ dane: { id: "dane", email: "dane@example.com", emailVerified: false },
79
+ };
80
+
81
+ const SESSION_ROWS: Record<string, Record<string, unknown>> = {
82
+ cookie_ada: { id: "sess_ada", userId: "ada", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
83
+ cookie_bruno: { id: "sess_bruno", userId: "bruno", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
84
+ cookie_cleo: { id: "sess_cleo", userId: "cleo", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
85
+ cookie_dane: { id: "sess_dane", userId: "dane", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
86
+ };
87
+
88
+ type ListingFixture = {
89
+ id: string;
90
+ siteId: string;
91
+ founderEmail: string;
92
+ ownerUserId: string | null;
93
+ experimentKey: string | null;
94
+ createdAt: Date;
95
+ };
96
+
97
+ /** A complete stored row at `PROJECT_LISTING_SELECT`'s shape. */
98
+ function row(fixture: ListingFixture) {
99
+ return {
100
+ id: fixture.id,
101
+ siteId: fixture.siteId,
102
+ leadId: `lead_${fixture.id}`,
103
+ slug: fixture.id.replace("prjl_", ""),
104
+ name: "Atlas",
105
+ oneLiner: "A one-liner.",
106
+ problem: "A problem.",
107
+ url: "https://atlas.example.com",
108
+ product: "live-some-users",
109
+ commitment: "full-time",
110
+ teamComposition: "just-me",
111
+ faqs: null,
112
+ anythingElse: null,
113
+ address: "1 Main St",
114
+ addressLine1: null,
115
+ addressLine2: null,
116
+ addressCity: null,
117
+ addressRegion: null,
118
+ addressPostalCode: null,
119
+ addressCountry: null,
120
+ questions: null,
121
+ siteAnswers: null,
122
+ windowDays: 30,
123
+ founderName: "A Founder",
124
+ founderEmail: fixture.founderEmail,
125
+ founderCompany: null,
126
+ headline: "Atlas",
127
+ logoMarkUrl: null,
128
+ logoWordmarkUrl: null,
129
+ featured: false,
130
+ status: "requested",
131
+ consentAt: fixture.createdAt,
132
+ decidedAt: null,
133
+ decidedBy: "operator@example.com",
134
+ issueUrl: "https://github.com/example/repo/issues/1",
135
+ experimentKey: fixture.experimentKey,
136
+ windowEndsAt: null,
137
+ createdAt: fixture.createdAt,
138
+ updatedAt: fixture.createdAt,
139
+ };
140
+ }
141
+
142
+ const LISTINGS: ListingFixture[] = [
143
+ // ada's own, linked to ada.
144
+ {
145
+ id: "prjl_ada_one",
146
+ siteId: SITE_A,
147
+ founderEmail: SHARED_EMAIL,
148
+ ownerUserId: "ada",
149
+ experimentKey: "atlas-waitlist",
150
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
151
+ },
152
+ // ada again, NEWER, and in a SECOND workspace of ada's — proves the per-site
153
+ // waitlistCount resolution and the newest-first ordering.
154
+ {
155
+ id: "prjl_ada_two",
156
+ siteId: SITE_B,
157
+ founderEmail: SHARED_EMAIL,
158
+ ownerUserId: "ada",
159
+ experimentKey: "atlas-two-waitlist",
160
+ createdAt: new Date("2026-09-05T00:00:00.000Z"),
161
+ },
162
+ // bruno's own — SAME founderEmail as ada's rows, different person.
163
+ {
164
+ id: "prjl_bruno_one",
165
+ siteId: SITE_A,
166
+ founderEmail: SHARED_EMAIL,
167
+ ownerUserId: "bruno",
168
+ experimentKey: null,
169
+ createdAt: new Date("2026-09-03T00:00:00.000Z"),
170
+ },
171
+ // Carries the shared address and was NEVER linked. Invisible to everyone.
172
+ {
173
+ id: "prjl_unlinked",
174
+ siteId: SITE_A,
175
+ founderEmail: SHARED_EMAIL,
176
+ ownerUserId: null,
177
+ experimentKey: null,
178
+ createdAt: new Date("2026-09-04T00:00:00.000Z"),
179
+ },
180
+ // dane's own, linked while dane's address was still verified.
181
+ {
182
+ id: "prjl_dane_one",
183
+ siteId: SITE_A,
184
+ founderEmail: "dane@example.com",
185
+ ownerUserId: "dane",
186
+ experimentKey: null,
187
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
188
+ },
189
+ ];
190
+
191
+ type ListingWhere = Record<string, unknown> & { ownerUserId?: unknown };
192
+
193
+ const WAITLIST_COUNTS: Record<string, Record<string, number>> = {
194
+ [SITE_A]: { "atlas-waitlist": 7 },
195
+ [SITE_B]: { "atlas-two-waitlist": 3 },
196
+ };
197
+
198
+ function installFixtureMocks(): void {
199
+ (prisma.session.findUnique as jest.Mock).mockImplementation(
200
+ ({ where }: { where: { token?: string } }) =>
201
+ Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
202
+ );
203
+ (prisma.apiToken.findUnique as jest.Mock).mockResolvedValue(null);
204
+ (prisma.user.findUnique as jest.Mock).mockImplementation(({ where }: { where: { id: string } }) =>
205
+ Promise.resolve(USERS[where.id] ?? null),
206
+ );
207
+
208
+ /**
209
+ * The owner link is the ONLY join the service may use, and this mock is
210
+ * written to FAIL on a regression rather than to absorb one.
211
+ *
212
+ * Two earlier, weaker forms were rejected during review, both of which let a
213
+ * regression keep the security tests green:
214
+ *
215
+ * - `expect(where).not.toHaveProperty("founderEmail")` inspects TOP-LEVEL
216
+ * keys only, so an `OR`-wrapped email fallback
217
+ * (`{ OR: [{ ownerUserId }, { founderEmail }] }`) sails straight past it —
218
+ * and a mock that then filtered on `where.ownerUserId` returns `[]` for
219
+ * that shape, which looks like "nothing leaked".
220
+ * - Filtering on `where.ownerUserId` when it is `undefined` also returns
221
+ * `[]`, while Prisma compiles an `undefined` filter to no filter at all
222
+ * (`WHERE 1=1`) and the REAL read would be unscoped.
223
+ *
224
+ * So: any key other than `ownerUserId` THROWS, an `ownerUserId` that is not a
225
+ * string THROWS, and the filter is exact equality on it. A widened read now
226
+ * fails as a thrown error rather than as a quietly empty result.
227
+ */
228
+ (prisma.projectListing.findMany as jest.Mock).mockImplementation(
229
+ ({ where }: { where: ListingWhere }) => {
230
+ const keys = Object.keys(where);
231
+ if (keys.length !== 1 || keys[0] !== "ownerUserId") {
232
+ throw new Error(
233
+ `the /me read must scope on ownerUserId and nothing else; got where keys [${keys.join(", ")}]`,
234
+ );
235
+ }
236
+ if (typeof where.ownerUserId !== "string") {
237
+ throw new Error(
238
+ "ownerUserId must be a concrete string; Prisma compiles an undefined filter to no filter, leaving the read unscoped",
239
+ );
240
+ }
241
+ const ownerUserId = where.ownerUserId;
242
+ return Promise.resolve(
243
+ LISTINGS.filter((l) => l.ownerUserId === ownerUserId)
244
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
245
+ .map(row),
246
+ );
247
+ },
248
+ );
249
+
250
+ (prisma.waitlistScore.count as jest.Mock).mockImplementation(
251
+ ({ where }: { where: { siteId: string; experimentKey: string } }) =>
252
+ Promise.resolve(WAITLIST_COUNTS[where.siteId]?.[where.experimentKey] ?? 0),
253
+ );
254
+ }
255
+
256
+ async function buildApp(): Promise<FastifyInstance> {
257
+ const app = Fastify();
258
+ await app.register(async (authScope) => {
259
+ await authScope.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
260
+ await authScope.register(projectListingMeRoute);
261
+ });
262
+ await app.ready();
263
+ return app;
264
+ }
265
+
266
+ const cookie = (token: string) => ({ cookie: `better-auth.session_token=${token}` });
267
+ const URL_ME = "/v1/project-listings/me";
268
+
269
+ const listingWhere = (call = 0) =>
270
+ (prisma.projectListing.findMany as jest.Mock).mock.calls[call][0].where as ListingWhere;
271
+
272
+ let app: FastifyInstance;
273
+
274
+ beforeEach(async () => {
275
+ jest.clearAllMocks();
276
+ installFixtureMocks();
277
+ app = await buildApp();
278
+ });
279
+
280
+ afterEach(async () => {
281
+ await app.close();
282
+ });
283
+
284
+ describe("GET /v1/project-listings/me — auth", () => {
285
+ it("no session cookie and no bearer → the preHandler's existing 401; no listing read", async () => {
286
+ const res = await app.inject({ method: "GET", url: URL_ME });
287
+
288
+ expect(res.statusCode).toBe(401);
289
+ expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it("an expired / unknown cookie → 401", async () => {
293
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_nobody") });
294
+
295
+ expect(res.statusCode).toBe(401);
296
+ expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
297
+ });
298
+ });
299
+
300
+ describe("GET /v1/project-listings/me — the owner link is the only join", () => {
301
+ /** SECURITY PROPERTY: two users sharing a founderEmail value. */
302
+ it("two users sharing a founderEmail never see each other's listings", async () => {
303
+ const adaRes = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
304
+ const brunoRes = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_bruno") });
305
+
306
+ expect(adaRes.statusCode).toBe(200);
307
+ expect(brunoRes.statusCode).toBe(200);
308
+
309
+ const adaIds = (adaRes.json().data as Array<{ id: string }>).map((r) => r.id);
310
+ const brunoIds = (brunoRes.json().data as Array<{ id: string }>).map((r) => r.id);
311
+
312
+ // ada sees her two rows, newest first, and NOT bruno's.
313
+ expect(adaIds).toEqual(["prjl_ada_two", "prjl_ada_one"]);
314
+ // bruno sees his one row, and NOT ada's — though every row in play carries
315
+ // the identical founderEmail.
316
+ expect(brunoIds).toEqual(["prjl_bruno_one"]);
317
+
318
+ expect(adaIds).not.toContain("prjl_bruno_one");
319
+ expect(brunoIds).not.toContain("prjl_ada_one");
320
+ expect(brunoIds).not.toContain("prjl_ada_two");
321
+ // And neither of them reaches the un-linked row carrying the same address.
322
+ expect([...adaIds, ...brunoIds]).not.toContain("prjl_unlinked");
323
+ });
324
+
325
+ /** SECURITY PROPERTY: no read-time email fallback. */
326
+ it("a listing carrying the caller's own address but NO owner link is never returned", async () => {
327
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
328
+
329
+ const ids = (res.json().data as Array<{ id: string }>).map((r) => r.id);
330
+ expect(ids).not.toContain("prjl_unlinked");
331
+ });
332
+
333
+ it("the where clause is the owner link and carries nothing email-shaped", async () => {
334
+ await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
335
+
336
+ expect(listingWhere()).toEqual({ ownerUserId: "ada" });
337
+ });
338
+
339
+ it("a query parameter is never read — ?email= and ?ownerUserId= change nothing", async () => {
340
+ const res = await app.inject({
341
+ method: "GET",
342
+ url: `${URL_ME}?email=${encodeURIComponent(SHARED_EMAIL)}&ownerUserId=bruno`,
343
+ headers: cookie("cookie_ada"),
344
+ });
345
+
346
+ expect(res.statusCode).toBe(200);
347
+ // Still ada's own rows, scoped by the SESSION's user id.
348
+ expect(listingWhere()).toEqual({ ownerUserId: "ada" });
349
+ expect((res.json().data as Array<{ id: string }>).map((r) => r.id)).toEqual([
350
+ "prjl_ada_two",
351
+ "prjl_ada_one",
352
+ ]);
353
+ });
354
+ });
355
+
356
+ describe("GET /v1/project-listings/me — the empty case is not a refusal", () => {
357
+ /** SECURITY / UX PROPERTY: owning nothing is an empty list, never a 403. */
358
+ it("a signed-in caller who owns no listing gets 200 with an empty list, not a 403", async () => {
359
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_cleo") });
360
+
361
+ expect(res.statusCode).toBe(200);
362
+ expect(res.json()).toEqual({ ok: true, data: [] });
363
+ // Specifically NOT the tenant routes' 403 — cleo is legitimately signed in.
364
+ expect(res.statusCode).not.toBe(403);
365
+ });
366
+
367
+ it("no waitlist count is read when the caller owns nothing", async () => {
368
+ await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_cleo") });
369
+
370
+ expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
371
+ });
372
+ });
373
+
374
+ describe("GET /v1/project-listings/me — the response shape", () => {
375
+ it("every row parses under projectListingMeResponseSchema", async () => {
376
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
377
+
378
+ expect(res.statusCode).toBe(200);
379
+ const parsed = projectListingMeResponseSchema.safeParse(res.json().data);
380
+ expect(parsed.success).toBe(true);
381
+ });
382
+
383
+ it("the five internal references never reach the founder", async () => {
384
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
385
+
386
+ for (const listing of res.json().data as Array<Record<string, unknown>>) {
387
+ expect(listing).not.toHaveProperty("siteId");
388
+ expect(listing).not.toHaveProperty("leadId");
389
+ expect(listing).not.toHaveProperty("decidedBy");
390
+ expect(listing).not.toHaveProperty("issueUrl");
391
+ expect(listing).not.toHaveProperty("experimentKey");
392
+ }
393
+ // No workspace id reaches the wire anywhere in the body.
394
+ expect(res.body).not.toContain(SITE_A);
395
+ expect(res.body).not.toContain(SITE_B);
396
+ });
397
+
398
+ it("waitlistCount is resolved per site, not all under one site id", async () => {
399
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
400
+
401
+ const byId = Object.fromEntries(
402
+ (res.json().data as Array<{ id: string; waitlistCount: number }>).map((r) => [
403
+ r.id,
404
+ r.waitlistCount,
405
+ ]),
406
+ );
407
+ // prjl_ada_one's experiment lives at SITE_A (7); prjl_ada_two's at SITE_B (3).
408
+ // One shared site id would have reported 0 for one of them.
409
+ expect(byId["prjl_ada_one"]).toBe(7);
410
+ expect(byId["prjl_ada_two"]).toBe(3);
411
+ expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
412
+ where: { siteId: SITE_A, experimentKey: "atlas-waitlist" },
413
+ });
414
+ expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
415
+ where: { siteId: SITE_B, experimentKey: "atlas-two-waitlist" },
416
+ });
417
+ });
418
+
419
+ it("the founder's own email is echoed back to the founder who wrote it", async () => {
420
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_bruno") });
421
+
422
+ expect((res.json().data as Array<{ founderEmail: string }>)[0]?.founderEmail).toBe(
423
+ SHARED_EMAIL,
424
+ );
425
+ });
426
+ });
427
+
428
+ describe("GET /v1/project-listings/me — the read-time verification posture", () => {
429
+ /**
430
+ * A caller whose address is no longer verified still reads the rows their
431
+ * link already covers. This is deliberate, and it is the one place this route
432
+ * diverges from `/v1/flow-checkouts/me`, which re-checks `emailVerified`.
433
+ *
434
+ * The re-check is load-bearing on `/v1/waitlist-signups/me`, where the match
435
+ * IS an email, and it is defence in depth on `/v1/flow-checkouts/me`. Here it
436
+ * would be neither: the link is the proof, it was only ever issuable under
437
+ * verification, and refusing on a later unverified address would hide rows
438
+ * the caller already proved they own (changing your email address is not a
439
+ * reason to lose your own submissions). An unverified address still cannot
440
+ * CREATE a link — `listing-promotion.ts` refuses before any listing read.
441
+ */
442
+ it("a caller whose address is no longer verified still reads the rows their link already covers", async () => {
443
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_dane") });
444
+
445
+ expect(res.statusCode).toBe(200);
446
+ expect((res.json().data as Array<{ id: string }>).map((r) => r.id)).toEqual([
447
+ "prjl_dane_one",
448
+ ]);
449
+ expect(listingWhere()).toEqual({ ownerUserId: "dane" });
450
+ });
451
+ });
452
+
453
+ describe("the existing operator role floor is untouched", () => {
454
+ it("PROJECT_LISTING_ROLES is still exactly owner + admin", () => {
455
+ expect([...PROJECT_LISTING_ROLES].sort()).toEqual(["admin", "owner"]);
456
+ expect(PROJECT_LISTING_ROLES.size).toBe(2);
457
+ });
458
+
459
+ it("the /me route never consults the tenant context or the role floor", async () => {
460
+ // The app this suite builds registers NO tenant preHandler at all, so
461
+ // `request.tenant` is undefined for every request above. That every test in
462
+ // this file answers 200 is the proof: a route that consulted the floor
463
+ // would have answered 401/403 instead.
464
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
465
+
466
+ expect(res.statusCode).toBe(200);
467
+ });
468
+ });