@anthusai/papyrus 5.35.0 → 5.36.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.
@@ -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
- 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))
1504
+ if proposal_rules.event_field_name(event) in proposal_rules.MUTATIONS:
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,14 +1527,20 @@ 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
 
1533
1539
  settings = settings_from_environment()
1534
1540
  store = store_from_environment()
1535
1541
  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)
1542
+ if proposal_rules.is_appsync_event(event):
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 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
+ 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
@@ -54,8 +57,28 @@ TARGET_COUNTS: dict[str, tuple[int, int]] = {
54
57
  TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
55
58
 
56
59
  MUTATIONS = ("fileProposal", "transitionProposal")
60
+
61
+
62
+ def event_field_name(event: dict[str, Any]) -> str | None:
63
+ """The GraphQL field an AppSync invocation is for.
64
+
65
+ The Amplify Gen 2 Lambda data source sends ``fieldName`` at the top level with no ``info`` key; a direct resolver nests it
66
+ under ``info``. Accept both.
67
+ """
68
+ info = event.get("info")
69
+ name = (info.get("fieldName") if isinstance(info, dict) else None) or event.get("fieldName")
70
+ return name if isinstance(name, str) and name else None
71
+
72
+
73
+ def is_appsync_event(event: dict[str, Any]) -> bool:
74
+ """True for an AppSync invocation (arguments plus ``info`` or a top-level ``fieldName``); the public path sends siteSubject, action and payload."""
75
+ return "arguments" in event and ("info" in event or event_field_name(event) is not None)
57
76
  PROPOSER_AI = "ai"
58
77
  STAFF_GROUPS = frozenset({"editor", "admin"})
78
+ #: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
79
+ OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
80
+ BY_STAFF, BY_OPERATOR = "staff", "operator"
81
+ MAXIMUM_OPERATOR_LABEL = 256
59
82
  PROPOSAL_ID = re.compile(r"prop-[0-9a-f]{32}")
60
83
  TARGET_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:\-]{0,127}")
61
84
  MAXIMUM_TITLE = 200
@@ -101,6 +124,24 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
101
124
  return subject
102
125
 
103
126
 
127
+ def operator_actor(identity: dict[str, Any], account_id: str | None) -> str | None:
128
+ """``operator:<principal ARN>`` for an IAM principal (a role or user) of the function's own account, or ``None``.
129
+
130
+ A Cognito identity-pool user is also an IAM principal of this account, but it carries a Cognito identity id (and a
131
+ user-pool caller carries a subject); neither is an operator. A principal of another account is refused, and so is a
132
+ caller whose ARN names another account than the one AppSync reports.
133
+ """
134
+ if not isinstance(identity, dict) or not account_id:
135
+ return None
136
+ arn = identity.get("userArn")
137
+ parts = arn.split(":") if isinstance(arn, str) else []
138
+ if len(parts) < 6 or parts[0] != "arn" or parts[4] != account_id or identity.get("accountId") != account_id:
139
+ return None
140
+ if any(identity.get(key) for key in ("cognitoIdentityId", "cognitoIdentityPoolId", "cognitoIdentityAuthType", "sub", "claims")):
141
+ return None
142
+ return f"operator:{arn}"[:MAXIMUM_OPERATOR_LABEL]
143
+
144
+
104
145
  def clean_text(value: Any, limit: int) -> str | None:
105
146
  if not isinstance(value, str):
106
147
  return None
@@ -190,7 +231,7 @@ def file_proposal(arguments: dict[str, Any], *, actor_sub: str, now_iso: str, pr
190
231
  return row, None
191
232
 
192
233
 
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:
234
+ 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
235
  """The changes that move ``row`` to ``to_state``, or why it may not move."""
195
236
  state = row.get("state")
196
237
  if to_state not in STATES:
@@ -209,20 +250,34 @@ def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_su
209
250
  return refused("result_required", f"An override says what the editor decided: {' or '.join(OVERRIDE_RESULTS)}.")
210
251
  elif result is not None:
211
252
  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}
253
+ entry = {"from": state, "to": to_state, "by": by, "actor": actor_sub, "reason": text, "at": now_iso}
213
254
  if result is not None:
214
255
  entry["result"] = result
215
256
  history = [*(parse_json(row.get("history")) or []), entry]
216
257
  return {"state": to_state, "history": json.dumps(history)}
217
258
 
218
259
 
219
- def apply_transition(store: ProposalStore, *, proposal_id: Any, to_state: Any, reason: Any, actor_sub: str, now_iso: str, result: Any = None) -> Outcome:
260
+ def apply_transition(
261
+ store: ProposalStore,
262
+ *,
263
+ proposal_id: Any,
264
+ to_state: Any,
265
+ reason: Any,
266
+ actor_sub: str,
267
+ now_iso: str,
268
+ result: Any = None,
269
+ by: str = BY_STAFF,
270
+ allowed: frozenset[tuple[str, str]] | None = None,
271
+ ) -> Outcome:
272
+ """Move a proposal. ``allowed`` (as (from, to) pairs) narrows the road for a caller who may not take all of it."""
220
273
  if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
221
274
  return refused("invalid_proposal", "That is not a proposal id.")
222
275
  row = store.get_proposal(proposal_id)
223
276
  if row is None:
224
277
  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)
