@forumone/create-throughline 0.3.1 → 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/CHANGELOG.md +125 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +5 -0
- package/dist/generator.js.map +1 -1
- package/dist/post-install.d.ts.map +1 -1
- package/dist/post-install.js +10 -3
- package/dist/post-install.js.map +1 -1
- package/dist/templates/base/.env.example +32 -18
- package/dist/templates/base/CLAUDE.md +112 -0
- package/dist/templates/base/README.md +92 -7
- package/dist/templates/base/apps/web/e2e/site.ts +36 -0
- package/dist/templates/base/apps/web/e2e/smoke.spec.ts +52 -0
- package/dist/templates/base/apps/web/package.json.template +23 -7
- package/dist/templates/base/apps/web/playwright.config.ts +63 -0
- package/dist/templates/base/apps/web/scripts/check-migrations.ts +147 -0
- package/dist/templates/base/apps/web/scripts/lib/target.ts +95 -0
- package/dist/templates/base/apps/web/scripts/write-migration-snapshot.ts +119 -0
- package/dist/templates/base/apps/web/src/access/anonymousAccess.test.ts +36 -0
- package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +41 -4
- package/dist/templates/base/apps/web/src/instrumentation.ts +35 -0
- package/dist/templates/base/apps/web/src/lib/cache-tags.ts +23 -0
- package/dist/templates/base/apps/web/src/lib/database.ts +158 -0
- package/dist/templates/base/apps/web/src/payload.config.ts.template +136 -19
- package/dist/templates/base/apps/web/vitest.config.ts +57 -0
- package/dist/templates/base/claude/hooks/gate.sh +45 -0
- package/dist/templates/base/claude/settings.json +16 -0
- package/dist/templates/base/github/scripts/check-generated.sh +44 -0
- package/dist/templates/base/github/workflows/ci.yml +143 -0
- package/dist/templates/base/gitignore +9 -0
- package/dist/templates/base/package.json.template +7 -1
- package/dist/templates/base/scripts/check-audit.ts +117 -0
- package/dist/templates/base/scripts/check-single-instance.ts +113 -0
- package/dist/templates/base/scripts/check-tested-packages.ts +163 -0
- package/dist/templates/base/turbo.json +16 -1
- package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +59 -29
- package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +26 -1
- package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.contract.ts +1 -0
- package/dist/versions.json +11 -11
- package/package.json +5 -4
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Which connection string to connect with, and the name of the variable it came
|
|
3
|
+
from.
|
|
4
|
+
|
|
5
|
+
Three things make this more than `process.env.DATABASE_URI!`.
|
|
6
|
+
|
|
7
|
+
**The Vercel–Neon integration does not set `DATABASE_URI`.** It injects
|
|
8
|
+
`DATABASE_URL`, `DATABASE_URL_UNPOOLED`, `POSTGRES_URL`,
|
|
9
|
+
`POSTGRES_URL_NON_POOLING` and friends. Left alone, the first deploy fails at
|
|
10
|
+
page-data collection with a connection error that says nothing about a missing
|
|
11
|
+
variable.
|
|
12
|
+
|
|
13
|
+
**The app wants the pooler and a migration cannot use it.** Neon's pooled
|
|
14
|
+
endpoint is PgBouncer in transaction mode: right for a serving function, whose
|
|
15
|
+
connections are many and short, and unusable for DDL, where Drizzle relies on
|
|
16
|
+
prepared statements and session state. `migrate` hangs or fails partway. The two
|
|
17
|
+
hostnames differ only by a `-pooler` suffix on the endpoint id.
|
|
18
|
+
|
|
19
|
+
**`DATABASE_URI` leads both lists, deliberately.** It is the variable you scope
|
|
20
|
+
per environment — a Preview-scoped `DATABASE_URI` pointing at a preview branch —
|
|
21
|
+
while the integration's variables are project-wide and resolve to production. A
|
|
22
|
+
preview build that fell through to one of those while migrating would migrate
|
|
23
|
+
production.
|
|
24
|
+
|
|
25
|
+
If you deploy somewhere other than Vercel + Neon, set `DATABASE_URI` and the
|
|
26
|
+
rest of the list is never consulted.
|
|
27
|
+
*/
|
|
28
|
+
const RUNTIME_CANDIDATES = [
|
|
29
|
+
'DATABASE_URI',
|
|
30
|
+
// Neon via the Vercel integration, pooled. Correct for a serving instance.
|
|
31
|
+
'DATABASE_URL',
|
|
32
|
+
'POSTGRES_URL',
|
|
33
|
+
// Direct, as a fallback: a project with no pooler still boots.
|
|
34
|
+
'DATABASE_URL_UNPOOLED',
|
|
35
|
+
'POSTGRES_URL_NON_POOLING',
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
The DDL path: an optional migration-only credential first, then direct endpoints
|
|
40
|
+
ahead of pooled ones. The pooled pair is kept at the end rather than dropped, so
|
|
41
|
+
a migration whose only reachable string is pooled meets the explanation in
|
|
42
|
+
`describeProblem` instead of "no connection string".
|
|
43
|
+
|
|
44
|
+
`MIGRATION_DATABASE_URL` lets the app run as a role that can read and write rows
|
|
45
|
+
and no more, while migrations run as the schema owner. It is consulted only by
|
|
46
|
+
the process that migrates — see `isMigrationProcess` — because a platform with
|
|
47
|
+
no build-only variables would otherwise hand the DDL role straight back to the
|
|
48
|
+
running app.
|
|
49
|
+
*/
|
|
50
|
+
const MIGRATION_CANDIDATES = [
|
|
51
|
+
'MIGRATION_DATABASE_URL',
|
|
52
|
+
'DATABASE_URI',
|
|
53
|
+
'DATABASE_URL_UNPOOLED',
|
|
54
|
+
'POSTGRES_URL_NON_POOLING',
|
|
55
|
+
'DATABASE_URL',
|
|
56
|
+
'POSTGRES_URL',
|
|
57
|
+
] as const
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether this process is `payload migrate` rather than a server or a build.
|
|
61
|
+
*
|
|
62
|
+
* `PAYLOAD_MIGRATING=1` is set by the `migrate` script in package.json and by
|
|
63
|
+
* nothing else. `NEXT_RUNTIME` is defined in every server bundle Next emits, so
|
|
64
|
+
* no route or page can satisfy both conditions even if the first were set.
|
|
65
|
+
*/
|
|
66
|
+
export function isMigrationProcess(): boolean {
|
|
67
|
+
return process.env.PAYLOAD_MIGRATING === '1' && !process.env.NEXT_RUNTIME
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let announced = false
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
One line in the deploy log saying which variable a migration connected through.
|
|
74
|
+
Without it, a migration that quietly fell through to the app's credential looks
|
|
75
|
+
exactly like one that worked. The name, never the value: a build log is not a
|
|
76
|
+
secret store.
|
|
77
|
+
*/
|
|
78
|
+
function announceMigrationCredential(name: string): void {
|
|
79
|
+
if (announced) return
|
|
80
|
+
announced = true
|
|
81
|
+
process.stderr.write(`payload migrate: connecting via ${name}\n`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
What is wrong with a connection string, in words, before Postgres says
|
|
86
|
+
"password authentication failed" and leaves you guessing. A string wrapped in
|
|
87
|
+
quotes or prefixed with `psql` fails as `getaddrinfo ENOTFOUND`; a masked or
|
|
88
|
+
truncated password fails as an auth error. The checks below catch the shapes
|
|
89
|
+
that can be seen without connecting. Nothing here ever prints the value.
|
|
90
|
+
*/
|
|
91
|
+
function describeProblem(name: string, value: string, migrating: boolean): string | undefined {
|
|
92
|
+
const raw = value.trim()
|
|
93
|
+
|
|
94
|
+
if (/^psql\s/i.test(raw)) {
|
|
95
|
+
return `${name} starts with "psql". Copy the connection string itself, not the command that uses it.`
|
|
96
|
+
}
|
|
97
|
+
if (/^["']|["']$/.test(raw)) {
|
|
98
|
+
return `${name} is wrapped in quotes. Most hosts store the value literally — paste it without them.`
|
|
99
|
+
}
|
|
100
|
+
if (!/^postgres(ql)?:\/\//i.test(raw)) {
|
|
101
|
+
return `${name} does not start with postgres:// or postgresql://.`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let url: URL
|
|
105
|
+
try {
|
|
106
|
+
url = new URL(raw)
|
|
107
|
+
} catch {
|
|
108
|
+
return `${name} is not a valid URL.`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!url.password) {
|
|
112
|
+
return `${name} has no password. Neon's console masks it until you reveal or copy it.`
|
|
113
|
+
}
|
|
114
|
+
if (/^[•*·]+$/.test(decodeURIComponent(url.password))) {
|
|
115
|
+
return `${name}'s password is a row of bullets — it was copied while the console still had it masked.`
|
|
116
|
+
}
|
|
117
|
+
if (migrating && url.hostname.includes('-pooler')) {
|
|
118
|
+
return (
|
|
119
|
+
`${name} points at Neon's pooled endpoint. Migrations need the direct one: PgBouncer in ` +
|
|
120
|
+
`transaction mode does not support the prepared statements Drizzle's DDL relies on. Use ` +
|
|
121
|
+
`the host without "-pooler".`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The connection string, and the name of the variable it came from.
|
|
130
|
+
*
|
|
131
|
+
* The name matters: `DATABASE_URI` leads both lists, so one left in a shell or a
|
|
132
|
+
* pulled `.env` file wins without saying so, and every Neon branch's database is
|
|
133
|
+
* called `neondb` — the host and database name alone may not tell you which
|
|
134
|
+
* environment you are about to write to. See `scripts/lib/target.ts`.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveDatabaseConnection(): { name: string; value: string } {
|
|
137
|
+
const migrating = isMigrationProcess()
|
|
138
|
+
const candidates: readonly string[] = migrating ? MIGRATION_CANDIDATES : RUNTIME_CANDIDATES
|
|
139
|
+
|
|
140
|
+
for (const name of candidates) {
|
|
141
|
+
const value = process.env[name]
|
|
142
|
+
if (value && value.trim() !== '') {
|
|
143
|
+
const problem = describeProblem(name, value, migrating)
|
|
144
|
+
if (problem) throw new Error(problem)
|
|
145
|
+
if (migrating) announceMigrationCredential(name)
|
|
146
|
+
return { name, value: value.trim() }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new Error(
|
|
151
|
+
`No database connection string. Set DATABASE_URI, or connect a Neon database in Vercel — ` +
|
|
152
|
+
`the integration provides ${candidates.slice(1).join(', ')}, and every one was empty.`,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function databaseConnectionString(): string {
|
|
157
|
+
return resolveDatabaseConnection().value
|
|
158
|
+
}
|
|
@@ -1,34 +1,102 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { buildConfig } from 'payload'
|
|
4
|
-
import type { CollectionConfig } from 'payload'
|
|
4
|
+
import type { Access, CollectionConfig } from 'payload'
|
|
5
5
|
import { postgresAdapter } from '@payloadcms/db-postgres'
|
|
6
6
|
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
|
7
7
|
import {
|
|
8
|
+
assertEnvironment,
|
|
8
9
|
auditPlugin,
|
|
9
|
-
createApiKeysCollection,
|
|
10
10
|
createInngestClient,
|
|
11
|
+
createMcpToolCollector,
|
|
12
|
+
mcpApiKeyAccess,
|
|
11
13
|
} from '@forumone/throughline-core'
|
|
14
|
+
import { mcpPlugin } from '@payloadcms/plugin-mcp'
|
|
12
15
|
import { componentsPlugin } from '@forumone/throughline-components'
|
|
13
16
|
import { publishingPlugin } from '@forumone/throughline-publishing'
|
|
14
17
|
import {
|
|
18
|
+
approvalsEnv,
|
|
15
19
|
approvalsPlugin,
|
|
16
20
|
buildActionUrl,
|
|
17
21
|
generateActionToken,
|
|
18
22
|
} from '@forumone/throughline-approvals'
|
|
19
23
|
import { auditQueryPlugin } from '@forumone/throughline-audit'
|
|
20
24
|
import { integrationsPlugin } from '@forumone/throughline-integrations'
|
|
21
|
-
import { emailPlugin } from '@forumone/throughline-email'
|
|
22
|
-
import { formsPlugin } from '@forumone/throughline-forms'
|
|
25
|
+
import { emailEnv, emailPlugin } from '@forumone/throughline-email'
|
|
26
|
+
import { formsEnv, formsPlugin } from '@forumone/throughline-forms'
|
|
27
|
+
import { createTagRevalidationHooks } from '@forumone/throughline-workflows'
|
|
28
|
+
import { cacheTags } from './lib/cache-tags'
|
|
29
|
+
import { jobFailuresPlugin } from '@forumone/throughline-core/observability'
|
|
30
|
+
import { databaseConnectionString } from './lib/database'
|
|
23
31
|
{{#if useReferenceDs}}import type { Manifest } from '@forumone/throughline-design-contract'
|
|
24
32
|
import designSystemManifest from '{{designSystemName}}/manifest' with { type: 'json' }
|
|
25
33
|
{{/if}}
|
|
26
34
|
|
|
35
|
+
/*
|
|
36
|
+
Every variable this site needs, checked once and reported together, before any
|
|
37
|
+
plugin runs. Without it each plugin throws on the first thing it finds, in
|
|
38
|
+
registration order, inside `next build`'s "Failed to collect page data" — one
|
|
39
|
+
missing variable per deploy, reading like a build failure.
|
|
40
|
+
|
|
41
|
+
The plugins' lists are their own declarations of what they refuse to start
|
|
42
|
+
without, so a requirement a plugin adds arrives here with the upgrade. Only the
|
|
43
|
+
site's own variables are written out. A value passed to a plugin as an option
|
|
44
|
+
rather than read from the environment takes that plugin's list out of this call.
|
|
45
|
+
|
|
46
|
+
Anything that loads this file — `next build`, `payload generate:types`,
|
|
47
|
+
`payload migrate` — needs these set. Where only parsing matters (CI's typecheck
|
|
48
|
+
and type generation), placeholders of the right length do; see
|
|
49
|
+
.github/workflows/ci.yml.
|
|
50
|
+
*/
|
|
51
|
+
assertEnvironment(
|
|
52
|
+
approvalsEnv,
|
|
53
|
+
emailEnv,
|
|
54
|
+
formsEnv,
|
|
55
|
+
{
|
|
56
|
+
name: 'PAYLOAD_SECRET',
|
|
57
|
+
minLength: 32,
|
|
58
|
+
why: 'Signs Payload sessions and auth tokens. Generate with `openssl rand -base64 48`.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'NEXT_PUBLIC_SERVER_URL',
|
|
62
|
+
why:
|
|
63
|
+
'The origin approval links are built against, e.g. http://localhost:3000. On a ' +
|
|
64
|
+
'Vercel preview, set it to https://$VERCEL_URL.',
|
|
65
|
+
},
|
|
66
|
+
// Accepts several names (see lib/database.ts), so it is a check, not a name.
|
|
67
|
+
() => databaseConnectionString(),
|
|
68
|
+
)
|
|
69
|
+
|
|
27
70
|
const __filename = fileURLToPath(import.meta.url)
|
|
28
71
|
const __dirname = path.dirname(__filename)
|
|
29
72
|
|
|
30
73
|
const inngest = createInngestClient({ id: '{{projectName}}' })
|
|
31
74
|
|
|
75
|
+
/*
|
|
76
|
+
Where every Throughline server puts its tools for `mcpPlugin`, which serves them
|
|
77
|
+
all on one `/api/mcp`. Each plugin declares its tools as the config is built and
|
|
78
|
+
binds the handlers at `onInit`; `mcpPlugin` reads this array at both moments.
|
|
79
|
+
Hand over `mcpTools.tools` itself — a spread or `.slice()` would hand over
|
|
80
|
+
something nobody fills.
|
|
81
|
+
*/
|
|
82
|
+
const mcpTools = createMcpToolCollector()
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
Drops a Next cache tag when content changes, named by the same scheme your
|
|
86
|
+
cached reads use (see lib/cache-tags.ts). Give every collection and global that
|
|
87
|
+
a cached read renders a pair of these: afterChange and afterDelete on a
|
|
88
|
+
collection, afterChange on a global. Draft saves and autosave drop nothing.
|
|
89
|
+
*/
|
|
90
|
+
const revalidation = createTagRevalidationHooks({ cacheTags })
|
|
91
|
+
|
|
92
|
+
// A person in `users` with the admin role. `req.user` can also be an MCP key
|
|
93
|
+
// document, which has no roles and is never an admin, so the collection is
|
|
94
|
+
// checked first — once payload-types.ts exists, TypeScript insists on it.
|
|
95
|
+
const isAdmin: Access = ({ req: { user } }) =>
|
|
96
|
+
user?.collection === 'users' &&
|
|
97
|
+
Array.isArray(user.roles) &&
|
|
98
|
+
(user.roles as unknown[]).includes('admin')
|
|
99
|
+
|
|
32
100
|
// Users collection — extend with your own fields and SSO/auth provider.
|
|
33
101
|
const Users: CollectionConfig = {
|
|
34
102
|
slug: 'users',
|
|
@@ -52,13 +120,27 @@ const Users: CollectionConfig = {
|
|
|
52
120
|
],
|
|
53
121
|
}
|
|
54
122
|
|
|
123
|
+
// The public site reads with nobody signed in, so a collection it renders needs
|
|
124
|
+
// a read rule that says yes to that. Without one, Payload's default refuses —
|
|
125
|
+
// and the page renders empty rather than erroring. Anonymous readers get a
|
|
126
|
+
// query, so they see published documents only; anybody signed in sees drafts
|
|
127
|
+
// too, which the admin and live preview need. `src/access/anonymousAccess.test.ts`
|
|
128
|
+
// holds every collection to the bucket it is listed in.
|
|
129
|
+
const publishedOrSignedIn: Access = ({ req: { user } }) =>
|
|
130
|
+
user ? true : { _status: { equals: 'published' } }
|
|
131
|
+
|
|
55
132
|
// Example Pages collection. Replace this with your real content model;
|
|
56
133
|
// keep the `policy` group on collections that should require approval —
|
|
57
134
|
// the approvals plugin reads that field shape.
|
|
58
135
|
const Pages: CollectionConfig = {
|
|
59
136
|
slug: 'pages',
|
|
60
137
|
admin: { useAsTitle: 'title' },
|
|
138
|
+
access: { read: publishedOrSignedIn },
|
|
61
139
|
versions: { drafts: true },
|
|
140
|
+
hooks: {
|
|
141
|
+
afterChange: [revalidation.afterCollectionChange()],
|
|
142
|
+
afterDelete: [revalidation.afterCollectionDelete()],
|
|
143
|
+
},
|
|
62
144
|
fields: [
|
|
63
145
|
{ name: 'title', type: 'text', required: true },
|
|
64
146
|
{ name: 'slug', type: 'text', required: true, unique: true },
|
|
@@ -93,23 +175,43 @@ export default buildConfig({
|
|
|
93
175
|
admin: { user: 'users' },
|
|
94
176
|
editor: lexicalEditor(),
|
|
95
177
|
db: postgresAdapter({
|
|
96
|
-
|
|
178
|
+
// See lib/database.ts: which variable wins, and why a migration refuses
|
|
179
|
+
// Neon's pooled endpoint.
|
|
180
|
+
pool: { connectionString: databaseConnectionString() },
|
|
181
|
+
migrationDir: path.resolve(__dirname, 'migrations'),
|
|
182
|
+
// Never push schema: migrations are the only way this database changes.
|
|
183
|
+
// The adapter pushes whenever NODE_ENV !== 'production', which is true of
|
|
184
|
+
// every script and dev server. One pointed at a shared database silently
|
|
185
|
+
// syncs its schema and records a `dev` migration, and the next
|
|
186
|
+
// `payload migrate` in CI then stops to ask about it — with no terminal to
|
|
187
|
+
// answer — and skips the migration it was meant to apply.
|
|
188
|
+
push: false,
|
|
97
189
|
}),
|
|
98
|
-
|
|
190
|
+
// MCP keys live in `payload-mcp-api-keys`, which `mcpPlugin` below brings.
|
|
191
|
+
collections: [Users, Pages],
|
|
99
192
|
secret: process.env.PAYLOAD_SECRET!,
|
|
100
193
|
typescript: {
|
|
101
194
|
outputFile: path.resolve(__dirname, 'payload-types.ts'),
|
|
102
195
|
},
|
|
103
196
|
plugins: [
|
|
104
|
-
// Order matters
|
|
105
|
-
//
|
|
197
|
+
// Order matters twice. `auditPlugin` first: every other Throughline plugin
|
|
198
|
+
// requires the audit log at init. And every tool-bearing plugin before
|
|
199
|
+
// `mcpPlugin`, which reads the declared tools when the config is built — a
|
|
200
|
+
// plugin registered after it gets no per-key checkbox, and its tools are
|
|
201
|
+
// denied to every key with no error anywhere.
|
|
106
202
|
auditPlugin({ inngest }),
|
|
107
203
|
|
|
204
|
+
// Where a workflow that ran out of retries, or a failing healthcheck, leaves
|
|
205
|
+
// a row — see the failure handlers in app/api/inngest/route.ts. Not the
|
|
206
|
+
// audit log: that records who did what through an MCP tool.
|
|
207
|
+
jobFailuresPlugin({}),
|
|
208
|
+
|
|
108
209
|
componentsPlugin({
|
|
109
210
|
{{#if useReferenceDs}}manifest: { type: 'object', manifest: designSystemManifest as unknown as Manifest },
|
|
110
211
|
{{else}}// TODO: point at your design system manifest
|
|
111
212
|
manifest: { type: 'url', url: 'https://your-design-system.example.com/manifest.json' },
|
|
112
213
|
{{/if}}matching: { strategy: 'tfidf' },
|
|
214
|
+
mcpTools,
|
|
113
215
|
}),
|
|
114
216
|
|
|
115
217
|
publishingPlugin({
|
|
@@ -119,6 +221,7 @@ export default buildConfig({
|
|
|
119
221
|
approvalResolver: {
|
|
120
222
|
getActiveApproval: async () => null, // TODO: wire to approvals plugin
|
|
121
223
|
},
|
|
224
|
+
mcpTools,
|
|
122
225
|
}),
|
|
123
226
|
|
|
124
227
|
approvalsPlugin({
|
|
@@ -135,9 +238,10 @@ export default buildConfig({
|
|
|
135
238
|
return []
|
|
136
239
|
},
|
|
137
240
|
},
|
|
241
|
+
mcpTools,
|
|
138
242
|
}),
|
|
139
243
|
|
|
140
|
-
auditQueryPlugin({}),
|
|
244
|
+
auditQueryPlugin({ mcpTools }),
|
|
141
245
|
|
|
142
246
|
emailPlugin({
|
|
143
247
|
inngest,
|
|
@@ -161,19 +265,32 @@ export default buildConfig({
|
|
|
161
265
|
|
|
162
266
|
formsPlugin({
|
|
163
267
|
inngest,
|
|
164
|
-
ipHashSecret:
|
|
165
|
-
// TODO: list every place a form is allowed to send to
|
|
166
|
-
// refuses to
|
|
268
|
+
// ipHashSecret: read from FORMS_IP_HASH_SECRET, checked above.
|
|
269
|
+
// TODO: list every place a form is allowed to send to, and delete the
|
|
270
|
+
// placeholder. The plugin refuses to start with an empty list — the
|
|
271
|
+
// allowlist is the security perimeter — and refuses to deliver anywhere
|
|
272
|
+
// not on it. example.com is reserved and accepts no mail, so the
|
|
273
|
+
// placeholder routes nothing anywhere while it stands.
|
|
167
274
|
allowedDestinations: [
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
275
|
+
{
|
|
276
|
+
type: 'email',
|
|
277
|
+
value: 'forms@example.com',
|
|
278
|
+
label: 'Placeholder',
|
|
279
|
+
description: 'Replace before launch: example.com accepts no mail.',
|
|
280
|
+
},
|
|
174
281
|
],
|
|
282
|
+
mcpTools,
|
|
175
283
|
}),
|
|
176
284
|
|
|
177
|
-
integrationsPlugin({ inngest }),
|
|
285
|
+
integrationsPlugin({ inngest, mcpTools }),
|
|
286
|
+
|
|
287
|
+
// Last of the tool-bearing chain. An MCP key is a credential that runs
|
|
288
|
+
// every tool as the person it is bound to, so who may mint, read and
|
|
289
|
+
// revoke one is an admin's call: `mcpApiKeyAccess` narrows the key
|
|
290
|
+
// collection to `isAdmin`, and refuses a key principal outright.
|
|
291
|
+
mcpPlugin({
|
|
292
|
+
mcp: { tools: mcpTools.tools },
|
|
293
|
+
overrideApiKeyCollection: mcpApiKeyAccess(isAdmin),
|
|
294
|
+
}),
|
|
178
295
|
],
|
|
179
296
|
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { defineConfig } from 'vitest/config'
|
|
4
|
+
|
|
5
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The cheap tier: vitest in node, with no database and no browser.
|
|
9
|
+
|
|
10
|
+
Everything here is an invariant over values — the access rules, field
|
|
11
|
+
validations, hooks as functions — and `payload.config.ts` is imported, never
|
|
12
|
+
connected. That is what lets these run on every pull request, in CI's `fast`
|
|
13
|
+
job, which has no database. Anything that needs one belongs in `verify`, and
|
|
14
|
+
anything true only of a real response belongs in the smoke pack (`e2e/`).
|
|
15
|
+
|
|
16
|
+
`environment: 'node'`, deliberately: nothing in `apps/web` renders here. The
|
|
17
|
+
components live in the design system, whose own suite runs them.
|
|
18
|
+
*/
|
|
19
|
+
export default defineConfig({
|
|
20
|
+
/*
|
|
21
|
+
The two aliases `tsconfig.json` declares. Next resolves them from that file
|
|
22
|
+
and vitest does not, so a module importing `@/lib/…` or `@payload-config`
|
|
23
|
+
cannot be loaded here unless they are repeated.
|
|
24
|
+
*/
|
|
25
|
+
resolve: {
|
|
26
|
+
alias: {
|
|
27
|
+
'@payload-config': path.join(here, 'src/payload.config.ts'),
|
|
28
|
+
'@': path.join(here, 'src'),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
test: {
|
|
32
|
+
environment: 'node',
|
|
33
|
+
include: ['src/**/*.test.ts'],
|
|
34
|
+
/*
|
|
35
|
+
Enough for `payload.config.ts` to load, and nothing more.
|
|
36
|
+
|
|
37
|
+
The plugins check their secrets when the config is built, so importing it
|
|
38
|
+
needs every value present. None is a credential: the secrets are padded
|
|
39
|
+
placeholders, the address is under `.invalid`, which RFC 2606 reserves so
|
|
40
|
+
it can never resolve, and the connection string names a database nobody
|
|
41
|
+
has. That is safe because the config *parses* the string and never
|
|
42
|
+
connects — the same fact CI's `fast` job relies on.
|
|
43
|
+
|
|
44
|
+
Set here rather than left to the shell, so `pnpm test` needs no setup and a
|
|
45
|
+
`DATABASE_URI` somebody exported for a migration cannot reach a test.
|
|
46
|
+
*/
|
|
47
|
+
env: {
|
|
48
|
+
DATABASE_URI: 'postgres://test:test@localhost:5432/does-not-exist',
|
|
49
|
+
PAYLOAD_SECRET: 'test-only-not-a-real-secret-000000000000',
|
|
50
|
+
APPROVAL_TOKEN_SECRET: 'test-only-not-a-real-secret-000000000000',
|
|
51
|
+
FORMS_IP_HASH_SECRET: 'test-only-not-a-real-secret-000000000000',
|
|
52
|
+
RESEND_API_KEY: 're_test_only_not_a_real_key',
|
|
53
|
+
EMAIL_FROM_ADDRESS: 'test-only@example.invalid',
|
|
54
|
+
NEXT_PUBLIC_SERVER_URL: 'http://localhost:3000',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Stop hook: run the cheap half of CI's gate — typecheck and lint — when a
|
|
4
|
+
# session ends with pending changes under apps/ or design-system/.
|
|
5
|
+
#
|
|
6
|
+
# Why a hook and not only CI: these failures are cheap to fix in the session
|
|
7
|
+
# that caused them and expensive later. A session that touched nothing exits in
|
|
8
|
+
# milliseconds, which is what keeps this cheap enough to leave switched on.
|
|
9
|
+
#
|
|
10
|
+
# Deliberately not here: tests, manifest validation, the generated-files check
|
|
11
|
+
# and the build. They take minutes or need a database; they are CI's job. See
|
|
12
|
+
# CLAUDE.md for the whole gate.
|
|
13
|
+
#
|
|
14
|
+
# This reports. It never blocks and never edits, so it cannot wedge a session on
|
|
15
|
+
# a failure it cannot resolve. Exit is always 0. Set THROUGHLINE_SKIP_GATE=1 to
|
|
16
|
+
# silence it, for instance in headless sessions that run their own gate.
|
|
17
|
+
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
[[ -z "${THROUGHLINE_SKIP_GATE:-}" ]] || exit 0
|
|
21
|
+
|
|
22
|
+
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
|
|
23
|
+
[[ -n "$ROOT" ]] || exit 0
|
|
24
|
+
|
|
25
|
+
if [[ -z "$(git -C "$ROOT" status --porcelain -- apps design-system 2>/dev/null)" ]]; then
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
cd "$ROOT" || exit 0
|
|
30
|
+
|
|
31
|
+
OUTPUT="$(pnpm typecheck 2>&1 && pnpm lint 2>&1)"
|
|
32
|
+
STATUS=$?
|
|
33
|
+
|
|
34
|
+
if [[ $STATUS -eq 0 ]]; then
|
|
35
|
+
MESSAGE="Gate clean: typecheck + lint. Tests, validate, check-generated and the build are CI's job — see CLAUDE.md."
|
|
36
|
+
else
|
|
37
|
+
# The failing rule or type error is at the end, and a systemMessage is read
|
|
38
|
+
# in a terminal, not scrolled.
|
|
39
|
+
MESSAGE="Gate FAILED (typecheck/lint) — CI will fail on this:
|
|
40
|
+
|
|
41
|
+
$(printf '%s' "$OUTPUT" | tail -25)"
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
node -e 'process.stdout.write(JSON.stringify({ systemMessage: process.argv[1], suppressOutput: true }))' "$MESSAGE"
|
|
45
|
+
exit 0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"Stop": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "GATE=\"$(git rev-parse --show-toplevel 2>/dev/null)/.claude/hooks/gate.sh\"; [ -f \"$GATE\" ] && exec bash \"$GATE\" || exit 0",
|
|
9
|
+
"timeout": 180,
|
|
10
|
+
"statusMessage": "Running the gate (typecheck + lint)…"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# payload-types.ts and importMap.js are committed but generated from
|
|
4
|
+
# payload.config.ts. Regenerating and diffing is what stops them drifting from
|
|
5
|
+
# the config:
|
|
6
|
+
#
|
|
7
|
+
# - a stale importMap.js is a 500 on any admin screen that renders a component
|
|
8
|
+
# the map has never heard of — a plugin upgrade that swaps in a custom
|
|
9
|
+
# Publish button is enough
|
|
10
|
+
# - stale types make everything downstream compile against a schema that no
|
|
11
|
+
# longer exists
|
|
12
|
+
#
|
|
13
|
+
# Run from the repository root, with the environment payload.config.ts demands.
|
|
14
|
+
# **No database is needed** — the config parses a connection string and never
|
|
15
|
+
# reaches it — which is what lets this run on every pull request.
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
TYPES='apps/web/src/payload-types.ts'
|
|
20
|
+
MAP='apps/web/src/app/(payload)/admin/importMap.js'
|
|
21
|
+
|
|
22
|
+
# `git status --porcelain` rather than `git diff`: a file that was never
|
|
23
|
+
# committed is untracked, and `git diff` says nothing about it.
|
|
24
|
+
pnpm --dir apps/web run generate:types
|
|
25
|
+
if [ -n "$(git status --porcelain -- "$TYPES")" ]; then
|
|
26
|
+
git --no-pager diff -- "$TYPES" || true
|
|
27
|
+
echo "::error::payload-types.ts is stale or uncommitted — run 'pnpm --dir apps/web generate:types' and commit it."
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# importMap.js is compared as a *set* of lines. Payload emits plugin entries in
|
|
32
|
+
# an order that differs between macOS and Linux, so a byte comparison fails on a
|
|
33
|
+
# file whose content is identical. Order does not matter to correctness; a
|
|
34
|
+
# missing entry does, and that is what this catches.
|
|
35
|
+
sort "$MAP" > "${RUNNER_TEMP:-/tmp}/importmap-committed"
|
|
36
|
+
pnpm --dir apps/web run generate:importmap
|
|
37
|
+
sort "$MAP" > "${RUNNER_TEMP:-/tmp}/importmap-regenerated"
|
|
38
|
+
|
|
39
|
+
if ! diff -u "${RUNNER_TEMP:-/tmp}/importmap-committed" "${RUNNER_TEMP:-/tmp}/importmap-regenerated"; then
|
|
40
|
+
echo "::error::importMap.js is stale — run 'pnpm --dir apps/web generate:importmap' and commit it."
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
git checkout -- "$MAP"
|