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
@@ -64,6 +64,7 @@ import {
64
64
  import { prisma } from "../../../lib/prisma.js";
65
65
  import { listProjectListings } from "../../../services/project-listings.js";
66
66
  import { patchProjectListingDraftRoute } from "../patch-draft.js";
67
+ import { projectListingDirectoryRoute } from "../index.js";
67
68
  import { publicProjectListingsRoute } from "../public.js";
68
69
  import { startProjectListingRoute } from "../start.js";
69
70
  import { submitProjectListingRoute } from "../submit.js";
@@ -816,3 +817,206 @@ describe("#6335 — start fails closed when the site names no workspace", () =>
816
817
  expect(rows).toHaveLength(0);
817
818
  });
818
819
  });
820
+
821
+ // ─── #7521 — a listicle's own Application fields ────────────────────────────
822
+ //
823
+ // A draft a LIVE listicle captured (#6314's `capturedByFlowId`) may answer
824
+ // that listicle's eligible Application fields — `text`/`textarea`/`url`/
825
+ // `select` with no `mapsTo` — with the same per-control checks the site's own
826
+ // questions get. The accepted entry's directory profile publishes them, minus
827
+ // the owner's `privateFields` and any id the site itself declares.
828
+ describe("a captured draft answers its listicle's Application fields (#7521 AC-2)", () => {
829
+ setupLeadFileEnv("project-listing-listicle-answers");
830
+ let app: FastifyInstance;
831
+ let flows: Row[];
832
+
833
+ const en = (value: string) => ({ en: value });
834
+ function listicle(overrides: Row = {}, listing?: Record<string, unknown>): Row {
835
+ return {
836
+ id: "flow_partners",
837
+ siteId: SITE_ID,
838
+ workspaceId: SITE_ID,
839
+ slug: "partners-apply",
840
+ kind: "listicle",
841
+ status: "live",
842
+ landingPage: "/partners",
843
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
844
+ config: {
845
+ slug: "partners-apply",
846
+ defaultLocale: "en",
847
+ steps: [
848
+ {
849
+ key: "application",
850
+ type: "field",
851
+ copy: { title: en("Apply") },
852
+ fields: [
853
+ { name: "full_name", inputType: "text", mapsTo: "name", copy: { label: en("Name") } },
854
+ { name: "email", inputType: "email", mapsTo: "email", copy: { label: en("Email") } },
855
+ { name: "phone", inputType: "tel", mapsTo: "phone", copy: { label: en("Phone") } },
856
+ { name: "website", inputType: "url", copy: { label: en("Website") } },
857
+ {
858
+ name: "stage",
859
+ inputType: "select",
860
+ options: [
861
+ { value: "idea", label: en("Idea") },
862
+ { value: "live", label: en("Live") },
863
+ ],
864
+ copy: { label: en("Stage") },
865
+ },
866
+ { name: "about", inputType: "textarea", copy: { label: en("About") } },
867
+ ],
868
+ },
869
+ ],
870
+ ...(listing !== undefined && { listing }),
871
+ },
872
+ ...overrides,
873
+ };
874
+ }
875
+
876
+ beforeEach(async () => {
877
+ jest.clearAllMocks();
878
+ installStore();
879
+ flows = [listicle()];
880
+ (prisma.flowDefinition.findFirst as jest.Mock).mockImplementation(
881
+ ({ where }: { where: Record<string, unknown> }) =>
882
+ Promise.resolve(flows.find((f) => matches(f, where)) ?? null),
883
+ );
884
+ (prisma.flowDefinition.findMany as jest.Mock).mockImplementation(
885
+ ({ where }: { where: Record<string, unknown> }) =>
886
+ Promise.resolve(flows.filter((f) => matches(f, where))),
887
+ );
888
+ (prisma.waitlistScore.count as jest.Mock).mockResolvedValue(0);
889
+ app = Fastify();
890
+ await app.register(startProjectListingRoute);
891
+ await app.register(patchProjectListingDraftRoute);
892
+ await app.register(publicProjectListingsRoute);
893
+ await app.register(projectListingDirectoryRoute);
894
+ await app.ready();
895
+ });
896
+
897
+ afterEach(async () => {
898
+ await app.close();
899
+ });
900
+
901
+ async function startCaptured(): Promise<{ id: string; resumeToken: string }> {
902
+ const res = await app.inject({
903
+ method: "POST",
904
+ url: "/v1/project-listings",
905
+ payload: { ...START_PAYLOAD, capturedByFlowSlug: "partners-apply" },
906
+ });
907
+ expect(res.statusCode).toBe(201);
908
+ expect(rows[0]!["capturedByFlowId"]).toBe("flow_partners");
909
+ return (res.json() as { data: { id: string; resumeToken: string } }).data;
910
+ }
911
+
912
+ /** Accept the one row, so it reaches the directory. */
913
+ function accept(): void {
914
+ Object.assign(rows[0]!, {
915
+ slug: "acme",
916
+ name: "Acme",
917
+ status: "active",
918
+ decidedAt: new Date(),
919
+ windowEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
920
+ });
921
+ }
922
+
923
+ async function profile(): Promise<Array<Record<string, string>>> {
924
+ const res = await app.inject({
925
+ method: "GET",
926
+ url: `/v1/project-listings/directory?siteKey=${SITE_KEY}&landingPage=/partners`,
927
+ });
928
+ expect(res.statusCode).toBe(200);
929
+ const data = (res.json() as { data: { entries: Array<{ profile: Array<Record<string, string>> }> } }).data;
930
+ return data.entries[0]!.profile;
931
+ }
932
+
933
+ it("saves answers to the listicle's eligible fields on a captured draft", async () => {
934
+ const draft = await startCaptured();
935
+
936
+ const res = await patch(app, draft, { siteAnswers: { website: "https://x.test", stage: "live" } });
937
+
938
+ expect(res.statusCode).toBe(200);
939
+ expect(rows[0]!["siteAnswers"]).toEqual({ website: "https://x.test", stage: "live" });
940
+ });
941
+
942
+ it("refuses a contact field and a non-http link, writing nothing", async () => {
943
+ const draft = await startCaptured();
944
+
945
+ const email = await patch(app, draft, { siteAnswers: { email: "a@b.test" } });
946
+ const script = await patch(app, draft, { siteAnswers: { website: "javascript:alert(1)" } });
947
+
948
+ expect(email.statusCode).toBe(400);
949
+ expect(script.statusCode).toBe(400);
950
+ expect(rows[0]!["siteAnswers"]).toBeNull();
951
+ });
952
+
953
+ it("refuses the same answers on an uncaptured draft", async () => {
954
+ const draft = await startDraft(app);
955
+
956
+ const res = await patch(app, draft, { siteAnswers: { website: "https://x.test" } });
957
+
958
+ expect(res.statusCode).toBe(400);
959
+ expect(rows[0]!["siteAnswers"]).toBeNull();
960
+ });
961
+
962
+ it("refuses answers on a draft a DRAFT-status listicle captured", async () => {
963
+ const draft = await startCaptured();
964
+ flows[0]!["status"] = "draft";
965
+
966
+ const res = await patch(app, draft, { siteAnswers: { website: "https://x.test" } });
967
+
968
+ expect(res.statusCode).toBe(400);
969
+ expect(rows[0]!["siteAnswers"]).toBeNull();
970
+ });
971
+
972
+ it("refuses a 501-character url answer", async () => {
973
+ const draft = await startCaptured();
974
+
975
+ const res = await patch(app, draft, {
976
+ siteAnswers: { website: `https://x.test/${"a".repeat(486)}` },
977
+ });
978
+
979
+ expect(res.statusCode).toBe(400);
980
+ expect(rows[0]!["siteAnswers"]).toBeNull();
981
+ });
982
+
983
+ it("a site-declared operator-only `about` keeps its visibility: off /public and off the profile", async () => {
984
+ configData = {
985
+ siteAnswers: { questions: [{ id: "about", control: "textarea", visibility: "operator-only" }] },
986
+ };
987
+ const draft = await startCaptured();
988
+
989
+ const res = await patch(app, draft, {
990
+ siteAnswers: { website: "https://x.test", about: "for the operator" },
991
+ });
992
+ expect(res.statusCode).toBe(200);
993
+ accept();
994
+
995
+ expect((await profile()).map((row) => row["name"])).toEqual(["website"]);
996
+ const publicRead = await app.inject({
997
+ method: "GET",
998
+ url: `/v1/project-listings/public?siteKey=${SITE_KEY}`,
999
+ });
1000
+ expect(publicRead.body).not.toContain("for the operator");
1001
+ });
1002
+
1003
+ it("listing.privateFields keeps `stage` off the profile while the answer stays stored", async () => {
1004
+ flows = [listicle({}, { privateFields: ["stage"] })];
1005
+ const draft = await startCaptured();
1006
+ await patch(app, draft, { siteAnswers: { website: "https://x.test", stage: "live" } });
1007
+ accept();
1008
+
1009
+ expect((await profile()).map((row) => row["name"])).toEqual(["website"]);
1010
+ expect(rows[0]!["siteAnswers"]).toEqual({ website: "https://x.test", stage: "live" });
1011
+ });
1012
+
1013
+ it("a `javascript:` value stored while the field was `text` comes back as kind text", async () => {
1014
+ await startCaptured();
1015
+ accept();
1016
+ rows[0]!["siteAnswers"] = { website: "javascript:x" };
1017
+
1018
+ expect(await profile()).toEqual([
1019
+ { name: "website", label: "Website", value: "javascript:x", kind: "text" },
1020
+ ]);
1021
+ });
1022
+ });
@@ -9,6 +9,7 @@
9
9
  * POST /v1/project-listings/drafts/:id/verify-email — anonymous, token- AND
