@xignet/payload 0.0.0-stage → 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +112 -2
- package/dist/login.d.ts +7 -0
- package/dist/login.js +312 -0
- package/dist/native-session.d.ts +26 -0
- package/dist/native-session.js +305 -0
- package/dist/next.d.ts +10 -0
- package/dist/next.js +14 -0
- package/dist/rpc-audit.d.ts +3 -0
- package/dist/rpc-audit.js +115 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +1 -0
- package/dist/v3.d.ts +5 -0
- package/dist/v3.js +513 -0
- package/package.json +51 -3
package/dist/v3.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createTokenVerifier, hasAllScopes, trustedUrl, } from '@xignet/core';
|
|
3
|
+
import { createBearerChallenge, createProtectedResourceMetadata, validateScopes, } from '@xignet/core/oauth';
|
|
4
|
+
import { mcpPlugin, } from '@payloadcms/plugin-mcp';
|
|
5
|
+
import { APIError, } from 'payload';
|
|
6
|
+
import { createNativeSessions, SESSION_COLLECTION } from './native-session.js';
|
|
7
|
+
import { createLogin } from './login.js';
|
|
8
|
+
import { inspectRpc, observeRpc, rpcMethod } from './rpc-audit.js';
|
|
9
|
+
class ResourceError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
requiredScopes;
|
|
12
|
+
invalidToken;
|
|
13
|
+
detail;
|
|
14
|
+
constructor(status, requiredScopes = [], invalidToken = false, detail) {
|
|
15
|
+
super('Xignet resource request refused');
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.requiredScopes = requiredScopes;
|
|
18
|
+
this.invalidToken = invalidToken;
|
|
19
|
+
this.detail = detail;
|
|
20
|
+
this.name = 'XignetResourceError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const operations = ['find', 'create', 'update'];
|
|
24
|
+
const camelCase = (slug) => slug
|
|
25
|
+
.replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : '')
|
|
26
|
+
.replace(/^(.)/, (c) => c.toLowerCase());
|
|
27
|
+
function snapshot(input = {}) {
|
|
28
|
+
const result = Object.create(null);
|
|
29
|
+
for (const [slug, policy] of Object.entries(input)) {
|
|
30
|
+
const saved = Object.freeze({
|
|
31
|
+
...Object.fromEntries(operations
|
|
32
|
+
.filter((op) => policy[op] !== undefined)
|
|
33
|
+
.map((op) => {
|
|
34
|
+
const scopes = [...policy[op].scopes];
|
|
35
|
+
validateScopes(scopes);
|
|
36
|
+
if (!scopes.length)
|
|
37
|
+
throw new Error('MCP operations require explicit scopes');
|
|
38
|
+
return [op, Object.freeze({ scopes: Object.freeze(scopes) })];
|
|
39
|
+
})),
|
|
40
|
+
mutationPolicy: policy.mutationPolicy,
|
|
41
|
+
});
|
|
42
|
+
if ((saved.create || saved.update) && !saved.mutationPolicy)
|
|
43
|
+
throw new Error(`MCP writes require mutationPolicy: ${slug}`);
|
|
44
|
+
result[slug] = saved;
|
|
45
|
+
}
|
|
46
|
+
return Object.freeze(result);
|
|
47
|
+
}
|
|
48
|
+
/** Compose official v3 MCP transport with request-scoped Xignet authorization. */
|
|
49
|
+
export function createXignetPayload(input) {
|
|
50
|
+
const options = Object.freeze({
|
|
51
|
+
...input,
|
|
52
|
+
login: input.login
|
|
53
|
+
? Object.freeze({
|
|
54
|
+
...input.login,
|
|
55
|
+
scopes: Object.freeze([...input.login.scopes]),
|
|
56
|
+
session: Object.freeze({ ...input.login.session }),
|
|
57
|
+
})
|
|
58
|
+
: undefined,
|
|
59
|
+
admissionScopes: Object.freeze([...input.admissionScopes]),
|
|
60
|
+
});
|
|
61
|
+
const enabled = input.mcp.enabled;
|
|
62
|
+
const writesEnabled = input.mcp.writesEnabled !== false;
|
|
63
|
+
const collections = snapshot(input.mcp.collections);
|
|
64
|
+
const globals = snapshot(input.mcp.globals);
|
|
65
|
+
const entries = [...Object.entries(collections), ...Object.entries(globals)];
|
|
66
|
+
const names = new Set();
|
|
67
|
+
for (const [slug] of entries) {
|
|
68
|
+
const key = camelCase(slug);
|
|
69
|
+
if (names.has(key) ||
|
|
70
|
+
[
|
|
71
|
+
'user',
|
|
72
|
+
'auth',
|
|
73
|
+
'config',
|
|
74
|
+
'collections',
|
|
75
|
+
'globals',
|
|
76
|
+
'jobs',
|
|
77
|
+
'custom',
|
|
78
|
+
'__proto__',
|
|
79
|
+
'constructor',
|
|
80
|
+
'prototype',
|
|
81
|
+
'payloadMcpApiKeys',
|
|
82
|
+
].includes(key))
|
|
83
|
+
throw new Error(`Ambiguous/reserved MCP resource name: ${slug}`);
|
|
84
|
+
names.add(key);
|
|
85
|
+
}
|
|
86
|
+
if (!options.admissionScopes.length)
|
|
87
|
+
throw new Error('MCP requires admissionScopes');
|
|
88
|
+
validateScopes(options.admissionScopes);
|
|
89
|
+
const verifier = createTokenVerifier({
|
|
90
|
+
...options,
|
|
91
|
+
audience: options.resource,
|
|
92
|
+
});
|
|
93
|
+
const resource = trustedUrl(options.resource, options.allowInsecureLoopback);
|
|
94
|
+
const metadata = createProtectedResourceMetadata({
|
|
95
|
+
resource: options.resource,
|
|
96
|
+
issuer: options.issuer,
|
|
97
|
+
scopesSupported: [
|
|
98
|
+
...options.admissionScopes,
|
|
99
|
+
...(input.mcp.scopesSupported ?? []),
|
|
100
|
+
...entries.flatMap(([, p]) => operations.flatMap((op) => p[op]?.scopes ?? [])),
|
|
101
|
+
],
|
|
102
|
+
allowInsecureLoopback: options.allowInsecureLoopback,
|
|
103
|
+
});
|
|
104
|
+
// Context is copied by Payload's Local API. Its marker object retains its
|
|
105
|
+
// identity through the shallow copy; JSON from clients cannot forge it.
|
|
106
|
+
const principals = new WeakMap();
|
|
107
|
+
const contextKey = '__xignetPayloadPrincipal';
|
|
108
|
+
const principal = (req) => {
|
|
109
|
+
const marker = req.context?.[contextKey];
|
|
110
|
+
if (!marker || typeof marker !== 'object')
|
|
111
|
+
return undefined;
|
|
112
|
+
const known = principals.get(marker);
|
|
113
|
+
if (known &&
|
|
114
|
+
known.user.id === req.user?.id &&
|
|
115
|
+
known.user.collection === req.user?.collection)
|
|
116
|
+
return known;
|
|
117
|
+
return undefined;
|
|
118
|
+
};
|
|
119
|
+
const audit = (req, operation, target, outcome) => {
|
|
120
|
+
const p = principal(req);
|
|
121
|
+
if (req.payloadAPI !== 'MCP' || !p)
|
|
122
|
+
return;
|
|
123
|
+
req.payload.logger.info({
|
|
124
|
+
event: 'xignet_mcp_operation',
|
|
125
|
+
requestId: p.requestId,
|
|
126
|
+
issuer: p.identity.issuer,
|
|
127
|
+
subject: p.identity.subject,
|
|
128
|
+
clientId: p.identity.clientId,
|
|
129
|
+
userId: p.user.id,
|
|
130
|
+
operation,
|
|
131
|
+
target,
|
|
132
|
+
outcome,
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
const guardAccess = (existing, policy, operation) => async (args) => {
|
|
136
|
+
if (args.req.payloadAPI === 'MCP') {
|
|
137
|
+
const p = principal(args.req);
|
|
138
|
+
if (!p ||
|
|
139
|
+
(operation !== 'find' && !writesEnabled) ||
|
|
140
|
+
!policy?.[operation] ||
|
|
141
|
+
!hasAllScopes(p.identity, [
|
|
142
|
+
...options.admissionScopes,
|
|
143
|
+
...policy[operation].scopes,
|
|
144
|
+
]))
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
// Payload's default is an authenticated user when no collection access
|
|
148
|
+
// function is configured. Preserve that default for all channels.
|
|
149
|
+
const allowed = existing ? await existing(args) : Boolean(args.req.user);
|
|
150
|
+
if (args.req.payloadAPI === 'MCP' && allowed === false) {
|
|
151
|
+
const p = principal(args.req);
|
|
152
|
+
if (p)
|
|
153
|
+
p.rpc.outcome = 'denied';
|
|
154
|
+
}
|
|
155
|
+
return allowed;
|
|
156
|
+
};
|
|
157
|
+
const guardMutation = (policy) => async (args) => {
|
|
158
|
+
if (args.req.payloadAPI !== 'MCP')
|
|
159
|
+
return args.data;
|
|
160
|
+
const p = principal(args.req);
|
|
161
|
+
const operation = args.operation ?? 'update';
|
|
162
|
+
const op = policy[operation];
|
|
163
|
+
if (!p ||
|
|
164
|
+
!writesEnabled ||
|
|
165
|
+
!op ||
|
|
166
|
+
!policy.mutationPolicy ||
|
|
167
|
+
!hasAllScopes(p.identity, op.scopes))
|
|
168
|
+
throw new APIError('MCP mutation refused', 403);
|
|
169
|
+
const required = await policy.mutationPolicy({
|
|
170
|
+
...args,
|
|
171
|
+
operation,
|
|
172
|
+
identity: p.identity,
|
|
173
|
+
});
|
|
174
|
+
if (required === false) {
|
|
175
|
+
p.rpc.outcome = 'denied';
|
|
176
|
+
audit(args.req, operation, 'mutation', 'denied_by_policy');
|
|
177
|
+
throw new APIError('MCP mutation refused', 403);
|
|
178
|
+
}
|
|
179
|
+
validateScopes(required);
|
|
180
|
+
if (!hasAllScopes(p.identity, [...options.admissionScopes, ...required])) {
|
|
181
|
+
p.rpc.outcome = 'denied';
|
|
182
|
+
audit(args.req, operation, 'mutation', 'insufficient_scope');
|
|
183
|
+
throw new APIError('MCP scope insufficient', 403);
|
|
184
|
+
}
|
|
185
|
+
return args.data;
|
|
186
|
+
};
|
|
187
|
+
const settings = (p) => {
|
|
188
|
+
const result = { user: p.user };
|
|
189
|
+
for (const [slug, policy] of entries) {
|
|
190
|
+
result[camelCase(slug)] = Object.fromEntries([
|
|
191
|
+
...operations.map((op) => [
|
|
192
|
+
op,
|
|
193
|
+
Boolean((op === 'find' || writesEnabled) &&
|
|
194
|
+
policy[op] &&
|
|
195
|
+
hasAllScopes(p.identity, policy[op].scopes)),
|
|
196
|
+
]),
|
|
197
|
+
['delete', false],
|
|
198
|
+
]);
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
};
|
|
202
|
+
const authenticate = async (req, requestId) => {
|
|
203
|
+
// A previous native cookie/API-key identity cannot substitute for Bearer.
|
|
204
|
+
req.user = null;
|
|
205
|
+
const authorization = req.headers.get('authorization');
|
|
206
|
+
const match = authorization?.match(/^Bearer ([^\s,]+)$/i);
|
|
207
|
+
if (!match)
|
|
208
|
+
throw new ResourceError(401, options.admissionScopes, Boolean(authorization));
|
|
209
|
+
const verified = await verifier.verifyAccessToken(match[1]);
|
|
210
|
+
if (!verified.ok)
|
|
211
|
+
throw new ResourceError(verified.kind === 'verification_unavailable' ? 503 : 401, options.admissionScopes, true, verified.reason);
|
|
212
|
+
if (!hasAllScopes(verified.identity, options.admissionScopes))
|
|
213
|
+
throw new ResourceError(403, options.admissionScopes);
|
|
214
|
+
const resolved = await options.resolveUser({
|
|
215
|
+
identity: verified.identity,
|
|
216
|
+
channel: 'mcp',
|
|
217
|
+
payload: req.payload,
|
|
218
|
+
});
|
|
219
|
+
if (resolved.status !== 'allowed')
|
|
220
|
+
throw new ResourceError(403);
|
|
221
|
+
if (!resolved.user ||
|
|
222
|
+
resolved.user.collection !== options.userCollection ||
|
|
223
|
+
!['number', 'string'].includes(typeof resolved.user.id))
|
|
224
|
+
throw new Error('Xignet resolver returned an invalid user collection or ID');
|
|
225
|
+
req.user = { ...resolved.user, _strategy: 'xignet-mcp' };
|
|
226
|
+
const marker = Object.freeze({});
|
|
227
|
+
const p = Object.freeze({
|
|
228
|
+
requestId,
|
|
229
|
+
identity: verified.identity,
|
|
230
|
+
user: req.user,
|
|
231
|
+
rpc: {},
|
|
232
|
+
});
|
|
233
|
+
principals.set(marker, p);
|
|
234
|
+
req.context = { ...req.context, [contextKey]: marker };
|
|
235
|
+
return p;
|
|
236
|
+
};
|
|
237
|
+
const sessions = createNativeSessions(options);
|
|
238
|
+
const login = createLogin(options, sessions);
|
|
239
|
+
const tools = new Map();
|
|
240
|
+
for (const [kind, policies] of [
|
|
241
|
+
['collection', collections],
|
|
242
|
+
['global', globals],
|
|
243
|
+
])
|
|
244
|
+
for (const [slug, policy] of Object.entries(policies))
|
|
245
|
+
for (const op of operations)
|
|
246
|
+
if (policy[op]) {
|
|
247
|
+
const name = `${op}${slug.charAt(0).toUpperCase()}${camelCase(slug).slice(1)}`;
|
|
248
|
+
tools.set(name, {
|
|
249
|
+
target: `${kind}:${slug}`,
|
|
250
|
+
scopes: policy[op].scopes,
|
|
251
|
+
operation: op,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const toolResponse = (response, doc, req, collection = true) => {
|
|
255
|
+
const p = principal(req);
|
|
256
|
+
if (p) {
|
|
257
|
+
// Only collection bulk result envelopes carry operation errors.
|
|
258
|
+
// Single documents always have an ID; globals cannot be bulk updated.
|
|
259
|
+
const bulk = collection && !('id' in doc) && Array.isArray(doc.docs);
|
|
260
|
+
const hasErrors = bulk && Array.isArray(doc.errors) && doc.errors.length > 0;
|
|
261
|
+
const hasSuccesses = Array.isArray(doc.docs) && doc.docs.length > 0;
|
|
262
|
+
if (hasErrors && hasSuccesses)
|
|
263
|
+
p.rpc.outcome = 'partial_failure';
|
|
264
|
+
else if (p.rpc.outcome !== 'denied')
|
|
265
|
+
p.rpc.outcome =
|
|
266
|
+
hasErrors || !Object.keys(doc).length ? 'failed' : 'success';
|
|
267
|
+
}
|
|
268
|
+
return response;
|
|
269
|
+
};
|
|
270
|
+
return {
|
|
271
|
+
...login,
|
|
272
|
+
loginEnabled: options.login?.enabled === true,
|
|
273
|
+
revokeUserSessions: sessions.revokeUserSessions,
|
|
274
|
+
metadata: () => enabled
|
|
275
|
+
? Response.json(metadata, { headers: { 'cache-control': 'no-store' } })
|
|
276
|
+
: new Response(null, { status: 404 }),
|
|
277
|
+
plugin: () => async (config) => {
|
|
278
|
+
const apiPath = `${config.routes?.api ?? '/api'}/mcp`;
|
|
279
|
+
if (resource.pathname !== apiPath)
|
|
280
|
+
throw new Error(`MCP resource path must equal ${apiPath}`);
|
|
281
|
+
if (config.serverURL &&
|
|
282
|
+
new URL(config.serverURL).origin !== resource.origin)
|
|
283
|
+
throw new Error('MCP resource origin must match serverURL');
|
|
284
|
+
if (!(config.collections ?? []).some((c) => c.slug === options.userCollection && c.auth))
|
|
285
|
+
throw new Error('Xignet requires an auth-enabled userCollection');
|
|
286
|
+
for (const slug of Object.keys(collections))
|
|
287
|
+
if (!(config.collections ?? []).some((c) => c.slug === slug))
|
|
288
|
+
throw new Error(`Unknown MCP collection: ${slug}`);
|
|
289
|
+
for (const slug of Object.keys(globals))
|
|
290
|
+
if (!(config.globals ?? []).some((g) => g.slug === slug))
|
|
291
|
+
throw new Error(`Unknown MCP global: ${slug}`);
|
|
292
|
+
if ((config.collections ?? []).some((c) => c.slug === SESSION_COLLECTION))
|
|
293
|
+
throw new Error('Reserved Xignet session collection already exists');
|
|
294
|
+
config.collections = [
|
|
295
|
+
...(config.collections ?? []).map((c) => c.slug === options.userCollection ? sessions.enhanceUser(c) : c),
|
|
296
|
+
sessions.collection,
|
|
297
|
+
];
|
|
298
|
+
const mcpOptions = {
|
|
299
|
+
disabled: !enabled,
|
|
300
|
+
userCollection: options.userCollection,
|
|
301
|
+
collections: Object.fromEntries(Object.entries(collections).map(([slug, p]) => [
|
|
302
|
+
slug,
|
|
303
|
+
{
|
|
304
|
+
overrideResponse: toolResponse,
|
|
305
|
+
enabled: {
|
|
306
|
+
find: Boolean(p.find),
|
|
307
|
+
create: Boolean(p.create),
|
|
308
|
+
update: Boolean(p.update),
|
|
309
|
+
delete: false,
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
])),
|
|
313
|
+
globals: Object.fromEntries(Object.entries(globals).map(([slug, p]) => [
|
|
314
|
+
slug,
|
|
315
|
+
{
|
|
316
|
+
enabled: { find: Boolean(p.find), update: Boolean(p.update) },
|
|
317
|
+
overrideResponse: (response, doc, req) => toolResponse(response, doc, req, false),
|
|
318
|
+
},
|
|
319
|
+
])),
|
|
320
|
+
overrideAuth: async (req) => {
|
|
321
|
+
const p = principal(req);
|
|
322
|
+
if (!p)
|
|
323
|
+
throw new APIError('MCP authentication required', 401);
|
|
324
|
+
return settings(p);
|
|
325
|
+
},
|
|
326
|
+
overrideApiKeyCollection: (c) => ({
|
|
327
|
+
...c,
|
|
328
|
+
admin: { ...c.admin, hidden: true },
|
|
329
|
+
access: {
|
|
330
|
+
read: () => false,
|
|
331
|
+
create: () => false,
|
|
332
|
+
update: () => false,
|
|
333
|
+
delete: () => false,
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
};
|
|
337
|
+
const original = (config.endpoints ?? []).filter((e) => e.path === '/mcp');
|
|
338
|
+
if (original.length)
|
|
339
|
+
throw new Error('Install Xignet MCP once; another /mcp endpoint already exists');
|
|
340
|
+
const result = await mcpPlugin(mcpOptions)(config);
|
|
341
|
+
if (!enabled)
|
|
342
|
+
return result;
|
|
343
|
+
result.collections = result.collections?.map((c) => {
|
|
344
|
+
const p = collections[c.slug];
|
|
345
|
+
return {
|
|
346
|
+
...c,
|
|
347
|
+
access: {
|
|
348
|
+
...c.access,
|
|
349
|
+
read: guardAccess(c.access?.read, p, 'find'),
|
|
350
|
+
create: guardAccess(c.access?.create, p, 'create'),
|
|
351
|
+
update: guardAccess(c.access?.update, p, 'update'),
|
|
352
|
+
delete: async (args) => args.req.payloadAPI === 'MCP'
|
|
353
|
+
? false
|
|
354
|
+
: c.access?.delete
|
|
355
|
+
? c.access.delete(args)
|
|
356
|
+
: Boolean(args.req.user),
|
|
357
|
+
readVersions: guardAccess(c.access?.readVersions, p, 'find'),
|
|
358
|
+
},
|
|
359
|
+
hooks: {
|
|
360
|
+
...c.hooks,
|
|
361
|
+
beforeChange: [
|
|
362
|
+
...(c.hooks?.beforeChange ?? []),
|
|
363
|
+
...(p?.mutationPolicy ? [guardMutation(p)] : []),
|
|
364
|
+
],
|
|
365
|
+
afterOperation: [
|
|
366
|
+
...(c.hooks?.afterOperation ?? []),
|
|
367
|
+
(args) => {
|
|
368
|
+
audit(args.req, args.operation, `collection:${c.slug}`, args.result &&
|
|
369
|
+
typeof args.result === 'object' &&
|
|
370
|
+
!('id' in args.result) &&
|
|
371
|
+
'docs' in args.result &&
|
|
372
|
+
Array.isArray(args.result.docs) &&
|
|
373
|
+
'errors' in args.result &&
|
|
374
|
+
Array.isArray(args.result.errors) &&
|
|
375
|
+
args.result.errors.length
|
|
376
|
+
? 'partial_failure'
|
|
377
|
+
: 'success');
|
|
378
|
+
return args.result;
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
result.globals = result.globals?.map((g) => {
|
|
385
|
+
const p = globals[g.slug];
|
|
386
|
+
return {
|
|
387
|
+
...g,
|
|
388
|
+
access: {
|
|
389
|
+
...g.access,
|
|
390
|
+
read: guardAccess(g.access?.read, p, 'find'),
|
|
391
|
+
update: guardAccess(g.access?.update, p, 'update'),
|
|
392
|
+
readVersions: guardAccess(g.access?.readVersions, p, 'find'),
|
|
393
|
+
},
|
|
394
|
+
hooks: {
|
|
395
|
+
...g.hooks,
|
|
396
|
+
beforeChange: [
|
|
397
|
+
...(g.hooks?.beforeChange ?? []),
|
|
398
|
+
...(p?.mutationPolicy ? [guardMutation(p)] : []),
|
|
399
|
+
],
|
|
400
|
+
afterRead: [
|
|
401
|
+
...(g.hooks?.afterRead ?? []),
|
|
402
|
+
(args) => {
|
|
403
|
+
audit(args.req, 'find', `global:${g.slug}`, 'success');
|
|
404
|
+
return args.doc;
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
afterChange: [
|
|
408
|
+
...(g.hooks?.afterChange ?? []),
|
|
409
|
+
(args) => {
|
|
410
|
+
audit(args.req, 'update', `global:${g.slug}`, 'success');
|
|
411
|
+
return args.doc;
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
result.endpoints = result.endpoints?.map((endpoint) => endpoint.path !== '/mcp'
|
|
418
|
+
? endpoint
|
|
419
|
+
: {
|
|
420
|
+
...endpoint,
|
|
421
|
+
handler: async (req) => {
|
|
422
|
+
const requestId = randomUUID();
|
|
423
|
+
try {
|
|
424
|
+
req.payloadAPI = 'MCP';
|
|
425
|
+
const p = await authenticate(req, requestId);
|
|
426
|
+
const rpc = req.clone
|
|
427
|
+
? await inspectRpc(req.clone())
|
|
428
|
+
: undefined;
|
|
429
|
+
const method = rpcMethod(rpc?.method);
|
|
430
|
+
const name = rpc?.params &&
|
|
431
|
+
typeof rpc.params === 'object' &&
|
|
432
|
+
'name' in rpc.params
|
|
433
|
+
? rpc.params.name
|
|
434
|
+
: undefined;
|
|
435
|
+
const tool = typeof name === 'string' ? tools.get(name) : undefined;
|
|
436
|
+
const target = tool?.target;
|
|
437
|
+
if (tool &&
|
|
438
|
+
(!hasAllScopes(p.identity, tool.scopes) ||
|
|
439
|
+
(tool.operation !== 'find' && !writesEnabled)))
|
|
440
|
+
p.rpc.outcome = 'denied';
|
|
441
|
+
const response = await endpoint.handler(req);
|
|
442
|
+
const body = observeRpc(response, (reply, completed) => {
|
|
443
|
+
const isError = reply?.error ||
|
|
444
|
+
(reply?.result &&
|
|
445
|
+
typeof reply.result === 'object' &&
|
|
446
|
+
'isError' in reply.result &&
|
|
447
|
+
reply.result.isError === true);
|
|
448
|
+
let outcome = 'unobserved';
|
|
449
|
+
if (rpc && completed) {
|
|
450
|
+
if (p.rpc.outcome === 'denied')
|
|
451
|
+
outcome = 'denied';
|
|
452
|
+
else if (isError || response.status >= 400)
|
|
453
|
+
outcome = 'failed';
|
|
454
|
+
else if (p.rpc.outcome)
|
|
455
|
+
outcome = p.rpc.outcome;
|
|
456
|
+
else if (reply?.result && method !== 'tools/call')
|
|
457
|
+
outcome = 'success';
|
|
458
|
+
}
|
|
459
|
+
audit(req, method, target ??
|
|
460
|
+
(method === 'tools/call' ? 'unknown_tool' : 'protocol'), outcome);
|
|
461
|
+
});
|
|
462
|
+
audit(req, 'request', options.resource, `http_${response.status}`);
|
|
463
|
+
const headers = new Headers(response.headers);
|
|
464
|
+
headers.set('cache-control', 'no-store');
|
|
465
|
+
headers.set('x-request-id', requestId);
|
|
466
|
+
return new Response(body, {
|
|
467
|
+
status: response.status,
|
|
468
|
+
statusText: response.statusText,
|
|
469
|
+
headers,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
req.payload.logger.warn({
|
|
474
|
+
event: 'xignet_mcp_request_failed',
|
|
475
|
+
requestId,
|
|
476
|
+
resource: options.resource,
|
|
477
|
+
status: error instanceof ResourceError ? error.status : 500,
|
|
478
|
+
category: error instanceof Error ? error.name : 'unknown',
|
|
479
|
+
...(error instanceof ResourceError
|
|
480
|
+
? { detail: error.detail }
|
|
481
|
+
: {}),
|
|
482
|
+
});
|
|
483
|
+
if (!(error instanceof ResourceError))
|
|
484
|
+
throw error;
|
|
485
|
+
const headers = new Headers({
|
|
486
|
+
'cache-control': 'no-store',
|
|
487
|
+
'x-request-id': requestId,
|
|
488
|
+
});
|
|
489
|
+
if (error.status !== 503 &&
|
|
490
|
+
(error.status === 401 || error.requiredScopes.length))
|
|
491
|
+
headers.set('WWW-Authenticate', createBearerChallenge({
|
|
492
|
+
resource: options.resource,
|
|
493
|
+
requiredScopes: error.requiredScopes,
|
|
494
|
+
error: error.status === 403
|
|
495
|
+
? 'insufficient_scope'
|
|
496
|
+
: error.invalidToken
|
|
497
|
+
? 'invalid_token'
|
|
498
|
+
: undefined,
|
|
499
|
+
}));
|
|
500
|
+
return Response.json({
|
|
501
|
+
error: error.status === 503
|
|
502
|
+
? 'verification_unavailable'
|
|
503
|
+
: error.status === 401
|
|
504
|
+
? 'unauthorized'
|
|
505
|
+
: 'forbidden',
|
|
506
|
+
}, { status: error.status, headers });
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
return result;
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xignet/payload",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Xignet OAuth resource and OIDC integration for Payload v3.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/marxbiobuilder/signet-integration.git",
|
|
10
|
+
"directory": "packages/payload"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
"./v3": {
|
|
17
|
+
"types": "./dist/v3.d.ts",
|
|
18
|
+
"import": "./dist/v3.js"
|
|
19
|
+
},
|
|
20
|
+
"./next": {
|
|
21
|
+
"types": "./dist/next.d.ts",
|
|
22
|
+
"import": "./dist/next.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": "^22.23.4 || >=24.22.1"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"jose": "^5.10.0",
|
|
33
|
+
"@xignet/core": "^0.1.0-beta.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"payload": "3.93.0",
|
|
37
|
+
"@payloadcms/plugin-mcp": "3.93.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"payload": "3.93.0",
|
|
41
|
+
"@payloadcms/plugin-mcp": "3.93.0",
|
|
42
|
+
"@payloadcms/db-sqlite": "3.93.0",
|
|
43
|
+
"typescript": "^5.7.3",
|
|
44
|
+
"@types/node": "^22.12.0",
|
|
45
|
+
"prettier": "^3.4.2"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.json",
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"lint": "prettier --check 'src/**/*.ts' 'test/**/*.mjs'",
|
|
51
|
+
"test": "pnpm build && node --test test/*.test.mjs",
|
|
52
|
+
"test:cov": "pnpm build && node --experimental-test-coverage --test test/*.test.mjs"
|
|
53
|
+
}
|
|
6
54
|
}
|