@forumone/create-throughline 0.4.0 → 1.0.0-next.2

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 (50) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/dist/generator.d.ts.map +1 -1
  3. package/dist/generator.js +4 -3
  4. package/dist/generator.js.map +1 -1
  5. package/dist/post-install.d.ts.map +1 -1
  6. package/dist/post-install.js +6 -2
  7. package/dist/post-install.js.map +1 -1
  8. package/dist/templates/base/.env.example +22 -8
  9. package/dist/templates/base/CLAUDE.md +112 -0
  10. package/dist/templates/base/README.md +39 -8
  11. package/dist/templates/base/apps/web/e2e/site.ts +36 -0
  12. package/dist/templates/base/apps/web/e2e/smoke.spec.ts +52 -0
  13. package/dist/templates/base/apps/web/package.json.template +26 -25
  14. package/dist/templates/base/apps/web/playwright.config.ts +63 -0
  15. package/dist/templates/base/apps/web/src/access/anonymousAccess.test.ts +33 -0
  16. package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +15 -79
  17. package/dist/templates/base/apps/web/src/instrumentation.ts +35 -0
  18. package/dist/templates/base/apps/web/src/lib/cache-tags.ts +23 -0
  19. package/dist/templates/base/apps/web/src/lib/inngest.ts.template +7 -0
  20. package/dist/templates/base/apps/web/src/payload.config.ts.template +138 -123
  21. package/dist/templates/base/apps/web/vitest.config.ts +56 -0
  22. package/dist/templates/base/claude/hooks/gate.sh +45 -0
  23. package/dist/templates/base/claude/settings.json +16 -0
  24. package/dist/templates/base/github/workflows/ci.yml +28 -3
  25. package/dist/templates/base/gitignore +9 -0
  26. package/dist/templates/base/package.json.template +1 -0
  27. package/dist/templates/base/scripts/check-tested-packages.ts +0 -2
  28. package/dist/templates/base/turbo.json +0 -1
  29. package/dist/templates/with-reference-ds/design-system/package.json.template +1 -1
  30. package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +1 -1
  31. package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +3 -3
  32. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.contract.ts +1 -1
  33. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.contract.ts +1 -1
  34. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.contract.ts +1 -1
  35. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.contract.ts +2 -1
  36. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.contract.ts +1 -1
  37. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.contract.ts +1 -1
  38. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.contract.ts +1 -1
  39. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.contract.ts +1 -1
  40. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.contract.ts +1 -1
  41. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.contract.ts +1 -1
  42. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.contract.ts +1 -1
  43. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.contract.ts +1 -1
  44. package/dist/templates/with-reference-ds/design-system/src/tokens/index.ts +1 -1
  45. package/dist/utils/versions.d.ts +3 -3
  46. package/dist/utils/versions.d.ts.map +1 -1
  47. package/dist/utils/versions.js +14 -8
  48. package/dist/utils/versions.js.map +1 -1
  49. package/dist/versions.json +2 -12
  50. package/package.json +2 -1
