@nitrogenbuilder/connector-payload 0.1.44 → 0.1.51

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.
@@ -0,0 +1 @@
1
+ export declare const NitrogenAgentCredential: import("react").ComponentType<import("./NitrogenAgentCredentialRuntime.js").NitrogenAgentCredentialRuntimeProps>;
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+ import dynamic from 'next/dynamic.js';
3
+ export const NitrogenAgentCredential = dynamic(() => import('./NitrogenAgentCredentialRuntime.js').then((module) => module.NitrogenAgentCredentialRuntime), {
4
+ loading: () => null,
5
+ ssr: false,
6
+ });
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ export interface NitrogenAgentCredentialRuntimeProps {
3
+ /** Base URL of the hosted Nitrogen app; the MCP endpoint is `${instanceUrl}/mcp`. */
4
+ instanceUrl?: string;
5
+ /** Connector API base advertised via the `x-nitrogen-api-url` header. */
6
+ apiUrl?: string;
7
+ /** Payload forwards arbitrary field props; tolerate them. */
8
+ [key: string]: unknown;
9
+ }
10
+ export declare const NitrogenAgentCredentialRuntime: React.FC<NitrogenAgentCredentialRuntimeProps>;
@@ -0,0 +1,136 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useState } from 'react';
4
+ import { Button, useAuth, useDocumentInfo } from '@payloadcms/ui';
5
+ const DEFAULT_INSTANCE_URL = 'https://app.nitrogenbuilder.com';
6
+ const DEFAULT_API_URL = '/api/nitrogen/v1';
7
+ const TOKEN_ENDPOINT = '/api/nitrogen/v1/agent/token';
8
+ function formatCreated(created) {
9
+ if (!created)
10
+ return '';
11
+ const date = new Date(created * 1000);
12
+ if (Number.isNaN(date.getTime()))
13
+ return '';
14
+ return date.toLocaleString();
15
+ }
16
+ const rowStyle = {
17
+ borderBottom: '1px solid var(--theme-elevation-150)',
18
+ };
19
+ const cellStyle = {
20
+ padding: '6px 10px',
21
+ verticalAlign: 'top',
22
+ textAlign: 'left',
23
+ };
24
+ const codeStyle = {
25
+ fontFamily: 'var(--font-mono, monospace)',
26
+ fontSize: 13,
27
+ wordBreak: 'break-all',
28
+ };
29
+ export const NitrogenAgentCredentialRuntime = ({ instanceUrl, apiUrl }) => {
30
+ const { user } = useAuth();
31
+ const { id: docId } = useDocumentInfo();
32
+ // The credential is personal: each user mints/revokes only their own. The
33
+ // /agent/token endpoints always act on the logged-in user, so the controls
34
+ // are meaningful only on your OWN account. On another user's edit screen
35
+ // (or an unsaved new user) render nothing. Mirrors the WP profile section,
36
+ // which hides the controls for everyone but the profile owner.
37
+ const isSelf = !!user && docId != null && String(user.id) === String(docId);
38
+ const [status, setStatus] = useState(null);
39
+ const [plaintext, setPlaintext] = useState(null);
40
+ const [busy, setBusy] = useState(false);
41
+ const [error, setError] = useState(null);
42
+ const resolvedInstanceUrl = (instanceUrl || DEFAULT_INSTANCE_URL).replace(/\/$/, '');
43
+ const mcpEndpoint = `${resolvedInstanceUrl}/mcp`;
44
+ const resolvedApiUrl = apiUrl || DEFAULT_API_URL;
45
+ useEffect(() => {
46
+ if (!isSelf)
47
+ return;
48
+ let cancelled = false;
49
+ fetch(TOKEN_ENDPOINT, { credentials: 'same-origin' })
50
+ .then((res) => res.json())
51
+ .then((data) => {
52
+ if (!cancelled) {
53
+ setStatus({
54
+ exists: Boolean(data?.exists),
55
+ created: Number(data?.created || 0),
56
+ });
57
+ }
58
+ })
59
+ .catch(() => {
60
+ if (!cancelled)
61
+ setError('Could not load credential status.');
62
+ });
63
+ return () => {
64
+ cancelled = true;
65
+ };
66
+ }, [isSelf]);
67
+ // Hide the whole section unless viewing your own account.
68
+ if (!isSelf)
69
+ return null;
70
+ const generate = () => {
71
+ if (!window.confirm('Generate a new credential? Any existing one will stop working.')) {
72
+ return;
73
+ }
74
+ setBusy(true);
75
+ setError(null);
76
+ fetch(TOKEN_ENDPOINT, {
77
+ method: 'POST',
78
+ credentials: 'same-origin',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ })
81
+ .then((res) => res.json())
82
+ .then((data) => {
83
+ setBusy(false);
84
+ if (data && data.token) {
85
+ setPlaintext(data.token);
86
+ setStatus({
87
+ exists: true,
88
+ created: Number(data.created || Math.floor(Date.now() / 1000)),
89
+ });
90
+ }
91
+ else {
92
+ setError('Could not generate a credential.');
93
+ }
94
+ })
95
+ .catch(() => {
96
+ setBusy(false);
97
+ setError('Could not generate a credential.');
98
+ });
99
+ };
100
+ const revoke = () => {
101
+ if (!window.confirm('Revoke your agent credential?')) {
102
+ return;
103
+ }
104
+ setBusy(true);
105
+ setError(null);
106
+ fetch(TOKEN_ENDPOINT, {
107
+ method: 'DELETE',
108
+ credentials: 'same-origin',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ })
111
+ .then(() => {
112
+ setBusy(false);
113
+ setPlaintext(null);
114
+ setStatus({ exists: false, created: 0 });
115
+ })
116
+ .catch(() => {
117
+ setBusy(false);
118
+ setError('Could not revoke the credential.');
119
+ });
120
+ };
121
+ const hasToken = Boolean(status?.exists);
122
+ const createdLabel = status ? formatCreated(status.created) : '';
123
+ return (_jsxs("div", { style: { color: 'var(--theme-text)', maxWidth: 640 }, children: [_jsx("h3", { style: { marginTop: 0 }, children: "Nitrogen AI Agent Access" }), _jsx("p", { style: { color: 'var(--theme-elevation-600)' }, children: "Connect your AI agent (e.g. Claude) to drive the Nitrogen editor as you. The agent can only ever edit on your behalf \u2014 never as another user. The credential is shown once; if you lose it, generate a new one." }), status === null ? (_jsx("p", { style: { color: 'var(--theme-elevation-600)' }, children: "Loading\u2026" })) : (_jsx("p", { children: hasToken
124
+ ? `A credential is active${createdLabel ? ` (generated ${createdLabel})` : ''}.`
125
+ : 'No credential generated yet.' })), _jsxs("div", { style: { display: 'flex', gap: 8, margin: '12px 0' }, children: [_jsx(Button, { buttonStyle: "primary", margin: false, disabled: busy || status === null, onClick: generate, children: hasToken ? 'Regenerate Credential' : 'Generate Credential' }), hasToken && (_jsx(Button, { buttonStyle: "secondary", margin: false, disabled: busy, onClick: revoke, children: "Revoke" }))] }), error && (_jsx("p", { style: { color: 'var(--theme-error-500, #c00)' }, children: error })), plaintext && (_jsxs("div", { style: { margin: '8px 0' }, children: [_jsx("p", { style: { margin: '0 0 4px' }, children: _jsx("strong", { children: "Copy this now \u2014 it will not be shown again:" }) }), _jsx("input", { type: "text", readOnly: true, value: plaintext, onClick: (e) => e.currentTarget.select(), style: {
126
+ width: '100%',
127
+ padding: '6px 8px',
128
+ fontFamily: 'var(--font-mono, monospace)',
129
+ fontSize: 13,
130
+ } })] })), _jsx("table", { style: {
131
+ width: '100%',
132
+ borderCollapse: 'collapse',
133
+ marginTop: 16,
134
+ border: '1px solid var(--theme-elevation-150)',
135
+ }, children: _jsxs("tbody", { children: [_jsxs("tr", { style: rowStyle, children: [_jsx("td", { style: cellStyle, children: _jsx("strong", { children: "MCP endpoint" }) }), _jsx("td", { style: { ...cellStyle, ...codeStyle }, children: mcpEndpoint })] }), _jsxs("tr", { style: rowStyle, children: [_jsx("td", { style: { ...cellStyle, ...codeStyle }, children: "x-nitrogen-api-url" }), _jsx("td", { style: { ...cellStyle, ...codeStyle }, children: resolvedApiUrl })] }), _jsxs("tr", { children: [_jsx("td", { style: { ...cellStyle, ...codeStyle }, children: "Authorization" }), _jsx("td", { style: { ...cellStyle, ...codeStyle }, children: "Bearer <your credential>" })] })] }) })] }));
136
+ };
@@ -2,7 +2,6 @@ interface NitrogenEditorSearchParams {
2
2
  pageId?: string;
3
3
  collection?: string;
4
4
  development?: string;
5
- editorDevelopment?: string;
6
5
  }
