@xignet/payload 0.1.0-beta.0 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,30 +5,30 @@ export const SESSION_COLLECTION = 'xignet-sessions';
5
5
  export const SESSION_CLAIM = '__xignet_sso';
6
6
  // Match Payload 3.93's credential precedence and CSRF rules. extractJWT is
7
7
  // not a public export, so the pinned adapter tests native browser auth.
8
- function selectedToken(req) {
8
+ function selectedToken(req, headers = req.headers) {
9
9
  for (const method of req.payload.config.auth.jwtOrder) {
10
10
  if (method === 'Bearer' || method === 'JWT') {
11
- const header = req.headers.get('authorization');
11
+ const header = headers.get('authorization');
12
12
  if (header?.startsWith(`${method} `))
13
13
  return header.slice(method.length + 1);
14
14
  }
15
15
  else {
16
- const token = parseCookies(req.headers).get(`${req.payload.config.cookiePrefix}-token`);
16
+ const token = parseCookies(headers).get(`${req.payload.config.cookiePrefix}-token`);
17
17
  if (!token)
18
18
  continue;
19
- const origin = req.headers.get('origin');
19
+ const origin = headers.get('origin');
20
20
  const csrf = req.payload.config.csrf;
21
21
  if (origin
22
22
  ? csrf.length === 0 || csrf.includes(origin)
23
23
  : csrf.length === 0 ||
24
- ['same-origin', 'same-site', 'none'].includes(req.headers.get('sec-fetch-site') ?? ''))
24
+ ['same-origin', 'same-site', 'none'].includes(headers.get('sec-fetch-site') ?? ''))
25
25
  return token;
26
26
  }
27
27
  }
28
28
  return undefined;
29
29
  }