@@ -0,0 +1,63 @@
1
+ import { defineConfig, devices } from '@playwright/test'
2
+ import { site } from './e2e/site'
3
+
4
+ /*
5
+ The smoke pack: a real server answering real requests.
6
+
7
+ The vitest suite (the `*.test.ts` files under `src`) checks values — access rules, field
8
+ validations — and connects to nothing. What it cannot reach is anything true
9
+ only of a *response*: that the admin serves a login screen, that an unknown
10
+ path is a 404 rather than a 500, that the REST API refuses an anonymous read of
11
+ `users` in practice and not only on paper. That is this.
12
+
13
+ **Deliberately not a functional suite.** It runs against an empty, migrated
14
+ database — CI's `verify` job creates one — so nothing here needs a document to
15
+ exist. Anything that does belongs in a seeded environment.
16
+
17
+ pnpm --dir apps/web build && pnpm --dir apps/web test:smoke # local build
18
+ E2E_BASE_URL=https://example.org pnpm --dir apps/web test:smoke # deployed
19
+
20
+ With `E2E_BASE_URL` set the pack runs against that URL and starts no server.
21
+ Every check is a GET, which is what makes pointing it at production defensible;
22
+ adding one that writes would not be. Which routes it checks is in `e2e/site.ts`.
23
+ */
24
+
25
+ /*
26
+ `next start`, not `next dev`: headers, caching and rendering are properties of
27
+ the built output, and a pack against `dev` would assert something nobody
28
+ deploys. So this needs `.next/` — in CI the build step has made it; locally,
29
+ run `pnpm build` first.
30
+
31
+ Hoisted, and spread in only when there is no URL to point at: with
32
+ `exactOptionalPropertyTypes` an explicit `webServer: undefined` is a type
33
+ error, and omitting the key is the only way to say "start nothing".
34
+ */
35
+ const webServer = {
36
+ command: `pnpm exec next start --port ${String(site.port)}`,
37
+ url: `http://127.0.0.1:${String(site.port)}${site.home}`,
38
+ reuseExistingServer: !process.env.CI,
39
+ timeout: 120_000,
40
+ stdout: 'pipe' as const,
41
+ stderr: 'pipe' as const,
42
+ }
43
+
44
+ export default defineConfig({
45
+ testDir: './e2e',
46
+ outputDir: './e2e/.results',
47
+
48
+ // A cold `next start` can lose the first request to a route while it warms.
49
+ // Nothing here is a visual comparison, so one retry cannot hide a real failure.
50
+ retries: process.env.CI ? 1 : 0,
51
+ forbidOnly: Boolean(process.env.CI),
52
+ reporter: process.env.CI ? [['github'], ['list']] : [['list']],
53
+ workers: 1,
54
+
55
+ use: {
56
+ baseURL: site.baseURL ?? `http://127.0.0.1:${String(site.port)}`,
57
+ trace: 'retain-on-failure',
58
+ },
59
+
60
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
61
+
62
+ ...(site.baseURL ? {} : { webServer }),
63
+ })
@@ -0,0 +1,33 @@
1
+ import { describeAnonymousAccess } from '@forumone/throughline/testing'
2
+ import config from '../payload.config'
3
+
4
+ /*
5
+ What a reader with no account can read, as values — no database.
6
+
7
+ Every query the public site makes runs with nobody signed in, so a collection's
8
+ own `read` rule decides whether a page can see its content. Close one by
9
+ mistake and nothing errors: the page renders empty. Open the wrong one and the
10
+ audit log, the approval queue or the MCP keys are on the internet.
11
+
12
+ Every collection in the config — yours and the ones the plugins bring — has to
13
+ be in exactly one bucket below. **Adding a collection fails this test until you
14
+ put it in one.** Say where the site reads it, or why it is private.
15
+ */
16
+ describeAnonymousAccess(config, {
17
+ renderPath: {
18
+ // Anonymous reads see published pages only; signed-in users see drafts.
19
+ pages: 'the page routes, once the frontend renders them',
20
+ },
21
+ private: {
22
+ users: 'accounts',
23
+ 'payload-mcp-api-keys': 'MCP keys are credentials; narrowed to admins in payload.config.ts',
24
+ 'audit-events': 'who did what',
25
+ approvals: 'who signed off on what',
26
+ integrations: 'connection settings and sync state',
27
+ 'job-failures': 'background jobs that ran out of retries, with their errors',
28
+ 'payload-kv': 'the key-value store Payload keeps',
29
+ 'payload-locked-documents': 'who is editing what',
30
+ 'payload-preferences': 'admin UI state',
31
+ 'payload-migrations': 'the migration history',
32
+ },
33
+ })
@@ -1,92 +1,28 @@
1
- import type { InngestFunction } from 'inngest'
2
1
  import { serve } from 'inngest/next'
3
2
  import { getPayload } from 'payload'
4
- import config from '@/payload.config'
5
- import { createInngestClient } from '@forumone/throughline-core'
6
- import {
7
- createAuditEventEchoFunction,
8
- createExecuteScheduledPublishesFunction,
9
- createExpireStaleApprovalsFunction,
10
- createHealthcheckFunction,
11
- createPayloadReachableCheck,
12
- createPublishAtScheduledTimeFunction,
13
- createRevalidateOnPublishFunction,
14
- type ScheduledPublishRequest,
15
- } from '@forumone/throughline-workflows'
16
- import { getPublishingService } from '@forumone/throughline-publishing'
17
- import { getEmailFunctions } from '@forumone/throughline-email'
18
- import { getFormsFunctions } from '@forumone/throughline-forms'
19
- import {
20
- getIntegrationContext,
21
- getIntegrationRegistry,
22
- } from '@forumone/throughline-integrations'
3
+ import config, { suite } from '@/payload.config'
4
+ import { inngest } from '@/lib/inngest'
5
+ import { inngestJobs } from '@forumone/throughline/jobs/inngest'
6
+ import { createTerminalFailureHandler } from '@forumone/throughline/jobs'
23
7
 
24
- const inngest = createInngestClient({ id: '{{projectName}}' })
25
8
  const payload = await getPayload({ config })
26
9
 
27
- const emailFunctions = getEmailFunctions(payload) ?? []
28
- const formsFunctions = getFormsFunctions(payload) ?? []
29
10
  /*
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
- }
11
+ Every job the Throughline suite runs, as `throughline()` in payload.config.ts
12
+ listed them: publish revalidation, scheduled publishing and its backstop,
13
+ approval expiry, the audit echo, the healthcheck, the approval emails and each
14
+ integration's jobs. Each keeps the function id it has always had, so a run in
15
+ flight survives an upgrade.
43
16
 
44
- const scheduledCollections = [{ slug: 'pages' }]
17
+ A job that runs out of retries is logged, recorded in the `job-failures`
18
+ collection, and posted to ERROR_WEBHOOK_URL if that is set: given once, here,
19
+ and handed to every job.
45
20
 
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.
21
+ Add this site's own functions to the list beside them.
51
22
  */
52
- const integrationRegistry = getIntegrationRegistry<InngestFunction.Any>(payload)
53
- const integrationContext = getIntegrationContext(payload)
54
- const integrationFunctions =
55
- integrationRegistry && integrationContext
56
- ? integrationRegistry
57
- .list()
58
- .flatMap((integration) => integration.createFunctions(integrationContext))
59
- : []
23
+ const jobs = inngestJobs(inngest, { onFailure: createTerminalFailureHandler({ payload }), payload })
60
24
 
61
25
  export const { GET, POST, PUT } = serve({
62
26
  client: inngest,
63
- functions: [
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.
73
- createExecuteScheduledPublishesFunction({
74
- inngest,
75
- payload,
76
- collections: scheduledCollections,
77
- publish: publishScheduled,
78
- schedule: '17 3 * * *',
79
- overdueByMs: 60 * 60 * 1000,
80
- }),
81
- createExpireStaleApprovalsFunction({ inngest, payload }),
82
- createAuditEventEchoFunction({ inngest }),
83
- createHealthcheckFunction({
84
- inngest,
85
- payload,
86
- checks: [createPayloadReachableCheck()],
87
- }),
88
- ...emailFunctions,
89
- ...formsFunctions,
90
- ...integrationFunctions,
91
- ],
27
+ functions: [...jobs.functions(suite.jobs)],
92
28
  })
