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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file lead-promotion.ts
|
|
3
|
+
* @description Lead → user promotion across the inbox/auth namespace boundary
|
|
4
|
+
* (ADR 0009 D3 service-layer pattern; spec
|
|
5
|
+
* docs/specs/3586-lead-user-promotion-spec.md).
|
|
6
|
+
*
|
|
7
|
+
* Reconciles an anonymous inbox.Lead with an auth.User by stable email, once
|
|
8
|
+
* the reconciling user's own email is verified (D2 — the consent basis for
|
|
9
|
+
* linking anonymous-lead PII to a user) and only within that user's own
|
|
10
|
+
* tenant workspaces (D3 — the tenant-safety invariant). Workspace resolution
|
|
11
|
+
* is delegated to `owner-workspaces.ts` — the one site→workspace resolution in
|
|
12
|
+
* this app, which in turn delegates to the sole cross-namespace tenancy
|
|
13
|
+
* assembler — so this file never issues a raw membership or workspace model
|
|
14
|
+
* query of its own (architecture.yaml's tenant-scope forbidden pattern
|
|
15
|
+
* reserves those to the tenant-context / tenant-db modules).
|
|
16
|
+
*
|
|
17
|
+
* Mirrors lead-routing.ts's shape: discriminated `{ ok } | { ok: false, error }`
|
|
18
|
+
* result, no throw on the expected (no-op) paths, PII-safe (never logs email /
|
|
19
|
+
* payload / name / phone).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Id } from "@working-theory/ids";
|
|
23
|
+
|
|
24
|
+
import { prisma } from "../lib/prisma.js";
|
|
25
|
+
|
|
26
|
+
import { resolveOwnerWorkspaces } from "./owner-workspaces.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Workspace resolution moved to `owner-workspaces.ts` (#5678) so the leads
|
|
30
|
+
* READ path can share it rather than write a second one. The module-
|
|
31
|
+
* conditional slice load, the zero-workspace semantics and the delegation to
|
|
32
|
+
* the tenancy assembler are unchanged — this file simply calls it now.
|
|
33
|
+
*
|
|
34
|
+
* It had to be a separate module rather than an export from here:
|
|
35
|
+
* `paidModuleTemplateExclusions()` drops every file that reaches a paid
|
|
36
|
+
* module's `ownedFiles` through the import graph, this file is inside that
|
|
37
|
+
* closure, and importing it from the leads read would have carried the
|
|
38
|
+
* closure all the way into `server.ts`. See `owner-workspaces.ts`'s own
|
|
39
|
+
* header.
|
|
40
|
+
*
|
|
41
|
+
* Semantics on a build without the multi-tenant module, CORRECTED by #5677
|
|
42
|
+
* and carried across the move. `services/default-tenant.ts` now resolves the
|
|
43
|
+
* seeded default Workspace on every build, so a module-less deployment DOES
|
|
44
|
+
* have a resolvable workspace — the shared resolver's empty result there is a
|
|
45
|
+
* deliberate scope limit, not a statement that there is nothing to resolve.
|
|
46
|
+
*
|
|
47
|
+
* PROMOTION specifically is not wired to that default resolution, and that is
|
|
48
|
+
* #5677's own disposition rather than an oversight of this move: single-tenant
|
|
49
|
+
* leads would start acquiring `promotedUserId`, which is what
|
|
50
|
+
* `/v1/flow-checkouts/me` and `/v1/waitlist-signups/me` join a respondent's own
|
|
51
|
+
* rows through — a respondent's `/account` would begin showing purchases where
|
|
52
|
+
* it shows none today. It also needs the deployment to pin `AILK_SITE_ID` to
|
|
53
|
+
* `DEFAULT_WORKSPACE_ID`, because `siteId` comes from `lib/site.ts`
|
|
54
|
+
* (`AILK_SITE_ID ?? "default"`) while `workspaceId` is the sentinel, and
|
|
55
|
+
* `siteId ∈ workspaceIds` only matches when those agree.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export type PromotedLead = {
|
|
61
|
+
leadId: string;
|
|
62
|
+
siteId: string;
|
|
63
|
+
source: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type PromoteLeadsResult =
|
|
67
|
+
| { ok: true; count: number; promoted: PromotedLead[] }
|
|
68
|
+
| {
|
|
69
|
+
ok: false;
|
|
70
|
+
error: { kind: "user_not_found" | "internal_error"; message: string };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Promote every un-promoted `inbox.Lead` matching the verifying user's own
|
|
77
|
+
* email, within that user's own tenant workspaces (D1/D3). No-ops
|
|
78
|
+
* (`count: 0`) — never throws — when: the user's email is unverified (D2),
|
|
79
|
+
* the user belongs to no workspace, or no matching un-promoted lead exists.
|
|
80
|
+
* Idempotent: a lead already carrying `promotedUserId` is excluded from the
|
|
81
|
+
* match set, so re-invocation promotes nothing (AC-6).
|
|
82
|
+
*/
|
|
83
|
+
export async function promoteLeadsForVerifiedUser(params: {
|
|
84
|
+
userId: string;
|
|
85
|
+
}): Promise<PromoteLeadsResult> {
|
|
86
|
+
const { userId } = params;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const user = await prisma.user.findUnique({
|
|
90
|
+
where: { id: userId },
|
|
91
|
+
select: { id: true, email: true, emailVerified: true },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!user) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: { kind: "user_not_found", message: "User not found" },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// D2 — the verified-email gate is a service-level invariant, not a
|
|
102
|
+
// caller obligation: it cannot be bypassed by a wrong call-site.
|
|
103
|
+
if (!user.emailVerified) {
|
|
104
|
+
return { ok: true, count: 0, promoted: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// D3/D4 — resolve the verifying user's OWN workspaces through the one
|
|
108
|
+
// shared resolution (`owner-workspaces.ts`). Role-agnostic here:
|
|
109
|
+
// promotion links a lead to the person whose verified email it carries,
|
|
110
|
+
// in any workspace they belong to, exactly as it did before the move.
|
|
111
|
+
// Without the multi-tenant module the shared resolver yields nothing and
|
|
112
|
+
// the early return below fires — a scope limit since #5677, not an
|
|
113
|
+
// absence of anything to resolve; see the header.
|
|
114
|
+
const workspaces = await resolveOwnerWorkspaces(user.id as Id<"user">);
|
|
115
|
+
if (!workspaces.ok) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: { kind: "internal_error", message: workspaces.error.message },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const workspaceIds = workspaces.value.map((workspace) => workspace.id);
|
|
122
|
+
|
|
123
|
+
if (workspaceIds.length === 0) {
|
|
124
|
+
return { ok: true, count: 0, promoted: [] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// D3/AC-4 (tenant-safety — load-bearing): the match set is siteId ∈ the
|
|
128
|
+
// user's OWN workspaceIds, so a lead captured under a different
|
|
129
|
+
// organization's workspace is never matched, by construction.
|
|
130
|
+
const candidates = await prisma.lead.findMany({
|
|
131
|
+
where: {
|
|
132
|
+
email: user.email,
|
|
133
|
+
siteId: { in: workspaceIds },
|
|
134
|
+
promotedUserId: null,
|
|
135
|
+
},
|
|
136
|
+
select: { id: true, siteId: true, source: true },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (candidates.length === 0) {
|
|
140
|
+
return { ok: true, count: 0, promoted: [] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await prisma.lead.updateMany({
|
|
144
|
+
where: { id: { in: candidates.map((lead) => lead.id) } },
|
|
145
|
+
data: { promotedUserId: user.id, promotedAt: new Date() },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
count: candidates.length,
|
|
151
|
+
promoted: candidates.map((lead) => ({
|
|
152
|
+
leadId: lead.id,
|
|
153
|
+
siteId: lead.siteId,
|
|
154
|
+
source: lead.source,
|
|
155
|
+
})),
|
|
156
|
+
};
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
159
|
+
return { ok: false, error: { kind: "internal_error", message } };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file leads-inbox.ts
|
|
3
|
+
* @description The owner-side READ over captured leads (#5678) — the other
|
|
4
|
+
* half of `lead-routing.ts`, which only ever wrote them.
|
|
5
|
+
*
|
|
6
|
+
* Scope is resolved server-side and never supplied by the caller: the reader's
|
|
7
|
+
* identity is `request.userId`, their workspaces come from
|
|
8
|
+
* `owner-workspaces.ts`'s `resolveOwnerWorkspaces` — the ONE site→workspace
|
|
9
|
+
* resolution in this app, shared with `lead-promotion.ts` — and every query
|
|
10
|
+
* filters `siteId ∈ workspaceIds`.
|
|
11
|
+
* There is no site/org parameter to widen, so cross-tenant enumeration is
|
|
12
|
+
* structurally impossible rather than merely checked — the same posture
|
|
13
|
+
* `routes/waitlist-experiments/list.ts` takes.
|
|
14
|
+
*
|
|
15
|
+
* The scope is the UNION of every workspace the caller owns or administers,
|
|
16
|
+
* across every organization they belong to — not one active tenant, the way
|
|
17
|
+
* `waitlist-dashboard.ts` scopes to `tenant.workspaceId`. That difference is
|
|
18
|
+
* deliberate and structural: this route registers outside the tenant scope
|
|
19
|
+
* (it must work on a build with no multi-tenant module), so there is no
|
|
20
|
+
* active tenant to scope to. `Membership` is an organization-level grant, so
|
|
21
|
+
* the union stays inside what the caller may already see. When a workspace
|
|
22
|
+
* switcher reaches this surface, it is the thing that will narrow it.
|
|
23
|
+
*
|
|
24
|
+
* ## What it takes for this to return anything
|
|
25
|
+
*
|
|
26
|
+
* Two preconditions, neither of which this file can assert:
|
|
27
|
+
*
|
|
28
|
+
* 1. `resolveOwnerWorkspaces` must yield a workspace. On a build with no
|
|
29
|
+
* `multi-tenant` module it yields none — see its own header, and #5677,
|
|
30
|
+
* which made that a deliberate scope limit rather than a statement that
|
|
31
|
+
* there is nothing to resolve.
|
|
32
|
+
* 2. The `siteId` the leads were WRITTEN under must equal a workspace id,
|
|
33
|
+
* and there are TWO independent sources of it — both must agree with the
|
|
34
|
+
* workspace, and neither is `currentSiteId()` on the primary path:
|
|
35
|
+
*
|
|
36
|
+
* - The three POST capture routes take `siteId` from the REQUEST BODY
|
|
37
|
+
* (`routes/leads/index.ts`), which the capture sections declare in
|
|
38
|
+
* content and default to `"default"` (`ContactForm`, `Newsletter`,
|
|
39
|
+
* `StepFlow` — `ext.siteId ?? "default"`). Neither
|
|
40
|
+
* `routes/leads/index.ts` nor `lead-routing.ts` calls
|
|
41
|
+
* `currentSiteId()` at all.
|
|
42
|
+
* - The recommender's capture path (`routes/flows/recommender.ts`) is
|
|
43
|
+
* the one that DOES use `currentSiteId()` (`lib/site.ts`,
|
|
44
|
+
* `AILK_SITE_ID ?? "default"`).
|
|
45
|
+
*
|
|
46
|
+
* A workspace id is `wksp_<uuidv7>`, so `siteId ∈ workspaceIds` only
|
|
47
|
+
* matches when the content-declared value — and, for recommender leads,
|
|
48
|
+
* `AILK_SITE_ID` — is set to it. This is the same convention
|
|
49
|
+
* `waitlist-dashboard.ts` already relies on ("owner scope =
|
|
50
|
+
* `siteId == tenant.workspaceId`"); it is a deployment and content fact,
|
|
51
|
+
* not something either resolver can supply. Naming only `AILK_SITE_ID`
|
|
52
|
+
* here would send an operator to the wrong knob for every contact-form,
|
|
53
|
+
* newsletter and callback lead (PR #5705 security re-review, finding A).
|
|
54
|
+
*
|
|
55
|
+
* Both failures are EMPTY, never wide: the inbox shows no leads rather than
|
|
56
|
+
* someone else's. Raised by the PR #5705 security review, finding 2.
|
|
57
|
+
*
|
|
58
|
+
* PII boundary: this service returns lead PII by design, so it returns the
|
|
59
|
+
* NARROWEST row the inbox view renders — `id`, `name`, `email`, `source`,
|
|
60
|
+
* `createdAt` — and nothing else. `payload`'s remaining free-form keys
|
|
61
|
+
* (message, phone, and whatever else a site's form declares), `siteId`,
|
|
62
|
+
* `promotedUserId` and `promotedAt` are never selected out of the row that
|
|
63
|
+
* leaves this file, and the one value lifted out of `payload` is truncated
|
|
64
|
+
* (see `MAX_NAME_LENGTH`). Logs carry no lead field at all.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import type { Prisma } from "@working-theory/database";
|
|
68
|
+
import type { Id } from "@working-theory/ids";
|
|
69
|
+
|
|
70
|
+
import { prisma } from "../lib/prisma.js";
|
|
71
|
+
|
|
72
|
+
import { resolveOwnerWorkspaces } from "./owner-workspaces.js";
|
|
73
|
+
|
|
74
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** One row of the inbox — exactly the five values the table renders. */
|
|
77
|
+
export type LeadInboxRow = {
|
|
78
|
+
/** The lead id: the row key, and the cursor for the page after this one. */
|
|
79
|
+
id: string;
|
|
80
|
+
/**
|
|
81
|
+
* The contact's name, lifted from `payload.name` when the site's form
|
|
82
|
+
* captured one. `null` otherwise — `payload` is a free-form record
|
|
83
|
+
* (`boundedLeadPayload`), so a site whose form declares no name field has
|
|
84
|
+
* none to show.
|
|
85
|
+
*/
|
|
86
|
+
name: string | null;
|
|
87
|
+
email: string;
|
|
88
|
+
/** Which capture produced the lead (`"web"`, `"conversational-intake"`, …). */
|
|
89
|
+
source: string;
|
|
90
|
+
/** ISO 8601. */
|
|
91
|
+
createdAt: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type LeadInboxPage = {
|
|
95
|
+
items: LeadInboxRow[];
|
|
96
|
+
/** The `cursor` for the next page, or `null` when this is the last one. */
|
|
97
|
+
nextCursor: string | null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type ListLeadsErrorKind = "invalid_cursor" | "internal_error";
|
|
101
|
+
export type ListLeadsResult =
|
|
102
|
+
| { ok: true; value: LeadInboxPage }
|
|
103
|
+
| { ok: false; error: { kind: ListLeadsErrorKind; message: string } };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Reads require `owner` or `admin` in the organization the workspace belongs
|
|
107
|
+
* to — the inbox exposes contact PII, so membership alone is not
|
|
108
|
+
* authorization. Same floor, and the same reason, as
|
|
109
|
+
* `WAITLIST_DASHBOARD_ROLES` (`waitlist-dashboard.ts`); declared here rather
|
|
110
|
+
* than imported because that file is a `signal-collectors` slice the strip
|
|
111
|
+
* removes and this one ships at every level.
|
|
112
|
+
*/
|
|
113
|
+
const LEAD_INBOX_ROLES: ReadonlySet<string> = new Set(["owner", "admin"]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The ONLY columns read out of the Lead table on this path.
|
|
117
|
+
*
|
|
118
|
+
* `payload` is here because Prisma cannot `select` a JSON sub-path and
|
|
119
|
+
* `name` lives inside it — so the whole blob crosses the DB→API boundary for
|
|
120
|
+
* each of the `limit + 1` rows and is narrowed in this process, not by the
|
|
121
|
+
* query. Nothing but `payload.name` leaves this file (see `readName`), but the
|
|
122
|
+
* READ is the full record: the memory cost of a page is `(limit + 1) ×
|
|
123
|
+
* payload size`, independent of what is returned. Bounded above by Fastify's
|
|
124
|
+
* 1 MiB body limit on the write side and by `MAX_LIMIT` on this one.
|
|
125
|
+
*/
|
|
126
|
+
const LEAD_INBOX_SELECT = {
|
|
127
|
+
id: true,
|
|
128
|
+
email: true,
|
|
129
|
+
source: true,
|
|
130
|
+
payload: true,
|
|
131
|
+
createdAt: true,
|
|
132
|
+
} as const satisfies Prisma.LeadSelect;
|
|
133
|
+
|
|
134
|
+
// ─── Row shaping ──────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The rendered `name` is truncated to this many characters. The write side
|
|
138
|
+
* bounds `payload` at 64 KEYS and nothing else (`boundedLeadPayload`,
|
|
139
|
+
* `packages/validation/src/schemas/leads.ts`) — no per-value length — so a
|
|
140
|
+
* `name` arrives with only Fastify's 1 MiB body limit above it, while every
|
|
141
|
+
* other rendered field is bounded at the schema (`email` 254, `source` 64).
|
|
142
|
+
*
|
|
143
|
+
* That gap is reachable by an anonymous caller: `siteId` is content-declared
|
|
144
|
+
* and shipped to the browser by the capture forms, `POST /v1/leads/submit`
|
|
145
|
+
* performs no ownership check on it, and the leads-scope rate limit keys on
|
|
146
|
+
* `req.ip` under instance-wide `trustProxy` (which `routes/leads/index.ts`
|
|
147
|
+
* documents as attacker-influenceable). Fifty-one ~900 KB names would
|
|
148
|
+
* otherwise be a ~45 MB response — and, because the order is `createdAt desc`
|
|
149
|
+
* and this PR ships no delete surface, permanently page one. Truncating here
|
|
150
|
+
* is what makes the inbox's size a function of the page size rather than of
|
|
151
|
+
* what a stranger typed. Raised by the PR #5705 security review, finding 1.
|
|
152
|
+
*
|
|
153
|
+
* 200 is the same order of magnitude as `callbackSchema`'s own declared
|
|
154
|
+
* `name` bound (128) with room for a long real one. Bounding `payload`'s
|
|
155
|
+
* string VALUES at the write boundary would fix this for every future reader,
|
|
156
|
+
* but that changes what the three POST routes accept, which this issue's
|
|
157
|
+
* scope fence forbids — so the bound lives on the read.
|
|
158
|
+
*/
|
|
159
|
+
const MAX_NAME_LENGTH = 200;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `payload` is JSON of a shape the site's own form decides, so reading one
|
|
163
|
+
* key out of it is a boundary read: anything that is not a string is `null`,
|
|
164
|
+
* and a string is truncated (see `MAX_NAME_LENGTH`).
|
|
165
|
+
*/
|
|
166
|
+
function readName(payload: Prisma.JsonValue): string | null {
|
|
167
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const name = (payload as Record<string, unknown>)["name"];
|
|
171
|
+
return typeof name === "string" ? name.slice(0, MAX_NAME_LENGTH) : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* List the leads the caller's own sites captured, newest first.
|
|
178
|
+
*
|
|
179
|
+
* Pagination is the cursor shape `waitlist-experiments/signups` already
|
|
180
|
+
* established in this codebase: `(createdAt desc, id desc)`, `take: limit + 1`
|
|
181
|
+
* to decide `hasMore`, and a `cursor` that is the previous page's last row id.
|
|
182
|
+
* A supplied cursor that does not resolve WITHIN the caller's scope is a
|
|
183
|
+
* client input error (`invalid_cursor` → 400), never a silent fall back to
|
|
184
|
+
* page 1 at THIS layer — the web inbox re-reads page one when a URL-supplied
|
|
185
|
+
* cursor 400s, which is its own rendering choice and leaves this contract
|
|
186
|
+
* intact (a foreign cursor and a nonexistent one stay indistinguishable
|
|
187
|
+
* either way) — and never a probe that distinguishes "another site's lead" from
|
|
188
|
+
* "no such lead", because the scope filter runs on the cursor lookup too.
|
|
189
|
+
*/
|
|
190
|
+
export async function listOwnedLeads(params: {
|
|
191
|
+
userId: Id<"user">;
|
|
192
|
+
cursor?: string | undefined;
|
|
193
|
+
limit: number;
|
|
194
|
+
}): Promise<ListLeadsResult> {
|
|
195
|
+
const workspaces = await resolveOwnerWorkspaces(params.userId);
|
|
196
|
+
if (!workspaces.ok) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
error: { kind: "internal_error", message: workspaces.error.message },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const siteIds = workspaces.value
|
|
204
|
+
.filter((workspace) => LEAD_INBOX_ROLES.has(workspace.role))
|
|
205
|
+
.map((workspace) => workspace.id);
|
|
206
|
+
|
|
207
|
+
// No workspace the caller owns or administers means no lead can match, so
|
|
208
|
+
// there is nothing to query. An empty page is the honest answer — the same
|
|
209
|
+
// zero-workspace no-op the promotion path returns.
|
|
210
|
+
if (siteIds.length === 0) return { ok: true, value: { items: [], nextCursor: null } };
|
|
211
|
+
|
|
212
|
+
let cursorLead: { createdAt: Date; id: string } | undefined;
|
|
213
|
+
if (params.cursor) {
|
|
214
|
+
cursorLead =
|
|
215
|
+
(await prisma.lead.findFirst({
|
|
216
|
+
where: { id: params.cursor, siteId: { in: siteIds } },
|
|
217
|
+
select: { id: true, createdAt: true },
|
|
218
|
+
})) ?? undefined;
|
|
219
|
+
if (!cursorLead) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: {
|
|
223
|
+
kind: "invalid_cursor",
|
|
224
|
+
message: "cursor does not resolve within this scope",
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const rows = await prisma.lead.findMany({
|
|
231
|
+
where: {
|
|
232
|
+
siteId: { in: siteIds },
|
|
233
|
+
...(cursorLead
|
|
234
|
+
? {
|
|
235
|
+
OR: [
|
|
236
|
+
{ createdAt: { lt: cursorLead.createdAt } },
|
|
237
|
+
{ createdAt: cursorLead.createdAt, id: { lt: cursorLead.id } },
|
|
238
|
+
],
|
|
239
|
+
}
|
|
240
|
+
: {}),
|
|
241
|
+
},
|
|
242
|
+
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
243
|
+
take: params.limit + 1,
|
|
244
|
+
select: LEAD_INBOX_SELECT,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const hasMore = rows.length > params.limit;
|
|
248
|
+
const page = hasMore ? rows.slice(0, params.limit) : rows;
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
value: {
|
|
253
|
+
items: page.map((lead) => ({
|
|
254
|
+
id: lead.id,
|
|
255
|
+
name: readName(lead.payload),
|
|
256
|
+
email: lead.email,
|
|
257
|
+
source: lead.source,
|
|
258
|
+
createdAt: lead.createdAt.toISOString(),
|
|
259
|
+
})),
|
|
260
|
+
nextCursor: hasMore ? (page[page.length - 1]?.id ?? null) : null,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file owner-workspaces.ts
|
|
3
|
+
* @description The ONE site→workspace resolution in `apps/api`.
|
|
4
|
+
*
|
|
5
|
+
* A `Lead` is keyed on `siteId` and a `Workspace` on `id`, but the two are the
|
|
6
|
+
* SAME value space named per namespace — `database/config/schema.prisma` says
|
|
7
|
+
* as much of `ProjectListing`, "the one keyed on `siteId` rather than
|
|
8
|
+
* `workspaceId`". So ownership is the direct membership test
|
|
9
|
+
* `siteId ∈ workspaceIds`, never a join through a mapping table, and every
|
|
10
|
+
* caller that needs it resolves it here rather than writing a second path: a
|
|
11
|
+
* divergent isolation path across two PII surfaces is exactly the thing that
|
|
12
|
+
* turns into rework when the multi-tenant product arrives.
|
|
13
|
+
*
|
|
14
|
+
* Callers: `lead-promotion.ts` (lead → user promotion, #3586) and
|
|
15
|
+
* `leads-inbox.ts` (the owner's leads read, #5678).
|
|
16
|
+
*
|
|
17
|
+
* ## Why this is its own file rather than a `lead-promotion.ts` export
|
|
18
|
+
*
|
|
19
|
+
* `paidModuleTemplateExclusions()` (`packages/create-ailk/src/module-architecture.ts`)
|
|
20
|
+
* walks the import graph from each paid module's `ownedFiles` to a fixed point
|
|
21
|
+
* and drops every file that reaches one, so that a keyless scaffold carries no
|
|
22
|
+
* paid source. `lead-promotion.ts` is inside that closure and always has been,
|
|
23
|
+
* which means anything importing it is dropped too. Putting this function
|
|
24
|
+
* there and importing it from the leads read would have carried the closure
|
|
25
|
+
* through `leads-inbox.ts` and `routes/leads/list.ts` into `server.ts`, and a
|
|
26
|
+
* scaffold with no server entry point does not build — the same base-breaking
|
|
27
|
+
* cascade `module-architecture.ts` records twice in its own comments, measured
|
|
28
|
+
* here before this file existed. Keeping the resolution in a module that
|
|
29
|
+
* reaches nothing excluded is what lets both callers share it.
|
|
30
|
+
*
|
|
31
|
+
* Nothing in this file may name `tenant-context.js` in an import form,
|
|
32
|
+
* including inside a comment: the closure's scanner is a regular expression
|
|
33
|
+
* over the file's text and does not know which lines are code.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { Id } from "@working-theory/ids";
|
|
37
|
+
|
|
38
|
+
import { isModuleEnabled } from "../config/modules.js";
|
|
39
|
+
import { loadSlice } from "../lib/slice-load.js";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The tenant-context module is a multi-tenant SLICE the OSS strip removes
|
|
43
|
+
* (#5182), and `listTenancy` is a VALUE — a runtime call, not an erasable type
|
|
44
|
+
* — so a static import, or a specifier written literally at the call site,
|
|
45
|
+
* would be TS2307 on the public tree: `tsc` resolves a string-literal
|
|
46
|
+
* specifier whether or not the branch containing it can ever run.
|
|
47
|
+
*
|
|
48
|
+
* The remedy is the one `server.ts` and `lead-promotion.ts` already use: gate
|
|
49
|
+
* the call on `isModuleEnabled('multi-tenant')` and read the specifier from
|
|
50
|
+
* this constant, so it is not a literal at the call site. What defeats
|
|
51
|
+
* resolution is the INDIRECTION, not the type of the value. The constant form
|
|
52
|
+
* matters too — `@vercel/nft` constant-propagates a module-level binding into
|
|
53
|
+
* the dynamic call and emits the file, which a table lookup would hide from it
|
|
54
|
+
* (#5304 D3).
|
|
55
|
+
*
|
|
56
|
+
* `apps/api` is a `tsc`-only surface with no bundler, which is the
|
|
57
|
+
* precondition that makes this sound here.
|
|
58
|
+
*/
|
|
59
|
+
const TENANT_CONTEXT_SPECIFIER = "./tenant-context.js";
|
|
60
|
+
|
|
61
|
+
/** The one export this file needs from the multi-tenant slice. */
|
|
62
|
+
interface TenantContextSlice {
|
|
63
|
+
listTenancy: (params: {
|
|
64
|
+
userId: Id<"user">;
|
|
65
|
+
sessionTenant: undefined;
|
|
66
|
+
}) => Promise<
|
|
67
|
+
| {
|
|
68
|
+
ok: true;
|
|
69
|
+
value: {
|
|
70
|
+
organizations: Array<{
|
|
71
|
+
role: string;
|
|
72
|
+
workspaces: Array<{ id: string }>;
|
|
73
|
+
}>;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
| { ok: false; error: { message: string } }
|
|
77
|
+
>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One workspace the caller belongs to, carrying the role they hold in its
|
|
82
|
+
* organization. `role` is the organization-level `Membership.role`
|
|
83
|
+
* (`owner | admin | member`) the tenancy assembler already returns — typed as a
|
|
84
|
+
* plain string here rather than `MembershipRole`, because that type lives in
|
|
85
|
+
* the stripped slice this file may not name.
|
|
86
|
+
*/
|
|
87
|
+
export type OwnerWorkspace = { id: string; role: string };
|
|
88
|
+
|
|
89
|
+
export type ResolveOwnerWorkspacesResult =
|
|
90
|
+
| { ok: true; value: OwnerWorkspace[] }
|
|
91
|
+
| { ok: false; error: { message: string } };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the caller's OWN workspaces.
|
|
95
|
+
*
|
|
96
|
+
* Delegates to the sole cross-namespace tenancy assembler — this file never
|
|
97
|
+
* issues a raw membership or workspace model query of its own, which
|
|
98
|
+
* `architecture.yaml`'s tenant-scope forbidden pattern reserves to the
|
|
99
|
+
* tenant-context and tenant-db modules.
|
|
100
|
+
*
|
|
101
|
+
* Module-conditional, and #5677 changed what that MEANS rather than what it
|
|
102
|
+
* does. `services/default-tenant.ts` now resolves the seeded default
|
|
103
|
+
* Workspace on every build, so a module-less deployment does have a
|
|
104
|
+
* resolvable workspace — the empty result below is a deliberate scope limit,
|
|
105
|
+
* not a statement that there is nothing to resolve. Wiring this resolver to
|
|
106
|
+
* that default is a behavior change for BOTH callers at once (promotion would
|
|
107
|
+
* begin stamping `promotedUserId` on single-tenant leads, which
|
|
108
|
+
* `/v1/flow-checkouts/me` and `/v1/waitlist-signups/me` join through), and it
|
|
109
|
+
* needs the deployment to pin `AILK_SITE_ID` to `DEFAULT_WORKSPACE_ID` —
|
|
110
|
+
* `siteId` comes from `lib/site.ts` while `workspaceId` is the sentinel, and
|
|
111
|
+
* `siteId ∈ workspaceIds` only matches when those agree.
|
|
112
|
+
*/
|
|
113
|
+
export async function resolveOwnerWorkspaces(
|
|
114
|
+
userId: Id<"user">,
|
|
115
|
+
): Promise<ResolveOwnerWorkspacesResult> {
|
|
116
|
+
if (!isModuleEnabled("multi-tenant")) return { ok: true, value: [] };
|
|
117
|
+
|
|
118
|
+
const specifier: string = TENANT_CONTEXT_SPECIFIER;
|
|
119
|
+
const { listTenancy } = (await loadSlice(
|
|
120
|
+
specifier,
|
|
121
|
+
() => import(specifier),
|
|
122
|
+
)) as TenantContextSlice;
|
|
123
|
+
const tenancy = await listTenancy({ userId, sessionTenant: undefined });
|
|
124
|
+
if (!tenancy.ok) return { ok: false, error: tenancy.error };
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
value: tenancy.value.organizations.flatMap((org) =>
|
|
129
|
+
org.workspaces.map((workspace) => ({ id: workspace.id, role: org.role })),
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -24,7 +24,7 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
|
|
|
24
24
|
|
|
25
25
|
**Read (9):** `list_pages`, `read_content`, `search_content`, `get_site_info`, `get_business_hours`, `get_locations`, `get_offerings`, `get_pricing`, `get_faq`
|
|
26
26
|
**Conversion-write (3):** `capture_lead`, `subscribe_newsletter`, `request_callback`
|
|
27
|
-
**Maintenance (2):** `audit`, `
|
|
27
|
+
**Maintenance (2):** `audit`, `create_project`
|
|
28
28
|
**Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366): identity-blind and free of any `requirePaidTier()` call like every tool in this list, but registered only when the signal-collectors module is present, the same shape Booking below already has
|
|
29
29
|
**Booking (3):** `list_availability`, `get_event_meta`, `create_booking` — module-gated (`module: "scheduling"`)
|
|
30
30
|
|
|
@@ -105,7 +105,7 @@ const MCP_ONLY_TOOLS = new Set([
|
|
|
105
105
|
"ailkAudit", // audit — AEO scoring tool, no web action equivalent (I5; ADR 0013)
|
|
106
106
|
"ailkSubscribeNewsletter", // subscribe_newsletter — anonymous write via MCP; no Server Action (B7)
|
|
107
107
|
"ailkRequestCallback", // request_callback — anonymous write via MCP; no Server Action (B7)
|
|
108
|
-
"
|
|
108
|
+
"ailkCreateProject", // create_project — agent-only project creator; no Server Action equivalent (#299)
|
|
109
109
|
"ailkCreatePage", // create_page — agent-first Pro content write; HTTP twin is apps/api, not a Server Action (D14a #55)
|
|
110
110
|
"ailkUpdatePage", // update_page — agent-first Pro content write; HTTP twin is apps/api, not a Server Action (D14a #55)
|
|
111
111
|
"ailkDeletePage", // delete_page — agent-first Pro content write; HTTP twin is apps/api, not a Server Action (D14a #55)
|
|
@@ -246,7 +246,7 @@ describe("MCP ↔ Server-Action parity (filesystem)", () => {
|
|
|
246
246
|
// - ailkAudit: MCP-only (AEO scoring; I5; in MCP_ONLY_TOOLS allow-list).
|
|
247
247
|
// - ailkSubscribeNewsletter, ailkRequestCallback:
|
|
248
248
|
// MCP-only anonymous-write tools (B7; in MCP_ONLY_TOOLS allow-list).
|
|
249
|
-
// -
|
|
249
|
+
// - ailkCreateProject: MCP-only agent project creator (#299; in MCP_ONLY_TOOLS).
|
|
250
250
|
// - ailkCreatePage, ailkUpdatePage, ailkDeletePage: MCP-only agent-first
|
|
251
251
|
// Pro content writes; HTTP twin is apps/api, not Server Actions (D14a #55).
|
|
252
252
|
// - ailkGetFlow: MCP-only agent-callable flow-engine read; HTTP twin is
|