@forumone/create-throughline 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/generator.d.ts.map +1 -1
  3. package/dist/generator.js +7 -1
  4. package/dist/generator.js.map +1 -1
  5. package/dist/post-install.d.ts.map +1 -1
  6. package/dist/post-install.js +10 -3
  7. package/dist/post-install.js.map +1 -1
  8. package/dist/templates/base/.env.example +9 -13
  9. package/dist/templates/base/README.md +63 -11
  10. package/dist/templates/base/apps/web/package.json.template +21 -12
  11. package/dist/templates/base/apps/web/scripts/check-migrations.ts +147 -0
  12. package/dist/templates/base/apps/web/scripts/lib/target.ts +95 -0
  13. package/dist/templates/base/apps/web/scripts/write-migration-snapshot.ts +119 -0
  14. package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +40 -4
  15. package/dist/templates/base/apps/web/src/lib/database.ts +158 -0
  16. package/dist/templates/base/apps/web/src/payload.config.ts.template +90 -20
  17. package/dist/templates/base/github/scripts/check-generated.sh +44 -0
  18. package/dist/templates/base/github/workflows/ci.yml +131 -0
  19. package/dist/templates/base/package.json.template +6 -1
  20. package/dist/templates/base/scripts/check-audit.ts +117 -0
  21. package/dist/templates/base/scripts/check-single-instance.ts +113 -0
  22. package/dist/templates/base/scripts/check-tested-packages.ts +165 -0
  23. package/dist/templates/base/turbo.json +16 -1
  24. package/dist/templates/with-reference-ds/design-system/package.json.template +3 -3
  25. package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +59 -29
  26. package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +26 -1
  27. package/dist/utils/versions.d.ts +20 -0
  28. package/dist/utils/versions.d.ts.map +1 -0
  29. package/dist/utils/versions.js +59 -0
  30. package/dist/utils/versions.js.map +1 -0
  31. package/dist/versions.json +14 -0
  32. package/package.json +6 -6