7
6
  /**
8
7
  * Creates a standalone editor page that loads the Nitrogen builder.
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { getPayload } from "payload";
3
3
  import { headers } from "next/headers";
4
4
  import { redirect } from "next/navigation";
@@ -28,9 +28,6 @@ function getSiteUrl(settings, isDevelopment) {
28
28
  : settings.frontendDevUrl || settings.developmentUrl;
29
29
  return preferredUrl || fallbackUrl || "";
30
30
  }
31
- function getNitrogenEditorDevUrl(settings) {
32
- return settings.nitrogenEditorDevUrl || settings.instanceUrl || "";
33
- }
34
31
  /**
35
32
  * Creates a standalone editor page that loads the Nitrogen builder.
36
33
  *
@@ -71,6 +68,9 @@ export function createNitrogenEditorPage(config) {
71
68
  type: "payload",
72
69
  apiUrl: getNitrogenApiUrl(isPreviewDevelopment),
73
70
  collection: params.collection,
71
+ // Shown as a "Sign in" button in the editor when the CMS session has
72
+ // expired (e.g. a save fails with 401/403).
73
+ loginUrl: "/admin/login",
74
74
  },
75
75
  siteUrl: siteUrl || "",
76
76
  urlMaps: nitrogenConfig.urlMaps || [],
@@ -78,31 +78,20 @@ export function createNitrogenEditorPage(config) {
78
78
  cssInjection: nitrogenConfig.cssInjection || "",
79
79
  };
80
80
  const editId = params.pageId;
81
- // Preserve the old localhost flow: `development=true` should still imply
82
- // local editor HMR when the editor page itself is served from localhost.
83
- // Deployed hosts stay on the built editor unless explicitly opted in.
84
- const requestHost = headersList.get("host") || "";
85
- const requestHostname = requestHost.split(":")[0];
86
- const isLocalRequestHost = requestHostname === "localhost" ||
87
- requestHostname === "127.0.0.1" ||
88
- requestHostname === "[::1]";
89
- const isEditorDevelopment = params.editorDevelopment === "true";
90
- const editorDevOrigin = getNitrogenEditorDevUrl(settings).replace(/\/$/, "");
91
- const isEditorDev = !!editorDevOrigin &&
92
- (isEditorDevelopment ||
93
- (isPreviewDevelopment && isLocalRequestHost));
94
- const editorAssetsBase = isEditorDev
95
- ? editorDevOrigin
96
- : "/nitrogen-editor/assets";
81
+ const editorAssetsBase = "/nitrogen-editor/assets";
97
82
  const editorCdnVersion = typeof settings.editorCdnVersion === "string" &&
98
83
  settings.editorCdnVersion.trim()
99
84
  ? settings.editorCdnVersion.trim()
100
85
  : DEFAULT_EDITOR_CDN_VERSION;
101
86
  const cdnBase = `https://cdn.jsdelivr.net/npm/@nitrogenbuilder/editor@${editorCdnVersion}`;
102
- return (_jsxs(_Fragment, { children: [!isEditorDev && (_jsxs(_Fragment, { children: [_jsx("link", { rel: "preconnect", href: "https://cdn.jsdelivr.net" }), _jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "" })] })), isEditorDev ? (_jsxs(_Fragment, { children: [_jsx("link", { rel: "stylesheet", crossOrigin: "", href: `${editorAssetsBase}/fa620pro/css/all.css` }), _jsx("link", { rel: "stylesheet", crossOrigin: "", href: `${editorAssetsBase}/fa620pro/css/sharp-solid.css` }), _jsx("link", { rel: "stylesheet", crossOrigin: "", href: `${editorDevOrigin}/src/index.scss` })] })) : (_jsxs(_Fragment, { children: [_jsx("link", { rel: "stylesheet", crossOrigin: "", href: `${editorAssetsBase}/fa620pro/css/all.css` }), _jsx("link", { rel: "stylesheet", crossOrigin: "", href: `${editorAssetsBase}/fa620pro/css/sharp-solid.css` }), _jsx("link", { rel: "stylesheet", crossOrigin: "", href: `${cdnBase}/index.css` })] })), _jsx("script", { dangerouslySetInnerHTML: {
87
+ return (_jsxs(_Fragment, { children: [_jsx("link", { rel: "preconnect", href: "https://cdn.jsdelivr.net" }), _jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "" }), _jsx("link", { rel: "stylesheet", crossOrigin: "", href: `${editorAssetsBase}/fa620pro/css/all.css` }), _jsx("link", { rel: "stylesheet", crossOrigin: "", href: `${editorAssetsBase}/fa620pro/css/sharp-solid.css` }), _jsx("link", { rel: "stylesheet", crossOrigin: "", href: `${cdnBase}/index.css` }), _jsx("script", { dangerouslySetInnerHTML: {
103
88
  __html: [
104
89
  `window.nitrogenConfig = ${JSON.stringify(builderConfig)};`,
105
90
  `window.nitrogenEditId = "${editId}";`,
91
+ // Identity injected as a global (not via URL) so a copied edit URL
92
+ // doesn't carry the original author's id/email. The editor reads
93
+ // window.nitrogenUser, falling back to the authorId/author params below.
94
+ `window.nitrogenUser = ${JSON.stringify({ id: String(user.id), email: user.email })};`,
106
95
  // Keep both legacy and current author params present for editor builds.
107
96
  `(function(){var u=new URL(window.location.href),a=${JSON.stringify(String(user.id))},changed=false;if(!u.searchParams.has("authorId")){u.searchParams.set("authorId",a);changed=true}if(!u.searchParams.has("author")){u.searchParams.set("author",a);changed=true}if(changed){window.history.replaceState(null,"",u.toString())}})();`,
108
97
  ].join(""),
@@ -114,18 +103,12 @@ export function createNitrogenEditorPage(config) {
114
103
  alignItems: "center",
115
104
  justifyContent: "center",
116
105
  backgroundColor: "#0F1214",
117
- }, children: isEditorDev ? (_jsx("img", { src: `${editorAssetsBase}/logo-white.svg`, alt: "Loading\u2026", style: {
118
- width: "24rem",
119
- maxWidth: "100%",
120
- animation: "nitrogen-loader-pulse 3s ease-in-out infinite",
121
- } })) : (_jsx("img", { src: `${editorAssetsBase}/logo-white.svg`, alt: "Loading\u2026", style: {
106
+ }, children: _jsx("img", { src: `${editorAssetsBase}/logo-white.svg`, alt: "Loading\u2026", style: {
122
107
  width: "24rem",
123
108
  maxWidth: "100%",
124
109
  animation: "nitrogen-loader-pulse 3s ease-in-out infinite",
125
- } })) }), _jsx("style", { dangerouslySetInnerHTML: {
110
+ } }) }), _jsx("style", { dangerouslySetInnerHTML: {
126
111
  __html: `@keyframes nitrogen-loader-pulse{0%{transform:scale(1);opacity:0}50%{opacity:1}100%{transform:scale(1.05);opacity:0}}`,
127
- } })] }), isEditorDev ? (_jsxs(_Fragment, { children: [_jsx("script", { type: "module", dangerouslySetInnerHTML: {
128
- __html: `import RefreshRuntime from "${editorDevOrigin}/@react-refresh";RefreshRuntime.injectIntoGlobalHook(window);window.$RefreshReg$ = () => {};window.$RefreshSig$ = () => (type) => type;window.__vite_plugin_react_preamble_installed__ = true;`,
129
- } }), _jsx("script", { type: "module", src: `${editorDevOrigin}/@vite/client` }), _jsx("script", { type: "module", crossOrigin: "", src: `${editorDevOrigin}/src/main.tsx` })] })) : (_jsx("script", { type: "module", crossOrigin: "", src: `${cdnBase}/index.js` }))] }));
112
+ } })] }), _jsx("script", { type: "module", crossOrigin: "", src: `${cdnBase}/index.js` })] }));
130
113
  };
131
114
  }
@@ -0,0 +1,31 @@
1
+ import type { Endpoint } from 'payload';
2
+ /**
3
+ * AI-agent (MCP) authentication for the bundled Nitrogen MCP server.
4
+ *
5
+ * Port of the WordPress connector's `Agent_Auth` (includes/agent-auth.php). The
6
+ * cloud MCP server never trusts its caller's claimed identity; for each MCP
7
+ * session it POSTs the user's agent credential to `/agent/verify` and binds the
8
+ * session to the verified `{ userId, host }`.
9
+ *
10
+ * Credential format: `<userId>:<secret>` (Bearer). We persist only a salted
11
+ * scrypt hash of `<secret>` on the user, looked up by `<userId>` — so a single
12
+ * hash check verifies it without scanning every user. Each editor mints their
13
+ * own from their profile screen; the plaintext is shown exactly once.
14
+ */
15
+ /** Generate a url-safe credential secret. */
16
+ export declare function generateSecret(): Promise<string>;
17
+ /**
18
+ * Hash a secret with a random salt using scrypt. Returns `salt:hash` (both hex).
19
+ */
20
+ export declare function hashSecret(secret: string): Promise<string>;
21
+ /**
22
+ * Verify a secret against a stored `salt:hash`. Constant-time comparison.
23
+ * Returns false on any malformed input; never throws.
24
+ */
25
+ export declare function verifySecret(secret: string, stored: string): Promise<boolean>;
26
+ /**
27
+ * Build the four agent-auth endpoints bound to a given user collection slug.
28
+ * The user collection must have fields `nitrogenAgentTokenHash` (text) and
29
+ * `nitrogenAgentTokenCreated` (number).
30
+ */
31
+ export declare function createAgentAuthEndpoints(userCollection: string): Endpoint[];
@@ -0,0 +1,210 @@
1
+ import { getNitrogenSettings, requireAuth } from './helpers.js';
2
+ // Load node's crypto lazily via dynamic import so this module's STATIC import
3
+ // graph contains no node-builtin import. Payload's tsx-based `migrate` bin
4
+ // transpiles the config graph at load time and mishandles builtin imports once
5
+ // esbuild normalizes them to `node:crypto` (throws ENOENT on
6
+ // `node:crypto?tsx-namespace=…`). A dynamic import is left to runtime and is
7
+ // only ever evaluated inside a request handler — never during `migrate`.
8
+ async function loadCrypto() {
9
+ return import('crypto');
10
+ }
11
+ /**
12
+ * AI-agent (MCP) authentication for the bundled Nitrogen MCP server.
13
+ *
14
+ * Port of the WordPress connector's `Agent_Auth` (includes/agent-auth.php). The
15
+ * cloud MCP server never trusts its caller's claimed identity; for each MCP
16
+ * session it POSTs the user's agent credential to `/agent/verify` and binds the
17
+ * session to the verified `{ userId, host }`.
18
+ *
19
+ * Credential format: `<userId>:<secret>` (Bearer). We persist only a salted
20
+ * scrypt hash of `<secret>` on the user, looked up by `<userId>` — so a single
21
+ * hash check verifies it without scanning every user. Each editor mints their
22
+ * own from their profile screen; the plaintext is shown exactly once.
23
+ */
24
+ /** Generate a url-safe credential secret. */
25
+ export async function generateSecret() {
26
+ const { randomBytes } = await loadCrypto();
27
+ return randomBytes(32).toString('base64url');
28
+ }
29
+ /**
30
+ * Hash a secret with a random salt using scrypt. Returns `salt:hash` (both hex).
31
+ */
32
+ export async function hashSecret(secret) {
33
+ const { randomBytes, scryptSync } = await loadCrypto();
34
+ const salt = randomBytes(16);
35
+ const hash = scryptSync(secret, salt, 64);
36
+ return `${salt.toString('hex')}:${hash.toString('hex')}`;
37
+ }
38
+ /**
39
+ * Verify a secret against a stored `salt:hash`. Constant-time comparison.
40
+ * Returns false on any malformed input; never throws.
41
+ */
42
+ export async function verifySecret(secret, stored) {
43
+ try {
44
+ if (typeof secret !== 'string' || typeof stored !== 'string')
45
+ return false;
46
+ const sep = stored.indexOf(':');
47
+ if (sep <= 0)
48
+ return false;
49
+ const saltHex = stored.slice(0, sep);
50
+ const hashHex = stored.slice(sep + 1);
51
+ if (!saltHex || !hashHex)
52
+ return false;
53
+ const salt = Buffer.from(saltHex, 'hex');
54
+ const expected = Buffer.from(hashHex, 'hex');
55
+ if (salt.length === 0 || expected.length === 0)
56
+ return false;
57
+ const { scryptSync, timingSafeEqual } = await loadCrypto();
58
+ const derived = scryptSync(secret, salt, expected.length);
59
+ if (derived.length !== expected.length)
60
+ return false;
61
+ return timingSafeEqual(derived, expected);
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ /**
68
+ * Read the bearer credential from the Authorization header. Returns the raw
69
+ * `<userId>:<secret>` string, or '' if absent/malformed. Some hosts strip the
70
+ * Authorization header, so an explicit `x-nitrogen-agent-token` fallback is
71
+ * allowed.
72
+ */
73
+ function bearerCredential(req) {
74
+ const header = req.headers.get('authorization');
75
+ if (!header) {
76
+ const fallback = req.headers.get('x-nitrogen-agent-token');
77
+ return fallback ? fallback.trim() : '';
78
+ }
79
+ if (/^Bearer /i.test(header)) {
80
+ return header.slice(7).trim();
81
+ }
82
+ return '';
83
+ }
84
+ /** Hostname of the configured frontend URL, or '' if unset/unparseable. */
85
+ function agentHost(frontendUrl) {
86
+ if (!frontendUrl)
87
+ return '';
88
+ try {
89
+ return new URL(frontendUrl).host;
90
+ }
91
+ catch {
92
+ return '';
93
+ }
94
+ }
95
+ /**
96
+ * Build the four agent-auth endpoints bound to a given user collection slug.
97
+ * The user collection must have fields `nitrogenAgentTokenHash` (text) and
98
+ * `nitrogenAgentTokenCreated` (number).
99
+ */
100
+ export function createAgentAuthEndpoints(userCollection) {
101
+ return [
102
+ // POST /agent/verify — identity verification, called by the MCP server, NOT
103
+ // a browser. Does its own bearer-credential auth, so the gate is open.
104
+ // Every failure returns an identical 401 (no enumeration).
105
+ {
106
+ path: '/nitrogen/v1/agent/verify',
107
+ method: 'post',
108
+ handler: async (req) => {
109
+ const unauthorized = () => Response.json({ error: 'Invalid agent credential.' }, { status: 401 });
110
+ const credential = bearerCredential(req);
111
+ const sep = credential.indexOf(':');
112
+ if (!credential || sep < 0) {
113
+ return unauthorized();
114
+ }
115
+ const userId = credential.slice(0, sep);
116
+ const secret = credential.slice(sep + 1);
117
+ if (!userId || !secret) {
118
+ return unauthorized();
119
+ }
120
+ const user = (await req.payload.findByID({
121
+ collection: userCollection,
122
+ id: userId,
123
+ overrideAccess: true,
124
+ disableErrors: true,
125
+ }));
126
+ const storedHash = user?.nitrogenAgentTokenHash;
127
+ if (!user || !storedHash || !(await verifySecret(secret, storedHash))) {
128
+ return unauthorized();
129
+ }
130
+ const settings = await getNitrogenSettings(req.payload);
131
+ return Response.json({
132
+ userId: String(userId),
133
+ host: agentHost(settings.frontendUrl),
134
+ // License key = the websocket room token the editor connects with;
135
+ // lets the MCP server scope precisely to this user's room.
136
+ token: settings.licenseKey || '',
137
+ });
138
+ },
139
+ },
140
+ // GET /agent/token — non-secret status of the current user's credential.
141
+ {
142
+ path: '/nitrogen/v1/agent/token',
143
+ method: 'get',
144
+ handler: async (req) => {
145
+ const unauth = requireAuth(req);
146
+ if (unauth)
147
+ return unauth;
148
+ // Read fresh with overrideAccess: the hash field is access-gated
149
+ // (read: () => false) and may be stripped from req.user.
150
+ const user = (await req.payload.findByID({
151
+ collection: userCollection,
152
+ id: req.user.id,
153
+ overrideAccess: true,
154
+ disableErrors: true,
155
+ }));
156
+ return Response.json({
157
+ exists: Boolean(user?.nitrogenAgentTokenHash),
158
+ created: Number(user?.nitrogenAgentTokenCreated || 0),
159
+ });
160
+ },
161
+ },
162
+ // POST /agent/token — mint a fresh credential for the current user,
163
+ // replacing any existing one. The plaintext is returned ONCE and never
164
+ // stored; only a salted hash is persisted.
165
+ {
166
+ path: '/nitrogen/v1/agent/token',
167
+ method: 'post',
168
+ handler: async (req) => {
169
+ const unauth = requireAuth(req);
170
+ if (unauth)
171
+ return unauth;
172
+ const secret = await generateSecret();
173
+ const created = Math.floor(Date.now() / 1000);
174
+ await req.payload.update({
175
+ collection: userCollection,
176
+ id: req.user.id,
177
+ data: {
178
+ nitrogenAgentTokenHash: await hashSecret(secret),
179
+ nitrogenAgentTokenCreated: created,
180
+ },
181
+ overrideAccess: true,
182
+ });
183
+ return Response.json({
184
+ token: `${req.user.id}:${secret}`,
185
+ created,
186
+ }, { status: 201 });
187
+ },
188
+ },
189
+ // DELETE /agent/token — revoke the current user's credential.
190
+ {
191
+ path: '/nitrogen/v1/agent/token',
192
+ method: 'delete',
193
+ handler: async (req) => {
194
+ const unauth = requireAuth(req);
195
+ if (unauth)
196
+ return unauth;
197
+ await req.payload.update({
198
+ collection: userCollection,
199
+ id: req.user.id,
200
+ data: {
201
+ nitrogenAgentTokenHash: null,
202
+ nitrogenAgentTokenCreated: null,
203
+ },
204
+ overrideAccess: true,
205
+ });
206
+ return Response.json({ exists: false });
207
+ },
208
+ },
209
+ ];
210
+ }
@@ -18,6 +18,16 @@ function isTruthyParam(value) {
18
18
  function unique(values) {
19
19
  return Array.from(new Set(values));
20
20
  }
21
+ // Stable signature for a batch request — identical (endpoint, params) requests
22
+ // share one underlying query (mirrors the WP batch dedup, rest-routes.php:542-580).
23
+ // Object keys are sorted so param ordering doesn't defeat the dedup.
24
+ function requestSignature(batchReq) {
25
+ const params = batchReq.params ?? {};
26
+ const sortedParams = Object.fromEntries(Object.keys(params)
27
+ .sort()
28
+ .map((k) => [k, params[k]]));
29
+ return JSON.stringify({ endpoint: batchReq.endpoint, params: sortedParams });
30
+ }
21
31
  async function resolveRelationshipIds(payload, collection, values) {
22
32
  if (values.length === 0)
23
33
  return [];
@@ -88,7 +98,22 @@ export const batchEndpoints = [
88
98
  }
89
99
  const settings = await getNitrogenSettings(payload);
90
100
  const results = {};
91
- await Promise.all(requests.map(async (batchReq) => {
101
+ // Dedup: group identical (endpoint, params) requests and run each group's
102
+ // query once via its first ("representative") request; fan the result out
103
+ // to the duplicate keys afterward.
104
+ const groups = new Map();
105
+ for (const batchReq of requests) {
106
+ const sig = requestSignature(batchReq);
107
+ const existing = groups.get(sig);
108
+ if (existing) {
109
+ existing.push(batchReq);
110
+ }
111
+ else {
112
+ groups.set(sig, [batchReq]);
113
+ }
114
+ }
115
+ await Promise.all(Array.from(groups.values()).map(async (group) => {
116
+ const batchReq = group[0];
92
117
  const { key, endpoint, params = {} } = batchReq;
93
118
  try {
94
119
  const collectionSlug = resolveCollection(endpoint);
@@ -306,6 +331,13 @@ export const batchEndpoints = [
306
331
  };
307
332
  }
308
333
  }));
334
+ // Fan each representative's result out to the keys that shared its query.
335
+ for (const group of groups.values()) {
336
+ const representativeKey = group[0].key;
337
+ for (let i = 1; i < group.length; i++) {
338
+ results[group[i].key] = results[representativeKey];
339
+ }
340
+ }
309
341
  return Response.json(results);
310
342
  },
311
343
  },
@@ -1,4 +1,5 @@
1
- import { getNitrogenSettings, buildDynamicData, buildResolvedPageResponse, buildResolvedListItemResponse, getTemplateForType, requireAuth, } from './helpers.js';
1
+ import { getNitrogenSettings, buildDynamicData, buildResolvedPageResponse, buildResolvedListItemResponse, getTemplateForType, requireAuth, canServeDocument, } from './helpers.js';
2
+ import { resolveTemplateForDocument } from './templateConditions.js';
2
3
  /**
3
4
  * Creates a complete set of CRUD endpoints for a given Payload collection.
4
5
  *
@@ -67,13 +68,16 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
67
68
  const { payload, routeParams } = req;
68
69
  const id = routeParams?.id;
69
70
  try {
70
- const [doc, settings, headerTemplate, footerTemplate, pageTemplate] = await Promise.all([
71
+ const [doc, settings, headerTemplate, footerTemplate] = await Promise.all([
71
72
  payload.findByID({ collection, id, depth: 1 }),
72
73
  getNitrogenSettings(payload),
73
74
  getTemplateForType(payload, 'header'),
74
75
  getTemplateForType(payload, 'footer'),
75
- getTemplateForType(payload, collectionSlug),
76
76
  ]);
77
+ if (!canServeDocument(req, doc, settings)) {
78
+ return Response.json({ error: 'Not found' }, { status: 404 });
79
+ }
80
+ const pageTemplate = await resolveTemplateForDocument(payload, doc, collectionSlug, settings);
77
81
  const dynamicData = buildDynamicData(doc, settings);
78
82
  const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData, collectionSlug);
79
83
  return Response.json({
@@ -179,17 +183,20 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
179
183
  if (!slug) {
180
184
  return Response.json({ error: 'Slug is required' }, { status: 400 });
181
185
  }
182
- const [result, settings, headerTemplate, footerTemplate, pageTemplate] = await Promise.all([
186
+ const [result, settings, headerTemplate, footerTemplate] = await Promise.all([
183
187
  payload.find({ collection, where: { slug: { equals: slug } }, limit: 1, depth: 1 }),
184
188
  getNitrogenSettings(payload),
185
189
  getTemplateForType(payload, 'header'),
186
190
  getTemplateForType(payload, 'footer'),
187
- getTemplateForType(payload, collectionSlug),
188
191
  ]);
189
192
  if (!result.docs.length) {
190
193
  return Response.json({ error: 'Not found' }, { status: 404 });
191
194
  }
192
195
  const doc = result.docs[0];
196
+ if (!canServeDocument(req, doc, settings)) {
197
+ return Response.json({ error: 'Not found' }, { status: 404 });
198
+ }
199
+ const pageTemplate = await resolveTemplateForDocument(payload, doc, collectionSlug, settings);
193
200
  const dynamicData = buildDynamicData(doc, settings);
194
201
  const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData, collectionSlug);
195
202
  return Response.json({
@@ -207,17 +214,20 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
207
214
  handler: async (req) => {
208
215
  const { payload, routeParams } = req;
209
216
  const slug = routeParams?.slug;
210
- const [result, settings, headerTemplate, footerTemplate, pageTemplate] = await Promise.all([
217
+ const [result, settings, headerTemplate, footerTemplate] = await Promise.all([
211
218
  payload.find({ collection, where: { slug: { equals: slug } }, limit: 1, depth: 1 }),
212
219
  getNitrogenSettings(payload),
213
220
  getTemplateForType(payload, 'header'),
214
221
  getTemplateForType(payload, 'footer'),
215
- getTemplateForType(payload, collectionSlug),
216
222
  ]);
217
223
  if (!result.docs.length) {
218
224
  return Response.json({ error: 'Not found' }, { status: 404 });
219
225
  }
220
226
  const doc = result.docs[0];
227
+ if (!canServeDocument(req, doc, settings)) {
228
+ return Response.json({ error: 'Not found' }, { status: 404 });
229
+ }
230
+ const pageTemplate = await resolveTemplateForDocument(payload, doc, collectionSlug, settings);
221
231
  const dynamicData = buildDynamicData(doc, settings);
222
232
  const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData, collectionSlug);
223
233
  return Response.json({
@@ -141,3 +141,16 @@ export declare function getNitrogenSettings(payload: Payload): Promise<NitrogenS
141
141
  */
142
142
  export declare function getTemplateForType(payload: Payload, type: string): Promise<TemplateRef | null>;
143
143
  export declare function requireAuth(req: PayloadRequest): Response | null;
144
+ /**
145
+ * True when the request carries the connector token (the `x-nitrogen-token`
146
+ * header, or `nitrogen-token` query param for iframe URLs) matching the stored
147
+ * `connectorToken` setting. Lets the staging frontend / editor preview draft &
148
+ * private docs without a logged-in session. Mirrors WP `preview_token_valid`.
149
+ */
150
+ export declare function previewTokenValid(req: PayloadRequest, settings: NitrogenSettingsGlobal): boolean;
151
+ /**
152
+ * Whether a document may be served for a given request. Published docs are
153
+ * public; non-published (draft/pending/private) docs require either a logged-in
154
+ * user or a valid preview token. Mirrors the WP status gate in `get_item`.
155
+ */
156
+ export declare function canServeDocument(req: PayloadRequest, doc: Record<string, unknown> | null | undefined, settings: NitrogenSettingsGlobal): boolean;
@@ -231,3 +231,49 @@ export function requireAuth(req) {
231
231
  }
232
232
  return null;
233
233
  }
234
+ // Constant-time string compare in pure JS — deliberately avoids importing a
235
+ // node builtin. Payload's tsx-based `migrate` bin transpiles the config graph
236
+ // and chokes when esbuild normalizes a builtin import to `node:crypto`
237
+ // (ENOENT on `node:crypto?tsx-namespace=…`), so nothing reachable from the
238
+ // config at load time may statically import `crypto`.
239
+ function timingSafeStringEqual(a, b) {
240
+ if (a.length !== b.length)
241
+ return false;
242
+ let mismatch = 0;
243
+ for (let i = 0; i < a.length; i++) {
244
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
245
+ }
246
+ return mismatch === 0;
247
+ }
248
+ /**
249
+ * True when the request carries the connector token (the `x-nitrogen-token`
250
+ * header, or `nitrogen-token` query param for iframe URLs) matching the stored
251
+ * `connectorToken` setting. Lets the staging frontend / editor preview draft &
252
+ * private docs without a logged-in session. Mirrors WP `preview_token_valid`.
253
+ */
254
+ export function previewTokenValid(req, settings) {
255
+ const expected = settings.connectorToken;
256
+ if (!expected)
257
+ return false;
258
+ let token = req.headers?.get?.('x-nitrogen-token') || '';
259
+ if (!token) {
260
+ const url = new URL(req.url || '', 'http://localhost');
261
+ token = url.searchParams.get('nitrogen-token') || '';
262
+ }
263
+ if (!token)
264
+ return false;
265
+ return timingSafeStringEqual(String(expected), token);
266
+ }
267
+ /**
268
+ * Whether a document may be served for a given request. Published docs are
269
+ * public; non-published (draft/pending/private) docs require either a logged-in
270
+ * user or a valid preview token. Mirrors the WP status gate in `get_item`.
271
+ */
272
+ export function canServeDocument(req, doc, settings) {
273
+ if (!doc)
274
+ return false;
275
+ const status = String(doc._status ?? doc.status ?? 'published');
276
+ if (status === 'published')
277
+ return true;
278
+ return !!req.user || previewTokenValid(req, settings);
279
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Sitemap endpoints — ported from the WordPress connector
3
+ * (rest-routes.php: get_sitemap_index, get_sitemap_for_post_type,
4
+ * build_sitemap_urls_for_post_type, resolve_sitemap_locs_for_post).
5
+ *
6
+ * PORTING NOTE: in WP a single post could emit MULTIPLE `loc`s — one per
7
+ * nitrogen_template matching the post, each contributing its own `url_prefix`
8
+ * (so a post served at both canonical and prefixed URLs appeared multiple
9
+ * times). The TARGET (Payload) model has exactly one `routePattern` per
10
+ * registered collection, so we emit ONE canonical URL per document via
11
+ * `buildRelativePermalink`. Per-template prefix multiplexing is deferred.
12
+ */
13
+ import type { Endpoint } from 'payload';
14
+ export declare const sitemapEndpoints: Endpoint[];
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Sitemap endpoints — ported from the WordPress connector
3
+ * (rest-routes.php: get_sitemap_index, get_sitemap_for_post_type,
4
+ * build_sitemap_urls_for_post_type, resolve_sitemap_locs_for_post).
5
+ *
6
+ * PORTING NOTE: in WP a single post could emit MULTIPLE `loc`s — one per
7
+ * nitrogen_template matching the post, each contributing its own `url_prefix`
8
+ * (so a post served at both canonical and prefixed URLs appeared multiple
9
+ * times). The TARGET (Payload) model has exactly one `routePattern` per
10
+ * registered collection, so we emit ONE canonical URL per document via
11
+ * `buildRelativePermalink`. Per-template prefix multiplexing is deferred.
12
+ */
13
+ import { buildRelativePermalink, getRegisteredCollections, resolveCollection, } from '../collection-registry.js';
14
+ import { findAllDocs } from '../inventory/indexing.js';
15
+ /** Internal Nitrogen collections that must never appear in the sitemap. */
16
+ const EXCLUDED_COLLECTIONS = new Set([
17
+ 'nitrogen-templates',
18
+ 'nitrogen-component-catalog',
19
+ 'nitrogen-component-usage',
20
+ ]);
21
+ /**
22
+ * Fetch published docs for a collection selecting only `slug` and `updatedAt`.
23
+ *
24
+ * Payload collections may or may not have drafts enabled (the `_status`
25
+ * field). We first try filtering by `_status: 'published'`; if that throws
26
+ * (collection has no `_status`), we fall back to fetching all docs.
27
+ */
28
+ async function fetchPublishedSitemapDocs(payload, collection) {
29
+ const select = { slug: true, updatedAt: true };
30
+ try {
31
+ return await findAllDocs(payload, collection, {
32
+ where: { _status: { equals: 'published' } },
33
+ select,
34
+ });
35
+ }
36
+ catch {
37
+ return findAllDocs(payload, collection, { select });
38
+ }
39
+ }
40
+ /** Coerce a doc's updatedAt into an ISO-8601 string, or null when absent. */
41
+ function toIsoString(value) {
42
+ if (!value)
43
+ return null;
44
+ const date = new Date(value);
45
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
46
+ }
47
+ export const sitemapEndpoints = [
48
+ // GET /nitrogen/v1/sitemap — sitemap index (mirror get_sitemap_index)
49
+ {
50
+ path: '/nitrogen/v1/sitemap',
51
+ method: 'get',
52
+ handler: async (req) => {
53
+ const { payload } = req;
54
+ const postTypes = [];
55
+ for (const { collectionSlug } of getRegisteredCollections()) {
56
+ if (EXCLUDED_COLLECTIONS.has(collectionSlug))
57
+ continue;
58
+ let docs;
59
+ try {
60
+ docs = await fetchPublishedSitemapDocs(payload, collectionSlug);
61
+ }
62
+ catch {
63
+ // One bad collection shouldn't 500 the whole index — skip it.
64
+ continue;
65
+ }
66
+ if (docs.length === 0)
67
+ continue;
68
+ let lastmod = null;
69
+ for (const doc of docs) {
70
+ const iso = toIsoString(doc.updatedAt);
71
+ if (iso !== null && (lastmod === null || iso > lastmod)) {
72
+ lastmod = iso;
73
+ }
74
+ }
75
+ postTypes.push({ post_type: collectionSlug, lastmod });
76
+ }
77
+ return Response.json({ post_types: postTypes });
78
+ },
79
+ },
80
+ // GET /nitrogen/v1/sitemap/:post_type — URLs for one post type
81
+ // (mirror get_sitemap_for_post_type)
82
+ {
83
+ path: '/nitrogen/v1/sitemap/:post_type',
84
+ method: 'get',
85
+ handler: async (req) => {
86
+ const { payload, routeParams } = req;
87
+ const postType = String(routeParams?.post_type || '');
88
+ const collection = resolveCollection(postType);
89
+ if (EXCLUDED_COLLECTIONS.has(collection)) {
90
+ return Response.json({ error: 'Post type not eligible for sitemap' }, { status: 404 });
91
+ }
92
+ let docs;
93
+ try {
94
+ docs = await fetchPublishedSitemapDocs(payload, collection);
95
+ }
96
+ catch {
97
+ return Response.json({ error: 'No URLs for this post type' }, { status: 404 });
98
+ }
99
+ const urls = docs.map((doc) => ({
100
+ loc: buildRelativePermalink(postType, String(doc.slug || '')),
101
+ lastmod: toIsoString(doc.updatedAt),
102
+ }));
103
+ if (urls.length === 0) {
104
+ return Response.json({ error: 'No URLs for this post type' }, { status: 404 });
105
+ }
106
+ return Response.json({ urls });
107
+ },
108
+ },
109
+ ];
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Template Conditions — TypeScript port of the WordPress
3
+ * `Nitrogen\TemplateConditions` scoring engine
4
+ * (nitrogen-connector/includes/template-conditions.php).
5
+ *
6
+ * Resolves the best-matching nitrogen-template for a document by scoring
7
+ * editor-authored condition groups (OR'd groups, AND'd conditions within a
8
+ * group) and falling back to a legacy associatedCollection match.
9
+ *
10
+ * Everything here is pure/typed and defensive: the evaluators never throw —
11
+ * malformed input simply yields a non-match (false).
12
+ */
13
+ import type { Payload } from 'payload';
14
+ import type { JsonObject, NitrogenPageDoc, NitrogenTemplateDoc, NitrogenSettingsGlobal } from '../types.js';
15
+ import { type TemplateRef } from './helpers.js';
16
+ /**
17
+ * Evaluation context for a single document.
18
+ *
19
+ * `dynamic` holds the buildDynamicData() namespace; `post` is the raw document.
20
+ * The dynamic_data evaluator resolves its dot-path against both (dynamic first,
21
+ * then post) so editor-authored paths match leniently.
22
+ */
23
+ export interface TemplateContext {
24
+ post: NitrogenPageDoc | NitrogenTemplateDoc | Record<string, unknown>;
25
+ postType: string;
26
+ postId: string;
27
+ dynamic: JsonObject;
28
+ }
29
+ /**
30
+ * Evaluate authored conditions against a context (php:45-100).
31
+ *
32
+ * Groups are OR'd; conditions within a group are AND'd. An `exclude` flag
33
+ * inverts a condition's result. An unknown condition type fails the whole group.
34
+ *
35
+ * Returns the matched-condition count (score) of the first matching group, or
36
+ * false. If there are no conditionGroups, returns false (the caller handles the
37
+ * legacy fallback).
38
+ */
39
+ export declare function evaluateConditions(templateConditions: unknown, context: TemplateContext): number | false;
40
+ /**
41
+ * Build the evaluation context for a document (php:187-216, Payload-adapted).
42
+ */
43
+ export declare function buildTemplateContext(doc: NitrogenPageDoc | NitrogenTemplateDoc, collectionSlug: string, settings: NitrogenSettingsGlobal): TemplateContext;
44
+ /**
45
+ * Resolve the best-matching template for a document
46
+ * (php:140-182 resolve_template + php:106-132 evaluate_legacy).
47
+ *
48
+ * Returns a { ID, content } ref with ctrl-links resolved and content
49
+ * JSON-stringified — the same shape getTemplateForType() returns — or null if
50
+ * no template matches.
51
+ */
52
+ export declare function resolveTemplateForDocument(payload: Payload, doc: NitrogenPageDoc | NitrogenTemplateDoc, collectionSlug: string, settings: NitrogenSettingsGlobal): Promise<TemplateRef | null>;
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Template Conditions — TypeScript port of the WordPress
3
+ * `Nitrogen\TemplateConditions` scoring engine
4
+ * (nitrogen-connector/includes/template-conditions.php).
5
+ *
6
+ * Resolves the best-matching nitrogen-template for a document by scoring
7
+ * editor-authored condition groups (OR'd groups, AND'd conditions within a
8
+ * group) and falling back to a legacy associatedCollection match.
9
+ *
10
+ * Everything here is pure/typed and defensive: the evaluators never throw —
11
+ * malformed input simply yields a non-match (false).
12
+ */
13
+ import { buildDynamicData } from './helpers.js';
14
+ import { resolveCtrlLinksInNitrogenData } from './ctrlLinkResolver.js';
15
+ // Template types that are resolved separately (header/footer/404) and must be
16
+ // skipped by the document template resolver.
17
+ const RESERVED_TEMPLATE_TYPES = ['header', 'footer', 'not_found'];
18
+ // --- Built-in condition evaluators (php:225-309) -------------------------------
19
+ /**
20
+ * post_type — compare 'is'/'is_not' against context.postType.
21
+ * The condition value is a list (single values are coerced to a one-item list).
22
+ */
23
+ function evaluatePostType(condition, context) {
24
+ const value = toArray(condition.value).map((v) => String(v));
25
+ const postType = context.postType ?? '';
26
+ const compare = condition.compare ?? 'is';
27
+ const inList = value.includes(postType);
28
+ return compare === 'is' ? inList : !inList;
29
+ }
30
+ /**
31
+ * post_id — compare 'is'/'is_not' against context.postId.
32
+ * Payload ids may be string or number, so everything is coerced to string
33
+ * for comparison.
34
+ */
35
+ function evaluatePostId(condition, context) {
36
+ const value = toArray(condition.value).map((v) => String(v));
37
+ const postId = String(context.postId ?? '');
38
+ const compare = condition.compare ?? 'is';
39
+ const inList = value.includes(postId);
40
+ return compare === 'is' ? inList : !inList;
41
+ }
42
+ /**
43
+ * dynamic_data — resolve `field` (a dot-path, optionally wrapped in {{ }}) and
44
+ * compare it.
45
+ *
46
+ * NOTE: the exact dynamic-data path namespace is best-effort — the WP source
47
+ * resolved against post/acf_fields/taxonomies, whereas the Payload
48
+ * buildDynamicData() namespace is shaped differently (e.g. `Post.*`,
49
+ * `Global Variables.*`). To match editor-authored paths leniently we resolve
50
+ * against BOTH context.dynamic and the raw context.post (dynamic first).
51
+ *
52
+ * Compares: equals/not_equals/contains/not_contains/is_empty/is_not_empty.
53
+ */
54
+ function evaluateDynamicData(condition, context) {
55
+ let fieldPath = condition.field ?? '';
56
+ const compare = condition.compare ?? 'equals';
57
+ const expected = condition.value ?? '';
58
+ if (!fieldPath) {
59
+ return false;
60
+ }
61
+ // Strip surrounding {{ }} wrappers, e.g. "{{Post.id}}" -> "Post.id".
62
+ fieldPath = fieldPath.trim().replace(/^\{\{/, '').replace(/\}\}$/, '').trim();
63
+ if (!fieldPath) {
64
+ return false;
65
+ }
66
+ // Try the dynamic-data namespace first, then fall back to the raw post.
67
+ let actual = resolveDotPath(context.dynamic, fieldPath);
68
+ if (actual === null || actual === undefined) {
69
+ actual = resolveDotPath(context.post, fieldPath);
70
+ }
71
+ const actualStr = Array.isArray(actual)
72
+ ? actual.map((v) => String(v)).join(', ')
73
+ : String(actual ?? '');
74
+ const expectedStr = String(expected ?? '');
75
+ switch (compare) {
76
+ case 'equals':
77
+ return actualStr === expectedStr;
78
+ case 'not_equals':
79
+ return actualStr !== expectedStr;
80
+ case 'contains':
81
+ return actualStr.includes(expectedStr);
82
+ case 'not_contains':
83
+ return !actualStr.includes(expectedStr);
84
+ case 'is_empty':
85
+ return isEmpty(actual);
86
+ case 'is_not_empty':
87
+ return !isEmpty(actual);
88
+ default:
89
+ return false;
90
+ }
91
+ }
92
+ const CONDITION_EVALUATORS = {
93
+ post_type: evaluatePostType,
94
+ post_id: evaluatePostId,
95
+ dynamic_data: evaluateDynamicData,
96
+ };
97
+ // --- Helpers -------------------------------------------------------------------
98
+ function toArray(value) {
99
+ if (Array.isArray(value))
100
+ return value;
101
+ if (value === null || value === undefined)
102
+ return [];
103
+ return [value];
104
+ }
105
+ function isRecord(value) {
106
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
107
+ }
108
+ /**
109
+ * Mirrors PHP `empty()` closely enough for condition evaluation:
110
+ * null/undefined, '', '0', 0, false, and empty arrays are "empty".
111
+ */
112
+ function isEmpty(value) {
113
+ if (value === null || value === undefined)
114
+ return true;
115
+ if (value === false || value === 0)
116
+ return true;
117
+ if (value === '' || value === '0')
118
+ return true;
119
+ if (Array.isArray(value))
120
+ return value.length === 0;
121
+ if (isRecord(value))
122
+ return Object.keys(value).length === 0;
123
+ return false;
124
+ }
125
+ /**
126
+ * Resolve a dot-notation path against a nested object/array.
127
+ * Returns null if any segment is missing (matching the PHP behaviour).
128
+ */
129
+ function resolveDotPath(data, path) {
130
+ const keys = path.split('.');
131
+ let current = data;
132
+ for (const key of keys) {
133
+ if (isRecord(current) && Object.prototype.hasOwnProperty.call(current, key)) {
134
+ current = current[key];
135
+ }
136
+ else if (Array.isArray(current)) {
137
+ const index = Number(key);
138
+ if (Number.isInteger(index) && index >= 0 && index < current.length) {
139
+ current = current[index];
140
+ }
141
+ else {
142
+ return null;
143
+ }
144
+ }
145
+ else {
146
+ return null;
147
+ }
148
+ }
149
+ return current;
150
+ }
151
+ function parseConditions(templateConditions) {
152
+ let data = templateConditions;
153
+ if (typeof data === 'string') {
154
+ try {
155
+ data = JSON.parse(data);
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ if (!isRecord(data))
162
+ return null;
163
+ return data;
164
+ }
165
+ // --- Public API ----------------------------------------------------------------
166
+ /**
167
+ * Evaluate authored conditions against a context (php:45-100).
168
+ *
169
+ * Groups are OR'd; conditions within a group are AND'd. An `exclude` flag
170
+ * inverts a condition's result. An unknown condition type fails the whole group.
171
+ *
172
+ * Returns the matched-condition count (score) of the first matching group, or
173
+ * false. If there are no conditionGroups, returns false (the caller handles the
174
+ * legacy fallback).
175
+ */
176
+ export function evaluateConditions(templateConditions, context) {
177
+ const data = parseConditions(templateConditions);
178
+ if (!data || !Array.isArray(data.conditionGroups) || data.conditionGroups.length === 0) {
179
+ return false;
180
+ }
181
+ for (const group of data.conditionGroups) {
182
+ const conditions = group?.conditions;
183
+ if (!Array.isArray(conditions) || conditions.length === 0) {
184
+ continue;
185
+ }
186
+ let groupMatches = true;
187
+ let groupScore = 0;
188
+ for (const condition of conditions) {
189
+ const type = condition?.type ?? '';
190
+ const evaluator = CONDITION_EVALUATORS[type];
191
+ if (!evaluator) {
192
+ // Unknown condition type — treat the group as a non-match.
193
+ groupMatches = false;
194
+ break;
195
+ }
196
+ let result;
197
+ try {
198
+ result = evaluator(condition, context);
199
+ }
200
+ catch {
201
+ result = false;
202
+ }
203
+ // The exclude flag inverts the result.
204
+ if (condition?.exclude) {
205
+ result = !result;
206
+ }
207
+ if (!result) {
208
+ groupMatches = false;
209
+ break;
210
+ }
211
+ groupScore++;
212
+ }
213
+ if (groupMatches) {
214
+ return groupScore;
215
+ }
216
+ }
217
+ return false;
218
+ }
219
+ /**
220
+ * Build the evaluation context for a document (php:187-216, Payload-adapted).
221
+ */
222
+ export function buildTemplateContext(doc, collectionSlug, settings) {
223
+ return {
224
+ post: doc,
225
+ postType: collectionSlug,
226
+ postId: String(doc.id),
227
+ dynamic: buildDynamicData(doc, settings),
228
+ };
229
+ }
230
+ /**
231
+ * Resolve the best-matching template for a document
232
+ * (php:140-182 resolve_template + php:106-132 evaluate_legacy).
233
+ *
234
+ * Returns a { ID, content } ref with ctrl-links resolved and content
235
+ * JSON-stringified — the same shape getTemplateForType() returns — or null if
236
+ * no template matches.
237
+ */
238
+ export async function resolveTemplateForDocument(payload, doc, collectionSlug, settings) {
239
+ let templates;
240
+ try {
241
+ const result = await payload.find({
242
+ collection: 'nitrogen-templates',
243
+ where: {
244
+ status: { equals: 'published' },
245
+ },
246
+ limit: 10000,
247
+ depth: 0,
248
+ sort: '-updatedAt',
249
+ });
250
+ templates = result.docs;
251
+ }
252
+ catch {
253
+ return null;
254
+ }
255
+ if (!templates.length)
256
+ return null;
257
+ const context = buildTemplateContext(doc, collectionSlug, settings);
258
+ let bestMatch = null;
259
+ let bestScore = -1;
260
+ for (const template of templates) {
261
+ const associatedCollection = template.associatedCollection ?? '';
262
+ // Skip header/footer/not_found templates — resolved separately.
263
+ if (RESERVED_TEMPLATE_TYPES.includes(associatedCollection)) {
264
+ continue;
265
+ }
266
+ const conditionsData = parseConditions(template.templateConditions);
267
+ let score;
268
+ if (conditionsData && Array.isArray(conditionsData.conditionGroups)) {
269
+ score = evaluateConditions(template.templateConditions, context);
270
+ }
271
+ else {
272
+ // Legacy fallback (php:106-132): match by associatedCollection.
273
+ score = associatedCollection === collectionSlug ? 1 : false;
274
+ }
275
+ // Highest score wins; ties resolve to the first encountered template, which
276
+ // (thanks to the -updatedAt sort) is the most recently updated.
277
+ if (score !== false && score > bestScore) {
278
+ bestScore = score;
279
+ bestMatch = template;
280
+ }
281
+ }
282
+ if (!bestMatch)
283
+ return null;
284
+ let resolved = null;
285
+ if (bestMatch.nitrogenData) {
286
+ try {
287
+ resolved = await resolveCtrlLinksInNitrogenData(payload, bestMatch.nitrogenData, settings);
288
+ }
289
+ catch {
290
+ resolved = null;
291
+ }
292
+ }
293
+ return {
294
+ ID: bestMatch.id,
295
+ content: resolved ? JSON.stringify(resolved) : '[]',
296
+ };
297
+ }
@@ -27,6 +27,14 @@ export const NitrogenSettings = {
27
27
  type: 'text',
28
28
  admin: { description: 'Connector token for server communication' },
29
29
  },
30
+ {
31
+ name: 'nitrogenInstanceUrl',
32
+ label: 'Nitrogen Instance URL',
33
+ type: 'text',
34
+ admin: {
35
+ description: 'Base URL of the Nitrogen instance hosting the MCP server (the agent endpoint is this + "/mcp"). Leave blank to use the hosted app.',
36
+ },
37
+ },
30
38
  {
31
39
  type: 'row',
32
40
  fields: [
package/dist/index.d.ts CHANGED
@@ -28,6 +28,12 @@ export interface NitrogenConnectorPluginOptions {
28
28
  * inventory. These do not need to be editor-enabled.
29
29
  */
30
30
  indexCollections?: string[];
31
+ /**
32
+ * Slug of the auth-enabled collection that owns MCP agent credentials. When
33
+ * omitted, the first collection with `auth` enabled is used (falling back to
34
+ * `users`).
35
+ */
36
+ userCollection?: string;
31
37
  }
32
38
  export declare const nitrogenConnectorPlugin: (options?: NitrogenConnectorPluginOptions) => any;
33
39
  export { NitrogenTemplates } from "./collections/NitrogenTemplates.js";
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ import { menuEndpoints } from "./endpoints/menu.js";
12
12
  import { createCollectionEndpoints } from "./endpoints/collection-endpoints.js";
13
13
  import { createComponentInventoryEndpoints } from './endpoints/component-inventory.js';
14
14
  import { batchEndpoints } from "./endpoints/batch.js";
15
+ import { sitemapEndpoints } from "./endpoints/sitemap.js";
16
+ import { createAgentAuthEndpoints } from "./endpoints/agent-auth.js";
15
17
  import { registerCollection } from "./collection-registry.js";
16
18
  import { deleteDocumentUsage, reindexDocumentUsage, syncComponentCatalog, } from './inventory/indexing.js';
17
19
  /** Fields required by Nitrogen that will be injected into collections if missing */
@@ -42,6 +44,11 @@ export const nitrogenConnectorPlugin = (options = {}) => (incomingConfig) => {
42
44
  return incomingConfig;
43
45
  }
44
46
  const config = { ...incomingConfig };
47
+ // The auth collection that owns MCP agent credentials. Prefer the explicit
48
+ // option, else the first auth-enabled collection, else `users`.
49
+ const userCollectionSlug = options.userCollection ||
50
+ (incomingConfig.collections || []).find((col) => col.auth)?.slug ||
51
+ "users";
45
52
  const inventoryCollections = Array.from(new Set([
46
53
  ...(options.collections || []),
47
54
  ...(options.indexCollections || []),
@@ -94,6 +101,8 @@ export const nitrogenConnectorPlugin = (options = {}) => (incomingConfig) => {
94
101
  ...collectionsEndpoints,
95
102
  ...menuEndpoints,
96
103
  ...batchEndpoints,
104
+ ...sitemapEndpoints,
105
+ ...createAgentAuthEndpoints(userCollectionSlug),
97
106
  ...createComponentInventoryEndpoints({
98
107
  collections: inventoryCollections,
99
108
  componentManifest: options.componentManifest,
@@ -224,6 +233,55 @@ export const nitrogenConnectorPlugin = (options = {}) => (incomingConfig) => {
224
233
  }
225
234
  config.collections[existingIndex] = withInventoryHooks(config.collections[existingIndex], slug);
226
235
  }
236
+ // Inject MCP agent-credential storage + management UI onto the auth users
237
+ // collection. The hashed secret is never exposed via the API.
238
+ const usersIndex = (config.collections || []).findIndex((col) => col.slug === userCollectionSlug);
239
+ if (usersIndex === -1) {
240
+ console.warn(`[@nitrogenbuilder/connector-payload] Users collection "${userCollectionSlug}" not found; ` +
241
+ `MCP agent credentials cannot be stored. Pass the "userCollection" option if your auth collection differs.`);
242
+ }
243
+ else {
244
+ const usersCol = config.collections[usersIndex];
245
+ const usersFieldNames = new Set((usersCol.fields || [])
246
+ .filter((f) => "name" in f && !!f.name)
247
+ .map((f) => f.name));
248
+ const agentFields = [];
249
+ if (!usersFieldNames.has("nitrogenAgentTokenHash")) {
250
+ agentFields.push({
251
+ name: "nitrogenAgentTokenHash",
252
+ type: "text",
253
+ access: { read: () => false },
254
+ admin: { hidden: true, readOnly: true },
255
+ });
256
+ }
257
+ if (!usersFieldNames.has("nitrogenAgentTokenCreated")) {
258
+ agentFields.push({
259
+ name: "nitrogenAgentTokenCreated",
260
+ type: "number",
261
+ admin: { hidden: true, readOnly: true },
262
+ });
263
+ }
264
+ if (!usersFieldNames.has("nitrogenAgentCredentialUI")) {
265
+ agentFields.push({
266
+ name: "nitrogenAgentCredentialUI",
267
+ type: "ui",
268
+ admin: {
269
+ components: {
270
+ Field: {
271
+ path: "@nitrogenbuilder/connector-payload/components/NitrogenAgentCredential#NitrogenAgentCredential",
272
+ clientProps: { apiUrl: "/api/nitrogen/v1" },
273
+ },
274
+ },
275
+ },
276
+ });
277
+ }
278
+ if (agentFields.length > 0) {
279
+ config.collections[usersIndex] = {
280
+ ...usersCol,
281
+ fields: [...usersCol.fields, ...agentFields],
282
+ };
283
+ }
284
+ }
227
285
  return config;
228
286
  };
229
287
  // Re-export for consumers who need direct access
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitrogenbuilder/connector-payload",
3
- "version": "0.1.44",
3
+ "version": "0.1.51",
4
4
  "description": "Nitrogen page builder connector plugin for Payload CMS 3.x",
5
5
  "author": "Leonardo Dentzien <leo@torchmedia.ca>",
6
6
  "type": "module",
@@ -56,6 +56,9 @@
56
56
  "@nitrogenbuilder/types": "link:../monogen/packages/types"
57
57
  },
58
58
  "pnpm": {
59
- "onlyBuiltDependencies": ["esbuild", "sharp"]
59
+ "onlyBuiltDependencies": [
60
+ "esbuild",
61
+ "sharp"
62
+ ]
60
63
  }
61
- }
64
+ }