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.
Files changed (146) hide show
  1. package/component-catalog.json +11939 -0
  2. package/component-catalog.md +176 -64
  3. package/dist/cli.js +29 -1
  4. package/dist/derive-architecture.js +5 -2
  5. package/dist/lib/extract-module-bundle.d.ts +50 -2
  6. package/dist/lib/extract-module-bundle.js +223 -15
  7. package/dist/lib/fetch-guard.d.ts +28 -0
  8. package/dist/lib/fetch-guard.js +101 -0
  9. package/dist/lib/fetch-module.js +4 -0
  10. package/dist/lib/fetch-template.js +4 -0
  11. package/dist/module-architecture.d.ts +59 -8
  12. package/dist/module-architecture.js +334 -115
  13. package/dist/parse-args.js +6 -2
  14. package/dist/programmatic.d.ts +10 -0
  15. package/dist/programmatic.js +134 -19
  16. package/dist/site-chrome.d.ts +80 -0
  17. package/dist/site-chrome.js +135 -0
  18. package/dist/surfaces.d.ts +21 -6
  19. package/dist/surfaces.js +107 -32
  20. package/dist/template-manifest.d.ts +55 -3
  21. package/dist/template-manifest.js +181 -12
  22. package/dist/template-names.d.ts +1 -1
  23. package/dist/template-names.js +1 -0
  24. package/package.json +9 -2
  25. package/site-templates/_base/ailk-template.json +6 -0
  26. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  27. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
  28. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
  29. package/site-templates/marketing-site/ailk-template.json +11 -0
  30. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  31. package/site-templates/marketing-site/provenance.json +4 -0
  32. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  33. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
  34. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  35. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
  36. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
  37. package/site-templates/minimalism-site/ailk-template.json +21 -0
  38. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  39. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  40. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  41. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  42. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  44. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  45. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  46. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  47. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  48. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  49. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  50. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  51. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
  52. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  53. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  54. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  55. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  56. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  57. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  58. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  59. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  60. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  61. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  62. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  63. package/site-templates/minimalism-site/provenance.json +4 -0
  64. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  65. package/site-templates/saas-product/ailk-template.json +16 -0
  66. package/site-templates/saas-product/provenance.json +10 -0
  67. package/templates/.claude/skills/README.md +16 -25
  68. package/templates/.env.example +32 -7
  69. package/templates/.github/workflows/ci.yml +48 -0
  70. package/templates/README.md +3 -1
  71. package/templates/apps/api/CLAUDE.md +4 -1
  72. package/templates/apps/api/eslint.config.mjs +69 -0
  73. package/templates/apps/api/package.json +6 -2
  74. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
  75. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  76. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +8 -1
  77. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  78. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
  79. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
  80. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  82. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  83. package/templates/apps/api/src/server.ts +82 -6
  84. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  85. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  86. package/templates/apps/api/src/services/default-tenant.ts +261 -0
  87. package/templates/apps/api/src/services/lead-promotion.ts +161 -0
  88. package/templates/apps/api/src/services/leads-inbox.ts +263 -0
  89. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  90. package/templates/apps/mcp/CLAUDE.md +1 -1
  91. package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
  92. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  93. package/templates/apps/mcp/eslint.config.mjs +69 -0
  94. package/templates/apps/mcp/package.json +6 -2
  95. package/templates/apps/mcp/src/server.ts +2 -2
  96. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  97. package/templates/apps/mcp/src/tools/index.ts +2 -2
  98. package/templates/apps/web/.env.example +3 -3
  99. package/templates/apps/web/CLAUDE.md +2 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
  101. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  103. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  104. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  105. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  106. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  107. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  108. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  109. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  111. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  112. package/templates/apps/web/app/[locale]/layout.tsx +214 -17
  113. package/templates/apps/web/components/google-analytics.tsx +18 -1
  114. package/templates/apps/web/eslint.config.mjs +69 -0
  115. package/templates/apps/web/jest.config.cjs +7 -15
  116. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
  117. package/templates/apps/web/lib/nav-config.ts +27 -7
  118. package/templates/apps/web/lib/server-api.ts +1 -1
  119. package/templates/apps/web/lib/site-chrome.ts +29 -0
  120. package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
  121. package/templates/apps/web/messages/en.json +29 -2
  122. package/templates/apps/web/next.config.mjs +20 -0
  123. package/templates/apps/web/package.json +6 -2
  124. package/templates/apps/web/sentry.client.config.ts +131 -0
  125. package/templates/apps/web/sentry.edge.config.ts +72 -0
  126. package/templates/apps/web/sentry.server.config.ts +72 -0
  127. package/templates/apps/web/src/lib/routes.ts +13 -18
  128. package/templates/brand_assets/README.md +24 -0
  129. package/templates/content/en/AboutPage/about.mdx +160 -15
  130. package/templates/content/en/ContactPage/contact.mdx +90 -13
  131. package/templates/content/en/FAQPage/faq.mdx +62 -22
  132. package/templates/content/en/HomePage/home.mdx +244 -20
  133. package/templates/content/en/LandingPage/blog.mdx +83 -0
  134. package/templates/content/en/LandingPage/features.mdx +103 -0
  135. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  136. package/templates/database/CHANGELOG.md +7 -0
  137. package/templates/database/__tests__/seed.test.ts +1 -1
  138. package/templates/database/eslint.config.mjs +69 -0
  139. package/templates/database/package.json +7 -3
  140. package/templates/database/seed.ts +1 -1
  141. package/templates/package.json +1 -14
  142. package/versions.json +52 -0
  143. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  144. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  145. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  146. /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`, `scaffold`
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
- "ailkScaffold", // scaffold — agent-only scaffolder; no Server Action equivalent (#299)
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
- // - ailkScaffold: MCP-only agent scaffolder (#299; in MCP_ONLY_TOOLS).
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