create-ailk 0.11.5 → 0.12.1
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.
- package/component-catalog.json +35 -2
- package/component-catalog.md +9 -6
- package/dist/add-module.js +13 -0
- package/dist/module-architecture.d.ts +8 -0
- package/dist/module-architecture.js +33 -0
- package/dist/paid-sections.d.ts +23 -0
- package/dist/paid-sections.js +119 -0
- package/dist/programmatic.js +8 -0
- package/package.json +3 -3
- package/templates/.env.example +69 -3
- package/templates/apps/api/.env.example +22 -0
- package/templates/apps/api/CLAUDE.md +19 -7
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/bin/spokes.ts +137 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
- package/templates/apps/api/src/config/index.ts +97 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
- package/templates/apps/api/src/lib/spoke-store.ts +131 -0
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
- package/templates/apps/api/src/routes/leads/index.ts +33 -3
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
- package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
- package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
- package/templates/apps/api/src/server.ts +48 -0
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
- package/templates/apps/api/src/services/default-tenant.ts +51 -28
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
- package/templates/apps/api/src/services/lead-routing.ts +13 -1
- package/templates/apps/api/src/services/project-listings.ts +209 -18
- package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
- package/templates/apps/api/src/services/slack-notify.ts +54 -3
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/layout.tsx +4 -0
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/jest.config.cjs +1 -1
- package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
- package/templates/apps/web/lib/listings.tsx +472 -0
- package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
- package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
- package/templates/apps/web/messages/en.json +10 -0
- package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
- package/templates/database/CHANGELOG.md +34 -0
- package/templates/database/__tests__/build.test.ts +131 -0
- package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
- package/templates/database/auth/schema.prisma +31 -0
- package/templates/database/config/schema.prisma +26 -0
- package/templates/database/inbox/schema.prisma +21 -0
- package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
- package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
- package/templates/database/package.json +3 -3
- 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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
3
|
-
*
|
|
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.
|
|
6
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
| {
|
|
153
|
+
| {
|
|
154
|
+
kind: "unresolved";
|
|
155
|
+
reason: "ambiguous" | "host-unmatched" | "spoke-refused";
|
|
156
|
+
};
|
|
48
157
|
|
|
49
158
|
/**
|
|
50
|
-
* Resolves the workspace a public
|
|
159
|
+
* Resolves the workspace a public read serves, or refuses to.
|
|
51
160
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|