@@ -0,0 +1,119 @@
1
+ import { readdir, readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { parseArgs } from 'node:util'
5
+ import { getPayload } from 'payload'
6
+ import config from '@payload-config'
7
+
8
+ /*
9
+ Writes the `.json` schema snapshot beside a hand-written migration.
10
+
11
+ pnpm migrate:snapshot # the newest migration
12
+ pnpm migrate:snapshot 20260812_154500_add_jobs
13
+ pnpm migrate:snapshot --force # replace an existing snapshot
14
+
15
+ `migrate:create` diffs the config against the snapshot beside the **most
16
+ recent** migration, and writes a fresh one only when it generates the SQL
17
+ itself. A migration written by hand therefore leaves the generator one step
18
+ behind, and every run after it re-proposes a change that already shipped.
19
+
20
+ This closes the gap the way `createMigration` does, minus the diff: it asks the
21
+ adapter for the schema the config describes and serialises it. The adapter
22
+ builds that schema from the config alone, so this needs **no database**.
23
+
24
+ ## When it would be a lie
25
+
26
+ The snapshot says: *the database, once this migration has run, matches the
27
+ config.* Run it only after the hand-written migration is complete. A snapshot
28
+ claiming a config change the migration only half implements makes the next
29
+ generated migration quietly omit the missing half.
30
+
31
+ Overwriting needs `--force`, because the common mistake is running this instead
32
+ of writing the migration: the snapshot is rewritten to match a config that moved
33
+ on, and the change becomes invisible to the generator — the schema drifts with
34
+ no migration anywhere that would apply it.
35
+
36
+ The config is loaded, so the environment it validates must be present. Values
37
+ that only need to parse — secrets, keys — can be placeholders on this run.
38
+ */
39
+
40
+ const here = path.dirname(fileURLToPath(import.meta.url))
41
+ const MIGRATIONS = path.resolve(here, '../src/migrations')
42
+
43
+ // `pnpm run x -- --flag` forwards the `--` itself, which `parseArgs` would read
44
+ // as "positionals from here".
45
+ const argv = process.argv.slice(2).filter((arg) => arg !== '--')
46
+
47
+ const { positionals, values } = parseArgs({
48
+ args: argv,
49
+ allowPositionals: true,
50
+ options: { force: { type: 'boolean', default: false } },
51
+ })
52
+
53
+ const migrations = (await readdir(MIGRATIONS).catch(() => [] as string[]))
54
+ .filter((name) => name.endsWith('.ts') && name !== 'index.ts')
55
+ .map((name) => name.slice(0, -'.ts'.length))
56
+ .sort()
57
+
58
+ if (migrations.length === 0) {
59
+ console.error(`No migrations in ${MIGRATIONS}.`)
60
+ process.exit(1)
61
+ }
62
+
63
+ const target = positionals[0]?.replace(/\.(json|ts)$/, '') ?? migrations.at(-1)!
64
+
65
+ if (!migrations.includes(target)) {
66
+ console.error(
67
+ `No migration named ${target}.\n\nMigrations in src/migrations:\n` +
68
+ migrations.map((name) => ` ${name}`).join('\n') +
69
+ '\n',
70
+ )
71
+ process.exit(1)
72
+ }
73
+
74
+ const destination = path.join(MIGRATIONS, `${target}.json`)
75
+ const existing = await readFile(destination, 'utf8').then(
76
+ () => true,
77
+ () => false,
78
+ )
79
+
80
+ if (existing && !values.force) {
81
+ console.error(
82
+ `${target}.json already exists, and describes the schema as of that migration.\n` +
83
+ 'Rewriting it to match a config that has since changed hides the change from\n' +
84
+ '`migrate:create`. If replacing it is genuinely what you want, pass --force.\n',
85
+ )
86
+ process.exit(1)
87
+ }
88
+
89
+ if (target !== migrations.at(-1)) {
90
+ console.warn(
91
+ `Warning: ${target} is not the newest migration. Only the newest snapshot is\n` +
92
+ 'read by migrate:create, so this describes a state nothing will diff against.\n',
93
+ )
94
+ }
95
+
96
+ // No database, and nothing that runs on startup (seeds, syncs, cron
97
+ // registration) from a script whose whole job is to serialise a schema.
98
+ const payload = await getPayload({ config, disableDBConnect: true, disableOnInit: true })
99
+
100
+ const db = payload.db as typeof payload.db & {
101
+ requireDrizzleKit: () => { generateDrizzleJson: (schema: unknown) => Promise<unknown> }
102
+ schema: unknown
103
+ }
104
+
105
+ const { generateDrizzleJson } = db.requireDrizzleKit()
106
+ const snapshot = await generateDrizzleJson(db.schema)
107
+
108
+ // Byte for byte what `createMigration` writes, with no trailing newline, so a
109
+ // hand-written migration's snapshot is indistinguishable from a generated one.
110
+ await writeFile(destination, JSON.stringify(snapshot, null, 2))
111
+
112
+ console.log(
113
+ `Wrote src/migrations/${target}.json\n\n` +
114
+ 'Commit it beside the migration. Confirm the generator is unblocked with:\n\n' +
115
+ ' pnpm migrate:create check --skip-empty\n\n' +
116
+ 'which exits without writing anything when there is no diff.\n',
117
+ )
118
+
119
+ process.exit(0)
@@ -1,3 +1,4 @@
1
+ import type { InngestFunction } from 'inngest'
1
2
  import { serve } from 'inngest/next'
2
3
  import { getPayload } from 'payload'
3
4
  import config from '@/payload.config'
@@ -8,8 +9,11 @@ import {
8
9
  createExpireStaleApprovalsFunction,
9
10
  createHealthcheckFunction,
10
11
  createPayloadReachableCheck,
12
+ createPublishAtScheduledTimeFunction,
11
13
  createRevalidateOnPublishFunction,
14
+ type ScheduledPublishRequest,
12
15
  } from '@forumone/throughline-workflows'
16
+ import { getPublishingService } from '@forumone/throughline-publishing'
13
17
  import { getEmailFunctions } from '@forumone/throughline-email'
14
18
  import { getFormsFunctions } from '@forumone/throughline-forms'
15
19
  import {
@@ -22,7 +26,30 @@ const payload = await getPayload({ config })
22
26
 
23
27
  const emailFunctions = getEmailFunctions(payload) ?? []
24
28
  const formsFunctions = getFormsFunctions(payload) ?? []
25
- const integrationRegistry = getIntegrationRegistry(payload)
29
+ /*
30
+ Through the publishing service, in process — so a scheduled publish passes the
31
+ same composition, accessibility and approval gates as an interactive one. Not
32
+ `payload.update`, which skips all of them.
33
+ */
34
+ async function publishScheduled({ collection, id, reasoning }: ScheduledPublishRequest) {
35
+ const outcome = await getPublishingService(payload).publish({
36
+ collection,
37
+ id,
38
+ actor: { apiKeyName: 'scheduled-publish', channel: 'mcp' },
39
+ meta: { reasoning },
40
+ })
41
+ return { published: outcome.published, reason: outcome.reason }
42
+ }
43
+
44
+ const scheduledCollections = [{ slug: 'pages' }]
45
+
46
+ /*
47
+ Typed with this app's own `InngestFunction.Any`. The registry is generic in what
48
+ `createFunctions` returns and defaults to `unknown`, because the platform never
49
+ touches those functions — and one `unknown` element leaves `serve()` unable to
50
+ type the whole array below.
51
+ */
52
+ const integrationRegistry = getIntegrationRegistry<InngestFunction.Any>(payload)
26
53
  const integrationContext = getIntegrationContext(payload)
27
54
  const integrationFunctions =
28
55
  integrationRegistry && integrationContext
@@ -35,12 +62,21 @@ export const { GET, POST, PUT } = serve({
35
62
  client: inngest,
36
63
  functions: [
37
64
  createRevalidateOnPublishFunction({ inngest, payload }),
65
+ // On time: sleeps until each document's scheduled time.
66
+ createPublishAtScheduledTimeFunction({
67
+ inngest,
68
+ payload,
69
+ collections: scheduledCollections,
70
+ publish: publishScheduled,
71
+ }),
72
+ // The backstop, for a schedule whose wake-up event never arrived.
38
73
  createExecuteScheduledPublishesFunction({
39
74
  inngest,
40
75
  payload,
41
- collections: [{ slug: 'pages' }],
42
- publishingServerUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
43
- publishingApiKey: process.env.PUBLISHING_SYSTEM_API_KEY!,
76
+ collections: scheduledCollections,
77
+ publish: publishScheduled,
78
+ schedule: '17 3 * * *',
79
+ overdueByMs: 60 * 60 * 1000,
44
80
  }),
45
81
  createExpireStaleApprovalsFunction({ inngest, payload }),
46
82
  createAuditEventEchoFunction({ inngest }),
@@ -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,21 +1,27 @@
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
8
  auditPlugin,
9
- createApiKeysCollection,
10
9
  createInngestClient,
10
+ createMcpToolCollector,
11
11
  } from '@forumone/throughline-core'
12
+ import { mcpPlugin } from '@payloadcms/plugin-mcp'
12
13
  import { componentsPlugin } from '@forumone/throughline-components'
13
14
  import { publishingPlugin } from '@forumone/throughline-publishing'
14
- import { approvalsPlugin } from '@forumone/throughline-approvals'
15
+ import {
16
+ approvalsPlugin,
17
+ buildActionUrl,
18
+ generateActionToken,
19
+ } from '@forumone/throughline-approvals'
15
20
  import { auditQueryPlugin } from '@forumone/throughline-audit'
16
21
  import { integrationsPlugin } from '@forumone/throughline-integrations'
17
22
  import { emailPlugin } from '@forumone/throughline-email'
18
23
  import { formsPlugin } from '@forumone/throughline-forms'
24
+ import { databaseConnectionString } from './lib/database'
19
25
  {{#if useReferenceDs}}import type { Manifest } from '@forumone/throughline-design-contract'
20
26
  import designSystemManifest from '{{designSystemName}}/manifest' with { type: 'json' }
21
27
  {{/if}}
@@ -25,6 +31,23 @@ const __dirname = path.dirname(__filename)
25
31
 
26
32
  const inngest = createInngestClient({ id: '{{projectName}}' })
27
33
 
34
+ /*
35
+ Where every Throughline server puts its tools for `mcpPlugin`, which serves them
36
+ all on one `/api/mcp`. Each plugin declares its tools as the config is built and
37
+ binds the handlers at `onInit`; `mcpPlugin` reads this array at both moments.
38
+ Hand over `mcpTools.tools` itself — a spread or `.slice()` would hand over
39
+ something nobody fills.
40
+ */
41
+ const mcpTools = createMcpToolCollector()
42
+
43
+ // A person in `users` with the admin role. `req.user` can also be an MCP key
44
+ // document, which has no roles and is never an admin, so the collection is
45
+ // checked first — once payload-types.ts exists, TypeScript insists on it.
46
+ const isAdmin: Access = ({ req: { user } }) =>
47
+ user?.collection === 'users' &&
48
+ Array.isArray(user.roles) &&
49
+ (user.roles as unknown[]).includes('admin')
50
+
28
51
  // Users collection — extend with your own fields and SSO/auth provider.
29
52
  const Users: CollectionConfig = {
30
53
  slug: 'users',
@@ -89,16 +112,30 @@ export default buildConfig({
89
112
  admin: { user: 'users' },
90
113
  editor: lexicalEditor(),
91
114
  db: postgresAdapter({
92
- pool: { connectionString: process.env.DATABASE_URI! },
115
+ // See lib/database.ts: which variable wins, and why a migration refuses
116
+ // Neon's pooled endpoint.
117
+ pool: { connectionString: databaseConnectionString() },
118
+ migrationDir: path.resolve(__dirname, 'migrations'),
119
+ // Never push schema: migrations are the only way this database changes.
120
+ // The adapter pushes whenever NODE_ENV !== 'production', which is true of
121
+ // every script and dev server. One pointed at a shared database silently
122
+ // syncs its schema and records a `dev` migration, and the next
123
+ // `payload migrate` in CI then stops to ask about it — with no terminal to
124
+ // answer — and skips the migration it was meant to apply.
125
+ push: false,
93
126
  }),
94
- collections: [Users, Pages, createApiKeysCollection()],
127
+ // MCP keys live in `payload-mcp-api-keys`, which `mcpPlugin` below brings.
128
+ collections: [Users, Pages],
95
129
  secret: process.env.PAYLOAD_SECRET!,
96
130
  typescript: {
97
131
  outputFile: path.resolve(__dirname, 'payload-types.ts'),
98
132
  },
99
133
  plugins: [
100
- // Order matters: audit first (so other plugins can write to it),
101
- // then content plugins, then notification/forms/integrations.
134
+ // Order matters twice. `auditPlugin` first: every other Throughline plugin
135
+ // requires the audit log at init. And every tool-bearing plugin before
136
+ // `mcpPlugin`, which reads the declared tools when the config is built — a
137
+ // plugin registered after it gets no per-key checkbox, and its tools are
138
+ // denied to every key with no error anywhere.
102
139
  auditPlugin({ inngest }),
103
140
 
104
141
  componentsPlugin({
@@ -106,6 +143,7 @@ export default buildConfig({
106
143
  {{else}}// TODO: point at your design system manifest
107
144
  manifest: { type: 'url', url: 'https://your-design-system.example.com/manifest.json' },
108
145
  {{/if}}matching: { strategy: 'tfidf' },
146
+ mcpTools,
109
147
  }),
110
148
 
111
149
  publishingPlugin({
@@ -115,6 +153,7 @@ export default buildConfig({
115
153
  approvalResolver: {
116
154
  getActiveApproval: async () => null, // TODO: wire to approvals plugin
117
155
  },
156
+ mcpTools,
118
157
  }),
119
158
 
120
159
  approvalsPlugin({
@@ -131,36 +170,67 @@ export default buildConfig({
131
170
  return []
132
171
  },
133
172
  },
173
+ mcpTools,
134
174
  }),
135
175
 
136
- auditQueryPlugin({}),
176
+ auditQueryPlugin({ mcpTools }),
137
177
 
138
178
  emailPlugin({
139
179
  inngest,
140
180
  // TODO: replace with your real user lookups
141
181
  resolveApprover: async (_userId) => null,
142
182
  resolveRequester: async (_userId) => null,
143
- // TODO: sign these URLs (HMAC) so an approval decision can't be forged.
144
- buildActionUrl: async ({ approvalId, action, approverId }) =>
145
- `${process.env.NEXT_PUBLIC_SERVER_URL}/api/approvals/decision?approval=${approvalId}&action=${action}&approver=${approverId}`,
183
+ // Signed, and pointed at the route approvalsPlugin serves
184
+ // (`/api/approvals/action`). The token binds one approval, one action
185
+ // and one approver, so a link cannot be edited into a different decision.
186
+ buildActionUrl: async ({ approvalId, action, approverId }) => {
187
+ const secret = process.env.APPROVAL_TOKEN_SECRET
188
+ if (!secret) throw new Error('APPROVAL_TOKEN_SECRET is required to sign approval links.')
189
+ const token = await generateActionToken(
190
+ { approvalId, action, approverId, issuedAt: Date.now() },
191
+ secret,
192
+ )
193
+ return buildActionUrl(process.env.NEXT_PUBLIC_SERVER_URL!, token)
194
+ },
146
195
  tokens: { brandName: '{{projectName}}' },
147
196
  }),
148
197
 
149
198
  formsPlugin({
150
199
  inngest,
151
200
  ipHashSecret: process.env.FORMS_IP_HASH_SECRET!,
152
- // TODO: list every place a form is allowed to send to. The plugin
153
- // refuses to deliver to destinations that aren't in this list.
201
+ // TODO: list every place a form is allowed to send to, and delete the
202
+ // placeholder. The plugin refuses to start with an empty list — the
203
+ // allowlist is the security perimeter — and refuses to deliver anywhere
204
+ // not on it. example.com is reserved and accepts no mail, so the
205
+ // placeholder routes nothing anywhere while it stands.
154
206
  allowedDestinations: [
155
- // {
156
- // type: 'email',
157
- // value: 'team@example.com',
158
- // label: 'Main inbox',
159
- // description: 'General contact form destination',
160
- // },
207
+ {
208
+ type: 'email',
209
+ value: 'forms@example.com',
210
+ label: 'Placeholder',
211
+ description: 'Replace before launch: example.com accepts no mail.',
212
+ },
161
213
  ],
214
+ mcpTools,
162
215
  }),
163
216
 
164
- integrationsPlugin({ inngest }),
217
+ integrationsPlugin({ inngest, mcpTools }),
218
+
219
+ // Last of the tool-bearing chain. An MCP key is a credential: the plugin
220
+ // ships its collection with no access rules — "anybody signed in" — so it
221
+ // is narrowed to admins here.
222
+ mcpPlugin({
223
+ mcp: { tools: mcpTools.tools },
224
+ overrideApiKeyCollection: (collection) => ({
225
+ ...collection,
226
+ access: {
227
+ ...collection.access,
228
+ read: isAdmin,
229
+ create: isAdmin,
230
+ update: isAdmin,
231
+ delete: isAdmin,
232
+ },
233
+ }),
234
+ }),
165
235
  ],
166
236
  })
@@ -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"