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
|
@@ -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 {
|
|
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 {
|
|
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
|
};
|
|
@@ -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),
|
|
@@ -987,6 +1005,15 @@ export async function build() {
|
|
|
987
1005
|
await tenantScope.register(
|
|
988
1006
|
await sliceRoutePlugin("workspaceMessageRoutes"),
|
|
989
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
|
+
);
|
|
990
1017
|
// The asset store's MANAGE routes — list, PATCH, DELETE (#6093 D16).
|
|
991
1018
|
// Registered HERE, in the plain tenant scope, and NOT inside the
|
|
992
1019
|
// upload's own nested strict-rate-limit scope below: a JSON
|
|
@@ -1279,6 +1306,27 @@ export async function build() {
|
|
|
1279
1306
|
}
|
|
1280
1307
|
});
|
|
1281
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
|
+
|
|
1282
1330
|
// Flows routes — anonymous surface; rate-limit middleware scoped to this
|
|
1283
1331
|
// sub-router, matching the leads scope above (#3956 D4 Clarifications:
|
|
1284
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
|
+
});
|