30
- async function reference(req) {
31
- const token = selectedToken(req);
30
+ async function reference(req, headers = req.headers) {
31
+ const token = selectedToken(req, headers);
32
32
  if (!token)
33
33
  return undefined;
34
34
  try {
@@ -47,6 +47,7 @@ async function reference(req) {
47
47
  export function createNativeSessions(options) {
48
48
  const maxAge = options.login?.session.maxAgeSeconds ?? 7200;
49
49
  const absoluteMaxAge = options.login?.session.absoluteMaxAgeSeconds ?? 7200;
50
+ const xignetOnly = options.sessionAuthentication === 'xignet-only';
50
51
  if (![maxAge, absoluteMaxAge].every((n) => Number.isInteger(n) && n > 0 && n <= 604800) ||
51
52
  maxAge > absoluteMaxAge)
52
53
  throw new Error('Invalid SSO session lifetimes');
@@ -188,12 +189,66 @@ export function createNativeSessions(options) {
188
189
  const enhanceUser = (config) => {
189
190
  const auth = typeof config.auth === 'object' ? config.auth : {};
190
191
  if (!config.auth ||
191
- auth.disableLocalStrategy ||
192
+ (auth.disableLocalStrategy && !xignetOnly) ||
192
193
  auth.useSessions === false) {
193
- if (options.login?.enabled)
194
+ if (options.login?.enabled || xignetOnly)
194
195
  throw new Error('SSO requires native Payload auth with sessions');
195
196
  return config;
196
197
  }
198
+ if (xignetOnly &&
199
+ ((auth.useAPIKey && !options.legacyAPIKeys) || auth.strategies?.length))
200
+ throw new Error('Xignet-only sessions cannot compose API-key or other authentication strategies');
201
+ const strategy = {
202
+ name: 'xignet-session',
203
+ authenticate: async ({ headers, payload, req: incomingReq }) => {
204
+ const req = incomingReq ??
205
+ (await createLocalReq({
206
+ req: new Request(options.resource, { headers }),
207
+ }, payload));
208
+ if (/^Bearer(?:\s|$)/i.test(headers.get('authorization') ?? ''))
209
+ return { user: null };
210
+ try {
211
+ const ref = await reference(req, headers);
212
+ if (!ref ||
213
+ ref.collection !== options.userCollection ||
214
+ !['string', 'number'].includes(typeof ref.id) ||
215
+ typeof ref.sid !== 'string')
216
+ return { user: null };
217
+ const entry = await record(req, ref.sid);
218
+ if (!entry || !(await allowed(req, entry, ref.id))) {
219
+ await revokeSession(req, ref.id, ref.sid);
220
+ return { user: null };
221
+ }
222
+ const user = (await payload.findByID({
223
+ collection: options.userCollection,
224
+ id: ref.id,
225
+ req,
226
+ overrideAccess: true,
227
+ depth: 0,
228
+ disableErrors: true,
229
+ }));
230
+ if ((auth.verify && user?._verified !== true) ||
231
+ !user?.sessions?.some((s) => s.id === ref.sid &&
232
+ Date.parse(String(s.expiresAt)) > Date.now()))
233
+ return { user: null };
234
+ return {
235
+ user: {
236
+ ...user,
237
+ collection: options.userCollection,
238
+ _sid: ref.sid,
239
+ _strategy: 'xignet-session',
240
+ },
241
+ };
242
+ }
243
+ catch (error) {
244
+ payload.logger.error({
245
+ event: 'xignet_session_authentication_failed',
246
+ category: error instanceof Error ? error.name : 'unknown',
247
+ });
248
+ throw new APIError('Xignet session authentication unavailable', 503);
249
+ }
250
+ },
251
+ };
197
252
  const previousRefresh = config.hooks?.refresh ?? [];
198
253
  const refresh = async (args) => {
199
254
  const ref = await reference(args.args.req);
@@ -266,6 +321,13 @@ export function createNativeSessions(options) {
266
321
  ...config,
267
322
  auth: {
268
323
  ...auth,
324
+ ...(xignetOnly
325
+ ? {
326
+ disableLocalStrategy: { enableFields: true },
327
+ useAPIKey: options.legacyAPIKeys === true,
328
+ strategies: [strategy],
329
+ }
330
+ : {}),
269
331
  useSessions: true,
270
332
  cookies: {
271
333
  ...auth.cookies,
@@ -277,7 +339,23 @@ export function createNativeSessions(options) {
277
339
  },
278
340
  hooks: {
279
341
  ...config.hooks,
280
- afterRead: [...(config.hooks?.afterRead ?? []), protectSession],
342
+ beforeValidate: [
343
+ ...(xignetOnly
344
+ ? [
345
+ ({ data }) => {
346
+ if (data &&
347
+ ['password', 'hash', 'salt'].some((key) => data[key] != null))
348
+ throw new APIError('Password credentials are disabled for Xignet-only users', 400);
349
+ return data;
350
+ },
351
+ ]
352
+ : []),
353
+ ...(config.hooks?.beforeValidate ?? []),
354
+ ],
355
+ afterRead: [
356
+ ...(config.hooks?.afterRead ?? []),
357
+ ...(xignetOnly ? [] : [protectSession]),
358
+ ],
281
359
  refresh: [refresh],
282
360
  afterLogout: [
283
361
  ...(config.hooks?.afterLogout ?? []),
package/dist/next.js CHANGED
@@ -8,7 +8,17 @@ export function createXignetRouteHandlers(integration, options) {
8
8
  callback: async (request) => {
9
9
  if (!integration.loginEnabled || !options)
10
10
  return new Response(null, { status: 404 });
11
- return integration.callback(request, await options.getPayload());
11
+ const payload = await options.getPayload();
12
+ if (!payload.config.serverURL)
13
+ return integration.callback(request, payload);
14
+ // Standalone Next requests may use an internal hostname and port.
15
+ // Only configured serverURL supplies the public origin, never headers.
16
+ const incoming = new URL(request.url);
17
+ const callback = new URL(payload.config.serverURL);
18
+ callback.pathname = incoming.pathname;
19
+ callback.search = incoming.search;
20
+ callback.hash = incoming.hash;
21
+ return integration.callback(new Request(callback, request), payload);
12
22
  },
13
23
  };
14
24
  }
@@ -0,0 +1,29 @@
1
+ import type { DirectoryClient } from '@xignet/client/directory';
2
+ import type { Plugin, TypedUser } from 'payload';
3
+ import { type ManagedUsersOptions } from './managed-users.js';
4
+ import type { XignetPayloadIntegration, XignetPayloadOptions } from './types.js';
5
+ /** The production AS currently advertises this issuer in discovery and tokens. */
6
+ export declare const XIGNET_PROVIDER: Readonly<{
7
+ issuer: "https://signet.marxbio.tech";
8
+ jwksUri: "https://signet.marxbio.tech/.well-known/jwks.json";
9
+ }>;
10
+ export type XignetPluginOptions<TUser extends TypedUser = TypedUser> = Omit<XignetPayloadOptions<TUser>, 'issuer' | 'jwksUri' | 'resolveUser' | 'sessionAuthentication'> & {
11
+ /** Explicit override for tests or a self-hosted Xignet AS. */
12
+ readonly provider?: {
13
+ readonly issuer: string;
14
+ readonly jwksUri: string;
15
+ };
16
+ readonly users: ManagedUsersOptions<TUser>;
17
+ /** Server-only directory client. Omit to disable account provisioning. */
18
+ readonly directory?: DirectoryClient;
19
+ readonly admin?: {
20
+ readonly loginPath?: string;
21
+ readonly label?: string;
22
+ };
23
+ };
24
+ export type XignetPlugin = Plugin & XignetPayloadIntegration;
25
+ /** A standard Payload plugin with managed bindings and Xignet-only sessions. */
26
+ export declare function xignetPlugin<TUser extends TypedUser>(input: XignetPluginOptions<TUser>): XignetPlugin;
27
+ export type { ManagedUsersOptions } from './managed-users.js';
28
+ export { isXignetService } from './rest.js';
29
+ export type { RestOptions, RestEntityPolicy } from './rest.js';
package/dist/plugin.js ADDED
@@ -0,0 +1,64 @@
1
+ import { createManagedUsers, } from './managed-users.js';
2
+ import { createXignetPayload } from './v3.js';
3
+ /** The production AS currently advertises this issuer in discovery and tokens. */
4
+ export const XIGNET_PROVIDER = Object.freeze({
5
+ issuer: 'https://signet.marxbio.tech',
6
+ jwksUri: 'https://signet.marxbio.tech/.well-known/jwks.json',
7
+ });
8
+ /** A standard Payload plugin with managed bindings and Xignet-only sessions. */
9
+ export function xignetPlugin(input) {
10
+ const userCollection = input.userCollection;
11
+ const loginLabel = input.admin?.label ?? 'Sign in with Xignet';
12
+ const provider = Object.freeze({ ...(input.provider ?? XIGNET_PROVIDER) });
13
+ const managed = createManagedUsers({
14
+ ...provider,
15
+ resource: input.resource,
16
+ directory: input.directory,
17
+ userCollection,
18
+ policy: Object.freeze({
19
+ ...input.users,
20
+ revokeOnFields: Object.freeze([...(input.users.revokeOnFields ?? [])]),
21
+ }),
22
+ revoke: (args) => integration.revokeUserSessions(args),
23
+ });
24
+ const integration = createXignetPayload({
25
+ ...input,
26
+ ...provider,
27
+ sessionAuthentication: 'xignet-only',
28
+ resolveUser: managed.resolveUser,
29
+ });
30
+ const apply = integration.plugin();
31
+ const loginPath = input.admin?.loginPath ?? '/auth/xignet/login';
32
+ if (!loginPath.startsWith('/') ||
33
+ loginPath.startsWith('//') ||
34
+ new URL(loginPath, 'https://cms.invalid').origin !== 'https://cms.invalid')
35
+ throw new Error('Xignet loginPath must be a local absolute path');
36
+ const plugin = async (config) => {
37
+ const enhanced = await apply({
38
+ ...config,
39
+ collections: (config.collections ?? []).map((collection) => collection.slug === userCollection
40
+ ? managed.enhanceUser(collection)
41
+ : collection),
42
+ });
43
+ enhanced.admin = {
44
+ ...enhanced.admin,
45
+ components: {
46
+ ...enhanced.admin?.components,
47
+ beforeLogin: [
48
+ ...(enhanced.admin?.components?.beforeLogin ?? []),
49
+ {
50
+ path: '@xignet/payload/components#XignetLogin',
51
+ serverProps: {
52
+ enabled: integration.loginEnabled,
53
+ loginPath,
54
+ label: loginLabel,
55
+ },
56
+ },
57
+ ],
58
+ },
59
+ };
60
+ return enhanced;
61
+ };
62
+ return Object.assign(plugin, integration, { plugin: () => plugin });
63
+ }
64
+ export { isXignetService } from './rest.js';
package/dist/rest.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { Config, Payload, TypedUser } from 'payload';
2
+ import type { XignetPayloadOptions } from './types.js';
3
+ export interface RestEntityPolicy {
4
+ readonly read?: readonly string[];
5
+ readonly create?: readonly string[];
6
+ readonly update?: readonly string[];
7
+ }
8
+ export interface RestOptions {
9
+ readonly enabled: boolean;
10
+ /** Separate audience from MCP, e.g. https://cms.example.com/api. */
11
+ readonly resource: string;
12
+ readonly admissionScopes: readonly string[];
13
+ readonly collections: Readonly<Record<string, RestEntityPolicy>>;
14
+ readonly globals?: Readonly<Record<string, Omit<RestEntityPolicy, 'create'>>>;
15
+ /** Exact custom paths relative to the API root; no wildcard or inherited permission. */
16
+ readonly endpoints?: readonly {
17
+ readonly path: string;
18
+ readonly method: 'GET' | 'POST';
19
+ readonly scopes: readonly string[];
20
+ }[];
21
+ /** Locally approved machine identities. No human user or admin role is assigned. */
22
+ readonly services?: readonly {
23
+ readonly subject: string;
24
+ readonly clientId: string;
25
+ }[];
26
+ }
27
+ /** Only true for an authenticated service principal created by this adapter. */
28
+ export declare function isXignetService(req: {
29
+ user?: TypedUser | null;
30
+ }): boolean;
31
+ export declare function createRest<TUser extends TypedUser>(options: XignetPayloadOptions<TUser>): {
32
+ handle(request: Request, payload: Payload, next: () => Promise<Response>): Promise<Response>;
33
+ enhance(config: Config): Config;
34
+ };
package/dist/rest.js ADDED
@@ -0,0 +1,272 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { createTokenVerifier, hasAllScopes, trustedUrl, } from '@xignet/core';
4
+ import { validateScopes } from '@xignet/core/oauth';
5
+ const principals = new WeakMap();
6
+ const key = '__xignetRest';
7
+ function principal(req) {
8
+ const marker = req.user?.[key];
9
+ return marker && typeof marker === 'object'
10
+ ? principals.get(marker)
11
+ : undefined;
12
+ }
13
+ /** Only true for an authenticated service principal created by this adapter. */
14
+ export function isXignetService(req) {
15
+ return principal(req)?.service === true;
16
+ }
17
+ class Refused extends Error {
18
+ status;
19
+ category;
20
+ constructor(status, category = status ===
21
+ 401
22
+ ? 'invalid_token'
23
+ : 'permission_denied') {
24
+ super('Xignet REST access refused');
25
+ this.status = status;
26
+ this.category = category;
27
+ }
28
+ }
29
+ const bearer = (headers) => /^Bearer(?:\s|$)/i.test(headers.get('authorization') ?? '');
30
+ export function createRest(options) {
31
+ const authenticated = new AsyncLocalStorage();
32
+ // Snapshot authorization policy; caller mutation must not expand active grants.
33
+ const rest = options.rest && JSON.parse(JSON.stringify(options.rest));
34
+ const scopes = (value) => {
35
+ validateScopes(value);
36
+ if (!value.length)
37
+ throw new Error('REST policy requires explicit scopes');
38
+ };
39
+ if (rest) {
40
+ scopes(rest.admissionScopes);
41
+ for (const policy of [
42
+ ...Object.values(rest.collections),
43
+ ...Object.values(rest.globals ?? {}),
44
+ ])
45
+ for (const value of Object.values(policy))
46
+ scopes(value);
47
+ for (const endpoint of rest.endpoints ?? []) {
48
+ if (!/^\/[a-zA-Z0-9/_-]+$/.test(endpoint.path) ||
49
+ !['GET', 'POST'].includes(endpoint.method))
50
+ throw new Error('REST custom endpoint requires an exact path and method');
51
+ scopes(endpoint.scopes);
52
+ }
53
+ for (const service of rest.services ?? [])
54
+ if (!service.subject.startsWith('service:') ||
55
+ service.subject !== service.clientId)
56
+ throw new Error('Xignet service subject must equal its service client ID');
57
+ const resource = trustedUrl(rest.resource, options.allowInsecureLoopback);
58
+ if (resource.origin !== new URL(options.resource).origin ||
59
+ rest.resource === options.resource)
60
+ throw new Error('REST requires a separate audience on the CMS origin');
61
+ }
62
+ const verifier = rest && createTokenVerifier({ ...options, audience: rest.resource });
63
+ function route(request) {
64
+ if (!rest)
65
+ return;
66
+ const path = new URL(request.url).pathname;
67
+ const root = new URL(rest.resource).pathname;
68
+ if (!path.startsWith(`${root}/`) || /%|\\|\/\//.test(path))
69
+ return;
70
+ const relative = path.slice(root.length);
71
+ const endpoint = rest.endpoints?.find((e) => e.path === relative && e.method === request.method);
72
+ if (endpoint)
73
+ return endpoint.scopes;
74
+ const parts = relative.slice(1).split('/');
75
+ if (parts[0] === 'globals' && parts.length === 2) {
76
+ const policy = rest.globals?.[parts[1]];
77
+ return request.method === 'GET'
78
+ ? policy?.read
79
+ : request.method === 'POST'
80
+ ? policy?.update
81
+ : undefined;
82
+ }
83
+ const policy = rest.collections[parts[0]];
84
+ if (!policy || (parts.length !== 1 && parts.length !== 2))
85
+ return;
86
+ // Only native document IDs. Excludes auth, versions, access and custom routes.
87
+ if (parts.length === 2 &&
88
+ !/^(?:[0-9]+|[a-fA-F0-9]{24}|[a-fA-F0-9-]{36})$/.test(parts[1]))
89
+ return;
90
+ if (request.method === 'GET')
91
+ return policy.read;
92
+ if (parts.length === 1 && request.method === 'POST')
93
+ return policy.create;
94
+ if (parts.length === 2 && request.method === 'PATCH')
95
+ return policy.update;
96
+ }
97
+ async function authenticate(headers, payload) {
98
+ if (!rest?.enabled || !verifier)
99
+ throw new Refused(401);
100
+ const token = headers
101
+ .get('authorization')
102
+ ?.match(/^Bearer ([^\s,]+)$/i)?.[1];
103
+ if (!token)
104
+ throw new Refused(401);
105
+ const verified = await verifier.verifyAccessToken(token);
106
+ if (!verified.ok)
107
+ throw new Refused(verified.kind === 'verification_unavailable' ? 503 : 401, verified.kind === 'verification_unavailable'
108
+ ? 'jwks_unavailable'
109
+ : 'invalid_token');
110
+ const identity = verified.identity;
111
+ if (!hasAllScopes(identity, rest.admissionScopes))
112
+ throw new Refused(403);
113
+ const service = identity.subject.startsWith('service:');
114
+ let user;
115
+ if (service) {
116
+ if (!rest.services?.some((s) => s.subject === identity.subject && s.clientId === identity.clientId))
117
+ throw new Refused(403);
118
+ user = {
119
+ id: identity.subject,
120
+ collection: 'xignet-services',
121
+ };
122
+ }
123
+ else {
124
+ const resolved = await options.resolveUser({
125
+ identity,
126
+ channel: 'rest',
127
+ payload,
128
+ });
129
+ if (resolved.status !== 'allowed' ||
130
+ resolved.user.collection !== options.userCollection)
131
+ throw new Refused(403);
132
+ user = resolved.user;
133
+ }
134
+ const marker = Object.freeze({});
135
+ principals.set(marker, { identity, service });
136
+ return { ...user, _strategy: 'xignet-rest', [key]: marker };
137
+ }
138
+ const guard = (existing, required) => async (args) => {
139
+ const p = principal(args.req);
140
+ if (p && (!required || !hasAllScopes(p.identity, required)))
141
+ return false;
142
+ // Keep all application access filters, including explicit local service policy.
143
+ return existing ? existing(args) : Boolean(args.req.user);
144
+ };
145
+ return {
146
+ async handle(request, payload, next) {
147
+ // MCP owns its own Bearer validation and audience.
148
+ if (!bearer(request.headers) ||
149
+ new URL(request.url).pathname === new URL(options.resource).pathname)
150
+ return next();
151
+ const requestId = randomUUID();
152
+ let user;
153
+ try {
154
+ user = await authenticate(request.headers, payload);
155
+ const required = route(request);
156
+ if (!required || !hasAllScopes(principal({ user }).identity, required))
157
+ throw new Refused(403);
158
+ }
159
+ catch (error) {
160
+ const status = error instanceof Refused ? error.status : 503;
161
+ const category = error instanceof Refused
162
+ ? error.category
163
+ : 'authentication_internal_error';
164
+ const entry = {
165
+ event: 'xignet_rest_denied',
166
+ requestId,
167
+ status,
168
+ category,
169
+ };
170
+ if (status === 503)
171
+ payload.logger.error(entry);
172
+ else
173
+ payload.logger.info(entry);
174
+ return Response.json({
175
+ error: status === 503
176
+ ? 'authentication_unavailable'
177
+ : status === 401
178
+ ? 'invalid_token'
179
+ : 'insufficient_permission',
180
+ }, {
181
+ status,
182
+ headers: {
183
+ 'cache-control': 'no-store',
184
+ 'x-request-id': requestId,
185
+ ...(status === 401
186
+ ? { 'www-authenticate': 'Bearer error="invalid_token"' }
187
+ : {}),
188
+ },
189
+ });
190
+ }
191
+ const response = await authenticated.run({ authorization: request.headers.get('authorization'), user }, next);
192
+ payload.logger.info({
193
+ event: 'xignet_rest_request',
194
+ requestId,
195
+ method: request.method,
196
+ subject: principal({ user }).identity.subject,
197
+ clientId: principal({ user }).identity.clientId,
198
+ status: response.status,
199
+ });
200
+ return response;
201
+ },
202
+ enhance(config) {
203
+ if (!rest?.enabled)
204
+ return config;
205
+ if (new URL(rest.resource).pathname !== (config.routes?.api ?? '/api'))
206
+ throw new Error('REST resource must equal the Payload API root');
207
+ for (const slug of Object.keys(rest.collections)) {
208
+ const collection = config.collections?.find((c) => c.slug === slug);
209
+ if (!collection || collection.auth)
210
+ throw new Error(`REST requires a non-auth content collection: ${slug}`);
211
+ }
212
+ for (const slug of Object.keys(rest.globals ?? {}))
213
+ if (!config.globals?.some((g) => g.slug === slug))
214
+ throw new Error(`Unknown REST global: ${slug}`);
215
+ return {
216
+ ...config,
217
+ collections: config.collections?.map((c) => ({
218
+ ...c,
219
+ ...(c.slug === options.userCollection
220
+ ? {
221
+ auth: {
222
+ ...(typeof c.auth === 'object' ? c.auth : {}),
223
+ strategies: [
224
+ {
225
+ name: 'xignet-rest',
226
+ authenticate: async ({ headers, payload, req, isGraphQL, }) => {
227
+ if (!bearer(headers) ||
228
+ isGraphQL ||
229
+ (req &&
230
+ req.pathname === new URL(options.resource).pathname))
231
+ return { user: null };
232
+ const current = authenticated.getStore();
233
+ if (current &&
234
+ current.authorization === headers.get('authorization'))
235
+ return { user: current.user };
236
+ try {
237
+ return { user: await authenticate(headers, payload) };
238
+ }
239
+ catch {
240
+ return { user: null };
241
+ } // HTTP failures are handled before Payload dispatch by handle().
242
+ },
243
+ },
244
+ ...(typeof c.auth === 'object'
245
+ ? (c.auth.strategies ?? [])
246
+ : []),
247
+ ],
248
+ },
249
+ }
250
+ : {}),
251
+ access: {
252
+ ...c.access,
253
+ read: guard(c.access?.read, rest.collections[c.slug]?.read),
254
+ create: guard(c.access?.create, rest.collections[c.slug]?.create),
255
+ update: guard(c.access?.update, rest.collections[c.slug]?.update),
256
+ delete: guard(c.access?.delete),
257
+ readVersions: guard(c.access?.readVersions),
258
+ },
259
+ })),
260
+ globals: config.globals?.map((g) => ({
261
+ ...g,
262
+ access: {
263
+ ...g.access,
264
+ read: guard(g.access?.read, rest.globals?.[g.slug]?.read),
265
+ update: guard(g.access?.update, rest.globals?.[g.slug]?.update),
266
+ readVersions: guard(g.access?.readVersions),
267
+ },
268
+ })),
269
+ };
270
+ },
271
+ };
272
+ }
package/dist/types.d.ts CHANGED
@@ -9,7 +9,7 @@ export type UserResolution<TUser extends TypedUser = TypedUser> = {
9
9
  };
10
10
  export type ResolveUser<TUser extends TypedUser = TypedUser> = (input: {
11
11
  identity: VerifiedIdentity;
12
- channel: 'login' | 'mcp' | 'session';
12
+ channel: 'login' | 'mcp' | 'session' | 'rest';
13
13
  payload: Payload;
14
14
  login?: {
15
15
  idTokenClaims: Readonly<Record<string, unknown>>;
@@ -35,10 +35,15 @@ export interface EntityPolicy {
35
35
  readonly mutationPolicy?: MutationPolicy;
36
36
  }
37
37
  export interface XignetPayloadOptions<TUser extends TypedUser = TypedUser> extends Omit<TokenVerifierOptions, 'audience'> {
38
+ /** Transitional opt-in. Disable after all API-key consumers have migrated. */
39
+ readonly legacyAPIKeys?: boolean;
40
+ readonly rest?: import('./rest.js').RestOptions;
38
41
  readonly resource: string;
39
42
  readonly admissionScopes: readonly string[];
40
43
  readonly userCollection: CollectionSlug;
41
44
  readonly resolveUser: ResolveUser<TUser>;
45
+ /** xignet-only disables passwords; API keys require the explicit legacyAPIKeys bridge. */
46
+ readonly sessionAuthentication?: 'mixed' | 'xignet-only';
42
47
  readonly login?: {
43
48
  readonly enabled: boolean;
44
49
  readonly clientId: string;
@@ -63,6 +68,8 @@ export interface XignetPayloadOptions<TUser extends TypedUser = TypedUser> exten
63
68
  }
64
69
  export interface XignetPayloadIntegration {
65
70
  readonly loginEnabled: boolean;
71
+ /** Wrap every Payload REST method to reject Bearer failures before dispatch. */
72
+ rest(request: Request, payload: Payload, next: () => Promise<Response>): Promise<Response>;
66
73
  plugin(): Plugin;
67
74
  /** HTTP metadata response; mount at the resource's well-known path. */
68
75
  metadata(): Response;
package/dist/v3.js CHANGED
@@ -4,6 +4,7 @@ import { createBearerChallenge, createProtectedResourceMetadata, validateScopes,
4
4
  import { mcpPlugin, } from '@payloadcms/plugin-mcp';
5
5
  import { APIError, } from 'payload';
6
6
  import { createNativeSessions, SESSION_COLLECTION } from './native-session.js';
7
+ import { createRest } from './rest.js';
7
8
  import { createLogin } from './login.js';
8
9
  import { inspectRpc, observeRpc, rpcMethod } from './rpc-audit.js';
9
10
  class ResourceError extends Error {
@@ -47,6 +48,9 @@ function snapshot(input = {}) {
47
48
  }
48
49
  /** Compose official v3 MCP transport with request-scoped Xignet authorization. */
49
50
  export function createXignetPayload(input) {
51
+ if (input.sessionAuthentication !== undefined &&
52
+ !['mixed', 'xignet-only'].includes(input.sessionAuthentication))
53
+ throw new Error('Invalid Xignet sessionAuthentication mode');
50
54
  const options = Object.freeze({
51
55
  ...input,
52
56
  login: input.login
@@ -234,6 +238,7 @@ export function createXignetPayload(input) {
234
238
  req.context = { ...req.context, [contextKey]: marker };
235
239
  return p;
236
240
  };
241
+ const rest = createRest(options);
237
242
  const sessions = createNativeSessions(options);
238
243
  const login = createLogin(options, sessions);
239
244
  const tools = new Map();
@@ -274,7 +279,13 @@ export function createXignetPayload(input) {
274
279
  metadata: () => enabled
275
280
  ? Response.json(metadata, { headers: { 'cache-control': 'no-store' } })
276
281
  : new Response(null, { status: 404 }),
282
+ rest: rest.handle,
277
283
  plugin: () => async (config) => {
284
+ if (options.sessionAuthentication === 'xignet-only' &&
285
+ config.admin?.autoLogin &&
286
+ (typeof config.admin.autoLogin !== 'object' ||
287
+ !config.admin.autoLogin.prefillOnly))
288
+ throw new Error('Xignet-only authentication requires admin autoLogin to be disabled');
278
289
  const apiPath = `${config.routes?.api ?? '/api'}/mcp`;
279
290
  if (resource.pathname !== apiPath)
280
291
  throw new Error(`MCP resource path must equal ${apiPath}`);
@@ -337,7 +348,7 @@ export function createXignetPayload(input) {
337
348
  const original = (config.endpoints ?? []).filter((e) => e.path === '/mcp');
338
349
  if (original.length)
339
350
  throw new Error('Install Xignet MCP once; another /mcp endpoint already exists');
340
- const result = await mcpPlugin(mcpOptions)(config);
351
+ const result = rest.enhance(await mcpPlugin(mcpOptions)(config));
341
352
  if (!enabled)
342
353
  return result;
343
354
  result.collections = result.collections?.map((c) => {