@cloudhal/agent-runtime 0.0.0-stage → 0.1.1
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 +202 -0
- package/README.md +8 -2
- package/dist/src/approvals.d.ts +127 -0
- package/dist/src/approvals.js +346 -0
- package/dist/src/audit.d.ts +75 -0
- package/dist/src/audit.js +209 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +13 -0
- package/dist/src/modes.d.ts +124 -0
- package/dist/src/modes.js +279 -0
- package/dist/src/ownership.d.ts +76 -0
- package/dist/src/ownership.js +200 -0
- package/dist/src/policy.d.ts +197 -0
- package/dist/src/policy.js +508 -0
- package/dist/src/roles.d.ts +110 -0
- package/dist/src/roles.js +292 -0
- package/dist/src/site-access.d.ts +35 -0
- package/dist/src/site-access.js +81 -0
- package/dist/src/skills.d.ts +47 -0
- package/dist/src/skills.js +151 -0
- package/dist/src/taxonomy.d.ts +41 -0
- package/dist/src/taxonomy.js +78 -0
- package/package.json +44 -3
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent modes (ADR-0015): how independently an agent works, chosen per API key by its owner —
|
|
3
|
+
* the same idea as an IDE agent's permission modes. A mode never grants scopes; it narrows what
|
|
4
|
+
* the key's scopes allow and decides where a person must approve.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/** In order of increasing autonomy. */
|
|
11
|
+
export const AGENT_MODES = ["plan", "manual", "edits", "auto", "bypass"];
|
|
12
|
+
|
|
13
|
+
/** What existing keys and new keys without a choice get. */
|
|
14
|
+
export const DEFAULT_AGENT_MODE = "edits";
|
|
15
|
+
|
|
16
|
+
/** Modes a remote (HTTP, API key) agent may hold. `bypass` is for local stdio only. */
|
|
17
|
+
export const REMOTE_AGENT_MODES = ["plan", "manual", "edits", "auto"];
|
|
18
|
+
|
|
19
|
+
export function isAgentMode(value ) {
|
|
20
|
+
return typeof value === "string" && (AGENT_MODES ).includes(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The scopes a key needs to be useful in a mode (offered by default when creating one). */
|
|
24
|
+
export function scopesForMode(mode ) {
|
|
25
|
+
// `analytics:read` is read-only and carries no personal data: every mode, plan included (RFC-0006).
|
|
26
|
+
// `comments:read` likewise: reader text, never an email or IP hash (RFC-0007 D2).
|
|
27
|
+
const read = [
|
|
28
|
+
"site:read",
|
|
29
|
+
"content:read",
|
|
30
|
+
"preview:create",
|
|
31
|
+
"analytics:read",
|
|
32
|
+
"comments:read",
|
|
33
|
+
// RFC-0008 §10.1: the schedule, read-only, in every mode.
|
|
34
|
+
"schedules:read",
|
|
35
|
+
];
|
|
36
|
+
// RFC-0014 §5 (ADR-0028): a plan key may propose body edits; they change nothing until a person
|
|
37
|
+
// accepts them. Other modes get it through `content:write`.
|
|
38
|
+
if (mode === "plan") return [...read, "content:propose"];
|
|
39
|
+
// `asset:upload` is part of the default agent-key scopes outside plan (RFC-0004, ADR-0018);
|
|
40
|
+
// `schedules:write` lets the agent propose and ask for releases (RFC-0008 §10.1).
|
|
41
|
+
return [
|
|
42
|
+
...read,
|
|
43
|
+
"content:write",
|
|
44
|
+
"asset:upload",
|
|
45
|
+
"site:publish",
|
|
46
|
+
"site:rollback",
|
|
47
|
+
"schedules:write",
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Limits for an `auto`-mode publish that goes live without a person (see `isLowRiskPublish`). */
|
|
52
|
+
export const AUTO_PUBLISH_LIMIT = 20;
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
/** Why `auto` mode did or did not let a publish through, as data (Studio words it itself). */
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
/** The `auto`-mode check behind a publish (stored on audit rows and approvals, ADR-0015). */
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
const AUTO_CHECK_TEXT = {
|
|
86
|
+
"low-risk": (c) => `${c.changes} article change(s) only`,
|
|
87
|
+
"removes-content": () => "it removes content",
|
|
88
|
+
nothing: () => "nothing to publish",
|
|
89
|
+
structure: () => "it changes pages, menus, site settings, taxonomies or redirects",
|
|
90
|
+
"too-many": (c) => `it changes more than ${c.limit} articles`,
|
|
91
|
+
media: () => "it adds or changes media that is not part of an article change",
|
|
92
|
+
"outside-window": () =>
|
|
93
|
+
"it would run after your autonomy ends (the elevation's end, or 24 hours from now)",
|
|
94
|
+
correction: () => "it adds a correction to a published article",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** English wording of a check, for agents and audit reasons. */
|
|
98
|
+
export function describeAutoCheck(check ) {
|
|
99
|
+
return AUTO_CHECK_TEXT[check.code](check);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Low risk = only articles added or edited, nothing removed, no pages, menus, site settings,
|
|
104
|
+
* taxonomies, terms or redirects touched (RFC-0002 §6.4, RFC-0003 §6.4), and at most `AUTO_PUBLISH_LIMIT` changes. Anything
|
|
105
|
+
* else waits for a person even in `auto`.
|
|
106
|
+
*
|
|
107
|
+
* Media (RFC-0004 §6.3, answer 1): an added asset whose id is in `ridingAssets` (a new raster
|
|
108
|
+
* image used only by articles in the same publish; the store decides) goes out with them and does
|
|
109
|
+
* not count toward the limit. Any other asset change is `media` and waits for a person.
|
|
110
|
+
*
|
|
111
|
+
* Corrections (RFC-0019 §2.4): an entry in `corrections` (it adds a correction to its live
|
|
112
|
+
* version; the store decides) makes the publish wait for a person.
|
|
113
|
+
*/
|
|
114
|
+
export function checkAutoPublish(
|
|
115
|
+
diff
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
,
|
|
120
|
+
options = {},
|
|
121
|
+
) {
|
|
122
|
+
const riding = options.ridingAssets ?? new Set ();
|
|
123
|
+
const corrects = options.corrections ?? new Set ();
|
|
124
|
+
const rides = (n ) => n.kind === "asset" && riding.has(n.id);
|
|
125
|
+
const added = diff.added.filter((n) => !rides(n));
|
|
126
|
+
const touched = [...added, ...diff.changed.map((c) => c.after)];
|
|
127
|
+
const base = {
|
|
128
|
+
rule: "low-risk-publish" ,
|
|
129
|
+
changes: touched.length + diff.removed.length,
|
|
130
|
+
limit: AUTO_PUBLISH_LIMIT,
|
|
131
|
+
};
|
|
132
|
+
const media = touched.some((n) => n.kind === "asset");
|
|
133
|
+
const code =
|
|
134
|
+
diff.removed.length > 0
|
|
135
|
+
? "removes-content"
|
|
136
|
+
: touched.length === 0
|
|
137
|
+
? "nothing"
|
|
138
|
+
: touched.some((n) => n.kind !== "contentEntry" && n.kind !== "asset")
|
|
139
|
+
? "structure"
|
|
140
|
+
: media
|
|
141
|
+
? "media"
|
|
142
|
+
: touched.some((n) => corrects.has(n.id))
|
|
143
|
+
? "correction"
|
|
144
|
+
: touched.length > AUTO_PUBLISH_LIMIT
|
|
145
|
+
? "too-many"
|
|
146
|
+
: "low-risk";
|
|
147
|
+
return { ...base, code, lowRisk: code === "low-risk" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Time-boxed elevation (ADR-0016): the owner lets a key work in a more autonomous mode until
|
|
152
|
+
* `until`; afterwards it is back in its own mode without anyone doing anything (the effective mode
|
|
153
|
+
* is computed on every call).
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
/** Shortest and longest elevation an owner can grant. */
|
|
167
|
+
export const ELEVATION_MIN_MINUTES = 5;
|
|
168
|
+
export const ELEVATION_MAX_MINUTES = 24 * 60;
|
|
169
|
+
|
|
170
|
+
/** `a` lets the agent do more on its own than `b` (AGENT_MODES order). */
|
|
171
|
+
export function isMoreAutonomous(a , b ) {
|
|
172
|
+
return AGENT_MODES.indexOf(a) > AGENT_MODES.indexOf(b);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Why an elevation request is refused, or undefined when it is acceptable: a remote mode more
|
|
177
|
+
* autonomous than the key's own, for between `ELEVATION_MIN_MINUTES` and `ELEVATION_MAX_MINUTES`.
|
|
178
|
+
*/
|
|
179
|
+
export function elevationProblem(
|
|
180
|
+
base ,
|
|
181
|
+
mode ,
|
|
182
|
+
minutes ,
|
|
183
|
+
) {
|
|
184
|
+
if (!REMOTE_AGENT_MODES.includes(mode))
|
|
185
|
+
return `mode "${mode}" is not available for API keys (bypass is local only)`;
|
|
186
|
+
if (!isMoreAutonomous(mode, base))
|
|
187
|
+
return `an elevation must be more autonomous than the key's own mode (${base})`;
|
|
188
|
+
if (
|
|
189
|
+
!Number.isInteger(minutes) ||
|
|
190
|
+
minutes < ELEVATION_MIN_MINUTES ||
|
|
191
|
+
minutes > ELEVATION_MAX_MINUTES
|
|
192
|
+
)
|
|
193
|
+
return `minutes must be a whole number between ${ELEVATION_MIN_MINUTES} and ${ELEVATION_MAX_MINUTES}`;
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The elevation that applies right now, if any (expired or no-longer-higher ones do not). */
|
|
198
|
+
export function activeElevation(
|
|
199
|
+
base ,
|
|
200
|
+
elevation ,
|
|
201
|
+
now ,
|
|
202
|
+
) {
|
|
203
|
+
if (!elevation || !REMOTE_AGENT_MODES.includes(elevation.mode)) return undefined;
|
|
204
|
+
if (!isMoreAutonomous(elevation.mode, base)) return undefined;
|
|
205
|
+
const until = Date.parse(elevation.until);
|
|
206
|
+
return Number.isFinite(until) && until > now.getTime() ? elevation : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** How long before an elevation ends that owners and the agent are told (Studio notifications, `get_mode`). */
|
|
210
|
+
export const ELEVATION_NOTICE_MINUTES = 10;
|
|
211
|
+
|
|
212
|
+
/** Whole minutes left until `until` (rounded up; 0 once it has ended). */
|
|
213
|
+
export function minutesLeft(until , now ) {
|
|
214
|
+
const ms = Date.parse(until) - now.getTime();
|
|
215
|
+
return Number.isFinite(ms) && ms > 0 ? Math.ceil(ms / 60_000) : 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** True during the last `ELEVATION_NOTICE_MINUTES` of an elevation. */
|
|
219
|
+
export function endsSoon(until , now ) {
|
|
220
|
+
const left = minutesLeft(until, now);
|
|
221
|
+
return left > 0 && left <= ELEVATION_NOTICE_MINUTES;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The longest an `auto`-mode release may wait without a person (RFC-0008 §10.7, D9). */
|
|
225
|
+
export const AUTO_RELEASE_WINDOW_HOURS = 24;
|
|
226
|
+
|
|
227
|
+
/** A release item as the low-risk check sees it (RFC-0008 §10.3). */
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* `checkAutoPublish` applied to a release (RFC-0008 §10.3): articles only, nothing removed, at most
|
|
242
|
+
* `AUTO_PUBLISH_LIMIT` changes; a new asset rides along when an article item of the same release
|
|
243
|
+
* references it.
|
|
244
|
+
*/
|
|
245
|
+
export function checkAutoRelease(items ) {
|
|
246
|
+
const riding = new Set ();
|
|
247
|
+
for (const i of items)
|
|
248
|
+
if (i.kind === "set" && i.nodeKind === "contentEntry")
|
|
249
|
+
for (const d of i.deps ?? []) riding.add(d);
|
|
250
|
+
const ref = (i ) => ({ id: i.nodeId, kind: i.nodeKind });
|
|
251
|
+
const set = items.filter((i) => i.kind === "set");
|
|
252
|
+
const check = checkAutoPublish(
|
|
253
|
+
{
|
|
254
|
+
added: set.filter((i) => !i.live).map(ref),
|
|
255
|
+
changed: set.filter((i) => i.live).map((i) => ({ after: ref(i) })),
|
|
256
|
+
removed: items.filter((i) => i.kind === "remove").map(ref),
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
ridingAssets: new Set(
|
|
260
|
+
set
|
|
261
|
+
.filter((i) => i.nodeKind === "asset" && !i.live && riding.has(i.nodeId))
|
|
262
|
+
.map((i) => i.nodeId),
|
|
263
|
+
),
|
|
264
|
+
corrections: new Set(set.filter((i) => i.corrects).map((i) => i.nodeId)),
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
return { ...check, rule: "low-risk-release" };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The latest an `auto`-mode release may run and still go ahead without a person (RFC-0008 §10.7,
|
|
272
|
+
* D9): the end of the key's elevation (when `auto` comes from one) or 24 hours from now, whichever
|
|
273
|
+
* comes first.
|
|
274
|
+
*/
|
|
275
|
+
export function autoReleaseWindowEnds(now , elevationUntil ) {
|
|
276
|
+
const day = now.getTime() + AUTO_RELEASE_WINDOW_HOURS * 3_600_000;
|
|
277
|
+
const until = elevationUntil ? Date.parse(elevationUntil) : Number.NaN;
|
|
278
|
+
return new Date(Number.isFinite(until) ? Math.min(day, until) : day).toISOString();
|
|
279
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Own" (RFC-0005 §4): who owns an entry, what an own-limited caller may do to it, and when a change
|
|
3
|
+
* set is own-only. REST handlers and MCP tool handlers use the same helpers (I9, I10).
|
|
4
|
+
*/
|
|
5
|
+
import type { Actor, ContentEntry, NodeRef } from "@cloudhal/core-contracts";
|
|
6
|
+
import type { OwnLimit } from "./policy.js";
|
|
7
|
+
/**
|
|
8
|
+
* Maps an actor to the person behind it (an actor id `user_…`): a person is themselves, an agent is
|
|
9
|
+
* the person whose key it used (RFC-0005 §4), `system` is nobody. Server-side for now; the optional
|
|
10
|
+
* contract field `Actor.onBehalfOf` (RFC-0005 §8) is a follow-up.
|
|
11
|
+
*/
|
|
12
|
+
export interface OwnershipResolver {
|
|
13
|
+
personOf(actor: Actor): Promise<string | undefined>;
|
|
14
|
+
}
|
|
15
|
+
/** People are themselves; agents and the system resolve to nobody (no membership source). */
|
|
16
|
+
export declare const PEOPLE_ONLY_OWNERSHIP: OwnershipResolver;
|
|
17
|
+
export type OwnProblemCode = "not_own" | "draft_only" | "author_immutable" | "no_person";
|
|
18
|
+
/** An own-limited caller tried something outside its limit (REST 403, MCP `denied`). */
|
|
19
|
+
export declare class OwnLimitError extends Error {
|
|
20
|
+
readonly name = "OwnLimitError";
|
|
21
|
+
readonly code: OwnProblemCode;
|
|
22
|
+
constructor(code: OwnProblemCode, message: string);
|
|
23
|
+
}
|
|
24
|
+
type EntryState = Pick<ContentEntry, "author" | "status"> & {
|
|
25
|
+
publishedAt?: string | undefined;
|
|
26
|
+
};
|
|
27
|
+
/** Never published and still a draft or in review (what a Contributor may touch). */
|
|
28
|
+
export declare function isUnpublishedDraft(entry: EntryState): boolean;
|
|
29
|
+
/** Is `person` an owner of `entry` (its `author`, or the person behind it)? */
|
|
30
|
+
export declare function ownsEntry(owner: OwnershipResolver, person: string, entry: Pick<ContentEntry, "author">): Promise<boolean>;
|
|
31
|
+
/**
|
|
32
|
+
* Checks an entry write against an own limit (I9). No limit: nothing to check.
|
|
33
|
+
* - create: the new entry's author is the caller; `draft` also keeps it a draft or in review;
|
|
34
|
+
* - update: the caller owns the stored entry, `author` is unchanged, and `draft` needs a
|
|
35
|
+
* never-published entry that stays draft/review;
|
|
36
|
+
* - delete: the caller owns it; `draft` needs a never-published entry.
|
|
37
|
+
*/
|
|
38
|
+
export declare function checkEntryLimit(input: {
|
|
39
|
+
limit: OwnLimit | undefined;
|
|
40
|
+
op: "create" | "update" | "delete";
|
|
41
|
+
person: string | undefined;
|
|
42
|
+
owner: OwnershipResolver;
|
|
43
|
+
stored?: EntryState | undefined;
|
|
44
|
+
next?: EntryState | undefined;
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
/** A diff between the live state and what would go live (core-ts `SnapshotDiff`). */
|
|
47
|
+
export interface ChangeSet {
|
|
48
|
+
readonly added: readonly NodeRef[];
|
|
49
|
+
readonly removed: readonly NodeRef[];
|
|
50
|
+
readonly changed: readonly {
|
|
51
|
+
readonly before: NodeRef;
|
|
52
|
+
readonly after: NodeRef;
|
|
53
|
+
}[];
|
|
54
|
+
}
|
|
55
|
+
/** Reads the two sides of a change set (snapshot blobs, or the current node for "after"). */
|
|
56
|
+
export interface ChangeSetReader {
|
|
57
|
+
before(ref: NodeRef): Promise<unknown>;
|
|
58
|
+
after(ref: NodeRef): Promise<unknown>;
|
|
59
|
+
}
|
|
60
|
+
export interface OwnOnlyResult {
|
|
61
|
+
ownOnly: boolean;
|
|
62
|
+
/** Structure changed (pages, site, taxonomies, terms, redirects, menus, assets…). */
|
|
63
|
+
structure: boolean;
|
|
64
|
+
/** Publicly visible entries the person does not own. */
|
|
65
|
+
foreign: string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Is a change set own-only for `person` (RFC-0005 §4)? Every publicly visible change must be an
|
|
69
|
+
* entry they own: entries published before or after count, entries draft/review on both sides are
|
|
70
|
+
* ignored, and any other node kind (except a site node that only moved its live pointer) makes it
|
|
71
|
+
* not own-only.
|
|
72
|
+
*/
|
|
73
|
+
export declare function ownOnlyChangeSet(diff: ChangeSet, person: string | undefined, owner: OwnershipResolver, read: ChangeSetReader): Promise<OwnOnlyResult>;
|
|
74
|
+
/** English reason a change set is not own-only (for agents and approval prompts). */
|
|
75
|
+
export declare function describeOwnOnly(result: OwnOnlyResult): string;
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Own" (RFC-0005 §4): who owns an entry, what an own-limited caller may do to it, and when a change
|
|
3
|
+
* set is own-only. REST handlers and MCP tool handlers use the same helpers (I9, I10).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Maps an actor to the person behind it (an actor id `user_…`): a person is themselves, an agent is
|
|
10
|
+
* the person whose key it used (RFC-0005 §4), `system` is nobody. Server-side for now; the optional
|
|
11
|
+
* contract field `Actor.onBehalfOf` (RFC-0005 §8) is a follow-up.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** People are themselves; agents and the system resolve to nobody (no membership source). */
|
|
18
|
+
export const PEOPLE_ONLY_OWNERSHIP = {
|
|
19
|
+
async personOf(actor) {
|
|
20
|
+
return actor.kind === "human" ? actor.id : undefined;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/** An own-limited caller tried something outside its limit (REST 403, MCP `denied`). */
|
|
27
|
+
export class OwnLimitError extends Error {
|
|
28
|
+
name = "OwnLimitError";
|
|
29
|
+
code ;
|
|
30
|
+
constructor(code , message ) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
/** Never published and still a draft or in review (what a Contributor may touch). */
|
|
39
|
+
export function isUnpublishedDraft(entry ) {
|
|
40
|
+
return !entry.publishedAt && (entry.status === "draft" || entry.status === "review");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sameActor(a , b ) {
|
|
44
|
+
return a.kind === b.kind && a.id === b.id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Is `person` an owner of `entry` (its `author`, or the person behind it)? */
|
|
48
|
+
export async function ownsEntry(
|
|
49
|
+
owner ,
|
|
50
|
+
person ,
|
|
51
|
+
entry ,
|
|
52
|
+
) {
|
|
53
|
+
return (await owner.personOf(entry.author)) === person;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks an entry write against an own limit (I9). No limit: nothing to check.
|
|
58
|
+
* - create: the new entry's author is the caller; `draft` also keeps it a draft or in review;
|
|
59
|
+
* - update: the caller owns the stored entry, `author` is unchanged, and `draft` needs a
|
|
60
|
+
* never-published entry that stays draft/review;
|
|
61
|
+
* - delete: the caller owns it; `draft` needs a never-published entry.
|
|
62
|
+
*/
|
|
63
|
+
export async function checkEntryLimit(input
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
) {
|
|
71
|
+
const { limit, op, person, owner, stored, next } = input;
|
|
72
|
+
if (limit?.target !== "entry") return;
|
|
73
|
+
if (!person)
|
|
74
|
+
throw new OwnLimitError("no_person", "ownership cannot be established for this caller");
|
|
75
|
+
const draft = limit.level === "draft";
|
|
76
|
+
const stayDraft = (e ) => {
|
|
77
|
+
if (draft && e && !isUnpublishedDraft(e))
|
|
78
|
+
throw new OwnLimitError(
|
|
79
|
+
"draft_only",
|
|
80
|
+
"your role can only keep articles as drafts or send them for review; an Editor publishes them",
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
if (op === "create") {
|
|
84
|
+
if (next && !(await ownsEntry(owner, person, next)))
|
|
85
|
+
throw new OwnLimitError(
|
|
86
|
+
"author_immutable",
|
|
87
|
+
"your role cannot file articles for someone else",
|
|
88
|
+
);
|
|
89
|
+
stayDraft(next);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!stored) return;
|
|
93
|
+
if (!(await ownsEntry(owner, person, stored)))
|
|
94
|
+
throw new OwnLimitError("not_own", "your role can only change your own articles");
|
|
95
|
+
if (draft && !isUnpublishedDraft(stored))
|
|
96
|
+
throw new OwnLimitError(
|
|
97
|
+
"draft_only",
|
|
98
|
+
"this article was published; only an Editor (or its Author) can change it now",
|
|
99
|
+
);
|
|
100
|
+
if (op === "update" && next) {
|
|
101
|
+
if (!sameActor(stored.author, next.author))
|
|
102
|
+
throw new OwnLimitError("author_immutable", "your role cannot change an article's author");
|
|
103
|
+
stayDraft(next);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A diff between the live state and what would go live (core-ts `SnapshotDiff`). */
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
/** Reads the two sides of a change set (snapshot blobs, or the current node for "after"). */
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
function isPublished(node ) {
|
|
129
|
+
return (node )?.status === "published";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** JSON with object keys sorted at every level (stable comparison). */
|
|
133
|
+
function stable(value ) {
|
|
134
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
|
135
|
+
if (value && typeof value === "object")
|
|
136
|
+
return `{${Object.keys(value)
|
|
137
|
+
.sort()
|
|
138
|
+
.map((k) => `${JSON.stringify(k)}:${stable((value )[k])}`)
|
|
139
|
+
.join(",")}}`;
|
|
140
|
+
return JSON.stringify(value) ?? "null";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The site node without what publishing itself rewrites (pointer, rev, timestamp). */
|
|
144
|
+
function stripSite(site ) {
|
|
145
|
+
const { publishedSnapshotId: _p, rev: _r, updatedAt: _u, ...rest } = site;
|
|
146
|
+
return stable(rest);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Is a change set own-only for `person` (RFC-0005 §4)? Every publicly visible change must be an
|
|
151
|
+
* entry they own: entries published before or after count, entries draft/review on both sides are
|
|
152
|
+
* ignored, and any other node kind (except a site node that only moved its live pointer) makes it
|
|
153
|
+
* not own-only.
|
|
154
|
+
*/
|
|
155
|
+
export async function ownOnlyChangeSet(
|
|
156
|
+
diff ,
|
|
157
|
+
person ,
|
|
158
|
+
owner ,
|
|
159
|
+
read ,
|
|
160
|
+
) {
|
|
161
|
+
const foreign = [];
|
|
162
|
+
let structure = false;
|
|
163
|
+
const owned = async (node ) =>
|
|
164
|
+
person !== undefined &&
|
|
165
|
+
(await ownsEntry(owner, person, node ).catch(() => false));
|
|
166
|
+
const entry = async (id , before , after ) => {
|
|
167
|
+
if (!isPublished(before) && !isPublished(after)) return;
|
|
168
|
+
for (const side of [before, after])
|
|
169
|
+
if (side !== undefined && !(await owned(side))) {
|
|
170
|
+
if (!foreign.includes(id)) foreign.push(id);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
for (const ref of diff.added) {
|
|
175
|
+
if (ref.kind !== "contentEntry") structure = true;
|
|
176
|
+
else await entry(ref.id, undefined, await read.after(ref));
|
|
177
|
+
}
|
|
178
|
+
for (const ref of diff.removed) {
|
|
179
|
+
if (ref.kind !== "contentEntry") structure = true;
|
|
180
|
+
else await entry(ref.id, await read.before(ref), undefined);
|
|
181
|
+
}
|
|
182
|
+
for (const c of diff.changed) {
|
|
183
|
+
if (c.after.kind === "site") {
|
|
184
|
+
const [a, b] = [await read.before(c.before), await read.after(c.after)];
|
|
185
|
+
if (!a || !b || stripSite(a ) !== stripSite(b )) structure = true;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (c.after.kind !== "contentEntry") structure = true;
|
|
189
|
+
else await entry(c.after.id, await read.before(c.before), await read.after(c.after));
|
|
190
|
+
}
|
|
191
|
+
return { ownOnly: !structure && foreign.length === 0, structure, foreign };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** English reason a change set is not own-only (for agents and approval prompts). */
|
|
195
|
+
export function describeOwnOnly(result ) {
|
|
196
|
+
if (result.ownOnly) return "every public change is your own";
|
|
197
|
+
if (result.structure)
|
|
198
|
+
return "it changes pages, site settings, taxonomies, terms or redirects, which needs an Editor";
|
|
199
|
+
return `it includes ${result.foreign.length} published article(s) by someone else, which needs an Editor`;
|
|
200
|
+
}
|