@anthusai/papyrus 5.35.0 → 5.37.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/amplify/plugins/reader-steering/function/handler.py +23 -10
- package/amplify/plugins/reader-steering/function/proposals.py +178 -15
- package/amplify/plugins/reader-steering/papyrus.plugin.js +1 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +1 -1
- package/amplify/plugins/reader-steering/schema.js +24 -0
- package/amplify/plugins/reader-steering/schema.ts +24 -0
- package/lib/automation-overview.ts +8 -0
- package/lib/prospectus-sign-in-routes.ts +27 -7
- package/lib/prospectus-site-identity.ts +22 -5
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -1015,15 +1015,22 @@ class DynamoReaderStore:
|
|
|
1015
1015
|
raise
|
|
1016
1016
|
return True
|
|
1017
1017
|
|
|
1018
|
-
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool:
|
|
1018
|
+
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool:
|
|
1019
|
+
"""Apply ``changes`` if the state (and, when given, ``updatedAt``) is still what the caller saw; a value of ``None`` removes the attribute."""
|
|
1019
1020
|
names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
|
|
1020
|
-
|
|
1021
|
-
|
|
1021
|
+
sets = ", ".join(f"#c{index} = :c{index}" for index, value in enumerate(changes.values()) if value is not None)
|
|
1022
|
+
removes = ", ".join(f"#c{index}" for index, value in enumerate(changes.values()) if value is None)
|
|
1023
|
+
values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values()) if value is not None}}
|
|
1024
|
+
condition = "#st = :expected"
|
|
1025
|
+
if expected_updated_at is not None:
|
|
1026
|
+
condition += " AND #u = :seen"
|
|
1027
|
+
values[":seen"] = expected_updated_at
|
|
1028
|
+
expression = f"SET {sets + ', ' if sets else ''}#u = :now" + (f" REMOVE {removes}" if removes else "")
|
|
1022
1029
|
try:
|
|
1023
1030
|
self._table("proposal").update_item(
|
|
1024
1031
|
Key={"id": proposal_id},
|
|
1025
|
-
UpdateExpression=
|
|
1026
|
-
ConditionExpression=
|
|
1032
|
+
UpdateExpression=expression,
|
|
1033
|
+
ConditionExpression=condition,
|
|
1027
1034
|
ExpressionAttributeNames=names,
|
|
1028
1035
|
ExpressionAttributeValues=values,
|
|
1029
1036
|
)
|
|
@@ -1499,10 +1506,10 @@ def run_ai_worker(
|
|
|
1499
1506
|
return result
|
|
1500
1507
|
|
|
1501
1508
|
|
|
1502
|
-
def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float) -> dict[str, Any]:
|
|
1509
|
+
def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float, account_id: str | None = None) -> dict[str, Any]:
|
|
1503
1510
|
"""``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
|
|
1504
|
-
if (event
|
|
1505
|
-
return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds))
|
|
1511
|
+
if proposal_rules.event_field_name(event) in proposal_rules.MUTATIONS:
|
|
1512
|
+
return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds), account_id=account_id)
|
|
1506
1513
|
identity = event.get("identity") or {}
|
|
1507
1514
|
claims = identity.get("claims") or {}
|
|
1508
1515
|
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
@@ -1527,14 +1534,20 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
|
|
|
1527
1534
|
return outcome.to_dict()
|
|
1528
1535
|
|
|
1529
1536
|
|
|
1537
|
+
def function_account(context: Any) -> str | None:
|
|
1538
|
+
"""The AWS account this function runs in, from its own ARN: the only account whose IAM operators may file proposals."""
|
|
1539
|
+
parts = str(getattr(context, "invoked_function_arn", None) or "").split(":")
|
|
1540
|
+
return parts[4] if len(parts) > 4 and parts[4] else None
|
|
1541
|
+
|
|
1542
|
+
|
|
1530
1543
|
def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
|
|
1531
1544
|
import boto3
|
|
1532
1545
|
|
|
1533
1546
|
settings = settings_from_environment()
|
|
1534
1547
|
store = store_from_environment()
|
|
1535
1548
|
now_seconds = time.time()
|
|
1536
|
-
if
|
|
1537
|
-
return handle_staff_event(event, store=store, settings=settings, now_seconds=now_seconds)
|
|
1549
|
+
if proposal_rules.is_appsync_event(event):
|
|
1550
|
+
return handle_staff_event(event, store=store, settings=settings, now_seconds=now_seconds, account_id=function_account(_context))
|
|
1538
1551
|
target = snapshot_target_from_environment()
|
|
1539
1552
|
if settings.moderation_mode == "ai" and target is not None and _context is not None:
|
|
1540
1553
|
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
|
+
``editProposal`` is the editor tier (pin, hold, exclude and restore votes): editors and admins only, never an operator. 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
|
|
@@ -53,9 +56,41 @@ TARGET_COUNTS: dict[str, tuple[int, int]] = {
|
|
|
53
56
|
}
|
|
54
57
|
TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
|
|
55
58
|
|
|
56
|
-
MUTATIONS = ("fileProposal", "transitionProposal")
|
|
59
|
+
MUTATIONS = ("fileProposal", "transitionProposal", "editProposal")
|
|
60
|
+
|
|
61
|
+
#: What ``editProposal`` does beyond moving the state. Editors and admins only: an operator is refused every one of them.
|
|
62
|
+
PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION = "pin", "unpin", "hold", "release", "exclude_votes", "undo_exclusion"
|
|
63
|
+
EDIT_ACTIONS = (PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION)
|
|
64
|
+
FINAL_STATES = (APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR)
|
|
65
|
+
#: The batch rules for excluding votes. A rule excludes the votes on one proposal that were already cast, by accounts that were
|
|
66
|
+
#: younger than ``hours`` when they voted; the snapshot applies it (votes are not counted here). ``label`` is the public wording.
|
|
67
|
+
EXCLUSION_RULES: dict[str, dict[str, Any]] = {
|
|
68
|
+
"new-accounts-24h": {"hours": 24, "label": "accounts under 24 h"},
|
|
69
|
+
}
|
|
70
|
+
EXCLUSION_ID = re.compile(r"exc-[0-9a-f]{12}")
|
|
71
|
+
MAXIMUM_EDITOR_LOG = 200
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def event_field_name(event: dict[str, Any]) -> str | None:
|
|
75
|
+
"""The GraphQL field an AppSync invocation is for.
|
|
76
|
+
|
|
77
|
+
The Amplify Gen 2 Lambda data source sends ``fieldName`` at the top level with no ``info`` key; a direct resolver nests it
|
|
78
|
+
under ``info``. Accept both.
|
|
79
|
+
"""
|
|
80
|
+
info = event.get("info")
|
|
81
|
+
name = (info.get("fieldName") if isinstance(info, dict) else None) or event.get("fieldName")
|
|
82
|
+
return name if isinstance(name, str) and name else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_appsync_event(event: dict[str, Any]) -> bool:
|
|
86
|
+
"""True for an AppSync invocation (arguments plus ``info`` or a top-level ``fieldName``); the public path sends siteSubject, action and payload."""
|
|
87
|
+
return "arguments" in event and ("info" in event or event_field_name(event) is not None)
|
|
57
88
|
PROPOSER_AI = "ai"
|
|
58
89
|
STAFF_GROUPS = frozenset({"editor", "admin"})
|
|
90
|
+
#: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
|
|
91
|
+
OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
|
|
92
|
+
BY_STAFF, BY_OPERATOR = "staff", "operator"
|
|
93
|
+
MAXIMUM_OPERATOR_LABEL = 256
|
|
59
94
|
PROPOSAL_ID = re.compile(r"prop-[0-9a-f]{32}")
|
|
60
95
|
TARGET_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:\-]{0,127}")
|
|
61
96
|
MAXIMUM_TITLE = 200
|
|
@@ -86,7 +121,7 @@ class ProposalStore(Protocol):
|
|
|
86
121
|
|
|
87
122
|
def create_proposal(self, row: dict[str, Any]) -> bool: ...
|
|
88
123
|
|
|
89
|
-
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool: ...
|
|
124
|
+
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool: ...
|
|
90
125
|
|
|
91
126
|
|
|
92
127
|
def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
@@ -101,6 +136,24 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
|
101
136
|
return subject
|
|
102
137
|
|
|
103
138
|
|
|
139
|
+
def operator_actor(identity: dict[str, Any], account_id: str | None) -> str | None:
|
|
140
|
+
"""``operator:<principal ARN>`` for an IAM principal (a role or user) of the function's own account, or ``None``.
|
|
141
|
+
|
|
142
|
+
A Cognito identity-pool user is also an IAM principal of this account, but it carries a Cognito identity id (and a
|
|
143
|
+
user-pool caller carries a subject); neither is an operator. A principal of another account is refused, and so is a
|
|
144
|
+
caller whose ARN names another account than the one AppSync reports.
|
|
145
|
+
"""
|
|
146
|
+
if not isinstance(identity, dict) or not account_id:
|
|
147
|
+
return None
|
|
148
|
+
arn = identity.get("userArn")
|
|
149
|
+
parts = arn.split(":") if isinstance(arn, str) else []
|
|
150
|
+
if len(parts) < 6 or parts[0] != "arn" or parts[4] != account_id or identity.get("accountId") != account_id:
|
|
151
|
+
return None
|
|
152
|
+
if any(identity.get(key) for key in ("cognitoIdentityId", "cognitoIdentityPoolId", "cognitoIdentityAuthType", "sub", "claims")):
|
|
153
|
+
return None
|
|
154
|
+
return f"operator:{arn}"[:MAXIMUM_OPERATOR_LABEL]
|
|
155
|
+
|
|
156
|
+
|
|
104
157
|
def clean_text(value: Any, limit: int) -> str | None:
|
|
105
158
|
if not isinstance(value, str):
|
|
106
159
|
return None
|
|
@@ -190,13 +243,15 @@ def file_proposal(arguments: dict[str, Any], *, actor_sub: str, now_iso: str, pr
|
|
|
190
243
|
return row, None
|
|
191
244
|
|
|
192
245
|
|
|
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:
|
|
246
|
+
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
247
|
"""The changes that move ``row`` to ``to_state``, or why it may not move."""
|
|
195
248
|
state = row.get("state")
|
|
196
249
|
if to_state not in STATES:
|
|
197
250
|
return refused("invalid_state", f"state must be one of {', '.join(STATES)}.")
|
|
198
251
|
if to_state not in TRANSITIONS.get(state, ()):
|
|
199
252
|
return refused("transition_not_allowed", f"A proposal in {state} cannot move to {to_state}.")
|
|
253
|
+
if row.get("held"):
|
|
254
|
+
return refused("proposal_held", "That proposal is on hold. Release it before it moves.")
|
|
200
255
|
text = None
|
|
201
256
|
if reason is not None and str(reason).strip():
|
|
202
257
|
text = clean_text(reason, MAXIMUM_REASON)
|
|
@@ -209,20 +264,38 @@ def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_su
|
|
|
209
264
|
return refused("result_required", f"An override says what the editor decided: {' or '.join(OVERRIDE_RESULTS)}.")
|
|
210
265
|
elif result is not None:
|
|
211
266
|
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":
|
|
267
|
+
entry = {"from": state, "to": to_state, "by": by, "actor": actor_sub, "reason": text, "at": now_iso}
|
|
213
268
|
if result is not None:
|
|
214
269
|
entry["result"] = result
|
|
215
270
|
history = [*(parse_json(row.get("history")) or []), entry]
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
271
|
+
changes: dict[str, Any] = {"state": to_state, "history": json.dumps(history)}
|
|
272
|
+
if to_state == OVERRIDDEN_BY_EDITOR:
|
|
273
|
+
# Stored on the proposal beside the community result, which stays as it was: readers see both.
|
|
274
|
+
changes["override"] = json.dumps({"result": result, "reason": text, "at": now_iso})
|
|
275
|
+
return changes
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def apply_transition(
|
|
279
|
+
store: ProposalStore,
|
|
280
|
+
*,
|
|
281
|
+
proposal_id: Any,
|
|
282
|
+
to_state: Any,
|
|
283
|
+
reason: Any,
|
|
284
|
+
actor_sub: str,
|
|
285
|
+
now_iso: str,
|
|
286
|
+
result: Any = None,
|
|
287
|
+
by: str = BY_STAFF,
|
|
288
|
+
allowed: frozenset[tuple[str, str]] | None = None,
|
|
289
|
+
) -> Outcome:
|
|
290
|
+
"""Move a proposal. ``allowed`` (as (from, to) pairs) narrows the road for a caller who may not take all of it."""
|
|
220
291
|
if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
|
|
221
292
|
return refused("invalid_proposal", "That is not a proposal id.")
|
|
222
293
|
row = store.get_proposal(proposal_id)
|
|
223
294
|
if row is None:
|
|
224
295
|
return refused("not_found", "There is no such proposal.")
|
|
225
|
-
|
|
296
|
+
if allowed is not None and (row.get("state"), to_state) not in allowed:
|
|
297
|
+
return refused("operator_not_allowed", "An operator can only move a draft to in_steering or declined; the rest is for editors and admins.")
|
|
298
|
+
plan = transition_plan(row, to_state, reason, actor_sub=actor_sub, now_iso=now_iso, result=result, by=by)
|
|
226
299
|
if isinstance(plan, Outcome):
|
|
227
300
|
return plan
|
|
228
301
|
if not store.change_proposal(proposal_id, plan, row["state"], now_iso):
|
|
@@ -230,13 +303,101 @@ def apply_transition(store: ProposalStore, *, proposal_id: Any, to_state: Any, r
|
|
|
230
303
|
return Outcome("ok", id=proposal_id)
|
|
231
304
|
|
|
232
305
|
|
|
233
|
-
def
|
|
234
|
-
|
|
235
|
-
|
|
306
|
+
def log_entry(action: str, actor_sub: str, now_iso: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
307
|
+
entry: dict[str, Any] = {"action": action, "by": BY_STAFF, "actor": actor_sub, "at": now_iso}
|
|
308
|
+
if detail:
|
|
309
|
+
entry["detail"] = detail
|
|
310
|
+
return entry
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def edit_plan(row: dict[str, Any], action: Any, *, rule: Any = None, exclusion_id: Any = None, actor_sub: str, now_iso: str) -> dict[str, Any] | Outcome:
|
|
314
|
+
"""The changes for a pin, hold or vote-exclusion action on ``row``, or why it may not be done.
|
|
315
|
+
|
|
316
|
+
Every action is appended to the private ``editorLog`` in the same write. Changing the exclusions clears the frozen
|
|
317
|
+
``communityResult`` so the next snapshot fixes it again with the exclusions applied; the function never counts votes itself.
|
|
318
|
+
"""
|
|
319
|
+
if action not in EDIT_ACTIONS:
|
|
320
|
+
return refused("invalid_action", f"action must be one of {', '.join(EDIT_ACTIONS)}.")
|
|
321
|
+
detail: dict[str, Any] | None = None
|
|
322
|
+
changes: dict[str, Any] = {}
|
|
323
|
+
if action in (PIN, UNPIN):
|
|
324
|
+
if bool(row.get("pinned")) == (action == PIN):
|
|
325
|
+
return refused("already_pinned" if action == PIN else "not_pinned", "That proposal is already pinned." if action == PIN else "That proposal is not pinned.")
|
|
326
|
+
changes["pinned"] = action == PIN
|
|
327
|
+
elif action in (HOLD, RELEASE):
|
|
328
|
+
if action == HOLD and row.get("state") in FINAL_STATES:
|
|
329
|
+
return refused("proposal_decided", "A decided proposal cannot be put on hold.")
|
|
330
|
+
if bool(row.get("held")) == (action == HOLD):
|
|
331
|
+
return refused("already_held" if action == HOLD else "not_held", "That proposal is already on hold." if action == HOLD else "That proposal is not on hold.")
|
|
332
|
+
changes["held"] = action == HOLD
|
|
333
|
+
else:
|
|
334
|
+
exclusions = parse_json(row.get("exclusions")) or []
|
|
335
|
+
if action == EXCLUDE_VOTES:
|
|
336
|
+
if rule not in EXCLUSION_RULES:
|
|
337
|
+
return refused("invalid_rule", f"rule must be one of {', '.join(EXCLUSION_RULES)}.")
|
|
338
|
+
identifier = "exc-" + uuid.uuid4().hex[:12]
|
|
339
|
+
exclusions = [*exclusions, {"id": identifier, "rule": rule, "at": now_iso, "actor": actor_sub}]
|
|
340
|
+
detail = {"exclusion": identifier, "rule": rule}
|
|
341
|
+
else:
|
|
342
|
+
if not isinstance(exclusion_id, str) or not EXCLUSION_ID.fullmatch(exclusion_id):
|
|
343
|
+
return refused("invalid_exclusion", "That is not an exclusion id.")
|
|
344
|
+
found = next((entry for entry in exclusions if entry.get("id") == exclusion_id), None)
|
|
345
|
+
if found is None:
|
|
346
|
+
return refused("unknown_exclusion", "That proposal has no such exclusion.")
|
|
347
|
+
if found.get("undoneAt"):
|
|
348
|
+
return refused("already_undone", "That exclusion was already undone.")
|
|
349
|
+
found["undoneAt"], found["undoneBy"] = now_iso, actor_sub
|
|
350
|
+
detail = {"exclusion": exclusion_id, "rule": found.get("rule")}
|
|
351
|
+
changes["exclusions"] = json.dumps(exclusions)
|
|
352
|
+
if row.get("communityResult"):
|
|
353
|
+
changes["communityResult"] = None # removed: the next snapshot fixes it again without the excluded votes
|
|
354
|
+
log = [*(parse_json(row.get("editorLog")) or []), log_entry(action, actor_sub, now_iso, detail)][-MAXIMUM_EDITOR_LOG:]
|
|
355
|
+
changes["editorLog"] = json.dumps(log)
|
|
356
|
+
return changes
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def apply_edit(store: ProposalStore, *, proposal_id: Any, action: Any, rule: Any, exclusion_id: Any, actor_sub: str, now_iso: str) -> Outcome:
|
|
360
|
+
if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
|
|
361
|
+
return refused("invalid_proposal", "That is not a proposal id.")
|
|
362
|
+
row = store.get_proposal(proposal_id)
|
|
363
|
+
if row is None:
|
|
364
|
+
return refused("not_found", "There is no such proposal.")
|
|
365
|
+
plan = edit_plan(row, action, rule=rule, exclusion_id=exclusion_id, actor_sub=actor_sub, now_iso=now_iso)
|
|
366
|
+
if isinstance(plan, Outcome):
|
|
367
|
+
return plan
|
|
368
|
+
if not store.change_proposal(proposal_id, plan, row["state"], now_iso, expected_updated_at=row.get("updatedAt")):
|
|
369
|
+
return refused("target_changed", "Someone else changed that proposal first.")
|
|
370
|
+
# An exclusion's id is what undo_exclusion takes; the pin and hold receipts carry the proposal id.
|
|
371
|
+
created = json.loads(plan["exclusions"])[-1]["id"] if action == EXCLUDE_VOTES else proposal_id
|
|
372
|
+
return Outcome("ok", id=created)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str, account_id: str | None = None) -> dict[str, Any]:
|
|
376
|
+
"""``fileProposal``, ``transitionProposal`` or ``editProposal`` from AppSync. Answers a receipt, never an exception.
|
|
377
|
+
|
|
378
|
+
Editors and admins act as themselves. An IAM principal of ``account_id`` (the function's own account) acts as an operator:
|
|
379
|
+
it files AI proposals and moves draft to in_steering or declined, recorded as ``operator:<ARN>``. Anyone else is refused.
|
|
380
|
+
"""
|
|
381
|
+
identity = event.get("identity") or {}
|
|
382
|
+
staff = staff_subject(identity)
|
|
383
|
+
operator = None if staff is not None else operator_actor(identity, account_id)
|
|
384
|
+
actor = staff or operator
|
|
236
385
|
if actor is None:
|
|
237
|
-
return refused("not_staff", "Only editors and
|
|
386
|
+
return refused("not_staff", "Only editors, admins and operators of this account can file proposals; only editors and admins decide them.").to_dict()
|
|
238
387
|
arguments = event.get("arguments") or {}
|
|
239
|
-
field = (event
|
|
388
|
+
field = event_field_name(event)
|
|
389
|
+
if field == "editProposal":
|
|
390
|
+
if staff is None:
|
|
391
|
+
return refused("operator_not_allowed", "Pinning, holding and excluding votes are for editors and admins; an operator can only file proposals and move a draft to in_steering or declined.").to_dict()
|
|
392
|
+
return apply_edit(
|
|
393
|
+
store,
|
|
394
|
+
proposal_id=arguments.get("proposalId"),
|
|
395
|
+
action=arguments.get("action"),
|
|
396
|
+
rule=arguments.get("rule"),
|
|
397
|
+
exclusion_id=arguments.get("exclusionId"),
|
|
398
|
+
actor_sub=staff,
|
|
399
|
+
now_iso=now_iso,
|
|
400
|
+
).to_dict()
|
|
240
401
|
if field == "fileProposal":
|
|
241
402
|
row, problem = file_proposal(arguments, actor_sub=actor, now_iso=now_iso)
|
|
242
403
|
if problem is not None:
|
|
@@ -252,4 +413,6 @@ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_is
|
|
|
252
413
|
result=arguments.get("result"),
|
|
253
414
|
actor_sub=actor,
|
|
254
415
|
now_iso=now_iso,
|
|
416
|
+
by=BY_OPERATOR if operator else BY_STAFF,
|
|
417
|
+
allowed=OPERATOR_TRANSITIONS if operator else None,
|
|
255
418
|
).to_dict()
|
|
@@ -15,7 +15,7 @@ export const readerSteeringManifest = {
|
|
|
15
15
|
name: "reader-steering",
|
|
16
16
|
contract: 1,
|
|
17
17
|
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
18
|
-
mutations: ["moderateReader", "fileProposal", "transitionProposal"],
|
|
18
|
+
mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
|
|
19
19
|
guest: { mutations: [] },
|
|
20
20
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
21
21
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -22,7 +22,7 @@ export const readerSteeringManifest: PluginManifest = {
|
|
|
22
22
|
name: "reader-steering",
|
|
23
23
|
contract: 1,
|
|
24
24
|
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
25
|
-
mutations: ["moderateReader", "fileProposal", "transitionProposal"],
|
|
25
|
+
mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
|
|
26
26
|
guest: { mutations: [] },
|
|
27
27
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
28
28
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -148,6 +148,12 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
148
148
|
state: a.string().required(),
|
|
149
149
|
history: a.json().required(),
|
|
150
150
|
communityResult: a.json(),
|
|
151
|
+
pinned: a.boolean(),
|
|
152
|
+
held: a.boolean(),
|
|
153
|
+
override: a.json(),
|
|
154
|
+
exclusions: a.json(),
|
|
155
|
+
editorLog: a.json(),
|
|
156
|
+
needsLook: a.json(),
|
|
151
157
|
createdAt: a.datetime().required(),
|
|
152
158
|
})
|
|
153
159
|
.disableOperations(["subscriptions"])
|
|
@@ -159,6 +165,9 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
159
165
|
error: a.string(),
|
|
160
166
|
message: a.string(),
|
|
161
167
|
}),
|
|
168
|
+
// Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
|
|
169
|
+
// schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
|
|
170
|
+
// of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
|
|
162
171
|
fileProposal: a
|
|
163
172
|
.mutation()
|
|
164
173
|
.arguments({
|
|
@@ -172,6 +181,8 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
172
181
|
.returns(a.ref("ProposalReceipt"))
|
|
173
182
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
174
183
|
.handler(functionHandler("readerActions", readerActions)),
|
|
184
|
+
// The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
|
|
185
|
+
// Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
|
|
175
186
|
transitionProposal: a
|
|
176
187
|
.mutation()
|
|
177
188
|
.arguments({
|
|
@@ -183,6 +194,19 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
183
194
|
.returns(a.ref("ProposalReceipt"))
|
|
184
195
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
185
196
|
.handler(functionHandler("readerActions", readerActions)),
|
|
197
|
+
// Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
|
|
198
|
+
// undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
|
|
199
|
+
editProposal: a
|
|
200
|
+
.mutation()
|
|
201
|
+
.arguments({
|
|
202
|
+
proposalId: a.string().required(),
|
|
203
|
+
action: a.string().required(),
|
|
204
|
+
rule: a.string(),
|
|
205
|
+
exclusionId: a.string(),
|
|
206
|
+
})
|
|
207
|
+
.returns(a.ref("ProposalReceipt"))
|
|
208
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
209
|
+
.handler(functionHandler("readerActions", readerActions)),
|
|
186
210
|
ModerationReceipt: a.customType({
|
|
187
211
|
status: a.string().required(),
|
|
188
212
|
id: a.string(),
|
|
@@ -150,6 +150,12 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
150
150
|
state: a.string().required(),
|
|
151
151
|
history: a.json().required(),
|
|
152
152
|
communityResult: a.json(),
|
|
153
|
+
pinned: a.boolean(),
|
|
154
|
+
held: a.boolean(),
|
|
155
|
+
override: a.json(),
|
|
156
|
+
exclusions: a.json(),
|
|
157
|
+
editorLog: a.json(),
|
|
158
|
+
needsLook: a.json(),
|
|
153
159
|
createdAt: a.datetime().required(),
|
|
154
160
|
})
|
|
155
161
|
.disableOperations(["subscriptions"])
|
|
@@ -161,6 +167,9 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
161
167
|
error: a.string(),
|
|
162
168
|
message: a.string(),
|
|
163
169
|
}),
|
|
170
|
+
// Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
|
|
171
|
+
// schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
|
|
172
|
+
// of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
|
|
164
173
|
fileProposal: a
|
|
165
174
|
.mutation()
|
|
166
175
|
.arguments({
|
|
@@ -174,6 +183,8 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
174
183
|
.returns(a.ref("ProposalReceipt"))
|
|
175
184
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
176
185
|
.handler(functionHandler("readerActions", readerActions)),
|
|
186
|
+
// The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
|
|
187
|
+
// Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
|
|
177
188
|
transitionProposal: a
|
|
178
189
|
.mutation()
|
|
179
190
|
.arguments({
|
|
@@ -185,6 +196,19 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
185
196
|
.returns(a.ref("ProposalReceipt"))
|
|
186
197
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
187
198
|
.handler(functionHandler("readerActions", readerActions)),
|
|
199
|
+
// Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
|
|
200
|
+
// undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
|
|
201
|
+
editProposal: a
|
|
202
|
+
.mutation()
|
|
203
|
+
.arguments({
|
|
204
|
+
proposalId: a.string().required(),
|
|
205
|
+
action: a.string().required(),
|
|
206
|
+
rule: a.string(),
|
|
207
|
+
exclusionId: a.string(),
|
|
208
|
+
})
|
|
209
|
+
.returns(a.ref("ProposalReceipt"))
|
|
210
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
211
|
+
.handler(functionHandler("readerActions", readerActions)),
|
|
188
212
|
ModerationReceipt: a.customType({
|
|
189
213
|
status: a.string().required(),
|
|
190
214
|
id: a.string(),
|
|
@@ -72,6 +72,10 @@ export type AutomationRecord = {
|
|
|
72
72
|
enabled: boolean;
|
|
73
73
|
enabledNote: string;
|
|
74
74
|
trigger: { kind: "monitor" | "schedule" | "event" | "manual"; config: Record<string, unknown> };
|
|
75
|
+
/** Story-stage automations only: the stage that fires it, where the engine moves the story after, and the bot-hop limit. */
|
|
76
|
+
onStage?: string | null;
|
|
77
|
+
nextStage?: string | null;
|
|
78
|
+
maxHops?: number | null;
|
|
75
79
|
supported: boolean;
|
|
76
80
|
supportNote: string | null;
|
|
77
81
|
procedureKey: string;
|
|
@@ -106,6 +110,10 @@ export function triggerText(trigger: AutomationRecord["trigger"]): string {
|
|
|
106
110
|
if (trigger.kind === "monitor") {
|
|
107
111
|
return `${kind} ${String(trigger.config.monitorId ?? "?")}, delivering to ${String(trigger.config.publicationId ?? "?")}`;
|
|
108
112
|
}
|
|
113
|
+
if (trigger.kind === "event" && trigger.config.event === "story.stage_entered") {
|
|
114
|
+
const next = trigger.config.nextStage ? `, then moves it to ${String(trigger.config.nextStage)}` : "";
|
|
115
|
+
return `${kind}: a story enters ${String(trigger.config.onStage ?? "?")}${next}`;
|
|
116
|
+
}
|
|
109
117
|
return kind;
|
|
110
118
|
}
|
|
111
119
|
|
|
@@ -41,6 +41,8 @@ export interface ProspectusSignInRouteOptions {
|
|
|
41
41
|
nowInSeconds?: () => number;
|
|
42
42
|
/** Trusted configuration the public site origin is read from; defaults to process.env. */
|
|
43
43
|
environment?: Record<string, string | undefined>;
|
|
44
|
+
/** Receives each sign-in failure line; defaults to console.error so it reaches the compute log. */
|
|
45
|
+
log?: (line: string) => void;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
@@ -91,6 +93,22 @@ function signInFailedResponse(status: number, code: string, message: string): Ne
|
|
|
91
93
|
* A failed sign-in never leaves the browser on /api/auth/prospectus/callback: it answers 303 to the page the reader started from
|
|
92
94
|
* (or the home page when that is not known) with `sign_in_error=<code>`, which the reader plugin turns into a short message.
|
|
93
95
|
*/
|
|
96
|
+
/** Which callback step failed; with the code it tells an operator where a reader's sign-in stopped. */
|
|
97
|
+
type SignInFailureBranch = "pending_cookie" | "provider_error" | "missing_parameters" | "completion" | "unexpected";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One structured line per failed callback: `prospectus_sign_in_failed {"code":...,"branch":...}`. It carries only the
|
|
101
|
+
* code, the branch, and (for errors from completing the sign-in) the HTTP status, Prospectus error code, its rule text
|
|
102
|
+
* (`prospectusMessage`, which names the broken rule and carries no personal data) and error name.
|
|
103
|
+
* Never tokens, authorization codes, state, emails or subjects.
|
|
104
|
+
*/
|
|
105
|
+
function logSignInFailure(
|
|
106
|
+
options: ProspectusSignInRouteOptions,
|
|
107
|
+
entry: { code: string; branch: SignInFailureBranch; httpStatus?: number; prospectusError?: string; prospectusMessage?: string; errorName?: string },
|
|
108
|
+
): void {
|
|
109
|
+
(options.log ?? console.error)(`prospectus_sign_in_failed ${JSON.stringify(entry)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
function signInFailedRedirect(request: NextRequest, options: ProspectusSignInRouteOptions, returnPath: string | null, code: string): NextResponse {
|
|
95
113
|
const target = new URL(returnPath ?? "/", siteOriginFor(request, options));
|
|
96
114
|
target.searchParams.set("sign_in_error", /^[a-z0-9_]{1,60}$/.test(code) ? code : "sign_in_failed");
|
|
@@ -169,13 +187,16 @@ async function completeCallback(
|
|
|
169
187
|
options: ProspectusSignInRouteOptions,
|
|
170
188
|
nowInSeconds: number,
|
|
171
189
|
): Promise<NextResponse> {
|
|
172
|
-
const failed = (code: string
|
|
173
|
-
|
|
190
|
+
const failed = (code: string, branch: SignInFailureBranch, detail: { httpStatus?: number; prospectusError?: string; prospectusMessage?: string; errorName?: string } = {}) => {
|
|
191
|
+
logSignInFailure(options, { code, branch, ...detail });
|
|
192
|
+
return signInFailedRedirect(request, options, pendingSignIn?.returnPath ?? null, code);
|
|
193
|
+
};
|
|
194
|
+
if (!pendingSignIn) return failed("sign_in_expired", "pending_cookie");
|
|
174
195
|
const searchParams = request.nextUrl.searchParams;
|
|
175
|
-
if (searchParams.get("error")) return failed("sign_in_declined");
|
|
196
|
+
if (searchParams.get("error")) return failed("sign_in_declined", "provider_error");
|
|
176
197
|
const authorizationCode = searchParams.get("code");
|
|
177
198
|
const returnedState = searchParams.get("state");
|
|
178
|
-
if (!authorizationCode || !returnedState) return failed("sign_in_incomplete");
|
|
199
|
+
if (!authorizationCode || !returnedState) return failed("sign_in_incomplete", "missing_parameters");
|
|
179
200
|
try {
|
|
180
201
|
const signedIn = await completeProspectusGoogleSignIn({
|
|
181
202
|
accounts: runtime.accounts,
|
|
@@ -200,9 +221,8 @@ async function completeCallback(
|
|
|
200
221
|
);
|
|
201
222
|
return response;
|
|
202
223
|
} catch (error) {
|
|
203
|
-
if (error instanceof ProspectusAccountsError) return failed(error.code);
|
|
204
|
-
|
|
205
|
-
return failed("sign_in_failed");
|
|
224
|
+
if (error instanceof ProspectusAccountsError) return failed(error.code, "completion", { httpStatus: error.status, prospectusError: error.code, prospectusMessage: error.message.slice(0, 300) });
|
|
225
|
+
return failed("sign_in_failed", "unexpected", { errorName: error instanceof Error ? error.name : "error" });
|
|
206
226
|
}
|
|
207
227
|
}
|
|
208
228
|
|
|
@@ -132,19 +132,28 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
|
|
|
132
132
|
export class ProspectusSiteAccounts {
|
|
133
133
|
constructor(private readonly transport: ProspectusTransport) {}
|
|
134
134
|
|
|
135
|
-
requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number }> {
|
|
135
|
+
requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number; challengeToken?: string }> {
|
|
136
136
|
return this.transport({ path: "/v1/identities/google/challenge", method: "POST", body: {} }) as Promise<{
|
|
137
137
|
challengeId: string;
|
|
138
138
|
nonce: string;
|
|
139
139
|
expiresAt: number;
|
|
140
|
+
challengeToken?: string;
|
|
140
141
|
}>;
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
|
|
144
|
+
/**
|
|
145
|
+
* When Prospectus signs its challenges it stores nothing, so the signed `challengeToken` is the only proof the
|
|
146
|
+
* challenge exists and must be sent back unchanged. Without a token (a service that stores challenges) the ID is sent.
|
|
147
|
+
*/
|
|
148
|
+
resolveGoogleSignIn(
|
|
149
|
+
challenge: { challengeId: string; challengeToken?: string },
|
|
150
|
+
humanAccessToken: string,
|
|
151
|
+
identityToken: string,
|
|
152
|
+
): Promise<{ person: ProspectusPerson }> {
|
|
144
153
|
return this.transport({
|
|
145
154
|
path: "/v1/identities/google/resolve",
|
|
146
155
|
method: "POST",
|
|
147
|
-
body: { challengeId },
|
|
156
|
+
body: challenge.challengeToken ? { challengeToken: challenge.challengeToken } : { challengeId: challenge.challengeId },
|
|
148
157
|
humanAccessToken,
|
|
149
158
|
identityToken,
|
|
150
159
|
}) as Promise<{ person: ProspectusPerson }>;
|
|
@@ -197,6 +206,8 @@ export interface GoogleOnlyCognitoClient {
|
|
|
197
206
|
export interface PendingProspectusSignIn {
|
|
198
207
|
challengeId: string;
|
|
199
208
|
nonce: string;
|
|
209
|
+
/** The signed challenge from Prospectus, present when it signs challenges instead of storing them. */
|
|
210
|
+
challengeToken?: string;
|
|
200
211
|
state: string;
|
|
201
212
|
codeVerifier: string;
|
|
202
213
|
expiresAt: number;
|
|
@@ -242,7 +253,13 @@ export async function beginProspectusGoogleSignIn(
|
|
|
242
253
|
}).toString();
|
|
243
254
|
return {
|
|
244
255
|
authorizationUrl: authorizationUrl.toString(),
|
|
245
|
-
pendingSignIn: {
|
|
256
|
+
pendingSignIn: {
|
|
257
|
+
challengeId: challenge.challengeId,
|
|
258
|
+
nonce: challenge.nonce,
|
|
259
|
+
...(challenge.challengeToken ? { challengeToken: challenge.challengeToken } : {}),
|
|
260
|
+
state, codeVerifier,
|
|
261
|
+
expiresAt: challenge.expiresAt,
|
|
262
|
+
},
|
|
246
263
|
};
|
|
247
264
|
}
|
|
248
265
|
|
|
@@ -303,7 +320,7 @@ export async function completeProspectusGoogleSignIn(
|
|
|
303
320
|
expiresInSeconds: Number(tokenBody.expires_in) || 0,
|
|
304
321
|
};
|
|
305
322
|
await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
|
|
306
|
-
const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn
|
|
323
|
+
const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn, tokens.accessToken, tokens.idToken);
|
|
307
324
|
return {
|
|
308
325
|
person,
|
|
309
326
|
accessToken: tokens.accessToken,
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED