create-ailk 0.11.4 → 0.12.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 (81) hide show
  1. package/component-catalog.json +36 -3
  2. package/component-catalog.md +10 -7
  3. package/dist/add-module.js +13 -0
  4. package/dist/module-architecture.d.ts +8 -0
  5. package/dist/module-architecture.js +33 -0
  6. package/dist/paid-sections.d.ts +23 -0
  7. package/dist/paid-sections.js +119 -0
  8. package/dist/programmatic.js +8 -0
  9. package/package.json +3 -3
  10. package/templates/.env.example +69 -3
  11. package/templates/apps/api/.env.example +22 -0
  12. package/templates/apps/api/CLAUDE.md +19 -7
  13. package/templates/apps/api/package.json +1 -0
  14. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +104 -0
  15. package/templates/apps/api/src/__tests__/root-modules.test.ts +175 -0
  16. package/templates/apps/api/src/bin/spokes.ts +137 -0
  17. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
  18. package/templates/apps/api/src/config/index.ts +97 -0
  19. package/templates/apps/api/src/config/modules.ts +38 -0
  20. package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
  21. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
  22. package/templates/apps/api/src/lib/spoke-store.ts +131 -0
  23. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
  24. package/templates/apps/api/src/routes/leads/index.ts +33 -3
  25. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
  26. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
  27. package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
  28. package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
  29. package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
  30. package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
  31. package/templates/apps/api/src/server.ts +56 -1
  32. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
  33. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +19 -0
  34. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
  35. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
  36. package/templates/apps/api/src/services/default-tenant.ts +51 -28
  37. package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
  38. package/templates/apps/api/src/services/lead-routing.ts +13 -1
  39. package/templates/apps/api/src/services/project-listings.ts +209 -18
  40. package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
  41. package/templates/apps/api/src/services/revalidation.ts +8 -1
  42. package/templates/apps/api/src/services/slack-notify.ts +54 -3
  43. package/templates/apps/web/.env.example +14 -0
  44. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
  45. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
  46. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
  47. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
  48. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
  49. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
  50. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
  51. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
  52. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
  53. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
  54. package/templates/apps/web/app/[locale]/layout.tsx +4 -0
  55. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
  56. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
  57. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
  58. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
  59. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
  60. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
  61. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
  62. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
  63. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
  64. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
  65. package/templates/apps/web/jest.config.cjs +1 -1
  66. package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
  67. package/templates/apps/web/lib/listings.tsx +472 -0
  68. package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
  69. package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
  70. package/templates/apps/web/messages/en.json +49 -15
  71. package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
  72. package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
  73. package/templates/database/CHANGELOG.md +31 -0
  74. package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
  75. package/templates/database/auth/schema.prisma +31 -0
  76. package/templates/database/config/schema.prisma +26 -0
  77. package/templates/database/inbox/schema.prisma +21 -0
  78. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
  79. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
  80. package/templates/database/package.json +1 -1
  81. package/versions.json +34 -34
@@ -236,6 +236,26 @@ async function stampOutcome(
236
236
  }
237
237
  }
238
238
 
239
+ // ─── #7519 D6 — the sender address ──────────────────────────────────────────────
240
+
241
+ /**
242
+ * The From address every email this service sends carries:
243
+ * `AILK_DELIVERABLE_EMAIL_FROM`, else `AILK_LEAD_NOTIFICATION_FROM`,
244
+ * `undefined` when neither is set — an empty value counts as unset (#7519 D6). One resolver for the send paths
245
+ * (`sendAndStamp`, `sendCompletionEmail`) and the Admin's read
246
+ * (`GET /v1/workspaces/:workspaceId/general` `emailFrom`), so the address
247
+ * the Admin shows can never differ from the address the engine sends from.
248
+ */
249
+ export function deliverableSenderAddress(): string | undefined {
250
+ // `||`, not `??`: an env var set to "" names no sender, so it falls
251
+ // through to the next, and "" never reaches a From header or the Admin.
252
+ return (
253
+ process.env.AILK_DELIVERABLE_EMAIL_FROM ||
254
+ process.env.AILK_LEAD_NOTIFICATION_FROM ||
255
+ undefined
256
+ );
257
+ }
258
+
239
259
  // ─── #3998 D5 — the shared post-mint tail ────────────────────────────────────────
240
260
 
241
261
  type SendAndStampInput = {
@@ -261,9 +281,7 @@ async function sendAndStamp(input: SendAndStampInput): Promise<PostMintOutcome>
261
281
  input;
262
282
  const logCtx = { siteId, flowSlug, sessionId };
263
283
 
264
- const fromAddress =
265
- process.env.AILK_DELIVERABLE_EMAIL_FROM ??
266
- process.env.AILK_LEAD_NOTIFICATION_FROM;
284
+ const fromAddress = deliverableSenderAddress();
267
285
 
268
286
  if (!fromAddress) {
269
287
  await stampOutcome(grantId, "sender_unconfigured", new Date());
@@ -297,6 +315,74 @@ async function sendAndStamp(input: SendAndStampInput): Promise<PostMintOutcome>
297
315
  return outcome;
298
316
  }
299
317
 
318
+ // ─── #7519 D5 — the completion email (a flow with no deliverable) ──────────────
319
+
320
+ type CompletionEmailCopy = NonNullable<FlowConfig["completionEmail"]>;
321
+
322
+ export type SendCompletionEmailInput = {
323
+ siteId: string;
324
+ flowSlug: string;
325
+ sessionId: string;
326
+ /** The lead's address — `undefined` when the flow asked for none. */
327
+ email: string | undefined;
328
+ locale: string;
329
+ defaultLocale: string;
330
+ copy: CompletionEmailCopy;
331
+ };
332
+
333
+ type CompletionOutcome =
334
+ | "sent"
335
+ | "send_failed"
336
+ | "sender_unconfigured"
337
+ | "no_recipient";
338
+
339
+ const COMPLETION_OUTCOME_MESSAGE: Record<CompletionOutcome, string> = {
340
+ sent: "[deliverable-fulfillment] completion email sent",
341
+ send_failed: "[deliverable-fulfillment] completion email send failed",
342
+ sender_unconfigured:
343
+ "[deliverable-fulfillment] no from-address configured — completion email skipped",
344
+ no_recipient:
345
+ "[deliverable-fulfillment] no email answer — completion email skipped",
346
+ };
347
+
348
+ /**
349
+ * Sends a flow's `completionEmail` to the lead (#7519 D5) — the email a
350
+ * newsletter, contact, waitlist or diagnostic sends on completion, where
351
+ * there is no asset to deliver. The same sender, copy resolution and email
352
+ * function as the deliverable path, with no download link: it mints no
353
+ * grant and stamps nothing. Its outcome is logged with the
354
+ * `{ siteId, flowSlug, sessionId }` context the deliverable outcomes use.
355
+ * Never throws on its own business-logic misses; the caller's try/catch is
356
+ * the residual net for a genuine error.
357
+ */
358
+ export async function sendCompletionEmail(
359
+ input: SendCompletionEmailInput,
360
+ ): Promise<CompletionOutcome> {
361
+ const { siteId, flowSlug, sessionId, email, locale, defaultLocale, copy } =
362
+ input;
363
+ const outcome = await sendCompletion();
364
+ logger.info(
365
+ { siteId, flowSlug, sessionId, outcome },
366
+ COMPLETION_OUTCOME_MESSAGE[outcome],
367
+ );
368
+ return outcome;
369
+
370
+ async function sendCompletion(): Promise<CompletionOutcome> {
371
+ if (!email) return "no_recipient";
372
+ const fromAddress = deliverableSenderAddress();
373
+ if (!fromAddress) return "sender_unconfigured";
374
+ const cta = copy.cta ? resolveCopy(copy.cta, locale, defaultLocale) : undefined;
375
+ const sendResult = await sendDeliverableEmail({
376
+ to: email,
377
+ fromAddress,
378
+ subject: resolveCopy(copy.subject, locale, defaultLocale),
379
+ body: resolveCopy(copy.body, locale, defaultLocale),
380
+ ...(cta !== undefined && { cta }),
381
+ });
382
+ return sendResult.ok ? "sent" : "send_failed";
383
+ }
384
+ }
385
+
300
386
  // ─── fulfillDeliverable ─────────────────────────────────────────────────────────
301
387
 
302
388
  export async function fulfillDeliverable(
@@ -69,6 +69,11 @@ export type SourceMeta = {
69
69
  ip: string;
70
70
  userAgent: string | undefined;
71
71
  receivedAt: string; // ISO
72
+ /**
73
+ * The lead came from a registered spoke of a hub (#7532 D4). Absent reads as
74
+ * `false`. Never persisted — `captureRecordFor` copies fields by name.
75
+ */
76
+ viaSpoke?: boolean;
72
77
  };
73
78
 
74
79
  /** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
@@ -286,6 +291,7 @@ async function sendEmail(
286
291
  input: RouteLeadInput,
287
292
  id: string,
288
293
  persisted: Persisted,
294
+ viaSpoke = false,
289
295
  ): Promise<EmailResult> {
290
296
  // Request-and-confirm SUPERSEDES this notification for a callback (#5970
291
297
  // D6). AC-1 requires the tenant to receive exactly one email, and the
@@ -309,8 +315,14 @@ async function sendEmail(
309
315
  // there is no row, so suppressing here would have produced ZERO operator
310
316
  // emails for every callback captured during the outage, on the one surface
311
317
  // whose whole purpose is that an outage must not lose a lead.
318
+ //
319
+ // `!viaSpoke` (#7532 D4, SEC-7532-2): request-and-confirm mints a Payment
320
+ // Link on the deployment's ONE Stripe account and mails the deployment's ONE
321
+ // address, so a spoke's callback never takes it — the route does not send
322
+ // the confirm link for a spoke, and this keeps the generic notification.
312
323
  if (
313
324
  input.kind === "callback" &&
325
+ !viaSpoke &&
314
326
  persisted === "db" &&
315
327
  requestConfirmSupersedesNotification()
316
328
  ) {
@@ -600,7 +612,7 @@ export async function routeLead(
600
612
  if (skipEmail) {
601
613
  emailResult = { skipped: true, reason: skipReason ?? "skipped" };
602
614
  } else {
603
- emailResult = await sendEmail(input, id!, persisted!);
615
+ emailResult = await sendEmail(input, id!, persisted!, sourceMeta.viaSpoke);
604
616
  }
605
617
 
606
618
  return { ok: true, id: id!, persisted, email: emailResult };
@@ -56,12 +56,22 @@ import {
56
56
  composeAddressLine,
57
57
  composeFounderName,
58
58
  flowAddressValueSchema,
59
+ flowConfigSchema,
60
+ listicleSiteAnswerQuestions,
61
+ listingProfile,
59
62
  publicSiteAnswers,
63
+ withListicleQuestions,
64
+ LISTING_DIRECTORY_MAX_ENTRIES,
65
+ LISTING_ENTRY_TYPE_DEFAULT,
60
66
  PROJECT_LISTING_CONCURRENT_LIMIT,
61
67
  PROJECT_LISTING_NON_TERMINAL_STATUSES,
62
68
  } from "@working-theory/validation";
63
69
  import type {
64
70
  FlowAddressValue,
71
+ FlowConfig,
72
+ ListingDirectoryResponse,
73
+ ListingEntryPublic,
74
+ ListingSiteAnswersConfig,
65
75
  ProjectListing,
66
76
  ProjectListingDraftPatchInput,
67
77
  ProjectListingFaqs,
@@ -424,7 +434,7 @@ function toResponseRow(
424
434
  */
425
435
  async function attachWaitlistCounts(
426
436
  siteId: string,
427
- rows: readonly ProjectListingRow[],
437
+ rows: readonly Pick<ProjectListingRow, "experimentKey">[],
428
438
  ): Promise<Map<string, number>> {
429
439
  const experimentKeys = [
430
440
  ...new Set(rows.map((r) => r.experimentKey).filter((v): v is string => v != null)),
@@ -456,7 +466,10 @@ async function attachWaitlistCounts(
456
466
  return counts;
457
467
  }
458
468
 
459
- function waitlistCountFor(row: ProjectListingRow, counts: Map<string, number>): number {
469
+ function waitlistCountFor(
470
+ row: Pick<ProjectListingRow, "experimentKey">,
471
+ counts: Map<string, number>,
472
+ ): number {
460
473
  return row.experimentKey ? (counts.get(row.experimentKey) ?? 0) : 0;
461
474
  }
462
475
 
@@ -1133,6 +1146,43 @@ function draftUpdateData(
1133
1146
  };
1134
1147
  }
1135
1148
 
1149
+ /**
1150
+ * The site-answer declarations a CAPTURED draft is checked against (#7521 D2):
1151
+ * the site's own, plus the eligible Application fields of the LIVE listicle
1152
+ * that captured it (`listicleSiteAnswerQuestions`). A draft listicle accepts
1153
+ * no answers (SEC-7521-B1).
1154
+ *
1155
+ * Fails CLOSED: a missing listicle or a config that no longer parses logs at
1156
+ * warn and answers the site's declarations alone, so an id nobody declared
1157
+ * is still a 400.
1158
+ */
1159
+ async function capturedDraftSiteAnswers(
1160
+ siteId: string,
1161
+ listingId: string,
1162
+ capturedByFlowId: string,
1163
+ siteDeclared: ListingSiteAnswersConfig,
1164
+ ): Promise<ListingSiteAnswersConfig> {
1165
+ const listicle = await prisma.flowDefinition.findFirst({
1166
+ where: {
1167
+ id: capturedByFlowId,
1168
+ siteId,
1169
+ workspaceId: siteId,
1170
+ kind: "listicle",
1171
+ status: "live",
1172
+ },
1173
+ select: { config: true },
1174
+ });
1175
+ const config = listicle ? flowConfigSchema.safeParse(listicle.config) : null;
1176
+ if (!config?.success) {
1177
+ logger.warn(
1178
+ { siteId, listingId },
1179
+ "[project-listings] a captured draft's listicle is not live or its config does not parse; accepting the site's own answers only",
1180
+ );
1181
+ return siteDeclared;
1182
+ }
1183
+ return withListicleQuestions(siteDeclared, listicleSiteAnswerQuestions(config.data));
1184
+ }
1185
+
1136
1186
  /**
1137
1187
  * Merge a partial application into a draft and hand back the merged row.
1138
1188
  *
@@ -1158,17 +1208,29 @@ export async function patchProjectListingDraft(
1158
1208
  // DIFFERENT screen can still lose its answers to this one, exactly as it
1159
1209
  // can already lose a column. The store is one row's worth of one
1160
1210
  // founder's own form, not a shared document.
1161
- select: { siteId: true, siteAnswers: true },
1211
+ //
1212
+ // #7521 D2 — `capturedByFlowId` rides it too: a draft a listicle captured
1213
+ // may answer that listicle's own Application fields.
1214
+ select: { siteId: true, siteAnswers: true, capturedByFlowId: true },
1162
1215
  });
1163
1216
  if (!owner) {
1164
1217
  return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
1165
1218
  }
1166
1219
 
1167
1220
  const listingConfig = await listingConfigFor(owner.siteId);
1221
+ const siteAnswers =
1222
+ owner.capturedByFlowId === null
1223
+ ? listingConfig.siteAnswers
1224
+ : await capturedDraftSiteAnswers(
1225
+ owner.siteId,
1226
+ id,
1227
+ owner.capturedByFlowId,
1228
+ listingConfig.siteAnswers,
1229
+ );
1168
1230
  const parsed = buildProjectListingDraftPatchSchema(
1169
1231
  listingConfig.wordCaps,
1170
1232
  listingConfig.collectedFields,
1171
- listingConfig.siteAnswers,
1233
+ siteAnswers,
1172
1234
  ).safeParse(body);
1173
1235
  if (!parsed.success) {
1174
1236
  return {
@@ -1825,6 +1887,30 @@ export async function patchListingCopy(
1825
1887
 
1826
1888
  // ─── Public read (anonymous) ────────────────────────────────────────────────
1827
1889
 
1890
+ /**
1891
+ * The public order (#5033): the THREE newest by `decidedAt` first, then the
1892
+ * rest by `waitlistCount` desc, then `decidedAt` desc. One function, so the
1893
+ * project read and the directory read (#7521 D3) cannot order differently.
1894
+ */
1895
+ function orderPublicListings<T extends { row: { id: string; decidedAt: Date | null }; waitlistCount: number }>(
1896
+ withCounts: readonly T[],
1897
+ ): T[] {
1898
+ const decidedAtMs = (row: T["row"]): number => row.decidedAt?.getTime() ?? -Infinity;
1899
+
1900
+ const newestFirst = [...withCounts].sort((a, b) => decidedAtMs(b.row) - decidedAtMs(a.row));
1901
+ const top3 = newestFirst.slice(0, 3);
1902
+ const top3Ids = new Set(top3.map((t) => t.row.id));
1903
+
1904
+ const rest = withCounts
1905
+ .filter((t) => !top3Ids.has(t.row.id))
1906
+ .sort((a, b) => {
1907
+ if (b.waitlistCount !== a.waitlistCount) return b.waitlistCount - a.waitlistCount;
1908
+ return decidedAtMs(b.row) - decidedAtMs(a.row);
1909
+ });
1910
+
1911
+ return [...top3, ...rest];
1912
+ }
1913
+
1828
1914
  /**
1829
1915
  * The page-render read (#4998 AC4, reshaped by #5033): every `active` row for
1830
1916
  * the site, the THREE newest by `decidedAt` first, then the rest by
@@ -1846,21 +1932,8 @@ export async function publicProjectListings(siteId: string): Promise<ProjectList
1846
1932
  const counts = await attachWaitlistCounts(siteId, rows);
1847
1933
  const withCounts = rows.map((row) => ({ row, waitlistCount: waitlistCountFor(row, counts) }));
1848
1934
 
1849
- const decidedAtMs = (row: ProjectListingRow): number => row.decidedAt?.getTime() ?? -Infinity;
1850
-
1851
- const newestFirst = [...withCounts].sort((a, b) => decidedAtMs(b.row) - decidedAtMs(a.row));
1852
- const top3 = newestFirst.slice(0, 3);
1853
- const top3Ids = new Set(top3.map((t) => t.row.id));
1854
-
1855
- const rest = withCounts
1856
- .filter((t) => !top3Ids.has(t.row.id))
1857
- .sort((a, b) => {
1858
- if (b.waitlistCount !== a.waitlistCount) return b.waitlistCount - a.waitlistCount;
1859
- return decidedAtMs(b.row) - decidedAtMs(a.row);
1860
- });
1861
-
1862
1935
  const now = new Date();
1863
- return [...top3, ...rest].map(({ row, waitlistCount }) => {
1936
+ return orderPublicListings(withCounts).map(({ row, waitlistCount }) => {
1864
1937
  // #5407 — `founderPhone` and `emailVerifiedAt` leave here with
1865
1938
  // `founderEmail`. `projectListingPublicSchema` omits all three and
1866
1939
  // `sendValidated` re-parses `.strict()`, so a key that stopped being
@@ -1890,3 +1963,121 @@ export async function publicProjectListings(siteId: string): Promise<ProjectList
1890
1963
  };
1891
1964
  });
1892
1965
  }
1966
+
1967
+ // ─── Directory read (anonymous, #7521) ──────────────────────────────────────
1968
+
1969
+ /** Only what the directory maps — no contact column is ever read here. */
1970
+ const DIRECTORY_LISTING_SELECT = {
1971
+ id: true,
1972
+ slug: true,
1973
+ name: true,
1974
+ headline: true,
1975
+ oneLiner: true,
1976
+ url: true,
1977
+ logoMarkUrl: true,
1978
+ founderCompany: true,
1979
+ founderName: true,
1980
+ siteAnswers: true,
1981
+ featured: true,
1982
+ status: true,
1983
+ decidedAt: true,
1984
+ windowEndsAt: true,
1985
+ experimentKey: true,
1986
+ capturedByFlowId: true,
1987
+ } as const satisfies Prisma.ProjectListingSelect;
1988
+
1989
+ /**
1990
+ * A landing page's directory (#7521 D3): the entry type of the live listicle
1991
+ * that names `landingPage`, and that page's ACCEPTED entries — every `active`
1992
+ * listing (an elapsed one reads `closed`, as on `/public`) captured by a live
1993
+ * listicle naming it, at most the 200 most recently accepted, in the public
1994
+ * order. `null` when no live listicle of this site names the page, which is
1995
+ * almost every page.
1996
+ *
1997
+ * Each entry carries the generic directory shape, never the project-shaped
1998
+ * row: name, summary, links, and a `profile` built at read time from the
1999
+ * capturing listicle's CURRENT Application fields (`listingProfile`, D4).
2000
+ * Contact columns never leave this function.
2001
+ *
2002
+ * Two live listicles naming one page both contribute entries; the
2003
+ * earlier-created one supplies the entry type.
2004
+ */
2005
+ export async function listingDirectoryFor(
2006
+ siteId: string,
2007
+ landingPage: string,
2008
+ locale: string | undefined,
2009
+ ): Promise<ListingDirectoryResponse> {
2010
+ // fence: siteId and workspaceId both — the value `siteIdForPublicKey`
2011
+ // resolved from the public key, never from the request.
2012
+ const listicles = await prisma.flowDefinition.findMany({
2013
+ where: { siteId, workspaceId: siteId, kind: "listicle", status: "live", landingPage },
2014
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }],
2015
+ select: { id: true, config: true },
2016
+ });
2017
+ if (listicles.length === 0) return null;
2018
+
2019
+ const configs = new Map<string, FlowConfig | null>();
2020
+ for (const listicle of listicles) {
2021
+ const parsed = flowConfigSchema.safeParse(listicle.config);
2022
+ if (!parsed.success) {
2023
+ logger.warn(
2024
+ { siteId, flowId: listicle.id },
2025
+ "[project-listings] a live listicle's config does not parse; its entries publish with no profile",
2026
+ );
2027
+ }
2028
+ configs.set(listicle.id, parsed.success ? parsed.data : null);
2029
+ }
2030
+ const entryType =
2031
+ configs.get(listicles[0]!.id)?.listing?.entryType ?? LISTING_ENTRY_TYPE_DEFAULT;
2032
+
2033
+ const listingConfig = await listingConfigFor(siteId);
2034
+ const rows = await prisma.projectListing.findMany({
2035
+ where: {
2036
+ siteId,
2037
+ workspaceId: siteId,
2038
+ status: "active",
2039
+ capturedByFlowId: { in: listicles.map((l) => l.id) },
2040
+ slug: { not: null },
2041
+ },
2042
+ // NULLS LAST: Postgres sorts NULL first on DESC, so an undecided row
2043
+ // would otherwise take a slot in the window ahead of an accepted one.
2044
+ orderBy: [{ decidedAt: { sort: "desc", nulls: "last" } }, { id: "asc" }],
2045
+ take: LISTING_DIRECTORY_MAX_ENTRIES,
2046
+ select: DIRECTORY_LISTING_SELECT,
2047
+ });
2048
+ const counts = await attachWaitlistCounts(siteId, rows);
2049
+ const withCounts = rows.map((row) => ({ row, waitlistCount: waitlistCountFor(row, counts) }));
2050
+
2051
+ const now = new Date();
2052
+ const entries = orderPublicListings(withCounts).map(
2053
+ ({ row, waitlistCount }): ListingEntryPublic => {
2054
+ const config = row.capturedByFlowId ? configs.get(row.capturedByFlowId) : null;
2055
+ return {
2056
+ id: row.id,
2057
+ slug: row.slug!,
2058
+ name: row.headline ?? row.name ?? row.founderCompany ?? row.founderName,
2059
+ summary: row.oneLiner,
2060
+ url: row.url,
2061
+ logoMarkUrl: row.logoMarkUrl,
2062
+ featured: listingConfig.featuredEnabled && row.featured,
2063
+ status:
2064
+ computeListingStatus(row.status, row.windowEndsAt, now, row.featured) === "closed"
2065
+ ? "closed"
2066
+ : "active",
2067
+ windowEndsAt: row.windowEndsAt ? row.windowEndsAt.toISOString() : null,
2068
+ decidedAt: row.decidedAt ? row.decidedAt.toISOString() : null,
2069
+ waitlistCount,
2070
+ profile: config
2071
+ ? listingProfile(
2072
+ config,
2073
+ siteAnswersFromRow(row.siteAnswers),
2074
+ locale ?? config.defaultLocale,
2075
+ listingConfig.siteAnswers.questions,
2076
+ )
2077
+ : [],
2078
+ };
2079
+ },
2080
+ );
2081
+
2082
+ return { landingPage, entryType, entries };
2083
+ }
@@ -1,31 +1,48 @@
1
1
  /**
2
- * Public-read site resolution — which workspace an UNAUTHENTICATED content
3
- * read serves (#5852, roadmap D4 read half).
2
+ * Public-read site resolution — which workspace an UNAUTHENTICATED public
3
+ * request serves (#5852, roadmap D4 read half; spokes #7532).
4
4
  *
5
- * THE GUARD MODEL IS FAIL-CLOSED. The only branch that yields a workspace is an
6
- * unambiguously one-workspace deployment, answered by the same predicate the
7
- * authenticated path uses (`services/default-tenant.ts#resolveDefaultWorkspaceId`)
8
- * — one predicate, reached two ways, never two predicates. Zero workspaces, two
9
- * or more, or a sole workspace that is not the seeded default all resolve to
10
- * `unresolved`, and the caller answers 404 before any content query runs.
5
+ * THE GUARD MODEL IS FAIL-CLOSED. Two branches yield a workspace, and no
6
+ * other:
11
7
  *
12
- * IT READS NO HOST. Every deployment this resolver serves today is
13
- * one-workspace, so a host lookup would be dead code — and there is nothing to
14
- * look up: `WorkspaceSiteIdentity` carries no host column (the value lives
15
- * inside the unindexed `data.url`), and `request.hostname` resolves from
16
- * `X-Forwarded-Host` under this server's `trustProxy: true` (`server.ts`),
17
- * which is caller-suppliable and therefore not a tenancy input. The
18
- * multi-tenant host branch is #3635's, and must resolve against a server-side
19
- * binding rather than a forwarded header.
8
+ * 1. A REGISTERED SPOKE (#7532, `resolveCallerSite` below). A spoke is a
9
+ * separately deployed site calling this hub server-to-server with its
10
+ * hub-minted key in ONE header, `x-ailk-spoke-key`. The key is looked up
11
+ * by its SHA-256 digest in the hub's registry (`lib/spoke-store.ts`);
12
+ * the matching unrevoked row's workspace is the answer. A key that is
13
+ * malformed, unknown or revoked resolves to NOTHING — never to the
14
+ * alias, never to the default workspace.
15
+ * 2. An unambiguously one-workspace deployment, answered by the same
16
+ * predicate the authenticated path uses
17
+ * (`services/default-tenant.ts#resolveDefaultWorkspaceId`) — one
18
+ * predicate, reached two ways, never two predicates. Zero workspaces,
19
+ * two or more, or a sole workspace that is not the seeded default all
20
+ * resolve to `unresolved`, and the caller answers 404 before any content
21
+ * query runs. A header-less request takes this branch exactly as it did
22
+ * before spokes existed — unless the hub sets
23
+ * `AILK_HUB_REQUIRE_SPOKE_KEY`, which refuses it instead.
24
+ *
25
+ * IT READS NO HOST. `request.hostname` resolves from `X-Forwarded-Host` under
26
+ * this server's `trustProxy: true` (`server.ts`), which is caller-suppliable
27
+ * and therefore not a tenancy input; `Origin` is client-supplied outside a
28
+ * browser. The one header read here is the spoke key, and a key selects
29
+ * nothing by itself: only the server-side registry row it digests to does.
20
30
  *
21
31
  * THE CLIENT NEVER SUPPLIES A WORKSPACE. No path parameter, query string,
22
- * header or body field is read here, on any read. That is roadmap D4's own
23
- * STOP boundary, not a default.
32
+ * header or body field naming a workspace is read here, on any request. That
33
+ * is roadmap D4's own STOP boundary, not a default.
34
+ *
35
+ * NOTHING LOADS FOR A HEADER-LESS REQUEST. With no spoke key and strict mode
36
+ * off, the spoke branch reads nothing and imports nothing (#7532 NFR1); off
37
+ * the database adapter the function then returns `not-required` having
38
+ * issued no query and loaded no database module — which is what keeps the
39
+ * MDX default path byte-identical and keeps the no-`DATABASE_URL` scaffold
40
+ * starting. The registry module is imported on first use, and only when a
41
+ * key arrives and `DATABASE_URL` is set.
24
42
  *
25
- * IT IS A NO-OP OFF THE DATABASE ADAPTER. Unless `CONTENT_ADAPTER=database`,
26
- * the function returns `not-required` having issued no query and loaded no
27
- * database module — which is what keeps the MDX default path byte-identical
28
- * and keeps the no-`DATABASE_URL` scaffold starting.
43
+ * NO SECRET IS LOGGED. A refusal reaches `request.log.warn` as `{ reason }`
44
+ * (plus `spokeId` for a matched row) — never the key, its digest or its
45
+ * prefix — and never reaches the wire.
29
46
  */
30
47
  import {
31
48
  resolvedAdapterKind,
@@ -34,8 +51,97 @@ import {
34
51
  } from "@working-theory/content-adapters";
35
52
  import type { FastifyRequest } from "fastify";
36
53
 
54
+ import { config } from "../config/index.js";
37
55
  import { getContentAdapter } from "../lib/content-adapter.js";
38
56
 
57
+ /** The one request header a spoke names itself with (#7532 D2). */
58
+ const SPOKE_KEY_HEADER = "x-ailk-spoke-key";
59
+
60
+ /** The longest key the hub will hash; a minted key is 52 characters. */
61
+ const SPOKE_KEY_MAX_LENGTH = 128;
62
+
63
+ /**
64
+ * Visible ASCII only — the class the visitor-forward key already uses
65
+ * (`packages/api-client` `SAFE_HEADER_TOKEN`; `apps/api` cannot import that
66
+ * package, so this is its own constant). It also refuses the `"a, b"` string
67
+ * Node makes of a duplicated header, because of the space.
68
+ */
69
+ const SAFE_SPOKE_KEY = /^[\x21-\x7e]+$/;
70
+
71
+ /**
72
+ * Who is calling a spoke-aware public surface (#7532 D3).
73
+ *
74
+ * anonymous — no spoke key, strict mode off: resolve exactly as before.
75
+ * spoke — a registered, unrevoked spoke; its workspace is the answer.
76
+ * refused — resolve to NOTHING. `reason` is for `request.log` only.
77
+ */
78
+ export type CallerSite =
79
+ | { kind: "anonymous" }
80
+ | { kind: "spoke"; spokeId: string; workspaceId: string }
81
+ | {
82
+ kind: "refused";
83
+ reason:
84
+ | "key-required"
85
+ | "key-malformed"
86
+ | "no-database"
87
+ | "key-unknown"
88
+ | "key-revoked";
89
+ };
90
+
91
+ function refuse(
92
+ request: FastifyRequest,
93
+ reason: Extract<CallerSite, { kind: "refused" }>["reason"],
94
+ spokeId?: string,
95
+ ): CallerSite {
96
+ request.log.warn(
97
+ spokeId === undefined ? { reason } : { reason, spokeId },
98
+ "[spoke] caller refused",
99
+ );
100
+ return { kind: "refused", reason };
101
+ }
102
+
103
+ /**
104
+ * Resolve the caller of a spoke-aware public surface. Reads ONE header, the
105
+ * spoke key, and nothing else off the request; see the file header.
106
+ */
107
+ export async function resolveCallerSite(
108
+ request: FastifyRequest,
109
+ ): Promise<CallerSite> {
110
+ // Optional chain: a bare `{}` request (the resolver's own suite) is a
111
+ // header-less request.
112
+ const raw: unknown = request.headers?.[SPOKE_KEY_HEADER];
113
+
114
+ if (raw === undefined) {
115
+ return config.AILK_HUB_REQUIRE_SPOKE_KEY
116
+ ? refuse(request, "key-required")
117
+ : { kind: "anonymous" };
118
+ }
119
+
120
+ // A string[] is refused defensively; Node joins a duplicated custom header
121
+ // into one "a, b" string, which the visible-ASCII test refuses.
122
+ const key = typeof raw === "string" ? raw.trim() : "";
123
+ if (
124
+ key === "" ||
125
+ key.length > SPOKE_KEY_MAX_LENGTH ||
126
+ !SAFE_SPOKE_KEY.test(key)
127
+ ) {
128
+ return refuse(request, "key-malformed");
129
+ }
130
+
131
+ if (!process.env.DATABASE_URL) {
132
+ return refuse(request, "no-database");
133
+ }
134
+
135
+ // Deferred, like `lib/prisma.js` below: a header-less request never loads it.
136
+ const { findSpokeByKeyHash, hashSpokeKey } = await import(
137
+ "../lib/spoke-store.js"
138
+ );
139
+ const row = await findSpokeByKeyHash(hashSpokeKey(key));
140
+ if (!row) return refuse(request, "key-unknown");
141
+ if (row.revokedAt !== null) return refuse(request, "key-revoked", row.id);
142
+ return { kind: "spoke", spokeId: row.id, workspaceId: row.workspaceId };
143
+ }
144
+
39
145
  /**
40
146
  * The three answers a public read can get. `reason` exists for `request.log`
41
147
  * only: the route decides its response from `kind` alone, so no resolution
@@ -44,17 +150,29 @@ import { getContentAdapter } from "../lib/content-adapter.js";
44
150
  export type PublicSiteResolution =
45
151
  | { kind: "not-required" }
46
152
  | { kind: "resolved"; workspaceId: string }
47
- | { kind: "unresolved"; reason: "ambiguous" | "host-unmatched" };
153
+ | {
154
+ kind: "unresolved";
155
+ reason: "ambiguous" | "host-unmatched" | "spoke-refused";
156
+ };
48
157
 
49
158
  /**
50
- * Resolves the workspace a public content read serves, or refuses to.
159
+ * Resolves the workspace a public read serves, or refuses to.
51
160
  *
52
- * The `request` argument is the site identifier's eventual source (#3635) and
53
- * is deliberately unused today — see the no-host note in this file's header.
161
+ * A spoke resolves to its own workspace on any content adapter; a refused
162
+ * caller resolves to nothing; an anonymous caller takes the one-workspace
163
+ * path below, unchanged (#7532 D3).
54
164
  */
55
165
  export async function resolvePublicWorkspace(
56
- _request: FastifyRequest,
166
+ request: FastifyRequest,
57
167
  ): Promise<PublicSiteResolution> {
168
+ const caller = await resolveCallerSite(request);
169
+ if (caller.kind === "refused") {
170
+ return { kind: "unresolved", reason: "spoke-refused" };
171
+ }
172
+ if (caller.kind === "spoke") {
173
+ return { kind: "resolved", workspaceId: caller.workspaceId };
174
+ }
175
+
58
176
  if (resolvedAdapterKind() !== "database") {
59
177
  return { kind: "not-required" };
60
178
  }
@@ -90,7 +208,10 @@ export async function resolvePublicContentAdapter(
90
208
  );
91
209
  return null;
92
210
  }
93
- if (site.kind === "not-required") {
211
+ // A spoke on an MDX-adapter hub resolves a workspace (its meeting types are
212
+ // per-workspace), but MDX content is not: it keeps reading the shared
213
+ // source rather than being switched to the database one (#7532 D3).
214
+ if (site.kind === "not-required" || resolvedAdapterKind() !== "database") {
94
215
  return getContentAdapter();
95
216
  }
96
217
 
@@ -49,7 +49,14 @@ import { z } from "zod";
49
49
  import { config } from "../config/index.js";
50
50
  import { outboundFetch } from "../lib/outbound-http.js";
51
51
 
52
- const log = createLogger({ service: "revalidation" });
52
+ // The environment is the api's own NODE_ENV, as the observability `logger`
53
+ // singleton reads it. Omitted, createLogger assumes development and asks for a
54
+ // pino-pretty transport a production install does not carry — a throw at load
55
+ // that took the whole api down once content-write was enabled (#7502).
56
+ const log = createLogger({
57
+ service: "revalidation",
58
+ environment: config.NODE_ENV,
59
+ });
53
60
 
54
61
  const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
55
62
  const EVENT_HEADER = "x-ailk-revalidate-event";