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
package/templates/.env.example
CHANGED
|
@@ -50,16 +50,41 @@ CONTENT_BACKEND=mdx
|
|
|
50
50
|
# BETTER_AUTH_URL=http://localhost:3000
|
|
51
51
|
# BETTER_AUTH_SECRET=
|
|
52
52
|
|
|
53
|
-
# ── Single-tenant operator pin (
|
|
53
|
+
# ── Single-tenant operator pin (apps/api) ────────────────────────────────────
|
|
54
|
+
# REQUIRED on a build WITHOUT the multi-tenant module if you want the
|
|
55
|
+
# submissions review dashboard. OPTIONAL with the module.
|
|
56
|
+
#
|
|
54
57
|
# On a single-tenant deployment (the default Workspace exists and is the ONLY
|
|
55
58
|
# Workspace) the FIRST authenticated user to resolve a tenant context is
|
|
56
59
|
# bound as the sole owner (TOFU — trust on first use); every subsequent
|
|
57
|
-
# caller is denied. AILK_OPERATOR_EMAIL
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
60
|
+
# caller is denied. AILK_OPERATOR_EMAIL pins that binding to one email
|
|
61
|
+
# address instead of "whoever logs in first". Case-insensitive, trimmed.
|
|
62
|
+
#
|
|
63
|
+
# WITHOUT the multi-tenant module it is MANDATORY and unset fails CLOSED:
|
|
64
|
+
# the review routes (/v1/waitlist-experiments/*, the tenant-scoped
|
|
65
|
+
# /v1/project-listings/*) answer 403 to everyone, including you. That is
|
|
66
|
+
# deliberate. Those routes can widen to an unfiltered, every-site read for an
|
|
67
|
+
# owner of the default Organization, so an unpinned deployment must not seat
|
|
68
|
+
# whoever authenticates first — on a site with public sign-ups that is not
|
|
69
|
+
# necessarily you. The pinned account's email must also be VERIFIED.
|
|
70
|
+
#
|
|
71
|
+
# If your own dashboard returns 403, this variable is the first thing to
|
|
72
|
+
# check. With the multi-tenant module present, resolution comes from the
|
|
73
|
+
# session's active workspace and this stays optional, exactly as before.
|
|
61
74
|
# AILK_OPERATOR_EMAIL=
|
|
62
75
|
#
|
|
76
|
+
# ── Site id (apps/api, apps/web) ─────────────────────────────────────────────
|
|
77
|
+
# The scope captured rows are stamped with. Lead, WaitlistExperiment and
|
|
78
|
+
# ProjectListing carry `siteId`, which defaults to the literal "default"
|
|
79
|
+
# (apps/api/src/lib/site.ts), while tenancy scopes reads by WORKSPACE id.
|
|
80
|
+
# A single-tenant deployment should set this to the default workspace
|
|
81
|
+
# sentinel so the two agree:
|
|
82
|
+
# AILK_SITE_ID=wksp_00000000-0000-7000-8000-000000000001
|
|
83
|
+
#
|
|
84
|
+
# Leaving them disagreeing is why a correctly pinned owner can still open the
|
|
85
|
+
# review dashboard and see nothing: the rows exist, but under a different
|
|
86
|
+
# scope key than the one their session resolves.
|
|
87
|
+
#
|
|
63
88
|
# Belt-and-suspenders: the owner-pin denies any non-operator account at
|
|
64
89
|
# tenant resolution regardless, but for a single-tenant deployment you should
|
|
65
90
|
# also disable open sign-up / configure an OAuth allowlist on your identity
|
|
@@ -291,9 +316,9 @@ CONTENT_BACKEND=mdx
|
|
|
291
316
|
# SCHEDULING_PUBLIC_BASE_URL=https://cal.com
|
|
292
317
|
# SCHEDULING_PROVIDER=cal
|
|
293
318
|
#
|
|
294
|
-
# Which event the /
|
|
319
|
+
# Which event the /meet page books, as "<owner>/<event-slug>" (the path of
|
|
295
320
|
# the provider's own public booking page). Read server-side by apps/web; unset
|
|
296
|
-
# means the /
|
|
321
|
+
# means the /meet route 404s, exactly as if the scheduling module were not
|
|
297
322
|
# installed. Deliberately NOT a NEXT_PUBLIC_ variable — the whole scheduling
|
|
298
323
|
# config surface is server-read.
|
|
299
324
|
# SCHEDULING_CAL_LINK=owner/event-slug
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
ci:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
|
|
12
|
+
- uses: pnpm/action-setup@v4
|
|
13
|
+
|
|
14
|
+
# A fresh scaffold ships no pnpm-lock.yaml — it is generated by the
|
|
15
|
+
# customer's own first `pnpm install`. actions/setup-node's built-in
|
|
16
|
+
# pnpm cache globs for cache-dependency-path and errors the step
|
|
17
|
+
# outright when nothing matches, so a PR opened before that first
|
|
18
|
+
# install would fail here before `pnpm install` below even runs. This
|
|
19
|
+
# placeholder only stands in for the glob; `pnpm install` still
|
|
20
|
+
# generates (or refreshes) the real lockfile every run.
|
|
21
|
+
- run: test -f pnpm-lock.yaml || touch pnpm-lock.yaml
|
|
22
|
+
|
|
23
|
+
- uses: actions/setup-node@v4
|
|
24
|
+
with:
|
|
25
|
+
node-version: 20
|
|
26
|
+
cache: pnpm
|
|
27
|
+
cache-dependency-path: pnpm-lock.yaml
|
|
28
|
+
|
|
29
|
+
# Not --frozen-lockfile: the scaffold ships no pnpm-lock.yaml (it is
|
|
30
|
+
# generated fresh by the customer's own first `pnpm install`, and the
|
|
31
|
+
# placeholder step above is a cache-key stand-in, not a real lockfile),
|
|
32
|
+
# so a --frozen install would fail outright on any PR opened before a
|
|
33
|
+
# real lockfile has been committed.
|
|
34
|
+
- run: pnpm install
|
|
35
|
+
|
|
36
|
+
# Prisma's generated client (database/generated/) is a build artifact,
|
|
37
|
+
# not part of the scaffold — type-check/build/test all resolve
|
|
38
|
+
# `@working-theory/database`'s generated types, and `prisma generate` needs
|
|
39
|
+
# no DATABASE_URL (it reads only the schema file, no live DB).
|
|
40
|
+
- run: pnpm db:generate
|
|
41
|
+
|
|
42
|
+
- run: pnpm type-check
|
|
43
|
+
|
|
44
|
+
- run: pnpm lint
|
|
45
|
+
|
|
46
|
+
- run: pnpm test
|
|
47
|
+
|
|
48
|
+
- run: pnpm build
|
package/templates/README.md
CHANGED
|
@@ -103,7 +103,9 @@ If you need to override a section component, swap its registration in the sectio
|
|
|
103
103
|
|
|
104
104
|
## License & contributing
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
This is your code — `{{PROJECT_NAME}}`, owned by {{COMPANY_NAME}} ({{COMPANY_DOMAIN}}). The root `package.json`'s `license` field ships set to `UNLICENSED` as a neutral placeholder; it's yours to change to whatever license suits your project (or to remove entirely if the repo is private).
|
|
107
|
+
|
|
108
|
+
The `LICENSE` files under `apps/*/` and `database/` are the exception — they stay **Apache 2.0** because those directories are snapshots of `@working-theory/*` packages' own source, not code you authored.
|
|
107
109
|
|
|
108
110
|
The AI Launch Kit framework upstream is **Apache 2.0** — see <https://github.com/working-theory-labs/ai-launch-kit/blob/main/LICENSE>. Contributions are welcome at the upstream repo; start with the [contributing guide](https://github.com/working-theory-labs/ai-launch-kit/blob/main/CONTRIBUTING.md).
|
|
109
111
|
|
|
@@ -37,6 +37,7 @@ Every endpoint here has a 1:1 MCP tool in `apps/mcp`. When you add, change, or r
|
|
|
37
37
|
Routes are versioned under `/v1/*`:
|
|
38
38
|
|
|
39
39
|
- `/v1/leads/{submit,subscribe,callback}` — lead capture, newsletter subscription, callback request
|
|
40
|
+
- `/v1/leads` (GET, `?cursor=&limit=`) — the leads INBOX read (#5678): the caller's OWN sites' leads, newest first, cursor-paginated (`limit` defaults to 50, max 200; `(createdAt desc, id desc)`; `cursor` is the previous page's last row id, and one that does not resolve within the caller's scope is a 400, never a silent page 1 — the API keeps that contract, and the web inbox deliberately softens it on its own side by re-reading page one when a URL-supplied cursor 400s, which is a rendering choice and not a change to this response). Five fields per row and nothing more — `id`, `name` (lifted from `payload.name` when the site's form captured one), `email`, `source`, `createdAt`; `payload`'s other keys, `siteId`, `promotedUserId` and `promotedAt` are never selected (`src/services/leads-inbox.ts` is the only read path). Registered in `authScope` only (auth, no tenant guard) and — alone in that scope — **unconditionally**, with no `isModuleEnabled()` gate: lead capture is a never-gate surface at every level, so reading a lead back cannot be gated either. Identity is `request.userId`; there is no site, org or workspace parameter, so the scope cannot be widened from the wire. It is resolved from the caller's own memberships by `src/services/owner-workspaces.ts`'s `resolveOwnerWorkspaces` — the ONE site→workspace resolution in this app, shared with `lead-promotion.ts` rather than written a second time — and every read filters `siteId ∈ workspaceIds`. Role floor `owner|admin` (contact PII), the same floor and the same reason as `/v1/waitlist-experiments/*`; the scope is the UNION of every workspace the caller owns or administers, not one active tenant, because this route registers outside the tenant scope. **Two deployment preconditions decide whether it returns anything, and both fail EMPTY rather than wide:** a build with no `multi-tenant` module resolves no workspaces (a deliberate scope limit since #5677, not an absence of anything to resolve), and the `siteId` the leads were WRITTEN under must equal a workspace id. That `siteId` has TWO sources, and `currentSiteId()` is not the one on the primary path: the three POST capture routes read it from the REQUEST BODY (`routes/leads/index.ts`), which the capture sections declare in content and default to `"default"` (`ContactForm`/`Newsletter`/`StepFlow`, `ext.siteId ?? "default"`), while the recommender's capture path (`routes/flows/recommender.ts`) is the one that uses `currentSiteId()` (`lib/site.ts`, `AILK_SITE_ID ?? "default"`). A workspace id is `wksp_<uuidv7>`, so `siteId ∈ workspaceIds` matches only when the content-declared value — and, for recommender leads, `AILK_SITE_ID` — is set to it (the same convention `waitlist-dashboard.ts` already relies on). The rendered `name` is lifted from `payload.name` and truncated at 200 characters: the write side bounds `payload` at 64 keys and no per-value length, and `POST /v1/leads/submit` is anonymous with no ownership check on `siteId`. No MCP twin — identity-bound (#3591 exception, the same shape as `/v1/workspaces/*` and `/v1/billing/*`).
|
|
40
41
|
- `/v1/flows/:slug` (GET), `/v1/flows/:slug/steps` (POST) — serve a flow config + submit a step's answers; server-side per-step partial-state persistence (#3956). Module: `signal-collectors` (D-48, #5366) — registers only when the module is enabled; MCP twins `get_flow`/`submit_flow_step` carry the same `module` tag (D7).
|
|
41
42
|
- `/v1/flows/:slug/aggregate` (GET) — anonymous, site-scoped per-question response counts across all completed respondents for the results reveal (#4771): respondents are completed `FlowSession` rows (not `Lead` rows — a recommending-mode respondent has no Lead until the email moment), question metadata + the chart flag come from the `WaitlistExperiment` register, and only `resultsReveal`-flagged `single`/`multi` questions are returned (`src/services/flow-aggregate.ts`). No individual answer, email, name, session id, or score in the response; no auth. Module: `signal-collectors`. No MCP twin — recorded exception (an agent walking a flow already gets everything it needs from `submit_flow_step`'s own response; "how everyone else answered" changes none of it).
|
|
42
43
|
- `/v1/deliverables/:grantId` (GET) — the capability-token download route for gated PDFs and reports, including client deliverables (#3961 D5); the token itself is the sole gate, no auth preHandler applies (`src/routes/deliverables/index.ts`). Module: `signal-collectors` (D-48, #5366). No MCP twin — identity-bound capability-link download (#3591 exception, the same shape as `/v1/waitlist-signups/me`).
|
|
@@ -143,7 +144,9 @@ sweep's own DB constraint (never a caller-side lock).
|
|
|
143
144
|
- Don't put session state in `apps/api`. It's stateless.
|
|
144
145
|
- Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
|
|
145
146
|
|
|
146
|
-
Last updated: 2026-09-
|
|
147
|
+
Last updated: 2026-09-16 — doc-sync: added `GET /v1/leads` (#5678), the owner-scoped leads inbox read, to Endpoint conventions, recording its unconditional registration, its five-field row, its `resolveOwnerWorkspaces` scoping and its no-MCP-twin parity decision — verified against `apps/api/src/routes/leads/list.ts`, `apps/api/src/services/leads-inbox.ts`, `apps/api/src/services/owner-workspaces.ts` and `apps/api/src/server.ts`.
|
|
148
|
+
|
|
149
|
+
Prior: 2026-09-14 — doc-sync: signal-collectors module extraction (D-48, #5366) — added the `module: signal-collectors` note to the flows/waitlist-signups-me/project-listings-me endpoints, added the previously-undocumented `/v1/deliverables/:grantId`, `/v1/project-listings/{start,submit,:id,public}` and `/v1/slack/actions` rows — verified against `apps/api/src/server.ts` and each route's own header.
|
|
147
150
|
|
|
148
151
|
Prior: 2026-09-12 — doc-sync: added the `## Deploy path` section and the matching `## What NOT to do` line (#5334), recording that bundling `apps/api` is not a supported deploy path and naming the three gates — verified against `apps/api/vercel.json`, `apps/api/src/lib/slice-load.ts` and `scripts/check-bundled-build-diagnoses.ts`.
|
|
149
152
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Self-contained ESLint flat config for this scaffolded package.
|
|
2
|
+
//
|
|
3
|
+
// It deliberately does NOT depend on @working-theory/eslint-config — that
|
|
4
|
+
// package is internal to the AI Launch Kit monorepo and is not published, so
|
|
5
|
+
// it cannot be a dependency of a scaffolded starter. Everything below comes
|
|
6
|
+
// from public npm packages already pinned as devDependencies (see
|
|
7
|
+
// package.json): @eslint/js, globals and typescript-eslint.
|
|
8
|
+
//
|
|
9
|
+
// Source file name (#5556): this file is checked in as `_eslint.config.mjs`,
|
|
10
|
+
// not `eslint.config.mjs` — build-templates.mjs renames it at snapshot time
|
|
11
|
+
// (renameScaffoldLintConfigs()). A file literally named `eslint.config.mjs`
|
|
12
|
+
// anywhere under this monorepo (even one this repo's own root eslint.config.ts
|
|
13
|
+
// lists in its `ignores`) is auto-discovered by ESLint's flat-config nested
|
|
14
|
+
// resolution as a competing config root, which breaks the monorepo's own
|
|
15
|
+
// lint runs (verified live: typescript-eslint's "multiple candidate
|
|
16
|
+
// TSConfigRootDirs" parser error across unrelated packages/create-ailk
|
|
17
|
+
// files). The underscore prefix keeps it invisible to that discovery while
|
|
18
|
+
// it lives in this checkout; only the snapshot in templates/ — which ships
|
|
19
|
+
// to the customer, not this repo — gets the real filename.
|
|
20
|
+
import js from "@eslint/js";
|
|
21
|
+
import globals from "globals";
|
|
22
|
+
import tseslint from "typescript-eslint";
|
|
23
|
+
|
|
24
|
+
export default tseslint.config(
|
|
25
|
+
{
|
|
26
|
+
ignores: [
|
|
27
|
+
"**/.next/**",
|
|
28
|
+
"**/dist/**",
|
|
29
|
+
"**/generated/**",
|
|
30
|
+
"**/node_modules/**",
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
js.configs.recommended,
|
|
34
|
+
...tseslint.configs.recommended,
|
|
35
|
+
{
|
|
36
|
+
languageOptions: {
|
|
37
|
+
globals: {
|
|
38
|
+
...globals.node,
|
|
39
|
+
...globals.browser,
|
|
40
|
+
...globals.jest,
|
|
41
|
+
},
|
|
42
|
+
// Explicit, self-contained root — this scaffold config is never meant
|
|
43
|
+
// to combine with any sibling scaffold package's config (each ships
|
|
44
|
+
// standalone), and leaving it unset makes typescript-eslint's
|
|
45
|
+
// candidate-root disambiguation ambiguous the moment more than one
|
|
46
|
+
// scaffold package's config resolves in the same ESLint process (e.g.
|
|
47
|
+
// this monorepo's own `templates/` build, where all four exist
|
|
48
|
+
// side by side; a real customer scaffold never has that problem,
|
|
49
|
+
// since it only ever ships and lints ONE of these four at a time).
|
|
50
|
+
parserOptions: {
|
|
51
|
+
tsconfigRootDir: import.meta.dirname,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
rules: {
|
|
55
|
+
// Downgraded rather than off, so both stay visible without failing a
|
|
56
|
+
// fresh scaffold's lint:
|
|
57
|
+
// - unused-vars: most of the source follows the underscore-prefix
|
|
58
|
+
// convention for intentionally-unused bindings, which this rule's
|
|
59
|
+
// default options don't recognize; a handful of test fixtures also
|
|
60
|
+
// carry genuinely-unused locals this minimal config doesn't try to
|
|
61
|
+
// triage file-by-file.
|
|
62
|
+
// - require-imports: CommonJS require() is the correct form in Jest
|
|
63
|
+
// config/mock files and in a few test-only dynamic re-require
|
|
64
|
+
// patterns.
|
|
65
|
+
"@typescript-eslint/no-unused-vars": "warn",
|
|
66
|
+
"@typescript-eslint/no-require-imports": "warn",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
);
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"smoke:dev": "node -e \"const{spawn}=require('child_process');const http=require('http');const PORT=process.env.PORT||'3002';const SLUG=process.env.SMOKE_FLOW_SLUG||'smoke-check';const child=spawn('pnpm',['exec','tsx','watch','src/cli.ts'],{env:process.env,stdio:'ignore'});let done=false;function finish(code){if(done)return;done=true;try{child.kill();}catch(e){}process.exit(code);}function poll(attempt){if(attempt>40){console.error('smoke:dev - timeout waiting for server');finish(1);return;}const req=http.get({host:'127.0.0.1',port:PORT,path:'/v1/flows/'+SLUG},(res)=>{console.log('smoke:dev - GET /v1/flows/'+SLUG+' -> HTTP '+res.statusCode);res.resume();finish(0);});req.on('error',()=>{setTimeout(()=>poll(attempt+1),500);});}child.on('exit',(code)=>{if(!done){console.error('smoke:dev - child exited early with code '+code);finish(1);}});setTimeout(()=>poll(0),500);\"",
|
|
21
21
|
"smoke:build": "node -e \"const{spawn}=require('child_process');const http=require('http');const PORT=process.env.PORT||'3002';const SLUG=process.env.SMOKE_FLOW_SLUG||'smoke-check';const child=spawn('node',['dist/cli.js'],{env:process.env,stdio:'ignore'});let done=false;function finish(code){if(done)return;done=true;try{child.kill();}catch(e){}process.exit(code);}function poll(attempt){if(attempt>40){console.error('smoke:build - timeout waiting for server');finish(1);return;}const req=http.get({host:'127.0.0.1',port:PORT,path:'/v1/flows/'+SLUG},(res)=>{console.log('smoke:build - GET /v1/flows/'+SLUG+' -> HTTP '+res.statusCode);res.resume();finish(0);});req.on('error',()=>{setTimeout(()=>poll(attempt+1),500);});}child.on('exit',(code)=>{if(!done){console.error('smoke:build - child exited early with code '+code);finish(1);}});setTimeout(()=>poll(0),500);\"",
|
|
22
22
|
"smoke": "pnpm run smoke:dev && pnpm run smoke:build",
|
|
23
|
-
"lint": "
|
|
23
|
+
"lint": "eslint .",
|
|
24
24
|
"test": "jest",
|
|
25
25
|
"test:watch": "jest --watch",
|
|
26
26
|
"test:coverage": "jest --coverage",
|
|
@@ -64,6 +64,10 @@
|
|
|
64
64
|
"prisma": "^7.8.0",
|
|
65
65
|
"ts-jest": "^29.4.11",
|
|
66
66
|
"tsx": "^4.22.4",
|
|
67
|
-
"typescript": "^6.0.3"
|
|
67
|
+
"typescript": "^6.0.3",
|
|
68
|
+
"eslint": "^10.6.0",
|
|
69
|
+
"@eslint/js": "^10.0.1",
|
|
70
|
+
"globals": "^17.7.0",
|
|
71
|
+
"typescript-eslint": "^8.62.1"
|
|
68
72
|
}
|
|
69
73
|
}
|
|
@@ -59,11 +59,15 @@ async function withEnabledModules<T>(
|
|
|
59
59
|
// caught by the "still registered" assertions below.
|
|
60
60
|
const MODULE_ROUTES: Record<string, { method: 'GET' | 'POST'; url: string }[]> =
|
|
61
61
|
{
|
|
62
|
+
// /v1/waitlist-experiments and /v1/project-listings are deliberately NOT
|
|
63
|
+
// listed here since #5677: the review surface registers under EITHER
|
|
64
|
+
// multi-tenant OR signal-collectors, so excluding multi-tenant alone
|
|
65
|
+
// leaves them registered by design — the same shape the workspace-settings
|
|
66
|
+
// routes have. Their matrix is `review-surface-registration.test.ts`,
|
|
67
|
+
// beside `workspace-module-registration.test.ts`.
|
|
62
68
|
'multi-tenant': [
|
|
63
69
|
{ method: 'GET', url: '/v1/workspaces' },
|
|
64
70
|
{ method: 'GET', url: '/v1/billing' },
|
|
65
|
-
{ method: 'GET', url: '/v1/waitlist-experiments' },
|
|
66
|
-
{ method: 'GET', url: '/v1/project-listings' },
|
|
67
71
|
],
|
|
68
72
|
scheduling: [{ method: 'GET', url: '/v1/schedule/availability' }],
|
|
69
73
|
'content-write': [{ method: 'POST', url: '/v1/content/BlogPosting' }],
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file review-surface-registration.test.ts
|
|
3
|
+
* @description The `multi-tenant || signal-collectors` registration matrix for
|
|
4
|
+
* the review surface (#5677).
|
|
5
|
+
*
|
|
6
|
+
* This is the TWO-SIDED check the unit turns on. `signal-collectors` (Pro) is
|
|
7
|
+
* the module that sells the instruments — flows, deliverables, project
|
|
8
|
+
* listings, waitlist experiments — so a Pro customer could collect submissions
|
|
9
|
+
* and not read them, because the review routes registered only inside the
|
|
10
|
+
* multi-tenant scope. Widening the gate has to make the Pro side work WITHOUT
|
|
11
|
+
* moving the Pro Plus side, and only exercising both builds shows that.
|
|
12
|
+
*
|
|
13
|
+
* It cannot fold into `module-exclusion.test.ts`: that suite's shape is
|
|
14
|
+
* "excluding module X 404s every route X owns", and these routes register
|
|
15
|
+
* under EITHER module, so excluding one while the other is on leaves them
|
|
16
|
+
* registered by design — the same reason `workspace-module-registration.test.ts`
|
|
17
|
+
* is its own file, whose structure this mirrors.
|
|
18
|
+
*
|
|
19
|
+
* "Resolves" is measured as "not Fastify's own 404". An unauthenticated
|
|
20
|
+
* request to a registered tenant route answers 401, never 404, so the two
|
|
21
|
+
* states are unambiguous.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors `src/__tests__/server.test.ts`'s external-boundary mocks (CORS,
|
|
24
|
+
* rate-limit, email) — see that file's header for why each is mocked.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
jest.mock('@fastify/cors', () => {
|
|
28
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
jest.mock('@fastify/rate-limit', () => {
|
|
32
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
jest.mock('@working-theory/email', () => ({
|
|
36
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import { build } from '../server';
|
|
40
|
+
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
|
|
42
|
+
type App = any;
|
|
43
|
+
|
|
44
|
+
const EXPERIMENT = 'wlx_00000000-0000-7000-8000-0000000000aa';
|
|
45
|
+
|
|
46
|
+
/** The review surface — registers under EITHER module since #5677. */
|
|
47
|
+
const REVIEW_ROUTES = [
|
|
48
|
+
{ method: 'GET' as const, url: '/v1/waitlist-experiments' },
|
|
49
|
+
{ method: 'GET' as const, url: `/v1/waitlist-experiments/${EXPERIMENT}` },
|
|
50
|
+
{
|
|
51
|
+
method: 'GET' as const,
|
|
52
|
+
url: `/v1/waitlist-experiments/${EXPERIMENT}/signups`,
|
|
53
|
+
},
|
|
54
|
+
{ method: 'GET' as const, url: '/v1/project-listings' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Organization/Workspace ADMINISTRATION — deliberately left alone by #5677,
|
|
59
|
+
* because it is not a site's own submissions. These stay multi-tenant-only.
|
|
60
|
+
*/
|
|
61
|
+
const MULTI_TENANT_ONLY_ROUTES = [
|
|
62
|
+
{ method: 'GET' as const, url: '/v1/workspaces' },
|
|
63
|
+
{ method: 'GET' as const, url: '/v1/billing' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
async function withEnabledModules<T>(
|
|
67
|
+
enabled: string,
|
|
68
|
+
fn: () => Promise<T>,
|
|
69
|
+
): Promise<T> {
|
|
70
|
+
const prior = process.env.AILK_ENABLED_MODULES;
|
|
71
|
+
process.env.AILK_ENABLED_MODULES = enabled;
|
|
72
|
+
try {
|
|
73
|
+
return await fn();
|
|
74
|
+
} finally {
|
|
75
|
+
if (prior === undefined) {
|
|
76
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
77
|
+
} else {
|
|
78
|
+
process.env.AILK_ENABLED_MODULES = prior;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every (method, path) pair Fastify's router holds, one per line. */
|
|
84
|
+
function routeLines(app: App): string[] {
|
|
85
|
+
return (app.printRoutes({ commonPrefix: false }) as string)
|
|
86
|
+
.split('\n')
|
|
87
|
+
.map((line: string) => line.trim())
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('review-surface registration matrix (#5677)', () => {
|
|
92
|
+
let app: App;
|
|
93
|
+
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
if (app) {
|
|
96
|
+
await app.close();
|
|
97
|
+
app = undefined;
|
|
98
|
+
}
|
|
99
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("with 'signal-collectors' alone (a Pro build), every review route resolves behind the base tenant guard", async () => {
|
|
103
|
+
app = await withEnabledModules('signal-collectors', () => build());
|
|
104
|
+
|
|
105
|
+
for (const { method, url } of REVIEW_ROUTES) {
|
|
106
|
+
const res = await app.inject({ method, url });
|
|
107
|
+
expect({ url, status: res.statusCode }).toEqual({ url, status: 401 });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("with 'signal-collectors' alone, Organization/Workspace administration stays unregistered", async () => {
|
|
112
|
+
app = await withEnabledModules('signal-collectors', () => build());
|
|
113
|
+
|
|
114
|
+
for (const { method, url } of MULTI_TENANT_ONLY_ROUTES) {
|
|
115
|
+
const res = await app.inject({ method, url });
|
|
116
|
+
expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("with 'multi-tenant' alone (a Pro Plus build), every review route resolves exactly as before — the regression floor", async () => {
|
|
121
|
+
app = await withEnabledModules('multi-tenant', () => build());
|
|
122
|
+
|
|
123
|
+
for (const { method, url } of [
|
|
124
|
+
...REVIEW_ROUTES,
|
|
125
|
+
...MULTI_TENANT_ONLY_ROUTES,
|
|
126
|
+
]) {
|
|
127
|
+
const res = await app.inject({ method, url });
|
|
128
|
+
expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('with BOTH enabled, each review route registers exactly once', async () => {
|
|
133
|
+
// build() itself is the first assertion: Fastify throws
|
|
134
|
+
// FST_ERR_DUPLICATED_ROUTE at registration time, so a double-registration
|
|
135
|
+
// never reaches the router tree to be counted.
|
|
136
|
+
app = await withEnabledModules('multi-tenant,signal-collectors', () =>
|
|
137
|
+
build(),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const lines = routeLines(app);
|
|
141
|
+
const experimentRoutes = lines.filter((line) =>
|
|
142
|
+
line.includes('waitlist-experiments'),
|
|
143
|
+
);
|
|
144
|
+
expect(experimentRoutes.length).toBeGreaterThan(0);
|
|
145
|
+
expect(new Set(experimentRoutes).size).toBe(experimentRoutes.length);
|
|
146
|
+
|
|
147
|
+
for (const { method, url } of [
|
|
148
|
+
...REVIEW_ROUTES,
|
|
149
|
+
...MULTI_TENANT_ONLY_ROUTES,
|
|
150
|
+
]) {
|
|
151
|
+
const res = await app.inject({ method, url });
|
|
152
|
+
expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('with NEITHER enabled (a Free build), no review route registers — Free is unaffected', async () => {
|
|
157
|
+
app = await withEnabledModules('', () => build());
|
|
158
|
+
|
|
159
|
+
for (const { method, url } of [
|
|
160
|
+
...REVIEW_ROUTES,
|
|
161
|
+
...MULTI_TENANT_ONLY_ROUTES,
|
|
162
|
+
]) {
|
|
163
|
+
const res = await app.inject({ method, url });
|
|
164
|
+
expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -58,7 +58,14 @@ const SHARED_TENANT_ROUTES = [
|
|
|
58
58
|
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/members` },
|
|
59
59
|
];
|
|
60
60
|
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Routes that must NOT register under `workspace` alone — the gate's other
|
|
63
|
+
* half. `/v1/waitlist-experiments` is on this list because the review surface
|
|
64
|
+
* is gated on `multi-tenant || signal-collectors` (#5677), neither of which is
|
|
65
|
+
* enabled in this file's `workspace`-only cases; it is no longer
|
|
66
|
+
* multi-tenant-EXCLUSIVE, and its own matrix is
|
|
67
|
+
* `review-surface-registration.test.ts`.
|
|
68
|
+
*/
|
|
62
69
|
const MULTI_TENANT_ONLY_ROUTES = [
|
|
63
70
|
{ method: 'GET' as const, url: '/v1/workspaces' },
|
|
64
71
|
{ method: 'GET' as const, url: '/v1/billing' },
|
|
@@ -114,6 +114,9 @@ export const prisma = {
|
|
|
114
114
|
// `findUnique` — used by the deliverable-resend service (#3998) to
|
|
115
115
|
// resolve a grant's stored lead email.
|
|
116
116
|
findUnique: jest.fn(),
|
|
117
|
+
// `findFirst` — the leads-inbox cursor lookup (#5678), which resolves a
|
|
118
|
+
// supplied cursor WITHIN the caller's scope before paging from it.
|
|
119
|
+
findFirst: jest.fn(),
|
|
117
120
|
findMany: jest.fn(),
|
|
118
121
|
updateMany: jest.fn(),
|
|
119
122
|
},
|