@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110

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.
Files changed (227) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/data/function-handler.js +20 -0
  4. package/amplify/data/function-handler.ts +40 -0
  5. package/amplify/data/resource.js +152 -62
  6. package/amplify/data/resource.ts +158 -62
  7. package/amplify/data/schema.js +2101 -1987
  8. package/amplify/data/schema.ts +197 -70
  9. package/amplify/functions/assignment-action/data-access.js +2 -0
  10. package/amplify/functions/assignment-action/data-access.ts +2 -0
  11. package/amplify/functions/assignment-action/handler.ts +1 -1
  12. package/amplify/functions/automation-view/handler.py +17 -0
  13. package/amplify/functions/automation-view/resource.js +24 -0
  14. package/amplify/functions/automation-view/resource.ts +34 -0
  15. package/amplify/functions/automation-view/schema.js +26 -0
  16. package/amplify/functions/automation-view/schema.ts +28 -0
  17. package/amplify/functions/automation-view/wire.js +56 -0
  18. package/amplify/functions/automation-view/wire.ts +76 -0
  19. package/amplify/functions/content-actions/handler.py +64 -3
  20. package/amplify/functions/content-actions/requirements.txt +1 -0
  21. package/amplify/functions/content-actions/resource.js +1 -1
  22. package/amplify/functions/content-actions/resource.ts +1 -1
  23. package/amplify/functions/editorial-checks/handler.py +2 -2
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -1
  25. package/amplify/functions/editorial-checks/resource.js +3 -3
  26. package/amplify/functions/editorial-checks/resource.ts +3 -3
  27. package/amplify/functions/editorial-checks/schema.js +38 -8
  28. package/amplify/functions/editorial-checks/schema.ts +38 -8
  29. package/amplify/functions/editorial-checks/wire.js +4 -0
  30. package/amplify/functions/editorial-checks/wire.ts +4 -0
  31. package/amplify/functions/monitoring-view/handler.py +11 -0
  32. package/amplify/functions/monitoring-view/resource.js +24 -0
  33. package/amplify/functions/monitoring-view/resource.ts +34 -0
  34. package/amplify/functions/monitoring-view/schema.js +29 -0
  35. package/amplify/functions/monitoring-view/schema.ts +31 -0
  36. package/amplify/functions/monitoring-view/wire.js +64 -0
  37. package/amplify/functions/monitoring-view/wire.ts +81 -0
  38. package/amplify/functions/public-forms/core.js +247 -0
  39. package/amplify/functions/public-forms/core.ts +310 -0
  40. package/amplify/functions/public-forms/handler.ts +41 -0
  41. package/amplify/functions/public-forms/origins.js +35 -0
  42. package/amplify/functions/public-forms/origins.ts +43 -0
  43. package/amplify/functions/public-forms/resource.js +9 -0
  44. package/amplify/functions/public-forms/resource.ts +10 -0
  45. package/amplify/functions/public-forms/wire.js +25 -0
  46. package/amplify/functions/public-forms/wire.ts +32 -0
  47. package/amplify/functions/shared/editorial-context.ts +63 -4
  48. package/amplify/functions/shared/python-bundle.js +20 -14
  49. package/amplify/functions/shared/python-bundle.ts +25 -18
  50. package/amplify/plugins/newsletter/config.js +245 -0
  51. package/amplify/plugins/newsletter/config.ts +305 -0
  52. package/amplify/plugins/newsletter/function/handler.py +6 -0
  53. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  54. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  55. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  56. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  57. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  58. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  59. package/amplify/plugins/newsletter/schema.js +57 -0
  60. package/amplify/plugins/newsletter/schema.ts +62 -0
  61. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  62. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  63. package/amplify/plugins/newsletter/sending.js +140 -0
  64. package/amplify/plugins/newsletter/sending.ts +186 -0
  65. package/amplify/plugins/reader-steering/config.js +182 -0
  66. package/amplify/plugins/reader-steering/config.ts +290 -0
  67. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  68. package/amplify/plugins/reader-steering/function/handler.py +1589 -0
  69. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  70. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  71. package/amplify/plugins/reader-steering/function/proposals.py +487 -0
  72. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  73. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  74. package/amplify/plugins/reader-steering/papyrus.plugin.js +187 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
  76. package/amplify/plugins/reader-steering/schema.js +252 -0
  77. package/amplify/plugins/reader-steering/schema.ts +254 -0
  78. package/amplify/plugins/registry.js +4 -0
  79. package/amplify/plugins/registry.ts +4 -0
  80. package/amplify/plugins/signup/schema.js +2 -1
  81. package/amplify/plugins/signup/schema.ts +2 -1
  82. package/amplify/plugins/types.ts +5 -0
  83. package/amplify/plugins/wire.js +1 -0
  84. package/amplify/plugins/wire.ts +1 -0
  85. package/amplify/publication/projection-lease.js +30 -0
  86. package/amplify/publication/projection-lease.ts +39 -0
  87. package/amplify/publication/redirect-and-index.js +105 -0
  88. package/amplify/publication/redirect-rules.js +73 -0
  89. package/amplify/publication/redirect-rules.ts +93 -0
  90. package/amplify/publication/s3-projection.js +7 -1
  91. package/amplify/publication/s3-projection.ts +9 -1
  92. package/amplify/site-backend-config.js +19 -0
  93. package/amplify/site-backend-config.ts +45 -1
  94. package/amplify/site-backend-names.js +4 -2
  95. package/amplify/site-backend-names.ts +4 -2
  96. package/amplify/site-backend.js +42 -4
  97. package/amplify/site-backend.ts +45 -4
  98. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  99. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  100. package/app/api/newsletter/confirm/route.ts +13 -0
  101. package/app/api/newsletter/preferences/route.ts +13 -0
  102. package/app/api/newsletter/state/route.ts +9 -0
  103. package/app/api/newsletter/subscribe/route.ts +9 -0
  104. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  105. package/app/api/newsroom/chat/[...path]/route.ts +17 -0
  106. package/app/api/newsroom/curation/context/route.ts +9 -0
  107. package/app/api/newsroom/curation/priming/route.ts +9 -0
  108. package/app/api/newsroom/curation/publication/route.ts +9 -0
  109. package/app/api/reader/comment/delete/route.ts +9 -0
  110. package/app/api/reader/comment/edit/route.ts +9 -0
  111. package/app/api/reader/comment/route.ts +9 -0
  112. package/app/api/reader/flag/route.ts +9 -0
  113. package/app/api/reader/handle/route.ts +9 -0
  114. package/app/api/reader/me/route.ts +9 -0
  115. package/app/api/reader/rating/route.ts +9 -0
  116. package/app/api/reader/report/route.ts +9 -0
  117. package/app/api/reader/retired/route.ts +9 -0
  118. package/app/api/reader/submit/route.ts +9 -0
  119. package/app/api/reader/vote/route.ts +9 -0
  120. package/app/layout.tsx +5 -1
  121. package/bin/papyrus-infra.mjs +20 -1
  122. package/components/automation-panel.tsx +49 -0
  123. package/components/automation-run-now.tsx +97 -0
  124. package/components/automation-view.tsx +195 -0
  125. package/components/editorial-checks-admin-panel.tsx +32 -17
  126. package/components/moderation-panel.tsx +248 -0
  127. package/components/monitoring-overview-view.tsx +422 -0
  128. package/components/monitoring-panel.tsx +60 -0
  129. package/components/news-desk-client-provider.tsx +18 -18
  130. package/components/news-desk-taxonomy-client.ts +28 -8
  131. package/components/newsletter-panel.tsx +474 -0
  132. package/components/newsletter-sections.tsx +163 -0
  133. package/components/newsroom-access-gate.tsx +16 -2
  134. package/components/newsroom-article-editor.tsx +9 -7
  135. package/components/newsroom-articles-view.tsx +6 -6
  136. package/components/newsroom-assignment-detail.tsx +6 -6
  137. package/components/newsroom-board-view.tsx +8 -3
  138. package/components/newsroom-curation-bootstrap.tsx +45 -0
  139. package/components/newsroom-ops-overview.tsx +26 -96
  140. package/components/newsroom-ops-shell.tsx +2 -2
  141. package/components/newsroom-organizer.css +33 -0
  142. package/components/newsroom-overview-skeleton.tsx +25 -0
  143. package/components/newsroom-references-view.tsx +192 -49
  144. package/components/notification-center.tsx +281 -0
  145. package/components/papyrus-console-conversation.css +14 -0
  146. package/components/papyrus-console-conversation.tsx +55 -0
  147. package/components/papyrus-console-shell.tsx +62 -420
  148. package/components/papyrus-private-chat-panel.tsx +134 -0
  149. package/components/publication-cyclotron-panel.tsx +15 -10
  150. package/components/reader-auth-state.ts +1 -1
  151. package/components/reference-add-entry.tsx +75 -0
  152. package/components/reference-curation-controls.tsx +31 -22
  153. package/components/reference-cyclotron-priming.tsx +58 -19
  154. package/components/reference-cyclotron-suggestion.tsx +10 -6
  155. package/components/reference-priming.tsx +14 -10
  156. package/components/reference-relevance-review.tsx +3 -3
  157. package/components/reference-section-suggestion.tsx +1 -1
  158. package/components/topic-steering-workspace.tsx +230 -144
  159. package/infra/amplify-app-shell.js +96 -16
  160. package/infra/amplify-app-shell.ts +108 -18
  161. package/infra/cloud-email-config.js +32 -0
  162. package/infra/cloud-email-config.ts +39 -0
  163. package/infra/cloud-email.js +57 -0
  164. package/infra/cloud-email.test.js +69 -0
  165. package/infra/cloud-email.test.ts +66 -0
  166. package/infra/cloud-email.ts +60 -0
  167. package/infra/index.js +1 -0
  168. package/infra/secure-string-parameter-handler.js +41 -0
  169. package/infra/secure-string-parameter-handler.ts +41 -0
  170. package/infra/secure-string-parameter.js +79 -0
  171. package/infra/secure-string-parameter.ts +91 -0
  172. package/infra/site-config.js +92 -6
  173. package/infra/site-config.ts +138 -6
  174. package/lib/automation-client.ts +54 -0
  175. package/lib/automation-fixture.ts +93 -0
  176. package/lib/automation-overview.ts +252 -0
  177. package/lib/category-repository.ts +14 -12
  178. package/lib/cli-login-discovery.ts +78 -0
  179. package/lib/console-web-context.ts +1 -1
  180. package/lib/curation-routes.ts +345 -0
  181. package/lib/cyclotron-suggestions.ts +4 -4
  182. package/lib/editorial-checks-client.ts +18 -0
  183. package/lib/editorial-checks-demo.ts +1 -1
  184. package/lib/editorial-checks.ts +8 -4
  185. package/lib/editorial-preview.ts +5 -5
  186. package/lib/monitoring-client.ts +83 -0
  187. package/lib/monitoring-overview-fixture.ts +51 -0
  188. package/lib/monitoring-overview.ts +191 -0
  189. package/lib/news-desk-session.ts +12 -21
  190. package/lib/newsletter-api.ts +418 -0
  191. package/lib/newsletter-client.ts +46 -0
  192. package/lib/newsletter-fixture.ts +184 -0
  193. package/lib/newsletter-model.ts +202 -0
  194. package/lib/newsroom-demo-dashboard.ts +1 -1
  195. package/lib/newsroom-nav.ts +1 -1
  196. package/lib/newsroom-organizer-nav.ts +2 -1
  197. package/lib/papyrus-chat-admission.ts +65 -0
  198. package/lib/papyrus-hosted-chat.ts +217 -0
  199. package/lib/papyrus-private-chat-client.ts +81 -0
  200. package/lib/prospectus-session.ts +30 -14
  201. package/lib/prospectus-sign-in-routes.ts +106 -31
  202. package/lib/prospectus-site-identity.ts +46 -6
  203. package/lib/reader-api.ts +145 -0
  204. package/lib/reader-moderation.ts +220 -0
  205. package/lib/reader-retirement-route.ts +89 -0
  206. package/lib/reader-subject.md +29 -0
  207. package/lib/reader-subject.ts +53 -0
  208. package/lib/reference-review-details.ts +71 -0
  209. package/lib/reference-review-request.ts +14 -0
  210. package/lib/relevance-decisions.ts +42 -10
  211. package/lib/relevance-status.ts +2 -2
  212. package/lib/review-client.ts +120 -0
  213. package/lib/review-fixture.ts +45 -0
  214. package/lib/review-requests.ts +246 -0
  215. package/lib/semantic-graph.ts +2 -0
  216. package/lib/shared-chat-admission.ts +182 -0
  217. package/lib/site-brand-tenant.ts +11 -6
  218. package/lib/site-brand.ts +6 -3
  219. package/lib/site-origin-value.ts +50 -0
  220. package/lib/site-origin.ts +8 -0
  221. package/lib/use-reference-review-data.ts +44 -0
  222. package/middleware.ts +14 -3
  223. package/package.json +67 -3
  224. package/routes.manifest.json +238 -2
  225. package/src/sync.mjs +11 -2
  226. package/src/with-papyrus.d.mts +1 -0
  227. package/src/with-papyrus.mjs +17 -1
