@anthusai/papyrus 5.32.0 → 5.33.1

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 (39) hide show
  1. package/amplify/data/function-handler.js +20 -0
  2. package/amplify/data/function-handler.ts +40 -0
  3. package/amplify/data/resource.js +112 -60
  4. package/amplify/data/resource.ts +115 -60
  5. package/amplify/data/schema.js +104 -56
  6. package/amplify/data/schema.ts +107 -56
  7. package/amplify/functions/automation-view/handler.py +9 -3
  8. package/amplify/functions/automation-view/schema.js +13 -3
  9. package/amplify/functions/automation-view/schema.ts +13 -3
  10. package/amplify/functions/automation-view/wire.js +28 -4
  11. package/amplify/functions/automation-view/wire.ts +34 -4
  12. package/amplify/functions/editorial-checks/schema.js +9 -8
  13. package/amplify/functions/editorial-checks/schema.ts +9 -8
  14. package/amplify/functions/monitoring-view/schema.js +2 -1
  15. package/amplify/functions/monitoring-view/schema.ts +2 -1
  16. package/amplify/plugins/newsletter/schema.js +2 -1
  17. package/amplify/plugins/newsletter/schema.ts +2 -1
  18. package/amplify/plugins/reader-steering/config.js +3 -1
  19. package/amplify/plugins/reader-steering/config.ts +11 -1
  20. package/amplify/plugins/reader-steering/papyrus.plugin.js +2 -0
  21. package/amplify/plugins/reader-steering/papyrus.plugin.ts +2 -0
  22. package/amplify/plugins/reader-steering/schema.js +4 -3
  23. package/amplify/plugins/reader-steering/schema.ts +4 -3
  24. package/amplify/plugins/signup/schema.js +2 -1
  25. package/amplify/plugins/signup/schema.ts +2 -1
  26. package/components/automation-panel.tsx +12 -2
  27. package/components/automation-run-now.tsx +97 -0
  28. package/components/automation-view.tsx +50 -7
  29. package/components/newsroom-ops-overview.tsx +18 -0
  30. package/components/notification-center.tsx +262 -0
  31. package/components/topic-steering-workspace.tsx +4 -1
  32. package/lib/automation-client.ts +26 -1
  33. package/lib/automation-fixture.ts +46 -18
  34. package/lib/automation-overview.ts +96 -2
  35. package/lib/review-client.ts +99 -0
  36. package/lib/review-fixture.ts +45 -0
  37. package/lib/review-requests.ts +238 -0
  38. package/package.json +6 -1
  39. package/routes.manifest.json +1 -1
@@ -1,11 +1,23 @@
1
1
  import { Stack } from "aws-cdk-lib";
2
2
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
3
  import { MONITORING_TABLE_PARAMETER, MONITORING_VIEW_TABLE_ACTIONS, monitoringViewParameterArn, monitoringViewTableArns, } from "../monitoring-view/wire.js";
4
- /** The two reads the function makes of its own site's API with its own role. It writes nothing. */
5
- export const AUTOMATION_VIEW_FUNCTION_FIELDS = ["Query/fields/listAutomations", "Query/fields/getProcedureRun"];
4
+ /** The reads the function makes of its own site's API with its own role. It writes nothing there. */
5
+ export const AUTOMATION_VIEW_FUNCTION_FIELDS = [
6
+ "Query/fields/getAutomation",
7
+ "Query/fields/getNewsroomProcedureDefinition",
8
+ "Query/fields/getProcedureRun",
9
+ "Query/fields/listAutomations",
10
+ "Query/fields/listProcedureRunsByProcedureAndRequestedAt",
11
+ ];
12
+ /** The procedure runner stack's function (infra/procedures/papyrus-procedure-runner.yaml); Run now starts it asynchronously. */
13
+ export const PROCEDURE_RUNNER_FUNCTION_NAME = "papyrus-procedure-runner";
14
+ /** Run now writes one row of its own to the monitoring table, in a monitor's partition (`MONITOR#<id>`), and nothing else. */
15
+ export const AUTOMATION_RUN_NOW_TABLE_ACTIONS = ["dynamodb:PutItem"];
16
+ export const AUTOMATION_RUN_NOW_LEADING_KEYS = ["MONITOR#*"];
6
17
  /**
7
- * Environment and least-privilege IAM for the automation-view function: two fields of the site's own API,
8
- * and the same read-only grant on the monitoring table that the Monitoring page has. A site outside the
18
+ * Environment and least-privilege IAM for the automation-view function: a few read fields of the site's own API,
19
+ * the same read-only grant on the monitoring table that the Monitoring page has, one PutItem for Run now's row,
20
+ * and permission to start the procedure runner. A site outside the
9
21
  * monitoring account finds no parameter and the page lists its records without runs.
10
22
  */