10
10
  * nonce-gated (#5407)
11
11
  * GET /v1/project-listings/public — anonymous page-render read (leadsScope)
12
+ * GET /v1/project-listings/directory — anonymous landing-page directory (own scope, #7521)
12
13
  * GET /v1/project-listings/me — the founder's OWN listings (authScope)
13
14
  * GET /v1/project-listings — tenant-scoped dashboard list
14
15
  * GET /v1/project-listings/:id — tenant-scoped single read
@@ -33,7 +34,10 @@
33
34
  * own identity-blind posture (an applying founder has no account, and the
34
35
  * resume token is a per-draft capability rather than an identity); the
35
36
  * tenant-scoped set is an identity-bound dashboard surface, the same #3591
36
- * exception the sibling `/v1/waitlist-experiments/*` routes record.
37
+ * exception the sibling `/v1/waitlist-experiments/*` routes record. The
38
+ * directory read (#7521) takes the same no-twin posture as `/public`: agents
39
+ * read a directory from its page's JSON-LD, and a `list_directory` tool is a
40
+ * follow-up.
37
41
  */
38
42
  import type { FastifyPluginAsync } from "fastify";
39
43
 
@@ -41,7 +45,10 @@ import { loadSlice } from "../../lib/slice-load.js";
41
45
 
42
46
  import { projectListingMeRoute as meRoute } from "./me.js";
43
47
  import { patchProjectListingDraftRoute } from "./patch-draft.js";
44
- import { publicProjectListingsRoute } from "./public.js";
48
+ import {
49
+ projectListingDirectoryRoute as directoryRoute,
50
+ publicProjectListingsRoute,
51
+ } from "./public.js";
45
52
  import { startProjectListingRoute } from "./start.js";
46
53
  import { submitProjectListingRoute } from "./submit.js";
47
54
  import { verifyProjectListingDraftEmailRoute } from "./verify-email.js";
@@ -50,6 +57,11 @@ export const projectListingStartRoute = startProjectListingRoute;
50
57
  export const projectListingDraftPatchRoute = patchProjectListingDraftRoute;
51
58
  export const projectListingSubmitRoute = submitProjectListingRoute;
52
59
  export const projectListingPublicRoute = publicProjectListingsRoute;
60
+ /**
61
+ * The landing-page directory read (#7521). Static, like `/public`; `server.ts`
62
+ * registers it in its own rate-limited scope, not `leadsScope`.
63
+ */
64
+ export const projectListingDirectoryRoute = directoryRoute;
53
65
  /**
54
66
  * The resume link's verification write (#5407). Static, not a slice: it is
55
67
  * part of the free anonymous surface, exactly like the three routes above.
@@ -15,8 +15,20 @@
15
15
  * `SiteListingConfig` enables it. `founderEmail` never leaves this route —
16
16
  * `projectListingPublicSchema` (`.omit({founderEmail})`) is what the response
17
17
  * is re-parsed against before send.
18
+ *
19
+ * GET /v1/project-listings/directory?siteKey=&landingPage=&locale= (#7521 D3)
20
+ *
21
+ * The generic directory read beside it: the live listicle naming
22
+ * `landingPage`, its schema.org entry type and its accepted entries, each
23
+ * with a public profile of the listicle's own Application answers — or
24
+ * `data: null` when no live listicle names the page, which is almost every
25
+ * page (a 404 per page view would be noise). Its own plugin, so `server.ts`
26
+ * registers it in its own rate-limited scope rather than `leadsScope`: the
27
+ * web server renders it from one egress address (SEC-7521-D1).
18
28
  */
19
29
  import {
30
+ listingDirectoryQuerySchema,
31
+ listingDirectoryResponseSchema,
20
32
  projectListingPublicListResponseSchema,
21
33
  publicSiteKeySchema,
22
34
  } from "@working-theory/validation";
@@ -24,7 +36,10 @@ import type { FastifyPluginAsync } from "fastify";
24
36
  import { z } from "zod";
25
37
 
26
38
  import { siteIdForPublicKey } from "../../services/listing-config.js";
27
- import { publicProjectListings } from "../../services/project-listings.js";
39
+ import {
40
+ listingDirectoryFor,
41
+ publicProjectListings,
42
+ } from "../../services/project-listings.js";
28
43
 
29
44
  import { sendValidated } from "./respond.js";
30
45
 
@@ -55,3 +70,29 @@ export const publicProjectListingsRoute: FastifyPluginAsync = async (fastify) =>
55
70
  return sendValidated(reply, projectListingPublicListResponseSchema, data, "public");
56
71
  });
57
72
  };
