create-ailk 0.4.0 → 0.5.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.
- package/component-catalog.json +11939 -0
- package/component-catalog.md +176 -64
- package/dist/cli.js +29 -1
- package/dist/derive-architecture.js +5 -2
- package/dist/lib/extract-module-bundle.d.ts +50 -2
- package/dist/lib/extract-module-bundle.js +223 -15
- package/dist/lib/fetch-guard.d.ts +28 -0
- package/dist/lib/fetch-guard.js +101 -0
- package/dist/lib/fetch-module.js +4 -0
- package/dist/lib/fetch-template.js +4 -0
- package/dist/module-architecture.d.ts +59 -8
- package/dist/module-architecture.js +334 -115
- package/dist/parse-args.js +6 -2
- package/dist/programmatic.d.ts +10 -0
- package/dist/programmatic.js +134 -19
- package/dist/site-chrome.d.ts +80 -0
- package/dist/site-chrome.js +135 -0
- package/dist/surfaces.d.ts +21 -6
- package/dist/surfaces.js +107 -32
- package/dist/template-manifest.d.ts +55 -3
- package/dist/template-manifest.js +181 -12
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +1 -0
- package/package.json +9 -2
- package/site-templates/_base/ailk-template.json +6 -0
- package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
- package/site-templates/marketing-site/ailk-template.json +11 -0
- package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
- package/site-templates/marketing-site/provenance.json +4 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
- package/site-templates/marketing-site-free/ailk-template.json +2 -1
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
- package/site-templates/minimalism-site/ailk-template.json +21 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
- package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
- package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
- package/site-templates/minimalism-site/provenance.json +4 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
- package/site-templates/saas-product/ailk-template.json +16 -0
- package/site-templates/saas-product/provenance.json +10 -0
- package/templates/.claude/skills/README.md +16 -25
- package/templates/.env.example +32 -7
- package/templates/.github/workflows/ci.yml +48 -0
- package/templates/README.md +3 -1
- package/templates/apps/api/CLAUDE.md +4 -1
- package/templates/apps/api/eslint.config.mjs +69 -0
- package/templates/apps/api/package.json +6 -2
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +8 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
- package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
- package/templates/apps/api/src/routes/leads/list.ts +104 -0
- package/templates/apps/api/src/server.ts +82 -6
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
- package/templates/apps/api/src/services/default-tenant.ts +261 -0
- package/templates/apps/api/src/services/lead-promotion.ts +161 -0
- package/templates/apps/api/src/services/leads-inbox.ts +263 -0
- package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
- package/templates/apps/mcp/CLAUDE.md +1 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
- package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
- package/templates/apps/mcp/eslint.config.mjs +69 -0
- package/templates/apps/mcp/package.json +6 -2
- package/templates/apps/mcp/src/server.ts +2 -2
- package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
- package/templates/apps/mcp/src/tools/index.ts +2 -2
- package/templates/apps/web/.env.example +3 -3
- package/templates/apps/web/CLAUDE.md +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +214 -17
- package/templates/apps/web/components/google-analytics.tsx +18 -1
- package/templates/apps/web/eslint.config.mjs +69 -0
- package/templates/apps/web/jest.config.cjs +7 -15
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
- package/templates/apps/web/lib/nav-config.ts +27 -7
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/site-chrome.ts +29 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
- package/templates/apps/web/messages/en.json +29 -2
- package/templates/apps/web/next.config.mjs +20 -0
- package/templates/apps/web/package.json +6 -2
- package/templates/apps/web/sentry.client.config.ts +131 -0
- package/templates/apps/web/sentry.edge.config.ts +72 -0
- package/templates/apps/web/sentry.server.config.ts +72 -0
- package/templates/apps/web/src/lib/routes.ts +13 -18
- package/templates/brand_assets/README.md +24 -0
- package/templates/content/en/AboutPage/about.mdx +160 -15
- package/templates/content/en/ContactPage/contact.mdx +90 -13
- package/templates/content/en/FAQPage/faq.mdx +62 -22
- package/templates/content/en/HomePage/home.mdx +244 -20
- package/templates/content/en/LandingPage/blog.mdx +83 -0
- package/templates/content/en/LandingPage/features.mdx +103 -0
- package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/__tests__/seed.test.ts +1 -1
- package/templates/database/eslint.config.mjs +69 -0
- package/templates/database/package.json +7 -3
- package/templates/database/seed.ts +1 -1
- package/templates/package.json +1 -14
- package/versions.json +52 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
- /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
- /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
- /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file list.test.ts
|
|
3
|
+
* Route-level inject tests for `GET /v1/leads` (#5678).
|
|
4
|
+
*
|
|
5
|
+
* The scoping proof lives one layer down, in
|
|
6
|
+
* `src/services/__tests__/leads-inbox.test.ts` — it seeds two sites against a
|
|
7
|
+
* realistically filtering prisma mock and asserts a caller for one reads none
|
|
8
|
+
* of the other's. This file covers what only the HTTP boundary can show: that
|
|
9
|
+
* an unauthenticated caller is refused before the service is ever reached,
|
|
10
|
+
* that the scope cannot be widened from the query string, and that the
|
|
11
|
+
* envelope and the 400 mapping are what `apps/api/CLAUDE.md` specifies.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line no-restricted-syntax -- the service is this route's own collaborator; mocking it keeps these tests on the HTTP boundary (the scoping proof is the service's own test).
|
|
15
|
+
jest.mock("../../../services/leads-inbox.js", () => ({
|
|
16
|
+
listOwnedLeads: jest.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import type { FastifyInstance } from "fastify";
|
|
20
|
+
|
|
21
|
+
import { listOwnedLeads } from "../../../services/leads-inbox.js";
|
|
22
|
+
import { leadListRoute } from "../list";
|
|
23
|
+
|
|
24
|
+
const listOwnedLeadsMock = listOwnedLeads as jest.Mock;
|
|
25
|
+
|
|
26
|
+
const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
|
|
27
|
+
const PAGE = {
|
|
28
|
+
items: [
|
|
29
|
+
{
|
|
30
|
+
id: "lead_00000000-0000-7000-8000-0000000000d1",
|
|
31
|
+
name: "Ada Lovelace",
|
|
32
|
+
email: "ada@example.com",
|
|
33
|
+
source: "web",
|
|
34
|
+
createdAt: "2026-09-15T12:00:00.000Z",
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
nextCursor: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Builds the route with `request.userId` pre-set, standing in for the
|
|
42
|
+
* `authPreHandlerPlugin` that decorates it in `server.ts`'s `authScope`.
|
|
43
|
+
* `userId: undefined` is the anonymous case — the same state the decorator
|
|
44
|
+
* leaves behind when no session resolves.
|
|
45
|
+
*/
|
|
46
|
+
async function buildApp(userId: string | undefined): Promise<FastifyInstance> {
|
|
47
|
+
const fastify = require("fastify")();
|
|
48
|
+
fastify.decorateRequest("userId", undefined);
|
|
49
|
+
fastify.addHook("onRequest", (request: { userId?: string }, _reply: unknown, done: () => void) => {
|
|
50
|
+
request.userId = userId;
|
|
51
|
+
done();
|
|
52
|
+
});
|
|
53
|
+
await fastify.register(leadListRoute);
|
|
54
|
+
return fastify;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("GET /v1/leads", () => {
|
|
58
|
+
let app: FastifyInstance;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
jest.clearAllMocks();
|
|
62
|
+
listOwnedLeadsMock.mockResolvedValue({ ok: true, value: PAGE });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
await app.close();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("401s an unauthenticated caller without touching the read service", async () => {
|
|
70
|
+
app = await buildApp(undefined);
|
|
71
|
+
|
|
72
|
+
const res = await app.inject({ method: "GET", url: "/v1/leads" });
|
|
73
|
+
|
|
74
|
+
expect(res.statusCode).toBe(401);
|
|
75
|
+
expect(res.json()).toEqual({
|
|
76
|
+
ok: false,
|
|
77
|
+
error: { code: "unauthorized", message: "Authentication required" },
|
|
78
|
+
});
|
|
79
|
+
expect(listOwnedLeadsMock).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns the page in the { ok: true, data } envelope", async () => {
|
|
83
|
+
app = await buildApp(USER_ID);
|
|
84
|
+
|
|
85
|
+
const res = await app.inject({ method: "GET", url: "/v1/leads" });
|
|
86
|
+
|
|
87
|
+
expect(res.statusCode).toBe(200);
|
|
88
|
+
expect(res.json()).toEqual({ ok: true, data: PAGE });
|
|
89
|
+
expect(listOwnedLeadsMock).toHaveBeenCalledWith({
|
|
90
|
+
userId: USER_ID,
|
|
91
|
+
cursor: undefined,
|
|
92
|
+
limit: 50,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("ignores a caller-supplied siteId — the scope cannot be widened from the wire", async () => {
|
|
97
|
+
app = await buildApp(USER_ID);
|
|
98
|
+
|
|
99
|
+
const res = await app.inject({
|
|
100
|
+
method: "GET",
|
|
101
|
+
url: "/v1/leads?siteId=someone-elses-site&workspaceId=another",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(res.statusCode).toBe(200);
|
|
105
|
+
// The service is called with the caller's identity and the paging
|
|
106
|
+
// parameters only — no site, org or workspace value reaches it.
|
|
107
|
+
expect(listOwnedLeadsMock).toHaveBeenCalledWith({
|
|
108
|
+
userId: USER_ID,
|
|
109
|
+
cursor: undefined,
|
|
110
|
+
limit: 50,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("400s a malformed cursor before any read", async () => {
|
|
115
|
+
app = await buildApp(USER_ID);
|
|
116
|
+
|
|
117
|
+
const res = await app.inject({ method: "GET", url: "/v1/leads?cursor=not-a-lead-id" });
|
|
118
|
+
|
|
119
|
+
expect(res.statusCode).toBe(400);
|
|
120
|
+
expect(res.json().error.code).toBe("validation_failed");
|
|
121
|
+
expect(listOwnedLeadsMock).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("400s a limit above the 200 ceiling", async () => {
|
|
125
|
+
app = await buildApp(USER_ID);
|
|
126
|
+
|
|
127
|
+
const res = await app.inject({ method: "GET", url: "/v1/leads?limit=1000" });
|
|
128
|
+
|
|
129
|
+
expect(res.statusCode).toBe(400);
|
|
130
|
+
expect(listOwnedLeadsMock).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("maps the service's invalid_cursor to a 400, not a 404 or a silent page 1", async () => {
|
|
134
|
+
app = await buildApp(USER_ID);
|
|
135
|
+
listOwnedLeadsMock.mockResolvedValue({
|
|
136
|
+
ok: false,
|
|
137
|
+
error: { kind: "invalid_cursor", message: "cursor does not resolve within this scope" },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const res = await app.inject({
|
|
141
|
+
method: "GET",
|
|
142
|
+
url: "/v1/leads?cursor=lead_00000000-0000-7000-8000-0000000000d9",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(res.statusCode).toBe(400);
|
|
146
|
+
expect(res.json()).toEqual({
|
|
147
|
+
ok: false,
|
|
148
|
+
error: {
|
|
149
|
+
code: "validation_failed",
|
|
150
|
+
message: "cursor does not resolve within this scope",
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("500s an internal error without surfacing its detail", async () => {
|
|
156
|
+
app = await buildApp(USER_ID);
|
|
157
|
+
listOwnedLeadsMock.mockResolvedValue({
|
|
158
|
+
ok: false,
|
|
159
|
+
error: { kind: "internal_error", message: "connect ECONNREFUSED 127.0.0.1:5432" },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const res = await app.inject({ method: "GET", url: "/v1/leads" });
|
|
163
|
+
|
|
164
|
+
expect(res.statusCode).toBe(500);
|
|
165
|
+
expect(res.json()).toEqual({
|
|
166
|
+
ok: false,
|
|
167
|
+
error: { code: "internal_error", message: "Failed to read leads" },
|
|
168
|
+
});
|
|
169
|
+
expect(res.body).not.toContain("ECONNREFUSED");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The leads INBOX read (#5678).
|
|
3
|
+
*
|
|
4
|
+
* Routes:
|
|
5
|
+
* GET /v1/leads?cursor=&limit= — the caller's own sites' leads, newest first
|
|
6
|
+
*
|
|
7
|
+
* Registered INSIDE `server.ts`'s `authScope` (auth only — NOT the inner
|
|
8
|
+
* `tenantScope`, which the multi-tenant module owns), beside
|
|
9
|
+
* `/v1/waitlist-signups/me` and `/v1/project-listings/me`. Lead capture is a
|
|
10
|
+
* never-gate surface at every level, so reading a lead back must be too: this
|
|
11
|
+
* route is registered unconditionally, not behind an `isModuleEnabled()` gate.
|
|
12
|
+
* Registering everywhere is not the same as RETURNING a row everywhere —
|
|
13
|
+
* `services/leads-inbox.ts` § "What it takes for this to return anything"
|
|
14
|
+
* names the two deployment preconditions, both of which fail empty, never
|
|
15
|
+
* wide.
|
|
16
|
+
*
|
|
17
|
+
* Identity is `request.userId` ONLY. There is no site, org or workspace
|
|
18
|
+
* parameter — the query schema declares `cursor` and `limit` and nothing else
|
|
19
|
+
* — so the scope cannot be widened from the wire. `services/leads-inbox.ts`
|
|
20
|
+
* resolves it from the caller's own memberships through
|
|
21
|
+
* `services/owner-workspaces.ts`'s `resolveOwnerWorkspaces` and filters every
|
|
22
|
+
* read on `siteId ∈ workspaceIds`.
|
|
23
|
+
*
|
|
24
|
+
* Envelope per apps/api/CLAUDE.md: `{ ok: true, data }` /
|
|
25
|
+
* `{ ok: false, error: { code, message } }`, real HTTP status codes:
|
|
26
|
+
* 400 validation_failed · 401 unauthorized · 500 internal_error.
|
|
27
|
+
*
|
|
28
|
+
* No response-contract re-parse (the `waitlist-experiments/respond.ts`
|
|
29
|
+
* `sendValidated` net): the five fields of a row are assembled one by one in
|
|
30
|
+
* `leads-inbox.ts` from five explicitly selected columns, with no
|
|
31
|
+
* pass-through of a stored value that could carry an unexpected shape — so
|
|
32
|
+
* there is no drift for that net to catch.
|
|
33
|
+
*
|
|
34
|
+
* API↔MCP parity: identity-bound (the session IS the scope; the OSS MCP
|
|
35
|
+
* surface is identity-blind) — no MCP twin (#3591 exception, the same shape as
|
|
36
|
+
* `/v1/workspaces/*`, `/v1/billing/*` and `/v1/waitlist-experiments/*`).
|
|
37
|
+
*/
|
|
38
|
+
import { zLeadId } from "@working-theory/ids";
|
|
39
|
+
import type { FastifyPluginAsync, FastifyReply } from "fastify";
|
|
40
|
+
import { z } from "zod";
|
|
41
|
+
|
|
42
|
+
import { listOwnedLeads } from "../../services/leads-inbox.js";
|
|
43
|
+
|
|
44
|
+
const DEFAULT_LIMIT = 50;
|
|
45
|
+
const MAX_LIMIT = 200;
|
|
46
|
+
|
|
47
|
+
const emptyToUndefined = (val: unknown) => (val === "" ? undefined : val);
|
|
48
|
+
|
|
49
|
+
/** Boundary validation, mirroring `waitlist-experiments/signups.ts`'s query schema. */
|
|
50
|
+
const listQuerySchema = z.object({
|
|
51
|
+
cursor: z.preprocess(emptyToUndefined, zLeadId.optional()),
|
|
52
|
+
limit: z.preprocess(
|
|
53
|
+
emptyToUndefined,
|
|
54
|
+
z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
|
|
55
|
+
),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function unauthorized(reply: FastifyReply) {
|
|
59
|
+
return reply.status(401).send({
|
|
60
|
+
ok: false,
|
|
61
|
+
error: { code: "unauthorized", message: "Authentication required" },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function invalidQuery(reply: FastifyReply, message: string) {
|
|
66
|
+
return reply.status(400).send({
|
|
67
|
+
ok: false,
|
|
68
|
+
error: { code: "validation_failed", message },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const leadListRoute: FastifyPluginAsync = async (fastify) => {
|
|
73
|
+
fastify.get("/v1/leads", async (request, reply) => {
|
|
74
|
+
const userId = request.userId;
|
|
75
|
+
if (!userId) return unauthorized(reply);
|
|
76
|
+
|
|
77
|
+
const query = listQuerySchema.safeParse(request.query);
|
|
78
|
+
if (!query.success) return invalidQuery(reply, "Invalid query parameters");
|
|
79
|
+
|
|
80
|
+
const result = await listOwnedLeads({
|
|
81
|
+
userId,
|
|
82
|
+
cursor: query.data.cursor,
|
|
83
|
+
limit: query.data.limit ?? DEFAULT_LIMIT,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!result.ok) {
|
|
87
|
+
if (result.error.kind === "invalid_cursor") {
|
|
88
|
+
return invalidQuery(reply, result.error.message);
|
|
89
|
+
}
|
|
90
|
+
// Do not surface internal error detail — log it server-side only, and
|
|
91
|
+
// with no lead field in the log line (the leads PII boundary).
|
|
92
|
+
request.log.error(
|
|
93
|
+
{ route: "leads/list", error: result.error.message },
|
|
94
|
+
"leads list failed",
|
|
95
|
+
);
|
|
96
|
+
return reply.status(500).send({
|
|
97
|
+
ok: false,
|
|
98
|
+
error: { code: "internal_error", message: "Failed to read leads" },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return reply.status(200).send({ ok: true, data: result.value });
|
|
103
|
+
});
|
|
104
|
+
};
|
|
@@ -20,6 +20,7 @@ import { registerErrorSerializer } from "./lib/error-serializer.js";
|
|
|
20
20
|
import { validateLeadCaptureDir } from "./lib/lead-capture-store.js";
|
|
21
21
|
import { loadSlice } from "./lib/slice-load.js";
|
|
22
22
|
import { authPreHandlerPlugin } from "./middleware/auth.js";
|
|
23
|
+
import { defaultTenantPreHandlerPlugin } from "./middleware/default-tenant.js";
|
|
23
24
|
import {
|
|
24
25
|
rateLimitMiddleware,
|
|
25
26
|
DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
leadSubmitRoute,
|
|
50
51
|
leadSubscribeRoute,
|
|
51
52
|
} from "./routes/leads/index.js";
|
|
53
|
+
import { leadListRoute } from "./routes/leads/list.js";
|
|
52
54
|
import {
|
|
53
55
|
registerTestAuthRoute,
|
|
54
56
|
shouldMountTestAuth,
|
|
@@ -334,6 +336,27 @@ async function tenantPreHandlerSlicePlugin(): Promise<FastifyPluginCallback> {
|
|
|
334
336
|
return plugin;
|
|
335
337
|
}
|
|
336
338
|
|
|
339
|
+
/**
|
|
340
|
+
* The tenant guard THIS build runs (#5677). Exactly one of the two, never
|
|
341
|
+
* both: the multi-tenant module's `resolveActiveTenant` guard when the module
|
|
342
|
+
* is enabled, and the base-build default-workspace guard
|
|
343
|
+
* (`middleware/default-tenant.ts`) otherwise.
|
|
344
|
+
*
|
|
345
|
+
* Precedence, not parallelism. On a build WITH the module this returns the
|
|
346
|
+
* identical plugin every tenant scope registered before, so multi-tenant
|
|
347
|
+
* resolution and behavior are unchanged. On a build without it, the base guard
|
|
348
|
+
* resolves the seeded default Workspace (`database/tenancy.ts`) under the same
|
|
349
|
+
* default-deny posture — which is what makes the already-built review
|
|
350
|
+
* dashboard reachable at Pro instead of leaving its routes unregistered.
|
|
351
|
+
*
|
|
352
|
+
* The base guard is a STATIC import, not a slice load: it always ships.
|
|
353
|
+
*/
|
|
354
|
+
async function tenantPreHandlerForBuild(): Promise<FastifyPluginCallback> {
|
|
355
|
+
return isModuleEnabled("multi-tenant")
|
|
356
|
+
? await tenantPreHandlerSlicePlugin()
|
|
357
|
+
: defaultTenantPreHandlerPlugin;
|
|
358
|
+
}
|
|
359
|
+
|
|
337
360
|
/**
|
|
338
361
|
* The commerce boot-time sandbox guard — the one other slice entry that is
|
|
339
362
|
* not a route plugin, mirroring `tenantPreHandlerSlicePlugin()` above (D3).
|
|
@@ -527,6 +550,18 @@ export async function build() {
|
|
|
527
550
|
);
|
|
528
551
|
}
|
|
529
552
|
|
|
553
|
+
// The leads INBOX read (#5678) — auth only, NOT tenant-guarded, for the
|
|
554
|
+
// same reason as the three reads below: the tenant scope is registered
|
|
555
|
+
// only when the multi-tenant module is present, and lead capture is a
|
|
556
|
+
// never-gate surface at EVERY level, so reading a lead back cannot be
|
|
557
|
+
// gated on a module either. Registered unconditionally — it is the one
|
|
558
|
+
// route in this scope with no `isModuleEnabled()` gate. Identity is
|
|
559
|
+
// `request.userId`; the scope is resolved from the caller's own
|
|
560
|
+
// memberships by `services/leads-inbox.ts` through
|
|
561
|
+
// `services/owner-workspaces.ts`'s `resolveOwnerWorkspaces`, never from a
|
|
562
|
+
// request parameter.
|
|
563
|
+
await authScope.register(leadListRoute);
|
|
564
|
+
|
|
530
565
|
// Respondent signup read (#4731) — auth only, NOT tenant-guarded: a
|
|
531
566
|
// respondent has no workspace membership and the tenant guard is
|
|
532
567
|
// default-deny. Identity is request.userId; the service re-checks
|
|
@@ -580,6 +615,35 @@ export async function build() {
|
|
|
580
615
|
await tenantScope.register(await sliceRoutePlugin("sessionRoutes"));
|
|
581
616
|
// Org-billing routes (#3701) — org-scoped read + Stripe customer-portal.
|
|
582
617
|
await tenantScope.register(await sliceRoutePlugin("billingRoutes"));
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// REVIEW-SURFACE routes — a sibling tenant scope, gated on EITHER module
|
|
622
|
+
// (#5677). The waitlist operator dashboard and the project-listings
|
|
623
|
+
// dashboard were registered inside the multi-tenant scope above, which was
|
|
624
|
+
// too narrow: `signal-collectors` (Pro) is the module that SELLS the
|
|
625
|
+
// instruments these routes read back — flows, deliverables, project
|
|
626
|
+
// listings, waitlist experiments — so a Pro customer could collect
|
|
627
|
+
// submissions and not read them. That was an accident of registration
|
|
628
|
+
// scope, not a pricing decision (#5677). Widening the gate is the
|
|
629
|
+
// correction.
|
|
630
|
+
//
|
|
631
|
+
// This is a REGISTRATION change, not a tenant-isolation change. The scope
|
|
632
|
+
// registers `tenantPreHandlerForBuild()`, which on a multi-tenant build is
|
|
633
|
+
// byte-for-byte the guard these two route plugins already ran behind, and
|
|
634
|
+
// on a build without the module is the base-build guard with the same
|
|
635
|
+
// default-deny posture over the seeded default Workspace. Each plugin
|
|
636
|
+
// registers exactly once — they live here and nowhere else, so the scopes
|
|
637
|
+
// cannot collide on a path — and every OTHER route in the scope above
|
|
638
|
+
// (workspaceSessionRoutes, sessionRoutes, billingRoutes) stays
|
|
639
|
+
// multi-tenant-only: those are Organization/Workspace administration, not
|
|
640
|
+
// a site's own submissions.
|
|
641
|
+
if (
|
|
642
|
+
isModuleEnabled("multi-tenant") ||
|
|
643
|
+
isModuleEnabled("signal-collectors")
|
|
644
|
+
) {
|
|
645
|
+
await authScope.register(async (tenantScope) => {
|
|
646
|
+
await tenantScope.register(await tenantPreHandlerForBuild());
|
|
583
647
|
// Waitlist operator-dashboard read API (#4665 D7, B0) — tenant-scoped
|
|
584
648
|
// list/comparison/signups reads over A1's waitlist-experiment rows.
|
|
585
649
|
await tenantScope.register(
|
|
@@ -603,18 +667,30 @@ export async function build() {
|
|
|
603
667
|
// original mis-scoping twice.
|
|
604
668
|
//
|
|
605
669
|
// This is a REGISTRATION change, not a tenant-isolation change: the scope
|
|
606
|
-
// registers its own `
|
|
607
|
-
// guard the
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
670
|
+
// registers its own `tenantPreHandlerForBuild()`, the same default-deny
|
|
671
|
+
// guard the scopes above use. The remaining routes in the multi-tenant
|
|
672
|
+
// scope above (workspaceSessionRoutes, sessionRoutes, billingRoutes) stay
|
|
673
|
+
// multi-tenant-only.
|
|
674
|
+
//
|
|
675
|
+
// #5677's selector fixes ONE of this scope's two stripped-scaffold
|
|
676
|
+
// problems and not the other, so do not read it as making a
|
|
677
|
+
// `--modules workspace` scaffold work. It removes the guard's dependency
|
|
678
|
+
// on `./middleware/tenant.js` (a multi-tenant slice file, which threw
|
|
679
|
+
// ERR_MODULE_NOT_FOUND during construction). The three route plugins
|
|
680
|
+
// below are still reached through slice loads, and
|
|
681
|
+
// `routes/workspaces/config.ts` remains a multi-tenant `ownedFiles` entry
|
|
682
|
+
// — `general.ts` and `members.ts` are workspace-owned, `config.ts` is not
|
|
683
|
+
// — so that scaffold still has no `routes/workspaces/` directory and
|
|
684
|
+
// still throws here. Fixing it is the same one-line class of change #5677
|
|
685
|
+
// made for the review surface: co-own `config.ts` under `workspace` in
|
|
686
|
+
// MODULE_SLICES. Out of scope for #5677, which owns the review surface.
|
|
611
687
|
//
|
|
612
688
|
// A build with BOTH modules enabled registers each of the three route
|
|
613
689
|
// plugins exactly once: they live here and nowhere else, so the two
|
|
614
690
|
// scopes cannot collide on a path.
|
|
615
691
|
if (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")) {
|
|
616
692
|
await authScope.register(async (tenantScope) => {
|
|
617
|
-
await tenantScope.register(await
|
|
693
|
+
await tenantScope.register(await tenantPreHandlerForBuild());
|
|
618
694
|
await tenantScope.register(
|
|
619
695
|
await sliceRoutePlugin("workspaceConfigRoutes"),
|
|
620
696
|
);
|