@simmalugnt-se/payload-editor-assistant 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +35 -0
- package/dist/batch/targets.d.ts +25 -0
- package/dist/batch/targets.js +47 -0
- package/dist/capabilities.d.ts +4 -4
- package/dist/capabilities.js +3 -0
- package/dist/components/BatchPanel.d.ts +9 -0
- package/dist/components/BatchPanel.js +161 -0
- package/dist/components/EditorAssistantAside.js +4 -3
- package/dist/components/EditorAssistantProvider.js +2 -0
- package/dist/components/assistant-context.d.ts +11 -0
- package/dist/components/assistant-context.js +4 -0
- package/dist/components/batch-link.d.ts +5 -0
- package/dist/components/batch-link.js +35 -0
- package/dist/components/batch-run.d.ts +65 -0
- package/dist/components/batch-run.js +67 -0
- package/dist/components/copy.d.ts +82 -0
- package/dist/components/copy.js +81 -0
- package/dist/config/validate.js +3 -0
- package/dist/domain/audit.d.ts +2 -0
- package/dist/domain/audit.js +1 -0
- package/dist/domain/batch.d.ts +74 -0
- package/dist/domain/batch.js +342 -0
- package/dist/domain/content.d.ts +16 -0
- package/dist/domain/content.js +14 -5
- package/dist/domain/decide.js +4 -0
- package/dist/domain/guard.d.ts +3 -1
- package/dist/domain/guard.js +2 -2
- package/dist/domain/propose.d.ts +27 -1
- package/dist/domain/propose.js +88 -5
- package/dist/endpoints/batch.d.ts +8 -0
- package/dist/endpoints/batch.js +102 -0
- package/dist/package-name.d.ts +3 -2
- package/dist/package-name.js +3 -2
- package/dist/plugin.js +12 -2
- package/dist/runtime/agent.d.ts +4 -2
- package/dist/runtime/agent.js +5 -3
- package/dist/runtime/system-prompt.js +12 -1
- package/dist/runtime/tools.d.ts +15 -2
- package/dist/runtime/tools.js +20 -3
- package/dist/runtime/voice.d.ts +2 -2
- package/dist/runtime/voice.js +7 -3
- package/dist/styles/assistant.css +203 -0
- package/package.json +1 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { changedPaths } from "../form/changed-paths.js";
|
|
2
|
+
import { compileOperations } from "../form/compile.js";
|
|
3
|
+
import { hashFormRevision } from "../form/hash.js";
|
|
4
|
+
import { isFormOperation } from "../form/operations.js";
|
|
5
|
+
import { cloneData, getAtPath, parseFieldPath, setAtPath } from "../form/path.js";
|
|
6
|
+
import { relationValueError } from "../form/relation-shape.js";
|
|
7
|
+
import { loadRichText } from "../form/rich-text.js";
|
|
8
|
+
import { finalizeCompiled, openRichText } from "../form/shape.js";
|
|
9
|
+
import { translationPaths } from "../form/translation.js";
|
|
10
|
+
import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
|
|
11
|
+
import { runAgentTurn } from "../runtime/agent.js";
|
|
12
|
+
import { sourceLocale } from "../runtime/translation-source.js";
|
|
13
|
+
import { configuredDefaultLocale, configuredLocales, entityFields, findCollectionConfig, hasDrafts, } from "../schema/discover.js";
|
|
14
|
+
import { fieldAtPath } from "../schema/fields.js";
|
|
15
|
+
import { projectRecord } from "../schema/project.js";
|
|
16
|
+
import { hasCapability, resolveAllowlist } from "./allowlist.js";
|
|
17
|
+
import { writeAudit } from "./audit.js";
|
|
18
|
+
import { readDocument } from "./content.js";
|
|
19
|
+
import { localApi } from "./local-api.js";
|
|
20
|
+
import { verifyDocumentRelations } from "./relations.js";
|
|
21
|
+
const BATCH_ID = /^[\w-]{1,64}$/;
|
|
22
|
+
/** An editor's own wording in a batch card; longer than any meta text or alt text needs. */
|
|
23
|
+
const MAX_EDIT_LENGTH = 5000;
|
|
24
|
+
/**
|
|
25
|
+
* One document of a batch: a turn on the saved document, as if it were open with the finding's field
|
|
26
|
+
* selected. The model may only propose a `content.update` within the target's fields.
|
|
27
|
+
*/
|
|
28
|
+
export async function proposeBatchTarget(req, options, input, run = runAgentTurn) {
|
|
29
|
+
await loadRichText();
|
|
30
|
+
const { target } = input;
|
|
31
|
+
if (!BATCH_ID.test(input.batchId) || !input.prompt.trim()) {
|
|
32
|
+
return { ok: false, error: "tool_denied" };
|
|
33
|
+
}
|
|
34
|
+
const resolved = resolveAllowlist(options, { collection: target.collection });
|
|
35
|
+
if (!resolved || !hasCapability(resolved.allowlist, "content.update")) {
|
|
36
|
+
return { ok: false, error: "tool_denied", message: "Batch changes are not allowed here." };
|
|
37
|
+
}
|
|
38
|
+
const locale = target.locale ?? configuredDefaultLocale(req) ?? "en";
|
|
39
|
+
const saved = await readSaved(req, options, target.collection, target.id, locale);
|
|
40
|
+
if (!saved) {
|
|
41
|
+
return { ok: false, error: "tool_denied", message: "Document is not accessible." };
|
|
42
|
+
}
|
|
43
|
+
const fields = entityFields(req, options, { collection: target.collection });
|
|
44
|
+
const allowedPaths = await allowedPathsFor(req, options, input, locale, fields, saved.data);
|
|
45
|
+
if (!allowedPaths.ok) {
|
|
46
|
+
return { ok: false, error: allowedPaths.error };
|
|
47
|
+
}
|
|
48
|
+
const message = input.instruction?.trim()
|
|
49
|
+
? `${input.prompt.trim()}\n\n${input.instruction.trim()}`
|
|
50
|
+
: input.prompt.trim();
|
|
51
|
+
const result = await run(req, options, {
|
|
52
|
+
message,
|
|
53
|
+
messages: [{ role: "user", text: message }],
|
|
54
|
+
bridge: {
|
|
55
|
+
collection: target.collection,
|
|
56
|
+
documentId: target.id,
|
|
57
|
+
title: saved.title,
|
|
58
|
+
locale,
|
|
59
|
+
language: input.language,
|
|
60
|
+
uiLanguage: input.language,
|
|
61
|
+
selection: input.scope === "field" ? target.path : undefined,
|
|
62
|
+
batch: { allowedPaths: allowedPaths.paths, requestId: `batch:${input.batchId}` },
|
|
63
|
+
},
|
|
64
|
+
formData: saved.data,
|
|
65
|
+
});
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
return { ok: false, error: result.error, message: result.message };
|
|
68
|
+
}
|
|
69
|
+
const pending = result.pendingApproval;
|
|
70
|
+
if (pending?.capability !== "content.update") {
|
|
71
|
+
return { ok: true, text: result.text };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
text: result.text,
|
|
76
|
+
proposal: {
|
|
77
|
+
approvalId: pending.approvalId,
|
|
78
|
+
nonce: pending.nonce,
|
|
79
|
+
lines: pending.lines ?? pending.summary.split("\n").map((text) => ({ path: "", text })),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** The finding's field, or every field a translation still has to fill. */
|
|
84
|
+
async function allowedPathsFor(req, options, input, locale, fields, saved) {
|
|
85
|
+
if (input.scope === "field") {
|
|
86
|
+
return fieldAtPath(fields, input.target.path, saved)
|
|
87
|
+
? { ok: true, paths: [input.target.path] }
|
|
88
|
+
: { ok: false, error: "tool_denied" };
|
|
89
|
+
}
|
|
90
|
+
const source = sourceLocale({
|
|
91
|
+
open: locale,
|
|
92
|
+
locales: configuredLocales(req),
|
|
93
|
+
defaultLocale: configuredDefaultLocale(req),
|
|
94
|
+
});
|
|
95
|
+
const from = source
|
|
96
|
+
? await readSaved(req, options, input.target.collection, input.target.id, source)
|
|
97
|
+
: null;
|
|
98
|
+
if (!from) {
|
|
99
|
+
return { ok: false, error: "tool_denied" };
|
|
100
|
+
}
|
|
101
|
+
const empty = translationPaths(fields, saved, from.data).empty;
|
|
102
|
+
return empty.length ? { ok: true, paths: empty } : { ok: false, error: "nothing_to_do" };
|
|
103
|
+
}
|
|
104
|
+
async function readSaved(req, options, collection, id, locale) {
|
|
105
|
+
const read = await readDocument(req, options, { collection, documentId: id, locale });
|
|
106
|
+
if (!read.ok) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const useAsTitle = findCollectionConfig(req, collection)?.admin?.useAsTitle;
|
|
110
|
+
const title = useAsTitle ? read.doc[useAsTitle] : undefined;
|
|
111
|
+
return {
|
|
112
|
+
data: projectRecord(read.doc, read.fields, options.redact, read.slug).data,
|
|
113
|
+
raw: read.doc,
|
|
114
|
+
title: typeof title === "string" && title ? title : undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Saves one batch proposal to the document, or rejects it. Checked again as it is saved: the editor,
|
|
119
|
+
* the one-time approval, `content.update`, and that the saved document is still the one the proposal
|
|
120
|
+
* was made for. A collection with drafts gets a draft; one without is saved.
|
|
121
|
+
*/
|
|
122
|
+
export async function applyBatchTarget(req, options, input) {
|
|
123
|
+
await loadRichText();
|
|
124
|
+
const requestId = BATCH_ID.test(input.batchId) ? `batch:${input.batchId}` : undefined;
|
|
125
|
+
let record;
|
|
126
|
+
try {
|
|
127
|
+
record = (await localApi(req).findByID({
|
|
128
|
+
collection: DEFAULT_APPROVAL_SLUG,
|
|
129
|
+
id: input.approvalId,
|
|
130
|
+
req,
|
|
131
|
+
overrideAccess: false,
|
|
132
|
+
user: req.user,
|
|
133
|
+
depth: 0,
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return { ok: false, error: "tool_denied", message: "Approval is not valid." };
|
|
138
|
+
}
|
|
139
|
+
if (!record ||
|
|
140
|
+
record.actor !== String(req.user?.id) ||
|
|
141
|
+
record.nonce !== input.nonce ||
|
|
142
|
+
record.capability !== "content.update" ||
|
|
143
|
+
!record.targetCollection ||
|
|
144
|
+
!record.documentId) {
|
|
145
|
+
return { ok: false, error: "tool_denied", message: "Approval is not valid." };
|
|
146
|
+
}
|
|
147
|
+
if (record.status !== "proposed") {
|
|
148
|
+
return { ok: false, error: "tool_denied", message: "Approval is no longer usable." };
|
|
149
|
+
}
|
|
150
|
+
const event = {
|
|
151
|
+
capability: "content.update",
|
|
152
|
+
risk: "approval",
|
|
153
|
+
targetCollection: record.targetCollection,
|
|
154
|
+
documentId: record.documentId,
|
|
155
|
+
locale: record.locale,
|
|
156
|
+
approvalId: String(record.id),
|
|
157
|
+
nonceId: record.nonce.slice(0, 8),
|
|
158
|
+
requestId,
|
|
159
|
+
};
|
|
160
|
+
if (new Date(record.expiresAt).getTime() <= Date.now()) {
|
|
161
|
+
await consume(req, record.id, "expired");
|
|
162
|
+
await writeAudit(req, options, { ...event, outcome: "expired" });
|
|
163
|
+
return { ok: false, error: "stale_context", message: "The proposal expired." };
|
|
164
|
+
}
|
|
165
|
+
if (input.action === "reject") {
|
|
166
|
+
await consume(req, record.id, "rejected");
|
|
167
|
+
await writeAudit(req, options, { ...event, outcome: "rejected" });
|
|
168
|
+
return { ok: true, status: "rejected" };
|
|
169
|
+
}
|
|
170
|
+
const fail = async (error, message) => {
|
|
171
|
+
await consume(req, record.id, "failed");
|
|
172
|
+
await writeAudit(req, options, { ...event, outcome: "failed", errorClass: error });
|
|
173
|
+
return { ok: false, error, message };
|
|
174
|
+
};
|
|
175
|
+
const resolved = resolveAllowlist(options, { collection: record.targetCollection });
|
|
176
|
+
if (!resolved || !hasCapability(resolved.allowlist, "content.update")) {
|
|
177
|
+
return fail("tool_denied", "Batch changes are not allowed here.");
|
|
178
|
+
}
|
|
179
|
+
if (!Array.isArray(record.operations) || !record.operations.every(isFormOperation)) {
|
|
180
|
+
return fail("tool_denied", "Stored operations are invalid.");
|
|
181
|
+
}
|
|
182
|
+
const stored = record.operations;
|
|
183
|
+
if ((await hashFormRevision(stored)) !== record.canonicalHash) {
|
|
184
|
+
return fail("tool_denied", "Proposal hash mismatch.");
|
|
185
|
+
}
|
|
186
|
+
const saved = await readSaved(req, options, record.targetCollection, record.documentId, record.locale);
|
|
187
|
+
if (!saved) {
|
|
188
|
+
return fail("tool_denied", "Document is not accessible.");
|
|
189
|
+
}
|
|
190
|
+
if ((await hashFormRevision(saved.data)) !== record.formRevision) {
|
|
191
|
+
return fail("stale_context", "The document changed after the proposal.");
|
|
192
|
+
}
|
|
193
|
+
const fields = entityFields(req, options, { collection: record.targetCollection });
|
|
194
|
+
const edited = withEdits(stored, input.edits, fields, saved.data);
|
|
195
|
+
if (!edited.ok) {
|
|
196
|
+
// Not the editor's approval to spend: the card can still be saved without the edit.
|
|
197
|
+
return { ok: false, error: "tool_denied", message: edited.message };
|
|
198
|
+
}
|
|
199
|
+
const operations = edited.operations;
|
|
200
|
+
const compiled = compileOperations(openRichText(saved.data, fields), operations, {
|
|
201
|
+
allowlist: resolved.allowlist.fields,
|
|
202
|
+
denyFields: options.denyFields,
|
|
203
|
+
});
|
|
204
|
+
if (!compiled.ok) {
|
|
205
|
+
return fail("tool_denied", compiled.error);
|
|
206
|
+
}
|
|
207
|
+
const finalized = finalizeCompiled(compiled.data, fields, saved.data);
|
|
208
|
+
if (!finalized.ok) {
|
|
209
|
+
return fail("tool_denied", finalized.error);
|
|
210
|
+
}
|
|
211
|
+
const paths = changedPaths(operations);
|
|
212
|
+
const relationError = relationValueError(fields, finalized.data, paths);
|
|
213
|
+
if (relationError) {
|
|
214
|
+
return fail("tool_denied", relationError);
|
|
215
|
+
}
|
|
216
|
+
if (options.validateProposal) {
|
|
217
|
+
const extra = await options.validateProposal({ operations, data: finalized.data });
|
|
218
|
+
if (!extra.ok) {
|
|
219
|
+
return fail("tool_denied", extra.errors.join(" "));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const relations = await verifyDocumentRelations(req, fields, finalized.data, finalized.links);
|
|
223
|
+
if (!relations.ok) {
|
|
224
|
+
return fail("tool_denied", relations.message);
|
|
225
|
+
}
|
|
226
|
+
const drafts = hasDrafts(findCollectionConfig(req, record.targetCollection)?.versions);
|
|
227
|
+
try {
|
|
228
|
+
await localApi(req).update({
|
|
229
|
+
collection: record.targetCollection,
|
|
230
|
+
id: record.documentId,
|
|
231
|
+
locale: record.locale,
|
|
232
|
+
data: changedTopLevel(saved.raw, finalized.data, paths),
|
|
233
|
+
draft: drafts,
|
|
234
|
+
depth: 0,
|
|
235
|
+
req,
|
|
236
|
+
user: req.user,
|
|
237
|
+
overrideAccess: false,
|
|
238
|
+
overrideLock: false,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
return isLocked(error)
|
|
243
|
+
? fail("locked", "Someone else is editing the document.")
|
|
244
|
+
: fail("tool_denied", "The document could not be saved.");
|
|
245
|
+
}
|
|
246
|
+
const canonicalHash = await hashFormRevision(operations);
|
|
247
|
+
await consume(req, record.id, "executed");
|
|
248
|
+
// A hash that differs from the proposed event's says the editor changed the wording.
|
|
249
|
+
await writeAudit(req, options, {
|
|
250
|
+
...event,
|
|
251
|
+
outcome: "executed",
|
|
252
|
+
fieldPaths: paths,
|
|
253
|
+
canonicalHash,
|
|
254
|
+
baseRevision: record.formRevision ?? undefined,
|
|
255
|
+
});
|
|
256
|
+
return { ok: true, status: drafts ? "draft" : "saved" };
|
|
257
|
+
}
|
|
258
|
+
/** The stored operations with the editor's wording, only where they set plain text. */
|
|
259
|
+
function withEdits(operations, edits, fields, saved) {
|
|
260
|
+
if (!edits || Object.keys(edits).length === 0) {
|
|
261
|
+
return { ok: true, operations };
|
|
262
|
+
}
|
|
263
|
+
const next = cloneData(operations);
|
|
264
|
+
for (const [path, value] of Object.entries(edits)) {
|
|
265
|
+
const operation = next.find((entry) => entry.op === "set" && entry.path === path);
|
|
266
|
+
const type = fieldAtPath(fields, path, saved)?.type;
|
|
267
|
+
if (operation?.op !== "set" ||
|
|
268
|
+
typeof operation.value !== "string" ||
|
|
269
|
+
(type !== "text" && type !== "textarea") ||
|
|
270
|
+
typeof value !== "string" ||
|
|
271
|
+
value.length > MAX_EDIT_LENGTH) {
|
|
272
|
+
return { ok: false, message: `${path} cannot be edited here.` };
|
|
273
|
+
}
|
|
274
|
+
operation.value = value;
|
|
275
|
+
}
|
|
276
|
+
return { ok: true, operations: next };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The top-level fields the change reaches, as saved with only the changed values replaced, so
|
|
280
|
+
* fields the assistant does not see (denied, redacted) are written back as they were.
|
|
281
|
+
*/
|
|
282
|
+
function changedTopLevel(raw, changed, paths) {
|
|
283
|
+
const patched = cloneData(raw);
|
|
284
|
+
const data = {};
|
|
285
|
+
for (const path of paths) {
|
|
286
|
+
const segments = parseFieldPath(path);
|
|
287
|
+
setAtPath(patched, segments, getAtPath(changed, segments) ?? null);
|
|
288
|
+
const key = String(segments[0]);
|
|
289
|
+
data[key] = patched[key];
|
|
290
|
+
}
|
|
291
|
+
return data;
|
|
292
|
+
}
|
|
293
|
+
/** Payload's error for a document another editor has open (`Locked`, status 423). */
|
|
294
|
+
function isLocked(error) {
|
|
295
|
+
const record = error;
|
|
296
|
+
return record?.name === "Locked" || record?.status === 423;
|
|
297
|
+
}
|
|
298
|
+
async function consume(req, id, status) {
|
|
299
|
+
await localApi(req).update({
|
|
300
|
+
collection: DEFAULT_APPROVAL_SLUG,
|
|
301
|
+
id,
|
|
302
|
+
data: { status },
|
|
303
|
+
req,
|
|
304
|
+
user: req.user,
|
|
305
|
+
overrideAccess: false,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/** What the panel shows before the batch runs: titles and whether each document can be changed. */
|
|
309
|
+
export async function describeBatchTargets(req, options, targets) {
|
|
310
|
+
const infos = [];
|
|
311
|
+
// One after the other: the reads share the request.
|
|
312
|
+
for (const target of targets) {
|
|
313
|
+
const resolved = resolveAllowlist(options, { collection: target.collection });
|
|
314
|
+
const config = findCollectionConfig(req, target.collection);
|
|
315
|
+
const allowed = Boolean(resolved && hasCapability(resolved.allowlist, "content.update"));
|
|
316
|
+
const read = allowed
|
|
317
|
+
? await readDocument(req, options, {
|
|
318
|
+
collection: target.collection,
|
|
319
|
+
documentId: target.id,
|
|
320
|
+
locale: target.locale ?? configuredDefaultLocale(req) ?? "en",
|
|
321
|
+
})
|
|
322
|
+
: undefined;
|
|
323
|
+
const doc = read?.ok ? read.doc : undefined;
|
|
324
|
+
const useAsTitle = config?.admin?.useAsTitle;
|
|
325
|
+
const title = [useAsTitle ? doc?.[useAsTitle] : undefined, doc?.filename].find((value) => typeof value === "string" && value.trim() !== "");
|
|
326
|
+
infos.push({
|
|
327
|
+
key: target.key,
|
|
328
|
+
title: title ?? target.id,
|
|
329
|
+
thumbnail: doc && config?.upload ? thumbnailOf(doc) : undefined,
|
|
330
|
+
drafts: hasDrafts(config?.versions),
|
|
331
|
+
allowed: Boolean(doc),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return infos;
|
|
335
|
+
}
|
|
336
|
+
function thumbnailOf(doc) {
|
|
337
|
+
const sizes = doc.sizes;
|
|
338
|
+
const candidates = [doc.thumbnailURL, sizes?.thumbnail?.url, doc.url];
|
|
339
|
+
const mimeType = typeof doc.mimeType === "string" ? doc.mimeType : "";
|
|
340
|
+
const url = candidates.find((value) => typeof value === "string" && !!value);
|
|
341
|
+
return url && (!mimeType || mimeType.startsWith("image/")) ? url : undefined;
|
|
342
|
+
}
|
package/dist/domain/content.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PayloadRequest } from "payload";
|
|
2
2
|
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
|
+
import type { FieldNode } from "../schema/fields.ts";
|
|
3
4
|
export type ContentFindInput = {
|
|
4
5
|
collection?: string;
|
|
5
6
|
query: string;
|
|
@@ -28,3 +29,18 @@ export declare function findContentById(req: PayloadRequest, options: ValidatedE
|
|
|
28
29
|
error: string;
|
|
29
30
|
message: string;
|
|
30
31
|
}>;
|
|
32
|
+
/**
|
|
33
|
+
* A document or global as Admin reads it, with the editor's access: the latest draft, and a field
|
|
34
|
+
* nobody filled in this language empty rather than another language's text. Unprojected; callers
|
|
35
|
+
* project it or check their own capability first.
|
|
36
|
+
*/
|
|
37
|
+
export declare function readDocument(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: ContentFindByIdInput): Promise<{
|
|
38
|
+
ok: true;
|
|
39
|
+
doc: Record<string, unknown>;
|
|
40
|
+
fields: FieldNode[];
|
|
41
|
+
slug: string;
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
error: string;
|
|
45
|
+
message: string;
|
|
46
|
+
}>;
|
package/dist/domain/content.js
CHANGED
|
@@ -74,6 +74,18 @@ export async function findContentById(req, options, input) {
|
|
|
74
74
|
if (!resolved || !hasCapability(resolved.allowlist, "content.findById")) {
|
|
75
75
|
return { ok: false, error: "tool_denied", message: "Target is not allowlisted for read." };
|
|
76
76
|
}
|
|
77
|
+
const read = await readDocument(req, options, input);
|
|
78
|
+
if (!read.ok) {
|
|
79
|
+
return read;
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, ...projectRecord(read.doc, read.fields, options.redact, read.slug) };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A document or global as Admin reads it, with the editor's access: the latest draft, and a field
|
|
85
|
+
* nobody filled in this language empty rather than another language's text. Unprojected; callers
|
|
86
|
+
* project it or check their own capability first.
|
|
87
|
+
*/
|
|
88
|
+
export async function readDocument(req, options, input) {
|
|
77
89
|
const fields = entityFields(req, options, input);
|
|
78
90
|
try {
|
|
79
91
|
if (input.global) {
|
|
@@ -85,15 +97,13 @@ export async function findContentById(req, options, input) {
|
|
|
85
97
|
slug: input.global,
|
|
86
98
|
depth: 0,
|
|
87
99
|
locale: input.locale,
|
|
88
|
-
// As Admin reads it: a field nobody filled in this language is empty, not another's text.
|
|
89
100
|
fallbackLocale: false,
|
|
90
101
|
draft: true,
|
|
91
102
|
req,
|
|
92
103
|
user: req.user,
|
|
93
104
|
overrideAccess: false,
|
|
94
105
|
});
|
|
95
|
-
|
|
96
|
-
return { ok: true, ...projected };
|
|
106
|
+
return { ok: true, doc, fields, slug: input.global };
|
|
97
107
|
}
|
|
98
108
|
if (!input.collection || input.documentId === undefined) {
|
|
99
109
|
return {
|
|
@@ -113,8 +123,7 @@ export async function findContentById(req, options, input) {
|
|
|
113
123
|
user: req.user,
|
|
114
124
|
overrideAccess: false,
|
|
115
125
|
});
|
|
116
|
-
|
|
117
|
-
return { ok: true, ...projected };
|
|
126
|
+
return { ok: true, doc, fields, slug: input.collection };
|
|
118
127
|
}
|
|
119
128
|
catch {
|
|
120
129
|
return { ok: false, error: "tool_denied", message: "Document is not accessible." };
|
package/dist/domain/decide.js
CHANGED
|
@@ -27,6 +27,10 @@ export async function decideChange(req, options, input) {
|
|
|
27
27
|
if (record.status !== "proposed") {
|
|
28
28
|
return { ok: false, error: "tool_denied", message: "Approval is no longer usable." };
|
|
29
29
|
}
|
|
30
|
+
// A batch change is saved or rejected from the batch, against the saved document.
|
|
31
|
+
if (record.capability === "content.update") {
|
|
32
|
+
return { ok: false, error: "tool_denied", message: "Approval belongs to a batch." };
|
|
33
|
+
}
|
|
30
34
|
if (new Date(record.expiresAt).getTime() <= Date.now()) {
|
|
31
35
|
await consume(req, record.id, "expired");
|
|
32
36
|
return { ok: false, error: "stale_context", message: "Approval expired." };
|
package/dist/domain/guard.d.ts
CHANGED
|
@@ -9,5 +9,7 @@ export type GuardSuccess = {
|
|
|
9
9
|
ok: true;
|
|
10
10
|
actor: string;
|
|
11
11
|
};
|
|
12
|
-
export declare function guardMutationRequest(req: PayloadRequest, options: ValidatedEditorAssistantOptions
|
|
12
|
+
export declare function guardMutationRequest(req: PayloadRequest, options: ValidatedEditorAssistantOptions, guard?: {
|
|
13
|
+
rateLimit?: boolean;
|
|
14
|
+
}): Promise<GuardFailure | GuardSuccess>;
|
|
13
15
|
export declare function readJsonBody<T>(req: PayloadRequest): Promise<T | null>;
|
package/dist/domain/guard.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { verifyCsrf, verifyOrigin } from "../endpoints/csrf.js";
|
|
2
2
|
import { consumeRateLimit } from "../runtime/rate-limit.js";
|
|
3
|
-
export async function guardMutationRequest(req, options) {
|
|
3
|
+
export async function guardMutationRequest(req, options, guard = {}) {
|
|
4
4
|
if (!req.user) {
|
|
5
5
|
return { ok: false, status: 401, error: "unauthorized" };
|
|
6
6
|
}
|
|
@@ -11,7 +11,7 @@ export async function guardMutationRequest(req, options) {
|
|
|
11
11
|
return { ok: false, status: 403, error: "unavailable" };
|
|
12
12
|
}
|
|
13
13
|
const actor = String(req.user.id);
|
|
14
|
-
if (!consumeRateLimit(actor)) {
|
|
14
|
+
if (guard.rateLimit !== false && !consumeRateLimit(actor)) {
|
|
15
15
|
return { ok: false, status: 429, error: "rate_limited" };
|
|
16
16
|
}
|
|
17
17
|
return { ok: true, actor };
|
package/dist/domain/propose.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { PayloadRequest } from "payload";
|
|
2
2
|
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
3
|
export type ProposeInput = {
|
|
4
|
-
capability: "form.propose" | "draft.create";
|
|
4
|
+
capability: "form.propose" | "draft.create" | "content.update";
|
|
5
5
|
collection?: string;
|
|
6
6
|
global?: string;
|
|
7
7
|
documentId?: string | number;
|
|
@@ -10,6 +10,23 @@ export type ProposeInput = {
|
|
|
10
10
|
language?: "sv" | "en";
|
|
11
11
|
operations: unknown;
|
|
12
12
|
formData?: Record<string, unknown>;
|
|
13
|
+
/**
|
|
14
|
+
* `content.update` only: the fields the change may reach, each with what is under it. A batch
|
|
15
|
+
* proposal is against the saved document, so `formData` is ignored.
|
|
16
|
+
*/
|
|
17
|
+
allowedPaths?: string[];
|
|
18
|
+
/** Logged with the audit events, e.g. `batch:<id>`. */
|
|
19
|
+
requestId?: string;
|
|
20
|
+
};
|
|
21
|
+
/** One line of the change list; a plain text value the editor may rewrite before saving has `edit`. */
|
|
22
|
+
export type ProposalLine = {
|
|
23
|
+
path: string;
|
|
24
|
+
text: string;
|
|
25
|
+
edit?: {
|
|
26
|
+
label: string;
|
|
27
|
+
before: string;
|
|
28
|
+
value: string;
|
|
29
|
+
};
|
|
13
30
|
};
|
|
14
31
|
/** The form as it would be with a proposal applied, so Admin can show it before it is kept. */
|
|
15
32
|
export type ProposalPreview = {
|
|
@@ -23,6 +40,9 @@ export type ProposeResult = {
|
|
|
23
40
|
expiresAt: string;
|
|
24
41
|
summary: string;
|
|
25
42
|
title: string;
|
|
43
|
+
lines: ProposalLine[];
|
|
44
|
+
/** Characters in each plain text value set, as content health counts them. */
|
|
45
|
+
characters: Record<string, number>;
|
|
26
46
|
preview?: ProposalPreview;
|
|
27
47
|
} | {
|
|
28
48
|
ok: false;
|
|
@@ -30,3 +50,9 @@ export type ProposeResult = {
|
|
|
30
50
|
message: string;
|
|
31
51
|
};
|
|
32
52
|
export declare function proposeChange(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: ProposeInput): Promise<ProposeResult>;
|
|
53
|
+
/** A saved document as the batch sees it: allowlisted fields in the target's language. */
|
|
54
|
+
export declare function readSavedForm(req: PayloadRequest, options: ValidatedEditorAssistantOptions, target: {
|
|
55
|
+
collection?: string;
|
|
56
|
+
documentId?: string | number;
|
|
57
|
+
locale: string;
|
|
58
|
+
}): Promise<Record<string, unknown> | null>;
|
package/dist/domain/propose.js
CHANGED
|
@@ -10,9 +10,10 @@ import { finalizeCompiled, openRichText } from "../form/shape.js";
|
|
|
10
10
|
import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
|
|
11
11
|
import { entityFields, findCollectionConfig, hasDrafts } from "../schema/discover.js";
|
|
12
12
|
import { fieldAtPath } from "../schema/fields.js";
|
|
13
|
+
import { projectRecord } from "../schema/project.js";
|
|
13
14
|
import { hasCapability, resolveAllowlist } from "./allowlist.js";
|
|
14
15
|
import { writeAudit } from "./audit.js";
|
|
15
|
-
import { findContentById } from "./content.js";
|
|
16
|
+
import { findContentById, readDocument } from "./content.js";
|
|
16
17
|
import { localApi } from "./local-api.js";
|
|
17
18
|
import { labelRelations, verifyDocumentRelations } from "./relations.js";
|
|
18
19
|
/** Long enough to look at a change in the preview before keeping it. */
|
|
@@ -28,7 +29,17 @@ export async function proposeChange(req, options, input) {
|
|
|
28
29
|
return { ok: false, error: "tool_denied", message: "Operations are invalid." };
|
|
29
30
|
}
|
|
30
31
|
const typedOperations = operations;
|
|
31
|
-
|
|
32
|
+
if (input.capability === "content.update") {
|
|
33
|
+
const outside = batchScopeError(typedOperations, input);
|
|
34
|
+
if (outside) {
|
|
35
|
+
return { ok: false, error: "tool_denied", message: outside };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const resolvedForm = await resolveCurrentForm(req, options, input);
|
|
39
|
+
if (!resolvedForm) {
|
|
40
|
+
return { ok: false, error: "tool_denied", message: "Document is not accessible." };
|
|
41
|
+
}
|
|
42
|
+
const current = resolvedForm;
|
|
32
43
|
const fields = entityFields(req, options, {
|
|
33
44
|
collection: input.collection,
|
|
34
45
|
global: input.global,
|
|
@@ -73,7 +84,7 @@ export async function proposeChange(req, options, input) {
|
|
|
73
84
|
if (!relations.ok) {
|
|
74
85
|
return { ok: false, error: "tool_denied", message: relations.message };
|
|
75
86
|
}
|
|
76
|
-
const formRevision = input.capability === "
|
|
87
|
+
const formRevision = input.capability === "draft.create" ? undefined : await hashFormRevision(current);
|
|
77
88
|
const canonicalHash = await hashFormRevision(typedOperations);
|
|
78
89
|
const nonce = crypto.randomUUID();
|
|
79
90
|
const expiresAt = new Date(Date.now() + TTL_MS).toISOString();
|
|
@@ -90,6 +101,10 @@ export async function proposeChange(req, options, input) {
|
|
|
90
101
|
...humanDiff(current, typedOperations.filter((operation) => !inRichText(operation)), input.language, labels),
|
|
91
102
|
...richTextBefore.flatMap(({ path, blocks, after }) => richTextLines(path, blocks, Array.isArray(after) ? after : [after], input.language)),
|
|
92
103
|
];
|
|
104
|
+
const lines = diff.map((line) => ({
|
|
105
|
+
...line,
|
|
106
|
+
...editableLine(line.path, typedOperations, fields, current),
|
|
107
|
+
}));
|
|
93
108
|
let created;
|
|
94
109
|
try {
|
|
95
110
|
created = await localApi(req).create({
|
|
@@ -129,6 +144,7 @@ export async function proposeChange(req, options, input) {
|
|
|
129
144
|
nonceId: nonce.slice(0, 8),
|
|
130
145
|
canonicalHash,
|
|
131
146
|
baseRevision: formRevision,
|
|
147
|
+
requestId: input.requestId,
|
|
132
148
|
});
|
|
133
149
|
return {
|
|
134
150
|
ok: true,
|
|
@@ -137,6 +153,8 @@ export async function proposeChange(req, options, input) {
|
|
|
137
153
|
expiresAt,
|
|
138
154
|
title: approvalTitle(input.capability, input.language),
|
|
139
155
|
summary: diff.map((line) => line.text).join("\n"),
|
|
156
|
+
lines,
|
|
157
|
+
characters: Object.fromEntries(lines.flatMap((line) => line.edit ? [[line.path, [...line.edit.value.trim()].length]] : [])),
|
|
140
158
|
preview: input.capability === "form.propose"
|
|
141
159
|
? { data: finalized.data, changedPaths: changedPaths(typedOperations) }
|
|
142
160
|
: undefined,
|
|
@@ -144,12 +162,20 @@ export async function proposeChange(req, options, input) {
|
|
|
144
162
|
}
|
|
145
163
|
function approvalTitle(capability, language) {
|
|
146
164
|
if (language === "sv") {
|
|
147
|
-
return capability === "
|
|
165
|
+
return capability === "draft.create" ? "Nytt utkast att godkänna" : "Förslag i formuläret";
|
|
148
166
|
}
|
|
149
|
-
return capability === "
|
|
167
|
+
return capability === "draft.create" ? "Pending draft create" : "Suggested change";
|
|
150
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* The data the operations apply to: the open form over the saved document for `form.propose`, the
|
|
171
|
+
* saved document alone for `content.update` (null when the editor cannot read it), nothing for a
|
|
172
|
+
* new draft.
|
|
173
|
+
*/
|
|
151
174
|
async function resolveCurrentForm(req, options, input) {
|
|
152
175
|
const snapshot = input.formData && typeof input.formData === "object" ? input.formData : {};
|
|
176
|
+
if (input.capability === "content.update") {
|
|
177
|
+
return readSavedForm(req, options, input);
|
|
178
|
+
}
|
|
153
179
|
if (input.capability !== "form.propose") {
|
|
154
180
|
return {};
|
|
155
181
|
}
|
|
@@ -164,6 +190,63 @@ async function resolveCurrentForm(req, options, input) {
|
|
|
164
190
|
}
|
|
165
191
|
return { ...saved.data, ...snapshot };
|
|
166
192
|
}
|
|
193
|
+
/** A saved document as the batch sees it: allowlisted fields in the target's language. */
|
|
194
|
+
export async function readSavedForm(req, options, target) {
|
|
195
|
+
if (!target.collection || target.documentId === undefined) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const read = await readDocument(req, options, {
|
|
199
|
+
collection: target.collection,
|
|
200
|
+
documentId: target.documentId,
|
|
201
|
+
locale: target.locale,
|
|
202
|
+
});
|
|
203
|
+
if (!read.ok) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return projectRecord(read.doc, read.fields, options.redact, read.slug).data;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A batch proposal sets or clears values within its target's fields: no rows or blocks added,
|
|
210
|
+
* moved or removed, and nothing outside `allowedPaths`.
|
|
211
|
+
*/
|
|
212
|
+
function batchScopeError(operations, input) {
|
|
213
|
+
if (!input.collection || input.global || input.documentId === undefined) {
|
|
214
|
+
return "A batch change needs a document in a collection.";
|
|
215
|
+
}
|
|
216
|
+
const allowed = input.allowedPaths ?? [];
|
|
217
|
+
for (const operation of operations) {
|
|
218
|
+
if (operation.op !== "set" && operation.op !== "unset") {
|
|
219
|
+
return "A batch change only sets field values; rows and blocks stay as they are.";
|
|
220
|
+
}
|
|
221
|
+
const inside = allowed.some((path) => operation.path === path || operation.path.startsWith(`${path}.`));
|
|
222
|
+
if (!inside) {
|
|
223
|
+
return `${operation.path} is outside this task. Change only ${allowed.join(", ")}.`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
/** A plain text or textarea value this proposal sets, which the editor may rewrite in a batch. */
|
|
229
|
+
function editableLine(path, operations, fields, current) {
|
|
230
|
+
const operation = operations.find((entry) => entry.op === "set" && entry.path === path);
|
|
231
|
+
if (operation?.op !== "set" || typeof operation.value !== "string") {
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
const field = fieldAtPath(fields, path, current);
|
|
235
|
+
if (field?.type !== "text" && field?.type !== "textarea") {
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
const before = getAtPath(current, parseFieldPath(path));
|
|
239
|
+
return {
|
|
240
|
+
edit: {
|
|
241
|
+
label: capitalize(field.label ?? field.name),
|
|
242
|
+
before: typeof before === "string" ? before : "",
|
|
243
|
+
value: operation.value,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function capitalize(value) {
|
|
248
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
249
|
+
}
|
|
167
250
|
function draftCreateDenied(req, collection) {
|
|
168
251
|
if (!collection) {
|
|
169
252
|
return "Draft create requires a collection.";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Endpoint } from "payload";
|
|
2
|
+
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
|
+
/** Titles and access for the documents of a batch, before it runs. Read only. */
|
|
4
|
+
export declare function createBatchInfoEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
|
|
5
|
+
/** One document of a batch: a model turn that may leave a proposal to review. */
|
|
6
|
+
export declare function createBatchProposeEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
|
|
7
|
+
/** Saves or rejects one batch proposal. No model call, so it does not count against the rate. */
|
|
8
|
+
export declare function createBatchApplyEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
|