@anthusai/papyrus 2.0.0-next.81 → 2.0.0-next.82
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/amplify/plugins/reader-steering/function/handler.py +9 -3
- package/amplify/plugins/reader-steering/function/proposals.py +57 -9
- package/amplify/plugins/reader-steering/schema.js +5 -0
- package/amplify/plugins/reader-steering/schema.ts +5 -0
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -1499,10 +1499,10 @@ def run_ai_worker(
|
|
|
1499
1499
|
return result
|
|
1500
1500
|
|
|
1501
1501
|
|
|
1502
|
-
def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float) -> dict[str, Any]:
|
|
1502
|
+
def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float, account_id: str | None = None) -> dict[str, Any]:
|
|
1503
1503
|
"""``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
|
|
1504
1504
|
if (event.get("info") or {}).get("fieldName") in proposal_rules.MUTATIONS:
|
|
1505
|
-
return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds))
|
|
1505
|
+
return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds), account_id=account_id)
|
|
1506
1506
|
identity = event.get("identity") or {}
|
|
1507
1507
|
claims = identity.get("claims") or {}
|
|
1508
1508
|
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
@@ -1527,6 +1527,12 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
|
|
|
1527
1527
|
return outcome.to_dict()
|
|
1528
1528
|
|
|
1529
1529
|
|
|
1530
|
+
def function_account(context: Any) -> str | None:
|
|
1531
|
+
"""The AWS account this function runs in, from its own ARN: the only account whose IAM operators may file proposals."""
|
|
1532
|
+
parts = str(getattr(context, "invoked_function_arn", None) or "").split(":")
|
|
1533
|
+
return parts[4] if len(parts) > 4 and parts[4] else None
|
|
1534
|
+
|
|
1535
|
+
|
|
1530
1536
|
def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
|
|
1531
1537
|
import boto3
|
|
1532
1538
|
|
|
@@ -1534,7 +1540,7 @@ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
|
|
|
1534
1540
|
store = store_from_environment()
|
|
1535
1541
|
now_seconds = time.time()
|
|
1536
1542
|
if "arguments" in event and "info" in event:
|
|
1537
|
-
return handle_staff_event(event, store=store, settings=settings, now_seconds=now_seconds)
|
|
1543
|
+
return handle_staff_event(event, store=store, settings=settings, now_seconds=now_seconds, account_id=function_account(_context))
|
|
1538
1544
|
target = snapshot_target_from_environment()
|
|
1539
1545
|
if settings.moderation_mode == "ai" and target is not None and _context is not None:
|
|
1540
1546
|
if event.get("worker") == AI_WORKER:
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
A proposal is a change to the map written down where readers can see it: a story draft, a new concept, a rename, a merge,
|
|
4
4
|
a split, a new column or an element symbol. It is filed by AI (a reader proposer is part of the record but has no route yet)
|
|
5
5
|
and steered by votes; an editor decides. Staff reach this through the ``fileProposal`` and ``transitionProposal``
|
|
6
|
-
mutations (editors and admins
|
|
6
|
+
mutations (editors and admins; the function checks the groups again). An operator's IAM role in the function's own account
|
|
7
|
+
(an unattended automation or an agent without a browser sign-in) may also file an AI proposal and move it from draft to
|
|
8
|
+
in_steering or declined, and nothing else: approving, deciding after review and overriding stay with editors and admins.
|
|
9
|
+
Every state change is appended to the proposal's
|
|
7
10
|
``history`` in the same conditional write that changes the state, so the history and the state cannot disagree.
|
|
8
11
|
|
|
9
12
|
Nothing here knows a person id. The proposer is ``"ai"`` or a reader's siteSubject, which is stored and never published; the
|
|
@@ -56,6 +59,10 @@ TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
|
|
|
56
59
|
MUTATIONS = ("fileProposal", "transitionProposal")
|
|
57
60
|
PROPOSER_AI = "ai"
|
|
58
61
|
STAFF_GROUPS = frozenset({"editor", "admin"})
|
|
62
|
+
#: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
|
|
63
|
+
OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
|
|
64
|
+
BY_STAFF, BY_OPERATOR = "staff", "operator"
|
|
65
|
+
MAXIMUM_OPERATOR_LABEL = 256
|
|
59
66
|
PROPOSAL_ID = re.compile(r"prop-[0-9a-f]{32}")
|
|
60
67
|
TARGET_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:\-]{0,127}")
|
|
61
68
|
MAXIMUM_TITLE = 200
|
|
@@ -101,6 +108,24 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
|
101
108
|
return subject
|
|
102
109
|
|
|
103
110
|
|
|
111
|
+
def operator_actor(identity: dict[str, Any], account_id: str | None) -> str | None:
|
|
112
|
+
"""``operator:<principal ARN>`` for an IAM principal (a role or user) of the function's own account, or ``None``.
|
|
113
|
+
|
|
114
|
+
A Cognito identity-pool user is also an IAM principal of this account, but it carries a Cognito identity id (and a
|
|
115
|
+
user-pool caller carries a subject); neither is an operator. A principal of another account is refused, and so is a
|
|
116
|
+
caller whose ARN names another account than the one AppSync reports.
|
|
117
|
+
"""
|
|
118
|
+
if not isinstance(identity, dict) or not account_id:
|
|
119
|
+
return None
|
|
120
|
+
arn = identity.get("userArn")
|
|
121
|
+
parts = arn.split(":") if isinstance(arn, str) else []
|
|
122
|
+
if len(parts) < 6 or parts[0] != "arn" or parts[4] != account_id or identity.get("accountId") != account_id:
|
|
123
|
+
return None
|
|
124
|
+
if any(identity.get(key) for key in ("cognitoIdentityId", "cognitoIdentityPoolId", "cognitoIdentityAuthType", "sub", "claims")):
|
|
125
|
+
return None
|
|
126
|
+
return f"operator:{arn}"[:MAXIMUM_OPERATOR_LABEL]
|
|
127
|
+
|
|
128
|
+
|
|
104
129
|
def clean_text(value: Any, limit: int) -> str | None:
|
|
105
130
|
if not isinstance(value, str):
|
|
106
131
|
return None
|
|
@@ -190,7 +215,7 @@ def file_proposal(arguments: dict[str, Any], *, actor_sub: str, now_iso: str, pr
|
|
|
190
215
|
return row, None
|
|
191
216
|
|
|
192
217
|
|
|
193
|
-
def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_sub: str, now_iso: str, result: Any = None) -> dict[str, Any] | Outcome:
|
|
218
|
+
def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_sub: str, now_iso: str, result: Any = None, by: str = BY_STAFF) -> dict[str, Any] | Outcome:
|
|
194
219
|
"""The changes that move ``row`` to ``to_state``, or why it may not move."""
|
|
195
220
|
state = row.get("state")
|
|
196
221
|
if to_state not in STATES:
|
|
@@ -209,20 +234,34 @@ def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_su
|
|
|
209
234
|
return refused("result_required", f"An override says what the editor decided: {' or '.join(OVERRIDE_RESULTS)}.")
|
|
210
235
|
elif result is not None:
|
|
211
236
|
return refused("result_not_allowed", "Only an override carries a result; approve and decline are their own.")
|
|
212
|
-
entry = {"from": state, "to": to_state, "by":
|
|
237
|
+
entry = {"from": state, "to": to_state, "by": by, "actor": actor_sub, "reason": text, "at": now_iso}
|
|
213
238
|
if result is not None:
|
|
214
239
|
entry["result"] = result
|
|
215
240
|
history = [*(parse_json(row.get("history")) or []), entry]
|
|
216
241
|
return {"state": to_state, "history": json.dumps(history)}
|
|
217
242
|
|
|
218
243
|
|
|
219
|
-
def apply_transition(
|
|
244
|
+
def apply_transition(
|
|
245
|
+
store: ProposalStore,
|
|
246
|
+
*,
|
|
247
|
+
proposal_id: Any,
|
|
248
|
+
to_state: Any,
|
|
249
|
+
reason: Any,
|
|
250
|
+
actor_sub: str,
|
|
251
|
+
now_iso: str,
|
|
252
|
+
result: Any = None,
|
|
253
|
+
by: str = BY_STAFF,
|
|
254
|
+
allowed: frozenset[tuple[str, str]] | None = None,
|
|
255
|
+
) -> Outcome:
|
|
256
|
+
"""Move a proposal. ``allowed`` (as (from, to) pairs) narrows the road for a caller who may not take all of it."""
|
|
220
257
|
if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
|
|
221
258
|
return refused("invalid_proposal", "That is not a proposal id.")
|
|
222
259
|
row = store.get_proposal(proposal_id)
|
|
223
260
|
if row is None:
|
|
224
261
|
return refused("not_found", "There is no such proposal.")
|
|
225
|
-
|
|
262
|
+
if allowed is not None and (row.get("state"), to_state) not in allowed:
|
|
263
|
+
return refused("operator_not_allowed", "An operator can only move a draft to in_steering or declined; the rest is for editors and admins.")
|
|
264
|
+
plan = transition_plan(row, to_state, reason, actor_sub=actor_sub, now_iso=now_iso, result=result, by=by)
|
|
226
265
|
if isinstance(plan, Outcome):
|
|
227
266
|
return plan
|
|
228
267
|
if not store.change_proposal(proposal_id, plan, row["state"], now_iso):
|
|
@@ -230,11 +269,18 @@ def apply_transition(store: ProposalStore, *, proposal_id: Any, to_state: Any, r
|
|
|
230
269
|
return Outcome("ok", id=proposal_id)
|
|
231
270
|
|
|
232
271
|
|
|
233
|
-
def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str) -> dict[str, Any]:
|
|
234
|
-
"""``fileProposal`` or ``transitionProposal`` from AppSync. Answers a receipt, never an exception.
|
|
235
|
-
|
|
272
|
+
def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str, account_id: str | None = None) -> dict[str, Any]:
|
|
273
|
+
"""``fileProposal`` or ``transitionProposal`` from AppSync. Answers a receipt, never an exception.
|
|
274
|
+
|
|
275
|
+
Editors and admins act as themselves. An IAM principal of ``account_id`` (the function's own account) acts as an operator:
|
|
276
|
+
it files AI proposals and moves draft to in_steering or declined, recorded as ``operator:<ARN>``. Anyone else is refused.
|
|
277
|
+
"""
|
|
278
|
+
identity = event.get("identity") or {}
|
|
279
|
+
staff = staff_subject(identity)
|
|
280
|
+
operator = None if staff is not None else operator_actor(identity, account_id)
|
|
281
|
+
actor = staff or operator
|
|
236
282
|
if actor is None:
|
|
237
|
-
return refused("not_staff", "Only editors and
|
|
283
|
+
return refused("not_staff", "Only editors, admins and operators of this account can file proposals; only editors and admins decide them.").to_dict()
|
|
238
284
|
arguments = event.get("arguments") or {}
|
|
239
285
|
field = (event.get("info") or {}).get("fieldName")
|
|
240
286
|
if field == "fileProposal":
|
|
@@ -252,4 +298,6 @@ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_is
|
|
|
252
298
|
result=arguments.get("result"),
|
|
253
299
|
actor_sub=actor,
|
|
254
300
|
now_iso=now_iso,
|
|
301
|
+
by=BY_OPERATOR if operator else BY_STAFF,
|
|
302
|
+
allowed=OPERATOR_TRANSITIONS if operator else None,
|
|
255
303
|
).to_dict()
|
|
@@ -159,6 +159,9 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
159
159
|
error: a.string(),
|
|
160
160
|
message: a.string(),
|
|
161
161
|
}),
|
|
162
|
+
// Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
|
|
163
|
+
// schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
|
|
164
|
+
// of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
|
|
162
165
|
fileProposal: a
|
|
163
166
|
.mutation()
|
|
164
167
|
.arguments({
|
|
@@ -172,6 +175,8 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
172
175
|
.returns(a.ref("ProposalReceipt"))
|
|
173
176
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
174
177
|
.handler(functionHandler("readerActions", readerActions)),
|
|
178
|
+
// The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
|
|
179
|
+
// Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
|
|
175
180
|
transitionProposal: a
|
|
176
181
|
.mutation()
|
|
177
182
|
.arguments({
|
|
@@ -161,6 +161,9 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
161
161
|
error: a.string(),
|
|
162
162
|
message: a.string(),
|
|
163
163
|
}),
|
|
164
|
+
// Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
|
|
165
|
+
// schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
|
|
166
|
+
// of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
|
|
164
167
|
fileProposal: a
|
|
165
168
|
.mutation()
|
|
166
169
|
.arguments({
|
|
@@ -174,6 +177,8 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
174
177
|
.returns(a.ref("ProposalReceipt"))
|
|
175
178
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
176
179
|
.handler(functionHandler("readerActions", readerActions)),
|
|
180
|
+
// The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
|
|
181
|
+
// Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
|
|
177
182
|
transitionProposal: a
|
|
178
183
|
.mutation()
|
|
179
184
|
.arguments({
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED