@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.
- package/CHANGELOG.md +93 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +4 -3
- package/dist/generator.js.map +1 -1
- package/dist/post-install.d.ts.map +1 -1
- package/dist/post-install.js +6 -2
- package/dist/post-install.js.map +1 -1
- package/dist/templates/base/.env.example +22 -8
- package/dist/templates/base/CLAUDE.md +112 -0
- package/dist/templates/base/README.md +39 -8
- 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 +26 -25
- package/dist/templates/base/apps/web/playwright.config.ts +63 -0
- package/dist/templates/base/apps/web/src/access/anonymousAccess.test.ts +33 -0
- package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +15 -79
- 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/inngest.ts.template +7 -0
- package/dist/templates/base/apps/web/src/payload.config.ts.template +138 -123
- package/dist/templates/base/apps/web/vitest.config.ts +56 -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/workflows/ci.yml +28 -3
- package/dist/templates/base/gitignore +9 -0
- package/dist/templates/base/package.json.template +1 -0
- package/dist/templates/base/scripts/check-tested-packages.ts +0 -2
- package/dist/templates/base/turbo.json +0 -1
- package/dist/templates/with-reference-ds/design-system/package.json.template +1 -1
- package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +3 -3
- package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.contract.ts +2 -1
- package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.contract.ts +1 -1
- package/dist/templates/with-reference-ds/design-system/src/tokens/index.ts +1 -1
- package/dist/utils/versions.d.ts +3 -3
- package/dist/utils/versions.d.ts.map +1 -1
- package/dist/utils/versions.js +14 -8
- package/dist/utils/versions.js.map +1 -1
- package/dist/versions.json +2 -12
- 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 {
|
|
6
|
-
import {
|
|
7
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
224
|
-
overrideApiKeyCollection: (
|
|
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
|
+
})
|