@simmalugnt-se/payload-editor-assistant 0.9.1 → 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 +27 -0
- package/README.md +40 -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 +105 -9
- package/dist/endpoints/batch.d.ts +8 -0
- package/dist/endpoints/batch.js +102 -0
- package/dist/form/diff.js +15 -2
- package/dist/form/relation-shape.d.ts +9 -0
- package/dist/form/relation-shape.js +74 -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/context.d.ts +4 -0
- package/dist/runtime/context.js +9 -1
- package/dist/runtime/system-prompt.js +14 -3
- 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/schema/fields.d.ts +3 -0
- package/dist/schema/fields.js +2 -0
- 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>;
|