@xignet/payload 0.0.0-stage → 0.1.0-beta.1

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/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-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-beta.1",
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.1"
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
  }