73
+
74
+ export const projectListingDirectoryRoute: FastifyPluginAsync = async (fastify) => {
75
+ fastify.get("/v1/project-listings/directory", async (request, reply) => {
76
+ const parsedQuery = listingDirectoryQuerySchema.safeParse(request.query);
77
+ if (!parsedQuery.success) {
78
+ return reply.status(400).send({
79
+ ok: false,
80
+ error: { code: "validation_failed", message: "Invalid directory query" },
81
+ });
82
+ }
83
+
84
+ // The same one-key resolution `/public` makes: a `SiteListingConfig` row
85
+ // under multi-tenant, the env key under single-tenant (#5048 D2).
86
+ const { siteKey, landingPage, locale } = parsedQuery.data;
87
+ const siteId = await siteIdForPublicKey(siteKey);
88
+ if (siteId === null) {
89
+ return reply.status(400).send({
90
+ ok: false,
91
+ error: { code: "unknown_site_key", message: "Unknown site key" },
92
+ });
93
+ }
94
+
95
+ const data = await listingDirectoryFor(siteId, landingPage, locale);
96
+ return sendValidated(reply, listingDirectoryResponseSchema, data, "directory");
97
+ });
98
+ };
@@ -119,6 +119,9 @@ export const submitProjectListingRoute: FastifyPluginAsync = async (fastify) =>
119
119
  product: result.listing.product,