11
23
  export function wireAutomationView(backend) {
@@ -13,6 +25,7 @@ export function wireAutomationView(backend) {
13
25
  const stack = Stack.of(lambda);
14
26
  lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
15
27
  lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
28
+ lambda.addEnvironment("PROCEDURE_RUNNER_FUNCTION", PROCEDURE_RUNNER_FUNCTION_NAME);
16
29
  const apiId = backend.data.resources.graphqlApi.apiId;
17
30
  lambda.addToRolePolicy(new PolicyStatement({
18
31
  effect: Effect.ALLOW,
@@ -29,4 +42,15 @@ export function wireAutomationView(backend) {
29
42
  actions: MONITORING_VIEW_TABLE_ACTIONS,
30
43
  resources: monitoringViewTableArns(stack.region, stack.account),
31
44
  }));
45
+ lambda.addToRolePolicy(new PolicyStatement({
46
+ effect: Effect.ALLOW,
47
+ actions: AUTOMATION_RUN_NOW_TABLE_ACTIONS,
48
+ resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
49
+ conditions: { "ForAllValues:StringLike": { "dynamodb:LeadingKeys": AUTOMATION_RUN_NOW_LEADING_KEYS } },
50
+ }));
51
+ lambda.addToRolePolicy(new PolicyStatement({
52
+ effect: Effect.ALLOW,
53
+ actions: ["lambda:InvokeFunction"],
54
+ resources: [`arn:aws:lambda:${stack.region}:${stack.account}:function:${PROCEDURE_RUNNER_FUNCTION_NAME}`],
55
+ }));
32
56
  }
@@ -8,12 +8,26 @@ import {
8
8
  monitoringViewTableArns,
9
9
  } from "../monitoring-view/wire";
10
10
 
11
- /** The two reads the function makes of its own site's API with its own role. It writes nothing. */
12
- export const AUTOMATION_VIEW_FUNCTION_FIELDS = ["Query/fields/listAutomations", "Query/fields/getProcedureRun"];
11
+ /** The reads the function makes of its own site's API with its own role. It writes nothing there. */
12
+ export const AUTOMATION_VIEW_FUNCTION_FIELDS = [
13
+ "Query/fields/getAutomation",
14
+ "Query/fields/getNewsroomProcedureDefinition",
15
+ "Query/fields/getProcedureRun",
16
+ "Query/fields/listAutomations",
17
+ "Query/fields/listProcedureRunsByProcedureAndRequestedAt",
18
+ ];
19
+
20
+ /** The procedure runner stack's function (infra/procedures/papyrus-procedure-runner.yaml); Run now starts it asynchronously. */
21
+ export const PROCEDURE_RUNNER_FUNCTION_NAME = "papyrus-procedure-runner";
22
+
23
+ /** Run now writes one row of its own to the monitoring table, in a monitor's partition (`MONITOR#<id>`), and nothing else. */
24
+ export const AUTOMATION_RUN_NOW_TABLE_ACTIONS = ["dynamodb:PutItem"];
25
+ export const AUTOMATION_RUN_NOW_LEADING_KEYS = ["MONITOR#*"];
13
26
 
14
27
  /**
15
- * Environment and least-privilege IAM for the automation-view function: two fields of the site's own API,
16
- * and the same read-only grant on the monitoring table that the Monitoring page has. A site outside the
28
+ * Environment and least-privilege IAM for the automation-view function: a few read fields of the site's own API,
29
+ * the same read-only grant on the monitoring table that the Monitoring page has, one PutItem for Run now's row,
30
+ * and permission to start the procedure runner. A site outside the
17
31
  * monitoring account finds no parameter and the page lists its records without runs.
18
32
  */
19
33
  export function wireAutomationView(backend: any): void {
@@ -21,6 +35,7 @@ export function wireAutomationView(backend: any): void {
21
35
  const stack = Stack.of(lambda);
22
36
  lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
23
37
  lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
38
+ lambda.addEnvironment("PROCEDURE_RUNNER_FUNCTION", PROCEDURE_RUNNER_FUNCTION_NAME);
24
39
  const apiId = backend.data.resources.graphqlApi.apiId as string;
25
40
  lambda.addToRolePolicy(
26
41
  new PolicyStatement({
@@ -43,4 +58,19 @@ export function wireAutomationView(backend: any): void {
43
58
  resources: monitoringViewTableArns(stack.region, stack.account),
44
59
  }),
45
60
  );
61
+ lambda.addToRolePolicy(
62
+ new PolicyStatement({
63
+ effect: Effect.ALLOW,
64
+ actions: AUTOMATION_RUN_NOW_TABLE_ACTIONS,
65
+ resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
66
+ conditions: { "ForAllValues:StringLike": { "dynamodb:LeadingKeys": AUTOMATION_RUN_NOW_LEADING_KEYS } },
67
+ }),
68
+ );
69
+ lambda.addToRolePolicy(
70
+ new PolicyStatement({
71
+ effect: Effect.ALLOW,
72
+ actions: ["lambda:InvokeFunction"],
73
+ resources: [`arn:aws:lambda:${stack.region}:${stack.account}:function:${PROCEDURE_RUNNER_FUNCTION_NAME}`],
74
+ }),
75
+ );
46
76
  }
@@ -1,5 +1,6 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { editorialChecks } from "./resource.js";
3
+ import { functionHandler } from "../../data/function-handler.js";
3
4
  const editors = ["editor", "admin"];
4
5
  /**
5
6
  * Core to every site's data API. Policies, settings and editors' decisions live in Papyrus; Humanus checks
@@ -69,19 +70,19 @@ export const editorialChecksSchemaDefinitions = {
69
70
  .query()
70
71
  .returns(a.json())
71
72
  .authorization((allow) => [allow.groups(editors)])
72
- .handler(a.handler.function(editorialChecks)),
73
+ .handler(functionHandler("editorialChecks", editorialChecks)),
73
74
  checkItemVersion: a
74
75
  .mutation()
75
76
  .arguments({ itemId: a.id().required() })
76
77
  .returns(a.json())
77
78
  .authorization((allow) => [allow.groups(editors)])
78
- .handler(a.handler.function(editorialChecks)),
79
+ .handler(functionHandler("editorialChecks", editorialChecks)),
79
80
  checkEditorial: a
80
81
  .query()
81
82
  .arguments({ body: a.string().required(), format: a.string(), section: a.string() })
82
83
  .returns(a.json())
83
84
  .authorization((allow) => [allow.groups(editors)])
84
- .handler(a.handler.function(editorialChecks)),
85
+ .handler(functionHandler("editorialChecks", editorialChecks)),
85
86
  suggestEditorialRewrite: a
86
87
  .query()
87
88
  .arguments({
@@ -94,28 +95,28 @@ export const editorialChecksSchemaDefinitions = {
94
95
  })
95
96
  .returns(a.json())
96
97
  .authorization((allow) => [allow.groups(editors)])
97
- .handler(a.handler.function(editorialChecks)),
98
+ .handler(functionHandler("editorialChecks", editorialChecks)),
98
99
  draftEditorialQuestions: a
99
100
  .query()
100
101
  .arguments({ title: a.string().required(), guidance: a.string().required(), examples: a.json() })
101
102
  .returns(a.json())
102
103
  .authorization((allow) => [allow.group("admin")])
103
- .handler(a.handler.function(editorialChecks)),
104
+ .handler(functionHandler("editorialChecks", editorialChecks)),
104
105
  tryEditorialPolicy: a
105
106
  .query()
106
107
  .arguments({ policy: a.json().required(), text: a.string().required(), format: a.string() })
107
108
  .returns(a.json())
108
109
  .authorization((allow) => [allow.group("admin")])
109
- .handler(a.handler.function(editorialChecks)),
110
+ .handler(functionHandler("editorialChecks", editorialChecks)),
110
111
  importEditorialStarterPack: a
111
112
  .mutation()
112
113
  .arguments({ name: a.string().required() })
113
114
  .returns(a.json())
114
115
  .authorization((allow) => [allow.group("admin")])
115
- .handler(a.handler.function(editorialChecks)),
116
+ .handler(functionHandler("editorialChecks", editorialChecks)),
116
117
  recalibrateEditorialPolicies: a
117
118
  .mutation()
118
119
  .returns(a.json())
119
120
  .authorization((allow) => [allow.group("admin")])
120
- .handler(a.handler.function(editorialChecks)),
121
+ .handler(functionHandler("editorialChecks", editorialChecks)),
121
122
  };
@@ -1,5 +1,6 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { editorialChecks } from "./resource";
3
+ import { functionHandler } from "../../data/function-handler";
3
4
 
4
5
  const editors = ["editor", "admin"];
5
6
 
@@ -71,19 +72,19 @@ export const editorialChecksSchemaDefinitions = {
71
72
  .query()
72
73
  .returns(a.json())
73
74
  .authorization((allow) => [allow.groups(editors)])
74
- .handler(a.handler.function(editorialChecks)),
75
+ .handler(functionHandler("editorialChecks", editorialChecks)),
75
76
  checkItemVersion: a
76
77
  .mutation()
77
78
  .arguments({ itemId: a.id().required() })
78
79
  .returns(a.json())
79
80
  .authorization((allow) => [allow.groups(editors)])
80
- .handler(a.handler.function(editorialChecks)),
81
+ .handler(functionHandler("editorialChecks", editorialChecks)),
81
82
  checkEditorial: a
82
83
  .query()
83
84
  .arguments({ body: a.string().required(), format: a.string(), section: a.string() })
84
85
  .returns(a.json())
85
86
  .authorization((allow) => [allow.groups(editors)])
86
- .handler(a.handler.function(editorialChecks)),
87
+ .handler(functionHandler("editorialChecks", editorialChecks)),
87
88
  suggestEditorialRewrite: a
88
89
  .query()
89
90
  .arguments({
@@ -96,28 +97,28 @@ export const editorialChecksSchemaDefinitions = {
96
97
  })
97
98
  .returns(a.json())
98
99
  .authorization((allow) => [allow.groups(editors)])
99
- .handler(a.handler.function(editorialChecks)),
100
+ .handler(functionHandler("editorialChecks", editorialChecks)),
100
101
  draftEditorialQuestions: a
101
102
  .query()
102
103
  .arguments({ title: a.string().required(), guidance: a.string().required(), examples: a.json() })
103
104
  .returns(a.json())
104
105
  .authorization((allow) => [allow.group("admin")])
105
- .handler(a.handler.function(editorialChecks)),
106
+ .handler(functionHandler("editorialChecks", editorialChecks)),
106
107
  tryEditorialPolicy: a
107
108
  .query()
108
109
  .arguments({ policy: a.json().required(), text: a.string().required(), format: a.string() })
109
110
  .returns(a.json())
110
111
  .authorization((allow) => [allow.group("admin")])
111
- .handler(a.handler.function(editorialChecks)),
112
+ .handler(functionHandler("editorialChecks", editorialChecks)),
112
113
  importEditorialStarterPack: a
113
114
  .mutation()
114
115
  .arguments({ name: a.string().required() })
115
116
  .returns(a.json())
116
117
  .authorization((allow) => [allow.group("admin")])
117
- .handler(a.handler.function(editorialChecks)),
118
+ .handler(functionHandler("editorialChecks", editorialChecks)),
118
119
  recalibrateEditorialPolicies: a
119
120
  .mutation()
120
121
  .returns(a.json())
121
122
  .authorization((allow) => [allow.group("admin")])
122
- .handler(a.handler.function(editorialChecks)),
123
+ .handler(functionHandler("editorialChecks", editorialChecks)),
123
124
  };
@@ -1,5 +1,6 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { monitoringView } from "./resource.js";
3
+ import { functionHandler } from "../../data/function-handler.js";
3
4
  const editors = ["editor", "admin"];
4
5
  /**
5
6
  * Core to every site's data API. The Monitoring page reads the shared monitoring pipeline through
@@ -12,5 +13,5 @@ export const monitoringViewSchemaDefinitions = {
12
13
  .arguments({ scope: a.string() })
13
14
  .returns(a.json())
14
15
  .authorization((allow) => [allow.groups(editors)])
15
- .handler(a.handler.function(monitoringView)),
16
+ .handler(functionHandler("monitoringView", monitoringView)),
16
17
  };
@@ -1,5 +1,6 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { monitoringView } from "./resource";
3
+ import { functionHandler } from "../../data/function-handler";
3
4
 
4
5
  const editors = ["editor", "admin"];
5
6
 
@@ -14,5 +15,5 @@ export const monitoringViewSchemaDefinitions = {
14
15
  .arguments({ scope: a.string() })
15
16
  .returns(a.json())
16
17
  .authorization((allow) => [allow.groups(editors)])
17
- .handler(a.handler.function(monitoringView)),
18
+ .handler(functionHandler("monitoringView", monitoringView)),
18
19
  };
@@ -1,5 +1,6 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { newsletterOps } from "./ops/resource.js";
3
+ import { functionHandler } from "../../data/function-handler.js";
3
4
  const staff = ["editor", "admin"];
4
5
  const admins = ["admin"];
5
6
  /** The newsroom's newsletter operations, all JSON in and out. The function checks the groups again from the identity claims. */
@@ -46,7 +47,7 @@ export const NEWSLETTER_OPERATION_GROUPS = {
46
47
  * table, never readable through the content API. It adds fourteen operations for the newsroom's Newsletter panel.
47
48
  */
48
49
  export function newsletterSchemaDefinitions() {
49
- const handler = a.handler.function(newsletterOps);
50
+ const handler = functionHandler("newsletterOps", newsletterOps);
50
51
  const query = (name) => a.query().arguments({ input: a.json() }).returns(a.json()).authorization((allow) => [allow.groups(NEWSLETTER_OPERATION_GROUPS[name])]).handler(handler);
51
52
  const mutation = (name) => a.mutation().arguments({ input: a.json() }).returns(a.json()).authorization((allow) => [allow.groups(NEWSLETTER_OPERATION_GROUPS[name])]).handler(handler);
52
53
  return {
@@ -1,5 +1,6 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { newsletterOps } from "./ops/resource";
3
+ import { functionHandler } from "../../data/function-handler";
3
4
 
4
5
  const staff = ["editor", "admin"];
5
6
  const admins = ["admin"];
@@ -49,7 +50,7 @@ export const NEWSLETTER_OPERATION_GROUPS: Record<string, string[]> = {
49
50
  * table, never readable through the content API. It adds fourteen operations for the newsroom's Newsletter panel.
50
51
  */
51
52
  export function newsletterSchemaDefinitions(): Record<string, unknown> {
52
- const handler = a.handler.function(newsletterOps);
53
+ const handler = functionHandler("newsletterOps", newsletterOps);
53
54
  const query = (name: string) =>
54
55
  a.query().arguments({ input: a.json() }).returns(a.json()).authorization((allow) => [allow.groups(NEWSLETTER_OPERATION_GROUPS[name])]).handler(handler);
55
56
  const mutation = (name: string) =>
@@ -7,7 +7,7 @@ export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
7
7
  export const SUBMISSION_KIND_NAME = /^[a-z][a-z0-9-]{0,23}$/;
8
8
  export const SUBJECT_TYPES = ["item", "submission", "story", "proposal", "concept"];
9
9
  export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
10
- const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
10
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects", "proposals"]);
11
11
  function object(raw, label, known) {
12
12
  if (raw === undefined)
13
13
  return {};
@@ -117,6 +117,7 @@ export function resolveReaderSteeringConfig(request) {
117
117
  if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => SUBJECT_TYPES.includes(type))) {
118
118
  throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
119
119
  }
120
+ const proposals = object(raw.proposals, "proposals", ["publish"]);
120
121
  const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
121
122
  const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
122
123
  const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
@@ -173,6 +174,7 @@ export function resolveReaderSteeringConfig(request) {
173
174
  maximumLength: integer(comments.maximumLength, "comments.maximumLength", 2000, 1, 10000),
174
175
  maximumLinks: integer(comments.maximumLinks, "comments.maximumLinks", 3, 0, 50),
175
176
  },
177
+ proposals: { publish: flag(proposals.publish, "proposals.publish") },
176
178
  retirementPoll,
177
179
  ...(hypeGap ? { hypeGap } : {}),
178
180
  ...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
@@ -98,6 +98,14 @@ export type ReaderSteeringConfig = {
98
98
  /** Most links (http, https or www) in a comment. */
99
99
  maximumLinks: number;
100
100
  };
101
+ proposals: {
102
+ /**
103
+ * Publish every proposal, in every state (drafts included), as public `_data/proposals/` files in each snapshot run.
104
+ * Off by default: proposals stay private records until a site opts in. Turning it off stops further updates; it does not
105
+ * delete files already published.
106
+ */
107
+ publish: boolean;
108
+ };
101
109
  /**
102
110
  * The hype gap is `mean(plus dimensions) - mean(minus dimensions)`, computed for reader means and for editor scores.
103
111
  * Absent means no hype gap is published. The site names the dimensions in each group.
@@ -115,7 +123,7 @@ export type ReaderSteeringConfig = {
115
123
  retirementPoll: boolean;
116
124
  };
117
125
 
118
- const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
126
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects", "proposals"]);
119
127
 
120
128
  function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
121
129
  if (raw === undefined) return {};
@@ -217,6 +225,7 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
217
225
  if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => (SUBJECT_TYPES as readonly unknown[]).includes(type))) {
218
226
  throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
219
227
  }
228
+ const proposals = object(raw.proposals, "proposals", ["publish"]);
220
229
  const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
221
230
  const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
222
231
  const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
@@ -273,6 +282,7 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
273
282
  maximumLength: integer(comments.maximumLength, "comments.maximumLength", 2000, 1, 10000),
274
283
  maximumLinks: integer(comments.maximumLinks, "comments.maximumLinks", 3, 0, 50),
275
284
  },
285
+ proposals: { publish: flag(proposals.publish, "proposals.publish") },
276
286
  retirementPoll,
277
287
  ...(hypeGap ? { hypeGap } : {}),
278
288
  ...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
@@ -159,6 +159,8 @@ export const readerSteeringPlugin = {
159
159
  lambda.addEnvironment("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", contentActions.functionArn);
160
160
  lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
161
161
  contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
162
+ // Proposals are private unless the site opts in (docs/reader-steering.md, Proposals); the snapshot reads this flag.
163
+ contentActions.addEnvironment("PAPYRUS_READER_PUBLISH_PROPOSALS", config.proposals.publish ? "true" : "false");
162
164
  if (config.moderation.ai)
163
165
  wireAiModeration(lambda, contentActions, schedulerRole, config.moderation.ai.apiKeyParameter);
164
166
  lambda.addPermission("InvokeFromCmsCompute", {
@@ -182,6 +182,8 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
182
182
  lambda.addEnvironment("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", contentActions.functionArn);
183
183
  lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
184
184
  contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
185
+ // Proposals are private unless the site opts in (docs/reader-steering.md, Proposals); the snapshot reads this flag.
186
+ contentActions.addEnvironment("PAPYRUS_READER_PUBLISH_PROPOSALS", config.proposals.publish ? "true" : "false");
185
187
 
186
188
  if (config.moderation.ai) wireAiModeration(lambda, contentActions, schedulerRole, config.moderation.ai.apiKeyParameter);
187
189
 
@@ -1,5 +1,6 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { readerActions } from "./function/resource.js";
3
+ import { functionHandler } from "../../data/function-handler.js";
3
4
  /**
4
5
  * Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
5
6
  * tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
@@ -170,7 +171,7 @@ export function readerSteeringSchemaDefinitions() {
170
171
  })
171
172
  .returns(a.ref("ProposalReceipt"))
172
173
  .authorization((allow) => [allow.group("editor"), allow.group("admin")])
173
- .handler(a.handler.function(readerActions)),
174
+ .handler(functionHandler("readerActions", readerActions)),
174
175
  transitionProposal: a
175
176
  .mutation()
176
177
  .arguments({
@@ -181,7 +182,7 @@ export function readerSteeringSchemaDefinitions() {
181
182
  })
182
183
  .returns(a.ref("ProposalReceipt"))
183
184
  .authorization((allow) => [allow.group("editor"), allow.group("admin")])
184
- .handler(a.handler.function(readerActions)),
185
+ .handler(functionHandler("readerActions", readerActions)),
185
186
  ModerationReceipt: a.customType({
186
187
  status: a.string().required(),
187
188
  id: a.string(),
@@ -199,6 +200,6 @@ export function readerSteeringSchemaDefinitions() {
199
200
  })
200
201
  .returns(a.ref("ModerationReceipt"))
201
202
  .authorization((allow) => [allow.group("editor"), allow.group("admin")])
202
- .handler(a.handler.function(readerActions)),
203
+ .handler(functionHandler("readerActions", readerActions)),
203
204
  };
204
205
  }
@@ -1,5 +1,6 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { readerActions } from "./function/resource";
3
+ import { functionHandler } from "../../data/function-handler";
3
4
 
4
5
  /**
5
6
  * Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
@@ -172,7 +173,7 @@ export function readerSteeringSchemaDefinitions() {
172
173
  })
173
174
  .returns(a.ref("ProposalReceipt"))
174
175
  .authorization((allow) => [allow.group("editor"), allow.group("admin")])
175
- .handler(a.handler.function(readerActions)),
176
+ .handler(functionHandler("readerActions", readerActions)),
176
177
  transitionProposal: a
177
178
  .mutation()
178
179
  .arguments({
@@ -183,7 +184,7 @@ export function readerSteeringSchemaDefinitions() {
183
184
  })
184
185
  .returns(a.ref("ProposalReceipt"))
185
186
  .authorization((allow) => [allow.group("editor"), allow.group("admin")])
186
- .handler(a.handler.function(readerActions)),
187
+ .handler(functionHandler("readerActions", readerActions)),
187
188
  ModerationReceipt: a.customType({
188
189
  status: a.string().required(),
189
190
  id: a.string(),
@@ -201,6 +202,6 @@ export function readerSteeringSchemaDefinitions() {
201
202
  })
202
203
  .returns(a.ref("ModerationReceipt"))
203
204
  .authorization((allow) => [allow.group("editor"), allow.group("admin")])
204
- .handler(a.handler.function(readerActions)),
205
+ .handler(functionHandler("readerActions", readerActions)),
205
206
  };
206
207
  }
@@ -1,5 +1,6 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { signupSubmit } from "./function/resource.js";
3
+ import { functionHandler } from "../../data/function-handler.js";
3
4
  /**
4
5
  * SignupEntry is never guest-readable and has no create rule for anyone: only the
5
6
  * submitSignup function (IAM) writes it. Editors and admins list it; admins delete.
@@ -35,6 +36,6 @@ export function signupSchemaDefinitions() {
35
36
  })
36
37
  .returns(a.ref("SignupReceipt"))
37
38
  .authorization((allow) => [allow.guest()])
38
- .handler(a.handler.function(signupSubmit)),
39
+ .handler(functionHandler("signupSubmit", signupSubmit)),
39
40
  };
40
41
  }
@@ -1,5 +1,6 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { signupSubmit } from "./function/resource";
3
+ import { functionHandler } from "../../data/function-handler";
3
4
 
4
5
  /**
5
6
  * SignupEntry is never guest-readable and has no create rule for anyone: only the
@@ -36,6 +37,6 @@ export function signupSchemaDefinitions() {
36
37
  })
37
38
  .returns(a.ref("SignupReceipt"))
38
39
  .authorization((allow) => [allow.guest()])
39
- .handler(a.handler.function(signupSubmit)),
40
+ .handler(functionHandler("signupSubmit", signupSubmit)),
40
41
  };
41
42
  }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
- import { loadAutomationOverview } from "../lib/automation-client";
4
+ import { loadAutomationOverview, runAutomationNow } from "../lib/automation-client";
5
5
  import { FIXTURE_AUTOMATION_OVERVIEW } from "../lib/automation-fixture";
6
6
  import type { AutomationResult } from "../lib/automation-overview";
7
7
  import { AutomationView } from "./automation-view";
@@ -35,5 +35,15 @@ export function AutomationPanel({ isDemo }: { isDemo: boolean }) {
35
35
  };
36
36
  }, [isDemo]);
37
37
 
38
- return <AutomationView result={result} selectedId={selectedId} onSelect={setSelectedId} demo={isDemo} />;
38
+ const runNow = isDemo ? undefined : async (automationId: string, input: Record<string, unknown> | null) => {
39
+ const outcome = await runAutomationNow(automationId, input);
40
+ try {
41
+ setResult(await loadAutomationOverview()); // show the new run without waiting for the minute
42
+ } catch {
43
+ /* the next refresh will */
44
+ }
45
+ return outcome;
46
+ };
47
+
48
+ return <AutomationView result={result} selectedId={selectedId} onSelect={setSelectedId} demo={isDemo} onRunNow={runNow} />;
39
49
  }
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import {
5
+ atATimeText,
6
+ inputTemplateText,
7
+ parseRunInput,
8
+ runNowMessage,
9
+ type AutomationRecord,
10
+ type RunNowResult,
11
+ } from "../lib/automation-overview";
12
+
13
+ export type RunNowPhase = "idle" | "confirming" | "running" | "done";
14
+
15
+ /**
16
+ * "Run now": a button, then a confirm step that says what will start and, when the record's input names
17
+ * placeholders for an item, asks for the input of this run. Starting costs money (the procedure calls a model),
18
+ * so nothing starts on the first click. `initialPhase` and `initialMessage` exist so every step renders from a test.
19
+ */
20
+ export function RunNowControl({
21
+ record,
22
+ demo = false,
23
+ onRun,
24
+ initialPhase = "idle",
25
+ initialMessage = null,
26
+ }: {
27
+ record: Pick<AutomationRecord, "id" | "title" | "procedureKey" | "runNow" | "limits">;
28
+ demo?: boolean;
29
+ onRun?: (automationId: string, input: Record<string, unknown> | null) => Promise<RunNowResult>;
30
+ initialPhase?: RunNowPhase;
31
+ initialMessage?: string | null;
32
+ }) {
33
+ const [phase, setPhase] = useState<RunNowPhase>(initialPhase);
34
+ const [message, setMessage] = useState<string | null>(initialMessage);
35
+ const [text, setText] = useState(record.runNow.needsInput ? inputTemplateText(record.runNow.inputNames) : "");
36
+ const [problem, setProblem] = useState<string | null>(null);
37
+
38
+ if (!record.runNow.available) {
39
+ return (
40
+ <p className="news-desk-delegation-note" data-automation-run-now-unavailable>
41
+ Run now is not available: {record.runNow.reason === "disabled" ? "the automation is disabled. Enable it first." : (record.runNow.reason ?? "not supported")}.
42
+ </p>
43
+ );
44
+ }
45
+
46
+ async function confirm() {
47
+ const parsed = parseRunInput(text);
48
+ if (!parsed.ok) {
49
+ setProblem(parsed.message);
50
+ return;
51
+ }
52
+ if (record.runNow.needsInput && parsed.value === null) {
53
+ setProblem("This automation's input names an item, so give the input for this run.");
54
+ return;
55
+ }
56
+ setProblem(null);
57
+ setPhase("running");
58
+ try {
59
+ const result = onRun ? await onRun(record.id, parsed.value) : ({ state: "unavailable" } as RunNowResult);
60
+ setMessage(runNowMessage(result));
61
+ } catch {
62
+ setMessage(runNowMessage({ state: "unavailable" }));
63
+ }
64
+ setPhase("done");
65
+ }
66
+
67
+ return (
68
+ <div data-automation-run-now={record.id} data-run-now-phase={phase}>
69
+ {phase === "idle" || phase === "done" ? (
70
+ <button type="button" data-automation-run-now-start onClick={() => { setMessage(null); setPhase("confirming"); }}>
71
+ Run now
72
+ </button>
73
+ ) : null}
74
+ {phase === "confirming" || phase === "running" ? (
75
+ <div role="group" aria-label={`Confirm Run now for ${record.title}`} data-automation-run-now-confirm>
76
+ <p>
77
+ Run <strong>{record.procedureKey}</strong> now? It calls a model, which costs money, and it counts against the
78
+ limit ({atATimeText(record.limits)}). The run is linked to this automation.
79
+ </p>
80
+ {demo ? <p className="news-desk-delegation-note">This is the demo, so nothing will start.</p> : null}
81
+ <label>
82
+ Input for this run{record.runNow.needsInput ? " (required)" : " (optional; blank uses the record's input)"}
83
+ <textarea rows={4} value={text} onChange={(event) => setText(event.target.value)} disabled={phase === "running"} data-automation-run-now-input />
84
+ </label>
85
+ {problem ? <p role="alert" data-automation-run-now-problem>{problem}</p> : null}
86
+ <button type="button" onClick={() => void confirm()} disabled={phase === "running" || demo} data-automation-run-now-confirm-button>
87
+ {phase === "running" ? "Starting…" : "Confirm: start the run"}
88
+ </button>{" "}
89
+ <button type="button" onClick={() => { setPhase("idle"); setProblem(null); }} disabled={phase === "running"} data-automation-run-now-cancel>
90
+ Cancel
91
+ </button>
92
+ </div>
93
+ ) : null}
94
+ {message ? <p role="status" data-automation-run-now-message>{message}</p> : null}
95
+ </div>
96
+ );
97
+ }