create-ailk 0.7.0 → 0.8.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/README.md +44 -10
- package/component-catalog.json +520 -5
- package/component-catalog.md +9 -6
- package/dist/cli.js +22 -24
- package/dist/lib/extract-module-bundle.js +5 -1
- package/dist/lib/module-license-gate.d.ts +79 -0
- package/dist/lib/module-license-gate.js +160 -12
- package/dist/lib/module-tier.d.ts +45 -16
- package/dist/lib/module-tier.js +76 -0
- package/dist/lib/pro-callout.js +10 -2
- package/dist/parse-args.d.ts +78 -10
- package/dist/parse-args.js +204 -14
- package/dist/programmatic.d.ts +39 -9
- package/dist/programmatic.js +163 -25
- package/dist/site-roster.js +11 -6
- package/dist/starter-grid.d.ts +216 -0
- package/dist/starter-grid.js +449 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.js +102 -43
- package/dist/template-manifest.d.ts +36 -17
- package/dist/template-manifest.js +121 -367
- package/dist/template-names.d.ts +14 -12
- package/dist/template-names.js +13 -17
- package/package.json +3 -3
- package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -1
- package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
- package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
- package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
- package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
- package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
- package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
- package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
- package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +69 -0
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +69 -0
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +69 -0
- package/site-templates/designs/standard/DESIGN.md +5 -0
- package/site-templates/marketing-site/DESIGN.md +5 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +89 -25
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +35 -14
- package/site-templates/marketing-site-free/DESIGN.md +5 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +95 -29
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +74 -44
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +17 -29
- package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -1
- package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
- package/site-templates/saas-product/DESIGN.md +5 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
- package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +96 -76
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +302 -0
- package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
- package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
- package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/{simple-service → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
- package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
- package/site-templates/shapes/local-business-service/provenance.json +4 -0
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +362 -0
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +300 -0
- package/site-templates/shapes/organization-service/ailk-template.json +10 -0
- package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
- package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/shapes/organization-service/provenance.json +4 -0
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +369 -0
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +299 -0
- package/site-templates/shapes/person-service/ailk-template.json +10 -0
- package/site-templates/shapes/person-service/content/_site.mdx +85 -0
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +169 -0
- package/site-templates/shapes/person-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
- package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
- package/site-templates/shapes/person-service/provenance.json +4 -0
- package/templates/.claude/skills/README.md +3 -2
- package/templates/.claude/skills/add-page/references/page-types.md +4 -0
- package/templates/.claude/skills/setup-database/SKILL.md +156 -0
- package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
- package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
- package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +19 -28
- package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
- package/templates/DESIGN.md +5 -0
- package/templates/README.md +46 -38
- package/templates/apps/api/CLAUDE.md +3 -2
- package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
- package/templates/apps/api/src/config/index.ts +35 -16
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
- package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
- package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
- package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
- package/templates/apps/api/src/openapi/spec.ts +63 -12
- package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
- package/templates/apps/api/src/routes/leads/index.ts +46 -1
- package/templates/apps/api/src/server.ts +7 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
- package/templates/apps/api/src/services/lead-routing.ts +140 -30
- package/templates/apps/api/src/services/leads-inbox.ts +48 -11
- package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
- package/templates/apps/mcp/src/tools/create_project.ts +21 -6
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
- package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
- package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
- package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
- package/templates/apps/web/app/[locale]/layout.tsx +37 -5
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +49 -0
- package/templates/apps/web/app/[locale]/use-cases/[slug]/page.tsx +18 -3
- package/templates/apps/web/lib/nav-config.ts +48 -36
- package/templates/apps/web/lib/site-identity.ts +14 -0
- package/templates/apps/web/messages/en.json +28 -2
- package/templates/content/_site.mdx +4 -2
- package/templates/content/en/BlogList/blog.mdx +37 -16
- package/templates/content/en/ContactPage/contact.mdx +14 -44
- package/templates/content/en/HomePage/home.mdx +8 -16
- package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
- package/templates/content/en/UseCasesList/use-cases.mdx +6 -6
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
- package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
- package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
- package/site-templates/simple-service/DESIGN.md +0 -162
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
- package/site-templates/simple-service/ailk-template.json +0 -11
- package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
- package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
- package/site-templates/simple-service/provenance.json +0 -4
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
- package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
- package/templates/content/en/SoftwareProduct/product.mdx +0 -175
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# `RUNBOOK.md` Template
|
|
2
2
|
|
|
3
3
|
The `setup-deploy` skill emits this runbook at the consumer's
|
|
4
|
-
project root. It documents pre-deploy steps
|
|
5
|
-
leads
|
|
4
|
+
project root. It documents pre-deploy steps and where the site's
|
|
5
|
+
leads go — the two lead destinations, **Email (default)** and
|
|
6
|
+
**Lead inbox (setup-database)**.
|
|
6
7
|
|
|
7
|
-
The skill must verify
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
The skill must verify both lead-destination headings are present
|
|
9
|
+
after emit (per SKILL.md Step 6 validation). If either is missing,
|
|
10
|
+
the skill has a bug.
|
|
10
11
|
|
|
11
12
|
## Template
|
|
12
13
|
|
|
@@ -35,109 +36,65 @@ Before you click Deploy in Vercel:
|
|
|
35
36
|
set in Vercel before deploy.
|
|
36
37
|
- [ ] **Configure your custom domain** if you have one (Vercel
|
|
37
38
|
Project → Settings → Domains). Vercel handles TLS automatically.
|
|
38
|
-
- [ ] **Pick
|
|
39
|
-
|
|
40
|
-
your
|
|
41
|
-
|
|
39
|
+
- [ ] **Pick where leads go** — see "Leads — where messages go"
|
|
40
|
+
below. With neither an email key nor a database, your contact
|
|
41
|
+
page shows your contact email instead of the form in
|
|
42
|
+
production, and no messages are collected.
|
|
43
|
+
- [ ] **On a host other than Vercel**, set `NODE_ENV=production` on
|
|
44
|
+
the API process — that is how it knows it is in production.
|
|
42
45
|
- [ ] **Register production OAuth callbacks** if you used
|
|
43
46
|
`add-auth` and activated Google or GitHub. Each provider
|
|
44
47
|
needs the prod callback URL added to its OAuth app settings.
|
|
45
48
|
|
|
46
49
|
---
|
|
47
50
|
|
|
48
|
-
##
|
|
51
|
+
## Leads — where messages go
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
> file **does not persist** across cold starts. Leads captured
|
|
53
|
-
> during one container's lifetime are lost when that container
|
|
54
|
-
> shuts down.
|
|
53
|
+
Where a contact-form message goes depends only on what you set on
|
|
54
|
+
the **API** project:
|
|
55
55
|
|
|
56
|
-
You
|
|
57
|
-
|
|
58
|
-
your
|
|
56
|
+
| You set | A message… |
|
|
57
|
+
| ----------------------------------------- | -------------------------------------------- |
|
|
58
|
+
| `DATABASE_URL` | lands in your lead inbox at `/leads` |
|
|
59
|
+
| the three email variables, no database | is emailed to you |
|
|
60
|
+
| neither (production) | is not collected — `/contact` shows your contact email instead of the form |
|
|
61
|
+
| neither (`pnpm dev`) | is written to a local file, for testing |
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
A newsletter signup needs the database: without one, `/newsletter`
|
|
64
|
+
offers no signup form. A visitor is never told a message was sent
|
|
65
|
+
when it reached nobody.
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
## Persistence upgrade — Vercel KV
|
|
67
|
+
### Email (default)
|
|
67
68
|
|
|
68
|
-
**When to pick:** you want
|
|
69
|
-
|
|
70
|
-
storage, native to the Vercel dashboard.
|
|
69
|
+
**When to pick:** you want messages in your inbox with no database
|
|
70
|
+
to run. This is the default for a new site.
|
|
71
71
|
|
|
72
72
|
**Setup:**
|
|
73
73
|
|
|
74
|
-
1.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
5. Redeploy.
|
|
74
|
+
1. Create an API key at <https://resend.com/api-keys> and verify the
|
|
75
|
+
domain you will send from (Resend Dashboard → Domains).
|
|
76
|
+
2. On the **API** project in Vercel (Settings → Environment
|
|
77
|
+
Variables), set:
|
|
78
|
+
- `RESEND_API_KEY` — the key.
|
|
79
|
+
- `AILK_LEAD_NOTIFICATION_TO` — the address messages go to (yours).
|
|
80
|
+
- `AILK_LEAD_NOTIFICATION_FROM` — a sender on your verified domain.
|
|
81
|
+
3. Redeploy the API.
|
|
83
82
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
---
|
|
83
|
+
All three are required: a key without both addresses sends nothing.
|
|
84
|
+
If Resend refuses a message, the visitor is told it could not be
|
|
85
|
+
sent, so they can try again or email you directly.
|
|
89
86
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
**When to pick:** you want one provider for database, auth, and
|
|
93
|
-
storage. You're not committed to NextAuth (Supabase has its own
|
|
94
|
-
auth), or you're fine running NextAuth + Supabase auth in parallel.
|
|
95
|
-
|
|
96
|
-
**Setup:**
|
|
87
|
+
### Lead inbox (setup-database)
|
|
97
88
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
3. Go to Settings → API. Copy:
|
|
101
|
-
- `Project URL` → `NEXT_PUBLIC_SUPABASE_URL`
|
|
102
|
-
- `anon public` key → `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
|
103
|
-
- `service_role` key → `SUPABASE_SERVICE_ROLE_KEY` (server-only)
|
|
104
|
-
4. Set those three env vars in Vercel project settings.
|
|
105
|
-
5. Run the AILK Supabase migration (see `@working-theory/database` Supabase
|
|
106
|
-
adapter README — the migration creates a `leads` table with the
|
|
107
|
-
AILK schema).
|
|
108
|
-
6. Switch the lead-capture writer to use the Supabase adapter.
|
|
109
|
-
7. Redeploy.
|
|
110
|
-
|
|
111
|
-
**Trade-offs:** Supabase is generous on free tier, scales well, and
|
|
112
|
-
its dashboard is excellent. The auth coexistence with NextAuth is
|
|
113
|
-
the only design wrinkle — most consumers either use NextAuth alone
|
|
114
|
-
(treating Supabase as just a Postgres host) or commit to Supabase
|
|
115
|
-
auth and remove NextAuth.
|
|
116
|
-
|
|
117
|
-
---
|
|
118
|
-
|
|
119
|
-
## Persistence upgrade — Postgres via `@working-theory/database`
|
|
120
|
-
|
|
121
|
-
**When to pick:** you already have Postgres somewhere — Neon,
|
|
122
|
-
Railway, RDS, self-hosted, anywhere. You don't need a new provider
|
|
123
|
-
relationship.
|
|
124
|
-
|
|
125
|
-
**Setup:**
|
|
89
|
+
**When to pick:** you want every message kept, searchable, in a
|
|
90
|
+
lead inbox you review signed in — and newsletter signups collected.
|
|
126
91
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
provider's equivalent).
|
|
134
|
-
5. The `@working-theory/database` package exposes a Prisma client tied to the
|
|
135
|
-
AILK schema; switch the lead-capture writer to use it.
|
|
136
|
-
6. Redeploy.
|
|
137
|
-
|
|
138
|
-
**Trade-offs:** most flexible, most setup. You manage the database
|
|
139
|
-
yourself (backups, connection pooling, scaling). For consumers who
|
|
140
|
-
already have Postgres in their stack, this is the natural fit.
|
|
92
|
+
**Setup:** run the `setup-database` skill ("set up the database"). It
|
|
93
|
+
walks you through provisioning Postgres, setting `DATABASE_URL`,
|
|
94
|
+
running migrations, pinning you as the owner
|
|
95
|
+
(`AILK_OPERATOR_EMAIL`), and sending one test message that must
|
|
96
|
+
appear in `/leads` before it reports done. If the email variables
|
|
97
|
+
are also set, you still get an email for each new lead.
|
|
141
98
|
|
|
142
99
|
---
|
|
143
100
|
|
|
@@ -204,9 +161,9 @@ You do **not** need to redeploy after:
|
|
|
204
161
|
|
|
205
162
|
## Troubleshooting
|
|
206
163
|
|
|
207
|
-
- **"My
|
|
208
|
-
|
|
209
|
-
|
|
164
|
+
- **"My contact page shows an email address instead of the form"**
|
|
165
|
+
→ the API has no lead destination. Set the three email variables,
|
|
166
|
+
or run `setup-database` (see "Leads — where messages go").
|
|
210
167
|
- **"OAuth login redirects to localhost in production"** → you
|
|
211
168
|
haven't added the production callback URL to the OAuth app
|
|
212
169
|
(see above).
|
|
@@ -219,15 +176,14 @@ You do **not** need to redeploy after:
|
|
|
219
176
|
|
|
220
177
|
## Section headings the validation must find
|
|
221
178
|
|
|
222
|
-
After emit, the skill must grep for these
|
|
223
|
-
match).
|
|
179
|
+
After emit, the skill must grep for these two headings (verbatim
|
|
180
|
+
match). Both must be present:
|
|
224
181
|
|
|
225
|
-
-
|
|
226
|
-
-
|
|
227
|
-
- `## Persistence upgrade — Postgres via `@working-theory/database\``
|
|
182
|
+
- `### Email (default)`
|
|
183
|
+
- `### Lead inbox (setup-database)`
|
|
228
184
|
|
|
229
|
-
If
|
|
230
|
-
contract. Surface the failure to the consumer (not silent).
|
|
185
|
+
If either is missing, the template was edited in a way that broke
|
|
186
|
+
the contract. Surface the failure to the consumer (not silent).
|
|
231
187
|
|
|
232
188
|
## Merge logic
|
|
233
189
|
|
|
@@ -235,8 +191,8 @@ If the consumer already has `RUNBOOK.md`, the skill (per SKILL.md
|
|
|
235
191
|
Step 3) offers merge / overwrite / skip. Merge logic:
|
|
236
192
|
|
|
237
193
|
1. Read the existing file.
|
|
238
|
-
2. Check whether
|
|
239
|
-
headings
|
|
194
|
+
2. Check whether the "Leads — where messages go" section and its two
|
|
195
|
+
lead-destination headings are already present.
|
|
240
196
|
3. Headings already present → not modified.
|
|
241
197
|
4. Headings missing → append at the end of the existing file under:
|
|
242
198
|
```
|
package/templates/DESIGN.md
CHANGED
|
@@ -160,3 +160,8 @@ The foundation's pinned direction is the pacing of a classic wireframe marketing
|
|
|
160
160
|
- **Don't** add a colour accent. The foundation's one idea is black on white; a brand pass adds colour deliberately, through the theme.
|
|
161
161
|
- **Don't** replace a black rule with a shadow or a grey fill.
|
|
162
162
|
- **Don't** hard-code the "heading toward" values above into a page. They arrive through the platform, or not at all.
|
|
163
|
+
|
|
164
|
+
## Revisions
|
|
165
|
+
|
|
166
|
+
- **2026-09-24 — tile hover is outline-only (operator ruling, /build-starter live pass, #6687).** A comparison tile (`items` in `variant: tile`) hovers by shifting its border to the accent only — no lift, no glow — matching the Positionary `/alternatives` ruling of 2026-08-29. Expressed as the bounded `tileHover: "outline"` option (Card `wash-outline`); the library default `lift` is unchanged for every other site.
|
|
167
|
+
- **2026-09-25 — the before-after section's rulings (operator, /build-starter live pass, #6687).** One layout, not two variants: a panel body is an image or rows of content under the same label bar. The label bar is the tone's fill (brand → black bar, white text) at the heading-4 role; a `neutral` panel keeps a white bar. The view selector is underline tabs with the underline under the current tab only (no full-width baseline), active black, inactive muted, black on hover. The pager is a ghost "‹ Previous" at the left edge and "Next ›" at the right edge. The catalyst column carries no icon tile — it starts at the arrow, or at an image when one is set. No back-to-all link and no tag chips on the seed pages.
|
package/templates/README.md
CHANGED
|
@@ -39,34 +39,36 @@ pnpm build # production build
|
|
|
39
39
|
|
|
40
40
|
The `content/` directory is locale-prefixed: every page lives at `content/<locale>/<type>/<slug>.mdx`. The default locale is `en` (English); the scaffold ships English content only. Each subdirectory under `content/en/` is a schema-typed page surface, and the filename is the slug.
|
|
41
41
|
|
|
42
|
-
The pages of the Free
|
|
42
|
+
The pages of the Free `organization-product` shape, and where each one's copy lives:
|
|
43
43
|
|
|
44
44
|
<!-- generated:begin content -->
|
|
45
45
|
|
|
46
|
-
| Page | Content file | Ships in
|
|
47
|
-
| ------------------- | --------------------------------------------------- |
|
|
48
|
-
| `/` | `content/en/HomePage/home.mdx` | every scaffold
|
|
49
|
-
| `/
|
|
50
|
-
| `/
|
|
51
|
-
| `/use-cases` | `content/en/UseCasesList/use-cases.mdx` | `--
|
|
52
|
-
| `/use-cases/[slug]` | — (a code route) | `--
|
|
53
|
-
| `/pricing` | `content/en/Pricing/pricing.mdx` | `--
|
|
54
|
-
| `/about` | `content/en/AboutPage/about.mdx` | every scaffold
|
|
55
|
-
| `/faq` | `content/en/FAQPage/faq.mdx` | every scaffold
|
|
56
|
-
| `/contact` | `content/en/ContactPage/contact.mdx` | every scaffold
|
|
57
|
-
| `/glossary` | `content/en/GlossaryList/glossary.mdx` | `--
|
|
58
|
-
| `/newsletter` | `content/en/NewsletterSignup/newsletter.mdx` | `--
|
|
59
|
-
| `/book-a-call` | `content/en/BookCall/book-call.mdx` | `--
|
|
60
|
-
| `/sign-in` | — (a code route) | every scaffold
|
|
61
|
-
| `/sign-up` | — (a code route) | every scaffold
|
|
62
|
-
| `/privacy` | `content/en/PrivacyPage/privacy.mdx` | `--
|
|
63
|
-
| `/terms` | `content/en/TermsPage/terms.mdx` | `--
|
|
64
|
-
| `/purchase-policy` | `content/en/PurchasePolicyPage/purchase-policy.mdx` | every scaffold
|
|
65
|
-
| `/blog` | `content/en/BlogList/blog.mdx` | every scaffold
|
|
46
|
+
| Page | Content file | Ships in |
|
|
47
|
+
| ------------------- | --------------------------------------------------- | ------------------------------ |
|
|
48
|
+
| `/` | `content/en/HomePage/home.mdx` | every scaffold |
|
|
49
|
+
| `/features` | `content/en/FeaturesList/features.mdx` | `--shape organization-product` |
|
|
50
|
+
| `/features/[slug]` | — (a code route) | `--shape organization-product` |
|
|
51
|
+
| `/use-cases` | `content/en/UseCasesList/use-cases.mdx` | `--shape organization-product` |
|
|
52
|
+
| `/use-cases/[slug]` | — (a code route) | `--shape organization-product` |
|
|
53
|
+
| `/pricing` | `content/en/Pricing/pricing.mdx` | `--shape organization-product` |
|
|
54
|
+
| `/about` | `content/en/AboutPage/about.mdx` | every scaffold |
|
|
55
|
+
| `/faq` | `content/en/FAQPage/faq.mdx` | every scaffold |
|
|
56
|
+
| `/contact` | `content/en/ContactPage/contact.mdx` | every scaffold |
|
|
57
|
+
| `/glossary` | `content/en/GlossaryList/glossary.mdx` | `--shape organization-product` |
|
|
58
|
+
| `/newsletter` | `content/en/NewsletterSignup/newsletter.mdx` | `--shape organization-product` |
|
|
59
|
+
| `/book-a-call` | `content/en/BookCall/book-call.mdx` | `--shape organization-product` |
|
|
60
|
+
| `/sign-in` | — (a code route) | every scaffold |
|
|
61
|
+
| `/sign-up` | — (a code route) | every scaffold |
|
|
62
|
+
| `/privacy` | `content/en/PrivacyPage/privacy.mdx` | `--shape organization-product` |
|
|
63
|
+
| `/terms` | `content/en/TermsPage/terms.mdx` | `--shape organization-product` |
|
|
64
|
+
| `/purchase-policy` | `content/en/PurchasePolicyPage/purchase-policy.mdx` | every scaffold |
|
|
65
|
+
| `/blog` | `content/en/BlogList/blog.mdx` | every scaffold |
|
|
66
|
+
| `/portfolio` | `content/en/PortfolioList/portfolio.mdx` | `--shape organization-product` |
|
|
67
|
+
| `/portfolio/[slug]` | — (a code route) | `--shape organization-product` |
|
|
66
68
|
|
|
67
69
|
<!-- generated:end -->
|
|
68
70
|
|
|
69
|
-
A page marked `--
|
|
71
|
+
A page marked `--shape organization-product` is in your site when you scaffolded that shape, or an alias of it such as `--template marketing-site-free`. Another shape has its own page set; your site's roster of record is `.ailk-site-manifest.json` at its root.
|
|
70
72
|
|
|
71
73
|
To customize the home page, edit [`content/en/HomePage/home.mdx`](./content/en/HomePage/home.mdx). Save and reload the dev server. No build step.
|
|
72
74
|
|
|
@@ -124,12 +126,12 @@ Set `AILK_PUBLIC_DOMAIN` to your own domain. `STEALTH=true` keeps a fresh deploy
|
|
|
124
126
|
|
|
125
127
|
You need Postgres once you turn on one of these:
|
|
126
128
|
|
|
127
|
-
| Surface | What it stores
|
|
128
|
-
| -------------------------------------------------------------- |
|
|
129
|
-
| **Auth** — `/sign-in`, `/sign-up`, `/account` | users, sessions, accounts
|
|
130
|
-
| **Lead inbox** — `/leads` | submitted leads, for you to review signed in.
|
|
131
|
-
| **Checkout returns** — `/purchase/success`, `/purchase/cancel` | the paid checkouts the Stripe webhook records. These pages serve only on a site licensed for commerce.
|
|
132
|
-
| **`CONTENT_ADAPTER=database`** | pages authored in the Artifact store rather than in `content/*.mdx`
|
|
129
|
+
| Surface | What it stores |
|
|
130
|
+
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
131
|
+
| **Auth** — `/sign-in`, `/sign-up`, `/account` | users, sessions, accounts |
|
|
132
|
+
| **Lead inbox** — `/leads` | submitted leads, for you to review signed in, and newsletter signups. Without a database, lead capture emails each contact-form message to you instead (`RESEND_API_KEY` + `AILK_LEAD_NOTIFICATION_TO` + `AILK_LEAD_NOTIFICATION_FROM` on the API); with neither in production, `/contact` shows your `_site.mdx` `contactEmail` instead of the form. The `setup-database` skill walks you from no database to a working inbox. |
|
|
133
|
+
| **Checkout returns** — `/purchase/success`, `/purchase/cancel` | the paid checkouts the Stripe webhook records. These pages serve only on a site licensed for commerce. |
|
|
134
|
+
| **`CONTENT_ADAPTER=database`** | pages authored in the Artifact store rather than in `content/*.mdx` |
|
|
133
135
|
|
|
134
136
|
### One command to get one
|
|
135
137
|
|
|
@@ -162,12 +164,17 @@ The [deploy guide](https://ailaunchkit.ai/docs/guides/deploy-to-production) is w
|
|
|
162
164
|
|
|
163
165
|
Paid levels and the commerce add-on are bought at <https://ailaunchkit.ai/pricing>; your licence key arrives by email.
|
|
164
166
|
|
|
165
|
-
Today the key is used at scaffold time. Set `AILK_LICENSE_KEY` and scaffold the
|
|
167
|
+
Today the key is used at scaffold time. Set `AILK_LICENSE_KEY` and scaffold with the shape you want — the key sets the level and switches on its modules:
|
|
166
168
|
|
|
167
169
|
```bash
|
|
168
|
-
|
|
170
|
+
# Free — no key
|
|
171
|
+
pnpm create ailk@latest <dir> --shape <shape>
|
|
172
|
+
# Paid — the key sets the level and switches on its modules
|
|
173
|
+
AILK_LICENSE_KEY=<your key> pnpm create ailk@latest <dir> --shape <shape>
|
|
169
174
|
```
|
|
170
175
|
|
|
176
|
+
`<shape>` is one of `organization-product`, `organization-service`, `local-business-service`, `person-service`. `--modules` trims the level's modules, or adds the commerce add-on or the workspace module. `--design minimalism` picks the other look and needs a paid key.
|
|
177
|
+
|
|
171
178
|
Applying a paid module to an existing site in place is not yet supported. To move this site onto a paid template, scaffold the new one and bring your `content/` directory and `content/_site.mdx` across.
|
|
172
179
|
|
|
173
180
|
## Upgrading
|
|
@@ -186,14 +193,15 @@ This project ships Claude Code skills in `.claude/skills/`. Claude Code discover
|
|
|
186
193
|
|
|
187
194
|
<!-- generated:begin skills -->
|
|
188
195
|
|
|
189
|
-
| Skill
|
|
190
|
-
|
|
|
191
|
-
| `add-page`
|
|
192
|
-
| `add-auth`
|
|
193
|
-
| `setup-deploy`
|
|
194
|
-
| `
|
|
195
|
-
| `
|
|
196
|
-
| `
|
|
196
|
+
| Skill | What it does |
|
|
197
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
198
|
+
| `add-page` | Generate a new page: App Router route + MDX content stub + typed JSON-LD, in one confirm-before-write pass. Also documents the `schema:` frontmatter override for adding supplementary structured data to a page that already exists. |
|
|
199
|
+
| `add-auth` | Activate auth providers — magic-link via Resend, Google OAuth, GitHub OAuth. |
|
|
200
|
+
| `setup-deploy` | Emit deploy configuration — `vercel.json`, `.env.example`, and `RUNBOOK.md` — for a Vercel deploy. Asks where leads go (email or a database) and warns when neither is set. |
|
|
201
|
+
| `setup-database` | Take the site from no database to a working lead inbox: provision Postgres, create the tables, pin the owner, and verify one test message lands in `/leads`. |
|
|
202
|
+
| `check-site` | Run `ailk-audit --json` and explain the score and findings in plain language. |
|
|
203
|
+
| `fill-page` | Fill one page's section content from a category profile: labelled sample content (tier 1) or your own first draft from a short intake (tier 2). |
|
|
204
|
+
| `upgrade-plan` | Compare Free, Pro, Pro Plus, Agency and the commerce add-on side by side, with the prices the public pricing page publishes right now, and recommend one option for this site with a reason. |
|
|
197
205
|
|
|
198
206
|
<!-- generated:end -->
|
|
199
207
|
|
|
@@ -36,8 +36,9 @@ Every endpoint here has a 1:1 MCP tool in `apps/mcp`. When you add, change, or r
|
|
|
36
36
|
|
|
37
37
|
Routes are versioned under `/v1/*`:
|
|
38
38
|
|
|
39
|
-
- `/v1/leads/{submit,subscribe,callback}` — lead capture, newsletter subscription, callback request
|
|
40
|
-
- `/v1/leads` (GET
|
|
39
|
+
- `/v1/leads/{submit,subscribe,callback}` — lead capture, newsletter subscription, callback request. Where a lead goes is `src/lib/lead-delivery.ts` (#6730): `DATABASE_URL` set → the database (the inbox); no database but `RESEND_API_KEY` + `AILK_LEAD_NOTIFICATION_TO` + `AILK_LEAD_NOTIFICATION_FROM` → the owner email IS the delivery (`persisted: "email"`, a refused send is `503 delivery_failed`); neither outside production → the local file fallback; neither in production → `503 lead_capture_not_configured` before any write. A newsletter signup needs a database (or the local file). A lead is never reported captured when nothing stored or delivered it.
|
|
40
|
+
- `/v1/leads/capture` (GET) — `{ ok: true, data: { contact, newsletter } }`, two booleans saying whether a submit / a subscribe would be accepted (#6730). The web contact page reads it to show the form or the site's contact email. Never names the delivery mode, an address or an env var. Registered at the top level, outside `leadsScope`'s strict per-IP limit. No MCP twin — a recorded exception on the same footing as the anonymous form-lifecycle routes: an agent needs no preflight, because the three lead-write tools already return the 503 when capture is not accepted.
|
|
41
|
+
- `/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:** on a build with no `multi-tenant` module `resolveOwnerWorkspaces` resolves none (a deliberate scope limit since #5677), so there the scope is the default workspace, granted only through `services/default-tenant.ts`'s `resolveDefaultTenantForOperator` — `AILK_OPERATOR_EMAIL` set, the caller's verified email equal to it, a one-workspace deployment, role `owner|admin` — and every other caller gets the empty page (#6730); 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/*`).
|
|
41
42
|
- `/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).
|
|
42
43
|
- `/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).
|
|
43
44
|
- `/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`). **Three grant classes since #6310**, keyed on `grant.assetId`'s ADR-0031 prefix: `playbook:` (unchanged, the FEAT-074 U4b virtual asset); `asset_` — a workspace `Asset`, resolved through the named boundary `src/lib/deliverable-asset-store.ts`, scoped by `grant.workspaceId` (a serve-time tenancy re-check restored for this class — D15) — served `attachment` with an extension pinned to the stored content type, `nosniff`, a non-raster `Content-Security-Policy` (for EVERY content type, including raster — never `inline`), and `Cache-Control: private, no-store` so a deleted asset stops downloading immediately; `dasset_` — the LEGACY `DeliverableAsset` store (unchanged, tried only when a magnet's `assetKey` does not resolve against `Asset`). Every miss on the `asset_` class — a NULL `workspaceId`, no row, a foreign-workspace row — folds into the SAME uniform 404 as every other class; there is deliberately no redirect to the public `GET /v1/assets/:assetId` (that route's year-long `immutable` cache would defeat deletion as a revocation lever, and would collapse every lead's capability onto one workspace-canonical id — spec #6310 §9 SEC-001/SEC-002).
|
|
@@ -42,31 +42,49 @@ describe('Config', () => {
|
|
|
42
42
|
expect(dotenv.config).toHaveBeenCalled();
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// #6730 security review (M1): a production build whose enabled modules keep
|
|
46
|
+
// their state in the database still refuses to boot without DATABASE_URL —
|
|
47
|
+
// a missing database there is a broken deploy (e.g. a commerce licence read
|
|
48
|
+
// as absent would ack-and-drop signed Stripe events), not a Free site.
|
|
49
|
+
it.each(['commerce', 'content-write', 'multi-tenant', 'scheduling', 'signal-collectors', 'workspace'])(
|
|
50
|
+
'should exit on missing DATABASE_URL in production when the %s module is enabled',
|
|
51
|
+
(moduleName) => {
|
|
52
|
+
process.env.NODE_ENV = 'production';
|
|
53
|
+
process.env.AILK_ENABLED_MODULES = `marketing,${moduleName}`;
|
|
54
|
+
delete process.env.DATABASE_URL;
|
|
55
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
56
|
+
throw new Error('Process exit called');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(() => require('../index')).toThrow('Process exit called');
|
|
60
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
61
|
+
expect(mockLoggerError).toHaveBeenCalled();
|
|
62
|
+
|
|
63
|
+
exitSpy.mockRestore();
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
it('should start database-less in production with only non-database modules enabled', () => {
|
|
47
68
|
process.env.NODE_ENV = 'production';
|
|
69
|
+
process.env.AILK_ENABLED_MODULES = 'marketing,docs-blog';
|
|
48
70
|
delete process.env.DATABASE_URL;
|
|
49
|
-
|
|
50
71
|
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
51
72
|
throw new Error('Process exit called');
|
|
52
73
|
});
|
|
53
|
-
mockLoggerError.mockClear();
|
|
54
74
|
|
|
55
|
-
|
|
56
|
-
expect(() => {
|
|
57
|
-
require('../index');
|
|
58
|
-
}).toThrow('Process exit called');
|
|
59
|
-
|
|
60
|
-
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
61
|
-
expect(mockLoggerError).toHaveBeenCalled();
|
|
75
|
+
const { config } = require('../index');
|
|
62
76
|
|
|
77
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
78
|
+
expect(config.DATABASE_URL).toBeUndefined();
|
|
63
79
|
exitSpy.mockRestore();
|
|
64
80
|
});
|
|
65
81
|
|
|
66
|
-
// #6482 —
|
|
67
|
-
//
|
|
68
|
-
//
|
|
82
|
+
// #6482 — a missing DATABASE_URL starts the API database-less (one
|
|
83
|
+
// warning) instead of exiting, so a fresh scaffold's `pnpm dev` keeps its
|
|
84
|
+
// "no database needed" promise. #6730 (operator decision (a)) extends that
|
|
85
|
+
// to production: a Free site delivers leads by email with no Postgres.
|
|
69
86
|
it.each([
|
|
87
|
+
['production', 'production'],
|
|
70
88
|
['development', 'development'],
|
|
71
89
|
['unset', undefined],
|
|
72
90
|
['test', 'test'],
|
|
@@ -130,3 +148,86 @@ describe('Config', () => {
|
|
|
130
148
|
expect(config.FRONTEND_URL).toBe('http://localhost:3000');
|
|
131
149
|
});
|
|
132
150
|
});
|
|
151
|
+
|
|
152
|
+
// #6730 (operator decision (a)) — the REAL server boots in production with
|
|
153
|
+
// no database: an email-only Free site must be able to run its API, answer
|
|
154
|
+
// `GET /v1/leads/capture`, and refuse authenticated routes with a 503 rather
|
|
155
|
+
// than exit at startup.
|
|
156
|
+
describe('production boot without a database (#6730)', () => {
|
|
157
|
+
const originalEnv = process.env;
|
|
158
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the Fastify instance comes from an isolated require; its generics add nothing here
|
|
159
|
+
let app: any;
|
|
160
|
+
|
|
161
|
+
const EMAIL_ENV = {
|
|
162
|
+
RESEND_API_KEY: 're_test',
|
|
163
|
+
AILK_LEAD_NOTIFICATION_TO: 'owner@example.com',
|
|
164
|
+
AILK_LEAD_NOTIFICATION_FROM: 'noreply@example.com',
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// External boundaries only, as src/__tests__/server.test.ts mocks them.
|
|
168
|
+
beforeAll(() => {
|
|
169
|
+
jest.doMock('@fastify/cors', () => jest.fn().mockResolvedValue(undefined));
|
|
170
|
+
jest.doMock('@fastify/rate-limit', () => jest.fn().mockResolvedValue(undefined));
|
|
171
|
+
jest.doMock('@working-theory/email', () => ({
|
|
172
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: 'm' }),
|
|
173
|
+
sendRequestConfirmEmail: jest.fn(),
|
|
174
|
+
sendRequestPaidLinkEmail: jest.fn(),
|
|
175
|
+
}));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
afterEach(async () => {
|
|
179
|
+
if (app) await app.close();
|
|
180
|
+
app = undefined;
|
|
181
|
+
process.env = originalEnv;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
async function bootProduction(extra: Record<string, string> = {}) {
|
|
185
|
+
jest.resetModules();
|
|
186
|
+
const env: NodeJS.ProcessEnv = { ...originalEnv, NODE_ENV: 'production', ...extra };
|
|
187
|
+
delete env.DATABASE_URL;
|
|
188
|
+
delete env.DATABASE_URL_NON_POOLED;
|
|
189
|
+
delete env.RESEND_API_KEY;
|
|
190
|
+
delete env.AILK_LEAD_NOTIFICATION_TO;
|
|
191
|
+
delete env.AILK_LEAD_NOTIFICATION_FROM;
|
|
192
|
+
Object.assign(env, extra);
|
|
193
|
+
process.env = env;
|
|
194
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
195
|
+
throw new Error('Process exit called');
|
|
196
|
+
});
|
|
197
|
+
try {
|
|
198
|
+
const { build } = require('../../server');
|
|
199
|
+
app = await build();
|
|
200
|
+
} finally {
|
|
201
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
202
|
+
exitSpy.mockRestore();
|
|
203
|
+
}
|
|
204
|
+
return app;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
it('with only the email vars, starts and reports contact accepted, newsletter not', async () => {
|
|
208
|
+
const server = await bootProduction(EMAIL_ENV);
|
|
209
|
+
|
|
210
|
+
const res = await server.inject({ method: 'GET', url: '/v1/leads/capture' });
|
|
211
|
+
|
|
212
|
+
expect(res.statusCode).toBe(200);
|
|
213
|
+
expect(res.json()).toEqual({ ok: true, data: { contact: true, newsletter: false } });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('with nothing configured, starts and reports nothing accepted', async () => {
|
|
217
|
+
const server = await bootProduction();
|
|
218
|
+
|
|
219
|
+
const res = await server.inject({ method: 'GET', url: '/v1/leads/capture' });
|
|
220
|
+
|
|
221
|
+
expect(res.statusCode).toBe(200);
|
|
222
|
+
expect(res.json()).toEqual({ ok: true, data: { contact: false, newsletter: false } });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('refuses an authenticated route with 503 database_not_configured', async () => {
|
|
226
|
+
const server = await bootProduction(EMAIL_ENV);
|
|
227
|
+
|
|
228
|
+
const res = await server.inject({ method: 'GET', url: '/v1/leads' });
|
|
229
|
+
|
|
230
|
+
expect(res.statusCode).toBe(503);
|
|
231
|
+
expect(res.json()).toEqual({ error: 'database_not_configured' });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { logger } from '@working-theory/observability';
|
|
8
8
|
import * as dotenv from 'dotenv';
|
|
9
9
|
|
|
10
|
+
import { isModuleEnabled } from './modules.js';
|
|
11
|
+
|
|
10
12
|
dotenv.config();
|
|
11
13
|
|
|
12
14
|
export interface Config {
|
|
@@ -92,28 +94,45 @@ export interface Config {
|
|
|
92
94
|
CAL_WEBHOOK_SECRET?: string;
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
/** Enabled modules whose state lives in the database (see validateConfig). */
|
|
98
|
+
const DATABASE_MODULES = [
|
|
99
|
+
'commerce',
|
|
100
|
+
'content-write',
|
|
101
|
+
'multi-tenant',
|
|
102
|
+
'scheduling',
|
|
103
|
+
'signal-collectors',
|
|
104
|
+
'workspace',
|
|
105
|
+
] as const;
|
|
106
|
+
|
|
95
107
|
function validateConfig(): Config {
|
|
96
|
-
const errors: string[] = [];
|
|
97
108
|
const nodeEnv = (process.env.NODE_ENV || 'development') as Config['NODE_ENV'];
|
|
98
109
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
110
|
+
// No database is a supported shape in every environment, production
|
|
111
|
+
// included (#6730 operator decision (a)): a Free site delivers leads by
|
|
112
|
+
// email and needs no Postgres. Start database-less and let the auth
|
|
113
|
+
// preHandler answer 503 database_not_configured (#6482) — it reads
|
|
114
|
+
// DATABASE_URL per request, whatever NODE_ENV says, so no authenticated
|
|
115
|
+
// route runs without a database.
|
|
116
|
+
//
|
|
117
|
+
// Except a production build whose ENABLED modules keep their state in the
|
|
118
|
+
// database. There a missing DATABASE_URL is a broken deploy, not a Free
|
|
119
|
+
// site: e.g. a commerce site's licence lives in the database-backed store,
|
|
120
|
+
// so booting without it would read the site as unlicensed and ack-and-drop
|
|
121
|
+
// every signed Stripe event (#6730 security review, M1). Those builds keep
|
|
122
|
+
// the loud startup failure.
|
|
102
123
|
if (!process.env.DATABASE_URL) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'⚠️ DATABASE_URL not set — starting database-less; authenticated routes answer 503 database_not_configured',
|
|
124
|
+
const needsDatabase = DATABASE_MODULES.filter((m) => isModuleEnabled(m));
|
|
125
|
+
if (nodeEnv === 'production' && needsDatabase.length > 0) {
|
|
126
|
+
logger.error(
|
|
127
|
+
{ modules: needsDatabase },
|
|
128
|
+
'❌ DATABASE_URL is required in production when these modules are enabled',
|
|
109
129
|
);
|
|
130
|
+
process.exit(1);
|
|
110
131
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
logger.error({ errors }, '❌ Configuration validation failed');
|
|
116
|
-
process.exit(1);
|
|
132
|
+
logger.warn(
|
|
133
|
+
{ nodeEnv },
|
|
134
|
+
'⚠️ DATABASE_URL not set — starting database-less; authenticated routes answer 503 database_not_configured',
|
|
135
|
+
);
|
|
117
136
|
}
|
|
118
137
|
|
|
119
138
|
return {
|