@nitrogenbuilder/connector-payload 0.1.43 → 0.1.50

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.
@@ -3,8 +3,14 @@
3
3
  *
4
4
  * The plugin registers collections at init time via `registerCollection()`.
5
5
  */
6
+ type RegisteredCollection = {
7
+ collectionSlug: string;
8
+ routePattern?: string;
9
+ };
6
10
  export declare function registerCollection(alias: string, collectionSlug: string, routePattern?: string): void;
7
11
  export declare function resolveCollection(postType: string): string;
8
12
  export declare function resolveCollectionRoutePattern(postType: string): string | undefined;
13
+ export declare function getRegisteredCollections(): RegisteredCollection[];
9
14
  export declare function buildRelativePermalink(postType: string, slug: string): string;
10
15
  export declare function buildPermalink(postType: string, slug: string, frontendUrl?: string): string;
16
+ export {};
@@ -33,6 +33,16 @@ export function resolveCollection(postType) {
33
33
  export function resolveCollectionRoutePattern(postType) {
34
34
  return registeredCollections.get(postType)?.routePattern;
35
35
  }
36
+ export function getRegisteredCollections() {
37
+ const collectionsBySlug = new Map();
38
+ for (const collection of registeredCollections.values()) {
39
+ const existing = collectionsBySlug.get(collection.collectionSlug);
40
+ if (!existing || (!existing.routePattern && collection.routePattern)) {
41
+ collectionsBySlug.set(collection.collectionSlug, collection);
42
+ }
43
+ }
44
+ return Array.from(collectionsBySlug.values());
45
+ }
36
46
  export function buildRelativePermalink(postType, slug) {
37
47
  return buildRelativePath(resolveCollectionRoutePattern(postType), slug);
38
48
  }
@@ -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
  *
@@ -78,31 +75,20 @@ export function createNitrogenEditorPage(config) {
78
75
  cssInjection: nitrogenConfig.cssInjection || "",
79
76
  };
80
77
  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";
78
+ const editorAssetsBase = "/nitrogen-editor/assets";
97
79
  const editorCdnVersion = typeof settings.editorCdnVersion === "string" &&
98
80
  settings.editorCdnVersion.trim()
99
81
  ? settings.editorCdnVersion.trim()
100
82
  : DEFAULT_EDITOR_CDN_VERSION;
101
83
  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: {
84
+ 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
85
  __html: [
104
86
  `window.nitrogenConfig = ${JSON.stringify(builderConfig)};`,
105
87
  `window.nitrogenEditId = "${editId}";`,
88
+ // Identity injected as a global (not via URL) so a copied edit URL
89
+ // doesn't carry the original author's id/email. The editor reads
90
+ // window.nitrogenUser, falling back to the authorId/author params below.
91
+ `window.nitrogenUser = ${JSON.stringify({ id: String(user.id), email: user.email })};`,
106
92
  // Keep both legacy and current author params present for editor builds.
107
93
  `(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
94
  ].join(""),
@@ -114,18 +100,12 @@ export function createNitrogenEditorPage(config) {
114
100
  alignItems: "center",
115
101
  justifyContent: "center",
116
102
  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: {
103
+ }, children: _jsx("img", { src: `${editorAssetsBase}/logo-white.svg`, alt: "Loading\u2026", style: {
122
104
  width: "24rem",
123
105
  maxWidth: "100%",
124
106
  animation: "nitrogen-loader-pulse 3s ease-in-out infinite",
125
- } })) }), _jsx("style", { dangerouslySetInnerHTML: {
107
+ } }) }), _jsx("style", { dangerouslySetInnerHTML: {
126
108
  __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` }))] }));
109
+ } })] }), _jsx("script", { type: "module", crossOrigin: "", src: `${cdnBase}/index.js` })] }));
130
110
  };
131
111
  }
@@ -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
  },