@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.
@@ -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
- assignments = ", ".join(f"#c{index} = :c{index}" for index in range(len(changes)))
1021
- values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values())}}
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=f"SET {assignments}, #u = :now",
1026
- ConditionExpression="#st = :expected",
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.get("info") or {}).get("fieldName") in proposal_rules.MUTATIONS:
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 "arguments" in event and "info" in event:
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 only; the function checks the groups again). Every state change is appended to the proposal's
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": "staff", "actor": actor_sub, "reason": text, "at": now_iso}
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
- return {"state": to_state, "history": json.dumps(history)}
217
-
218
-
219
- def apply_transition(store: ProposalStore, *, proposal_id: Any, to_state: Any, reason: Any, actor_sub: str, now_iso: str, result: Any = None) -> Outcome:
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
- plan = transition_plan(row, to_state, reason, actor_sub=actor_sub, now_iso=now_iso, result=result)
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 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
- actor = staff_subject(event.get("identity") or {})
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 admins can file or decide proposals.").to_dict()
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.get("info") or {}).get("fieldName")
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) => signInFailedRedirect(request, options, pendingSignIn?.returnPath ?? null, code);
173
- if (!pendingSignIn) return failed("sign_in_expired");
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
- console.error(`Prospectus sign-in failed (${error instanceof Error ? error.name : "error"}).`);
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
- resolveGoogleSignIn(challengeId: string, humanAccessToken: string, identityToken: string): Promise<{ person: ProspectusPerson }> {
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: { challengeId: challenge.challengeId, nonce: challenge.nonce, state, codeVerifier, expiresAt: challenge.expiresAt },
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.challengeId, tokens.accessToken, tokens.idToken);
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
@@ -448,7 +448,7 @@
448
448
  "optional": true
449
449
  }
450
450
  },
451
- "version": "5.35.0",
451
+ "version": "5.37.0",
452
452
  "dependencies": {
453
453
  "@aws-amplify/adapter-nextjs": "^1.7.3",
454
454
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.35.0",
2
+ "version": "5.37.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",