payload-mcp-toolkit 0.7.0 → 0.7.5
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/README.md +30 -9
- package/dist/api-keys.js +57 -21
- package/dist/api-keys.js.map +1 -1
- package/dist/auth-strategy.d.ts +18 -7
- package/dist/auth-strategy.js +54 -12
- package/dist/auth-strategy.js.map +1 -1
- package/dist/tools/_helpers.d.ts +34 -0
- package/dist/tools/_helpers.js +98 -0
- package/dist/tools/_helpers.js.map +1 -1
- package/dist/tools/create-document.js +8 -0
- package/dist/tools/create-document.js.map +1 -1
- package/dist/tools/delete-document.d.ts +1 -1
- package/dist/tools/delete-document.js +6 -6
- package/dist/tools/delete-document.js.map +1 -1
- package/dist/tools/find-document.d.ts +3 -3
- package/dist/tools/find-document.js +8 -8
- package/dist/tools/find-document.js.map +1 -1
- package/dist/tools/publish-draft.js +33 -1
- package/dist/tools/publish-draft.js.map +1 -1
- package/dist/tools/publish-global-draft.js +30 -1
- package/dist/tools/publish-global-draft.js.map +1 -1
- package/package.json +29 -15
- package/dist/__tests__/api-keys.test.js +0 -292
- package/dist/__tests__/api-keys.test.js.map +0 -1
- package/dist/__tests__/auth-strategy.test.js +0 -681
- package/dist/__tests__/auth-strategy.test.js.map +0 -1
- package/dist/__tests__/conflict-detection.test.js +0 -69
- package/dist/__tests__/conflict-detection.test.js.map +0 -1
- package/dist/__tests__/delete-document.test.js +0 -70
- package/dist/__tests__/delete-document.test.js.map +0 -1
- package/dist/__tests__/endpoint.test.js +0 -143
- package/dist/__tests__/endpoint.test.js.map +0 -1
- package/dist/__tests__/find-document.test.js +0 -178
- package/dist/__tests__/find-document.test.js.map +0 -1
- package/dist/__tests__/find-global.test.js +0 -173
- package/dist/__tests__/find-global.test.js.map +0 -1
- package/dist/__tests__/global-versions.test.js +0 -183
- package/dist/__tests__/global-versions.test.js.map +0 -1
- package/dist/__tests__/hash.test.js +0 -58
- package/dist/__tests__/hash.test.js.map +0 -1
- package/dist/__tests__/index-integration.test.js +0 -191
- package/dist/__tests__/index-integration.test.js.map +0 -1
- package/dist/__tests__/introspection.test.js +0 -659
- package/dist/__tests__/introspection.test.js.map +0 -1
- package/dist/__tests__/patch-global-layout.test.js +0 -474
- package/dist/__tests__/patch-global-layout.test.js.map +0 -1
- package/dist/__tests__/patch-layout.test.js +0 -171
- package/dist/__tests__/patch-layout.test.js.map +0 -1
- package/dist/__tests__/registry.test.js +0 -795
- package/dist/__tests__/registry.test.js.map +0 -1
- package/dist/__tests__/resources.test.js +0 -139
- package/dist/__tests__/resources.test.js.map +0 -1
- package/dist/__tests__/update-global.test.js +0 -157
- package/dist/__tests__/update-global.test.js.map +0 -1
- package/dist/__tests__/url-validator.test.js +0 -326
- package/dist/__tests__/url-validator.test.js.map +0 -1
package/README.md
CHANGED
|
@@ -60,10 +60,10 @@ Configure each key's permissions through typed admin fields — no JSON to hand-
|
|
|
60
60
|
|
|
61
61
|
| Field | Effect |
|
|
62
62
|
|---|---|
|
|
63
|
-
| `preset` | Role preset: **Read-only**, **Editor** (read + create + update on collections; read-only on globals — see below), **Admin** (all actions on both), or **Custom** (use the override fields below). Required. Defaults to **Custom** so new keys deny everything until explicitly scoped. |
|
|
64
|
-
| `collectionScopes` | Array of `{
|
|
65
|
-
| `globalScopes` | Array of `{
|
|
66
|
-
| `toolAllow` | Multi-select. Only honoured when preset is **Custom**. If set, only these tools are callable with this key. **
|
|
63
|
+
| `preset` | Role preset: **Read-only**, **Editor** (read + create + update on collections; read-only on globals — see below), **Admin** (all actions on both), or **Custom** (use the override fields below). Required. Defaults to **Custom** so new keys deny everything until explicitly scoped. Switching away from Custom **clears every override field on save** (collectionScopes, globalScopes, toolAllow, toolDeny); switching back to Custom starts from a fresh deny-all baseline — reconfigure the matrices before saving. |
|
|
64
|
+
| `collectionScopes` | Array of `{ slug, actions[] }`. Only honoured when preset is **Custom**. Each row whitelists a collection and the actions (`read` / `create` / `update` / `delete`) allowed on it. An empty `actions[]` denies all actions on that collection. Listed collections are a *whitelist* — collections not in the list are denied. (Pre-v0.6 rows using `{ collection, actions[] }` are tolerated via a one-release legacy fallback; resave them to migrate.) |
|
|
65
|
+
| `globalScopes` | Array of `{ slug, actions[] }`. Only honoured when preset is **Custom** *and* the host config has at least one global. Globals only support `read` and `update` (no `create` / `delete` — they're singletons). Same whitelist semantics as `collectionScopes`. (Pre-v0.6 rows using `{ global, actions[] }` are tolerated via the same legacy fallback.) |
|
|
66
|
+
| `toolAllow` | Multi-select. Only honoured when preset is **Custom**. If set, only these tools are callable with this key. An empty list under Custom is treated as deny-all on the tools axis **only when no collection or global scopes are set** (the fresh-Custom-key sentinel); when collection or global scopes are populated, an empty list collapses to "no tool restriction" so the resource scopes alone gate access. To deny every tool while keeping resource scopes, enumerate them in `toolDeny` instead. |
|
|
67
67
|
| `toolDeny` | Multi-select. Always applied on top of any preset. Tools listed here are blocked regardless of preset / collection / global scopes. |
|
|
68
68
|
|
|
69
69
|
The collection and tool dropdowns are populated at plugin-init time from your live Payload config + the toolkit's registered tools. Adding a collection or custom tool requires a dev-server / app restart for it to surface in the dropdowns.
|
|
@@ -88,9 +88,9 @@ The same shape is editable programmatically via Payload's REST and GraphQL APIs
|
|
|
88
88
|
- `blockCompositionGuide` — section/leaf hierarchy and nesting rules.
|
|
89
89
|
- `draftWorkflowGuide` — which collections need `publishDraft` to go live.
|
|
90
90
|
|
|
91
|
-
**Auto-generated resources:** `blocks://catalog`, `collections://schema`, `collections://relationships`.
|
|
91
|
+
**Auto-generated resources:** `blocks://catalog`, `blocks://nesting`, `collections://schema`, `collections://relationships`. Plus `globals://schema` when the host config has at least one global.
|
|
92
92
|
|
|
93
|
-
**Tools (
|
|
93
|
+
**Tools (19 total — 10 collection-routed, 6 global-routed, 3 account-routed; globals tools register only when the host config has at least one global, and version / publish tools register only on draft-enabled resources):**
|
|
94
94
|
|
|
95
95
|
*Authoring*
|
|
96
96
|
- `createDocument` — local-API based creation for any collection. JSON-string `data`. Defaults to `draft: true` on draft-enabled collections.
|
|
@@ -99,12 +99,12 @@ The same shape is editable programmatically via Payload's REST and GraphQL APIs
|
|
|
99
99
|
- `uploadMedia` — fetch a public HTTPS image, validate (SSRF-safe with a streaming size cap), create a Media doc.
|
|
100
100
|
|
|
101
101
|
*Discovery*
|
|
102
|
-
- `findDocument` — read documents by `
|
|
102
|
+
- `findDocument` — read documents by `documentId` or `where` filter, polymorphic across collections. Decorates draft responses with preview URLs when configured.
|
|
103
103
|
- `resolveReference` — search collections by name/title/slug for relationship IDs.
|
|
104
104
|
- `searchContent` — natural-language editor triage (status, recency, missing fields, free text).
|
|
105
105
|
|
|
106
106
|
*Lifecycle / safety*
|
|
107
|
-
- `publishDraft` — flip `_status` from draft to published.
|
|
107
|
+
- `publishDraft` — flip `_status` from draft to published. Recovers from Payload's post-write field-validator quirk (validator throws *after* the new version row commits in some draft+versions setups): on a caught error, the tool re-reads the doc with `draft: false` and only downgrades to a "published-with-warning" response when the live row reflects the current attempt (strictly newer `updatedAt`), so a stale prior publish cannot mask a real failure.
|
|
108
108
|
- `schedulePublish` — auto-registered for collections with drafts AND a `publishedAt` date field. Stamps a future `publishedAt`; you wire up the actual flip via Payload Jobs Queue / cron / `beforeRead`.
|
|
109
109
|
- `listVersions` — recent saved versions of a draft document.
|
|
110
110
|
- `restoreVersion` — roll a document back to a saved version (creates a new version, so reversible).
|
|
@@ -115,7 +115,7 @@ The same shape is editable programmatically via Payload's REST and GraphQL APIs
|
|
|
115
115
|
- `findGlobal` — read any global by slug. Stamps a preview URL on draft documents when `admin.livePreview` / `admin.preview` is configured.
|
|
116
116
|
- `updateGlobal` — partial-merge update; same prose JSON contract as `updateDocument`. Draft-enabled globals default to `'always-draft'`.
|
|
117
117
|
- `patchGlobalLayout` — surgical block-array edits on any blocks-typed field inside a global, at any nesting depth (e.g. `footer.sections`). Registered only when at least one global has a blocks field.
|
|
118
|
-
- `publishGlobalDraft`, `listGlobalVersions`, `restoreGlobalVersion` — registered only for globals with `versions: { drafts: true }`.
|
|
118
|
+
- `publishGlobalDraft`, `listGlobalVersions`, `restoreGlobalVersion` — registered only for globals with `versions: { drafts: true }`. `publishGlobalDraft` uses the same post-write validation recovery as `publishDraft`, with `fallbackLocale: false` on the verify read so localized globals report the literal `_status` of the requested locale.
|
|
119
119
|
|
|
120
120
|
## Globals
|
|
121
121
|
|
|
@@ -173,6 +173,27 @@ mcpToolkitPlugin({
|
|
|
173
173
|
| `mediaUpload.maxFileSize` | Default 10MB. Enforced as a streaming cap, not a post-buffer check. |
|
|
174
174
|
| `mediaUpload.collectionSlug` | Default `'media'`. |
|
|
175
175
|
|
|
176
|
+
## Upgrading from 0.7.0
|
|
177
|
+
|
|
178
|
+
v0.7.1 is a patch release; no API or breaking config changes. The behavioural changes worth knowing:
|
|
179
|
+
|
|
180
|
+
- **Preset-switch clears overrides on save.** Switching an API key away from Custom now nulls `collectionScopes`, `globalScopes`, `toolAllow`, and `toolDeny` on save (admin UI conditional-field trap fix — previously, stale Custom-era values silently survived the switch and continued to narrow access). Switching back to Custom starts from a fresh deny-all baseline; reconfigure the matrices before saving.
|
|
181
|
+
- **Empty `toolAllow` under Custom + populated resource scopes no longer denies all tools.** When the key carries collection or global scopes and `toolAllow` is empty, it is treated as "no tool restriction" so the resource scopes alone determine what is callable. The fresh-Custom-key sentinel (no scopes anywhere → deny-all) still applies.
|
|
182
|
+
- **Legacy non-Custom rows with populated overrides emit a one-time warn.** Keys persisted before v0.7.1 that carry populated `collectionScopes` / `globalScopes` / `toolAllow` arrays under a non-Custom preset still narrow access as written (fail-closed safe), but `composeScopes` now logs `mcp.auth.legacy_non_custom_override` once per process to flag them for audit. Re-save affected keys in admin to align persisted state with the v0.7.1 semantics.
|
|
183
|
+
- **Publish tools recover from Payload's post-write validator throw deterministically.** Both `publishDraft` and `publishGlobalDraft` snapshot the document's `updatedAt` before the update and only downgrade a caught error to a `[publishDraft:published_with_warning]` / `[publishGlobalDraft:published_with_warning]` response when the live row reflects the current attempt (strictly newer `updatedAt`). MCP clients can branch on the stable token prefix without regex-matching prose.
|
|
184
|
+
|
|
185
|
+
## Upgrading from 0.6
|
|
186
|
+
|
|
187
|
+
v0.7 renames the exported plugin factory so the public symbol matches the package name. Pure rename — no options, runtime behaviour, or scope semantics changed.
|
|
188
|
+
|
|
189
|
+
```diff
|
|
190
|
+
- import { contentToolkitPlugin } from 'payload-mcp-toolkit'
|
|
191
|
+
+ import { mcpToolkitPlugin } from 'payload-mcp-toolkit'
|
|
192
|
+
|
|
193
|
+
- plugins: [contentToolkitPlugin()],
|
|
194
|
+
+ plugins: [mcpToolkitPlugin()],
|
|
195
|
+
```
|
|
196
|
+
|
|
176
197
|
## Upgrading from 0.5
|
|
177
198
|
|
|
178
199
|
v0.6 adds globals support across the MCP surface. The changes most likely to surprise an upgrade:
|
package/dist/api-keys.js
CHANGED
|
@@ -53,7 +53,7 @@ const isCustomPreset = (data)=>!!data && typeof data === 'object' && data.preset
|
|
|
53
53
|
defaultValue: 'custom',
|
|
54
54
|
options: PRESET_OPTIONS,
|
|
55
55
|
admin: {
|
|
56
|
-
description: 'Role preset. "Custom" unlocks the per-collection matrix and the tool overrides below.'
|
|
56
|
+
description: 'Role preset. "Custom" unlocks the per-collection matrix and the tool overrides below. ' + 'Switching away from Custom CLEARS every override on save (collectionScopes, globalScopes, ' + 'toolAllow, toolDeny); switching back to Custom starts from a fresh deny-all baseline, so ' + 'reconfigure the matrices and tool lists before saving.'
|
|
57
57
|
}
|
|
58
58
|
};
|
|
59
59
|
// Stored shape: Array<{ slug: string; actions: ('read'|'create'|'update'|'delete')[] }>
|
|
@@ -118,7 +118,7 @@ const isCustomPreset = (data)=>!!data && typeof data === 'object' && data.preset
|
|
|
118
118
|
hasMany: true,
|
|
119
119
|
options: toolOptions,
|
|
120
120
|
admin: {
|
|
121
|
-
description: 'If set, only these tools are callable with this key. Leave empty to allow any tool the collection scopes permit. Under the Custom preset, an
|
|
121
|
+
description: 'If set, only these tools are callable with this key. Leave empty to allow any tool ' + 'the collection or global scopes permit. Under the Custom preset, an empty list is ' + 'treated as deny-all ONLY when no collection or global scopes are set (the fresh- ' + 'Custom-key sentinel); when collection or global scopes are populated, an empty list ' + 'collapses to "no tool restriction" so the resource scopes alone determine what is ' + 'callable — to deny every tool while keeping resource scopes, enumerate them in ' + 'toolDeny instead. Preset-mode keys created via the REST API with an empty list are ' + 'coerced to "no restriction".'
|
|
122
122
|
}
|
|
123
123
|
},
|
|
124
124
|
{
|
|
@@ -154,28 +154,64 @@ const isCustomPreset = (data)=>!!data && typeof data === 'object' && data.preset
|
|
|
154
154
|
},
|
|
155
155
|
hooks: {
|
|
156
156
|
beforeValidate: [
|
|
157
|
-
({ data })=>{
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
157
|
+
({ data, originalDoc })=>{
|
|
158
|
+
// The override fields (collectionScopes, globalScopes, toolAllow,
|
|
159
|
+
// toolDeny) are conditionally rendered only under the Custom preset
|
|
160
|
+
// (`condition: isCustomPreset`). Under any other preset they are
|
|
161
|
+
// hidden in the admin UI, which means two things:
|
|
162
|
+
//
|
|
163
|
+
// 1. The admin form omits hidden fields from its payload on save,
|
|
164
|
+
// so `data` only carries the visible fields — we can't
|
|
165
|
+
// "collapse the empty array we see in `data`" because we never
|
|
166
|
+
// see it at all. The stale value lives on `originalDoc`.
|
|
167
|
+
// 2. A Custom→Admin switch silently keeps the prior
|
|
168
|
+
// `toolAllow:[...]` / `collectionScopes:[...]`, and
|
|
169
|
+
// `composeScopes` then emits a scope gate that rejects calls
|
|
170
|
+
// the user clearly intended to allow.
|
|
171
|
+
//
|
|
172
|
+
// Fix: when the preset is non-Custom, explicitly write `null` into
|
|
173
|
+
// `data` for every override axis (regardless of what `data` carries
|
|
174
|
+
// or what originalDoc holds). Payload persists nulls, so the stale
|
|
175
|
+
// values are erased on every save. The Custom-preset branch below
|
|
176
|
+
// keeps the explicit-empty-means-deny semantic intact.
|
|
167
177
|
if (!data) return data;
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
178
|
+
const d = data;
|
|
179
|
+
const orig = originalDoc ?? {};
|
|
180
|
+
const preset = d.preset ?? orig.preset;
|
|
181
|
+
// `readField` falls through to originalDoc when `data` omits the
|
|
182
|
+
// key entirely (admin form skipping hidden fields), but honours
|
|
183
|
+
// an explicit null/empty in `data` over originalDoc.
|
|
184
|
+
const readField = (key)=>key in d ? d[key] : orig[key];
|
|
185
|
+
const isNonEmptyArray = (v)=>Array.isArray(v) && v.length > 0;
|
|
186
|
+
const isEmptyArray = (v)=>Array.isArray(v) && v.length === 0;
|
|
187
|
+
const OVERRIDE_AXES = [
|
|
188
|
+
'collectionScopes',
|
|
189
|
+
'globalScopes',
|
|
171
190
|
'toolAllow',
|
|
172
191
|
'toolDeny'
|
|
173
|
-
]
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
192
|
+
];
|
|
193
|
+
if (preset !== 'custom') {
|
|
194
|
+
for (const axis of OVERRIDE_AXES)d[axis] = null;
|
|
195
|
+
return data;
|
|
196
|
+
}
|
|
197
|
+
// Custom preset: the Tools collapsible is labelled as an *override*
|
|
198
|
+
// layered on top of collection / global scopes, and its description
|
|
199
|
+
// says "Leave empty to allow any tool the collection scopes permit."
|
|
200
|
+
// Payload's hasMany-select default of `[]` would otherwise turn the
|
|
201
|
+
// Tools section into a mandatory whitelist — a user who configures
|
|
202
|
+
// collection scopes and never opens the collapsible would silently
|
|
203
|
+
// store `toolAllow:[]`, which `composeScopes` honours as deny-all on
|
|
204
|
+
// the tools axis and rejects every call.
|
|
205
|
+
//
|
|
206
|
+
// Resolve the mismatch by coercing an empty `toolAllow` to null
|
|
207
|
+
// whenever the key carries any concrete resource scope (collection
|
|
208
|
+
// or global entries). The fresh-Custom-key sentinel in
|
|
209
|
+
// `composeScopes` still covers the "no scopes at all" case
|
|
210
|
+
// (everything null → deny-all), so users who genuinely want
|
|
211
|
+
// deny-all do not regress.
|
|
212
|
+
const hasResourceScope = isNonEmptyArray(readField('collectionScopes')) || isNonEmptyArray(readField('globalScopes'));
|
|
213
|
+
if (hasResourceScope && isEmptyArray(readField('toolAllow'))) {
|
|
214
|
+
d.toolAllow = null;
|
|
179
215
|
}
|
|
180
216
|
return data;
|
|
181
217
|
}
|
package/dist/api-keys.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/api-keys.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\r\n\r\nexport const API_KEYS_DEFAULT_SLUG = 'payload-mcp-api-keys'\r\n\r\nexport interface CreateApiKeysCollectionOptions {\r\n /**\r\n * Collection slug. Defaults to `payload-mcp-api-keys` for zero-touch\r\n * compatibility with rows created by `@payloadcms/plugin-mcp` v0.3.x.\r\n */\r\n slug?: string\r\n /**\r\n * Slug of the user collection that API keys link to. Required.\r\n */\r\n userCollection: string\r\n /**\r\n * Collection slugs offered to the collection-scopes matrix component.\r\n * Snapshotted at plugin-init time from the host Payload config; adding a\r\n * collection requires a dev-server restart for it to surface in the\r\n * admin UI.\r\n */\r\n availableCollections: string[]\r\n /**\r\n * Tool names offered as options for the `toolAllow` / `toolDeny` selects.\r\n * Sourced from the toolkit's registered tools at plugin init.\r\n */\r\n availableTools: string[]\r\n /**\r\n * Global slugs offered to the global-scopes matrix component. Optional —\r\n * direct callers of the factory that pre-date globals support continue\r\n * to work; sites with no globals get an empty array and the second\r\n * matrix table is not rendered.\r\n */\r\n availableGlobals?: string[]\r\n}\r\n\r\nconst PRESET_OPTIONS = [\r\n { label: 'Read-only', value: 'read-only' },\r\n { label: 'Editor (read + create + update)', value: 'editor' },\r\n { label: 'Admin (all actions)', value: 'admin' },\r\n { label: 'Custom (use overrides below)', value: 'custom' },\r\n] as const\r\n\r\nconst isCustomPreset = (data: unknown): boolean =>\r\n !!data && typeof data === 'object' && (data as { preset?: unknown }).preset === 'custom'\r\n\r\n/**\r\n * Builds the `payload-mcp-api-keys` collection used by the v0.4 standalone\r\n * plugin. Reuses Payload's built-in `useAPIKey: true` so the underlying\r\n * `apiKey` / `apiKeyIndex` columns match what `@payloadcms/plugin-mcp`\r\n * v0.3.x wrote — existing rows authenticate without re-issue.\r\n *\r\n * Layout:\r\n * - Main column: name, description, preset, scopes matrix (custom only),\r\n * tools collapsible (custom only).\r\n * - Sidebar: user relationship, key prefix, expiresAt, revokedAt,\r\n * lastUsedAt — identity + lifecycle metadata kept out of the\r\n * scope-editing flow.\r\n */\r\nexport function createApiKeysCollection(\r\n options: CreateApiKeysCollectionOptions,\r\n): CollectionConfig {\r\n if (!options || !options.userCollection) {\r\n throw new Error(\r\n 'createApiKeysCollection: `userCollection` is required (slug of the user collection that owns API keys).',\r\n )\r\n }\r\n if (!Array.isArray(options.availableCollections)) {\r\n throw new Error(\r\n 'createApiKeysCollection: `availableCollections` is required (slugs of collections that scope overrides may target).',\r\n )\r\n }\r\n if (!Array.isArray(options.availableTools)) {\r\n throw new Error(\r\n 'createApiKeysCollection: `availableTools` is required (names of registered MCP tools).',\r\n )\r\n }\r\n\r\n const slug = options.slug ?? API_KEYS_DEFAULT_SLUG\r\n const toolOptions = options.availableTools.map((t) => ({ label: t, value: t }))\r\n const availableGlobals = Array.isArray(options.availableGlobals)\r\n ? options.availableGlobals\r\n : []\r\n\r\n const presetField: Field = {\r\n name: 'preset',\r\n type: 'select',\r\n required: true,\r\n defaultValue: 'custom',\r\n options: PRESET_OPTIONS as unknown as { label: string; value: string }[],\r\n admin: {\r\n description:\r\n 'Role preset. \"Custom\" unlocks the per-collection matrix and the tool overrides below.',\r\n },\r\n }\r\n\r\n // Stored shape: Array<{ slug: string; actions: ('read'|'create'|'update'|'delete')[] }>\r\n // The default Payload UI for an `array` would force users to add rows\r\n // one at a time; the custom matrix component renders all available\r\n // collections at once with a checkbox grid (rows × actions).\r\n //\r\n // `availableCollections` is forwarded via `clientProps` — Payload v3's\r\n // sanctioned escape hatch for serializable static data that the client\r\n // component needs at render time.\r\n const collectionScopesField: Field = {\r\n name: 'collectionScopes',\r\n type: 'json',\r\n admin: {\r\n condition: isCustomPreset,\r\n components: {\r\n Field: {\r\n path: 'payload-mcp-toolkit/client',\r\n exportName: 'CollectionScopesMatrix',\r\n clientProps: {\r\n availableCollections: options.availableCollections,\r\n },\r\n },\r\n },\r\n },\r\n }\r\n\r\n // Mirrors `collectionScopes` exactly — one additive JSONB column with a\r\n // default of `'[]'`, default-rendered by `GlobalScopesMatrix`. Hidden\r\n // under non-custom presets. Stored shape:\r\n // Array<{ slug: string; actions: ('read'|'update')[] }>\r\n // No `availableGlobals.length > 0` gate: `ScopesTable` renders its own\r\n // empty-state message when zero items are passed, so the field surfaces\r\n // under Custom regardless of host config, matching the collection variant.\r\n const globalScopesField: Field = {\r\n name: 'globalScopes',\r\n type: 'json',\r\n admin: {\r\n condition: isCustomPreset,\r\n components: {\r\n Field: {\r\n path: 'payload-mcp-toolkit/client',\r\n exportName: 'GlobalScopesMatrix',\r\n clientProps: {\r\n availableGlobals,\r\n },\r\n },\r\n },\r\n },\r\n }\r\n\r\n const toolsCollapsible: Field = {\r\n type: 'collapsible',\r\n label: 'Tool overrides',\r\n admin: {\r\n condition: isCustomPreset,\r\n description:\r\n 'Per-tool whitelist / blacklist. Layered on top of preset and collection scopes.',\r\n initCollapsed: true,\r\n },\r\n fields: [\r\n {\r\n name: 'toolAllow',\r\n type: 'select',\r\n hasMany: true,\r\n options: toolOptions,\r\n admin: {\r\n description:\r\n 'If set, only these tools are callable with this key. Leave empty to allow any tool the collection scopes permit. Under the Custom preset, an explicit empty list is treated as deny-all on this axis; preset-mode keys created via the REST API with an empty list are coerced to \"no restriction\".',\r\n },\r\n },\r\n {\r\n name: 'toolDeny',\r\n type: 'select',\r\n hasMany: true,\r\n options: toolOptions,\r\n admin: {\r\n description: 'These tools are blocked regardless of any other scope.',\r\n },\r\n },\r\n ],\r\n }\r\n\r\n return {\r\n slug,\r\n admin: {\r\n group: 'MCP',\r\n useAsTitle: 'name',\r\n description:\r\n 'API keys for MCP clients. Scopes control which collections and tools each key can access.',\r\n defaultColumns: ['name', 'user', 'keyPrefix', 'preset', 'lastUsedAt', 'expiresAt', 'revokedAt'],\r\n },\r\n auth: {\r\n disableLocalStrategy: true,\r\n useAPIKey: true,\r\n },\r\n hooks: {\r\n beforeValidate: [\r\n ({ data }) => {\r\n // REST clients that create a key with a preset (e.g. admin) but\r\n // omit toolAllow / toolDeny would hit Payload's hasMany-select\r\n // default of `[]`, which composeScopes correctly interprets as\r\n // \"deny-all on this axis\" (v0.6 Track A). That is the right\r\n // semantic for explicit-Custom configurations but a UX trap for\r\n // preset-mode keys created via the REST API. Collapse empty\r\n // arrays to null when the key is NOT on the Custom preset, so\r\n // preset-driven access flows through unimpeded. Custom-preset\r\n // keys keep the explicit-empty-means-deny semantic intact.\r\n if (!data) return data\r\n const preset = (data as { preset?: unknown }).preset\r\n if (preset === 'custom') return data\r\n for (const axis of ['toolAllow', 'toolDeny'] as const) {\r\n const v = (data as Record<string, unknown>)[axis]\r\n if (Array.isArray(v) && v.length === 0) {\r\n ;(data as Record<string, unknown>)[axis] = null\r\n }\r\n }\r\n return data\r\n },\r\n ],\r\n },\r\n labels: {\r\n plural: 'API Keys',\r\n singular: 'API Key',\r\n },\r\n fields: [\r\n // Main column.\r\n {\r\n name: 'name',\r\n type: 'text',\r\n required: true,\r\n admin: { description: 'Human label for this key (e.g. \"Editorial team — Claude Desktop\").' },\r\n },\r\n {\r\n name: 'description',\r\n type: 'textarea',\r\n admin: { description: 'Optional notes about the purpose of this key.' },\r\n },\r\n presetField,\r\n collectionScopesField,\r\n globalScopesField,\r\n toolsCollapsible,\r\n\r\n // Sidebar — identity + lifecycle.\r\n {\r\n name: 'user',\r\n type: 'relationship',\r\n relationTo: options.userCollection,\r\n required: true,\r\n admin: {\r\n position: 'sidebar',\r\n description:\r\n 'The user this key authenticates as. Tool calls use this user for access checks on target collections.',\r\n },\r\n },\r\n {\r\n name: 'keyPrefix',\r\n type: 'text',\r\n index: true,\r\n admin: {\r\n position: 'sidebar',\r\n readOnly: true,\r\n description:\r\n 'First 8 characters of the API key — used in audit logs to identify the key without exposing the full secret.',\r\n },\r\n hooks: {\r\n beforeChange: [\r\n ({ data, originalDoc, value }) => {\r\n if (typeof value === 'string' && value.length > 0) return value\r\n const incomingKey = (data as { apiKey?: unknown } | undefined)?.apiKey\r\n if (typeof incomingKey === 'string' && incomingKey.length >= 8) {\r\n return incomingKey.slice(0, 8)\r\n }\r\n const existing = (originalDoc as { keyPrefix?: unknown } | undefined)?.keyPrefix\r\n return typeof existing === 'string' ? existing : undefined\r\n },\r\n ],\r\n },\r\n },\r\n {\r\n name: 'expiresAt',\r\n type: 'date',\r\n admin: {\r\n position: 'sidebar',\r\n description: 'Optional expiry. Requests authenticated with an expired key are rejected.',\r\n },\r\n },\r\n {\r\n name: 'revokedAt',\r\n type: 'date',\r\n admin: {\r\n position: 'sidebar',\r\n description: 'Set to revoke a key. Revoked keys are rejected at auth time.',\r\n },\r\n },\r\n {\r\n name: 'lastUsedAt',\r\n type: 'date',\r\n admin: {\r\n position: 'sidebar',\r\n readOnly: true,\r\n description:\r\n 'Updated on each successful authentication. Fire-and-forget; not on the request hot path.',\r\n },\r\n },\r\n ],\r\n }\r\n}\r\n"],"names":["API_KEYS_DEFAULT_SLUG","PRESET_OPTIONS","label","value","isCustomPreset","data","preset","createApiKeysCollection","options","userCollection","Error","Array","isArray","availableCollections","availableTools","slug","toolOptions","map","t","availableGlobals","presetField","name","type","required","defaultValue","admin","description","collectionScopesField","condition","components","Field","path","exportName","clientProps","globalScopesField","toolsCollapsible","initCollapsed","fields","hasMany","group","useAsTitle","defaultColumns","auth","disableLocalStrategy","useAPIKey","hooks","beforeValidate","axis","v","length","labels","plural","singular","relationTo","position","index","readOnly","beforeChange","originalDoc","incomingKey","apiKey","slice","existing","keyPrefix","undefined"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../src/api-keys.ts"],"sourcesContent":["import type { CollectionBeforeValidateHook, CollectionConfig, Field } from 'payload'\r\n\r\nexport const API_KEYS_DEFAULT_SLUG = 'payload-mcp-api-keys'\r\n\r\nexport interface CreateApiKeysCollectionOptions {\r\n /**\r\n * Collection slug. Defaults to `payload-mcp-api-keys` for zero-touch\r\n * compatibility with rows created by `@payloadcms/plugin-mcp` v0.3.x.\r\n */\r\n slug?: string\r\n /**\r\n * Slug of the user collection that API keys link to. Required.\r\n */\r\n userCollection: string\r\n /**\r\n * Collection slugs offered to the collection-scopes matrix component.\r\n * Snapshotted at plugin-init time from the host Payload config; adding a\r\n * collection requires a dev-server restart for it to surface in the\r\n * admin UI.\r\n */\r\n availableCollections: string[]\r\n /**\r\n * Tool names offered as options for the `toolAllow` / `toolDeny` selects.\r\n * Sourced from the toolkit's registered tools at plugin init.\r\n */\r\n availableTools: string[]\r\n /**\r\n * Global slugs offered to the global-scopes matrix component. Optional —\r\n * direct callers of the factory that pre-date globals support continue\r\n * to work; sites with no globals get an empty array and the second\r\n * matrix table is not rendered.\r\n */\r\n availableGlobals?: string[]\r\n}\r\n\r\nconst PRESET_OPTIONS = [\r\n { label: 'Read-only', value: 'read-only' },\r\n { label: 'Editor (read + create + update)', value: 'editor' },\r\n { label: 'Admin (all actions)', value: 'admin' },\r\n { label: 'Custom (use overrides below)', value: 'custom' },\r\n] as const\r\n\r\nconst isCustomPreset = (data: unknown): boolean =>\r\n !!data && typeof data === 'object' && (data as { preset?: unknown }).preset === 'custom'\r\n\r\n/**\r\n * Builds the `payload-mcp-api-keys` collection used by the v0.4 standalone\r\n * plugin. Reuses Payload's built-in `useAPIKey: true` so the underlying\r\n * `apiKey` / `apiKeyIndex` columns match what `@payloadcms/plugin-mcp`\r\n * v0.3.x wrote — existing rows authenticate without re-issue.\r\n *\r\n * Layout:\r\n * - Main column: name, description, preset, scopes matrix (custom only),\r\n * tools collapsible (custom only).\r\n * - Sidebar: user relationship, key prefix, expiresAt, revokedAt,\r\n * lastUsedAt — identity + lifecycle metadata kept out of the\r\n * scope-editing flow.\r\n */\r\nexport function createApiKeysCollection(\r\n options: CreateApiKeysCollectionOptions,\r\n): CollectionConfig {\r\n if (!options || !options.userCollection) {\r\n throw new Error(\r\n 'createApiKeysCollection: `userCollection` is required (slug of the user collection that owns API keys).',\r\n )\r\n }\r\n if (!Array.isArray(options.availableCollections)) {\r\n throw new Error(\r\n 'createApiKeysCollection: `availableCollections` is required (slugs of collections that scope overrides may target).',\r\n )\r\n }\r\n if (!Array.isArray(options.availableTools)) {\r\n throw new Error(\r\n 'createApiKeysCollection: `availableTools` is required (names of registered MCP tools).',\r\n )\r\n }\r\n\r\n const slug = options.slug ?? API_KEYS_DEFAULT_SLUG\r\n const toolOptions = options.availableTools.map((t) => ({ label: t, value: t }))\r\n const availableGlobals = Array.isArray(options.availableGlobals)\r\n ? options.availableGlobals\r\n : []\r\n\r\n const presetField: Field = {\r\n name: 'preset',\r\n type: 'select',\r\n required: true,\r\n defaultValue: 'custom',\r\n options: PRESET_OPTIONS as unknown as { label: string; value: string }[],\r\n admin: {\r\n description:\r\n 'Role preset. \"Custom\" unlocks the per-collection matrix and the tool overrides below. ' +\r\n 'Switching away from Custom CLEARS every override on save (collectionScopes, globalScopes, ' +\r\n 'toolAllow, toolDeny); switching back to Custom starts from a fresh deny-all baseline, so ' +\r\n 'reconfigure the matrices and tool lists before saving.',\r\n },\r\n }\r\n\r\n // Stored shape: Array<{ slug: string; actions: ('read'|'create'|'update'|'delete')[] }>\r\n // The default Payload UI for an `array` would force users to add rows\r\n // one at a time; the custom matrix component renders all available\r\n // collections at once with a checkbox grid (rows × actions).\r\n //\r\n // `availableCollections` is forwarded via `clientProps` — Payload v3's\r\n // sanctioned escape hatch for serializable static data that the client\r\n // component needs at render time.\r\n const collectionScopesField: Field = {\r\n name: 'collectionScopes',\r\n type: 'json',\r\n admin: {\r\n condition: isCustomPreset,\r\n components: {\r\n Field: {\r\n path: 'payload-mcp-toolkit/client',\r\n exportName: 'CollectionScopesMatrix',\r\n clientProps: {\r\n availableCollections: options.availableCollections,\r\n },\r\n },\r\n },\r\n },\r\n }\r\n\r\n // Mirrors `collectionScopes` exactly — one additive JSONB column with a\r\n // default of `'[]'`, default-rendered by `GlobalScopesMatrix`. Hidden\r\n // under non-custom presets. Stored shape:\r\n // Array<{ slug: string; actions: ('read'|'update')[] }>\r\n // No `availableGlobals.length > 0` gate: `ScopesTable` renders its own\r\n // empty-state message when zero items are passed, so the field surfaces\r\n // under Custom regardless of host config, matching the collection variant.\r\n const globalScopesField: Field = {\r\n name: 'globalScopes',\r\n type: 'json',\r\n admin: {\r\n condition: isCustomPreset,\r\n components: {\r\n Field: {\r\n path: 'payload-mcp-toolkit/client',\r\n exportName: 'GlobalScopesMatrix',\r\n clientProps: {\r\n availableGlobals,\r\n },\r\n },\r\n },\r\n },\r\n }\r\n\r\n const toolsCollapsible: Field = {\r\n type: 'collapsible',\r\n label: 'Tool overrides',\r\n admin: {\r\n condition: isCustomPreset,\r\n description:\r\n 'Per-tool whitelist / blacklist. Layered on top of preset and collection scopes.',\r\n initCollapsed: true,\r\n },\r\n fields: [\r\n {\r\n name: 'toolAllow',\r\n type: 'select',\r\n hasMany: true,\r\n options: toolOptions,\r\n admin: {\r\n description:\r\n 'If set, only these tools are callable with this key. Leave empty to allow any tool ' +\r\n 'the collection or global scopes permit. Under the Custom preset, an empty list is ' +\r\n 'treated as deny-all ONLY when no collection or global scopes are set (the fresh- ' +\r\n 'Custom-key sentinel); when collection or global scopes are populated, an empty list ' +\r\n 'collapses to \"no tool restriction\" so the resource scopes alone determine what is ' +\r\n 'callable — to deny every tool while keeping resource scopes, enumerate them in ' +\r\n 'toolDeny instead. Preset-mode keys created via the REST API with an empty list are ' +\r\n 'coerced to \"no restriction\".',\r\n },\r\n },\r\n {\r\n name: 'toolDeny',\r\n type: 'select',\r\n hasMany: true,\r\n options: toolOptions,\r\n admin: {\r\n description: 'These tools are blocked regardless of any other scope.',\r\n },\r\n },\r\n ],\r\n }\r\n\r\n return {\r\n slug,\r\n admin: {\r\n group: 'MCP',\r\n useAsTitle: 'name',\r\n description:\r\n 'API keys for MCP clients. Scopes control which collections and tools each key can access.',\r\n defaultColumns: ['name', 'user', 'keyPrefix', 'preset', 'lastUsedAt', 'expiresAt', 'revokedAt'],\r\n },\r\n auth: {\r\n disableLocalStrategy: true,\r\n useAPIKey: true,\r\n },\r\n hooks: {\r\n beforeValidate: [\r\n (({ data, originalDoc }) => {\r\n // The override fields (collectionScopes, globalScopes, toolAllow,\r\n // toolDeny) are conditionally rendered only under the Custom preset\r\n // (`condition: isCustomPreset`). Under any other preset they are\r\n // hidden in the admin UI, which means two things:\r\n //\r\n // 1. The admin form omits hidden fields from its payload on save,\r\n // so `data` only carries the visible fields — we can't\r\n // \"collapse the empty array we see in `data`\" because we never\r\n // see it at all. The stale value lives on `originalDoc`.\r\n // 2. A Custom→Admin switch silently keeps the prior\r\n // `toolAllow:[...]` / `collectionScopes:[...]`, and\r\n // `composeScopes` then emits a scope gate that rejects calls\r\n // the user clearly intended to allow.\r\n //\r\n // Fix: when the preset is non-Custom, explicitly write `null` into\r\n // `data` for every override axis (regardless of what `data` carries\r\n // or what originalDoc holds). Payload persists nulls, so the stale\r\n // values are erased on every save. The Custom-preset branch below\r\n // keeps the explicit-empty-means-deny semantic intact.\r\n if (!data) return data\r\n const d = data as Record<string, unknown>\r\n const orig = (originalDoc ?? {}) as Record<string, unknown>\r\n const preset = d.preset ?? orig.preset\r\n\r\n // `readField` falls through to originalDoc when `data` omits the\r\n // key entirely (admin form skipping hidden fields), but honours\r\n // an explicit null/empty in `data` over originalDoc.\r\n const readField = (key: string): unknown =>\r\n key in d ? d[key] : orig[key]\r\n const isNonEmptyArray = (v: unknown): boolean =>\r\n Array.isArray(v) && v.length > 0\r\n const isEmptyArray = (v: unknown): boolean =>\r\n Array.isArray(v) && v.length === 0\r\n\r\n const OVERRIDE_AXES = [\r\n 'collectionScopes',\r\n 'globalScopes',\r\n 'toolAllow',\r\n 'toolDeny',\r\n ] as const\r\n\r\n if (preset !== 'custom') {\r\n for (const axis of OVERRIDE_AXES) d[axis] = null\r\n return data\r\n }\r\n\r\n // Custom preset: the Tools collapsible is labelled as an *override*\r\n // layered on top of collection / global scopes, and its description\r\n // says \"Leave empty to allow any tool the collection scopes permit.\"\r\n // Payload's hasMany-select default of `[]` would otherwise turn the\r\n // Tools section into a mandatory whitelist — a user who configures\r\n // collection scopes and never opens the collapsible would silently\r\n // store `toolAllow:[]`, which `composeScopes` honours as deny-all on\r\n // the tools axis and rejects every call.\r\n //\r\n // Resolve the mismatch by coercing an empty `toolAllow` to null\r\n // whenever the key carries any concrete resource scope (collection\r\n // or global entries). The fresh-Custom-key sentinel in\r\n // `composeScopes` still covers the \"no scopes at all\" case\r\n // (everything null → deny-all), so users who genuinely want\r\n // deny-all do not regress.\r\n const hasResourceScope =\r\n isNonEmptyArray(readField('collectionScopes')) ||\r\n isNonEmptyArray(readField('globalScopes'))\r\n if (hasResourceScope && isEmptyArray(readField('toolAllow'))) {\r\n d.toolAllow = null\r\n }\r\n return data\r\n }) as CollectionBeforeValidateHook,\r\n ],\r\n },\r\n labels: {\r\n plural: 'API Keys',\r\n singular: 'API Key',\r\n },\r\n fields: [\r\n // Main column.\r\n {\r\n name: 'name',\r\n type: 'text',\r\n required: true,\r\n admin: { description: 'Human label for this key (e.g. \"Editorial team — Claude Desktop\").' },\r\n },\r\n {\r\n name: 'description',\r\n type: 'textarea',\r\n admin: { description: 'Optional notes about the purpose of this key.' },\r\n },\r\n presetField,\r\n collectionScopesField,\r\n globalScopesField,\r\n toolsCollapsible,\r\n\r\n // Sidebar — identity + lifecycle.\r\n {\r\n name: 'user',\r\n type: 'relationship',\r\n relationTo: options.userCollection,\r\n required: true,\r\n admin: {\r\n position: 'sidebar',\r\n description:\r\n 'The user this key authenticates as. Tool calls use this user for access checks on target collections.',\r\n },\r\n },\r\n {\r\n name: 'keyPrefix',\r\n type: 'text',\r\n index: true,\r\n admin: {\r\n position: 'sidebar',\r\n readOnly: true,\r\n description:\r\n 'First 8 characters of the API key — used in audit logs to identify the key without exposing the full secret.',\r\n },\r\n hooks: {\r\n beforeChange: [\r\n ({ data, originalDoc, value }) => {\r\n if (typeof value === 'string' && value.length > 0) return value\r\n const incomingKey = (data as { apiKey?: unknown } | undefined)?.apiKey\r\n if (typeof incomingKey === 'string' && incomingKey.length >= 8) {\r\n return incomingKey.slice(0, 8)\r\n }\r\n const existing = (originalDoc as { keyPrefix?: unknown } | undefined)?.keyPrefix\r\n return typeof existing === 'string' ? existing : undefined\r\n },\r\n ],\r\n },\r\n },\r\n {\r\n name: 'expiresAt',\r\n type: 'date',\r\n admin: {\r\n position: 'sidebar',\r\n description: 'Optional expiry. Requests authenticated with an expired key are rejected.',\r\n },\r\n },\r\n {\r\n name: 'revokedAt',\r\n type: 'date',\r\n admin: {\r\n position: 'sidebar',\r\n description: 'Set to revoke a key. Revoked keys are rejected at auth time.',\r\n },\r\n },\r\n {\r\n name: 'lastUsedAt',\r\n type: 'date',\r\n admin: {\r\n position: 'sidebar',\r\n readOnly: true,\r\n description:\r\n 'Updated on each successful authentication. Fire-and-forget; not on the request hot path.',\r\n },\r\n },\r\n ],\r\n }\r\n}\r\n"],"names":["API_KEYS_DEFAULT_SLUG","PRESET_OPTIONS","label","value","isCustomPreset","data","preset","createApiKeysCollection","options","userCollection","Error","Array","isArray","availableCollections","availableTools","slug","toolOptions","map","t","availableGlobals","presetField","name","type","required","defaultValue","admin","description","collectionScopesField","condition","components","Field","path","exportName","clientProps","globalScopesField","toolsCollapsible","initCollapsed","fields","hasMany","group","useAsTitle","defaultColumns","auth","disableLocalStrategy","useAPIKey","hooks","beforeValidate","originalDoc","d","orig","readField","key","isNonEmptyArray","v","length","isEmptyArray","OVERRIDE_AXES","axis","hasResourceScope","toolAllow","labels","plural","singular","relationTo","position","index","readOnly","beforeChange","incomingKey","apiKey","slice","existing","keyPrefix","undefined"],"mappings":"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"}
|
package/dist/auth-strategy.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ interface ApiKeyRow {
|
|
|
36
36
|
apiKey?: string | null;
|
|
37
37
|
keyPrefix?: string | null;
|
|
38
38
|
}
|
|
39
|
+
/** @internal test-only: reset the one-time legacy warns. */
|
|
40
|
+
export declare function _resetLegacyWarnsForTests(): void;
|
|
39
41
|
/**
|
|
40
42
|
* Builds the runtime `KeyScopes` shape consumed by `registry.assertScopeAllows`
|
|
41
43
|
* from the typed scope fields on the api-key row.
|
|
@@ -54,13 +56,22 @@ interface ApiKeyRow {
|
|
|
54
56
|
* full access). The sentinel emits `{collections:{}, globals:{},
|
|
55
57
|
* tools:{allow:[]}}`.
|
|
56
58
|
*
|
|
57
|
-
* 2. **Per-axis explicit-empty.**
|
|
58
|
-
* axis (even `[]`)
|
|
59
|
-
* `toolAllow:[]` means "deny all tools", not "no opinion".
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* 2. **Per-axis explicit-empty, Custom-only.** Under the Custom preset, an
|
|
60
|
+
* empty array on any axis (even `[]`) is honoured as written — an empty
|
|
61
|
+
* `toolAllow:[]` means "deny all tools on this axis", not "no opinion".
|
|
62
|
+
* Under non-Custom presets, empty arrays are IGNORED because Payload's
|
|
63
|
+
* hasMany / unpopulated-JSON reads return `[]` for fields the user
|
|
64
|
+
* never touched (the override matrices are hidden in the admin UI under
|
|
65
|
+
* non-Custom presets via `condition: isCustomPreset`). The on-write
|
|
66
|
+
* counterpart of this rule lives in `createApiKeysCollection`'s
|
|
67
|
+
* `beforeValidate` hook, which proactively nulls the override axes on
|
|
68
|
+
* save when the preset is non-Custom — both layers must stay in sync.
|
|
69
|
+
* Non-empty arrays still apply as layered narrowing under any preset.
|
|
70
|
+
* `toolDeny` is a deny-list, so an empty array carries no entries — it
|
|
71
|
+
* is dropped rather than emitted. NOTE: legacy non-Custom rows persisted
|
|
72
|
+
* BEFORE v0.7.1 with populated stale override arrays continue to narrow
|
|
73
|
+
* on read until each row is manually re-saved; the on-write fix only
|
|
74
|
+
* applies to fresh writes.
|
|
64
75
|
*/
|
|
65
76
|
export declare function composeScopes(row: ApiKeyRow, logger?: {
|
|
66
77
|
warn?: (...args: unknown[]) => void;
|
package/dist/auth-strategy.js
CHANGED
|
@@ -6,6 +6,11 @@ export const AUTH_STRATEGY_NAME = 'mcp-toolkit-bearer';
|
|
|
6
6
|
* fires so operators can spot keys that need re-saving. The fallback is
|
|
7
7
|
* scheduled for removal in v0.7.
|
|
8
8
|
*/ let warnedLegacyShape = false;
|
|
9
|
+
let warnedLegacyNonCustomOverride = false;
|
|
10
|
+
/** @internal test-only: reset the one-time legacy warns. */ export function _resetLegacyWarnsForTests() {
|
|
11
|
+
warnedLegacyShape = false;
|
|
12
|
+
warnedLegacyNonCustomOverride = false;
|
|
13
|
+
}
|
|
9
14
|
function readScopeSlug(entry, legacyKey, logger) {
|
|
10
15
|
if (typeof entry?.slug === 'string') return entry.slug;
|
|
11
16
|
const legacy = entry?.[legacyKey];
|
|
@@ -49,13 +54,22 @@ const VALID_GLOBAL_ACTIONS = new Set([
|
|
|
49
54
|
* full access). The sentinel emits `{collections:{}, globals:{},
|
|
50
55
|
* tools:{allow:[]}}`.
|
|
51
56
|
*
|
|
52
|
-
* 2. **Per-axis explicit-empty.**
|
|
53
|
-
* axis (even `[]`)
|
|
54
|
-
* `toolAllow:[]` means "deny all tools", not "no opinion".
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* 2. **Per-axis explicit-empty, Custom-only.** Under the Custom preset, an
|
|
58
|
+
* empty array on any axis (even `[]`) is honoured as written — an empty
|
|
59
|
+
* `toolAllow:[]` means "deny all tools on this axis", not "no opinion".
|
|
60
|
+
* Under non-Custom presets, empty arrays are IGNORED because Payload's
|
|
61
|
+
* hasMany / unpopulated-JSON reads return `[]` for fields the user
|
|
62
|
+
* never touched (the override matrices are hidden in the admin UI under
|
|
63
|
+
* non-Custom presets via `condition: isCustomPreset`). The on-write
|
|
64
|
+
* counterpart of this rule lives in `createApiKeysCollection`'s
|
|
65
|
+
* `beforeValidate` hook, which proactively nulls the override axes on
|
|
66
|
+
* save when the preset is non-Custom — both layers must stay in sync.
|
|
67
|
+
* Non-empty arrays still apply as layered narrowing under any preset.
|
|
68
|
+
* `toolDeny` is a deny-list, so an empty array carries no entries — it
|
|
69
|
+
* is dropped rather than emitted. NOTE: legacy non-Custom rows persisted
|
|
70
|
+
* BEFORE v0.7.1 with populated stale override arrays continue to narrow
|
|
71
|
+
* on read until each row is manually re-saved; the on-write fix only
|
|
72
|
+
* applies to fresh writes.
|
|
59
73
|
*/ export function composeScopes(row, logger) {
|
|
60
74
|
const presetRaw = row.preset;
|
|
61
75
|
const hasPreset = typeof presetRaw === 'string' && presetRaw.length > 0;
|
|
@@ -81,10 +95,36 @@ const VALID_GLOBAL_ACTIONS = new Set([
|
|
|
81
95
|
};
|
|
82
96
|
}
|
|
83
97
|
const out = {};
|
|
84
|
-
|
|
98
|
+
const isCustomPreset = presetRaw === 'custom';
|
|
99
|
+
if (hasPreset && !isCustomPreset) {
|
|
85
100
|
out.preset = presetRaw;
|
|
86
101
|
}
|
|
87
|
-
|
|
102
|
+
// Under non-Custom presets, the override fields are hidden in the admin
|
|
103
|
+
// UI (`condition: isCustomPreset`) and Payload's hasMany / relational
|
|
104
|
+
// reads return `[]` for unpopulated relations even when the user never
|
|
105
|
+
// touched them. Treating that `[]` as "deny-all on this axis" turns
|
|
106
|
+
// every preset key into a deny-all key once it round-trips through the
|
|
107
|
+
// DB. Apply the explicit-empty-means-deny semantic only when the user
|
|
108
|
+
// is on the Custom preset (where the override fields are visible and
|
|
109
|
+
// meaningful); under preset modes, ignore empty arrays so that the
|
|
110
|
+
// preset alone drives access. Non-empty arrays still apply as layered
|
|
111
|
+
// narrowing, matching the field description ("layered on top of preset
|
|
112
|
+
// and collection scopes").
|
|
113
|
+
const treatEmptyAsScope = isCustomPreset;
|
|
114
|
+
// Legacy-row warn: non-Custom preset rows persisted BEFORE v0.7.1 may
|
|
115
|
+
// carry populated override arrays from a prior Custom configuration
|
|
116
|
+
// (the on-write null-out hook landed in v0.7.1). These still apply as
|
|
117
|
+
// layered narrowing on read — fail-closed-safe (narrows, never widens)
|
|
118
|
+
// but may not match operator intent. Warn once per process so legacy
|
|
119
|
+
// rows can be audited and re-saved.
|
|
120
|
+
if (hasPreset && !isCustomPreset && !warnedLegacyNonCustomOverride && (hasCollectionScopesField && row.collectionScopes.length > 0 || hasGlobalScopesField && row.globalScopes.length > 0 || hasToolAllowField && row.toolAllow.length > 0)) {
|
|
121
|
+
warnedLegacyNonCustomOverride = true;
|
|
122
|
+
logger?.warn?.({
|
|
123
|
+
event: 'mcp.auth.legacy_non_custom_override'
|
|
124
|
+
}, `[payload-mcp-toolkit] composeScopes read an API key with a non-Custom preset (${presetRaw}) ` + `carrying populated override arrays. These still narrow access as written, but the ` + `v0.7.1 admin UI clears overrides on preset switch — re-save affected keys to align ` + `persisted state with current admin semantics.`);
|
|
125
|
+
}
|
|
126
|
+
const emitCollectionScopes = hasCollectionScopesField && (treatEmptyAsScope || row.collectionScopes.length > 0);
|
|
127
|
+
if (emitCollectionScopes) {
|
|
88
128
|
const collections = {};
|
|
89
129
|
for (const entry of row.collectionScopes){
|
|
90
130
|
const slug = readScopeSlug(entry, 'collection', logger);
|
|
@@ -95,7 +135,8 @@ const VALID_GLOBAL_ACTIONS = new Set([
|
|
|
95
135
|
}
|
|
96
136
|
out.collections = collections;
|
|
97
137
|
}
|
|
98
|
-
|
|
138
|
+
const emitGlobalScopes = hasGlobalScopesField && (treatEmptyAsScope || row.globalScopes.length > 0);
|
|
139
|
+
if (emitGlobalScopes) {
|
|
99
140
|
const globals = {};
|
|
100
141
|
for (const entry of row.globalScopes){
|
|
101
142
|
const slug = readScopeSlug(entry, 'global', logger);
|
|
@@ -108,9 +149,10 @@ const VALID_GLOBAL_ACTIONS = new Set([
|
|
|
108
149
|
}
|
|
109
150
|
const toolDeny = hasToolDenyField ? row.toolDeny.filter((t)=>typeof t === 'string') : [];
|
|
110
151
|
const emitDeny = toolDeny.length > 0;
|
|
111
|
-
|
|
152
|
+
const emitToolAllow = hasToolAllowField && (treatEmptyAsScope || row.toolAllow.length > 0);
|
|
153
|
+
if (emitToolAllow || emitDeny) {
|
|
112
154
|
out.tools = {};
|
|
113
|
-
if (
|
|
155
|
+
if (emitToolAllow) {
|
|
114
156
|
const toolAllow = row.toolAllow.filter((t)=>typeof t === 'string');
|
|
115
157
|
out.tools.allow = toolAllow;
|
|
116
158
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/auth-strategy.ts"],"sourcesContent":["import type { AuthStrategy, PayloadRequest, Where } from 'payload'\r\nimport { extractBearerToken, hashKey } from './hash'\r\nimport type { CollectionAction, GlobalAction, KeyScopes, ScopePreset } from './types'\r\n\r\nexport type { CollectionAction, GlobalAction, KeyScopes, ScopePreset } from './types'\r\n\r\nexport const AUTH_STRATEGY_NAME = 'mcp-toolkit-bearer'\r\n\r\nexport interface CreateBearerStrategyOptions {\r\n /** Slug of the API-keys collection (defaults to `payload-mcp-api-keys`). */\r\n collectionSlug: string\r\n /** Slug of the user collection that API keys link to. */\r\n userCollection: string\r\n}\r\n\r\n/**\r\n * Stored shape for a row in `collectionScopes` / `globalScopes` (v0.6+).\r\n * The parent field name encodes the axis — collection-vs-global is not\r\n * repeated in the row payload. Legacy `collection` / `global` keys from\r\n * pre-0.6 rows are tolerated by `composeScopes` for one release; v0.7\r\n * drops the fallback (see CHANGELOG).\r\n */\r\ninterface ScopeRow {\r\n slug?: unknown\r\n /** @deprecated pre-0.6 collectionScopes shape — read via fallback only. */\r\n collection?: unknown\r\n /** @deprecated pre-0.6 globalScopes shape — read via fallback only. */\r\n global?: unknown\r\n actions?: unknown\r\n}\r\n\r\ninterface ApiKeyRow {\r\n id: string | number\r\n user: unknown\r\n preset?: ScopePreset | 'custom' | null\r\n collectionScopes?: ScopeRow[] | null\r\n globalScopes?: ScopeRow[] | null\r\n toolAllow?: string[] | null\r\n toolDeny?: string[] | null\r\n expiresAt?: string | Date | null\r\n revokedAt?: string | Date | null\r\n apiKey?: string | null\r\n keyPrefix?: string | null\r\n}\r\n\r\n/**\r\n * Reads the row's slug, tolerating the pre-0.6 `collection` / `global`\r\n * keys for one release. Logs a one-line warn when the legacy fallback\r\n * fires so operators can spot keys that need re-saving. The fallback is\r\n * scheduled for removal in v0.7.\r\n */\r\nlet warnedLegacyShape = false\r\nfunction readScopeSlug(\r\n entry: ScopeRow,\r\n legacyKey: 'collection' | 'global',\r\n logger?: { warn?: (...args: unknown[]) => void } | undefined,\r\n): string | null {\r\n if (typeof entry?.slug === 'string') return entry.slug\r\n const legacy = entry?.[legacyKey]\r\n if (typeof legacy === 'string') {\r\n if (!warnedLegacyShape) {\r\n warnedLegacyShape = true\r\n logger?.warn?.(\r\n { event: 'mcp.auth.legacy_scope_shape', legacyKey },\r\n `[payload-mcp-toolkit] composeScopes read a pre-0.6 row using \\`${legacyKey}\\`; resave the API key to migrate to {slug, actions}. The fallback is removed in v0.7.`,\r\n )\r\n }\r\n return legacy\r\n }\r\n return null\r\n}\r\n\r\nconst VALID_ACTIONS: ReadonlySet<CollectionAction> = new Set(['read', 'create', 'update', 'delete'])\r\nconst VALID_GLOBAL_ACTIONS: ReadonlySet<GlobalAction> = new Set(['read', 'update'])\r\n\r\n/**\r\n * Builds the runtime `KeyScopes` shape consumed by `registry.assertScopeAllows`\r\n * from the typed scope fields on the api-key row.\r\n *\r\n * Returns null when no typed fields are populated AND no preset is set\r\n * (= full access — back-compat for pre-0.5 rows that pre-date scoped authz).\r\n *\r\n * Two complementary fail-closed rules:\r\n *\r\n * 1. **`'custom'` deny-all sentinel.** `'custom'` is a UI sentinel meaning\r\n * \"use my override fields\"; it never becomes `KeyScopes.preset` itself.\r\n * Payload persists unset JSON / select fields as `null`, so a fresh\r\n * Custom key with no overrides arrives as `{preset:'custom',\r\n * collectionScopes:null, globalScopes:null, toolAllow:null,\r\n * toolDeny:null}`. That row must deny everything (not fall through to\r\n * full access). The sentinel emits `{collections:{}, globals:{},\r\n * tools:{allow:[]}}`.\r\n *\r\n * 2. **Per-axis explicit-empty.** When a row carries an array value on any\r\n * axis (even `[]`), that axis is honoured as written — an empty\r\n * `toolAllow:[]` means \"deny all tools\", not \"no opinion\". This closes a\r\n * gap where a Custom key with `collectionScopes` populated AND\r\n * `toolAllow:[]` previously emitted no `tools.allow` gate (the empty\r\n * array was treated as absent). `toolDeny` is a deny-list, so an empty\r\n * array carries no entries — it is dropped rather than emitted.\r\n */\r\nexport function composeScopes(\r\n row: ApiKeyRow,\r\n logger?: { warn?: (...args: unknown[]) => void },\r\n): KeyScopes | null {\r\n const presetRaw = row.preset\r\n const hasPreset = typeof presetRaw === 'string' && presetRaw.length > 0\r\n const hasCollectionScopesField = Array.isArray(row.collectionScopes)\r\n const hasGlobalScopesField = Array.isArray(row.globalScopes)\r\n const hasToolAllowField = Array.isArray(row.toolAllow)\r\n const hasToolDenyField = Array.isArray(row.toolDeny)\r\n\r\n // Pre-0.5 back-compat: row has no preset and no typed scope fields at\r\n // all (all null/undefined). Treat as full access.\r\n if (\r\n !hasPreset &&\r\n !hasCollectionScopesField &&\r\n !hasGlobalScopesField &&\r\n !hasToolAllowField &&\r\n !hasToolDenyField\r\n ) {\r\n return null\r\n }\r\n\r\n // Sentinel: Custom preset with every axis null/undefined (no array\r\n // committed) denies everything. Payload-persisted unset JSON / select\r\n // fields arrive as null; this is the fresh-Custom-key case.\r\n if (\r\n presetRaw === 'custom' &&\r\n !hasCollectionScopesField &&\r\n !hasGlobalScopesField &&\r\n !hasToolAllowField &&\r\n !hasToolDenyField\r\n ) {\r\n return { collections: {}, globals: {}, tools: { allow: [] } }\r\n }\r\n\r\n const out: KeyScopes = {}\r\n if (hasPreset && presetRaw !== 'custom') {\r\n out.preset = presetRaw as ScopePreset\r\n }\r\n\r\n if (hasCollectionScopesField) {\r\n const collections: Record<string, CollectionAction[]> = {}\r\n for (const entry of row.collectionScopes as ScopeRow[]) {\r\n const slug = readScopeSlug(entry, 'collection', logger)\r\n if (!slug) continue\r\n const rawActions = Array.isArray(entry?.actions) ? entry.actions : []\r\n const actions = rawActions.filter(\r\n (a): a is CollectionAction => typeof a === 'string' && VALID_ACTIONS.has(a as CollectionAction),\r\n )\r\n collections[slug] = actions\r\n }\r\n out.collections = collections\r\n }\r\n\r\n if (hasGlobalScopesField) {\r\n const globals: Record<string, GlobalAction[]> = {}\r\n for (const entry of row.globalScopes as ScopeRow[]) {\r\n const slug = readScopeSlug(entry, 'global', logger)\r\n if (!slug) continue\r\n const rawActions = Array.isArray(entry?.actions) ? entry.actions : []\r\n const actions = rawActions.filter(\r\n (a): a is GlobalAction => typeof a === 'string' && VALID_GLOBAL_ACTIONS.has(a as GlobalAction),\r\n )\r\n globals[slug] = actions\r\n }\r\n out.globals = globals\r\n }\r\n\r\n const toolDeny = hasToolDenyField\r\n ? (row.toolDeny as unknown[]).filter((t): t is string => typeof t === 'string')\r\n : []\r\n const emitDeny = toolDeny.length > 0\r\n\r\n if (hasToolAllowField || emitDeny) {\r\n out.tools = {}\r\n if (hasToolAllowField) {\r\n const toolAllow = (row.toolAllow as unknown[]).filter((t): t is string => typeof t === 'string')\r\n out.tools.allow = toolAllow\r\n }\r\n if (emitDeny) out.tools.deny = toolDeny\r\n }\r\n\r\n return out\r\n}\r\n\r\n/**\r\n * Builds the Payload `auth.strategies` entry that authenticates MCP requests.\r\n *\r\n * Authenticates `Authorization: Bearer <plaintext>` by computing the\r\n * upstream-compatible `apiKeyIndex` HMAC and looking up the row. On match,\r\n * it fires a non-blocking `lastUsedAt` write and hydrates `req.user` with\r\n * the linked user record + key context for downstream scope checks.\r\n */\r\nexport function createBearerStrategy(options: CreateBearerStrategyOptions): AuthStrategy {\r\n const { collectionSlug, userCollection } = options\r\n\r\n return {\r\n name: AUTH_STRATEGY_NAME,\r\n authenticate: async ({ headers, payload }) => {\r\n const headersAny = headers as unknown as\r\n | { get?: (name: string) => string | null }\r\n | Record<string, string | undefined>\r\n const headerValue =\r\n typeof (headersAny as { get?: (name: string) => string | null }).get === 'function'\r\n ? (headersAny as { get: (name: string) => string | null }).get('authorization')\r\n : (headersAny as Record<string, string | undefined>).authorization\r\n const token = extractBearerToken(headerValue ?? null)\r\n if (!token) return { user: null }\r\n\r\n const keyHash = hashKey(token, payload.secret)\r\n const where: Where = { apiKeyIndex: { equals: keyHash } }\r\n\r\n let docs: ApiKeyRow[] = []\r\n try {\r\n const result = (await payload.find({\r\n collection: collectionSlug,\r\n where,\r\n depth: 1,\r\n limit: 1,\r\n pagination: false,\r\n overrideAccess: true,\r\n })) as unknown as { docs: ApiKeyRow[] }\r\n docs = result.docs ?? []\r\n } catch (err) {\r\n payload.logger.error(\r\n { err, event: 'mcp.auth.lookup_failed' },\r\n '[payload-mcp-toolkit] API-key lookup failed',\r\n )\r\n return { user: null }\r\n }\r\n\r\n const row = docs[0]\r\n if (!row) return { user: null }\r\n\r\n const now = Date.now()\r\n if (row.revokedAt) return { user: null }\r\n if (row.expiresAt) {\r\n const expiry = new Date(row.expiresAt).getTime()\r\n if (Number.isFinite(expiry) && expiry < now) return { user: null }\r\n }\r\n\r\n const linkedUser = row.user\r\n if (!linkedUser || typeof linkedUser !== 'object') return { user: null }\r\n\r\n const effectiveScopes: KeyScopes | null = composeScopes(row, payload.logger)\r\n\r\n // Fire-and-forget: do not block the request on this write.\r\n void payload\r\n .update({\r\n collection: collectionSlug,\r\n id: row.id,\r\n data: { lastUsedAt: new Date().toISOString() } as Record<string, unknown>,\r\n overrideAccess: true,\r\n })\r\n .catch(() => {\r\n // Intentionally swallow: lastUsedAt drift is acceptable.\r\n })\r\n\r\n const user = linkedUser as Record<string, unknown>\r\n return {\r\n user: {\r\n ...user,\r\n collection: userCollection,\r\n _strategy: AUTH_STRATEGY_NAME,\r\n _mcpKey: {\r\n keyId: row.id,\r\n keyPrefix: typeof row.keyPrefix === 'string' ? row.keyPrefix : null,\r\n scopes: effectiveScopes,\r\n },\r\n } as unknown as PayloadRequest['user'],\r\n }\r\n },\r\n }\r\n}\r\n\r\n/**\r\n * Reads the per-request API-key context populated by the bearer strategy.\r\n * Returns null for non-MCP requests (e.g. cookie-authenticated admin users).\r\n */\r\nexport function getApiKeyContext(req: PayloadRequest): {\r\n keyId: string | number\r\n keyPrefix: string | null\r\n scopes: KeyScopes | null\r\n} | null {\r\n const user = req.user as\r\n | (Record<string, unknown> & {\r\n _mcpKey?: { keyId: string | number; keyPrefix: string | null; scopes: KeyScopes | null }\r\n })\r\n | null\r\n | undefined\r\n return user?._mcpKey ?? null\r\n}\r\n"],"names":["extractBearerToken","hashKey","AUTH_STRATEGY_NAME","warnedLegacyShape","readScopeSlug","entry","legacyKey","logger","slug","legacy","warn","event","VALID_ACTIONS","Set","VALID_GLOBAL_ACTIONS","composeScopes","row","presetRaw","preset","hasPreset","length","hasCollectionScopesField","Array","isArray","collectionScopes","hasGlobalScopesField","globalScopes","hasToolAllowField","toolAllow","hasToolDenyField","toolDeny","collections","globals","tools","allow","out","rawActions","actions","filter","a","has","t","emitDeny","deny","createBearerStrategy","options","collectionSlug","userCollection","name","authenticate","headers","payload","headersAny","headerValue","get","authorization","token","user","keyHash","secret","where","apiKeyIndex","equals","docs","result","find","collection","depth","limit","pagination","overrideAccess","err","error","now","Date","revokedAt","expiresAt","expiry","getTime","Number","isFinite","linkedUser","effectiveScopes","update","id","data","lastUsedAt","toISOString","catch","_strategy","_mcpKey","keyId","keyPrefix","scopes","getApiKeyContext","req"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../src/auth-strategy.ts"],"sourcesContent":["import type { AuthStrategy, PayloadRequest, Where } from 'payload'\r\nimport { extractBearerToken, hashKey } from './hash'\r\nimport type { CollectionAction, GlobalAction, KeyScopes, ScopePreset } from './types'\r\n\r\nexport type { CollectionAction, GlobalAction, KeyScopes, ScopePreset } from './types'\r\n\r\nexport const AUTH_STRATEGY_NAME = 'mcp-toolkit-bearer'\r\n\r\nexport interface CreateBearerStrategyOptions {\r\n /** Slug of the API-keys collection (defaults to `payload-mcp-api-keys`). */\r\n collectionSlug: string\r\n /** Slug of the user collection that API keys link to. */\r\n userCollection: string\r\n}\r\n\r\n/**\r\n * Stored shape for a row in `collectionScopes` / `globalScopes` (v0.6+).\r\n * The parent field name encodes the axis — collection-vs-global is not\r\n * repeated in the row payload. Legacy `collection` / `global` keys from\r\n * pre-0.6 rows are tolerated by `composeScopes` for one release; v0.7\r\n * drops the fallback (see CHANGELOG).\r\n */\r\ninterface ScopeRow {\r\n slug?: unknown\r\n /** @deprecated pre-0.6 collectionScopes shape — read via fallback only. */\r\n collection?: unknown\r\n /** @deprecated pre-0.6 globalScopes shape — read via fallback only. */\r\n global?: unknown\r\n actions?: unknown\r\n}\r\n\r\ninterface ApiKeyRow {\r\n id: string | number\r\n user: unknown\r\n preset?: ScopePreset | 'custom' | null\r\n collectionScopes?: ScopeRow[] | null\r\n globalScopes?: ScopeRow[] | null\r\n toolAllow?: string[] | null\r\n toolDeny?: string[] | null\r\n expiresAt?: string | Date | null\r\n revokedAt?: string | Date | null\r\n apiKey?: string | null\r\n keyPrefix?: string | null\r\n}\r\n\r\n/**\r\n * Reads the row's slug, tolerating the pre-0.6 `collection` / `global`\r\n * keys for one release. Logs a one-line warn when the legacy fallback\r\n * fires so operators can spot keys that need re-saving. The fallback is\r\n * scheduled for removal in v0.7.\r\n */\r\nlet warnedLegacyShape = false\r\nlet warnedLegacyNonCustomOverride = false\r\n\r\n/** @internal test-only: reset the one-time legacy warns. */\r\nexport function _resetLegacyWarnsForTests(): void {\r\n warnedLegacyShape = false\r\n warnedLegacyNonCustomOverride = false\r\n}\r\n\r\nfunction readScopeSlug(\r\n entry: ScopeRow,\r\n legacyKey: 'collection' | 'global',\r\n logger?: { warn?: (...args: unknown[]) => void } | undefined,\r\n): string | null {\r\n if (typeof entry?.slug === 'string') return entry.slug\r\n const legacy = entry?.[legacyKey]\r\n if (typeof legacy === 'string') {\r\n if (!warnedLegacyShape) {\r\n warnedLegacyShape = true\r\n logger?.warn?.(\r\n { event: 'mcp.auth.legacy_scope_shape', legacyKey },\r\n `[payload-mcp-toolkit] composeScopes read a pre-0.6 row using \\`${legacyKey}\\`; resave the API key to migrate to {slug, actions}. The fallback is removed in v0.7.`,\r\n )\r\n }\r\n return legacy\r\n }\r\n return null\r\n}\r\n\r\nconst VALID_ACTIONS: ReadonlySet<CollectionAction> = new Set(['read', 'create', 'update', 'delete'])\r\nconst VALID_GLOBAL_ACTIONS: ReadonlySet<GlobalAction> = new Set(['read', 'update'])\r\n\r\n/**\r\n * Builds the runtime `KeyScopes` shape consumed by `registry.assertScopeAllows`\r\n * from the typed scope fields on the api-key row.\r\n *\r\n * Returns null when no typed fields are populated AND no preset is set\r\n * (= full access — back-compat for pre-0.5 rows that pre-date scoped authz).\r\n *\r\n * Two complementary fail-closed rules:\r\n *\r\n * 1. **`'custom'` deny-all sentinel.** `'custom'` is a UI sentinel meaning\r\n * \"use my override fields\"; it never becomes `KeyScopes.preset` itself.\r\n * Payload persists unset JSON / select fields as `null`, so a fresh\r\n * Custom key with no overrides arrives as `{preset:'custom',\r\n * collectionScopes:null, globalScopes:null, toolAllow:null,\r\n * toolDeny:null}`. That row must deny everything (not fall through to\r\n * full access). The sentinel emits `{collections:{}, globals:{},\r\n * tools:{allow:[]}}`.\r\n *\r\n * 2. **Per-axis explicit-empty, Custom-only.** Under the Custom preset, an\r\n * empty array on any axis (even `[]`) is honoured as written — an empty\r\n * `toolAllow:[]` means \"deny all tools on this axis\", not \"no opinion\".\r\n * Under non-Custom presets, empty arrays are IGNORED because Payload's\r\n * hasMany / unpopulated-JSON reads return `[]` for fields the user\r\n * never touched (the override matrices are hidden in the admin UI under\r\n * non-Custom presets via `condition: isCustomPreset`). The on-write\r\n * counterpart of this rule lives in `createApiKeysCollection`'s\r\n * `beforeValidate` hook, which proactively nulls the override axes on\r\n * save when the preset is non-Custom — both layers must stay in sync.\r\n * Non-empty arrays still apply as layered narrowing under any preset.\r\n * `toolDeny` is a deny-list, so an empty array carries no entries — it\r\n * is dropped rather than emitted. NOTE: legacy non-Custom rows persisted\r\n * BEFORE v0.7.1 with populated stale override arrays continue to narrow\r\n * on read until each row is manually re-saved; the on-write fix only\r\n * applies to fresh writes.\r\n */\r\nexport function composeScopes(\r\n row: ApiKeyRow,\r\n logger?: { warn?: (...args: unknown[]) => void },\r\n): KeyScopes | null {\r\n const presetRaw = row.preset\r\n const hasPreset = typeof presetRaw === 'string' && presetRaw.length > 0\r\n const hasCollectionScopesField = Array.isArray(row.collectionScopes)\r\n const hasGlobalScopesField = Array.isArray(row.globalScopes)\r\n const hasToolAllowField = Array.isArray(row.toolAllow)\r\n const hasToolDenyField = Array.isArray(row.toolDeny)\r\n\r\n // Pre-0.5 back-compat: row has no preset and no typed scope fields at\r\n // all (all null/undefined). Treat as full access.\r\n if (\r\n !hasPreset &&\r\n !hasCollectionScopesField &&\r\n !hasGlobalScopesField &&\r\n !hasToolAllowField &&\r\n !hasToolDenyField\r\n ) {\r\n return null\r\n }\r\n\r\n // Sentinel: Custom preset with every axis null/undefined (no array\r\n // committed) denies everything. Payload-persisted unset JSON / select\r\n // fields arrive as null; this is the fresh-Custom-key case.\r\n if (\r\n presetRaw === 'custom' &&\r\n !hasCollectionScopesField &&\r\n !hasGlobalScopesField &&\r\n !hasToolAllowField &&\r\n !hasToolDenyField\r\n ) {\r\n return { collections: {}, globals: {}, tools: { allow: [] } }\r\n }\r\n\r\n const out: KeyScopes = {}\r\n const isCustomPreset = presetRaw === 'custom'\r\n if (hasPreset && !isCustomPreset) {\r\n out.preset = presetRaw as ScopePreset\r\n }\r\n\r\n // Under non-Custom presets, the override fields are hidden in the admin\r\n // UI (`condition: isCustomPreset`) and Payload's hasMany / relational\r\n // reads return `[]` for unpopulated relations even when the user never\r\n // touched them. Treating that `[]` as \"deny-all on this axis\" turns\r\n // every preset key into a deny-all key once it round-trips through the\r\n // DB. Apply the explicit-empty-means-deny semantic only when the user\r\n // is on the Custom preset (where the override fields are visible and\r\n // meaningful); under preset modes, ignore empty arrays so that the\r\n // preset alone drives access. Non-empty arrays still apply as layered\r\n // narrowing, matching the field description (\"layered on top of preset\r\n // and collection scopes\").\r\n const treatEmptyAsScope = isCustomPreset\r\n\r\n // Legacy-row warn: non-Custom preset rows persisted BEFORE v0.7.1 may\r\n // carry populated override arrays from a prior Custom configuration\r\n // (the on-write null-out hook landed in v0.7.1). These still apply as\r\n // layered narrowing on read — fail-closed-safe (narrows, never widens)\r\n // but may not match operator intent. Warn once per process so legacy\r\n // rows can be audited and re-saved.\r\n if (\r\n hasPreset &&\r\n !isCustomPreset &&\r\n !warnedLegacyNonCustomOverride &&\r\n ((hasCollectionScopesField && (row.collectionScopes as unknown[]).length > 0) ||\r\n (hasGlobalScopesField && (row.globalScopes as unknown[]).length > 0) ||\r\n (hasToolAllowField && (row.toolAllow as unknown[]).length > 0))\r\n ) {\r\n warnedLegacyNonCustomOverride = true\r\n logger?.warn?.(\r\n { event: 'mcp.auth.legacy_non_custom_override' },\r\n `[payload-mcp-toolkit] composeScopes read an API key with a non-Custom preset (${presetRaw}) ` +\r\n `carrying populated override arrays. These still narrow access as written, but the ` +\r\n `v0.7.1 admin UI clears overrides on preset switch — re-save affected keys to align ` +\r\n `persisted state with current admin semantics.`,\r\n )\r\n }\r\n\r\n const emitCollectionScopes =\r\n hasCollectionScopesField &&\r\n (treatEmptyAsScope || (row.collectionScopes as unknown[]).length > 0)\r\n if (emitCollectionScopes) {\r\n const collections: Record<string, CollectionAction[]> = {}\r\n for (const entry of row.collectionScopes as ScopeRow[]) {\r\n const slug = readScopeSlug(entry, 'collection', logger)\r\n if (!slug) continue\r\n const rawActions = Array.isArray(entry?.actions) ? entry.actions : []\r\n const actions = rawActions.filter(\r\n (a): a is CollectionAction => typeof a === 'string' && VALID_ACTIONS.has(a as CollectionAction),\r\n )\r\n collections[slug] = actions\r\n }\r\n out.collections = collections\r\n }\r\n\r\n const emitGlobalScopes =\r\n hasGlobalScopesField &&\r\n (treatEmptyAsScope || (row.globalScopes as unknown[]).length > 0)\r\n if (emitGlobalScopes) {\r\n const globals: Record<string, GlobalAction[]> = {}\r\n for (const entry of row.globalScopes as ScopeRow[]) {\r\n const slug = readScopeSlug(entry, 'global', logger)\r\n if (!slug) continue\r\n const rawActions = Array.isArray(entry?.actions) ? entry.actions : []\r\n const actions = rawActions.filter(\r\n (a): a is GlobalAction => typeof a === 'string' && VALID_GLOBAL_ACTIONS.has(a as GlobalAction),\r\n )\r\n globals[slug] = actions\r\n }\r\n out.globals = globals\r\n }\r\n\r\n const toolDeny = hasToolDenyField\r\n ? (row.toolDeny as unknown[]).filter((t): t is string => typeof t === 'string')\r\n : []\r\n const emitDeny = toolDeny.length > 0\r\n const emitToolAllow =\r\n hasToolAllowField &&\r\n (treatEmptyAsScope || (row.toolAllow as unknown[]).length > 0)\r\n\r\n if (emitToolAllow || emitDeny) {\r\n out.tools = {}\r\n if (emitToolAllow) {\r\n const toolAllow = (row.toolAllow as unknown[]).filter((t): t is string => typeof t === 'string')\r\n out.tools.allow = toolAllow\r\n }\r\n if (emitDeny) out.tools.deny = toolDeny\r\n }\r\n\r\n return out\r\n}\r\n\r\n/**\r\n * Builds the Payload `auth.strategies` entry that authenticates MCP requests.\r\n *\r\n * Authenticates `Authorization: Bearer <plaintext>` by computing the\r\n * upstream-compatible `apiKeyIndex` HMAC and looking up the row. On match,\r\n * it fires a non-blocking `lastUsedAt` write and hydrates `req.user` with\r\n * the linked user record + key context for downstream scope checks.\r\n */\r\nexport function createBearerStrategy(options: CreateBearerStrategyOptions): AuthStrategy {\r\n const { collectionSlug, userCollection } = options\r\n\r\n return {\r\n name: AUTH_STRATEGY_NAME,\r\n authenticate: async ({ headers, payload }) => {\r\n const headersAny = headers as unknown as\r\n | { get?: (name: string) => string | null }\r\n | Record<string, string | undefined>\r\n const headerValue =\r\n typeof (headersAny as { get?: (name: string) => string | null }).get === 'function'\r\n ? (headersAny as { get: (name: string) => string | null }).get('authorization')\r\n : (headersAny as Record<string, string | undefined>).authorization\r\n const token = extractBearerToken(headerValue ?? null)\r\n if (!token) return { user: null }\r\n\r\n const keyHash = hashKey(token, payload.secret)\r\n const where: Where = { apiKeyIndex: { equals: keyHash } }\r\n\r\n let docs: ApiKeyRow[] = []\r\n try {\r\n const result = (await payload.find({\r\n collection: collectionSlug,\r\n where,\r\n depth: 1,\r\n limit: 1,\r\n pagination: false,\r\n overrideAccess: true,\r\n })) as unknown as { docs: ApiKeyRow[] }\r\n docs = result.docs ?? []\r\n } catch (err) {\r\n payload.logger.error(\r\n { err, event: 'mcp.auth.lookup_failed' },\r\n '[payload-mcp-toolkit] API-key lookup failed',\r\n )\r\n return { user: null }\r\n }\r\n\r\n const row = docs[0]\r\n if (!row) return { user: null }\r\n\r\n const now = Date.now()\r\n if (row.revokedAt) return { user: null }\r\n if (row.expiresAt) {\r\n const expiry = new Date(row.expiresAt).getTime()\r\n if (Number.isFinite(expiry) && expiry < now) return { user: null }\r\n }\r\n\r\n const linkedUser = row.user\r\n if (!linkedUser || typeof linkedUser !== 'object') return { user: null }\r\n\r\n const effectiveScopes: KeyScopes | null = composeScopes(row, payload.logger)\r\n\r\n // Fire-and-forget: do not block the request on this write.\r\n void payload\r\n .update({\r\n collection: collectionSlug,\r\n id: row.id,\r\n data: { lastUsedAt: new Date().toISOString() } as Record<string, unknown>,\r\n overrideAccess: true,\r\n })\r\n .catch(() => {\r\n // Intentionally swallow: lastUsedAt drift is acceptable.\r\n })\r\n\r\n const user = linkedUser as Record<string, unknown>\r\n return {\r\n user: {\r\n ...user,\r\n collection: userCollection,\r\n _strategy: AUTH_STRATEGY_NAME,\r\n _mcpKey: {\r\n keyId: row.id,\r\n keyPrefix: typeof row.keyPrefix === 'string' ? row.keyPrefix : null,\r\n scopes: effectiveScopes,\r\n },\r\n } as unknown as PayloadRequest['user'],\r\n }\r\n },\r\n }\r\n}\r\n\r\n/**\r\n * Reads the per-request API-key context populated by the bearer strategy.\r\n * Returns null for non-MCP requests (e.g. cookie-authenticated admin users).\r\n */\r\nexport function getApiKeyContext(req: PayloadRequest): {\r\n keyId: string | number\r\n keyPrefix: string | null\r\n scopes: KeyScopes | null\r\n} | null {\r\n const user = req.user as\r\n | (Record<string, unknown> & {\r\n _mcpKey?: { keyId: string | number; keyPrefix: string | null; scopes: KeyScopes | null }\r\n })\r\n | null\r\n | undefined\r\n return user?._mcpKey ?? null\r\n}\r\n"],"names":["extractBearerToken","hashKey","AUTH_STRATEGY_NAME","warnedLegacyShape","warnedLegacyNonCustomOverride","_resetLegacyWarnsForTests","readScopeSlug","entry","legacyKey","logger","slug","legacy","warn","event","VALID_ACTIONS","Set","VALID_GLOBAL_ACTIONS","composeScopes","row","presetRaw","preset","hasPreset","length","hasCollectionScopesField","Array","isArray","collectionScopes","hasGlobalScopesField","globalScopes","hasToolAllowField","toolAllow","hasToolDenyField","toolDeny","collections","globals","tools","allow","out","isCustomPreset","treatEmptyAsScope","emitCollectionScopes","rawActions","actions","filter","a","has","emitGlobalScopes","t","emitDeny","emitToolAllow","deny","createBearerStrategy","options","collectionSlug","userCollection","name","authenticate","headers","payload","headersAny","headerValue","get","authorization","token","user","keyHash","secret","where","apiKeyIndex","equals","docs","result","find","collection","depth","limit","pagination","overrideAccess","err","error","now","Date","revokedAt","expiresAt","expiry","getTime","Number","isFinite","linkedUser","effectiveScopes","update","id","data","lastUsedAt","toISOString","catch","_strategy","_mcpKey","keyId","keyPrefix","scopes","getApiKeyContext","req"],"mappings":"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"}
|
package/dist/tools/_helpers.d.ts
CHANGED
|
@@ -20,6 +20,40 @@ export declare const DRAFT_NOTE = " Document is in draft status \u2014 use publi
|
|
|
20
20
|
export declare function textResponse(text: string): McpTextResponse;
|
|
21
21
|
export declare function jsonResponse(payload: unknown): McpTextResponse;
|
|
22
22
|
export declare function errorMessage(error: unknown): string;
|
|
23
|
+
export type PublishVerifyTarget = {
|
|
24
|
+
kind: 'collection';
|
|
25
|
+
slug: string;
|
|
26
|
+
id: string;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'global';
|
|
29
|
+
slug: string;
|
|
30
|
+
locale?: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Capture the document's current `updatedAt` BEFORE a publish attempt so
|
|
34
|
+
* the recovery branch can tell "this attempt landed despite a post-write
|
|
35
|
+
* validator throw" from "an older publish was successful and this attempt
|
|
36
|
+
* did nothing". A missing snapshot is non-fatal — the recovery branch
|
|
37
|
+
* conservatively falls through to the original error in that case.
|
|
38
|
+
*/
|
|
39
|
+
export declare function snapshotPublishMarker(req: PayloadRequest, target: PublishVerifyTarget): Promise<string | undefined>;
|
|
40
|
+
/**
|
|
41
|
+
* After a Payload update throws on a publish call, determine whether the
|
|
42
|
+
* publish actually landed despite the error. Returns the live document
|
|
43
|
+
* only when (a) the live `_status` is 'published' AND (b) `updatedAt`
|
|
44
|
+
* strictly advanced past the pre-update snapshot — i.e. the current
|
|
45
|
+
* attempt produced the published row. Without the strictly-newer check,
|
|
46
|
+
* a pre-existing published version from an earlier successful publish
|
|
47
|
+
* would mask a real failure of the current attempt.
|
|
48
|
+
*
|
|
49
|
+
* Returns null on:
|
|
50
|
+
* - missing pre-snapshot (cannot disambiguate; conservative)
|
|
51
|
+
* - verify read failure (do not mask the original error with a
|
|
52
|
+
* secondary read error)
|
|
53
|
+
* - live `_status` not 'published'
|
|
54
|
+
* - live `updatedAt` not strictly newer than the pre-snapshot
|
|
55
|
+
*/
|
|
56
|
+
export declare function verifyPublishSucceededDespiteError(req: PayloadRequest, target: PublishVerifyTarget, preUpdatedAt: string | undefined): Promise<Record<string, unknown> | null>;
|
|
23
57
|
export declare function stampMcpContext(req: PayloadRequest): void;
|
|
24
58
|
export declare function getDocDisplayName(doc: unknown, fallback: string): string;
|
|
25
59
|
export declare function requireDraftCollection(collection: string, draftCollections: Set<string>, noun?: string): McpTextResponse | null;
|