@forumone/throughline-core 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +192 -33
- package/bin/payload-cli.mjs +443 -0
- package/bin/throughline-payload.mjs +6 -0
- package/dist/audit/collection.d.ts +3 -0
- package/dist/audit/collection.d.ts.map +1 -1
- package/dist/audit/collection.js +2 -0
- package/dist/audit/collection.js.map +1 -1
- package/dist/env/index.d.ts +54 -0
- package/dist/env/index.d.ts.map +1 -0
- package/dist/env/index.js +113 -0
- package/dist/env/index.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/api-key-access.d.ts +50 -0
- package/dist/mcp/api-key-access.d.ts.map +1 -0
- package/dist/mcp/api-key-access.js +94 -0
- package/dist/mcp/api-key-access.js.map +1 -0
- package/dist/mcp/envelope.d.ts +6 -0
- package/dist/mcp/envelope.d.ts.map +1 -1
- package/dist/mcp/envelope.js +6 -0
- package/dist/mcp/envelope.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/payload-mcp.d.ts +1 -15
- package/dist/mcp/payload-mcp.d.ts.map +1 -1
- package/dist/mcp/payload-mcp.js +40 -1
- package/dist/mcp/payload-mcp.js.map +1 -1
- package/dist/observability/collection.d.ts +13 -0
- package/dist/observability/collection.d.ts.map +1 -0
- package/dist/observability/collection.js +76 -0
- package/dist/observability/collection.js.map +1 -0
- package/dist/observability/index.d.ts +9 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +5 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/plugin.d.ts +21 -0
- package/dist/observability/plugin.d.ts.map +1 -0
- package/dist/observability/plugin.js +51 -0
- package/dist/observability/plugin.js.map +1 -0
- package/dist/observability/report.d.ts +175 -0
- package/dist/observability/report.d.ts.map +1 -0
- package/dist/observability/report.js +274 -0
- package/dist/observability/report.js.map +1 -0
- package/dist/observability/writer.d.ts +24 -0
- package/dist/observability/writer.d.ts.map +1 -0
- package/dist/observability/writer.js +72 -0
- package/dist/observability/writer.js.map +1 -0
- package/dist/testing/anonymousAccess.d.ts +111 -0
- package/dist/testing/anonymousAccess.d.ts.map +1 -0
- package/dist/testing/anonymousAccess.js +185 -0
- package/dist/testing/anonymousAccess.js.map +1 -0
- package/dist/testing/describeAnonymousAccess.d.ts +26 -0
- package/dist/testing/describeAnonymousAccess.d.ts.map +1 -0
- package/dist/testing/describeAnonymousAccess.js +76 -0
- package/dist/testing/describeAnonymousAccess.js.map +1 -0
- package/dist/testing/index.d.ts +11 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +30 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,117 @@
|
|
|
1
1
|
# @forumone/throughline-core
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 006ae30: A missing environment variable is reported together with every other one, in a
|
|
8
|
+
single error, instead of one plugin at a time.
|
|
9
|
+
|
|
10
|
+
The plugins that read the environment at init now declare what they cannot
|
|
11
|
+
start without, as data: `approvalsEnv` (`APPROVAL_TOKEN_SECRET`, 32+
|
|
12
|
+
characters), `emailEnv` (`RESEND_API_KEY`, `EMAIL_FROM_ADDRESS`) and `formsEnv`
|
|
13
|
+
(`FORMS_IP_HASH_SECRET`, 32+ characters). Each is a list of `EnvRequirement`
|
|
14
|
+
(`{ name, minLength?, why }`), a new type in `plugin-contract`, and each
|
|
15
|
+
plugin's own init check now reads the same entries, so the two cannot drift.
|
|
16
|
+
An empty or whitespace-only value now counts as missing in those checks.
|
|
17
|
+
|
|
18
|
+
Core exports `assertEnvironment(...checks)`. Call it first in
|
|
19
|
+
`payload.config.ts` with the plugins' lists and your own variables; it throws
|
|
20
|
+
one `EnvironmentError` whose first line reads "Configuration problem: N
|
|
21
|
+
environment variables are missing or invalid", followed by every missing or
|
|
22
|
+
too-short variable and why it is needed. Values are never printed. An argument
|
|
23
|
+
can also be a function, for a rule that is not "this name, this long", such as
|
|
24
|
+
a database URL accepted under several names. `checkEnvValue` is the one-value
|
|
25
|
+
check behind it, for a plugin's own backstop.
|
|
26
|
+
|
|
27
|
+
New projects call `assertEnvironment` at the top of `payload.config.ts` with
|
|
28
|
+
`approvalsEnv`, `emailEnv`, `formsEnv`, `PAYLOAD_SECRET` (32+ characters),
|
|
29
|
+
`NEXT_PUBLIC_SERVER_URL` and the database resolver, and `.env.example` marks
|
|
30
|
+
which variables are checked. An existing site can do the same and delete any
|
|
31
|
+
hand-kept copy of the plugins' requirements.
|
|
32
|
+
|
|
33
|
+
- 549d292: A `throughline-payload` bin: the Payload CLI, run so a hung command cannot outlive the shell that started it. pnpm does not forward signals to the node process it spawns, so a killed shell left `payload generate:types` running, and a hung one spinning on a core indefinitely.
|
|
34
|
+
|
|
35
|
+
- Payload runs in its own process group; SIGINT, SIGTERM and SIGHUP are forwarded to the group, then SIGKILL after `PAYLOAD_CLI_GRACE_MS` (default 5s)
|
|
36
|
+
- a wall clock, `PAYLOAD_CLI_TIMEOUT_MS` (default 5 minutes, `0` disables; none by default for `migrate*`), exits 124
|
|
37
|
+
- each run is recorded in `.payload-cli-pids` at the workspace root, and the next run — or `throughline-payload --reap` — kills a recorded group whose runner was killed, after checking its pid, start time and command line
|
|
38
|
+
|
|
39
|
+
- 70385c4: A failed background job has somewhere to be recorded, and core can report errors.
|
|
40
|
+
|
|
41
|
+
- **`job-failures` collection.** `jobFailuresPlugin()` from the new `@forumone/throughline-core/observability` subpath adds a read-only `job-failures` collection (admin-only by default) and attaches a writer that never throws; a row it cannot write is logged at `error` with the failure's summary and message. It is deliberately not the audit log: `audit-events` records MCP tool calls, its `mcpServer` column is required and constrained to MCP server names, and a cron that wrote there with any other value was rejected by Payload and silently dropped.
|
|
42
|
+
- **Error reporting.** `createErrorReporter` / `reportError` post JSON reports to `ERROR_WEBHOOK_URL` (or a URL you pass), with a one-line `text` so a Slack incoming webhook works as-is, a 3-second timeout, and no throw on any failure. `buildRequestErrorReport` shapes what Next's `onRequestError` receives and copies request headers from an allowlist; `authorization`, `cookie`, `x-api-key` and `x-forwarded-for` are never copied. `describeErrorReporting` gives a boot-log sentence for on, off or misconfigured.
|
|
43
|
+
|
|
44
|
+
**Migration required.** Registering `jobFailuresPlugin` adds a table (`job_failures`) and an enum (`enum_job_failures_kind`). Run `payload migrate:create` after adding it and commit the migration. The audit collection is unchanged.
|
|
45
|
+
|
|
46
|
+
- c8a86bf: Adds a `./testing` subpath with `describeAnonymousAccess(config, buckets)`. It registers a vitest suite that checks every collection's `read` rule against an anonymous request, with no database. Every collection in the config, including those added by plugins and by Payload, must be in exactly one bucket:
|
|
47
|
+
|
|
48
|
+
- `renderPath`: read by the public site, so an anonymous read must be allowed. A collection with drafts must narrow that read with a query.
|
|
49
|
+
- `private`: an anonymous read must be refused.
|
|
50
|
+
|
|
51
|
+
A collection in no bucket fails, so adding a collection fails until somebody decides where it goes. `checkAnonymousAccess` returns the same findings as a list. `vitest` is an optional peer, and the main entry does not re-export the subpath.
|
|
52
|
+
|
|
53
|
+
- ab623e1: `mcpApiKeyAccess(isAdmin)` makes `payload-mcp-api-keys`, the key collection `@payloadcms/plugin-mcp` brings, admin-only. Pass it as `mcpPlugin({ overrideApiKeyCollection: mcpApiKeyAccess(isAdmin) })`. It applies the site's admin rule to `read`, `create`, `update`, `delete` and `unlock`, and refuses an MCP key principal before it asks the rule, so a key can never manage keys. It changes `access` and nothing else, so it composes with an override that also sets, say, `admin.group`.
|
|
54
|
+
|
|
55
|
+
`isSignedIn(user)` and its `Access` form `signedIn` are a "signed in" check to use instead of `Boolean(req.user)`. They refuse an MCP key document on `req.user`, which Payload before 3.89.0 could put there on any REST route. `isMcpApiKeyPrincipal(user)` and `MCP_API_KEYS_SLUG` are exported for rules that need to tell the two apart directly. None of these affects `/api/mcp`, where a tool runs as the key's user.
|
|
56
|
+
|
|
57
|
+
- ab623e1: The `payload` peer range moves from `^3.0.0` to `^3.89.0` for every package that has one. **A site on Payload older than 3.89.0 must upgrade Payload before upgrading these packages.**
|
|
58
|
+
|
|
59
|
+
Before 3.89.0, the `payload-mcp-api-keys` collection that `@payloadcms/plugin-mcp` adds registered Payload's API-key strategy on every REST route. Any key could then become `req.user` outside `/api/mcp` and pass access rules written as `Boolean(req.user)`. Every Throughline site runs that plugin, so the floor is the same for every package. No package's code changes with this bump.
|
|
60
|
+
|
|
61
|
+
- 36728c4: Collections that Throughline plugins declare now sit in a `Throughline` group in the admin sidebar, instead of loose at the top of it above every group. That covers `audit-events` (`auditPlugin`), the approvals collection (`approvalsPlugin`), `integrations` (`integrationsPlugin`), and `forms` and `form-submissions` (`formsPlugin`).
|
|
62
|
+
|
|
63
|
+
Each of those plugins accepts `admin: { group }`, which applies to every collection it declares:
|
|
64
|
+
|
|
65
|
+
- omitted: the `Throughline` group.
|
|
66
|
+
- a string, or a locale map such as `{ en: 'Workflow', fr: 'Flux' }`: that group.
|
|
67
|
+
- `false`: ungrouped, in Payload's default "Collections" section. This does not hide the collection, which is what `false` means on a collection's own `admin.group`.
|
|
68
|
+
|
|
69
|
+
A site that groups these collections with its own config plugin can pass `admin: { group }` to each plugin and delete that code. `createAuditCollection`, `createApprovalsCollection` and `createIntegrationsCollection` accept the same `admin` option.
|
|
70
|
+
|
|
71
|
+
`@forumone/throughline-plugin-contract` exports the shared pieces: `CollectionPluginOptions`, `PluginAdminOptions`, `PluginAdminGroup`, `DEFAULT_ADMIN_GROUP` and `resolveAdminGroup`, the helper a plugin spreads into each collection's `admin` block. `@forumone/throughline-core` re-exports the types.
|
|
72
|
+
|
|
73
|
+
### Patch Changes
|
|
74
|
+
|
|
75
|
+
- Updated dependencies [006ae30]
|
|
76
|
+
- Updated dependencies [ab623e1]
|
|
77
|
+
- Updated dependencies [36728c4]
|
|
78
|
+
- @forumone/throughline-plugin-contract@0.5.0
|
|
79
|
+
|
|
80
|
+
## 0.9.1
|
|
81
|
+
|
|
82
|
+
### Patch Changes
|
|
83
|
+
|
|
84
|
+
- d02772f: A refused MCP tool call now carries MCP's `isError` flag.
|
|
85
|
+
|
|
86
|
+
Every server in the suite answers a refusal with `{ error: … }` and returns it
|
|
87
|
+
rather than throwing, so that a denial reads to the model as a denial rather
|
|
88
|
+
than as a server fault. None of them set `isError`, so the refusal arrived as a
|
|
89
|
+
**successful** tool result that happened to contain an `error` key: a client
|
|
90
|
+
checking the protocol's flag instead of parsing the body read every refusal as
|
|
91
|
+
a success.
|
|
92
|
+
|
|
93
|
+
The publishing tools are where this was found, and they are the worst case for
|
|
94
|
+
it. `@payloadcms/plugin-mcp` assigns no `req.user` — it mutates `docs[0].user`
|
|
95
|
+
and passes it separately to its own CRUD tools — so `contextFrom(req)` reads
|
|
96
|
+
`null` and _every_ `Bearer`-authenticated publishing call is refused at
|
|
97
|
+
`actor.ts`'s identity guard. All of those refusals reported success.
|
|
98
|
+
|
|
99
|
+
Fixed in `toPayloadMcpTool`, which is the one adapter every tool in all six
|
|
100
|
+
servers passes through, so a tool written tomorrow is covered without its
|
|
101
|
+
author knowing the file exists.
|
|
102
|
+
|
|
103
|
+
**Nothing changes for a client that parses the body.** The content block is
|
|
104
|
+
byte-identical, and `isError` is absent rather than `false` on a success, so a
|
|
105
|
+
consumer reading `{ error }` today sees no difference. A consumer reading the
|
|
106
|
+
flag now sees the truth instead of its opposite.
|
|
107
|
+
|
|
108
|
+
The test is a non-empty string `error`, so a refusal that later grows a second
|
|
109
|
+
field — a code, a retry hint — keeps its flag. The corollary is now documented
|
|
110
|
+
on `deniedEnvelope`: `error` is reserved for refusals, and a success result
|
|
111
|
+
reports failure through `ok`, `healthy`, `message` or `details`.
|
|
112
|
+
|
|
113
|
+
Found exercising audit `04` F-02 against a real MCP key; forumone-2026#614.
|
|
114
|
+
|
|
3
115
|
## 0.9.0
|
|
4
116
|
|
|
5
117
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -4,13 +4,17 @@ The shared plumbing every Throughline server package depends on. Drop it into a
|
|
|
4
4
|
|
|
5
5
|
## What's inside
|
|
6
6
|
|
|
7
|
-
| Subsystem
|
|
8
|
-
|
|
9
|
-
| Audit
|
|
10
|
-
| Events
|
|
11
|
-
| MCP
|
|
12
|
-
|
|
|
13
|
-
|
|
|
7
|
+
| Subsystem | Subpath | Role |
|
|
8
|
+
| ------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| Audit | `./audit` | `auditPlugin`, `createAuditCollection`, `createAuditWriter`, `getAuditWriter`, `AUDIT_ACTIONS`, `AUDIT_MCP_SERVERS` |
|
|
10
|
+
| Events | `./events` | `createInngestClient`, `CoreEvents`, `FrameworkEvents` (module-augmentation seam) |
|
|
11
|
+
| MCP | `./mcp` | `createMcpToolCollector`, `toPayloadMcpTools`, `McpMetaSchema`, `withMeta`, `auditContext`, `mcpApiKeyAccess`, `isSignedIn`, `signedIn`, `isMcpApiKeyPrincipal` |
|
|
12
|
+
| Environment | `./env` | `assertEnvironment`, `checkEnvValue`, `EnvironmentError` |
|
|
13
|
+
| Observability | `./observability` | `jobFailuresPlugin`, `getJobFailureWriter`, `createErrorReporter`, `reportError`, `buildRequestErrorReport`, `describeErrorReporting` |
|
|
14
|
+
| Logger | (main) | `defaultLogger`, `createNamedLogger` |
|
|
15
|
+
| Utils | (main) | `documentContentHash`, `sha256Hex`, `formatZodIssues` |
|
|
16
|
+
| Testing | `./testing` only | `describeAnonymousAccess`, `checkAnonymousAccess` — test helpers for a site; not re-exported from the main entry |
|
|
17
|
+
| Payload CLI runner | bin | `throughline-payload` — see [below](#running-the-payload-cli-throughline-payload) |
|
|
14
18
|
|
|
15
19
|
There is no `./auth` any more. It held an MCP key collection, a bearer-token
|
|
16
20
|
authenticator and `createMcpHandler` — a JSON-RPC subset each plugin mounted at its
|
|
@@ -18,7 +22,7 @@ own `/mcp`. `@payloadcms/plugin-mcp` owns the transport, the keys and authentica
|
|
|
18
22
|
now; `createMcpToolCollector` is how this suite's tools get to it. `sha256Hex`
|
|
19
23
|
survived, under `./utils`.
|
|
20
24
|
|
|
21
|
-
The main entry re-exports everything
|
|
25
|
+
The main entry re-exports everything except `./testing`; the subpath exports keep bundles smaller for consumers who only need one slice.
|
|
22
26
|
|
|
23
27
|
## Installation
|
|
24
28
|
|
|
@@ -26,7 +30,7 @@ The main entry re-exports everything; the subpath exports keep bundles smaller f
|
|
|
26
30
|
pnpm add @forumone/throughline-core
|
|
27
31
|
```
|
|
28
32
|
|
|
29
|
-
Peers: `payload@^3.
|
|
33
|
+
Peers: `payload@^3.89.0` and `inngest@^4.0.0`.
|
|
30
34
|
|
|
31
35
|
## The audit log
|
|
32
36
|
|
|
@@ -68,30 +72,80 @@ onInit: async (payload) => {
|
|
|
68
72
|
|
|
69
73
|
The writer is **fire-and-forget**: failures log but never throw. Audit failures must never break the originating action.
|
|
70
74
|
|
|
75
|
+
### Sidebar group
|
|
76
|
+
|
|
77
|
+
The `audit-events` collection sits in the admin sidebar's `Throughline` group by default. `auditPlugin({ inngest, admin: { group: 'Workflow' } })` files it elsewhere; `admin: { group: false }` leaves it ungrouped. Every Throughline plugin that declares a collection takes the same option — see [the reference](https://github.com/forumone/throughline/blob/main/docs/reference/plugin-contract.md#admin-sidebar-group).
|
|
78
|
+
|
|
79
|
+
## Job failures and error reporting
|
|
80
|
+
|
|
81
|
+
The audit log records who did what through an MCP tool. A background job that
|
|
82
|
+
ran out of retries is not that, so it has its own collection:
|
|
83
|
+
`jobFailuresPlugin()` adds `job-failures` and attaches a writer that
|
|
84
|
+
`@forumone/throughline-workflows`' failure handlers find. Adding it to an
|
|
85
|
+
existing site is a schema change — run `payload migrate:create` afterwards.
|
|
86
|
+
|
|
87
|
+
Error reports go to a webhook, not to a vendor SDK: `reportError` posts JSON to
|
|
88
|
+
`ERROR_WEBHOOK_URL` (a log drain, an alerting endpoint, a Slack incoming
|
|
89
|
+
webhook, a proxy in front of a tracker), with a 3-second timeout, and never
|
|
90
|
+
throws. `buildRequestErrorReport` shapes what Next's `onRequestError` hands
|
|
91
|
+
over, copying request headers from an allowlist — `cookie` and `authorization`
|
|
92
|
+
are never copied.
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// instrumentation.ts
|
|
96
|
+
import type { Instrumentation } from 'next'
|
|
97
|
+
import { buildRequestErrorReport, reportError } from '@forumone/throughline-core/observability'
|
|
98
|
+
|
|
99
|
+
export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
|
|
100
|
+
await reportError(buildRequestErrorReport(error, request, context))
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`./observability` imports neither Payload nor Inngest at runtime, so it is safe
|
|
105
|
+
in `instrumentation.ts`. See `docs/operations/observability.md`.
|
|
106
|
+
|
|
71
107
|
## MCP authentication
|
|
72
108
|
|
|
73
|
-
`
|
|
109
|
+
`@payloadcms/plugin-mcp` handles MCP authentication. It adds the `payload-mcp-api-keys` collection, looks up `Authorization: Bearer <key>` on `/api/mcp`, and runs each tool as the person the key is bound to. This package's `createApiKeysCollection`, `createBearerTokenAuthenticator` and `createMcpHandler` are gone.
|
|
110
|
+
|
|
111
|
+
What this package adds is two access helpers for a site that registers that plugin.
|
|
112
|
+
|
|
113
|
+
**Only admins should manage keys.** A key runs every tool as its user, so minting, reading and revoking keys is an admin's job. The plugin doesn't enforce that by default. `mcpApiKeyAccess(isAdmin)` is an `overrideApiKeyCollection` that applies your admin rule to `read`, `create`, `update`, `delete` and `unlock`, and refuses an MCP key principal before it asks the rule:
|
|
74
114
|
|
|
75
115
|
```ts
|
|
76
|
-
import {
|
|
77
|
-
import
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
serverName: 'publishing',
|
|
87
|
-
tools: [/* McpToolDefinition[] */],
|
|
88
|
-
authenticator,
|
|
116
|
+
import { createMcpToolCollector, mcpApiKeyAccess } from '@forumone/throughline-core'
|
|
117
|
+
import { mcpPlugin } from '@payloadcms/plugin-mcp'
|
|
118
|
+
import type { Access } from 'payload'
|
|
119
|
+
|
|
120
|
+
const isAdmin: Access = ({ req: { user } }) =>
|
|
121
|
+
user?.collection === 'users' && Array.isArray(user.roles) && user.roles.includes('admin')
|
|
122
|
+
|
|
123
|
+
mcpPlugin({
|
|
124
|
+
mcp: { tools: mcpTools.tools },
|
|
125
|
+
overrideApiKeyCollection: mcpApiKeyAccess(isAdmin),
|
|
89
126
|
})
|
|
127
|
+
```
|
|
90
128
|
|
|
91
|
-
|
|
92
|
-
|
|
129
|
+
It changes `access` and nothing else. To set other options too, call it inside your own override:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
overrideApiKeyCollection: collection => {
|
|
133
|
+
const hardened = mcpApiKeyAccess(isAdmin)(collection)
|
|
134
|
+
return { ...hardened, admin: { ...hardened.admin, group: 'Throughline' } }
|
|
135
|
+
},
|
|
93
136
|
```
|
|
94
137
|
|
|
138
|
+
**"Signed in" is not `Boolean(req.user)`.** Before Payload 3.89.0, the key collection's `auth.useAPIKey` registered Payload's API-key strategy on every REST route, so a key _document_ could become `req.user`. It has no roles, but it passes `Boolean(req.user)`, so a rule like "published, or anybody signed in" served drafts to anyone holding a key. Payload 3.89.0 fixed this, and it's this package's peer floor. Refuse the principal in your own rules as well:
|
|
139
|
+
|
|
140
|
+
| Export | What it does |
|
|
141
|
+
| ---------------------------- | ------------------------------------------------------------------------------- |
|
|
142
|
+
| `isSignedIn(user)` | `true` for a person, `false` for anonymous or an MCP key document. A type guard |
|
|
143
|
+
| `signedIn` | `isSignedIn` as an `Access` function |
|
|
144
|
+
| `isMcpApiKeyPrincipal(user)` | `true` only for a key document from `payload-mcp-api-keys` |
|
|
145
|
+
| `MCP_API_KEYS_SLUG` | `'payload-mcp-api-keys'` |
|
|
146
|
+
|
|
147
|
+
None of these affects `/api/mcp`. A tool call there arrives as the key's user, in `users`, with that person's roles. See [the security model](../../docs/operations/security-model.md#signed-in-is-not-booleanrequser).
|
|
148
|
+
|
|
95
149
|
### Scopes
|
|
96
150
|
|
|
97
151
|
A key carries `scopes`, and a tool may declare the one it needs:
|
|
@@ -135,13 +189,13 @@ not as everything.
|
|
|
135
189
|
|
|
136
190
|
The consequential tools in this suite and the scopes they require:
|
|
137
191
|
|
|
138
|
-
| Scope
|
|
139
|
-
|
|
140
|
-
| `publishing.execute`
|
|
141
|
-
| `approvals.request`
|
|
142
|
-
| `approvals.decide`
|
|
143
|
-
| `forms.manage`
|
|
144
|
-
| `integrations.trigger` | `trigger_sync`, `test_integration`
|
|
192
|
+
| Scope | Tools |
|
|
193
|
+
| ---------------------- | --------------------------------------------------------------- |
|
|
194
|
+
| `publishing.execute` | `publish`, `unpublish`, `schedule_publish`, `rollback` |
|
|
195
|
+
| `approvals.request` | `request_approval` |
|
|
196
|
+
| `approvals.decide` | `respond_to_approval` |
|
|
197
|
+
| `forms.manage` | `create_form`, `update_form_fields`, `update_form_destinations` |
|
|
198
|
+
| `integrations.trigger` | `trigger_sync`, `test_integration` |
|
|
145
199
|
|
|
146
200
|
Everything else — the component tools, the audit queries, the read side of publishing and approvals — needs only a valid key.
|
|
147
201
|
|
|
@@ -161,6 +215,26 @@ declare module '@forumone/throughline-core/events' {
|
|
|
161
215
|
|
|
162
216
|
After augmentation, `inngest.send({ name: 'approval/decided', data: { ... } })` is type-checked everywhere.
|
|
163
217
|
|
|
218
|
+
## Checking the environment
|
|
219
|
+
|
|
220
|
+
Each plugin that falls back to `process.env` exports what it needs as data — `approvalsEnv`, `emailEnv`, `formsEnv` — and checks the same entries at init. A site passes every list, plus its own variables, to `assertEnvironment` first thing in `payload.config.ts`:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { assertEnvironment } from '@forumone/throughline-core'
|
|
224
|
+
import { approvalsEnv } from '@forumone/throughline-approvals'
|
|
225
|
+
import { emailEnv } from '@forumone/throughline-email'
|
|
226
|
+
|
|
227
|
+
assertEnvironment(
|
|
228
|
+
approvalsEnv,
|
|
229
|
+
emailEnv,
|
|
230
|
+
{ name: 'PAYLOAD_SECRET', minLength: 32, why: 'Signs Payload sessions.' },
|
|
231
|
+
() => databaseConnectionString(),
|
|
232
|
+
)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
It throws once, listing every missing or too-short value with the reason it is needed, under a first line that says `Configuration problem` — instead of each plugin failing on the first thing it finds, one deploy at a time, inside a `next build` stack trace. It never prints a value. A function argument is for a rule that is not "this name, this long": what it throws becomes a line in the report.
|
|
236
|
+
|
|
237
|
+
`checkEnvValue(requirement, value)` is the one-value check behind it, which a plugin uses for its own init backstop.
|
|
164
238
|
|
|
165
239
|
## Document content hashing
|
|
166
240
|
|
|
@@ -173,10 +247,95 @@ const version = await documentContentHash(page)
|
|
|
173
247
|
const withExtras = await documentContentHash(page, { exclude: ['syncedAt'] })
|
|
174
248
|
```
|
|
175
249
|
|
|
176
|
-
It exists so that approvals can bind to
|
|
250
|
+
It exists so that approvals can bind to _what an approver read_ rather than to when it was last saved. Approvals writes it as `targetVersion`; publishing recomputes it at publish time. Two consequences worth stating: a save that changed nothing keeps a granted approval, and an edit that is reverted brings one back.
|
|
177
251
|
|
|
178
252
|
**Two callers only agree if they hash a document loaded the same way.** Both of the above use `payload.findByID({ collection, id, draft: true })` at the config's default depth. A populated relationship and a bare relationship id are different values, and normalising cannot turn one into the other — so a third caller fetching at a different depth would produce a hash that matches nothing.
|
|
179
253
|
|
|
254
|
+
## Running the Payload CLI: `throughline-payload`
|
|
255
|
+
|
|
256
|
+
`pnpm payload generate:types` is shell → pnpm → node, and pnpm (like npm and
|
|
257
|
+
yarn) does not forward signals to the node child it spawns. Kill the shell — a
|
|
258
|
+
Ctrl-C, an agent's tool-call timeout, a cancelled CI step — and node keeps
|
|
259
|
+
running, reparented to PID 1. When the run was hung rather than slow, it spins
|
|
260
|
+
on a core until something kills it; one machine collected 48 of them.
|
|
261
|
+
|
|
262
|
+
`throughline-payload` is a drop-in for the `payload` binary that cannot do that.
|
|
263
|
+
Point every script that runs the Payload CLI at it:
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"scripts": {
|
|
268
|
+
"payload": "throughline-payload",
|
|
269
|
+
"payload:reap": "throughline-payload --reap",
|
|
270
|
+
"generate:types": "throughline-payload generate:types",
|
|
271
|
+
"generate:importmap": "throughline-payload generate:importmap",
|
|
272
|
+
"migrate": "PAYLOAD_MIGRATING=1 throughline-payload migrate",
|
|
273
|
+
"migrate:create": "throughline-payload migrate:create",
|
|
274
|
+
"migrate:status": "throughline-payload migrate:status"
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Arguments pass straight through. What it adds:
|
|
280
|
+
|
|
281
|
+
- **Its own process group.** Payload runs `detached`, so the whole subtree can be
|
|
282
|
+
killed as a group, by pid. Nothing is ever matched by process name.
|
|
283
|
+
- **Signals forwarded.** SIGINT, SIGTERM and SIGHUP go to the group; after a
|
|
284
|
+
grace period, whatever ignored them gets SIGKILL. When Payload exits on its
|
|
285
|
+
own, anything it left running in its group goes with it.
|
|
286
|
+
- **A wall clock.** `PAYLOAD_CLI_TIMEOUT_MS`, default 300000 (five minutes); `0`
|
|
287
|
+
disables it. The `migrate` family (`migrate`, `migrate:create`, …) has no wall
|
|
288
|
+
clock unless you set one: a large migration is legitimately long and
|
|
289
|
+
`migrate:create` can stop at a prompt. A timed-out run exits 124.
|
|
290
|
+
`PAYLOAD_CLI_GRACE_MS` (default 5000) is the wait between the polite signal
|
|
291
|
+
and SIGKILL.
|
|
292
|
+
- **A sweep for what a killed runner left behind.** A SIGKILLed runner runs no
|
|
293
|
+
handlers, so each run is recorded in `.payload-cli-pids` and the next run —
|
|
294
|
+
or `throughline-payload --reap` — kills the group of any entry whose runner
|
|
295
|
+
is gone. An entry is acted on only if the pid is alive, its start time and
|
|
296
|
+
full command line still match what was recorded (so a recycled pid is never
|
|
297
|
+
touched), the command line is rooted in this workspace, and its runner is
|
|
298
|
+
gone (so a concurrent run keeps its child). Anything else is forgotten, not
|
|
299
|
+
signalled.
|
|
300
|
+
|
|
301
|
+
Payload is resolved from the directory the command runs in — the app — not
|
|
302
|
+
from this package's install. `.payload-cli-pids` lives at the workspace root:
|
|
303
|
+
the nearest ancestor with a `pnpm-workspace.yaml`, or, outside a pnpm
|
|
304
|
+
workspace, the nearest directory with a `package.json`. Add it to
|
|
305
|
+
`.gitignore`. On Windows, which has neither process groups nor `ps`, the runner
|
|
306
|
+
just runs Payload.
|
|
307
|
+
|
|
308
|
+
## Testing what an anonymous reader can read
|
|
309
|
+
|
|
310
|
+
`@forumone/throughline-core/testing` holds a site's access rules to a bucket map, as a vitest suite that needs no database. It imports `vitest`, an optional peer, so it lives on its own subpath and the main entry never loads it.
|
|
311
|
+
|
|
312
|
+
Every query the public site makes runs with nobody signed in, so a collection's own `read` rule decides whether a page can see its content. Close one by mistake and nothing errors: the page renders empty. Open the wrong one and the audit log or the MCP keys are on the internet.
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
// apps/web/src/access/anonymousAccess.test.ts
|
|
316
|
+
import { describeAnonymousAccess } from '@forumone/throughline-core/testing'
|
|
317
|
+
import config from '../payload.config'
|
|
318
|
+
|
|
319
|
+
describeAnonymousAccess(config, {
|
|
320
|
+
renderPath: {
|
|
321
|
+
pages: 'the [...slug] route',
|
|
322
|
+
media: 'every populated upload',
|
|
323
|
+
},
|
|
324
|
+
private: ['users', 'payload-mcp-api-keys', 'audit-events', 'payload-preferences'],
|
|
325
|
+
// Optional. When given, every global must be in it.
|
|
326
|
+
globals: { renderPath: { navigation: 'the header and footer' } },
|
|
327
|
+
})
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
It asserts:
|
|
331
|
+
|
|
332
|
+
- **Every collection is in exactly one bucket.** That covers the collections plugins and Payload add as well as the site's own. A collection in no bucket fails, so adding one forces somebody to decide. An entry the config no longer has fails too.
|
|
333
|
+
- **Render-path collections allow an anonymous read.** The rule must answer `true` or a query. Having no rule fails, because Payload's default needs a user.
|
|
334
|
+
- **Collections with drafts narrow that read.** On a collection with `versions.drafts`, a bare `true` serves unpublished documents, so the rule has to answer a query, usually `{ _status: { equals: 'published' } }`.
|
|
335
|
+
- **Private collections refuse it.** `true` or a query fails. Having no rule passes, because Payload's default refuses.
|
|
336
|
+
|
|
337
|
+
A rule that calls `req.payload` cannot be checked as a value, and it throws rather than guessing. `checkAnonymousAccess(config, buckets)` returns the same findings as a list, for a script or a custom assertion.
|
|
338
|
+
|
|
180
339
|
## Why all of this lives in one package
|
|
181
340
|
|
|
182
341
|
Server packages (Component, Publishing, Approvals, Audit Query, Forms, Integrations) all depend on the same audit log, the same authentication pattern, and the same event taxonomy. Splitting these across packages would create circular dependencies — every server package would need the audit writer, and an audit-only package would need to know about every server. Consolidating the plumbing here keeps the dependency graph one-way: core → server packages → client app.
|