@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marx Biotech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,113 @@
1
- # Temporary Holding Version
1
+ # @xignet/payload
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Xignet resource authorization for Payload v3. The adapter uses the official MCP plugin and applies token scopes together with existing Payload collection, document and field access. This package is under implementation and has not been published. MCP and native-session OIDC are implemented; website rollout and final delivery review remain in progress under the [implementation plan](../../docs/architecture/core-payload-implementation-plan.md).
4
+
5
+ The current test baseline is Payload and `@payloadcms/plugin-mcp` **3.93.0**. No broad v3 or v4 compatibility is declared. The upstream plugin currently resolves `mcp-handler` 1.1.0 with SDK 1.30.0 despite that handler declaring SDK 1.26.0 as its exact peer; the workspace explicitly acknowledges this one tested combination using pnpm.peerDependencyRules.allowedVersions. Strict-peer pnpm consumers must apply the same narrow rule. This does not permit any other SDK version. Do not infer compatibility with other versions from these fixture results.
6
+
7
+ ```ts
8
+ import { createXignetPayload } from '@xignet/payload/v3';
9
+
10
+ const xignet = createXignetPayload({
11
+ issuer: process.env.XIGNET_ISSUER!,
12
+ jwksUri: process.env.XIGNET_JWKS_URI!,
13
+ resource: 'https://cms.example.com/api/mcp',
14
+ admissionScopes: ['cms:access'],
15
+ userCollection: 'users',
16
+ resolveUser: async ({ identity, payload }) => {
17
+ // Server-side lookup by (identity.issuer, identity.subject), requiring
18
+ // an enabled local user. Do not auto-link by email or infer local roles.
19
+ const user = await resolveExistingLocalUser(payload, identity);
20
+ return user
21
+ ? { status: 'allowed', user: { ...user, collection: 'users' } }
22
+ : { status: 'denied', reason: 'unbound' };
23
+ },
24
+ mcp: {
25
+ enabled: true,
26
+ collections: { bulletins: { find: { scopes: ['cms:read'] } } },
27
+ globals: {},
28
+ },
29
+ });
30
+
31
+ // Add xignet.plugin() once to buildConfig({ plugins: [...] }).
32
+ // Mount the metadata GET route using the standard Next.js handler helper:
33
+ import { createXignetRouteHandlers } from '@xignet/payload/next';
34
+ const handlers = createXignetRouteHandlers(xignet);
35
+ export const GET = handlers.metadata;
36
+ ```
37
+
38
+ The example's `resolveExistingLocalUser` belongs to the project, not this package. A successful resolver must return a real Payload user with an ID and the configured auth collection discriminator. Resolver failures propagate; they do not become an unbound-user response.
39
+
40
+ For a resource at `/api/mcp`, mount metadata at `/.well-known/oauth-protected-resource/api/mcp`. The resource origin must match configured `serverURL`, and its path must match the Payload API route plus `/mcp`. Trust settings are fixed configuration, never derived from request Host headers or token claims. Only the MCP routes accept Xignet Bearer; no global authentication strategy or cookie/API-key fallback is installed.
41
+
42
+ MCP operations require explicit scopes. Unsupported/unlisted operations, entities and experimental tools are closed. Deletion is not supported in this prerelease. The API-key collection added by the official plugin is hidden and denied to API callers; its schema is retained even with MCP disabled, so projects must include it in migrations. `mcp.enabled: false` removes the MCP routes and returns 404 metadata.
43
+
44
+ ## Write policies
45
+
46
+ ```ts
47
+ bulletins: {
48
+ find: { scopes: ['cms:read'] },
49
+ update: { scopes: ['cms:write'] },
50
+ mutationPolicy: ({ originalDoc, data, operation, req, identity }) => {
51
+ // This project has no drafts: any write changes public API content.
52
+ return ['cms:publish'];
53
+ },
54
+ }
55
+ ```
56
+
57
+ Every enabled write requires a mutationPolicy. Return additional required scopes or `false` to refuse; scopes must also be declared in `mcp.scopesSupported` when they cannot be derived from operation policies. The policy runs before document changes and is evaluated for each update in a batch. Globals use update semantics. Project-specific draft, publish, retire and immediate-live behavior belongs here.
58
+
59
+ The adapter preserves the original Access return value, including Where document filters, after enforcing scopes. Field access remains Payload's responsibility. Official content tools use `overrideAccess: false`. Verified request markers survive Local API context copies but cannot be forged by JSON. These controls apply to `payloadAPI: 'MCP'`; existing public REST/GraphQL and trusted Local API behavior follow the project's existing rules.
60
+
61
+ ## Validation
62
+
63
+ `pnpm --filter @xignet/payload test` initializes a temporary SQLite Payload database and sends requests through the actual official MCP handler. It verifies Bearer admission, token claims, local binding/disablement, scope-based tool lists, document/field access, user isolation, forged-context rejection, publication scopes, public access preservation and resolver outage propagation. Fixtures disable automatic types/import-map generation and release upstream MCP cleanup timers; they do not force-exit the test process.
64
+
65
+
66
+ ## Native-session SSO
67
+
68
+ Set `login` on the integration:
69
+
70
+ ```ts
71
+ login: {
72
+ enabled: true,
73
+ clientId: process.env.XIGNET_WEB_CLIENT_ID!,
74
+ redirectUri: 'https://cms.example.com/auth/xignet/callback',
75
+ scopes: ['openid', 'email'],
76
+ transactionSecret: process.env.XIGNET_LOGIN_TRANSACTION_SECRET!,
77
+ successRedirect: '/admin',
78
+ session: { maxAgeSeconds: 7200, absoluteMaxAgeSeconds: 7200 },
79
+ }
80
+ ```
81
+
82
+ Use a dedicated public web client with fixed callback and S256 PKCE, separate from MCP clients. The callback must share the configured CMS origin; redirects remain on that origin. `transactionSecret` must contain at least 32 random bytes. The adapter encrypts/authenticates the entire transaction in a short-lived, host-only HttpOnly SameSite=Lax cookie (Secure on HTTPS). It clears the cookie on completion or failure. Access/ID tokens and refresh credentials are not returned to application JavaScript or stored as browser sessions.
83
+
84
+ ```ts
85
+ const handlers = createXignetRouteHandlers(xignet, {
86
+ getPayload: async () => getPayload({ config }),
87
+ });
88
+ // Export GET = handlers.login at /auth/xignet/login.
89
+ // Export GET = handlers.callback at /auth/xignet/callback.
90
+ // Export GET = handlers.metadata at the resource's well-known path.
91
+ ```
92
+
93
+ Provide the app's actual Payload instance lazily. SSO-enabled route factories require this provider. SSO requires the native local auth strategy and `useSessions`; MCP-only consumers may use other auth models. The adapter invokes beforeLogin/afterLogin hooks and composes existing refresh hooks. These HTTP login routes return redirects or generic error responses with no token body.
94
+
95
+ The plugin adds the private `xignet-sessions` collection even while login is disabled. Include it and the official MCP key collection in database migrations. It stores native SID linkage, absolute expiry and the verified access-token identity from login; it never stores the raw access/ID/refresh token. Metadata is not readable/writable through REST/MCP, including by ordinary admin accounts. The project remains responsible for approved `(issuer, subject)` bindings and local roles.
96
+
97
+ `resolveUser` receives `channel: 'session'` during subsequent SSO authentication and refresh. This is the historical verified identity stored at login, not a newly verified OAuth token: its OAuth expiry may have elapsed. Resolve current local binding/enablement and roles without treating that historical expiry as the browser session expiry. No Xignet network call is required on each native session request.
98
+
99
+ Native JWTs remain signed by the project's Payload secret and reference a real SID. Refresh cannot cross the stored absolute deadline. Missing/expired metadata and disabled/unbound users observed during authentication invalidate the native SID persistently. For immediate revocation, projects must call revokeUserSessions with the update request from their binding/enablement change hook; this also prevents a disable/re-enable sequence with no intervening request from reviving an old cookie. `integration.revokeUserSessions({ payload, userId, req? })` explicitly removes that user's SSO sessions while retaining independent local sessions. Native logout cleans the current metadata entry. Global Xignet logout is not an automatic CMS logout.
100
+
101
+ Login failure rolls back SID/metadata when the adapter supports transactions, or explicitly removes the newly created SID/metadata otherwise. Arbitrary external side effects in custom hooks cannot be rolled back by this package. Existing local-password login retains its native expiration and has no SSO marker.
102
+
103
+ ## Auditing and release
104
+
105
+ The adapter logs structured MCP operation/transport outcomes with a generated request ID and verified issuer/subject/client/local user, without content bodies or credentials. SSO refusals, service failures and revocation have structured events; unexpected exception messages are not copied into HTTP error responses or logs.
106
+
107
+ Publish core first, then payload under `payload-vX.Y.Z` using `payload-publish.yml`. Configure npm trusted publishing for both new package names before tagging. Only the exact peer baseline above is supported in this prerelease; Payload's Node requirement is `^22.23.4 || >=24.22.1`; a server runtime is required. Adapter CI verifies Node 22.23.4, 24 and 26.13.1. Core independently supports Node 22.22.0+. The package's Next helpers use standard Request/Response and have no Next.js runtime import.
108
+
109
+ RPC audit events use the server-generated request ID, principal, allowlisted method/tool target and outcome (success, denied, failed, partial_failure or unobserved). They never include tool arguments, response content, access tokens or exception messages. Inspection retains at most 64 KiB per message. Response bytes are forwarded as the consumer reads them, with terminal audit on EOF, error or cancellation; an open stream does not block delivery. An oversized or unreadable request is recorded as unobserved. Official tool response hooks also report execution failures and partial bulk results, even when the transport returns HTTP 200.
110
+
111
+ The database adapter must provide read-after-write consistency for login session records. Before issuing the browser cookie, the callback confirms the native SID and private metadata with fresh reads outside the completed transaction, since some adapter commit helpers can suppress an underlying transaction failure. A missing record refuses login instead of issuing an unusable cookie.
112
+
113
+ For staged rollout, configure the final collection/global policies once and set `mcp.writesEnabled: false` to hide and deny create/update while retaining the same schema. Set it to true after write/publish validation; omission preserves the default of enabling configured operations. `mcp.enabled` and `login.enabled` also retain their private collections when disabled.
@@ -0,0 +1,7 @@
1
+ import { type Payload, type TypedUser } from 'payload';
2
+ import { type createNativeSessions } from './native-session.js';
3
+ import type { XignetPayloadOptions } from './types.js';
4
+ export declare function createLogin<TUser extends TypedUser>(options: XignetPayloadOptions<TUser>, sessions: ReturnType<typeof createNativeSessions<TUser>>): {
5
+ login(request: Request): Promise<Response>;
6
+ callback(request: Request, payload: Payload): Promise<Response>;
7
+ };
package/dist/login.js ADDED
@@ -0,0 +1,312 @@
1
+ import { hkdfSync } from 'node:crypto';
2
+ import { createOidcClient, LoginDiscoveryError, LoginTransportError, OidcError, } from '@xignet/core/oidc';
3
+ import { trustedUrl } from '@xignet/core';
4
+ import { EncryptJWT, errors, jwtDecrypt } from 'jose';
5
+ import { APIError, commitTransaction, createLocalReq, getFieldsToSign, initTransaction, jwtSign, } from 'payload';
6
+ import { addSessionToUser, generatePayloadCookie } from 'payload/shared';
7
+ import { SESSION_CLAIM, SESSION_COLLECTION, } from './native-session.js';
8
+ class LoginTransactionInterrupted extends Error {
9
+ constructor() {
10
+ super('Owned login transaction was interrupted by a nested operation');
11
+ this.name = 'LoginTransactionInterrupted';
12
+ }
13
+ }
14
+ export function createLogin(options, sessions) {
15
+ const login = options.login;
16
+ const resource = trustedUrl(options.resource, options.allowInsecureLoopback);
17
+ const redirect = login
18
+ ? trustedUrl(login.redirectUri, options.allowInsecureLoopback)
19
+ : undefined;
20
+ if (redirect && redirect.origin !== resource.origin)
21
+ throw new Error('SSO callback must share the CMS resource origin');
22
+ const success = new URL(login?.successRedirect ?? '/admin', resource.origin);
23
+ if (success.origin !== resource.origin ||
24
+ (login?.enabled && !login.successRedirect.startsWith('/')))
25
+ throw new Error('SSO successRedirect must be a local absolute path');
26
+ let key;
27
+ if (login?.enabled) {
28
+ if (Buffer.byteLength(login.transactionSecret, 'utf8') < 32)
29
+ throw new Error('SSO transactionSecret must be at least 32 random bytes');
30
+ key = new Uint8Array(hkdfSync('sha256', login.transactionSecret, new Uint8Array(), '@xignet/payload/v3 transaction', 32));
31
+ }
32
+ const oidc = login?.enabled
33
+ ? createOidcClient({
34
+ ...options,
35
+ audience: options.resource,
36
+ clientId: login.clientId,
37
+ redirectUri: login.redirectUri,
38
+ scopes: login.scopes,
39
+ admissionScopes: options.admissionScopes,
40
+ })
41
+ : undefined;
42
+ const cookieName = `xignet_login.${options.userCollection}`;
43
+ const transactionCookie = (value, seconds) => `${cookieName}=${value}; Path=${redirect.pathname}; Max-Age=${seconds}; HttpOnly; SameSite=Lax${redirect.protocol === 'https:' ? '; Secure' : ''}`;
44
+ return {
45
+ async login(request) {
46
+ if (!oidc || !key)
47
+ return new Response(null, { status: 404 });
48
+ if (request.method !== 'GET')
49
+ return new Response(null, { status: 405, headers: { allow: 'GET' } });
50
+ const begin = await oidc.begin();
51
+ const sealed = await new EncryptJWT({ transaction: begin.transaction })
52
+ .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
53
+ .setIssuer(resource.origin)
54
+ .setAudience(login.redirectUri)
55
+ .setIssuedAt()
56
+ .setExpirationTime('600s')
57
+ .encrypt(key);
58
+ return new Response(null, {
59
+ status: 303,
60
+ headers: {
61
+ location: begin.authorizationUrl,
62
+ 'set-cookie': transactionCookie(sealed, 600),
63
+ 'cache-control': 'no-store',
64
+ 'referrer-policy': 'no-referrer',
65
+ },
66
+ });
67
+ },
68
+ async callback(request, payload) {
69
+ if (!oidc || !key)
70
+ return new Response(null, { status: 404 });
71
+ if (request.method !== 'GET')
72
+ return new Response(null, { status: 405, headers: { allow: 'GET' } });
73
+ if (!payload.config.serverURL ||
74
+ new URL(payload.config.serverURL).origin !== resource.origin)
75
+ throw new Error('SSO Payload provider has the wrong serverURL');
76
+ const headers = new Headers({
77
+ 'set-cookie': transactionCookie('', 0),
78
+ 'cache-control': 'no-store',
79
+ 'referrer-policy': 'no-referrer',
80
+ });
81
+ const req = await createLocalReq({ req: request.clone() }, payload);
82
+ req.payloadAPI = 'REST';
83
+ let ownTransaction = false;
84
+ let ownedTransactionID;
85
+ let transactionCompleted = false;
86
+ let createdSession;
87
+ try {
88
+ const values = (request.headers.get('cookie') ?? '')
89
+ .split(';')
90
+ .map((pair) => pair.trim())
91
+ .filter((pair) => pair.startsWith(`${cookieName}=`));
92
+ if (values.length !== 1)
93
+ throw new OidcError('state_invalid');
94
+ const decrypted = await jwtDecrypt(values[0].slice(cookieName.length + 1), key, {
95
+ keyManagementAlgorithms: ['dir'],
96
+ contentEncryptionAlgorithms: ['A256GCM'],
97
+ issuer: resource.origin,
98
+ audience: login.redirectUri,
99
+ requiredClaims: ['exp'],
100
+ });
101
+ const result = await oidc.complete({
102
+ callbackUrl: request.url,
103
+ transaction: decrypted.payload
104
+ .transaction,
105
+ });
106
+ const resolved = await options.resolveUser({
107
+ identity: result.identity,
108
+ channel: 'login',
109
+ payload,
110
+ login: { idTokenClaims: result.idTokenClaims },
111
+ });
112
+ if (resolved.status !== 'allowed' ||
113
+ resolved.user.collection !== options.userCollection)
114
+ throw new APIError('SSO access denied', 403);
115
+ ownTransaction = await initTransaction(req);
116
+ if (ownTransaction)
117
+ ownedTransactionID = await req.transactionID;
118
+ const collectionConfig = payload.collections[options.userCollection].config;
119
+ let user = (await payload.db.findOne({
120
+ collection: options.userCollection,
121
+ req,
122
+ where: { id: { equals: resolved.user.id } },
123
+ }));
124
+ if (!user ||
125
+ (collectionConfig.auth.verify && user._verified !== true) ||
126
+ (user.lockUntil && Date.parse(user.lockUntil) > Date.now()))
127
+ throw new APIError('SSO access denied', 403);
128
+ user.collection = options.userCollection;
129
+ for (const hook of collectionConfig.hooks.beforeLogin)
130
+ user =
131
+ (await hook({
132
+ collection: collectionConfig,
133
+ context: req.context,
134
+ req,
135
+ user,
136
+ })) || user;
137
+ if (user.id !== resolved.user.id)
138
+ throw new APIError('SSO user mismatch', 403);
139
+ const ttl = Math.min(sessions.maxAge, sessions.absoluteMaxAge);
140
+ const { sid } = await addSessionToUser({
141
+ collectionConfig: {
142
+ ...collectionConfig,
143
+ auth: { ...collectionConfig.auth, tokenExpiration: ttl },
144
+ },
145
+ payload,
146
+ req,
147
+ user,
148
+ });
149
+ if (!sid)
150
+ throw new Error('Native Payload SID was not created');
151
+ createdSession = { sid, userId: user.id };
152
+ const signed = await jwtSign({
153
+ fieldsToSign: {
154
+ ...getFieldsToSign({
155
+ collectionConfig,
156
+ user,
157
+ email: user.email ?? '',
158
+ sid,
159
+ }),
160
+ [SESSION_CLAIM]: true,
161
+ },
162
+ secret: payload.secret,
163
+ tokenExpiration: ttl,
164
+ });
165
+ await payload.create({
166
+ collection: SESSION_COLLECTION,
167
+ req,
168
+ overrideAccess: true,
169
+ data: {
170
+ sid,
171
+ resource: options.resource,
172
+ user: user.id,
173
+ absoluteExpiresAt: new Date(Date.now() + sessions.absoluteMaxAge * 1000).toISOString(),
174
+ identity: result.identity,
175
+ },
176
+ });
177
+ user._sid = sid;
178
+ user._strategy = 'local-jwt';
179
+ req.user = user;
180
+ for (const hook of collectionConfig.hooks.afterLogin) {
181
+ user =
182
+ (await hook({
183
+ collection: collectionConfig,
184
+ context: req.context,
185
+ req,
186
+ user,
187
+ token: signed.token,
188
+ })) || user;
189
+ if (user.id !== resolved.user.id)
190
+ throw new APIError('SSO user mismatch', 403);
191
+ }
192
+ // Error responses may only clear the transaction cookie, including
193
+ // when committing the database transaction fails after signing.
194
+ const successHeaders = new Headers(headers);
195
+ successHeaders.append('set-cookie', generatePayloadCookie({
196
+ collectionAuthConfig: {
197
+ ...collectionConfig.auth,
198
+ tokenExpiration: ttl,
199
+ },
200
+ cookiePrefix: payload.config.cookiePrefix,
201
+ token: signed.token,
202
+ }));
203
+ successHeaders.set('location', success.toString());
204
+ const response = new Response(null, {
205
+ status: 303,
206
+ headers: successHeaders,
207
+ });
208
+ if (ownTransaction) {
209
+ if (req.transactionID !== ownedTransactionID)
210
+ throw new LoginTransactionInterrupted();
211
+ await commitTransaction(req);
212
+ transactionCompleted = true;
213
+ }
214
+ // Some adapters fulfill commit even if the underlying transaction
215
+ // rolled back. Fresh reads outside req's transaction must confirm
216
+ // both records before the browser receives its authentication cookie.
217
+ const [persistedMetadata, persistedUser] = await Promise.all([
218
+ payload.db.findOne({
219
+ collection: SESSION_COLLECTION,
220
+ where: {
221
+ and: [
222
+ { sid: { equals: sid } },
223
+ { resource: { equals: options.resource } },
224
+ { user: { equals: user.id } },
225
+ ],
226
+ },
227
+ }),
228
+ payload.db.findOne({
229
+ collection: options.userCollection,
230
+ where: { id: { equals: user.id } },
231
+ }),
232
+ ]);
233
+ if (!persistedMetadata ||
234
+ !persistedUser?.sessions?.some((session) => session.id === sid))
235
+ throw new APIError('SSO session persistence failed', 503);
236
+ return response;
237
+ }
238
+ catch (error) {
239
+ try {
240
+ if (ownTransaction && !transactionCompleted) {
241
+ // Payload's killTransaction swallows adapter rollback errors.
242
+ // This callback owns the transaction and must observe failures.
243
+ try {
244
+ const interrupted = req.transactionID !== ownedTransactionID;
245
+ await payload.db.rollbackTransaction(ownedTransactionID);
246
+ // Nested Local API operations may call killTransaction first,
247
+ // suppress rollback errors and remove req.transactionID. The
248
+ // adapter cannot establish cleanup success in that case.
249
+ if (interrupted)
250
+ throw new LoginTransactionInterrupted();
251
+ }
252
+ finally {
253
+ delete req.transactionID;
254
+ }
255
+ }
256
+ else if (createdSession)
257
+ await sessions.revokeSession(req, createdSession.userId, createdSession.sid);
258
+ }
259
+ catch (cleanupError) {
260
+ payload.logger.error({
261
+ event: 'xignet_login_cleanup_failed',
262
+ category: cleanupError instanceof Error ? cleanupError.name : 'unknown',
263
+ });
264
+ return Response.json({ error: 'login_failed' }, { status: 503, headers });
265
+ }
266
+ if (error instanceof LoginDiscoveryError ||
267
+ error instanceof LoginTransportError) {
268
+ payload.logger.warn({
269
+ event: 'xignet_login_unavailable',
270
+ category: error.name,
271
+ detail: error.detail,
272
+ });
273
+ return Response.json({ error: 'verification_unavailable' }, { status: 503, headers });
274
+ }
275
+ if (error instanceof OidcError ||
276
+ error instanceof errors.JOSEError ||
277
+ error instanceof APIError) {
278
+ const status = error instanceof OidcError &&
279
+ error.reason === 'verification_unavailable'
280
+ ? 503
281
+ : error instanceof APIError
282
+ ? error.status
283
+ : 401;
284
+ payload.logger.warn({
285
+ event: 'xignet_login_refused',
286
+ category: error.name,
287
+ ...(error instanceof OidcError
288
+ ? { reason: error.reason, detail: error.detail }
289
+ : {}),
290
+ });
291
+ return Response.json({
292
+ error: status === 503 ? 'verification_unavailable' : 'login_failed',
293
+ }, { status, headers });
294
+ }
295
+ // HTTP is the outer boundary: preserve the 500, clear browser state,
296
+ // and log stack locations without an arbitrary exception message.
297
+ const header = error instanceof Error
298
+ ? `${error.name}: ${error.message}`
299
+ : undefined;
300
+ const frames = error instanceof Error && header && error.stack?.startsWith(header)
301
+ ? error.stack.slice(header.length)
302
+ : undefined;
303
+ payload.logger.error({
304
+ event: 'xignet_login_failed',
305
+ category: error instanceof Error ? error.name : 'unknown',
306
+ stack: frames,
307
+ });
308
+ return Response.json({ error: 'login_failed' }, { status: 500, headers });
309
+ }
310
+ },
311
+ };
312
+ }
@@ -0,0 +1,26 @@
1
+ import { type VerifiedIdentity } from '@xignet/core';
2
+ import { type CollectionConfig, type Payload, type PayloadRequest, type TypedUser } from 'payload';
3
+ import type { XignetPayloadOptions } from './types.js';
4
+ export declare const SESSION_COLLECTION = "xignet-sessions";
5
+ export declare const SESSION_CLAIM = "__xignet_sso";
6
+ export type UserID = string | number;
7
+ export type SessionRecord = {
8
+ id: UserID;
9
+ sid: string;
10
+ resource: string;
11
+ user: UserID;
12
+ absoluteExpiresAt: string;
13
+ identity: VerifiedIdentity;
14
+ };
15
+ export declare function createNativeSessions<TUser extends TypedUser>(options: XignetPayloadOptions<TUser>): {
16
+ collection: CollectionConfig;
17
+ revokeSession: (req: PayloadRequest, userId: UserID, sid: string) => Promise<void>;
18
+ enhanceUser: (config: CollectionConfig) => CollectionConfig;
19
+ revokeUserSessions: ({ payload, userId, req: existingReq, }: {
20
+ payload: Payload;
21
+ userId: UserID;
22
+ req?: PayloadRequest;
23
+ }) => Promise<void>;
24
+ maxAge: number;
25
+ absoluteMaxAge: number;
26
+ };