@@ -0,0 +1,35 @@
1
+ import type { Instrumentation } from 'next'
2
+ import {
3
+ buildRequestErrorReport,
4
+ describeErrorReporting,
5
+ reportError,
6
+ } from '@forumone/throughline/observability'
7
+
8
+ /*
9
+ Where a server-side error goes.
10
+
11
+ `onRequestError` fires when Next captures an error in a Server Component
12
+ render, a Route Handler, a Server Action or the proxy — the one place that sees
13
+ all four. It does not see client-side errors; `error.tsx` and
14
+ `global-error.tsx` are the boundaries for those.
15
+
16
+ The report is logged first, always, and then posted to `ERROR_WEBHOOK_URL` if
17
+ it is set — a log drain, an alerting endpoint, a Slack incoming webhook, a
18
+ proxy in front of whichever tracker you choose. Request headers are copied
19
+ from an allowlist: `cookie` carries the Payload session, and must never leave.
20
+
21
+ The subpath import keeps the CMS out of this file. This hook runs when
22
+ something has already gone wrong, and loading Payload here would put the whole
23
+ CMS in the one path that has to work regardless.
24
+ */
25
+ export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
26
+ const report = buildRequestErrorReport(error, request, context)
27
+ console.error('[error]', JSON.stringify(report))
28
+ await reportError(report)
29
+ }
30
+
31
+ /** Once per server instance: say so if error reports are going nowhere. */
32
+ export function register(): void {
33
+ const status = describeErrorReporting()
34
+ if (!status.startsWith('error reporting: on')) console.warn('[boot]', status)
35
+ }
@@ -0,0 +1,23 @@
1
+ /*
2
+ Next cache tags, named in one place.
3
+
4
+ A tag has two ends: a reader that caches under it and a hook that drops it.
5
+ Nothing checks that they match, so both build their tags from this object —
6
+ `payload.config.ts` hands it to the revalidation hooks, the Inngest route hands
7
+ it to `createRevalidateOnPublishFunction`, and frontend code that caches a read
8
+ tags it from here too:
9
+
10
+ import { unstable_cache } from 'next/cache'
11
+ import { cacheTags } from '@/lib/cache-tags'
12
+
13
+ const getNavigation = unstable_cache(loadNavigation, ['navigation'], {
14
+ tags: [cacheTags.global('navigation')],
15
+ })
16
+
17
+ Change the scheme here and every end moves with it. Imported from the
18
+ package's `/cache-tags` subpath, which imports nothing, so frontend code that
19
+ uses it does not pull in Payload.
20
+ */
21
+ import { createCacheTags } from '@forumone/throughline/cache-tags'
22
+
23
+ export const cacheTags = createCacheTags()
@@ -0,0 +1,7 @@
1
+ import { createInngestClient } from '@forumone/throughline/jobs/inngest'
2
+
3
+ /*
4
+ The one Inngest client: payload.config.ts sends events through it, and the
5
+ endpoint at app/api/inngest serves this site's jobs with it.
6
+ */
7
+ export const inngest = createInngestClient({ id: '{{projectName}}' })
@@ -4,41 +4,133 @@ import { buildConfig } from 'payload'
4
4
  import type { Access, CollectionConfig } from 'payload'
5
5
  import { postgresAdapter } from '@payloadcms/db-postgres'
6
6
  import { lexicalEditor } from '@payloadcms/richtext-lexical'
7
- import {
8
- auditPlugin,
9
- createInngestClient,
10
- createMcpToolCollector,
11
- } from '@forumone/throughline-core'
7
+ import { assertEnvironment, mcpApiKeyAccess, throughline } from '@forumone/throughline'
8
+ import { inngestJobs } from '@forumone/throughline/jobs/inngest'
12
9
  import { mcpPlugin } from '@payloadcms/plugin-mcp'
