@anthusai/papyrus 2.0.0-next.9 → 2.0.0-next.90
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/auth/resource.js +16 -1
- package/amplify/auth/resource.ts +19 -1
- package/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +151 -61
- package/amplify/data/resource.ts +157 -61
- package/amplify/data/schema.js +2096 -1980
- package/amplify/data/schema.ts +198 -69
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +122 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +17 -0
- package/amplify/functions/monitoring-view/schema.ts +19 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1567 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +458 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
- package/amplify/plugins/reader-steering/schema.js +229 -0
- package/amplify/plugins/reader-steering/schema.ts +231 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +18 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +45 -3
- package/amplify/site-backend.ts +51 -3
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +195 -0
- package/components/editorial-checks-admin-panel.tsx +38 -13
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +16 -2
- package/components/newsroom-article-editor.tsx +11 -11
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-assignment-detail.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +26 -96
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +277 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +237 -158
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +94 -16
- package/infra/amplify-app-shell.ts +106 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +88 -6
- package/infra/site-config.ts +134 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +252 -0
- package/lib/category-repository.ts +14 -12
- package/lib/console-web-context.ts +1 -1
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +105 -20
- package/lib/prospectus-site-identity.ts +46 -6
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +117 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +244 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +56 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""The proposal record: what may be filed, which state changes are allowed, and who may make them.
|
|
2
|
+
|
|
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
|
+
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
|
+
and steered by votes; an editor decides. Staff reach this through the ``fileProposal`` and ``transitionProposal``
|
|
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
|
|
10
|
+
``history`` in the same conditional write that changes the state, so the history and the state cannot disagree.
|
|
11
|
+
|
|
12
|
+
Nothing here knows a person id. The proposer is ``"ai"`` or a reader's siteSubject, which is stored and never published; the
|
|
13
|
+
actor of a change is the staff member's Cognito subject, which is stored and never published either (the public file shows
|
|
14
|
+
"AI" or "Editor"). Votes are not counted here: they belong to the vote model and the snapshot joins them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Protocol
|
|
24
|
+
|
|
25
|
+
KINDS = ("story_draft", "new_concept", "rename", "merge", "split", "new_column", "element_symbol")
|
|
26
|
+
DRAFT, IN_STEERING, EDITOR_REVIEW, APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR = (
|
|
27
|
+
"draft",
|
|
28
|
+
"in_steering",
|
|
29
|
+
"editor_review",
|
|
30
|
+
"approved",
|
|
31
|
+
"declined",
|
|
32
|
+
"overridden_by_editor",
|
|
33
|
+
)
|
|
34
|
+
STATES = (DRAFT, IN_STEERING, EDITOR_REVIEW, APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR)
|
|
35
|
+
|
|
36
|
+
#: Every allowed state change. Anything not listed is refused. Approved, declined and overridden are final.
|
|
37
|
+
TRANSITIONS: dict[str, tuple[str, ...]] = {
|
|
38
|
+
DRAFT: (IN_STEERING, DECLINED),
|
|
39
|
+
IN_STEERING: (EDITOR_REVIEW,),
|
|
40
|
+
EDITOR_REVIEW: (APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR),
|
|
41
|
+
}
|
|
42
|
+
#: The changes that need a reason. Every reason is public; this one is also required, because it explains a decision against the community.
|
|
43
|
+
REASON_REQUIRED = frozenset({OVERRIDDEN_BY_EDITOR})
|
|
44
|
+
#: An override says what the editor decided against the community's view: approved or declined. Approve and decline are their own result.
|
|
45
|
+
OVERRIDE_RESULTS = (APPROVED, DECLINED)
|
|
46
|
+
|
|
47
|
+
#: How many target subjects each kind names, as (minimum, maximum), and the types a target may have.
|
|
48
|
+
TARGET_COUNTS: dict[str, tuple[int, int]] = {
|
|
49
|
+
"story_draft": (0, 10),
|
|
50
|
+
"new_concept": (0, 10),
|
|
51
|
+
"rename": (1, 1),
|
|
52
|
+
"merge": (2, 10),
|
|
53
|
+
"split": (1, 1),
|
|
54
|
+
"new_column": (0, 10),
|
|
55
|
+
"element_symbol": (1, 1),
|
|
56
|
+
}
|
|
57
|
+
TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
|
|
58
|
+
|
|
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)
|
|
88
|
+
PROPOSER_AI = "ai"
|
|
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
|
|
94
|
+
PROPOSAL_ID = re.compile(r"prop-[0-9a-f]{32}")
|
|
95
|
+
TARGET_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:\-]{0,127}")
|
|
96
|
+
MAXIMUM_TITLE = 200
|
|
97
|
+
MAXIMUM_SUMMARY = 300
|
|
98
|
+
MAXIMUM_BODY = 50_000
|
|
99
|
+
MAXIMUM_REASON = 500
|
|
100
|
+
MAXIMUM_EVIDENCE_SOURCE = 200
|
|
101
|
+
MAXIMUM_EVIDENCE_SIGNALS = 1_000_000
|
|
102
|
+
#: An optional figure spec kept in ``evidence.figure``: a small chart of cited series (no image), approved with the copy.
|
|
103
|
+
FIGURE_TYPES = ("line", "bar")
|
|
104
|
+
MAXIMUM_FIGURE_POINTS = 60
|
|
105
|
+
MAXIMUM_FIGURE_SERIES = 6
|
|
106
|
+
MAXIMUM_FIGURE_BYTES = 8_000
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Outcome:
|
|
111
|
+
status: str # "ok" or "refused"
|
|
112
|
+
id: str | None = None
|
|
113
|
+
error: str | None = None
|
|
114
|
+
message: str | None = None
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> dict[str, Any]:
|
|
117
|
+
return {"status": self.status, "id": self.id, "error": self.error, "message": self.message}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def refused(error: str, message: str) -> Outcome:
|
|
121
|
+
return Outcome("refused", error=error, message=message)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ProposalStore(Protocol):
|
|
125
|
+
def get_proposal(self, proposal_id: str) -> dict[str, Any] | None: ...
|
|
126
|
+
|
|
127
|
+
def create_proposal(self, row: dict[str, Any]) -> bool: ...
|
|
128
|
+
|
|
129
|
+
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool: ...
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
133
|
+
"""The Cognito subject of an editor or admin, or ``None`` for anyone else."""
|
|
134
|
+
claims = identity.get("claims") or {}
|
|
135
|
+
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
136
|
+
if isinstance(groups, str):
|
|
137
|
+
groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
|
|
138
|
+
subject = identity.get("sub")
|
|
139
|
+
if not isinstance(subject, str) or not subject or not STAFF_GROUPS.intersection(groups):
|
|
140
|
+
return None
|
|
141
|
+
return subject
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def operator_actor(identity: dict[str, Any], account_id: str | None) -> str | None:
|
|
145
|
+
"""``operator:<principal ARN>`` for an IAM principal (a role or user) of the function's own account, or ``None``.
|
|
146
|
+
|
|
147
|
+
A Cognito identity-pool user is also an IAM principal of this account, but it carries a Cognito identity id (and a
|
|
148
|
+
user-pool caller carries a subject); neither is an operator. A principal of another account is refused, and so is a
|
|
149
|
+
caller whose ARN names another account than the one AppSync reports.
|
|
150
|
+
"""
|
|
151
|
+
if not isinstance(identity, dict) or not account_id:
|
|
152
|
+
return None
|
|
153
|
+
arn = identity.get("userArn")
|
|
154
|
+
parts = arn.split(":") if isinstance(arn, str) else []
|
|
155
|
+
if len(parts) < 6 or parts[0] != "arn" or parts[4] != account_id or identity.get("accountId") != account_id:
|
|
156
|
+
return None
|
|
157
|
+
if any(identity.get(key) for key in ("cognitoIdentityId", "cognitoIdentityPoolId", "cognitoIdentityAuthType", "sub", "claims")):
|
|
158
|
+
return None
|
|
159
|
+
return f"operator:{arn}"[:MAXIMUM_OPERATOR_LABEL]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def clean_text(value: Any, limit: int) -> str | None:
|
|
163
|
+
if not isinstance(value, str):
|
|
164
|
+
return None
|
|
165
|
+
text = value.strip()
|
|
166
|
+
return text if text and len(text) <= limit else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_json(value: Any) -> Any:
|
|
170
|
+
"""AppSync delivers AWSJSON arguments as strings; the CLI and tests may pass the parsed value."""
|
|
171
|
+
if isinstance(value, str):
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(value)
|
|
174
|
+
except ValueError:
|
|
175
|
+
return None
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def clean_targets(kind: str, raw: Any) -> list[dict[str, str]] | Outcome:
|
|
180
|
+
parsed = parse_json(raw) if raw is not None else []
|
|
181
|
+
if not isinstance(parsed, list):
|
|
182
|
+
return refused("invalid_targets", "targets must be a list of {type, id}.")
|
|
183
|
+
minimum, maximum = TARGET_COUNTS[kind]
|
|
184
|
+
targets: list[dict[str, str]] = []
|
|
185
|
+
for entry in parsed:
|
|
186
|
+
if not isinstance(entry, dict) or entry.get("type") not in TARGET_TYPES or not isinstance(entry.get("id"), str) or not TARGET_ID.fullmatch(entry["id"]):
|
|
187
|
+
return refused("invalid_targets", f"Each target is {{type, id}} with a type of {', '.join(TARGET_TYPES)}.")
|
|
188
|
+
target = {"type": entry["type"], "id": entry["id"]}
|
|
189
|
+
if target in targets:
|
|
190
|
+
return refused("invalid_targets", "A target is named twice.")
|
|
191
|
+
targets.append(target)
|
|
192
|
+
if not minimum <= len(targets) <= maximum:
|
|
193
|
+
wanted = f"{minimum}" if minimum == maximum else f"{minimum} to {maximum}"
|
|
194
|
+
return refused("invalid_targets", f"A {kind} proposal names {wanted} target(s).")
|
|
195
|
+
return targets
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def clean_figure(raw: Any) -> dict[str, Any] | None:
|
|
199
|
+
"""A chart spec ``{type, title, caption, unit, source, x, series: [{signal, label, values}]}``, or None when it is malformed.
|
|
200
|
+
|
|
201
|
+
Unknown keys are dropped. Every series has exactly one value per ``x`` label; values are whole or decimal numbers, never text."""
|
|
202
|
+
if not isinstance(raw, dict) or raw.get("type") not in FIGURE_TYPES:
|
|
203
|
+
return None
|
|
204
|
+
title, caption, unit, source = (clean_text(raw.get(key), limit) for key, limit in (("title", 200), ("caption", 300), ("unit", 60), ("source", MAXIMUM_EVIDENCE_SOURCE)))
|
|
205
|
+
x, series = raw.get("x"), raw.get("series")
|
|
206
|
+
if None in (title, caption, unit, source) or not isinstance(x, list) or not isinstance(series, list):
|
|
207
|
+
return None
|
|
208
|
+
if not 2 <= len(x) <= MAXIMUM_FIGURE_POINTS or not 1 <= len(series) <= MAXIMUM_FIGURE_SERIES:
|
|
209
|
+
return None
|
|
210
|
+
labels = [clean_text(label, 20) for label in x]
|
|
211
|
+
if None in labels:
|
|
212
|
+
return None
|
|
213
|
+
cleaned = []
|
|
214
|
+
for entry in series:
|
|
215
|
+
if not isinstance(entry, dict):
|
|
216
|
+
return None
|
|
217
|
+
signal, label, values = clean_text(entry.get("signal"), 130), clean_text(entry.get("label"), 120), entry.get("values")
|
|
218
|
+
if signal is None or label is None or not isinstance(values, list) or len(values) != len(labels):
|
|
219
|
+
return None
|
|
220
|
+
if any(isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 1_000_000_000 for value in values):
|
|
221
|
+
return None
|
|
222
|
+
cleaned.append({"signal": signal, "label": label, "values": list(values)})
|
|
223
|
+
figure = {"type": raw["type"], "title": title, "caption": caption, "unit": unit, "source": source, "x": labels, "series": cleaned}
|
|
224
|
+
return figure if len(json.dumps(figure)) <= MAXIMUM_FIGURE_BYTES else None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def clean_evidence(raw: Any) -> dict[str, Any] | None | Outcome:
|
|
228
|
+
parsed = parse_json(raw) if raw is not None else None
|
|
229
|
+
if parsed is None:
|
|
230
|
+
return None
|
|
231
|
+
if not isinstance(parsed, dict):
|
|
232
|
+
return refused("invalid_evidence", "evidence must be {signals, source} with an optional figure.")
|
|
233
|
+
signals, source = parsed.get("signals"), parsed.get("source")
|
|
234
|
+
if not isinstance(signals, int) or isinstance(signals, bool) or not 0 <= signals <= MAXIMUM_EVIDENCE_SIGNALS:
|
|
235
|
+
return refused("invalid_evidence", "evidence.signals must be a whole number of signals.")
|
|
236
|
+
if source is not None and clean_text(source, MAXIMUM_EVIDENCE_SOURCE) is None:
|
|
237
|
+
return refused("invalid_evidence", "evidence.source must be short text.")
|
|
238
|
+
evidence = {"signals": signals, "source": source.strip() if isinstance(source, str) else None}
|
|
239
|
+
if parsed.get("figure") is not None:
|
|
240
|
+
figure = clean_figure(parsed["figure"])
|
|
241
|
+
if figure is None:
|
|
242
|
+
return refused("invalid_evidence", "evidence.figure must be a small chart spec: type line or bar, title, caption, unit, source, x labels and series with one value per label.")
|
|
243
|
+
evidence["figure"] = figure
|
|
244
|
+
return evidence
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def file_proposal(arguments: dict[str, Any], *, actor_sub: str, now_iso: str, proposal_id: str | None = None) -> tuple[dict[str, Any] | None, Outcome | None]:
|
|
248
|
+
"""The new row for a proposal in ``draft``, or the refusal. The proposer is AI: reader filing has no route yet."""
|
|
249
|
+
kind = arguments.get("kind")
|
|
250
|
+
if kind not in KINDS:
|
|
251
|
+
return None, refused("invalid_kind", f"kind must be one of {', '.join(KINDS)}.")
|
|
252
|
+
title = clean_text(arguments.get("title"), MAXIMUM_TITLE)
|
|
253
|
+
if title is None:
|
|
254
|
+
return None, refused("invalid_title", f"A proposal needs a title of at most {MAXIMUM_TITLE} characters.")
|
|
255
|
+
body = arguments.get("body") # Markus source, kept byte for byte
|
|
256
|
+
if not isinstance(body, str) or not body.strip() or len(body) > MAXIMUM_BODY:
|
|
257
|
+
return None, refused("invalid_body", "A proposal needs a body (Markus), at most 50,000 characters.")
|
|
258
|
+
summary = arguments.get("summary")
|
|
259
|
+
if summary is not None and clean_text(summary, MAXIMUM_SUMMARY) is None:
|
|
260
|
+
return None, refused("invalid_summary", f"summary is one line of at most {MAXIMUM_SUMMARY} characters.")
|
|
261
|
+
targets = clean_targets(kind, arguments.get("targets"))
|
|
262
|
+
if isinstance(targets, Outcome):
|
|
263
|
+
return None, targets
|
|
264
|
+
evidence = clean_evidence(arguments.get("evidence"))
|
|
265
|
+
if isinstance(evidence, Outcome):
|
|
266
|
+
return None, evidence
|
|
267
|
+
identifier = proposal_id or "prop-" + uuid.uuid4().hex
|
|
268
|
+
row: dict[str, Any] = {
|
|
269
|
+
"id": identifier,
|
|
270
|
+
"kind": kind,
|
|
271
|
+
"title": title,
|
|
272
|
+
"body": body,
|
|
273
|
+
"targets": json.dumps(targets),
|
|
274
|
+
"proposer": PROPOSER_AI,
|
|
275
|
+
"state": DRAFT,
|
|
276
|
+
"history": json.dumps([{"from": None, "to": DRAFT, "by": "ai", "actor": actor_sub, "reason": None, "at": now_iso}]),
|
|
277
|
+
"createdAt": now_iso,
|
|
278
|
+
}
|
|
279
|
+
if summary is not None:
|
|
280
|
+
row["summary"] = summary.strip()
|
|
281
|
+
if evidence is not None:
|
|
282
|
+
row["evidence"] = json.dumps(evidence)
|
|
283
|
+
return row, None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
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:
|
|
287
|
+
"""The changes that move ``row`` to ``to_state``, or why it may not move."""
|
|
288
|
+
state = row.get("state")
|
|
289
|
+
if to_state not in STATES:
|
|
290
|
+
return refused("invalid_state", f"state must be one of {', '.join(STATES)}.")
|
|
291
|
+
if to_state not in TRANSITIONS.get(state, ()):
|
|
292
|
+
return refused("transition_not_allowed", f"A proposal in {state} cannot move to {to_state}.")
|
|
293
|
+
if row.get("held"):
|
|
294
|
+
return refused("proposal_held", "That proposal is on hold. Release it before it moves.")
|
|
295
|
+
text = None
|
|
296
|
+
if reason is not None and str(reason).strip():
|
|
297
|
+
text = clean_text(reason, MAXIMUM_REASON)
|
|
298
|
+
if text is None:
|
|
299
|
+
return refused("invalid_reason", f"A reason is plain text of at most {MAXIMUM_REASON} characters.")
|
|
300
|
+
if to_state in REASON_REQUIRED and text is None:
|
|
301
|
+
return refused("reason_required", "Overriding the community needs a public reason.")
|
|
302
|
+
if to_state == OVERRIDDEN_BY_EDITOR:
|
|
303
|
+
if result not in OVERRIDE_RESULTS:
|
|
304
|
+
return refused("result_required", f"An override says what the editor decided: {' or '.join(OVERRIDE_RESULTS)}.")
|
|
305
|
+
elif result is not None:
|
|
306
|
+
return refused("result_not_allowed", "Only an override carries a result; approve and decline are their own.")
|
|
307
|
+
entry = {"from": state, "to": to_state, "by": by, "actor": actor_sub, "reason": text, "at": now_iso}
|
|
308
|
+
if result is not None:
|
|
309
|
+
entry["result"] = result
|
|
310
|
+
history = [*(parse_json(row.get("history")) or []), entry]
|
|
311
|
+
changes: dict[str, Any] = {"state": to_state, "history": json.dumps(history)}
|
|
312
|
+
if to_state == OVERRIDDEN_BY_EDITOR:
|
|
313
|
+
# Stored on the proposal beside the community result, which stays as it was: readers see both.
|
|
314
|
+
changes["override"] = json.dumps({"result": result, "reason": text, "at": now_iso})
|
|
315
|
+
return changes
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def apply_transition(
|
|
319
|
+
store: ProposalStore,
|
|
320
|
+
*,
|
|
321
|
+
proposal_id: Any,
|
|
322
|
+
to_state: Any,
|
|
323
|
+
reason: Any,
|
|
324
|
+
actor_sub: str,
|
|
325
|
+
now_iso: str,
|
|
326
|
+
result: Any = None,
|
|
327
|
+
by: str = BY_STAFF,
|
|
328
|
+
allowed: frozenset[tuple[str, str]] | None = None,
|
|
329
|
+
) -> Outcome:
|
|
330
|
+
"""Move a proposal. ``allowed`` (as (from, to) pairs) narrows the road for a caller who may not take all of it."""
|
|
331
|
+
if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
|
|
332
|
+
return refused("invalid_proposal", "That is not a proposal id.")
|
|
333
|
+
row = store.get_proposal(proposal_id)
|
|
334
|
+
if row is None:
|
|
335
|
+
return refused("not_found", "There is no such proposal.")
|
|
336
|
+
if allowed is not None and (row.get("state"), to_state) not in allowed:
|
|
337
|
+
return refused("operator_not_allowed", "An operator can only move a draft to in_steering or declined; the rest is for editors and admins.")
|
|
338
|
+
plan = transition_plan(row, to_state, reason, actor_sub=actor_sub, now_iso=now_iso, result=result, by=by)
|
|
339
|
+
if isinstance(plan, Outcome):
|
|
340
|
+
return plan
|
|
341
|
+
if not store.change_proposal(proposal_id, plan, row["state"], now_iso):
|
|
342
|
+
return refused("target_changed", "Someone else changed that proposal first.")
|
|
343
|
+
return Outcome("ok", id=proposal_id)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def log_entry(action: str, actor_sub: str, now_iso: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
347
|
+
entry: dict[str, Any] = {"action": action, "by": BY_STAFF, "actor": actor_sub, "at": now_iso}
|
|
348
|
+
if detail:
|
|
349
|
+
entry["detail"] = detail
|
|
350
|
+
return entry
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
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:
|
|
354
|
+
"""The changes for a pin, hold or vote-exclusion action on ``row``, or why it may not be done.
|
|
355
|
+
|
|
356
|
+
Every action is appended to the private ``editorLog`` in the same write. Changing the exclusions clears the frozen
|
|
357
|
+
``communityResult`` so the next snapshot fixes it again with the exclusions applied; the function never counts votes itself.
|
|
358
|
+
"""
|
|
359
|
+
if action not in EDIT_ACTIONS:
|
|
360
|
+
return refused("invalid_action", f"action must be one of {', '.join(EDIT_ACTIONS)}.")
|
|
361
|
+
detail: dict[str, Any] | None = None
|
|
362
|
+
changes: dict[str, Any] = {}
|
|
363
|
+
if action in (PIN, UNPIN):
|
|
364
|
+
if bool(row.get("pinned")) == (action == PIN):
|
|
365
|
+
return refused("already_pinned" if action == PIN else "not_pinned", "That proposal is already pinned." if action == PIN else "That proposal is not pinned.")
|
|
366
|
+
changes["pinned"] = action == PIN
|
|
367
|
+
elif action in (HOLD, RELEASE):
|
|
368
|
+
if action == HOLD and row.get("state") in FINAL_STATES:
|
|
369
|
+
return refused("proposal_decided", "A decided proposal cannot be put on hold.")
|
|
370
|
+
if bool(row.get("held")) == (action == HOLD):
|
|
371
|
+
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.")
|
|
372
|
+
changes["held"] = action == HOLD
|
|
373
|
+
else:
|
|
374
|
+
exclusions = parse_json(row.get("exclusions")) or []
|
|
375
|
+
if action == EXCLUDE_VOTES:
|
|
376
|
+
if rule not in EXCLUSION_RULES:
|
|
377
|
+
return refused("invalid_rule", f"rule must be one of {', '.join(EXCLUSION_RULES)}.")
|
|
378
|
+
identifier = "exc-" + uuid.uuid4().hex[:12]
|
|
379
|
+
exclusions = [*exclusions, {"id": identifier, "rule": rule, "at": now_iso, "actor": actor_sub}]
|
|
380
|
+
detail = {"exclusion": identifier, "rule": rule}
|
|
381
|
+
else:
|
|
382
|
+
if not isinstance(exclusion_id, str) or not EXCLUSION_ID.fullmatch(exclusion_id):
|
|
383
|
+
return refused("invalid_exclusion", "That is not an exclusion id.")
|
|
384
|
+
found = next((entry for entry in exclusions if entry.get("id") == exclusion_id), None)
|
|
385
|
+
if found is None:
|
|
386
|
+
return refused("unknown_exclusion", "That proposal has no such exclusion.")
|
|
387
|
+
if found.get("undoneAt"):
|
|
388
|
+
return refused("already_undone", "That exclusion was already undone.")
|
|
389
|
+
found["undoneAt"], found["undoneBy"] = now_iso, actor_sub
|
|
390
|
+
detail = {"exclusion": exclusion_id, "rule": found.get("rule")}
|
|
391
|
+
changes["exclusions"] = json.dumps(exclusions)
|
|
392
|
+
if row.get("communityResult"):
|
|
393
|
+
changes["communityResult"] = None # removed: the next snapshot fixes it again without the excluded votes
|
|
394
|
+
log = [*(parse_json(row.get("editorLog")) or []), log_entry(action, actor_sub, now_iso, detail)][-MAXIMUM_EDITOR_LOG:]
|
|
395
|
+
changes["editorLog"] = json.dumps(log)
|
|
396
|
+
return changes
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def apply_edit(store: ProposalStore, *, proposal_id: Any, action: Any, rule: Any, exclusion_id: Any, actor_sub: str, now_iso: str) -> Outcome:
|
|
400
|
+
if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
|
|
401
|
+
return refused("invalid_proposal", "That is not a proposal id.")
|
|
402
|
+
row = store.get_proposal(proposal_id)
|
|
403
|
+
if row is None:
|
|
404
|
+
return refused("not_found", "There is no such proposal.")
|
|
405
|
+
plan = edit_plan(row, action, rule=rule, exclusion_id=exclusion_id, actor_sub=actor_sub, now_iso=now_iso)
|
|
406
|
+
if isinstance(plan, Outcome):
|
|
407
|
+
return plan
|
|
408
|
+
if not store.change_proposal(proposal_id, plan, row["state"], now_iso, expected_updated_at=row.get("updatedAt")):
|
|
409
|
+
return refused("target_changed", "Someone else changed that proposal first.")
|
|
410
|
+
# An exclusion's id is what undo_exclusion takes; the pin and hold receipts carry the proposal id.
|
|
411
|
+
created = json.loads(plan["exclusions"])[-1]["id"] if action == EXCLUDE_VOTES else proposal_id
|
|
412
|
+
return Outcome("ok", id=created)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str, account_id: str | None = None) -> dict[str, Any]:
|
|
416
|
+
"""``fileProposal``, ``transitionProposal`` or ``editProposal`` from AppSync. Answers a receipt, never an exception.
|
|
417
|
+
|
|
418
|
+
Editors and admins act as themselves. An IAM principal of ``account_id`` (the function's own account) acts as an operator:
|
|
419
|
+
it files AI proposals and moves draft to in_steering or declined, recorded as ``operator:<ARN>``. Anyone else is refused.
|
|
420
|
+
"""
|
|
421
|
+
identity = event.get("identity") or {}
|
|
422
|
+
staff = staff_subject(identity)
|
|
423
|
+
operator = None if staff is not None else operator_actor(identity, account_id)
|
|
424
|
+
actor = staff or operator
|
|
425
|
+
if actor is None:
|
|
426
|
+
return refused("not_staff", "Only editors, admins and operators of this account can file proposals; only editors and admins decide them.").to_dict()
|
|
427
|
+
arguments = event.get("arguments") or {}
|
|
428
|
+
field = event_field_name(event)
|
|
429
|
+
if field == "editProposal":
|
|
430
|
+
if staff is None:
|
|
431
|
+
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()
|
|
432
|
+
return apply_edit(
|
|
433
|
+
store,
|
|
434
|
+
proposal_id=arguments.get("proposalId"),
|
|
435
|
+
action=arguments.get("action"),
|
|
436
|
+
rule=arguments.get("rule"),
|
|
437
|
+
exclusion_id=arguments.get("exclusionId"),
|
|
438
|
+
actor_sub=staff,
|
|
439
|
+
now_iso=now_iso,
|
|
440
|
+
).to_dict()
|
|
441
|
+
if field == "fileProposal":
|
|
442
|
+
row, problem = file_proposal(arguments, actor_sub=actor, now_iso=now_iso)
|
|
443
|
+
if problem is not None:
|
|
444
|
+
return problem.to_dict()
|
|
445
|
+
if not store.create_proposal(row):
|
|
446
|
+
return refused("exists", "That proposal already exists.").to_dict()
|
|
447
|
+
return Outcome("ok", id=row["id"]).to_dict()
|
|
448
|
+
return apply_transition(
|
|
449
|
+
store,
|
|
450
|
+
proposal_id=arguments.get("proposalId"),
|
|
451
|
+
to_state=arguments.get("state"),
|
|
452
|
+
reason=arguments.get("reason"),
|
|
453
|
+
result=arguments.get("result"),
|
|
454
|
+
actor_sub=actor,
|
|
455
|
+
now_iso=now_iso,
|
|
456
|
+
by=BY_OPERATOR if operator else BY_STAFF,
|
|
457
|
+
allowed=OPERATOR_TRANSITIONS if operator else None,
|
|
458
|
+
).to_dict()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
3
|
+
import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
|
|
4
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
export const readerActions = defineFunction((scope) => {
|
|
10
|
+
const rateTable = new Table(scope, "PapyrusReaderRateWindows", {
|
|
11
|
+
partitionKey: { name: "pk", type: AttributeType.STRING },
|
|
12
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
13
|
+
timeToLiveAttribute: "expiresAt",
|
|
14
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
15
|
+
});
|
|
16
|
+
const lambda = new Function(scope, "papyrus-reader-actions", {
|
|
17
|
+
runtime: Runtime.PYTHON_3_12,
|
|
18
|
+
architecture: Architecture.ARM_64,
|
|
19
|
+
handler: "handler.handler",
|
|
20
|
+
timeout: Duration.seconds(60),
|
|
21
|
+
memorySize: 256,
|
|
22
|
+
code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
|
|
23
|
+
environment: { PAPYRUS_READER_RATE_TABLE: rateTable.tableName },
|
|
24
|
+
});
|
|
25
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem", "dynamodb:PutItem"], resources: [rateTable.tableArn] }));
|
|
26
|
+
return lambda;
|
|
27
|
+
}, { resourceGroupName: "data" });
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
3
|
+
import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
|
|
4
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import type { Construct } from "constructs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
export const readerActions = defineFunction(
|
|
13
|
+
(scope: Construct) => {
|
|
14
|
+
const rateTable = new Table(scope, "PapyrusReaderRateWindows", {
|
|
15
|
+
partitionKey: { name: "pk", type: AttributeType.STRING },
|
|
16
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
17
|
+
timeToLiveAttribute: "expiresAt",
|
|
18
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
19
|
+
});
|
|
20
|
+
const lambda = new Function(scope, "papyrus-reader-actions", {
|
|
21
|
+
runtime: Runtime.PYTHON_3_12,
|
|
22
|
+
architecture: Architecture.ARM_64,
|
|
23
|
+
handler: "handler.handler",
|
|
24
|
+
timeout: Duration.seconds(60),
|
|
25
|
+
memorySize: 256,
|
|
26
|
+
code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
|
|
27
|
+
environment: { PAPYRUS_READER_RATE_TABLE: rateTable.tableName },
|
|
28
|
+
});
|
|
29
|
+
lambda.addToRolePolicy(
|
|
30
|
+
new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem", "dynamodb:PutItem"], resources: [rateTable.tableArn] }),
|
|
31
|
+
);
|
|
32
|
+
return lambda;
|
|
33
|
+
},
|
|
34
|
+
{ resourceGroupName: "data" },
|
|
35
|
+
);
|