@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.
- package/amplify/plugins/reader-steering/function/handler.py +11 -5
- package/amplify/plugins/reader-steering/function/proposals.py +74 -10
- package/amplify/plugins/reader-steering/schema.js +5 -0
- package/amplify/plugins/reader-steering/schema.ts +5 -0
- package/lib/automation-overview.ts +8 -0
- package/lib/prospectus-sign-in-routes.ts +26 -7
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -1499,10 +1499,10 @@ def run_ai_worker(
|
|
|
1499
1499
|
return result
|
|
1500
1500
|
|
|
1501
1501
|
|
|
1502
|
-
def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float) -> dict[str, Any]:
|
|
1502
|
+
def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float, account_id: str | None = None) -> dict[str, Any]:
|
|
1503
1503
|
"""``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
|
|
1504
|
-
if (event
|
|
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
|
|
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
|
|
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":
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
173
|
-
|
|
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
|
-
|
|
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
package/routes.manifest.json
CHANGED