@forumone/create-throughline 0.3.1 → 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 (27) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/generator.d.ts.map +1 -1
  3. package/dist/generator.js +4 -0
  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 +55 -2
  10. package/dist/templates/base/apps/web/package.json.template +11 -2
  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 +8 -1
  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 +73 -16
  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/scripts/build-manifest.ts +59 -29
  25. package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +26 -1
  26. package/dist/versions.json +1 -1
  27. package/package.json +2 -2
@@ -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'
@@ -42,7 +43,13 @@ async function publishScheduled({ collection, id, reasoning }: ScheduledPublishR
42
43
 
43
44
  const scheduledCollections = [{ slug: 'pages' }]
44
45
 
45
- const integrationRegistry = getIntegrationRegistry(payload)
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)
46
53
  const integrationContext = getIntegrationContext(payload)
47
54
  const integrationFunctions =
48
55
  integrationRegistry && integrationContext
@@ -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,14 +1,15 @@
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
15
  import {
@@ -20,6 +21,7 @@ import { auditQueryPlugin } from '@forumone/throughline-audit'
20
21
  import { integrationsPlugin } from '@forumone/throughline-integrations'
21
22
  import { emailPlugin } from '@forumone/throughline-email'
22
23
  import { formsPlugin } from '@forumone/throughline-forms'
24
+ import { databaseConnectionString } from './lib/database'
23
25
  {{#if useReferenceDs}}import type { Manifest } from '@forumone/throughline-design-contract'
24
26
  import designSystemManifest from '{{designSystemName}}/manifest' with { type: 'json' }
25
27
  {{/if}}
@@ -29,6 +31,23 @@ const __dirname = path.dirname(__filename)
29
31
 
30
32
  const inngest = createInngestClient({ id: '{{projectName}}' })
31
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
+
32
51
  // Users collection — extend with your own fields and SSO/auth provider.
33
52
  const Users: CollectionConfig = {
34
53
  slug: 'users',
@@ -93,16 +112,30 @@ export default buildConfig({
93
112
  admin: { user: 'users' },
94
113
  editor: lexicalEditor(),
95
114
  db: postgresAdapter({
96
- 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,
97
126
  }),
98
- collections: [Users, Pages, createApiKeysCollection()],
127
+ // MCP keys live in `payload-mcp-api-keys`, which `mcpPlugin` below brings.
128
+ collections: [Users, Pages],
99
129
  secret: process.env.PAYLOAD_SECRET!,
100
130
  typescript: {
101
131
  outputFile: path.resolve(__dirname, 'payload-types.ts'),
102
132
  },
103
133
  plugins: [
104
- // Order matters: audit first (so other plugins can write to it),
105
- // 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.
106
139
  auditPlugin({ inngest }),
107
140
 
108
141
  componentsPlugin({
@@ -110,6 +143,7 @@ export default buildConfig({
110
143
  {{else}}// TODO: point at your design system manifest
111
144
  manifest: { type: 'url', url: 'https://your-design-system.example.com/manifest.json' },
112
145
  {{/if}}matching: { strategy: 'tfidf' },
146
+ mcpTools,
113
147
  }),
114
148
 
115
149
  publishingPlugin({
@@ -119,6 +153,7 @@ export default buildConfig({
119
153
  approvalResolver: {
120
154
  getActiveApproval: async () => null, // TODO: wire to approvals plugin
121
155
  },
156
+ mcpTools,
122
157
  }),
123
158
 
124
159
  approvalsPlugin({
@@ -135,9 +170,10 @@ export default buildConfig({
135
170
  return []
136
171
  },
137
172
  },
173
+ mcpTools,
138
174
  }),
139
175
 
140
- auditQueryPlugin({}),
176
+ auditQueryPlugin({ mcpTools }),
141
177
 
142
178
  emailPlugin({
143
179
  inngest,
@@ -162,18 +198,39 @@ export default buildConfig({
162
198
  formsPlugin({
163
199
  inngest,
164
200
  ipHashSecret: process.env.FORMS_IP_HASH_SECRET!,
165
- // TODO: list every place a form is allowed to send to. The plugin
166
- // 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.
167
206
  allowedDestinations: [
168
- // {
169
- // type: 'email',
170
- // value: 'team@example.com',
171
- // label: 'Main inbox',
172
- // description: 'General contact form destination',
173
- // },
207
+ {
208
+ type: 'email',
209
+ value: 'forms@example.com',
210
+ label: 'Placeholder',
211
+ description: 'Replace before launch: example.com accepts no mail.',
212
+ },
174
213
  ],
214
+ mcpTools,
175
215
  }),
176
216
 
177
- 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
+ }),
178
235
  ],
179
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"
@@ -0,0 +1,131 @@
1
+ name: CI
2
+
3
+ # Two tiers.
4
+ #
5
+ # fast every pull request and every push to main. No database: every gate
6
+ # here needs the config to parse, not to connect.
7
+ # verify pushes to main, and on demand. A Postgres service, the migrations,
8
+ # and a production build — `next build` boots Payload to collect page
9
+ # data, so it needs a migrated database.
10
+ #
11
+ # A green `fast` is what a pull request is reviewed on; `verify` is what says
12
+ # main still builds and migrates. Add a path filter to run `verify` on pull
13
+ # requests that touch the config, the migrations or the dependencies, once this
14
+ # project has enough of all three to make it worth the minutes.
15
+
16
+ on:
17
+ pull_request:
18
+ push:
19
+ branches: [main]
20
+ workflow_dispatch:
21
+
22
+ concurrency:
23
+ group: ci-${{ github.ref }}
24
+ cancel-in-progress: true
25
+
26
+ # What payload.config.ts and the plugins check at load, as placeholders. The
27
+ # config parses the connection string and never connects in `fast`; secrets only
28
+ # have to be long enough to pass the plugins' own checks. Never a real value.
29
+ env:
30
+ DATABASE_URI: postgres://ci:ci@localhost:5432/ci
31
+ PAYLOAD_SECRET: ci-placeholder-not-a-secret-000000000000
32
+ APPROVAL_TOKEN_SECRET: ci-placeholder-not-a-secret-000000000000
33
+ FORMS_IP_HASH_SECRET: ci-placeholder-not-a-secret-000000000000
34
+ RESEND_API_KEY: re_ci_placeholder
35
+ EMAIL_FROM_ADDRESS: ci@example.com
36
+ NEXT_PUBLIC_SERVER_URL: http://localhost:3000
37
+
38
+ jobs:
39
+ fast:
40
+ runs-on: ubuntu-latest
41
+ timeout-minutes: 20
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+
45
+ - uses: pnpm/action-setup@v4
46
+
47
+ - uses: actions/setup-node@v4
48
+ with:
49
+ node-version: 22
50
+ cache: pnpm
51
+
52
+ # First, because a lockfile missing an entry passes every other gate
53
+ # locally and fails here.
54
+ - name: Install
55
+ run: pnpm install --frozen-lockfile
56
+
57
+ - name: Typecheck
58
+ run: pnpm typecheck
59
+
60
+ - name: Lint
61
+ run: pnpm lint
62
+
63
+ - name: Check singletons resolve once
64
+ run: pnpm check:instances
65
+
66
+ - name: Check every package that ships code has tests
67
+ run: pnpm check:tested
68
+
69
+ - name: Check the migration chain
70
+ run: pnpm check:migrations
71
+
72
+ # Builds the design system's manifest and lints it against the contract:
73
+ # an unknown token, a composition rule naming a missing component, an
74
+ # example pointing at a story that does not exist.
75
+ - name: Validate the design-system manifest
76
+ run: pnpm validate
77
+
78
+ - name: Check generated Payload artifacts are current
79
+ run: bash .github/scripts/check-generated.sh
80
+
81
+ - name: Test
82
+ run: pnpm test
83
+
84
+ # Non-blocking: a new advisory is news, not necessarily a defect, and
85
+ # blocking every open pull request the morning a CVE is published is how
86
+ # a gate gets removed rather than fixed. Read it.
87
+ - name: Audit production dependencies
88
+ run: pnpm check:audit
89
+ continue-on-error: true
90
+
91
+ verify:
92
+ if: github.event_name != 'pull_request'
93
+ runs-on: ubuntu-latest
94
+ timeout-minutes: 25
95
+
96
+ services:
97
+ postgres:
98
+ image: postgres:17-alpine
99
+ env:
100
+ POSTGRES_USER: ci
101
+ POSTGRES_PASSWORD: ci
102
+ POSTGRES_DB: ci
103
+ ports:
104
+ - 5432:5432
105
+ options: >-
106
+ --health-cmd pg_isready
107
+ --health-interval 10s
108
+ --health-timeout 5s
109
+ --health-retries 5
110
+
111
+ steps:
112
+ - uses: actions/checkout@v4
113
+
114
+ - uses: pnpm/action-setup@v4
115
+
116
+ - uses: actions/setup-node@v4
117
+ with:
118
+ node-version: 22
119
+ cache: pnpm
120
+
121
+ - name: Install
122
+ run: pnpm install --frozen-lockfile
123
+
124
+ # A fresh database, migrated exactly as a deploy would migrate it. A
125
+ # migration that does not apply cleanly from empty fails here rather than
126
+ # on the first environment somebody creates.
127
+ - name: Migrate
128
+ run: pnpm --dir apps/web migrate
129
+
130
+ - name: Build
131
+ run: pnpm build
@@ -7,14 +7,19 @@
7
7
  "dev": "{{#if useReferenceDs}}pnpm --filter \"./design-system\" build && {{/if}}turbo run dev",
8
8
  "storybook": "turbo run storybook",
9
9
  "build-storybook": "turbo run build-storybook",
10
- "validate": "turbo run validate",
10
+ "validate": "turbo run validate --filter=./design-system",
11
11
  "lint": "turbo run lint",
12
12
  "typecheck": "turbo run typecheck",
13
13
  "test": "turbo run test",
14
+ "check:instances": "tsx scripts/check-single-instance.ts",
15
+ "check:tested": "tsx scripts/check-tested-packages.ts",
16
+ "check:audit": "tsx scripts/check-audit.ts",
17
+ "check:migrations": "pnpm --dir apps/web run check:migrations",
14
18
  "format": "prettier --write ."
15
19
  },
16
20
  "devDependencies": {
17
21
  "prettier": "^3.4.0",
22
+ "tsx": "^4.21.0",
18
23
  "turbo": "^2.3.0",
19
24
  "typescript": "^5.6.0"
20
25
  },
@@ -0,0 +1,117 @@
1
+ import { execFileSync } from 'node:child_process'
2
+
3
+ /*
4
+ `pnpm audit` over the production tree, with an allowlist that cannot rot.
5
+
6
+ pnpm check:audit
7
+
8
+ **Why not `pnpm audit --prod --audit-level=high` on its own.** A Payload + Next
9
+ production tree is rarely clean: there are usually high advisories in it that no
10
+ request the site serves can reach. A gate that is red on every run is a gate
11
+ people stop reading.
12
+
13
+ **Why not `pnpm.auditConfig.ignoreGhsas`.** It filters, and that is all it does.
14
+ A list of ids in `package.json` carries no reason, and nothing notices when one
15
+ stops applying — the dependency is dropped or patched, and the entry sits there
16
+ suppressing a finding it no longer describes.
17
+
18
+ So this asserts the allowlist in both directions. An advisory that is not on the
19
+ list is reported. An entry that no longer appears in the audit is *also*
20
+ reported, as something to delete. That second half is why this is a script.
21
+
22
+ It is non-blocking in CI. A new advisory is news, not necessarily a defect, and
23
+ blocking every open pull request the morning a CVE is published is how a gate
24
+ gets removed rather than fixed.
25
+ */
26
+
27
+ interface Allowed {
28
+ ghsa: string
29
+ module: string
30
+ /** Which path it is on, and why that path cannot reach a request. */
31
+ why: string
32
+ }
33
+
34
+ /**
35
+ * High and critical advisories in the production tree that are accepted.
36
+ *
37
+ * "Not exploitable" is not a reason. Say which dependency path it arrives on
38
+ * and what would have to be true for a request to reach it.
39
+ */
40
+ const ALLOWED: Allowed[] = []
41
+
42
+ interface Advisory {
43
+ severity: string
44
+ module_name: string
45
+ title: string
46
+ github_advisory_id?: string
47
+ }
48
+
49
+ function audit(): Advisory[] {
50
+ let stdout: string
51
+ try {
52
+ // Exits non-zero whenever it finds anything at or above the level, so the
53
+ // throw is the normal case and the output is on it.
54
+ stdout = execFileSync('pnpm', ['audit', '--prod', '--audit-level=high', '--json'], {
55
+ encoding: 'utf8',
56
+ stdio: ['ignore', 'pipe', 'ignore'],
57
+ })
58
+ } catch (error) {
59
+ stdout = (error as { stdout?: string }).stdout ?? ''
60
+ }
61
+
62
+ if (stdout.trim() === '') return []
63
+ const parsed = JSON.parse(stdout) as { advisories?: Record<string, Advisory> }
64
+ // One advisory reached through several dependency paths is reported once
65
+ // per path; it is one finding.
66
+ const unique = new Map<string, Advisory>()
67
+ for (const advisory of Object.values(parsed.advisories ?? {})) {
68
+ if (advisory.severity !== 'high' && advisory.severity !== 'critical') continue
69
+ unique.set(advisory.github_advisory_id ?? `${advisory.module_name}:${advisory.title}`, advisory)
70
+ }
71
+ return [...unique.values()]
72
+ }
73
+
74
+ function main(): void {
75
+ const found = audit()
76
+ const seen = new Set(found.map((a) => a.github_advisory_id).filter(Boolean) as string[])
77
+ const allowed = new Map(ALLOWED.map((entry) => [entry.ghsa, entry]))
78
+
79
+ const unexpected = found.filter(
80
+ (a) => !a.github_advisory_id || !allowed.has(a.github_advisory_id),
81
+ )
82
+ const stale = ALLOWED.filter((entry) => !seen.has(entry.ghsa))
83
+
84
+ for (const advisory of found) {
85
+ if (advisory.github_advisory_id && allowed.has(advisory.github_advisory_id)) {
86
+ console.log(` = ${advisory.module_name.padEnd(18)} ${advisory.github_advisory_id} accepted`)
87
+ }
88
+ }
89
+
90
+ if (unexpected.length > 0) {
91
+ console.log(`\n${unexpected.length} advisory/advisories not on the allowlist:\n`)
92
+ for (const advisory of unexpected) {
93
+ console.log(` ! ${advisory.severity.padEnd(8)} ${advisory.module_name}`)
94
+ console.log(` ${advisory.github_advisory_id ?? '(no GHSA id)'} — ${advisory.title}`)
95
+ }
96
+ console.log(
97
+ '\nEither upgrade past it, or add it to ALLOWED in scripts/check-audit.ts with the\n' +
98
+ 'dependency path it arrives on and why no request can reach it.',
99
+ )
100
+ }
101
+
102
+ if (stale.length > 0) {
103
+ console.log(
104
+ `\n${stale.length} allowlist entry/entries no longer apply and should be deleted:\n`,
105
+ )
106
+ for (const entry of stale) console.log(` - ${entry.ghsa} (${entry.module})`)
107
+ }
108
+
109
+ if (unexpected.length === 0 && stale.length === 0) {
110
+ console.log(`\nNothing new. ${found.length} known advisory/advisories, each with a reason.`)
111
+ return
112
+ }
113
+
114
+ process.exitCode = 1
115
+ }
116
+
117
+ main()