13
- import { componentsPlugin } from '@forumone/throughline-components'
14
- import { publishingPlugin } from '@forumone/throughline-publishing'
15
- import {
16
- approvalsPlugin,
17
- buildActionUrl,
18
- generateActionToken,
19
- } from '@forumone/throughline-approvals'
20
- import { auditQueryPlugin } from '@forumone/throughline-audit'
21
- import { integrationsPlugin } from '@forumone/throughline-integrations'
22
- import { emailPlugin } from '@forumone/throughline-email'
23
- import { formsPlugin } from '@forumone/throughline-forms'
10
+ import { approvalsEnv } from '@forumone/throughline/approvals'
11
+ import { emailEnv } from '@forumone/throughline/email'
12
+ import { createPayloadReachableCheck } from '@forumone/throughline/integrations'
13
+ import { createTagRevalidationHooks } from '@forumone/throughline/publishing'
14
+ import { cacheTags } from './lib/cache-tags'
15
+ import { inngest } from './lib/inngest'
24
16
  import { databaseConnectionString } from './lib/database'
25
- {{#if useReferenceDs}}import type { Manifest } from '@forumone/throughline-design-contract'
17
+ {{#if useReferenceDs}}import type { Manifest } from '@forumone/throughline-design-system/contract'
26
18
  import designSystemManifest from '{{designSystemName}}/manifest' with { type: 'json' }
27
19
  {{/if}}
28
20
 
21
+ /*
22
+ Every variable this site needs, checked once and reported together, before any
23
+ plugin runs. Without it each plugin throws on the first thing it finds, in
24
+ registration order, inside `next build`'s "Failed to collect page data" — one
25
+ missing variable per deploy, reading like a build failure.
26
+
27
+ The plugins' lists are their own declarations of what they refuse to start
28
+ without, so a requirement a plugin adds arrives here with the upgrade. Only the
29
+ site's own variables are written out. A value passed to a plugin as an option
30
+ rather than read from the environment takes that plugin's list out of this call.
31
+
32
+ Anything that loads this file — `next build`, `payload generate:types`,
33
+ `payload migrate` — needs these set. Where only parsing matters (CI's typecheck
34
+ and type generation), placeholders of the right length do; see
35
+ .github/workflows/ci.yml.
36
+ */
37
+ assertEnvironment(
38
+ approvalsEnv,
39
+ emailEnv,
40
+ {
41
+ name: 'PAYLOAD_SECRET',
42
+ minLength: 32,
43
+ why: 'Signs Payload sessions and auth tokens. Generate with `openssl rand -base64 48`.',
44
+ },
45
+ {
46
+ name: 'NEXT_PUBLIC_SERVER_URL',
47
+ why:
48
+ 'The origin approval links are built against, e.g. http://localhost:3000. On a ' +
49
+ 'Vercel preview, set it to https://$VERCEL_URL.',
50
+ },
51
+ // Accepts several names (see lib/database.ts), so it is a check, not a name.
52
+ () => databaseConnectionString(),
53
+ )
54
+
29
55
  const __filename = fileURLToPath(import.meta.url)
30
56
  const __dirname = path.dirname(__filename)
31
57
 
32
- const inngest = createInngestClient({ id: '{{projectName}}' })
58
+ /*
59
+ The Throughline suite, in one call. It registers every plugin in the order they
60
+ need, gives each the one MCP tool collector, sends their events through the jobs
61
+ adapter, and lists the jobs these options call for, which app/api/inngest
62
+ serves. One value for each thing two plugins must agree on: the approvals
63
+ collection's slug reaches approvals, the emails and the expiry job alike.
64
+
65
+ A plugin is on when its key is here. Delete a key to turn one off.
66
+ */
67
+ export const suite = throughline({
68
+ jobs: inngestJobs(inngest),
69
+ // The collections publishing gates, "Your work" lists and scheduling publishes.
70
+ collections: ['pages'],
71
+
72
+ {{#if useReferenceDs}}components: {
73
+ manifest: { type: 'object', manifest: designSystemManifest as unknown as Manifest },
74
+ matching: { strategy: 'tfidf' },
75
+ },
76
+ {{else}}components: {
77
+ // TODO: point at your design system manifest
78
+ manifest: { type: 'url', url: 'https://your-design-system.example.com/manifest.json' },
79
+ matching: { strategy: 'tfidf' },
80
+ },
81
+ {{/if}}publishing: {
82
+ /*
83
+ Where each publishable collection's documents are served, so a publish
84
+ revalidates the right path. There are no defaults: a collection missing here
85
+ has its cache tags dropped but no path revalidated, and the run logs a
86
+ warning. Add an entry for every collection in `collections`.
87
+ */
88
+ urls: { pages: (slug: string) => (slug === 'home' ? '/' : `/${slug}`) },
89
+ revalidation: { cacheTags },
90
+ // Scheduled publishes go through the publishing pipeline, as an editor's
91
+ // would. The backstop catches a schedule whose wake-up event never arrived.
92
+ scheduling: { backstop: { schedule: '17 3 * * *', overdueByMs: 60 * 60 * 1000 } },
93
+ },
94
+
95
+ approvals: {
96
+ groups: [
97
+ { slug: 'editorial', name: 'Editorial' },
98
+ { slug: 'legal', name: 'Legal' },
99
+ { slug: 'communications', name: 'Communications' },
100
+ { slug: 'senior', name: 'Senior leadership' },
101
+ ],
102
+ groupResolver: {
103
+ // TODO: replace with your actual user/group lookup (DB query, SSO, etc.)
104
+ async resolveUsers(_groupSlugs) {
105
+ return []
106
+ },
107
+ },
108
+ },
109
+
110
+ auditQuery: {},
111
+
112
+ // Approval links are signed with APPROVAL_TOKEN_SECRET and point at
113
+ // NEXT_PUBLIC_SERVER_URL, both checked above.
114
+ email: {
115
+ // TODO: replace with your real user lookups
116
+ resolveApprover: async (_userId) => null,
117
+ resolveRequester: async (_userId) => null,
118
+ tokens: { brandName: '{{projectName}}' },
119
+ },
120
+
121
+ integrations: {},
122
+
123
+ // A failing check is recorded in `job-failures` and posted to ERROR_WEBHOOK_URL.
124
+ healthcheck: { checks: [createPayloadReachableCheck()] },
125
+ })
33
126
 
34
127
  /*
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.
128
+ Drops a Next cache tag when content changes, named by the same scheme your
129
+ cached reads use (see lib/cache-tags.ts). Give every collection and global that
130
+ a cached read renders a pair of these: afterChange and afterDelete on a
131
+ collection, afterChange on a global. Draft saves and autosave drop nothing.
40
132
  */
41
- const mcpTools = createMcpToolCollector()
133
+ const revalidation = createTagRevalidationHooks({ cacheTags })
42
134
 
43
135
  // A person in `users` with the admin role. `req.user` can also be an MCP key
44
136
  // document, which has no roles and is never an admin, so the collection is
@@ -60,7 +152,7 @@ const Users: CollectionConfig = {
60
152
  type: 'select',
61
153
  hasMany: true,
62
154
  defaultValue: ['admin'],
63
- options: ['admin', 'editor', 'approver', 'form-admin'],
155
+ options: ['admin', 'editor', 'approver'],
64
156
  },
65
157
  {
66
158
  name: 'groups',
@@ -71,13 +163,27 @@ const Users: CollectionConfig = {
71
163
  ],
72
164
  }
73
165
 
166
+ // The public site reads with nobody signed in, so a collection it renders needs
167
+ // a read rule that says yes to that. Without one, Payload's default refuses —
168
+ // and the page renders empty rather than erroring. Anonymous readers get a
169
+ // query, so they see published documents only; anybody signed in sees drafts
170
+ // too, which the admin and live preview need. `src/access/anonymousAccess.test.ts`
171
+ // holds every collection to the bucket it is listed in.
172
+ const publishedOrSignedIn: Access = ({ req: { user } }) =>
173
+ user ? true : { _status: { equals: 'published' } }
174
+
74
175
  // Example Pages collection. Replace this with your real content model;
75
176
  // keep the `policy` group on collections that should require approval —
76
177
  // the approvals plugin reads that field shape.
77
178
  const Pages: CollectionConfig = {
78
179
  slug: 'pages',
79
180
  admin: { useAsTitle: 'title' },
181
+ access: { read: publishedOrSignedIn },
80
182
  versions: { drafts: true },
183
+ hooks: {
184
+ afterChange: [revalidation.afterCollectionChange()],
185
+ afterDelete: [revalidation.afterCollectionDelete()],
186
+ },
81
187
  fields: [
82
188
  { name: 'title', type: 'text', required: true },
83
189
  { name: 'slug', type: 'text', required: true, unique: true },
@@ -131,106 +237,15 @@ export default buildConfig({
131
237
  outputFile: path.resolve(__dirname, 'payload-types.ts'),
132
238
  },
133
239
  plugins: [
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.
139
- auditPlugin({ inngest }),
140
-
141
- componentsPlugin({
142
- {{#if useReferenceDs}}manifest: { type: 'object', manifest: designSystemManifest as unknown as Manifest },
143
- {{else}}// TODO: point at your design system manifest
144
- manifest: { type: 'url', url: 'https://your-design-system.example.com/manifest.json' },
145
- {{/if}}matching: { strategy: 'tfidf' },
146
- mcpTools,
147
- }),
148
-
149
- publishingPlugin({
150
- collections: [{ slug: 'pages' }],
151
- inngest,
152
- // Lazy-resolve approvals at call time so plugin order doesn't matter.
153
- approvalResolver: {
154
- getActiveApproval: async () => null, // TODO: wire to approvals plugin
155
- },
156
- mcpTools,
157
- }),
158
-
159
- approvalsPlugin({
160
- inngest,
161
- groups: [
162
- { slug: 'editorial', name: 'Editorial' },
163
- { slug: 'legal', name: 'Legal' },
164
- { slug: 'communications', name: 'Communications' },
165
- { slug: 'senior', name: 'Senior leadership' },
166
- ],
167
- groupResolver: {
168
- // TODO: replace with your actual user/group lookup (DB query, SSO, etc.)
169
- async resolveUsers(_groupSlugs) {
170
- return []
171
- },
172
- },
173
- mcpTools,
174
- }),
175
-
176
- auditQueryPlugin({ mcpTools }),
177
-
178
- emailPlugin({
179
- inngest,
180
- // TODO: replace with your real user lookups
181
- resolveApprover: async (_userId) => null,
182
- resolveRequester: async (_userId) => null,
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
- },
195
- tokens: { brandName: '{{projectName}}' },
196
- }),
240
+ suite.plugin,
197
241
 
198
- formsPlugin({
199
- inngest,
200
- ipHashSecret: process.env.FORMS_IP_HASH_SECRET!,
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.
206
- allowedDestinations: [
207
- {
208
- type: 'email',
209
- value: 'forms@example.com',
210
- label: 'Placeholder',
211
- description: 'Replace before launch: example.com accepts no mail.',
212
- },
213
- ],
214
- mcpTools,
215
- }),
216
-
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.
242
+ // After the suite, whose plugins fill the tools it serves. An MCP key is a
243
+ // credential that runs every tool as the person it is bound to, so who may
244
+ // mint, read and revoke one is an admin's call: `mcpApiKeyAccess` narrows
245
+ // the key collection to `isAdmin`, and refuses a key principal outright.
222
246
  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
- }),
247
+ mcp: { tools: suite.mcpTools },
248
+ overrideApiKeyCollection: mcpApiKeyAccess(isAdmin),
234
249
  }),
235
250
  ],
236
251
  })
@@ -0,0 +1,56 @@
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
+ RESEND_API_KEY: 're_test_only_not_a_real_key',
52
+ EMAIL_FROM_ADDRESS: 'test-only@example.invalid',
53
+ NEXT_PUBLIC_SERVER_URL: 'http://localhost:3000',
54
+ },
55
+ },
56
+ })