@@ -0,0 +1,187 @@
1
+ import { ArnPrincipal, Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
2
+ import { Duration, Stack } from "aws-cdk-lib";
3
+ import { Rule, RuleTargetInput, Schedule } from "aws-cdk-lib/aws-events";
4
+ import { LambdaFunction as LambdaFunctionTarget } from "aws-cdk-lib/aws-events-targets";
5
+ import { resolveReaderSteeringConfig } from "./config.js";
6
+ import { readerActions } from "./function/resource.js";
7
+ import { readerSteeringSchemaDefinitions } from "./schema.js";
8
+ export { resolveReaderSteeringConfig };
9
+ export const READER_STEERING_OUTPUT_KEY = "readerSteering";
10
+ export const SNAPSHOT_SCHEDULE_PREFIX = "papyrus-reader-snapshot-";
11
+ export const READER_SNAPSHOT_TASK = { projectionTask: { task: "reader-snapshot" } };
12
+ export const READER_SCHEDULED_SNAPSHOT_TASK = { projectionTask: { task: "reader-snapshot", retirementPoll: true } };
13
+ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARAMETER";
14
+ export const readerSteeringManifest = {
15
+ name: "reader-steering",
16
+ contract: 1,
17
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
18
+ mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
19
+ queries: ["listStoryDrafts"],
20
+ guest: { mutations: [] },
21
+ newsroomPanels: [{ id: "moderation", label: "Moderation" }],
22
+ staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
23
+ };
24
+ /** What the reader-actions function reads from `PAPYRUS_READER_SETTINGS`. */
25
+ export function readerActionsSettings(config, allowInterimSubjects) {
26
+ return {
27
+ dimensions: config.dimensions,
28
+ limits: config.limits,
29
+ submissions: config.submissions,
30
+ subjects: config.subjects,
31
+ comments: config.comments,
32
+ moderation: config.moderation,
33
+ reservedHandles: config.reservedHandles,
34
+ allowInterimSubjects,
35
+ snapshotDebounceSeconds: 60,
36
+ };
37
+ }
38
+ /** What the `reader-snapshot` projection task reads from `PAPYRUS_READER_SNAPSHOT_SETTINGS`. */
39
+ export function readerSnapshotSettings(config) {
40
+ return {
41
+ dimensions: config.dimensions,
42
+ submissions: config.submissions,
43
+ subjects: config.subjects,
44
+ karma: config.karma,
45
+ ranking: config.ranking,
46
+ limits: { newAccountHours: config.limits.newAccountHours },
47
+ hypeMinimum: config.snapshot.hypeMinimum,
48
+ ...(config.hypeGap ? { hypeGap: config.hypeGap } : {}),
49
+ retirementPoll: config.retirementPoll,
50
+ };
51
+ }
52
+ export function interimSubjectsAllowedAtSynth(environment = process.env) {
53
+ return ["development", "test"].includes((environment.SITE_ENV ?? "").trim()) && environment.NODE_ENV !== "production";
54
+ }
55
+ /**
56
+ * The scheduled run polls Prospectus through the CMS route /api/reader/retired (docs/reader-steering.md), so it needs the
57
+ * site origin and the poll secret parameter that the app-shell stack generates (`cms.readerRetirementPoll`); both reach the
58
+ * backend build as branch variables. It also deletes and re-keys rows, and keeps its cursor in the rate table.
59
+ */
60
+ function wireRetirementPoll(readerLambda, contentActions, tables) {
61
+ const origin = (process.env.PAPYRUS_SITE_ORIGIN ?? "").trim();
62
+ const parameterName = (process.env[READER_POLL_SECRET_PARAMETER_ENV] ?? "").trim();
63
+ if (!origin.startsWith("https://") || !parameterName.startsWith("/")) {
64
+ throw new Error("reader-steering retirementPoll needs PAPYRUS_SITE_ORIGIN (an https origin) and PAPYRUS_READER_POLL_SECRET_PARAMETER (an SSM parameter name). The app-shell template sets both on the production branch when site.json has cms.readerRetirementPoll: true; see docs/reader-steering.md.");
65
+ }
66
+ const rateTable = readerLambda.node.scope?.node.tryFindChild("PapyrusReaderRateWindows");
67
+ if (!rateTable)
68
+ throw new Error("reader-steering retirementPoll could not find the reader-actions rate table to keep its cursor in");
69
+ tables.ReaderVote.grantReadWriteData(contentActions);
70
+ tables.ReaderHandle.grantReadWriteData(contentActions);
71
+ rateTable.grantReadWriteData(contentActions);
72
+ contentActions.addEnvironment("PAPYRUS_READER_RATE_TABLE", rateTable.tableName);
73
+ contentActions.addEnvironment("PAPYRUS_READER_POLL_URL", origin);
74
+ contentActions.addEnvironment(READER_POLL_SECRET_PARAMETER_ENV, parameterName);
75
+ const stack = Stack.of(contentActions);
76
+ contentActions.addToRolePolicy(new PolicyStatement({
77
+ effect: Effect.ALLOW,
78
+ actions: ["ssm:GetParameter"],
79
+ resources: [`arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${parameterName}`],
80
+ }));
81
+ }
82
+ /**
83
+ * AI moderation (`moderation.mode: ai`). The debounced run becomes a run of reader-actions itself (a one-off schedule that
84
+ * invokes this function), which judges the new items and then asks content-actions for the snapshot. So the scheduler role may
85
+ * invoke reader-actions, reader-actions may invoke content-actions (its ARN is already in the environment) and may read exactly
86
+ * one SSM SecureString: the API key, which the plugin never creates and never receives.
87
+ */
88
+ function wireAiModeration(readerLambda, contentActions, schedulerRole, apiKeyParameter) {
89
+ const stack = Stack.of(readerLambda);
90
+ readerLambda.grantInvoke(schedulerRole);
91
+ contentActions.grantInvoke(readerLambda);
92
+ readerLambda.addToRolePolicy(new PolicyStatement({
93
+ effect: Effect.ALLOW,
94
+ actions: ["ssm:GetParameter"],
95
+ resources: [`arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${apiKeyParameter}`],
96
+ }));
97
+ }
98
+ export const readerSteeringPlugin = {
99
+ manifest: readerSteeringManifest,
100
+ resolveConfig: resolveReaderSteeringConfig,
101
+ functions: () => ({ readerActions }),
102
+ schemaDefinitions: readerSteeringSchemaDefinitions,
103
+ publicSettings: (config) => ({
104
+ contract: readerSteeringManifest.contract,
105
+ dimensions: config.dimensions,
106
+ subjects: { types: config.subjects.types },
107
+ moderation: { mode: config.moderation.mode },
108
+ }),
109
+ wire(backend, config, context) {
110
+ const lambda = backend.readerActions.resources.lambda;
111
+ const tables = backend.data.resources.tables;
112
+ const contentActions = context.contentActionsLambda;
113
+ for (const [variable, model] of [
114
+ ["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
115
+ ["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
116
+ ["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
117
+ ["PAPYRUS_READER_FLAG_TABLE", "ReaderFlag"],
118
+ ["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
119
+ ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
120
+ ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
121
+ ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
122
+ ["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
123
+ ]) {
124
+ tables[model].grantReadWriteData(lambda);
125
+ lambda.addEnvironment(variable, tables[model].tableName);
126
+ contentActions.addEnvironment(variable, tables[model].tableName);
127
+ }
128
+ // Promoting a submission writes one draft Item (a version 1 draft; nothing is published by this function).
129
+ tables.Item.grantWriteData(lambda);
130
+ lambda.addEnvironment("PAPYRUS_READER_ITEM_TABLE", tables.Item.tableName);
131
+ tables.PublishedItem.grantReadData(lambda);
132
+ lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
133
+ tables.ReaderVote.grantReadData(contentActions);
134
+ tables.ReaderFlag.grantReadData(contentActions);
135
+ tables.ReaderProfile.grantReadWriteData(contentActions);
136
+ // The snapshot reads submissions, and the review step writes their signals and moderation state.
137
+ tables.ReaderSubmission.grantReadWriteData(contentActions);
138
+ tables.ReaderComment.grantReadWriteData(contentActions);
139
+ // The snapshot reads proposals and freezes the community result of one that reaches editor review.
140
+ tables.Proposal.grantReadWriteData(contentActions);
141
+ lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
142
+ if (config.reservedConcurrency !== undefined) {
143
+ lambda.node.defaultChild.addPropertyOverride("ReservedConcurrentExecutions", config.reservedConcurrency);
144
+ }
145
+ // Trailing-edge debounce: the first write in a window creates a one-off EventBridge Scheduler schedule at the end of
146
+ // the window (docs/reader-steering.md). The schedule invokes content-actions as this role; reader-actions may create
147
+ // schedules named for this feature, and pass exactly this role.
148
+ const schedulerRole = new Role(Stack.of(lambda), "ReaderSnapshotSchedulerRole", {
149
+ assumedBy: new ServicePrincipal("scheduler.amazonaws.com"),
150
+ description: "Lets one-off schedules created by reader-actions invoke the reader-snapshot run.",
151
+ });
152
+ contentActions.grantInvoke(schedulerRole);
153
+ const stack = Stack.of(lambda); // the role lives with the function, so the stacks keep their existing one-way dependency
154
+ lambda.addToRolePolicy(new PolicyStatement({
155
+ effect: Effect.ALLOW,
156
+ actions: ["scheduler:CreateSchedule"],
157
+ resources: [`arn:${stack.partition}:scheduler:${stack.region}:${stack.account}:schedule/default/${SNAPSHOT_SCHEDULE_PREFIX}*`],
158
+ }));
159
+ lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["iam:PassRole"], resources: [schedulerRole.roleArn] }));
160
+ lambda.addEnvironment("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", contentActions.functionArn);
161
+ lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
162
+ contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
163
+ // Proposals are private unless the site opts in (docs/reader-steering.md, Proposals); the snapshot reads this flag.
164
+ contentActions.addEnvironment("PAPYRUS_READER_PUBLISH_PROPOSALS", config.proposals.publish ? "true" : "false");
165
+ if (config.moderation.ai)
166
+ wireAiModeration(lambda, contentActions, schedulerRole, config.moderation.ai.apiKeyParameter);
167
+ lambda.addPermission("InvokeFromCmsCompute", {
168
+ principal: new ArnPrincipal(config.computeRoleArn),
169
+ action: "lambda:InvokeFunction",
170
+ });
171
+ if (config.retirementPoll)
172
+ wireRetirementPoll(lambda, contentActions, tables);
173
+ if (config.snapshot.schedule) {
174
+ new Rule(context.createStack("reader-steering-schedule"), "ReaderSnapshotSchedule", {
175
+ description: "Rebuilds the reader snapshot (time decay, Rising) and, when enabled, applies Prospectus subject retirements.",
176
+ schedule: Schedule.rate(Duration.hours(config.snapshot.intervalHours)),
177
+ targets: [
178
+ new LambdaFunctionTarget(contentActions, {
179
+ event: RuleTargetInput.fromObject(config.retirementPoll ? READER_SCHEDULED_SNAPSHOT_TASK : READER_SNAPSHOT_TASK),
180
+ retryAttempts: 0,
181
+ }),
182
+ ],
183
+ });
184
+ }
185
+ backend.addOutput({ custom: { [READER_STEERING_OUTPUT_KEY]: { actionsFunction: lambda.functionName } } });
186
+ },
187
+ };
@@ -0,0 +1,213 @@
1
+ import { ArnPrincipal, Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
2
+ import { Duration, Stack } from "aws-cdk-lib";
3
+ import type { ITable } from "aws-cdk-lib/aws-dynamodb";
4
+ import { Rule, RuleTargetInput, Schedule } from "aws-cdk-lib/aws-events";
5
+ import { LambdaFunction as LambdaFunctionTarget } from "aws-cdk-lib/aws-events-targets";
6
+ import { CfnFunction, type Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
7
+ import type { PapyrusPlugin, PluginManifest, PluginWiringContext } from "../types";
8
+ import { resolveReaderSteeringConfig, type ReaderSteeringConfig } from "./config";
9
+ import { readerActions } from "./function/resource";
10
+ import { readerSteeringSchemaDefinitions } from "./schema";
11
+
12
+ export { resolveReaderSteeringConfig };
13
+ export type { ReaderSteeringConfig };
14
+
15
+ export const READER_STEERING_OUTPUT_KEY = "readerSteering";
16
+ export const SNAPSHOT_SCHEDULE_PREFIX = "papyrus-reader-snapshot-";
17
+ export const READER_SNAPSHOT_TASK = { projectionTask: { task: "reader-snapshot" } };
18
+ export const READER_SCHEDULED_SNAPSHOT_TASK = { projectionTask: { task: "reader-snapshot", retirementPoll: true } };
19
+ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARAMETER";
20
+
21
+ export const readerSteeringManifest: PluginManifest = {
22
+ name: "reader-steering",
23
+ contract: 1,
24
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
25
+ mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
26
+ queries: ["listStoryDrafts"],
27
+ guest: { mutations: [] },
28
+ newsroomPanels: [{ id: "moderation", label: "Moderation" }],
29
+ staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
30
+ };
31
+
32
+ /** What the reader-actions function reads from `PAPYRUS_READER_SETTINGS`. */
33
+ export function readerActionsSettings(config: ReaderSteeringConfig, allowInterimSubjects: boolean) {
34
+ return {
35
+ dimensions: config.dimensions,
36
+ limits: config.limits,
37
+ submissions: config.submissions,
38
+ subjects: config.subjects,
39
+ comments: config.comments,
40
+ moderation: config.moderation,
41
+ reservedHandles: config.reservedHandles,
42
+ allowInterimSubjects,
43
+ snapshotDebounceSeconds: 60,
44
+ };
45
+ }
46
+
47
+ /** What the `reader-snapshot` projection task reads from `PAPYRUS_READER_SNAPSHOT_SETTINGS`. */
48
+ export function readerSnapshotSettings(config: ReaderSteeringConfig) {
49
+ return {
50
+ dimensions: config.dimensions,
51
+ submissions: config.submissions,
52
+ subjects: config.subjects,
53
+ karma: config.karma,
54
+ ranking: config.ranking,
55
+ limits: { newAccountHours: config.limits.newAccountHours },
56
+ hypeMinimum: config.snapshot.hypeMinimum,
57
+ ...(config.hypeGap ? { hypeGap: config.hypeGap } : {}),
58
+ retirementPoll: config.retirementPoll,
59
+ };
60
+ }
61
+
62
+ export function interimSubjectsAllowedAtSynth(environment: Record<string, string | undefined> = process.env): boolean {
63
+ return ["development", "test"].includes((environment.SITE_ENV ?? "").trim()) && environment.NODE_ENV !== "production";
64
+ }
65
+
66
+ /**
67
+ * The scheduled run polls Prospectus through the CMS route /api/reader/retired (docs/reader-steering.md), so it needs the
68
+ * site origin and the poll secret parameter that the app-shell stack generates (`cms.readerRetirementPoll`); both reach the
69
+ * backend build as branch variables. It also deletes and re-keys rows, and keeps its cursor in the rate table.
70
+ */
71
+ function wireRetirementPoll(readerLambda: LambdaFunction, contentActions: LambdaFunction, tables: Record<string, ITable>) {
72
+ const origin = (process.env.PAPYRUS_SITE_ORIGIN ?? "").trim();
73
+ const parameterName = (process.env[READER_POLL_SECRET_PARAMETER_ENV] ?? "").trim();
74
+ if (!origin.startsWith("https://") || !parameterName.startsWith("/")) {
75
+ throw new Error(
76
+ "reader-steering retirementPoll needs PAPYRUS_SITE_ORIGIN (an https origin) and PAPYRUS_READER_POLL_SECRET_PARAMETER (an SSM parameter name). The app-shell template sets both on the production branch when site.json has cms.readerRetirementPoll: true; see docs/reader-steering.md.",
77
+ );
78
+ }
79
+ const rateTable = readerLambda.node.scope?.node.tryFindChild("PapyrusReaderRateWindows") as ITable | undefined;
80
+ if (!rateTable) throw new Error("reader-steering retirementPoll could not find the reader-actions rate table to keep its cursor in");
81
+ tables.ReaderVote.grantReadWriteData(contentActions);
82
+ tables.ReaderHandle.grantReadWriteData(contentActions);
83
+ rateTable.grantReadWriteData(contentActions);
84
+ contentActions.addEnvironment("PAPYRUS_READER_RATE_TABLE", rateTable.tableName);
85
+ contentActions.addEnvironment("PAPYRUS_READER_POLL_URL", origin);
86
+ contentActions.addEnvironment(READER_POLL_SECRET_PARAMETER_ENV, parameterName);
87
+ const stack = Stack.of(contentActions);
88
+ contentActions.addToRolePolicy(
89
+ new PolicyStatement({
90
+ effect: Effect.ALLOW,
91
+ actions: ["ssm:GetParameter"],
92
+ resources: [`arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${parameterName}`],
93
+ }),
94
+ );
95
+ }
96
+
97
+ /**
98
+ * AI moderation (`moderation.mode: ai`). The debounced run becomes a run of reader-actions itself (a one-off schedule that
99
+ * invokes this function), which judges the new items and then asks content-actions for the snapshot. So the scheduler role may
100
+ * invoke reader-actions, reader-actions may invoke content-actions (its ARN is already in the environment) and may read exactly
101
+ * one SSM SecureString: the API key, which the plugin never creates and never receives.
102
+ */
103
+ function wireAiModeration(readerLambda: LambdaFunction, contentActions: LambdaFunction, schedulerRole: Role, apiKeyParameter: string) {
104
+ const stack = Stack.of(readerLambda);
105
+ readerLambda.grantInvoke(schedulerRole);
106
+ contentActions.grantInvoke(readerLambda);
107
+ readerLambda.addToRolePolicy(
108
+ new PolicyStatement({
109
+ effect: Effect.ALLOW,
110
+ actions: ["ssm:GetParameter"],
111
+ resources: [`arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${apiKeyParameter}`],
112
+ }),
113
+ );
114
+ }
115
+
116
+ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerActions: typeof readerActions }> = {
117
+ manifest: readerSteeringManifest,
118
+ resolveConfig: resolveReaderSteeringConfig,
119
+ functions: () => ({ readerActions }),
120
+ schemaDefinitions: readerSteeringSchemaDefinitions,
121
+ publicSettings: (config) => ({
122
+ contract: readerSteeringManifest.contract,
123
+ dimensions: config.dimensions,
124
+ subjects: { types: config.subjects.types },
125
+ moderation: { mode: config.moderation.mode },
126
+ }),
127
+ wire(backend, config, context: PluginWiringContext) {
128
+ const lambda = backend.readerActions.resources.lambda as LambdaFunction;
129
+ const tables = backend.data.resources.tables as Record<string, ITable>;
130
+ const contentActions = context.contentActionsLambda;
131
+
132
+ for (const [variable, model] of [
133
+ ["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
134
+ ["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
135
+ ["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
136
+ ["PAPYRUS_READER_FLAG_TABLE", "ReaderFlag"],
137
+ ["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
138
+ ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
139
+ ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
140
+ ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
141
+ ["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
142
+ ] as const) {
143
+ tables[model].grantReadWriteData(lambda);
144
+ lambda.addEnvironment(variable, tables[model].tableName);
145
+ contentActions.addEnvironment(variable, tables[model].tableName);
146
+ }
147
+ // Promoting a submission writes one draft Item (a version 1 draft; nothing is published by this function).
148
+ tables.Item.grantWriteData(lambda);
149
+ lambda.addEnvironment("PAPYRUS_READER_ITEM_TABLE", tables.Item.tableName);
150
+ tables.PublishedItem.grantReadData(lambda);
151
+ lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
152
+ tables.ReaderVote.grantReadData(contentActions);
153
+ tables.ReaderFlag.grantReadData(contentActions);
154
+ tables.ReaderProfile.grantReadWriteData(contentActions);
155
+ // The snapshot reads submissions, and the review step writes their signals and moderation state.
156
+ tables.ReaderSubmission.grantReadWriteData(contentActions);
157
+ tables.ReaderComment.grantReadWriteData(contentActions);
158
+ // The snapshot reads proposals and freezes the community result of one that reaches editor review.
159
+ tables.Proposal.grantReadWriteData(contentActions);
160
+
161
+ lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
162
+ if (config.reservedConcurrency !== undefined) {
163
+ (lambda.node.defaultChild as CfnFunction).addPropertyOverride("ReservedConcurrentExecutions", config.reservedConcurrency);
164
+ }
165
+
166
+ // Trailing-edge debounce: the first write in a window creates a one-off EventBridge Scheduler schedule at the end of
167
+ // the window (docs/reader-steering.md). The schedule invokes content-actions as this role; reader-actions may create
168
+ // schedules named for this feature, and pass exactly this role.
169
+ const schedulerRole = new Role(Stack.of(lambda), "ReaderSnapshotSchedulerRole", {
170
+ assumedBy: new ServicePrincipal("scheduler.amazonaws.com"),
171
+ description: "Lets one-off schedules created by reader-actions invoke the reader-snapshot run.",
172
+ });
173
+ contentActions.grantInvoke(schedulerRole);
174
+ const stack = Stack.of(lambda); // the role lives with the function, so the stacks keep their existing one-way dependency
175
+ lambda.addToRolePolicy(
176
+ new PolicyStatement({
177
+ effect: Effect.ALLOW,
178
+ actions: ["scheduler:CreateSchedule"],
179
+ resources: [`arn:${stack.partition}:scheduler:${stack.region}:${stack.account}:schedule/default/${SNAPSHOT_SCHEDULE_PREFIX}*`],
180
+ }),
181
+ );
182
+ lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["iam:PassRole"], resources: [schedulerRole.roleArn] }));
183
+ lambda.addEnvironment("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", contentActions.functionArn);
184
+ lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
185
+ contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
186
+ // Proposals are private unless the site opts in (docs/reader-steering.md, Proposals); the snapshot reads this flag.
187
+ contentActions.addEnvironment("PAPYRUS_READER_PUBLISH_PROPOSALS", config.proposals.publish ? "true" : "false");
188
+
189
+ if (config.moderation.ai) wireAiModeration(lambda, contentActions, schedulerRole, config.moderation.ai.apiKeyParameter);
190
+
191
+ lambda.addPermission("InvokeFromCmsCompute", {
192
+ principal: new ArnPrincipal(config.computeRoleArn),
193
+ action: "lambda:InvokeFunction",
194
+ });
195
+
196
+ if (config.retirementPoll) wireRetirementPoll(lambda, contentActions, tables);
197
+
198
+ if (config.snapshot.schedule) {
199
+ new Rule(context.createStack("reader-steering-schedule"), "ReaderSnapshotSchedule", {
200
+ description: "Rebuilds the reader snapshot (time decay, Rising) and, when enabled, applies Prospectus subject retirements.",
201
+ schedule: Schedule.rate(Duration.hours(config.snapshot.intervalHours)),
202
+ targets: [
203
+ new LambdaFunctionTarget(contentActions, {
204
+ event: RuleTargetInput.fromObject(config.retirementPoll ? READER_SCHEDULED_SNAPSHOT_TASK : READER_SNAPSHOT_TASK),
205
+ retryAttempts: 0,
206
+ }),
207
+ ],
208
+ });
209
+ }
210
+
211
+ backend.addOutput({ custom: { [READER_STEERING_OUTPUT_KEY]: { actionsFunction: lambda.functionName } } });
212
+ },
213
+ };
@@ -0,0 +1,252 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { readerActions } from "./function/resource.js";
3
+ import { functionHandler } from "../../data/function-handler.js";
4
+ /**
5
+ * Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
6
+ * tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
7
+ * the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote (`subjectKey#readerId#kind` for a flag; the key is the bare id for items and submissions and `type:id` for the rest) and
8
+ * `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
9
+ */
10
+ const staffOnly = (allow) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
11
+ export function readerSteeringSchemaDefinitions() {
12
+ return {
13
+ ReaderProfile: a
14
+ .model({
15
+ id: a.id().required(),
16
+ siteSubject: a.string().required(),
17
+ readerId: a.string().required(),
18
+ handle: a.string(),
19
+ state: a.string().required(),
20
+ karma: a.integer(),
21
+ })
22
+ .disableOperations(["subscriptions"])
23
+ .secondaryIndexes((index) => [index("readerId").name("profilesByReader").queryField("listReaderProfilesByReader")])
24
+ .authorization(staffOnly),
25
+ ReaderHandle: a
26
+ .model({
27
+ id: a.id().required(),
28
+ readerId: a.string().required(),
29
+ })
30
+ .disableOperations(["subscriptions"])
31
+ .authorization(staffOnly),
32
+ ReaderVote: a
33
+ .model({
34
+ id: a.id().required(),
35
+ subjectKey: a.string().required(),
36
+ subjectType: a.string(),
37
+ readerId: a.string().required(),
38
+ dimension: a.string().required(),
39
+ value: a.integer().required(),
40
+ voterRole: a.string().required(),
41
+ })
42
+ .disableOperations(["subscriptions"])
43
+ .authorization(staffOnly),
44
+ ReaderFlag: a
45
+ .model({
46
+ id: a.id().required(),
47
+ subjectKey: a.string().required(),
48
+ subjectType: a.string().required(),
49
+ readerId: a.string().required(),
50
+ kind: a.string().required(),
51
+ reason: a.string().required(),
52
+ targetKey: a.string(),
53
+ suggestedName: a.string(),
54
+ voterRole: a.string().required(),
55
+ createdAt: a.datetime().required(),
56
+ })
57
+ .disableOperations(["subscriptions"])
58
+ .authorization(staffOnly),
59
+ ReaderComment: a
60
+ .model({
61
+ id: a.id().required(),
62
+ subjectKey: a.string().required(),
63
+ readerId: a.string().required(),
64
+ body: a.string().required(),
65
+ parentId: a.string(),
66
+ ratingContext: a.json(),
67
+ state: a.string().required(),
68
+ signals: a.json(),
69
+ moderation: a.string().required(),
70
+ reportCount: a.integer(),
71
+ reviewedAt: a.datetime(),
72
+ editedAt: a.datetime(),
73
+ deletedAt: a.datetime(),
74
+ createdAt: a.datetime().required(),
75
+ })
76
+ .disableOperations(["subscriptions"])
77
+ .secondaryIndexes((index) => [index("moderation").sortKeys(["createdAt"]).name("commentsByModeration").queryField("listReaderCommentsByModerationAndCreatedAt")])
78
+ .authorization(staffOnly),
79
+ ReaderSubmission: a
80
+ .model({
81
+ id: a.id().required(),
82
+ canonicalUrl: a.string().required(),
83
+ domain: a.string().required(),
84
+ title: a.string().required(),
85
+ kind: a.string().required(),
86
+ note: a.string(),
87
+ readerId: a.string().required(),
88
+ state: a.string().required(),
89
+ similar: a.string().array(),
90
+ signals: a.json(),
91
+ extractionState: a.string().required(),
92
+ itemId: a.string(),
93
+ moderation: a.string().required(),
94
+ reportCount: a.integer(),
95
+ reviewedAt: a.datetime(),
96
+ createdAt: a.datetime().required(),
97
+ })
98
+ .disableOperations(["subscriptions"])
99
+ .secondaryIndexes((index) => [
100
+ index("domain").sortKeys(["createdAt"]).name("submissionsByDomain").queryField("listReaderSubmissionsByDomainAndCreatedAt"),
101
+ index("moderation").sortKeys(["createdAt"]).name("submissionsByModeration").queryField("listReaderSubmissionsByModerationAndCreatedAt"),
102
+ ])
103
+ .authorization(staffOnly),
104
+ ReaderReport: a
105
+ .model({
106
+ id: a.id().required(),
107
+ targetType: a.string().required(),
108
+ target: a.string().required(),
109
+ reporterReaderId: a.string().required(),
110
+ reason: a.string().required(),
111
+ state: a.string().required(),
112
+ closedAt: a.datetime(),
113
+ createdAt: a.datetime().required(),
114
+ })
115
+ .disableOperations(["subscriptions"])
116
+ .secondaryIndexes((index) => [index("target").sortKeys(["createdAt"]).name("reportsByTarget").queryField("listReaderReportsByTargetAndCreatedAt")])
117
+ .authorization(staffOnly),
118
+ ModerationAction: a
119
+ .model({
120
+ id: a.id().required(),
121
+ by: a.string().required(),
122
+ actorSub: a.string().required(),
123
+ action: a.string().required(),
124
+ targetType: a.string().required(),
125
+ target: a.string().required(),
126
+ reason: a.string(),
127
+ fromState: a.string().required(),
128
+ toState: a.string().required(),
129
+ fromModeration: a.string(),
130
+ toModeration: a.string(),
131
+ undoOf: a.string(),
132
+ undoneBy: a.string(),
133
+ at: a.datetime().required(),
134
+ })
135
+ .disableOperations(["subscriptions"])
136
+ .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
137
+ .authorization(staffOnly),
138
+ Proposal: a
139
+ .model({
140
+ id: a.id().required(),
141
+ kind: a.string().required(),
142
+ title: a.string().required(),
143
+ summary: a.string(),
144
+ body: a.string().required(),
145
+ targets: a.json().required(),
146
+ evidence: a.json(),
147
+ proposer: a.string().required(),
148
+ state: a.string().required(),
149
+ history: a.json().required(),
150
+ communityResult: a.json(),
151
+ pinned: a.boolean(),
152
+ held: a.boolean(),
153
+ override: a.json(),
154
+ exclusions: a.json(),
155
+ editorLog: a.json(),
156
+ needsLook: a.json(),
157
+ createdAt: a.datetime().required(),
158
+ })
159
+ .disableOperations(["subscriptions"])
160
+ .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
161
+ .authorization(staffOnly),
162
+ ProposalReceipt: a.customType({
163
+ status: a.string().required(),
164
+ id: a.string(),
165
+ error: a.string(),
166
+ message: a.string(),
167
+ }),
168
+ // What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
169
+ StoryDraftSummary: a.customType({
170
+ id: a.string().required(),
171
+ title: a.string().required(),
172
+ summary: a.string(),
173
+ targets: a.json(),
174
+ state: a.string().required(),
175
+ createdAt: a.string().required(),
176
+ }),
177
+ StoryDraftList: a.customType({
178
+ status: a.string().required(),
179
+ id: a.string(),
180
+ error: a.string(),
181
+ message: a.string(),
182
+ drafts: a.ref("StoryDraftSummary").array(),
183
+ }),
184
+ // Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
185
+ // schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
186
+ // of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
187
+ fileProposal: a
188
+ .mutation()
189
+ .arguments({
190
+ kind: a.string().required(),
191
+ title: a.string().required(),
192
+ body: a.string().required(),
193
+ summary: a.string(),
194
+ targets: a.json(),
195
+ evidence: a.json(),
196
+ })
197
+ .returns(a.ref("ProposalReceipt"))
198
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
199
+ .handler(functionHandler("readerActions", readerActions)),
200
+ // The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
201
+ // Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
202
+ transitionProposal: a
203
+ .mutation()
204
+ .arguments({
205
+ proposalId: a.string().required(),
206
+ state: a.string().required(),
207
+ reason: a.string(),
208
+ result: a.string(),
209
+ })
210
+ .returns(a.ref("ProposalReceipt"))
211
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
212
+ .handler(functionHandler("readerActions", readerActions)),
213
+ // Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
214
+ // answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
215
+ listStoryDrafts: a
216
+ .query()
217
+ .returns(a.ref("StoryDraftList"))
218
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
219
+ .handler(functionHandler("readerActions", readerActions)),
220
+ // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
221
+ // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
222
+ editProposal: a
223
+ .mutation()
224
+ .arguments({
225
+ proposalId: a.string().required(),
226
+ action: a.string().required(),
227
+ rule: a.string(),
228
+ exclusionId: a.string(),
229
+ })
230
+ .returns(a.ref("ProposalReceipt"))
231
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
232
+ .handler(functionHandler("readerActions", readerActions)),
233
+ ModerationReceipt: a.customType({
234
+ status: a.string().required(),
235
+ id: a.string(),
236
+ error: a.string(),
237
+ message: a.string(),
238
+ }),
239
+ moderateReader: a
240
+ .mutation()
241
+ .arguments({
242
+ action: a.string().required(),
243
+ targetType: a.string().required(),
244
+ target: a.string().required(),
245
+ reason: a.string(),
246
+ undoOf: a.string(),
247
+ })
248
+ .returns(a.ref("ModerationReceipt"))
249
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
250
+ .handler(functionHandler("readerActions", readerActions)),
251
+ };
252
+ }