create-ailk 0.3.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 +48 -10
- package/dist/derive-architecture.js +5 -2
- package/dist/lib/extract-module-bundle.d.ts +88 -1
- package/dist/lib/extract-module-bundle.js +375 -80
- 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.d.ts +20 -0
- package/dist/lib/fetch-template.js +133 -0
- package/dist/lib/module-license-gate.d.ts +8 -0
- package/dist/lib/module-license-gate.js +12 -4
- package/dist/module-architecture.d.ts +116 -7
- package/dist/module-architecture.js +465 -94
- package/dist/parse-args.d.ts +8 -1
- package/dist/parse-args.js +49 -2
- package/dist/programmatic.d.ts +36 -1
- package/dist/programmatic.js +216 -5
- 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 +140 -23
- package/dist/template-manifest.d.ts +144 -0
- package/dist/template-manifest.js +337 -0
- package/dist/template-names.d.ts +41 -0
- package/dist/template-names.js +45 -0
- package/package.json +10 -2
- package/site-templates/_base/ailk-template.json +6 -0
- package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
- package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +79 -0
- package/site-templates/_base/content/en/TermsPage/terms.mdx +80 -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 +9 -0
- package/site-templates/marketing-site-free/provenance.json +4 -0
- 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 +173 -0
- package/templates/apps/api/src/config/modules.ts +1 -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 +122 -3
- 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/app/[locale]/pricing/page.tsx +40 -0
- package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
- package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
- 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 +12 -15
- package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
- 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 +29 -13
- 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 +86 -1
- 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 +21 -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/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,
|
|
@@ -130,6 +132,8 @@ import {
|
|
|
130
132
|
const WORKSPACES_INDEX = "./routes/workspaces/index.js";
|
|
131
133
|
const WORKSPACES_SESSIONS = "./routes/workspaces/sessions.js";
|
|
132
134
|
const WORKSPACES_CONFIG = "./routes/workspaces/config.js";
|
|
135
|
+
const WORKSPACES_GENERAL = "./routes/workspaces/general.js";
|
|
136
|
+
const WORKSPACES_MEMBERS = "./routes/workspaces/members.js";
|
|
133
137
|
const BILLING_INDEX = "./routes/billing/index.js";
|
|
134
138
|
const WAITLIST_EXPERIMENTS_INDEX = "./routes/waitlist-experiments/index.js";
|
|
135
139
|
const CONTENT_CREATE = "./routes/content/create.js";
|
|
@@ -171,6 +175,14 @@ const SLICE_PLUGINS = {
|
|
|
171
175
|
specifier: WORKSPACES_CONFIG,
|
|
172
176
|
load: () => import(WORKSPACES_CONFIG),
|
|
173
177
|
},
|
|
178
|
+
workspaceGeneralRoutes: {
|
|
179
|
+
specifier: WORKSPACES_GENERAL,
|
|
180
|
+
load: () => import(WORKSPACES_GENERAL),
|
|
181
|
+
},
|
|
182
|
+
workspaceMembersRoutes: {
|
|
183
|
+
specifier: WORKSPACES_MEMBERS,
|
|
184
|
+
load: () => import(WORKSPACES_MEMBERS),
|
|
185
|
+
},
|
|
174
186
|
billingRoutes: {
|
|
175
187
|
specifier: BILLING_INDEX,
|
|
176
188
|
load: () => import(BILLING_INDEX),
|
|
@@ -324,6 +336,27 @@ async function tenantPreHandlerSlicePlugin(): Promise<FastifyPluginCallback> {
|
|
|
324
336
|
return plugin;
|
|
325
337
|
}
|
|
326
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
|
+
|
|
327
360
|
/**
|
|
328
361
|
* The commerce boot-time sandbox guard — the one other slice entry that is
|
|
329
362
|
* not a route plugin, mirroring `tenantPreHandlerSlicePlugin()` above (D3).
|
|
@@ -517,6 +550,18 @@ export async function build() {
|
|
|
517
550
|
);
|
|
518
551
|
}
|
|
519
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
|
+
|
|
520
565
|
// Respondent signup read (#4731) — auth only, NOT tenant-guarded: a
|
|
521
566
|
// respondent has no workspace membership and the tenant guard is
|
|
522
567
|
// default-deny. Identity is request.userId; the service re-checks
|
|
@@ -568,11 +613,37 @@ export async function build() {
|
|
|
568
613
|
await sliceRoutePlugin("workspaceSessionRoutes"),
|
|
569
614
|
);
|
|
570
615
|
await tenantScope.register(await sliceRoutePlugin("sessionRoutes"));
|
|
571
|
-
await tenantScope.register(
|
|
572
|
-
await sliceRoutePlugin("workspaceConfigRoutes"),
|
|
573
|
-
);
|
|
574
616
|
// Org-billing routes (#3701) — org-scoped read + Stripe customer-portal.
|
|
575
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());
|
|
576
647
|
// Waitlist operator-dashboard read API (#4665 D7, B0) — tenant-scoped
|
|
577
648
|
// list/comparison/signups reads over A1's waitlist-experiment rows.
|
|
578
649
|
await tenantScope.register(
|
|
@@ -585,6 +656,54 @@ export async function build() {
|
|
|
585
656
|
await tenantScope.register(projectListingTenantRoutes);
|
|
586
657
|
});
|
|
587
658
|
}
|
|
659
|
+
|
|
660
|
+
// Workspace SETTINGS routes — a sibling tenant scope, gated on EITHER
|
|
661
|
+
// module (#5371 D2). The site-config routes were registered inside the
|
|
662
|
+
// multi-tenant scope above, which was too narrow: nothing in their logic
|
|
663
|
+
// is multi-tenant-specific, and since Phase 2 `resolveActiveTenant`
|
|
664
|
+
// resolves a single-tenant session's TenantContext from the default
|
|
665
|
+
// Organization/Workspace exactly like a multi-tenant one. Widening the
|
|
666
|
+
// gate is the correction; duplicating the route file would encode the
|
|
667
|
+
// original mis-scoping twice.
|
|
668
|
+
//
|
|
669
|
+
// This is a REGISTRATION change, not a tenant-isolation change: the scope
|
|
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.
|
|
687
|
+
//
|
|
688
|
+
// A build with BOTH modules enabled registers each of the three route
|
|
689
|
+
// plugins exactly once: they live here and nowhere else, so the two
|
|
690
|
+
// scopes cannot collide on a path.
|
|
691
|
+
if (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")) {
|
|
692
|
+
await authScope.register(async (tenantScope) => {
|
|
693
|
+
await tenantScope.register(await tenantPreHandlerForBuild());
|
|
694
|
+
await tenantScope.register(
|
|
695
|
+
await sliceRoutePlugin("workspaceConfigRoutes"),
|
|
696
|
+
);
|
|
697
|
+
// The Pro workspace module's own two routes (#5371): the workspace
|
|
698
|
+
// name, and the read-only member roster.
|
|
699
|
+
await tenantScope.register(
|
|
700
|
+
await sliceRoutePlugin("workspaceGeneralRoutes"),
|
|
701
|
+
);
|
|
702
|
+
await tenantScope.register(
|
|
703
|
+
await sliceRoutePlugin("workspaceMembersRoutes"),
|
|
704
|
+
);
|
|
705
|
+
});
|
|
706
|
+
}
|
|
588
707
|
});
|
|
589
708
|
|
|
590
709
|
// Public routes — no auth required.
|