120
120
  founderName: result.listing.founderName,
121
121
  founderCompany: result.listing.founderCompany,
122
+ // #7522 D5 — the actions block names the listing's workspace; `siteId`
123
+ // IS the workspace id (the `project_listings_workspace_site_agree` CHECK).
124
+ workspaceId: result.listing.siteId,
122
125
  });
123
126
 
124
127
  const body = projectListingSubmitResponseSchema.parse({ id: result.id, slug: result.slug });
@@ -11,6 +11,13 @@
11
11
  * reached through an indirect dynamic import
12
12
  * so a keyless scaffold never resolves
13
13
  * `calcom.ts`, which it does not have.
14
+ * POST /v1/webhooks/calcom/:connectionToken — the same receiver for ONE
15
+ * workspace's own scheduling connection
16
+ * (#7533 D6): the token names the connection,
17
+ * whose own secret verifies the delivery and
18
+ * whose workspace receives the Appointment.
19
+ * Same module gate, same `loadSlice` call,
20
+ * its own rate-limit bucket.
14
21
  *
15
22
  * Rate limit: each receiver carries its OWN 100 req/min/IP bucket, from the
16
23
  * ONE plugin-level `rateLimit` registration below (a second plugin-level
@@ -66,6 +73,10 @@ export const webhooksRoutes: FastifyPluginAsync = async (fastify) => {
66
73
  if (isModuleEnabled("scheduling")) {
67
74
  const mod = (await loadSlice(CALCOM_HANDLER, () => import(CALCOM_HANDLER))) as {
68
75
  calcomWebhookHandler?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
76
+ calcomConnectionWebhookHandler?: (
77
+ request: FastifyRequest,
78
+ reply: FastifyReply,
79
+ ) => Promise<void>;
69
80
  };
70
81
  const handler = mod.calcomWebhookHandler;
71
82
  if (!handler) {
@@ -73,6 +84,12 @@ export const webhooksRoutes: FastifyPluginAsync = async (fastify) => {
73
84
  `AILK slice drift: '${CALCOM_HANDLER}' does not export 'calcomWebhookHandler'`,
74
85
  );
75
86
  }
87
+ const connectionHandler = mod.calcomConnectionWebhookHandler;
88
+ if (!connectionHandler) {
89
+ throw new Error(
90
+ `AILK slice drift: '${CALCOM_HANDLER}' does not export 'calcomConnectionWebhookHandler'`,
91
+ );
92
+ }
76
93
  fastify.post(
77
94
  "/v1/webhooks/calcom",
78
95
  {
@@ -85,5 +102,17 @@ export const webhooksRoutes: FastifyPluginAsync = async (fastify) => {
85
102
  },
86
103
  handler,
87
104
  );
105
+ fastify.post(
106
+ "/v1/webhooks/calcom/:connectionToken",
107
+ {
108
+ config: {
109
+ rateLimit: {
110
+ max: CALCOM_WEBHOOK_RATE_LIMIT_PER_MIN,
111
+ timeWindow: "1 minute",
112
+ },
113
+ },
114
+ },
115
+ connectionHandler,
116
+ );
88
117
  }
89
118
  };
@@ -21,7 +21,7 @@ import type {
21
21
  } from "fastify";
22
22
 
23
23
  import { config } from "./config/index.js";
24
- import { isModuleEnabled } from "./config/modules.js";
24
+ import { isModuleEnabled, servedModules } from "./config/modules.js";
25
25
  import { registerErrorSerializer } from "./lib/error-serializer.js";
26
26
  import { validateLeadCaptureDir } from "./lib/lead-capture-store.js";
27
27
  import { registerLicenseKeyStore } from "./lib/license-key-store.js";
@@ -212,6 +212,10 @@ const WORKSPACES_MEMBERS = "./routes/workspaces/members.js";
212
212
  // scope as WORKSPACES_GENERAL/WORKSPACES_MEMBERS below, not the
213
213
  // multi-tenant-only family WORKSPACES_MEETINGS/WORKSPACES_CONFIG sit in.
214
214
  const WORKSPACES_MESSAGES = "./routes/workspaces/messages.js";
215
+ // #7535 — the Inbox submissions and subscribers reads, siblings of
216
+ // WORKSPACES_MESSAGES in the same workspace/multi-tenant scope.
217
+ const WORKSPACES_SUBMISSIONS = "./routes/workspaces/submissions.js";
218
+ const WORKSPACES_SUBSCRIBERS = "./routes/workspaces/subscribers.js";
215
219
  // #6183 S9b — a SEPARATE specifier from WORKSPACES_CONFIG: this route is
216
220
  // registered under isModuleEnabled("scheduling") alone, never the
217
221
  // workspace/multi-tenant scope config.js shares with general.js/members.js
@@ -274,6 +278,15 @@ const SLICE_PLUGINS = {
274
278
  specifier: WORKSPACES_MESSAGES,
275
279
  load: () => import(WORKSPACES_MESSAGES),
276
280
  },
281
+ // #7535 — the Inbox submissions and subscribers reads, the SAME scope.
282
+ workspaceSubmissionRoutes: {
283
+ specifier: WORKSPACES_SUBMISSIONS,
284
+ load: () => import(WORKSPACES_SUBMISSIONS),
285
+ },
286
+ workspaceSubscriberRoutes: {
287
+ specifier: WORKSPACES_SUBSCRIBERS,
288
+ load: () => import(WORKSPACES_SUBSCRIBERS),
289
+ },
277
290
  // #6183 S9b — registered in the scheduling scope, not the
278
291
  // workspace/multi-tenant scope its siblings above share.
279
292
  workspaceMeetingsRoutes: {
@@ -374,6 +387,11 @@ const SLICE_PLUGINS = {
374
387
  specifier: PROJECT_LISTINGS_INDEX,
375
388
  load: () => import(PROJECT_LISTINGS_INDEX),
376
389
  },
390
+ // #7521 — the landing-page directory read; its own rate-limited scope below.
391
+ projectListingDirectoryRoute: {
392
+ specifier: PROJECT_LISTINGS_INDEX,
393
+ load: () => import(PROJECT_LISTINGS_INDEX),
394
+ },
377
395
  projectListingMeRoute: {
378
396
  specifier: PROJECT_LISTINGS_INDEX,
379
397
  load: () => import(PROJECT_LISTINGS_INDEX),
@@ -722,12 +740,19 @@ export async function build() {
722
740
  // Root endpoint — introduces this deployment as the SITE it serves, not as
723
741
  // the kit it was built with (#5307). `_site.mdx` is read once per process;
724
742
  // a site without one degrades to a neutral name, never to the kit's.
743
+ //
744
+ // `modules` (#7501) is the optional modules whose routes this build
745
+ // registers, computed once here — before any route registers — so the index
746
+ // reports what registered rather than re-reading the env per request. The
747
+ // Admin reads it to hide the surfaces whose module is off.
748
+ const modules = servedModules();
725
749
  server.get("/", async () => {
726
750
  const identity = resolveApiIdentity();
727
751
  return {
728
752
  name: apiDisplayName(identity),
729
753
  version: API_VERSION,
730
754
  description: apiDescription(identity),
755
+ modules,
731
756
  // Derived from the routes this process actually registered (#5314),
732
757
  // not a hand-maintained literal. The literal listed 11 endpoints against
733
758
  // 68 registered routes, omitted `/v1/project-listings/public` and
@@ -980,6 +1005,15 @@ export async function build() {
980
1005
  await tenantScope.register(
981
1006
  await sliceRoutePlugin("workspaceMessageRoutes"),
982
1007
  );
1008
+ // The Inbox submissions and subscribers reads (#7535) — the SAME
1009
+ // scope and guard: every flow completion and waitlist signup (list,
1010
+ // item read, handled stamp), and the read-only newsletter signups.
1011
+ await tenantScope.register(
1012
+ await sliceRoutePlugin("workspaceSubmissionRoutes"),
1013
+ );
1014
+ await tenantScope.register(
1015
+ await sliceRoutePlugin("workspaceSubscriberRoutes"),
1016
+ );
983
1017
  // The asset store's MANAGE routes — list, PATCH, DELETE (#6093 D16).
984
1018
  // Registered HERE, in the plain tenant scope, and NOT inside the
985
1019
  // upload's own nested strict-rate-limit scope below: a JSON
@@ -1272,6 +1306,27 @@ export async function build() {
1272
1306
  }
1273
1307
  });
1274
1308
 
1309
+ // The listicle directory read (#7521 D3) — anonymous, keyed by the public
1310
+ // site key, and rendered by the web server on every page view of a landing
1311
+ // page. Its OWN scope, NOT `leadsScope`: that scope's 10/min per IP would be
1312
+ // spent by the web server's SSR reads from one egress address, and the
1313
+ // directory would vanish past ~10 views a minute (SEC-7521-D1). 120/min,
1314
+ // overridable by AILK_RATE_LIMIT_DIRECTORY_PER_MINUTE (#7394's named-scope
1315
+ // rule); the web side memoises each landing page for 30 s per process.
1316
+ // Module-conditional (signal-collectors, D-48), so a build without the
1317
+ // module has no `/directory` and the web extension's read gets a 404.
1318
+ await server.register(async (directoryScope) => {
1319
+ directoryScope.register(rateLimitMiddleware, {
1320
+ perIpPerMinute: 120,
1321
+ scope: "directory",
1322
+ });
1323
+ if (isModuleEnabled("signal-collectors")) {
1324
+ await directoryScope.register(
1325
+ await sliceRoutePlugin("projectListingDirectoryRoute"),
1326
+ );
1327
+ }
1328
+ });
1329
+
1275
1330
  // Flows routes — anonymous surface; rate-limit middleware scoped to this
1276
1331
  // sub-router, matching the leads scope above (#3956 D4 Clarifications:
1277
1332
  // "follows whatever the existing anonymous /v1/leads/* routes do today").
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @file default-tenant.test.ts
3
+ *
4
+ * The one pin-match predicate both tenant guards share (#7523,
5
+ * docs/specs/7523-a-multi-tenant-session-with-spec.md D2): `isPinnedOperator`
6
+ * is true only when `AILK_OPERATOR_EMAIL` is set (trimmed, lowercased, a blank
7
+ * pin counting as unset) AND the caller's VERIFIED `User.email` equals it.
8
+ * `resolveDefaultTenantForOperator` delegates to it, so for every input the
9
+ * predicate refuses, the base-build entry point returns `null` before
10
+ * `resolveDefaultTenant` runs a single deployment or Membership read (AC-4).
11
+ *
12
+ * Mocks the module-level `prisma` singleton via the repo's manual-mock
13
+ * convention (`apps/api/src/lib/__mocks__/prisma.ts`).
14
+ */
15
+
16
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
17
+ jest.mock("../../lib/prisma.js");
18
+
19
+ import type { Id } from "@working-theory/ids";
20
+
21
+ import { prisma } from "../../lib/prisma.js";
22
+ import {
23
+ isPinnedOperator,
24
+ resolveDefaultTenantForOperator,
25
+ } from "../default-tenant";
26
+
27
+ const DEFAULT_ORG_ID = "org_00000000-0000-7000-8000-000000000001";
28
+ const DEFAULT_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
29
+
30
+ const CALLER = "user_00000000-0000-7000-8000-0000000000c1" as Id<"user">;
31
+
32
+ const ORIGINAL_ENV = process.env;
33
+
34
+ beforeEach(() => {
35
+ jest.clearAllMocks();
36
+ process.env = { ...ORIGINAL_ENV };
37
+ delete process.env.AILK_OPERATOR_EMAIL;
38
+ });
39
+
40
+ afterAll(() => {
41
+ process.env = ORIGINAL_ENV;
42
+ });
43
+
44
+ function mockUser(row: { email: string; emailVerified: boolean } | null): void {
45
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(
46
+ row === null ? null : { id: CALLER, ...row },
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Every input `isPinnedOperator` refuses. `reads` is whether the refusal needs
52
+ * the caller's `User` row — an unset or blank pin is refused before any read.
53
+ */
54
+ const REFUSALS: Array<{
55
+ label: string;
56
+ pin: string | undefined;
57
+ user: { email: string; emailVerified: boolean } | null;
58
+ reads: boolean;
59
+ }> = [
60
+ {
61
+ label: "the pin is unset",
62
+ pin: undefined,
63
+ user: { email: "op@x.io", emailVerified: true },
64
+ reads: false,
65
+ },
66
+ {
67
+ label: "the pin is blank",
68
+ pin: " ",
69
+ user: { email: "", emailVerified: true },
70
+ reads: false,
71
+ },
72
+ { label: "the caller has no User row", pin: "op@x.io", user: null, reads: true },
73
+ {
74
+ label: "the caller's address is the pin but unverified",
75
+ pin: "op@x.io",
76
+ user: { email: "op@x.io", emailVerified: false },
77
+ reads: true,
78
+ },
79
+ {
80
+ label: "the caller's verified address differs from the pin",
81
+ pin: "op@x.io",
82
+ user: { email: "attacker@evil.com", emailVerified: true },
83
+ reads: true,
84
+ },
85
+ ];
86
+
87
+ describe("isPinnedOperator (#7523 D2)", () => {
88
+ for (const { label, pin, user, reads } of REFUSALS) {
89
+ it(`false when ${label}`, async () => {
90
+ if (pin !== undefined) process.env.AILK_OPERATOR_EMAIL = pin;
91
+ mockUser(user);
92
+
93
+ await expect(isPinnedOperator(CALLER)).resolves.toBe(false);
94
+ expect(prisma.user.findUnique).toHaveBeenCalledTimes(reads ? 1 : 0);
95
+ });
96
+ }
97
+
98
+ it("true when the caller's VERIFIED address equals the pin, case and surrounding whitespace normalised on both sides", async () => {
99
+ process.env.AILK_OPERATOR_EMAIL = " Op@X.io ";
100
+ mockUser({ email: " OP@x.IO ", emailVerified: true });
101
+
102
+ await expect(isPinnedOperator(CALLER)).resolves.toBe(true);
103
+ // The read is `findVerifiedEmail`'s — it names `emailVerified` beside
104
+ // `email`, so the match is against a proven address.
105
+ expect(prisma.user.findUnique).toHaveBeenCalledWith({
106
+ where: { id: CALLER },
107
+ select: { id: true, email: true, emailVerified: true },
108
+ });
109
+ });
110
+ });
111
+
112
+ describe("resolveDefaultTenantForOperator delegates to isPinnedOperator (#7523 AC-4)", () => {
113
+ for (const { label, pin, user } of REFUSALS) {
114
+ it(`null when ${label}, with no deployment read, no Membership read and no write`, async () => {
115
+ if (pin !== undefined) process.env.AILK_OPERATOR_EMAIL = pin;
116
+ mockUser(user);
117
+
118
+ await expect(resolveDefaultTenantForOperator(CALLER)).resolves.toBeNull();
119
+ expect(prisma.workspace.count).not.toHaveBeenCalled();
120
+ expect(prisma.workspace.findUnique).not.toHaveBeenCalled();
121
+ expect(prisma.membership.findUnique).not.toHaveBeenCalled();
122
+ expect(prisma.membership.create).not.toHaveBeenCalled();
123
+ });
124
+ }
125
+
126
+ it("the pinned, verified operator reaches resolveDefaultTenant and resolves the default tenant as before", async () => {
127
+ process.env.AILK_OPERATOR_EMAIL = "op@x.io";
128
+ mockUser({ email: "op@x.io", emailVerified: true });
129
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(1);
130
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
131
+ id: DEFAULT_WORKSPACE_ID,
132
+ organizationId: DEFAULT_ORG_ID,
133
+ });
134
+ (prisma.membership.create as jest.Mock).mockResolvedValue({});
135
+ (prisma.membership.findUnique as jest.Mock).mockResolvedValue({
136
+ role: "owner",
137
+ });
138
+
139
+ await expect(resolveDefaultTenantForOperator(CALLER)).resolves.toEqual({
140
+ userId: CALLER,
141
+ organizationId: DEFAULT_ORG_ID,
142
+ workspaceId: DEFAULT_WORKSPACE_ID,
143
+ role: "owner",
144
+ });
145
+ expect(prisma.membership.findUnique).toHaveBeenCalledTimes(1);
146
+ });
147
+ });
@@ -311,3 +311,22 @@ describe("notifyRevalidate", () => {
311
311
  });
312
312
  });
313
313
  });
314
+
315
+ describe("logger environment (#7502)", () => {
316
+ it.each(["production", "development"])(
317
+ "builds its logger for NODE_ENV=%s, as the api's main logger does",
318
+ (nodeEnv) => {
319
+ process.env.NODE_ENV = nodeEnv;
320
+
321
+ loadService();
322
+
323
+ const { createLogger } = require("@working-theory/observability") as {
324
+ createLogger: jest.Mock;
325
+ };
326
+ expect(createLogger).toHaveBeenCalledWith({
327
+ service: "revalidation",
328
+ environment: nodeEnv,
329
+ });
330
+ },
331
+ );
332
+ });