@simmalugnt-se/payload-editor-assistant 0.1.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/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/capabilities.d.ts +10 -0
- package/dist/capabilities.js +43 -0
- package/dist/collections/approvals.d.ts +2 -0
- package/dist/collections/approvals.js +36 -0
- package/dist/collections/audit.d.ts +2 -0
- package/dist/collections/audit.js +40 -0
- package/dist/components/EditorAssistantAction.d.ts +1 -0
- package/dist/components/EditorAssistantAction.js +17 -0
- package/dist/components/EditorAssistantAside.d.ts +1 -0
- package/dist/components/EditorAssistantAside.js +335 -0
- package/dist/components/EditorAssistantDocumentBridge.d.ts +1 -0
- package/dist/components/EditorAssistantDocumentBridge.js +48 -0
- package/dist/components/EditorAssistantProvider.d.ts +8 -0
- package/dist/components/EditorAssistantProvider.js +14 -0
- package/dist/components/assistant-api.d.ts +9 -0
- package/dist/components/assistant-api.js +34 -0
- package/dist/components/assistant-context.d.ts +66 -0
- package/dist/components/assistant-context.js +69 -0
- package/dist/config/denied-fields.d.ts +3 -0
- package/dist/config/denied-fields.js +34 -0
- package/dist/config/validate.d.ts +15 -0
- package/dist/config/validate.js +86 -0
- package/dist/domain/allowlist.d.ts +12 -0
- package/dist/domain/allowlist.js +14 -0
- package/dist/domain/audit.d.ts +17 -0
- package/dist/domain/audit.js +25 -0
- package/dist/domain/content.d.ts +30 -0
- package/dist/domain/content.js +117 -0
- package/dist/domain/decide.d.ts +30 -0
- package/dist/domain/decide.js +162 -0
- package/dist/domain/guard.d.ts +13 -0
- package/dist/domain/guard.js +29 -0
- package/dist/domain/local-api.d.ts +16 -0
- package/dist/domain/local-api.js +3 -0
- package/dist/domain/navigate.d.ts +25 -0
- package/dist/domain/navigate.js +85 -0
- package/dist/domain/propose.d.ts +26 -0
- package/dist/domain/propose.js +150 -0
- package/dist/domain/relations.d.ts +12 -0
- package/dist/domain/relations.js +103 -0
- package/dist/endpoints/chat.d.ts +3 -0
- package/dist/endpoints/chat.js +88 -0
- package/dist/endpoints/csrf.d.ts +6 -0
- package/dist/endpoints/csrf.js +57 -0
- package/dist/endpoints/decide.d.ts +3 -0
- package/dist/endpoints/decide.js +33 -0
- package/dist/endpoints/propose.d.ts +3 -0
- package/dist/endpoints/propose.js +32 -0
- package/dist/exports/client.d.ts +4 -0
- package/dist/exports/client.js +4 -0
- package/dist/form/compile.d.ts +17 -0
- package/dist/form/compile.js +168 -0
- package/dist/form/diff.d.ts +9 -0
- package/dist/form/diff.js +155 -0
- package/dist/form/empty.d.ts +2 -0
- package/dist/form/empty.js +79 -0
- package/dist/form/hash.d.ts +2 -0
- package/dist/form/hash.js +22 -0
- package/dist/form/operations.d.ts +52 -0
- package/dist/form/operations.js +52 -0
- package/dist/form/path.d.ts +5 -0
- package/dist/form/path.js +79 -0
- package/dist/form/shape.d.ts +19 -0
- package/dist/form/shape.js +137 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/package-name.d.ts +7 -0
- package/dist/package-name.js +7 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +110 -0
- package/dist/runtime/agent.d.ts +42 -0
- package/dist/runtime/agent.js +262 -0
- package/dist/runtime/context.d.ts +37 -0
- package/dist/runtime/context.js +92 -0
- package/dist/runtime/inflight.d.ts +2 -0
- package/dist/runtime/inflight.js +11 -0
- package/dist/runtime/language.d.ts +10 -0
- package/dist/runtime/language.js +27 -0
- package/dist/runtime/propose-loop.d.ts +7 -0
- package/dist/runtime/propose-loop.js +11 -0
- package/dist/runtime/rate-limit.d.ts +1 -0
- package/dist/runtime/rate-limit.js +14 -0
- package/dist/runtime/system-prompt.d.ts +3 -0
- package/dist/runtime/system-prompt.js +110 -0
- package/dist/runtime/tools.d.ts +56 -0
- package/dist/runtime/tools.js +189 -0
- package/dist/runtime/voice.d.ts +11 -0
- package/dist/runtime/voice.js +55 -0
- package/dist/schema/discover.d.ts +35 -0
- package/dist/schema/discover.js +93 -0
- package/dist/schema/fields.d.ts +21 -0
- package/dist/schema/fields.js +139 -0
- package/dist/schema/project.d.ts +15 -0
- package/dist/schema/project.js +111 -0
- package/dist/styles/assistant.css +362 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +3 -0
- package/dist/view.d.ts +22 -0
- package/dist/view.js +72 -0
- package/package.json +73 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { compileOperations } from "../form/compile.js";
|
|
2
|
+
import { hashFormRevision } from "../form/hash.js";
|
|
3
|
+
import { isFormOperation } from "../form/operations.js";
|
|
4
|
+
import { finalizeCompiled } from "../form/shape.js";
|
|
5
|
+
import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
|
|
6
|
+
import { entityFields } from "../schema/discover.js";
|
|
7
|
+
import { hasCapability, resolveAllowlist } from "./allowlist.js";
|
|
8
|
+
import { writeAudit } from "./audit.js";
|
|
9
|
+
import { localApi } from "./local-api.js";
|
|
10
|
+
import { verifyDocumentRelations } from "./relations.js";
|
|
11
|
+
export async function decideChange(req, options, input) {
|
|
12
|
+
const actor = String(req.user?.id);
|
|
13
|
+
const found = await localApi(req).findByID({
|
|
14
|
+
collection: DEFAULT_APPROVAL_SLUG,
|
|
15
|
+
id: input.approvalId,
|
|
16
|
+
req,
|
|
17
|
+
overrideAccess: false,
|
|
18
|
+
user: req.user,
|
|
19
|
+
depth: 0,
|
|
20
|
+
});
|
|
21
|
+
const record = found;
|
|
22
|
+
if (!record || record.actor !== actor || record.nonce !== input.nonce) {
|
|
23
|
+
return { ok: false, error: "tool_denied", message: "Approval is not valid." };
|
|
24
|
+
}
|
|
25
|
+
if (record.status !== "proposed") {
|
|
26
|
+
return { ok: false, error: "tool_denied", message: "Approval is no longer usable." };
|
|
27
|
+
}
|
|
28
|
+
if (new Date(record.expiresAt).getTime() <= Date.now()) {
|
|
29
|
+
await consume(req, record.id, "expired");
|
|
30
|
+
return { ok: false, error: "stale_context", message: "Approval expired." };
|
|
31
|
+
}
|
|
32
|
+
if (input.action === "reject") {
|
|
33
|
+
await consume(req, record.id, "rejected");
|
|
34
|
+
await writeAudit(req, options, {
|
|
35
|
+
capability: record.capability,
|
|
36
|
+
risk: "approval",
|
|
37
|
+
outcome: "rejected",
|
|
38
|
+
approvalId: String(record.id),
|
|
39
|
+
nonceId: record.nonce.slice(0, 8),
|
|
40
|
+
locale: record.locale,
|
|
41
|
+
});
|
|
42
|
+
return { ok: true, rejected: true };
|
|
43
|
+
}
|
|
44
|
+
const target = {
|
|
45
|
+
collection: record.targetCollection ?? undefined,
|
|
46
|
+
global: record.targetGlobal ?? undefined,
|
|
47
|
+
};
|
|
48
|
+
const resolved = resolveAllowlist(options, target);
|
|
49
|
+
if (!resolved || !hasCapability(resolved.allowlist, record.capability)) {
|
|
50
|
+
await consume(req, record.id, "failed");
|
|
51
|
+
return { ok: false, error: "tool_denied", message: "Capability is not allowlisted." };
|
|
52
|
+
}
|
|
53
|
+
if (record.capability === "form.propose" && identityMismatch(record, input)) {
|
|
54
|
+
await consume(req, record.id, "failed");
|
|
55
|
+
return { ok: false, error: "stale_context", message: "The open document changed." };
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(record.operations) || !record.operations.every(isFormOperation)) {
|
|
58
|
+
await consume(req, record.id, "failed");
|
|
59
|
+
return { ok: false, error: "tool_denied", message: "Stored operations are invalid." };
|
|
60
|
+
}
|
|
61
|
+
const operations = record.operations;
|
|
62
|
+
const current = record.capability === "form.propose" ? (input.formData ?? {}) : {};
|
|
63
|
+
if (record.capability === "form.propose") {
|
|
64
|
+
const currentHash = await hashFormRevision(current);
|
|
65
|
+
if (currentHash !== record.formRevision) {
|
|
66
|
+
await consume(req, record.id, "failed");
|
|
67
|
+
return { ok: false, error: "stale_context", message: "The form changed before approval." };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const compiled = compileOperations(current, operations, {
|
|
71
|
+
allowlist: resolved.allowlist.fields,
|
|
72
|
+
denyFields: options.denyFields,
|
|
73
|
+
});
|
|
74
|
+
if (!compiled.ok) {
|
|
75
|
+
await consume(req, record.id, "failed");
|
|
76
|
+
return { ok: false, error: "tool_denied", message: compiled.error };
|
|
77
|
+
}
|
|
78
|
+
const canonicalHash = await hashFormRevision(operations);
|
|
79
|
+
if (canonicalHash !== record.canonicalHash) {
|
|
80
|
+
await consume(req, record.id, "failed");
|
|
81
|
+
return { ok: false, error: "tool_denied", message: "Proposal hash mismatch." };
|
|
82
|
+
}
|
|
83
|
+
const fields = entityFields(req, options, {
|
|
84
|
+
collection: record.targetCollection ?? undefined,
|
|
85
|
+
global: record.targetGlobal ?? undefined,
|
|
86
|
+
});
|
|
87
|
+
const finalized = finalizeCompiled(compiled.data, fields);
|
|
88
|
+
if (!finalized.ok) {
|
|
89
|
+
await consume(req, record.id, "failed");
|
|
90
|
+
return { ok: false, error: "tool_denied", message: finalized.error };
|
|
91
|
+
}
|
|
92
|
+
const relations = await verifyDocumentRelations(req, fields, finalized.data);
|
|
93
|
+
if (!relations.ok) {
|
|
94
|
+
await consume(req, record.id, "failed");
|
|
95
|
+
return { ok: false, error: "tool_denied", message: relations.message };
|
|
96
|
+
}
|
|
97
|
+
if (record.capability === "draft.create") {
|
|
98
|
+
if (!record.targetCollection) {
|
|
99
|
+
await consume(req, record.id, "failed");
|
|
100
|
+
return { ok: false, error: "tool_denied", message: "Draft create requires a collection." };
|
|
101
|
+
}
|
|
102
|
+
const created = await localApi(req).create({
|
|
103
|
+
collection: record.targetCollection,
|
|
104
|
+
data: { ...finalized.data, _status: "draft" },
|
|
105
|
+
draft: true,
|
|
106
|
+
locale: record.locale,
|
|
107
|
+
req,
|
|
108
|
+
user: req.user,
|
|
109
|
+
overrideAccess: false,
|
|
110
|
+
overrideLock: false,
|
|
111
|
+
});
|
|
112
|
+
await consume(req, record.id, "executed");
|
|
113
|
+
await writeAudit(req, options, {
|
|
114
|
+
capability: "draft.create",
|
|
115
|
+
risk: "approval",
|
|
116
|
+
outcome: "executed",
|
|
117
|
+
targetCollection: record.targetCollection,
|
|
118
|
+
documentId: String(created.id),
|
|
119
|
+
locale: record.locale,
|
|
120
|
+
approvalId: String(record.id),
|
|
121
|
+
nonceId: record.nonce.slice(0, 8),
|
|
122
|
+
canonicalHash,
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
capability: "draft.create",
|
|
127
|
+
documentId: String(created.id),
|
|
128
|
+
collection: record.targetCollection,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
await consume(req, record.id, "executed");
|
|
132
|
+
await writeAudit(req, options, {
|
|
133
|
+
capability: "form.propose",
|
|
134
|
+
risk: "approval",
|
|
135
|
+
outcome: "executed",
|
|
136
|
+
targetCollection: record.targetCollection ?? undefined,
|
|
137
|
+
targetGlobal: record.targetGlobal ?? undefined,
|
|
138
|
+
documentId: record.documentId ?? undefined,
|
|
139
|
+
locale: record.locale,
|
|
140
|
+
approvalId: String(record.id),
|
|
141
|
+
nonceId: record.nonce.slice(0, 8),
|
|
142
|
+
canonicalHash,
|
|
143
|
+
baseRevision: record.formRevision ?? undefined,
|
|
144
|
+
});
|
|
145
|
+
return { ok: true, capability: "form.propose", patchedData: finalized.data };
|
|
146
|
+
}
|
|
147
|
+
function identityMismatch(record, input) {
|
|
148
|
+
return ((input.collection ?? "") !== (record.targetCollection ?? "") ||
|
|
149
|
+
(input.global ?? "") !== (record.targetGlobal ?? "") ||
|
|
150
|
+
String(input.documentId ?? "") !== (record.documentId ?? "") ||
|
|
151
|
+
input.locale !== record.locale);
|
|
152
|
+
}
|
|
153
|
+
async function consume(req, id, status) {
|
|
154
|
+
await localApi(req).update({
|
|
155
|
+
collection: DEFAULT_APPROVAL_SLUG,
|
|
156
|
+
id,
|
|
157
|
+
data: { status },
|
|
158
|
+
req,
|
|
159
|
+
user: req.user,
|
|
160
|
+
overrideAccess: false,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
|
+
export type GuardFailure = {
|
|
4
|
+
ok: false;
|
|
5
|
+
status: number;
|
|
6
|
+
error: "unauthorized" | "unavailable" | "rate_limited";
|
|
7
|
+
};
|
|
8
|
+
export type GuardSuccess = {
|
|
9
|
+
ok: true;
|
|
10
|
+
actor: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function guardMutationRequest(req: PayloadRequest, options: ValidatedEditorAssistantOptions): Promise<GuardFailure | GuardSuccess>;
|
|
13
|
+
export declare function readJsonBody<T>(req: PayloadRequest): Promise<T | null>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { verifyCsrf, verifyOrigin } from "../endpoints/csrf.js";
|
|
2
|
+
import { consumeRateLimit } from "../runtime/rate-limit.js";
|
|
3
|
+
export async function guardMutationRequest(req, options) {
|
|
4
|
+
if (!req.user) {
|
|
5
|
+
return { ok: false, status: 401, error: "unauthorized" };
|
|
6
|
+
}
|
|
7
|
+
if (!verifyOrigin(req, options.trustedOrigins) || !verifyCsrf(req)) {
|
|
8
|
+
return { ok: false, status: 403, error: "unauthorized" };
|
|
9
|
+
}
|
|
10
|
+
if (options.enabled && !(await options.enabled({ userId: req.user.id }))) {
|
|
11
|
+
return { ok: false, status: 403, error: "unavailable" };
|
|
12
|
+
}
|
|
13
|
+
const actor = String(req.user.id);
|
|
14
|
+
if (!consumeRateLimit(actor)) {
|
|
15
|
+
return { ok: false, status: 429, error: "rate_limited" };
|
|
16
|
+
}
|
|
17
|
+
return { ok: true, actor };
|
|
18
|
+
}
|
|
19
|
+
export async function readJsonBody(req) {
|
|
20
|
+
if (typeof req.json !== "function") {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
return (await req.json());
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
export type LooseDoc = Record<string, unknown> & {
|
|
3
|
+
id?: string | number;
|
|
4
|
+
};
|
|
5
|
+
type LoosePayload = {
|
|
6
|
+
config: PayloadRequest["payload"]["config"];
|
|
7
|
+
find: (args: Record<string, unknown>) => Promise<{
|
|
8
|
+
docs: LooseDoc[];
|
|
9
|
+
}>;
|
|
10
|
+
findByID: (args: Record<string, unknown>) => Promise<LooseDoc>;
|
|
11
|
+
findGlobal: (args: Record<string, unknown>) => Promise<LooseDoc>;
|
|
12
|
+
create: (args: Record<string, unknown>) => Promise<LooseDoc>;
|
|
13
|
+
update: (args: Record<string, unknown>) => Promise<LooseDoc>;
|
|
14
|
+
};
|
|
15
|
+
export declare function localApi(req: PayloadRequest): LoosePayload;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
|
+
export type NavigateResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
href: string;
|
|
6
|
+
label: string;
|
|
7
|
+
} | {
|
|
8
|
+
ok: true;
|
|
9
|
+
candidates: Array<{
|
|
10
|
+
href: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}>;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
error: string;
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function navigateAdmin(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: {
|
|
19
|
+
collection?: string;
|
|
20
|
+
global?: string;
|
|
21
|
+
documentId?: string | number;
|
|
22
|
+
query?: string;
|
|
23
|
+
locale: string;
|
|
24
|
+
}): Promise<NavigateResult>;
|
|
25
|
+
export declare function canNavigate(options: ValidatedEditorAssistantOptions): boolean;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { findCollectionConfig, findGlobalConfig } from "../schema/discover.js";
|
|
2
|
+
import { resolveAllowlist } from "./allowlist.js";
|
|
3
|
+
import { findContent } from "./content.js";
|
|
4
|
+
import { localApi } from "./local-api.js";
|
|
5
|
+
export async function navigateAdmin(req, options, input) {
|
|
6
|
+
if (Object.keys(options.collections).length + Object.keys(options.globals).length === 0) {
|
|
7
|
+
return { ok: false, error: "tool_denied", message: "Nothing is allowlisted." };
|
|
8
|
+
}
|
|
9
|
+
const admin = req.payload.config.routes?.admin ?? "/admin";
|
|
10
|
+
if (input.global) {
|
|
11
|
+
if (!options.globals[input.global] || !findGlobalConfig(req, input.global)) {
|
|
12
|
+
return { ok: false, error: "tool_denied", message: "Global is not allowlisted." };
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
ok: true,
|
|
16
|
+
href: `${admin}/globals/${input.global}`,
|
|
17
|
+
label: input.global,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (input.collection && input.documentId !== undefined) {
|
|
21
|
+
const resolved = resolveAllowlist(options, { collection: input.collection });
|
|
22
|
+
if (!resolved) {
|
|
23
|
+
return { ok: false, error: "tool_denied", message: "Collection is not allowlisted." };
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const doc = await localApi(req).findByID({
|
|
27
|
+
collection: input.collection,
|
|
28
|
+
id: input.documentId,
|
|
29
|
+
depth: 0,
|
|
30
|
+
locale: input.locale,
|
|
31
|
+
draft: true,
|
|
32
|
+
req,
|
|
33
|
+
user: req.user,
|
|
34
|
+
overrideAccess: false,
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
href: `${admin}/collections/${input.collection}/${input.documentId}`,
|
|
39
|
+
label: String(doc.title ?? doc.slug ?? input.documentId),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { ok: false, error: "tool_denied", message: "Document is not accessible." };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (input.collection && !input.query) {
|
|
47
|
+
if (!options.collections[input.collection] || !findCollectionConfig(req, input.collection)) {
|
|
48
|
+
return { ok: false, error: "tool_denied", message: "Collection is not allowlisted." };
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
href: `${admin}/collections/${input.collection}`,
|
|
53
|
+
label: input.collection,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (input.query) {
|
|
57
|
+
const found = await findContent(req, options, {
|
|
58
|
+
collection: input.collection,
|
|
59
|
+
query: input.query,
|
|
60
|
+
locale: input.locale,
|
|
61
|
+
});
|
|
62
|
+
if (!found.ok) {
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
const candidates = found.results.map((result) => ({
|
|
66
|
+
href: `${admin}/collections/${String(result.collection)}/${String(result.id)}`,
|
|
67
|
+
label: String(result.title ?? result.slug ?? result.id),
|
|
68
|
+
}));
|
|
69
|
+
if (candidates.length === 1) {
|
|
70
|
+
return { ok: true, href: candidates[0].href, label: candidates[0].label };
|
|
71
|
+
}
|
|
72
|
+
if (candidates.length === 0) {
|
|
73
|
+
return { ok: false, error: "tool_denied", message: "No matching documents." };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, candidates };
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: "tool_denied",
|
|
80
|
+
message: "A collection, global, or search query is required.",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function canNavigate(options) {
|
|
84
|
+
return Object.keys(options.collections).length + Object.keys(options.globals).length > 0;
|
|
85
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
|
+
export type ProposeInput = {
|
|
4
|
+
capability: "form.propose" | "draft.create";
|
|
5
|
+
collection?: string;
|
|
6
|
+
global?: string;
|
|
7
|
+
documentId?: string | number;
|
|
8
|
+
locale: string;
|
|
9
|
+
/** Editor's chat language for the approval summary. */
|
|
10
|
+
language?: "sv" | "en";
|
|
11
|
+
operations: unknown;
|
|
12
|
+
formData?: Record<string, unknown>;
|
|
13
|
+
};
|
|
14
|
+
export type ProposeResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
approvalId: string;
|
|
17
|
+
nonce: string;
|
|
18
|
+
expiresAt: string;
|
|
19
|
+
summary: string;
|
|
20
|
+
title: string;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
error: "tool_denied" | "stale_context";
|
|
24
|
+
message: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function proposeChange(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: ProposeInput): Promise<ProposeResult>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { compileOperations } from "../form/compile.js";
|
|
2
|
+
import { humanDiff } from "../form/diff.js";
|
|
3
|
+
import { hashFormRevision } from "../form/hash.js";
|
|
4
|
+
import { isFormOperation, normalizeOperations } from "../form/operations.js";
|
|
5
|
+
import { finalizeCompiled } from "../form/shape.js";
|
|
6
|
+
import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
|
|
7
|
+
import { entityFields, findCollectionConfig, hasDrafts } from "../schema/discover.js";
|
|
8
|
+
import { hasCapability, resolveAllowlist } from "./allowlist.js";
|
|
9
|
+
import { writeAudit } from "./audit.js";
|
|
10
|
+
import { findContentById } from "./content.js";
|
|
11
|
+
import { localApi } from "./local-api.js";
|
|
12
|
+
import { verifyDocumentRelations } from "./relations.js";
|
|
13
|
+
const TTL_MS = 120_000;
|
|
14
|
+
export async function proposeChange(req, options, input) {
|
|
15
|
+
const resolved = resolveAllowlist(options, input);
|
|
16
|
+
if (!resolved || !hasCapability(resolved.allowlist, input.capability)) {
|
|
17
|
+
return { ok: false, error: "tool_denied", message: "Capability is not allowlisted." };
|
|
18
|
+
}
|
|
19
|
+
const operations = normalizeOperations(input.operations);
|
|
20
|
+
if (operations.length === 0 || !operations.every(isFormOperation)) {
|
|
21
|
+
return { ok: false, error: "tool_denied", message: "Operations are invalid." };
|
|
22
|
+
}
|
|
23
|
+
const typedOperations = operations;
|
|
24
|
+
const current = await resolveCurrentForm(req, options, input);
|
|
25
|
+
const compiled = compileOperations(current, typedOperations, {
|
|
26
|
+
allowlist: resolved.allowlist.fields,
|
|
27
|
+
denyFields: options.denyFields,
|
|
28
|
+
});
|
|
29
|
+
if (!compiled.ok) {
|
|
30
|
+
return { ok: false, error: "tool_denied", message: compiled.error };
|
|
31
|
+
}
|
|
32
|
+
if (options.validateProposal) {
|
|
33
|
+
const extra = await options.validateProposal({
|
|
34
|
+
operations: typedOperations,
|
|
35
|
+
data: compiled.data,
|
|
36
|
+
});
|
|
37
|
+
if (!extra.ok) {
|
|
38
|
+
return { ok: false, error: "tool_denied", message: extra.errors.join(" ") };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (input.capability === "draft.create") {
|
|
42
|
+
const blocked = draftCreateDenied(req, input.collection);
|
|
43
|
+
if (blocked) {
|
|
44
|
+
return { ok: false, error: "tool_denied", message: blocked };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const fields = entityFields(req, options, {
|
|
48
|
+
collection: input.collection,
|
|
49
|
+
global: input.global,
|
|
50
|
+
});
|
|
51
|
+
const finalized = finalizeCompiled(compiled.data, fields);
|
|
52
|
+
if (!finalized.ok) {
|
|
53
|
+
return { ok: false, error: "tool_denied", message: finalized.error };
|
|
54
|
+
}
|
|
55
|
+
const relations = await verifyDocumentRelations(req, fields, finalized.data);
|
|
56
|
+
if (!relations.ok) {
|
|
57
|
+
return { ok: false, error: "tool_denied", message: relations.message };
|
|
58
|
+
}
|
|
59
|
+
const formRevision = input.capability === "form.propose" ? await hashFormRevision(current) : undefined;
|
|
60
|
+
const canonicalHash = await hashFormRevision(typedOperations);
|
|
61
|
+
const nonce = crypto.randomUUID();
|
|
62
|
+
const expiresAt = new Date(Date.now() + TTL_MS).toISOString();
|
|
63
|
+
const diff = humanDiff(current, typedOperations, input.language);
|
|
64
|
+
let created;
|
|
65
|
+
try {
|
|
66
|
+
created = await localApi(req).create({
|
|
67
|
+
collection: DEFAULT_APPROVAL_SLUG,
|
|
68
|
+
data: {
|
|
69
|
+
actor: String(req.user?.id),
|
|
70
|
+
capability: input.capability,
|
|
71
|
+
targetCollection: input.collection,
|
|
72
|
+
targetGlobal: input.global,
|
|
73
|
+
documentId: input.documentId === undefined ? undefined : String(input.documentId),
|
|
74
|
+
locale: input.locale,
|
|
75
|
+
canonicalHash,
|
|
76
|
+
formRevision,
|
|
77
|
+
nonce,
|
|
78
|
+
expiresAt,
|
|
79
|
+
status: "proposed",
|
|
80
|
+
operations: typedOperations,
|
|
81
|
+
},
|
|
82
|
+
req,
|
|
83
|
+
overrideAccess: false,
|
|
84
|
+
user: req.user,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { ok: false, error: "tool_denied", message: "The proposal could not be stored." };
|
|
89
|
+
}
|
|
90
|
+
await writeAudit(req, options, {
|
|
91
|
+
capability: input.capability,
|
|
92
|
+
risk: "approval",
|
|
93
|
+
outcome: "proposed",
|
|
94
|
+
targetCollection: input.collection,
|
|
95
|
+
targetGlobal: input.global,
|
|
96
|
+
documentId: input.documentId === undefined ? undefined : String(input.documentId),
|
|
97
|
+
locale: input.locale,
|
|
98
|
+
fieldPaths: typedOperations.map((operation) => ("path" in operation ? operation.path : "")),
|
|
99
|
+
approvalId: String(created.id),
|
|
100
|
+
nonceId: nonce.slice(0, 8),
|
|
101
|
+
canonicalHash,
|
|
102
|
+
baseRevision: formRevision,
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
approvalId: String(created.id),
|
|
107
|
+
nonce,
|
|
108
|
+
expiresAt,
|
|
109
|
+
title: approvalTitle(input.capability, input.language),
|
|
110
|
+
summary: diff.map((line) => line.text).join("\n"),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function approvalTitle(capability, language) {
|
|
114
|
+
if (language === "sv") {
|
|
115
|
+
return capability === "form.propose" ? "Ändring att godkänna" : "Nytt utkast att godkänna";
|
|
116
|
+
}
|
|
117
|
+
return capability === "form.propose" ? "Pending form change" : "Pending draft create";
|
|
118
|
+
}
|
|
119
|
+
async function resolveCurrentForm(req, options, input) {
|
|
120
|
+
const snapshot = input.formData && typeof input.formData === "object" ? input.formData : {};
|
|
121
|
+
if (input.capability !== "form.propose") {
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
const saved = await findContentById(req, options, {
|
|
125
|
+
collection: input.collection,
|
|
126
|
+
global: input.global,
|
|
127
|
+
documentId: input.documentId,
|
|
128
|
+
locale: input.locale,
|
|
129
|
+
});
|
|
130
|
+
if (!saved.ok) {
|
|
131
|
+
return snapshot;
|
|
132
|
+
}
|
|
133
|
+
return { ...saved.data, ...snapshot };
|
|
134
|
+
}
|
|
135
|
+
function draftCreateDenied(req, collection) {
|
|
136
|
+
if (!collection) {
|
|
137
|
+
return "Draft create requires a collection.";
|
|
138
|
+
}
|
|
139
|
+
const config = findCollectionConfig(req, collection);
|
|
140
|
+
if (!config) {
|
|
141
|
+
return "Collection was not found.";
|
|
142
|
+
}
|
|
143
|
+
if (!hasDrafts(config.versions)) {
|
|
144
|
+
return "Collection does not enable drafts.";
|
|
145
|
+
}
|
|
146
|
+
if (config.upload) {
|
|
147
|
+
return "Collections that require file upload cannot be created by the assistant.";
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { FieldNode } from "../schema/fields.ts";
|
|
3
|
+
export type RelationRef = {
|
|
4
|
+
collection: string;
|
|
5
|
+
id: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function verifyDocumentRelations(req: PayloadRequest, fields: FieldNode[], data: Record<string, unknown>): Promise<{
|
|
8
|
+
ok: true;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
message: string;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { localApi } from "./local-api.js";
|
|
2
|
+
export async function verifyDocumentRelations(req, fields, data) {
|
|
3
|
+
const refs = collectRefs(fields, data);
|
|
4
|
+
for (const ref of refs) {
|
|
5
|
+
try {
|
|
6
|
+
await localApi(req).findByID({
|
|
7
|
+
collection: ref.collection,
|
|
8
|
+
id: ref.id,
|
|
9
|
+
depth: 0,
|
|
10
|
+
req,
|
|
11
|
+
user: req.user,
|
|
12
|
+
overrideAccess: false,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
message: "A relation or upload ID is missing, inaccessible, or invented.",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { ok: true };
|
|
23
|
+
}
|
|
24
|
+
function collectRefs(fields, data) {
|
|
25
|
+
if (!data || typeof data !== "object") {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
const refs = [];
|
|
29
|
+
if (Array.isArray(data)) {
|
|
30
|
+
for (const item of data) {
|
|
31
|
+
refs.push(...collectRefs(fields, item));
|
|
32
|
+
}
|
|
33
|
+
return refs;
|
|
34
|
+
}
|
|
35
|
+
const record = data;
|
|
36
|
+
for (const field of fields) {
|
|
37
|
+
const value = record[field.name];
|
|
38
|
+
if (value === undefined || value === null) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (field.type === "relationship" || field.type === "upload") {
|
|
42
|
+
refs.push(...refsFromValue(value, field.relationTo ?? []));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (field.type === "group") {
|
|
46
|
+
refs.push(...collectRefs(field.fields ?? [], value));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (field.type === "array" && Array.isArray(value)) {
|
|
50
|
+
for (const item of value) {
|
|
51
|
+
refs.push(...collectRefs(field.fields ?? [], item));
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (field.type === "blocks" && Array.isArray(value)) {
|
|
56
|
+
for (const item of value) {
|
|
57
|
+
if (!item || typeof item !== "object") {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const blockType = item.blockType;
|
|
61
|
+
const block = field.blocks?.find((entry) => entry.slug === blockType);
|
|
62
|
+
refs.push(...collectRefs(block?.fields ?? [], item));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return refs;
|
|
67
|
+
}
|
|
68
|
+
function refsFromValue(value, relationTo) {
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
return value.flatMap((entry) => refsFromValue(entry, relationTo));
|
|
71
|
+
}
|
|
72
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
73
|
+
if (relationTo.length !== 1) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
return [{ collection: relationTo[0], id: String(value) }];
|
|
77
|
+
}
|
|
78
|
+
if (!value || typeof value !== "object") {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const record = value;
|
|
82
|
+
if (typeof record.relationTo === "string") {
|
|
83
|
+
const id = idOf(record.value ?? record.id);
|
|
84
|
+
return id ? [{ collection: record.relationTo, id }] : [];
|
|
85
|
+
}
|
|
86
|
+
const id = idOf(record.id ?? record.value);
|
|
87
|
+
if (!id || relationTo.length !== 1) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
return [{ collection: relationTo[0], id }];
|
|
91
|
+
}
|
|
92
|
+
function idOf(value) {
|
|
93
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
94
|
+
return String(value);
|
|
95
|
+
}
|
|
96
|
+
if (value && typeof value === "object" && "id" in value) {
|
|
97
|
+
const id = value.id;
|
|
98
|
+
if (typeof id === "string" || typeof id === "number") {
|
|
99
|
+
return String(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|