@anthusai/papyrus 5.42.1 → 5.43.0-next.10
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/data/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/monitoring-view/schema.js +22 -0
- package/amplify/functions/monitoring-view/schema.ts +22 -0
- package/amplify/functions/monitoring-view/wire.js +1 -0
- package/amplify/functions/monitoring-view/wire.ts +1 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/handler.py +137 -0
- package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +20 -3
- package/amplify/functions/proposal-actions/resource.js +15 -0
- package/amplify/functions/proposal-actions/resource.ts +22 -0
- package/amplify/functions/proposal-actions/schema.js +106 -0
- package/amplify/functions/proposal-actions/schema.ts +108 -0
- package/amplify/functions/proposal-actions/wire.js +10 -0
- package/amplify/functions/proposal-actions/wire.ts +13 -0
- package/amplify/plugins/reader-steering/function/handler.py +13 -72
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
- package/amplify/plugins/reader-steering/schema.js +0 -95
- package/amplify/plugins/reader-steering/schema.ts +0 -95
- package/amplify/site-backend.js +4 -0
- package/amplify/site-backend.ts +4 -0
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/globals.css +31 -35
- package/app/tailwind.css +2 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +140 -23
- package/components/monitoring-panel.tsx +96 -6
- package/components/news-desk-client-provider.tsx +8 -2
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-mobile-navigation.tsx +23 -0
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-ops-shell.tsx +8 -10
- package/components/newsroom-organizer.css +1 -0
- package/components/newsroom-references-view.tsx +139 -257
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-frame.tsx +66 -0
- package/components/papyrus-console-shell.tsx +14 -42
- package/components/papyrus-private-chat-panel.tsx +50 -16
- package/components/proposals-panel.tsx +107 -0
- package/components/proposals-view.tsx +95 -0
- package/components/reference-review-card.tsx +400 -0
- package/components/topic-steering-workspace.tsx +13 -2
- package/components/use-console-visibility.ts +47 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/monitoring-client.ts +39 -3
- package/lib/monitoring-overview-fixture.ts +2 -2
- package/lib/monitoring-overview.ts +47 -3
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-chat-client.ts +30 -0
- package/lib/newsroom-chat-identity.ts +40 -0
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/papyrus-hosted-chat.ts +62 -80
- package/lib/papyrus-private-chat-client.ts +7 -4
- package/lib/proposals-admin.ts +115 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/prospectus-session.ts +2 -0
- package/lib/prospectus-sign-in-routes.ts +19 -2
- package/lib/prospectus-site-identity.ts +7 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/relevance-decisions.ts +9 -0
- package/lib/review-client.ts +38 -1
- package/lib/review-drafts.ts +231 -0
- package/lib/review-fixture.ts +36 -2
- package/lib/review-requests.ts +66 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +16 -1
- package/routes.manifest.json +12 -1
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.mjs +1 -1
package/amplify/data/schema.js
CHANGED
|
@@ -3,6 +3,7 @@ import { assignmentAction } from "../functions/assignment-action/resource.js";
|
|
|
3
3
|
import { categoryAction } from "../functions/category-action/resource.js";
|
|
4
4
|
import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema.js";
|
|
5
5
|
import { automationViewSchemaDefinitions } from "../functions/automation-view/schema.js";
|
|
6
|
+
import { proposalActionsSchemaDefinitions } from "../functions/proposal-actions/schema.js";
|
|
6
7
|
import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema.js";
|
|
7
8
|
import { contentActions } from "../functions/content-actions/resource.js";
|
|
8
9
|
import { knowledgeQuery } from "../functions/knowledge-query/resource.js";
|
|
@@ -2096,6 +2097,7 @@ function buildBaseDefinitions(includeEditions) {
|
|
|
2096
2097
|
...editorialChecksSchemaDefinitions,
|
|
2097
2098
|
...monitoringViewSchemaDefinitions,
|
|
2098
2099
|
...automationViewSchemaDefinitions,
|
|
2100
|
+
...proposalActionsSchemaDefinitions,
|
|
2099
2101
|
};
|
|
2100
2102
|
if (!includeEditions) {
|
|
2101
2103
|
const mutableDefinitions = definitions;
|
package/amplify/data/schema.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { assignmentAction } from "../functions/assignment-action/resource";
|
|
|
3
3
|
import { categoryAction } from "../functions/category-action/resource";
|
|
4
4
|
import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema";
|
|
5
5
|
import { automationViewSchemaDefinitions } from "../functions/automation-view/schema";
|
|
6
|
+
import { proposalActionsSchemaDefinitions } from "../functions/proposal-actions/schema";
|
|
6
7
|
import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema";
|
|
7
8
|
import { contentActions } from "../functions/content-actions/resource";
|
|
8
9
|
import { knowledgeQuery } from "../functions/knowledge-query/resource";
|
|
@@ -2225,6 +2226,7 @@ const definitions = {
|
|
|
2225
2226
|
...editorialChecksSchemaDefinitions,
|
|
2226
2227
|
...monitoringViewSchemaDefinitions,
|
|
2227
2228
|
...automationViewSchemaDefinitions,
|
|
2229
|
+
...proposalActionsSchemaDefinitions,
|
|
2228
2230
|
};
|
|
2229
2231
|
if (!includeEditions) {
|
|
2230
2232
|
const mutableDefinitions = definitions as Record<string, unknown>;
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
requestDelegation,
|
|
25
25
|
type DelegationStore,
|
|
26
26
|
} from "../shared/curation-delegation-flow";
|
|
27
|
+
import { claimGroups } from "../../../lib/staff-groups";
|
|
27
28
|
|
|
28
29
|
type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
|
|
29
30
|
type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
|
|
@@ -360,7 +361,7 @@ function requireSignedInPerson(event: { identity?: unknown }): { sub: string; la
|
|
|
360
361
|
}
|
|
361
362
|
const identity = event.identity as { sub?: unknown; claims?: Record<string, unknown> };
|
|
362
363
|
const claims = identity.claims ?? {};
|
|
363
|
-
const groups =
|
|
364
|
+
const groups = claimGroups(claims["cognito:groups"]);
|
|
364
365
|
return {
|
|
365
366
|
sub: normalizeRequiredString(claims.sub ?? identity.sub, "identity.sub"),
|
|
366
367
|
label: normalizeOptionalString(claims.email) ?? normalizeOptionalString(claims.name) ?? "Newsroom editor",
|
|
@@ -14,12 +14,34 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
14
14
|
.returns(a.json())
|
|
15
15
|
.authorization((allow) => [allow.groups(editors)])
|
|
16
16
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
17
|
+
// One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
|
|
18
|
+
// the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
|
|
19
|
+
monitoringPage: a
|
|
20
|
+
.query()
|
|
21
|
+
.arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
|
|
22
|
+
.returns(a.json())
|
|
23
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
24
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
17
25
|
monitoringEmailSubmission: a
|
|
18
26
|
.query()
|
|
19
27
|
.arguments({ receiptId: a.string().required() })
|
|
20
28
|
.returns(a.json())
|
|
21
29
|
.authorization((allow) => [allow.groups(editors)])
|
|
22
30
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
31
|
+
monitoringQueueEmailReference: a
|
|
32
|
+
.mutation()
|
|
33
|
+
.arguments({ input: a.json().required() })
|
|
34
|
+
.returns(a.json())
|
|
35
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
36
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
37
|
+
// Accept ("release") or Reject ("discard") one held email submission. The function validates the
|
|
38
|
+
// signed-in editor and that the message is routed to this publication; the receiver applies it.
|
|
39
|
+
monitoringDecideEmailSubmission: a
|
|
40
|
+
.mutation()
|
|
41
|
+
.arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
|
|
42
|
+
.returns(a.json())
|
|
43
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
44
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
23
45
|
monitoringResolveEmailArticleLink: a
|
|
24
46
|
.mutation()
|
|
25
47
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -16,12 +16,34 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
16
16
|
.returns(a.json())
|
|
17
17
|
.authorization((allow) => [allow.groups(editors)])
|
|
18
18
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
19
|
+
// One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
|
|
20
|
+
// the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
|
|
21
|
+
monitoringPage: a
|
|
22
|
+
.query()
|
|
23
|
+
.arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
|
|
24
|
+
.returns(a.json())
|
|
25
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
26
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
19
27
|
monitoringEmailSubmission: a
|
|
20
28
|
.query()
|
|
21
29
|
.arguments({ receiptId: a.string().required() })
|
|
22
30
|
.returns(a.json())
|
|
23
31
|
.authorization((allow) => [allow.groups(editors)])
|
|
24
32
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
33
|
+
monitoringQueueEmailReference: a
|
|
34
|
+
.mutation()
|
|
35
|
+
.arguments({ input: a.json().required() })
|
|
36
|
+
.returns(a.json())
|
|
37
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
38
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
39
|
+
// Accept ("release") or Reject ("discard") one held email submission. The function validates the
|
|
40
|
+
// signed-in editor and that the message is routed to this publication; the receiver applies it.
|
|
41
|
+
monitoringDecideEmailSubmission: a
|
|
42
|
+
.mutation()
|
|
43
|
+
.arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
|
|
44
|
+
.returns(a.json())
|
|
45
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
46
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
25
47
|
monitoringResolveEmailArticleLink: a
|
|
26
48
|
.mutation()
|
|
27
49
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -22,6 +22,7 @@ export function monitoringViewParameterArn(region, account) {
|
|
|
22
22
|
export function wireMonitoringView(backend, emailMonitorId = "hn") {
|
|
23
23
|
const lambda = backend.monitoringView.resources.lambda;
|
|
24
24
|
const stack = Stack.of(lambda);
|
|
25
|
+
lambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${stack.region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
|
|
25
26
|
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
|
|
26
27
|
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
27
28
|
lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
|
|
@@ -28,6 +28,7 @@ export function monitoringViewParameterArn(region: string, account: string): str
|
|
|
28
28
|
export function wireMonitoringView(backend: any, emailMonitorId = "hn"): void {
|
|
29
29
|
const lambda = backend.monitoringView.resources.lambda as LambdaFunction;
|
|
30
30
|
const stack = Stack.of(lambda);
|
|
31
|
+
lambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${stack.region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
|
|
31
32
|
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
|
|
32
33
|
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
33
34
|
lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
|
|
@@ -3,6 +3,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
|
|
|
3
3
|
import { Amplify } from "aws-amplify";
|
|
4
4
|
import { generateClient } from "aws-amplify/data";
|
|
5
5
|
import type { Schema } from "../../data/resource";
|
|
6
|
+
import { claimGroups } from "../../../lib/staff-groups";
|
|
6
7
|
|
|
7
8
|
type DataClient = ReturnType<typeof generateClient<Schema>>;
|
|
8
9
|
type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
|
|
@@ -474,20 +475,11 @@ function validateJsonSchemaInput(schema: Record<string, unknown>, input: Record<
|
|
|
474
475
|
|
|
475
476
|
function requireAdmin(event: ProcedureEvent): void {
|
|
476
477
|
const claims = objectValue(event.identity?.claims);
|
|
477
|
-
const groups = [
|
|
478
|
-
...normalizeClaimGroups(claims["cognito:groups"]),
|
|
479
|
-
...normalizeClaimGroups(claims.groups),
|
|
480
|
-
];
|
|
478
|
+
const groups = [...claimGroups(claims["cognito:groups"]), ...claimGroups(claims.groups)];
|
|
481
479
|
if (groups.includes("admin")) return;
|
|
482
480
|
throw new Error("Admin role required.");
|
|
483
481
|
}
|
|
484
482
|
|
|
485
|
-
function normalizeClaimGroups(value: unknown): string[] {
|
|
486
|
-
if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
487
|
-
if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
488
|
-
return [];
|
|
489
|
-
}
|
|
490
|
-
|
|
491
483
|
async function getDataClient(): Promise<DataClient> {
|
|
492
484
|
if (!clientPromise) {
|
|
493
485
|
clientPromise = (async () => {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""proposal-actions: the Proposals queue every newsroom backend has (editors and operators only).
|
|
2
|
+
|
|
3
|
+
Behind ``fileProposal``, ``transitionProposal``, ``editProposal`` and ``listStoryDrafts``. The rules are the stdlib module
|
|
4
|
+
``proposals.py`` beside this file; this file is the DynamoDB store and the AppSync entry point. The ``Proposal`` table is the
|
|
5
|
+
Amplify model table written directly, so editors can list it through the data API. Reader votes on proposals and the public
|
|
6
|
+
files belong to the reader-steering plugin, which reads this same table.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from decimal import Decimal
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
_FUNCTION_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
|
|
19
|
+
if _FUNCTION_DIRECTORY not in sys.path: # a Lambda already has its code directory on the path; a test loading this file by path does not
|
|
20
|
+
sys.path.insert(0, _FUNCTION_DIRECTORY)
|
|
21
|
+
import proposals as proposal_rules # noqa: E402
|
|
22
|
+
|
|
23
|
+
PROPOSAL_TABLE_ENV = "PAPYRUS_PROPOSAL_TABLE"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def iso(now_seconds: float) -> str:
|
|
27
|
+
"""UTC with milliseconds, so rows written in the same second still sort in the order they were written."""
|
|
28
|
+
moment = datetime.fromtimestamp(now_seconds, tz=timezone.utc)
|
|
29
|
+
return moment.strftime("%Y-%m-%dT%H:%M:%S.") + f"{moment.microsecond // 1000:03d}Z"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def plain(value: Any) -> Any:
|
|
33
|
+
if isinstance(value, Decimal):
|
|
34
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
return {key: plain(item) for key, item in value.items()}
|
|
37
|
+
if isinstance(value, list):
|
|
38
|
+
return [plain(item) for item in value]
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _without_none(item: dict[str, Any]) -> dict[str, Any]:
|
|
43
|
+
return {key: value for key, value in item.items() if value is not None}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_conditional_failure(error: Exception) -> bool:
|
|
47
|
+
return str(getattr(error, "response", {}).get("Error", {}).get("Code", "")) == "ConditionalCheckFailedException"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DynamoProposalStore:
|
|
51
|
+
"""The ``Proposal`` model table written directly: items carry ``__typename`` and ``updatedAt`` so staff can list them."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, *, resource: Any, proposal_table: str) -> None:
|
|
54
|
+
self._table = resource.Table(proposal_table)
|
|
55
|
+
|
|
56
|
+
def get_proposal(self, proposal_id: str) -> dict[str, Any] | None:
|
|
57
|
+
item = self._table.get_item(Key={"id": proposal_id}, ConsistentRead=True).get("Item")
|
|
58
|
+
return plain(item) if item else None
|
|
59
|
+
|
|
60
|
+
def create_proposal(self, row: dict[str, Any]) -> bool:
|
|
61
|
+
item = {**_without_none(row), "__typename": "Proposal", "updatedAt": row["createdAt"]}
|
|
62
|
+
try:
|
|
63
|
+
self._table.put_item(Item=item, ConditionExpression="attribute_not_exists(id)")
|
|
64
|
+
except Exception as error:
|
|
65
|
+
if is_conditional_failure(error):
|
|
66
|
+
return False
|
|
67
|
+
raise
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
def list_story_drafts(self, limit: int) -> list[dict[str, Any]]:
|
|
71
|
+
"""The story drafts of the table, only the fields ``listStoryDrafts`` may answer; at most ``limit`` (the newest if there are more)."""
|
|
72
|
+
fields = proposal_rules.STORY_DRAFT_FIELDS
|
|
73
|
+
names = {"#kind": "kind", **{f"#f{index}": field for index, field in enumerate(fields)}}
|
|
74
|
+
request: dict[str, Any] = {
|
|
75
|
+
"FilterExpression": "#kind = :kind",
|
|
76
|
+
"ProjectionExpression": ", ".join(f"#f{index}" for index in range(len(fields))) + ", #kind",
|
|
77
|
+
"ExpressionAttributeNames": names,
|
|
78
|
+
"ExpressionAttributeValues": {":kind": "story_draft"},
|
|
79
|
+
}
|
|
80
|
+
rows: list[dict[str, Any]] = []
|
|
81
|
+
while True:
|
|
82
|
+
page = self._table.scan(**request)
|
|
83
|
+
rows.extend(plain(item) for item in page.get("Items") or [])
|
|
84
|
+
if not page.get("LastEvaluatedKey"):
|
|
85
|
+
break
|
|
86
|
+
request["ExclusiveStartKey"] = page["LastEvaluatedKey"]
|
|
87
|
+
rows.sort(key=lambda row: (row.get("createdAt") or "", row.get("id") or ""), reverse=True)
|
|
88
|
+
return [{field: row.get(field) for field in fields} for row in rows[:limit]]
|
|
89
|
+
|
|
90
|
+
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool:
|
|
91
|
+
"""Apply ``changes`` if the state (and, when given, ``updatedAt``) is still what the caller saw; a value of ``None`` removes the attribute."""
|
|
92
|
+
names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
|
|
93
|
+
sets = ", ".join(f"#c{index} = :c{index}" for index, value in enumerate(changes.values()) if value is not None)
|
|
94
|
+
removes = ", ".join(f"#c{index}" for index, value in enumerate(changes.values()) if value is None)
|
|
95
|
+
values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values()) if value is not None}}
|
|
96
|
+
condition = "#st = :expected"
|
|
97
|
+
if expected_updated_at is not None:
|
|
98
|
+
condition += " AND #u = :seen"
|
|
99
|
+
values[":seen"] = expected_updated_at
|
|
100
|
+
expression = f"SET {sets + ', ' if sets else ''}#u = :now" + (f" REMOVE {removes}" if removes else "")
|
|
101
|
+
try:
|
|
102
|
+
self._table.update_item(
|
|
103
|
+
Key={"id": proposal_id},
|
|
104
|
+
UpdateExpression=expression,
|
|
105
|
+
ConditionExpression=condition,
|
|
106
|
+
ExpressionAttributeNames=names,
|
|
107
|
+
ExpressionAttributeValues=values,
|
|
108
|
+
)
|
|
109
|
+
except Exception as error:
|
|
110
|
+
if is_conditional_failure(error):
|
|
111
|
+
return False
|
|
112
|
+
raise
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def store_from_environment(environment: dict[str, str] | None = None) -> DynamoProposalStore:
|
|
117
|
+
import boto3
|
|
118
|
+
|
|
119
|
+
environment = os.environ if environment is None else environment
|
|
120
|
+
return DynamoProposalStore(resource=boto3.resource("dynamodb"), proposal_table=environment[PROPOSAL_TABLE_ENV])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def function_account(context: Any) -> str | None:
|
|
124
|
+
"""The AWS account this function runs in, from its own ARN: the only account whose IAM operators may file proposals."""
|
|
125
|
+
parts = str(getattr(context, "invoked_function_arn", None) or "").split(":")
|
|
126
|
+
return parts[4] if len(parts) > 4 and parts[4] else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def handle_event(event: dict[str, Any], *, store: Any, now_seconds: float, account_id: str | None = None) -> dict[str, Any]:
|
|
130
|
+
"""Route an AppSync invocation to the proposal rules. Answers a receipt, never an exception."""
|
|
131
|
+
if proposal_rules.event_field_name(event) in proposal_rules.QUERIES:
|
|
132
|
+
return proposal_rules.handle_proposal_query(event, store, account_id=account_id)
|
|
133
|
+
return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds), account_id=account_id)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
|
|
137
|
+
return handle_event(event, store=store_from_environment(), now_seconds=time.time(), account_id=function_account(_context))
|
package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py
RENAMED
|
@@ -92,6 +92,25 @@ def is_appsync_event(event: dict[str, Any]) -> bool:
|
|
|
92
92
|
return "arguments" in event and ("info" in event or event_field_name(event) is not None)
|
|
93
93
|
PROPOSER_AI = "ai"
|
|
94
94
|
STAFF_GROUPS = frozenset({"editor", "admin"})
|
|
95
|
+
|
|
96
|
+
_GROUP_SEPARATORS = re.compile(r"[\s,]+")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def claim_groups(value: Any) -> list[str]:
|
|
100
|
+
"""Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
|
|
101
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
102
|
+
raw = [item for item in value if isinstance(item, str)]
|
|
103
|
+
elif isinstance(value, str):
|
|
104
|
+
raw = _GROUP_SEPARATORS.split(value)
|
|
105
|
+
else:
|
|
106
|
+
return []
|
|
107
|
+
cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
|
|
108
|
+
return [item for item in cleaned if item]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def has_editor_rights(groups: Any) -> bool:
|
|
112
|
+
"""``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
|
|
113
|
+
return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
|
|
95
114
|
#: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
|
|
96
115
|
OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
|
|
97
116
|
BY_STAFF, BY_OPERATOR = "staff", "operator"
|
|
@@ -140,10 +159,8 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
|
140
159
|
"""The Cognito subject of an editor or admin, or ``None`` for anyone else."""
|
|
141
160
|
claims = identity.get("claims") or {}
|
|
142
161
|
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
143
|
-
if isinstance(groups, str):
|
|
144
|
-
groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
|
|
145
162
|
subject = identity.get("sub")
|
|
146
|
-
if not isinstance(subject, str) or not subject or not
|
|
163
|
+
if not isinstance(subject, str) or not subject or not has_editor_rights(groups):
|
|
147
164
|
return None
|
|
148
165
|
return subject
|
|
149
166
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
import { Duration } from "aws-cdk-lib";
|
|
3
|
+
import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
/** stdlib plus the runtime's boto3: the asset is this directory's two Python files and nothing else. */
|
|
8
|
+
export const proposalActions = defineFunction((scope) => new Function(scope, "papyrus-proposal-actions", {
|
|
9
|
+
runtime: Runtime.PYTHON_3_12,
|
|
10
|
+
architecture: Architecture.ARM_64,
|
|
11
|
+
handler: "handler.handler",
|
|
12
|
+
timeout: Duration.seconds(25),
|
|
13
|
+
memorySize: 256,
|
|
14
|
+
code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
|
|
15
|
+
}), { resourceGroupName: "data" });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
import { Duration } from "aws-cdk-lib";
|
|
3
|
+
import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
4
|
+
import type { Construct } from "constructs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
/** stdlib plus the runtime's boto3: the asset is this directory's two Python files and nothing else. */
|
|
11
|
+
export const proposalActions = defineFunction(
|
|
12
|
+
(scope: Construct) =>
|
|
13
|
+
new Function(scope, "papyrus-proposal-actions", {
|
|
14
|
+
runtime: Runtime.PYTHON_3_12,
|
|
15
|
+
architecture: Architecture.ARM_64,
|
|
16
|
+
handler: "handler.handler",
|
|
17
|
+
timeout: Duration.seconds(25),
|
|
18
|
+
memorySize: 256,
|
|
19
|
+
code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
|
|
20
|
+
}),
|
|
21
|
+
{ resourceGroupName: "data" },
|
|
22
|
+
);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { proposalActions } from "./resource.js";
|
|
3
|
+
import { functionHandler } from "../../data/function-handler.js";
|
|
4
|
+
/**
|
|
5
|
+
* Core to every site's data API (editors and operators only; nothing here is reader-facing). Editors may list proposals;
|
|
6
|
+
* admins may also delete. The proposal-actions function writes the table with its own role. The reader-steering plugin
|
|
7
|
+
* builds on this model: it reads the table to take reader votes on proposals and to publish them in its snapshot.
|
|
8
|
+
*/
|
|
9
|
+
const staffOnly = (allow) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
|
|
10
|
+
export const proposalActionsSchemaDefinitions = {
|
|
11
|
+
Proposal: a
|
|
12
|
+
.model({
|
|
13
|
+
id: a.id().required(),
|
|
14
|
+
kind: a.string().required(),
|
|
15
|
+
title: a.string().required(),
|
|
16
|
+
summary: a.string(),
|
|
17
|
+
body: a.string().required(),
|
|
18
|
+
targets: a.json().required(),
|
|
19
|
+
evidence: a.json(),
|
|
20
|
+
proposer: a.string().required(),
|
|
21
|
+
state: a.string().required(),
|
|
22
|
+
history: a.json().required(),
|
|
23
|
+
communityResult: a.json(),
|
|
24
|
+
pinned: a.boolean(),
|
|
25
|
+
held: a.boolean(),
|
|
26
|
+
override: a.json(),
|
|
27
|
+
exclusions: a.json(),
|
|
28
|
+
editorLog: a.json(),
|
|
29
|
+
needsLook: a.json(),
|
|
30
|
+
createdAt: a.datetime().required(),
|
|
31
|
+
})
|
|
32
|
+
.disableOperations(["subscriptions"])
|
|
33
|
+
.secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
|
|
34
|
+
.authorization(staffOnly),
|
|
35
|
+
ProposalReceipt: a.customType({
|
|
36
|
+
status: a.string().required(),
|
|
37
|
+
id: a.string(),
|
|
38
|
+
error: a.string(),
|
|
39
|
+
message: a.string(),
|
|
40
|
+
}),
|
|
41
|
+
// What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
|
|
42
|
+
StoryDraftSummary: a.customType({
|
|
43
|
+
id: a.string().required(),
|
|
44
|
+
title: a.string().required(),
|
|
45
|
+
summary: a.string(),
|
|
46
|
+
targets: a.json(),
|
|
47
|
+
state: a.string().required(),
|
|
48
|
+
createdAt: a.string().required(),
|
|
49
|
+
}),
|
|
50
|
+
StoryDraftList: a.customType({
|
|
51
|
+
status: a.string().required(),
|
|
52
|
+
id: a.string(),
|
|
53
|
+
error: a.string(),
|
|
54
|
+
message: a.string(),
|
|
55
|
+
drafts: a.ref("StoryDraftSummary").array(),
|
|
56
|
+
}),
|
|
57
|
+
// Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
|
|
58
|
+
// schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
|
|
59
|
+
// of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
|
|
60
|
+
fileProposal: a
|
|
61
|
+
.mutation()
|
|
62
|
+
.arguments({
|
|
63
|
+
kind: a.string().required(),
|
|
64
|
+
title: a.string().required(),
|
|
65
|
+
body: a.string().required(),
|
|
66
|
+
summary: a.string(),
|
|
67
|
+
targets: a.json(),
|
|
68
|
+
evidence: a.json(),
|
|
69
|
+
})
|
|
70
|
+
.returns(a.ref("ProposalReceipt"))
|
|
71
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
72
|
+
.handler(functionHandler("proposalActions", proposalActions)),
|
|
73
|
+
// The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
|
|
74
|
+
// Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
|
|
75
|
+
transitionProposal: a
|
|
76
|
+
.mutation()
|
|
77
|
+
.arguments({
|
|
78
|
+
proposalId: a.string().required(),
|
|
79
|
+
state: a.string().required(),
|
|
80
|
+
reason: a.string(),
|
|
81
|
+
result: a.string(),
|
|
82
|
+
})
|
|
83
|
+
.returns(a.ref("ProposalReceipt"))
|
|
84
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
85
|
+
.handler(functionHandler("proposalActions", proposalActions)),
|
|
86
|
+
// Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
|
|
87
|
+
// answers the story drafts' id, title, summary, targets, state and createdAt so an unattended run can dedupe; nothing else.
|
|
88
|
+
listStoryDrafts: a
|
|
89
|
+
.query()
|
|
90
|
+
.returns(a.ref("StoryDraftList"))
|
|
91
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
92
|
+
.handler(functionHandler("proposalActions", proposalActions)),
|
|
93
|
+
// Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
|
|
94
|
+
// undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
|
|
95
|
+
editProposal: a
|
|
96
|
+
.mutation()
|
|
97
|
+
.arguments({
|
|
98
|
+
proposalId: a.string().required(),
|
|
99
|
+
action: a.string().required(),
|
|
100
|
+
rule: a.string(),
|
|
101
|
+
exclusionId: a.string(),
|
|
102
|
+
})
|
|
103
|
+
.returns(a.ref("ProposalReceipt"))
|
|
104
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
105
|
+
.handler(functionHandler("proposalActions", proposalActions)),
|
|
106
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { proposalActions } from "./resource";
|
|
3
|
+
import { functionHandler } from "../../data/function-handler";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Core to every site's data API (editors and operators only; nothing here is reader-facing). Editors may list proposals;
|
|
7
|
+
* admins may also delete. The proposal-actions function writes the table with its own role. The reader-steering plugin
|
|
8
|
+
* builds on this model: it reads the table to take reader votes on proposals and to publish them in its snapshot.
|
|
9
|
+
*/
|
|
10
|
+
const staffOnly = (allow: any) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
|
|
11
|
+
|
|
12
|
+
export const proposalActionsSchemaDefinitions = {
|
|
13
|
+
Proposal: a
|
|
14
|
+
.model({
|
|
15
|
+
id: a.id().required(),
|
|
16
|
+
kind: a.string().required(),
|
|
17
|
+
title: a.string().required(),
|
|
18
|
+
summary: a.string(),
|
|
19
|
+
body: a.string().required(),
|
|
20
|
+
targets: a.json().required(),
|
|
21
|
+
evidence: a.json(),
|
|
22
|
+
proposer: a.string().required(),
|
|
23
|
+
state: a.string().required(),
|
|
24
|
+
history: a.json().required(),
|
|
25
|
+
communityResult: a.json(),
|
|
26
|
+
pinned: a.boolean(),
|
|
27
|
+
held: a.boolean(),
|
|
28
|
+
override: a.json(),
|
|
29
|
+
exclusions: a.json(),
|
|
30
|
+
editorLog: a.json(),
|
|
31
|
+
needsLook: a.json(),
|
|
32
|
+
createdAt: a.datetime().required(),
|
|
33
|
+
})
|
|
34
|
+
.disableOperations(["subscriptions"])
|
|
35
|
+
.secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
|
|
36
|
+
.authorization(staffOnly),
|
|
37
|
+
ProposalReceipt: a.customType({
|
|
38
|
+
status: a.string().required(),
|
|
39
|
+
id: a.string(),
|
|
40
|
+
error: a.string(),
|
|
41
|
+
message: a.string(),
|
|
42
|
+
}),
|
|
43
|
+
// What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
|
|
44
|
+
StoryDraftSummary: a.customType({
|
|
45
|
+
id: a.string().required(),
|
|
46
|
+
title: a.string().required(),
|
|
47
|
+
summary: a.string(),
|
|
48
|
+
targets: a.json(),
|
|
49
|
+
state: a.string().required(),
|
|
50
|
+
createdAt: a.string().required(),
|
|
51
|
+
}),
|
|
52
|
+
StoryDraftList: a.customType({
|
|
53
|
+
status: a.string().required(),
|
|
54
|
+
id: a.string(),
|
|
55
|
+
error: a.string(),
|
|
56
|
+
message: a.string(),
|
|
57
|
+
drafts: a.ref("StoryDraftSummary").array(),
|
|
58
|
+
}),
|
|
59
|
+
// Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
|
|
60
|
+
// schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
|
|
61
|
+
// of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
|
|
62
|
+
fileProposal: a
|
|
63
|
+
.mutation()
|
|
64
|
+
.arguments({
|
|
65
|
+
kind: a.string().required(),
|
|
66
|
+
title: a.string().required(),
|
|
67
|
+
body: a.string().required(),
|
|
68
|
+
summary: a.string(),
|
|
69
|
+
targets: a.json(),
|
|
70
|
+
evidence: a.json(),
|
|
71
|
+
})
|
|
72
|
+
.returns(a.ref("ProposalReceipt"))
|
|
73
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
74
|
+
.handler(functionHandler("proposalActions", proposalActions)),
|
|
75
|
+
// The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
|
|
76
|
+
// Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
|
|
77
|
+
transitionProposal: a
|
|
78
|
+
.mutation()
|
|
79
|
+
.arguments({
|
|
80
|
+
proposalId: a.string().required(),
|
|
81
|
+
state: a.string().required(),
|
|
82
|
+
reason: a.string(),
|
|
83
|
+
result: a.string(),
|
|
84
|
+
})
|
|
85
|
+
.returns(a.ref("ProposalReceipt"))
|
|
86
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
87
|
+
.handler(functionHandler("proposalActions", proposalActions)),
|
|
88
|
+
// Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
|
|
89
|
+
// answers the story drafts' id, title, summary, targets, state and createdAt so an unattended run can dedupe; nothing else.
|
|
90
|
+
listStoryDrafts: a
|
|
91
|
+
.query()
|
|
92
|
+
.returns(a.ref("StoryDraftList"))
|
|
93
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
94
|
+
.handler(functionHandler("proposalActions", proposalActions)),
|
|
95
|
+
// Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
|
|
96
|
+
// undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
|
|
97
|
+
editProposal: a
|
|
98
|
+
.mutation()
|
|
99
|
+
.arguments({
|
|
100
|
+
proposalId: a.string().required(),
|
|
101
|
+
action: a.string().required(),
|
|
102
|
+
rule: a.string(),
|
|
103
|
+
exclusionId: a.string(),
|
|
104
|
+
})
|
|
105
|
+
.returns(a.ref("ProposalReceipt"))
|
|
106
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
107
|
+
.handler(functionHandler("proposalActions", proposalActions)),
|
|
108
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Least-privilege wiring for proposal-actions: read and write on the Proposal table and nothing else, with the table's
|
|
3
|
+
* name in `PAPYRUS_PROPOSAL_TABLE`. Every site gets this, with or without the reader-steering plugin.
|
|
4
|
+
*/
|
|
5
|
+
export function wireProposalActions(backend) {
|
|
6
|
+
const lambda = backend.proposalActions.resources.lambda;
|
|
7
|
+
const table = backend.data.resources.tables.Proposal;
|
|
8
|
+
table.grantReadWriteData(lambda);
|
|
9
|
+
lambda.addEnvironment("PAPYRUS_PROPOSAL_TABLE", table.tableName);
|
|
10
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ITable } from "aws-cdk-lib/aws-dynamodb";
|
|
2
|
+
import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Least-privilege wiring for proposal-actions: read and write on the Proposal table and nothing else, with the table's
|
|
6
|
+
* name in `PAPYRUS_PROPOSAL_TABLE`. Every site gets this, with or without the reader-steering plugin.
|
|
7
|
+
*/
|
|
8
|
+
export function wireProposalActions(backend: any): void {
|
|
9
|
+
const lambda = backend.proposalActions.resources.lambda as LambdaFunction;
|
|
10
|
+
const table = (backend.data.resources.tables as Record<string, ITable>).Proposal;
|
|
11
|
+
table.grantReadWriteData(lambda);
|
|
12
|
+
lambda.addEnvironment("PAPYRUS_PROPOSAL_TABLE", table.tableName);
|
|
13
|
+
}
|