278
+ if allowed is not None and (row.get("state"), to_state) not in allowed:
279
+ return refused("operator_not_allowed", "An operator can only move a draft to in_steering or declined; the rest is for editors and admins.")
280
+ plan = transition_plan(row, to_state, reason, actor_sub=actor_sub, now_iso=now_iso, result=result, by=by)
226
281
  if isinstance(plan, Outcome):
227
282
  return plan
228
283
  if not store.change_proposal(proposal_id, plan, row["state"], now_iso):
@@ -230,13 +285,20 @@ def apply_transition(store: ProposalStore, *, proposal_id: Any, to_state: Any, r
230
285
  return Outcome("ok", id=proposal_id)
231
286
 
232
287
 
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 {})
288
+ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str, account_id: str | None = None) -> dict[str, Any]:
289
+ """``fileProposal`` or ``transitionProposal`` from AppSync. Answers a receipt, never an exception.
290
+
291
+ Editors and admins act as themselves. An IAM principal of ``account_id`` (the function's own account) acts as an operator:
292
+ it files AI proposals and moves draft to in_steering or declined, recorded as ``operator:<ARN>``. Anyone else is refused.
293
+ """
294
+ identity = event.get("identity") or {}
295
+ staff = staff_subject(identity)
296
+ operator = None if staff is not None else operator_actor(identity, account_id)
297
+ actor = staff or operator
236
298
  if actor is None:
237
- return refused("not_staff", "Only editors and admins can file or decide proposals.").to_dict()
299
+ return refused("not_staff", "Only editors, admins and operators of this account can file proposals; only editors and admins decide them.").to_dict()
238
300
  arguments = event.get("arguments") or {}
239
- field = (event.get("info") or {}).get("fieldName")
301
+ field = event_field_name(event)
240
302
  if field == "fileProposal":
241
303
  row, problem = file_proposal(arguments, actor_sub=actor, now_iso=now_iso)
242
304
  if problem is not None:
@@ -252,4 +314,6 @@ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_is
252
314
  result=arguments.get("result"),
253
315
  actor_sub=actor,
254
316
  now_iso=now_iso,
317
+ by=BY_OPERATOR if operator else BY_STAFF,
318
+ allowed=OPERATOR_TRANSITIONS if operator else None,
255
319
  ).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({
@@ -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,21 @@ 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 and error name.
102
+ * Never tokens, authorization codes, state, emails or subjects.
103
+ */
104
+ function logSignInFailure(
105
+ options: ProspectusSignInRouteOptions,
106
+ entry: { code: string; branch: SignInFailureBranch; httpStatus?: number; prospectusError?: string; errorName?: string },
107
+ ): void {
108
+ (options.log ?? console.error)(`prospectus_sign_in_failed ${JSON.stringify(entry)}`);
109
+ }
110
+
94
111
  function signInFailedRedirect(request: NextRequest, options: ProspectusSignInRouteOptions, returnPath: string | null, code: string): NextResponse {
95
112
  const target = new URL(returnPath ?? "/", siteOriginFor(request, options));
96
113
  target.searchParams.set("sign_in_error", /^[a-z0-9_]{1,60}$/.test(code) ? code : "sign_in_failed");
@@ -169,13 +186,16 @@ async function completeCallback(
169
186
  options: ProspectusSignInRouteOptions,
170
187
  nowInSeconds: number,
171
188
  ): Promise<NextResponse> {
172
- const failed = (code: string) => signInFailedRedirect(request, options, pendingSignIn?.returnPath ?? null, code);
173
- if (!pendingSignIn) return failed("sign_in_expired");
189
+ const failed = (code: string, branch: SignInFailureBranch, detail: { httpStatus?: number; prospectusError?: string; errorName?: string } = {}) => {
190
+ logSignInFailure(options, { code, branch, ...detail });
191
+ return signInFailedRedirect(request, options, pendingSignIn?.returnPath ?? null, code);
192
+ };
193
+ if (!pendingSignIn) return failed("sign_in_expired", "pending_cookie");
174
194
  const searchParams = request.nextUrl.searchParams;
175
- if (searchParams.get("error")) return failed("sign_in_declined");
195
+ if (searchParams.get("error")) return failed("sign_in_declined", "provider_error");
176
196
  const authorizationCode = searchParams.get("code");
177
197
  const returnedState = searchParams.get("state");
178
- if (!authorizationCode || !returnedState) return failed("sign_in_incomplete");
198
+ if (!authorizationCode || !returnedState) return failed("sign_in_incomplete", "missing_parameters");
179
199
  try {
180
200
  const signedIn = await completeProspectusGoogleSignIn({
181
201
  accounts: runtime.accounts,
@@ -200,9 +220,8 @@ async function completeCallback(
200
220
  );
201
221
  return response;
202
222
  } 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");
223
+ if (error instanceof ProspectusAccountsError) return failed(error.code, "completion", { httpStatus: error.status, prospectusError: error.code });
224
+ return failed("sign_in_failed", "unexpected", { errorName: error instanceof Error ? error.name : "error" });
206
225
  }
207
226
  }
208
227
 
package/package.json CHANGED
@@ -448,7 +448,7 @@
448
448
  "optional": true
449
449
  }
450
450
  },
451
- "version": "5.35.0",
451
+ "version": "5.36.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.36.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",