@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.
- package/dist/components/NitrogenAgentCredential.d.ts +1 -0
- package/dist/components/NitrogenAgentCredential.js +6 -0
- package/dist/components/NitrogenAgentCredentialRuntime.d.ts +10 -0
- package/dist/components/NitrogenAgentCredentialRuntime.js +136 -0
- package/dist/editor/NitrogenEditorPage.d.ts +0 -1
- package/dist/editor/NitrogenEditorPage.js +13 -30
- package/dist/endpoints/agent-auth.d.ts +31 -0
- package/dist/endpoints/agent-auth.js +210 -0
- package/dist/endpoints/batch.js +33 -1
- package/dist/endpoints/collection-endpoints.js +17 -7
- package/dist/endpoints/helpers.d.ts +13 -0
- package/dist/endpoints/helpers.js +46 -0
- package/dist/endpoints/sitemap.d.ts +14 -0
- package/dist/endpoints/sitemap.js +109 -0
- package/dist/endpoints/templateConditions.d.ts +52 -0
- package/dist/endpoints/templateConditions.js +297 -0
- package/dist/globals/NitrogenSettings.js +8 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +58 -0
- package/package.json +6 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const NitrogenAgentCredential: import("react").ComponentType<import("./NitrogenAgentCredentialRuntime.js").NitrogenAgentCredentialRuntimeProps>;
|
|
@@ -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
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
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
|
-
|
|
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: [
|
|
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:
|
|
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
|
-
} })
|
|
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
|
-
} })] }),
|
|
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
|
+
}
|
package/dist/endpoints/batch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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": [
|
|
59
|
+
"onlyBuiltDependencies": [
|
|
60
|
+
"esbuild",
|
|
61
|
+
"sharp"
|
|
62
|
+
]
|
|
60
63
|
}
|
|
61
|
-
}
|
|
64
|
+
}
|