@anthusai/papyrus 2.0.0-next.41 → 2.0.0-next.43

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 (52) hide show
  1. package/amplify/functions/assignment-action/data-access.js +2 -0
  2. package/amplify/functions/assignment-action/data-access.ts +2 -0
  3. package/amplify/functions/content-actions/handler.py +1 -1
  4. package/amplify/functions/shared/editorial-context.ts +59 -6
  5. package/amplify/plugins/newsletter/config.js +195 -0
  6. package/amplify/plugins/newsletter/config.ts +240 -0
  7. package/amplify/plugins/newsletter/function/handler.py +6 -0
  8. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  9. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  10. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  11. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  12. package/amplify/plugins/newsletter/papyrus.plugin.js +246 -0
  13. package/amplify/plugins/newsletter/papyrus.plugin.ts +291 -0
  14. package/amplify/plugins/newsletter/schema.js +37 -0
  15. package/amplify/plugins/newsletter/schema.ts +42 -0
  16. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  17. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  18. package/amplify/plugins/newsletter/sending.js +139 -0
  19. package/amplify/plugins/newsletter/sending.ts +185 -0
  20. package/amplify/plugins/reader-steering/config.js +55 -3
  21. package/amplify/plugins/reader-steering/config.ts +98 -3
  22. package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
  23. package/amplify/plugins/reader-steering/function/handler.py +829 -10
  24. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  25. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  26. package/amplify/plugins/reader-steering/function/resource.js +1 -1
  27. package/amplify/plugins/reader-steering/function/resource.ts +1 -1
  28. package/amplify/plugins/reader-steering/papyrus.plugin.js +78 -6
  29. package/amplify/plugins/reader-steering/papyrus.plugin.ts +85 -6
  30. package/amplify/plugins/reader-steering/schema.js +101 -1
  31. package/amplify/plugins/reader-steering/schema.ts +101 -1
  32. package/amplify/plugins/registry.js +2 -0
  33. package/amplify/plugins/registry.ts +2 -0
  34. package/amplify/plugins/types.ts +2 -0
  35. package/app/api/newsletter/confirm/route.ts +13 -0
  36. package/app/api/newsletter/preferences/route.ts +13 -0
  37. package/app/api/newsletter/subscribe/route.ts +9 -0
  38. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  39. package/app/api/reader/comment/delete/route.ts +9 -0
  40. package/app/api/reader/comment/edit/route.ts +9 -0
  41. package/app/api/reader/comment/route.ts +9 -0
  42. package/app/api/reader/report/route.ts +9 -0
  43. package/app/api/reader/submit/route.ts +9 -0
  44. package/components/moderation-panel.tsx +248 -0
  45. package/components/topic-steering-workspace.tsx +19 -1
  46. package/lib/editorial-checks.ts +3 -1
  47. package/lib/newsletter-api.ts +391 -0
  48. package/lib/newsletter-pages.ts +264 -0
  49. package/lib/reader-api.ts +7 -5
  50. package/lib/reader-moderation.ts +220 -0
  51. package/package.json +14 -1
  52. package/routes.manifest.json +103 -1
@@ -15,14 +15,16 @@ export type { ReaderSteeringConfig };
15
15
  export const READER_STEERING_OUTPUT_KEY = "readerSteering";
16
16
  export const SNAPSHOT_SCHEDULE_PREFIX = "papyrus-reader-snapshot-";
17
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";
18
20
 
19
21
  export const readerSteeringManifest: PluginManifest = {
20
22
  name: "reader-steering",
21
23
  contract: 1,
22
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote"],
23
- mutations: [],
24
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
25
+ mutations: ["moderateReader"],
24
26
  guest: { mutations: [] },
25
- newsroomPanels: [],
27
+ newsroomPanels: [{ id: "moderation", label: "Moderation" }],
26
28
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
27
29
  };
28
30
 
@@ -31,6 +33,9 @@ export function readerActionsSettings(config: ReaderSteeringConfig, allowInterim
31
33
  return {
32
34
  dimensions: config.dimensions,
33
35
  limits: config.limits,
36
+ submissions: config.submissions,
37
+ comments: config.comments,
38
+ moderation: config.moderation,
34
39
  reservedHandles: config.reservedHandles,
35
40
  allowInterimSubjects,
36
41
  snapshotDebounceSeconds: 60,
@@ -41,6 +46,7 @@ export function readerActionsSettings(config: ReaderSteeringConfig, allowInterim
41
46
  export function readerSnapshotSettings(config: ReaderSteeringConfig) {
42
47
  return {
43
48
  dimensions: config.dimensions,
49
+ submissions: config.submissions,
44
50
  karma: config.karma,
45
51
  ranking: config.ranking,
46
52
  hypeMinimum: config.snapshot.hypeMinimum,
@@ -53,12 +59,66 @@ export function interimSubjectsAllowedAtSynth(environment: Record<string, string
53
59
  return ["development", "test"].includes((environment.SITE_ENV ?? "").trim()) && environment.NODE_ENV !== "production";
54
60
  }
55
61
 
62
+ /**
63
+ * The scheduled run polls Prospectus through the CMS route /api/reader/retired (docs/reader-steering.md), so it needs the
64
+ * site origin and the poll secret parameter that the app-shell stack generates (`cms.readerRetirementPoll`); both reach the
65
+ * backend build as branch variables. It also deletes and re-keys rows, and keeps its cursor in the rate table.
66
+ */
67
+ function wireRetirementPoll(readerLambda: LambdaFunction, contentActions: LambdaFunction, tables: Record<string, ITable>) {
68
+ const origin = (process.env.PAPYRUS_SITE_ORIGIN ?? "").trim();
69
+ const parameterName = (process.env[READER_POLL_SECRET_PARAMETER_ENV] ?? "").trim();
70
+ if (!origin.startsWith("https://") || !parameterName.startsWith("/")) {
71
+ throw new Error(
72
+ "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.",
73
+ );
74
+ }
75
+ const rateTable = readerLambda.node.scope?.node.tryFindChild("PapyrusReaderRateWindows") as ITable | undefined;
76
+ if (!rateTable) throw new Error("reader-steering retirementPoll could not find the reader-actions rate table to keep its cursor in");
77
+ tables.ReaderVote.grantReadWriteData(contentActions);
78
+ tables.ReaderHandle.grantReadWriteData(contentActions);
79
+ rateTable.grantReadWriteData(contentActions);
80
+ contentActions.addEnvironment("PAPYRUS_READER_RATE_TABLE", rateTable.tableName);
81
+ contentActions.addEnvironment("PAPYRUS_READER_POLL_URL", origin);
82
+ contentActions.addEnvironment(READER_POLL_SECRET_PARAMETER_ENV, parameterName);
83
+ const stack = Stack.of(contentActions);
84
+ contentActions.addToRolePolicy(
85
+ new PolicyStatement({
86
+ effect: Effect.ALLOW,
87
+ actions: ["ssm:GetParameter"],
88
+ resources: [`arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${parameterName}`],
89
+ }),
90
+ );
91
+ }
92
+
93
+ /**
94
+ * AI moderation (`moderation.mode: ai`). The debounced run becomes a run of reader-actions itself (a one-off schedule that
95
+ * invokes this function), which judges the new items and then asks content-actions for the snapshot. So the scheduler role may
96
+ * invoke reader-actions, reader-actions may invoke content-actions (its ARN is already in the environment) and may read exactly
97
+ * one SSM SecureString: the API key, which the plugin never creates and never receives.
98
+ */
99
+ function wireAiModeration(readerLambda: LambdaFunction, contentActions: LambdaFunction, schedulerRole: Role, apiKeyParameter: string) {
100
+ const stack = Stack.of(readerLambda);
101
+ readerLambda.grantInvoke(schedulerRole);
102
+ contentActions.grantInvoke(readerLambda);
103
+ readerLambda.addToRolePolicy(
104
+ new PolicyStatement({
105
+ effect: Effect.ALLOW,
106
+ actions: ["ssm:GetParameter"],
107
+ resources: [`arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${apiKeyParameter}`],
108
+ }),
109
+ );
110
+ }
111
+
56
112
  export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerActions: typeof readerActions }> = {
57
113
  manifest: readerSteeringManifest,
58
114
  resolveConfig: resolveReaderSteeringConfig,
59
115
  functions: () => ({ readerActions }),
60
116
  schemaDefinitions: readerSteeringSchemaDefinitions,
61
- publicSettings: (config) => ({ contract: readerSteeringManifest.contract, dimensions: config.dimensions }),
117
+ publicSettings: (config) => ({
118
+ contract: readerSteeringManifest.contract,
119
+ dimensions: config.dimensions,
120
+ moderation: { mode: config.moderation.mode },
121
+ }),
62
122
  wire(backend, config, context: PluginWiringContext) {
63
123
  const lambda = backend.readerActions.resources.lambda as LambdaFunction;
64
124
  const tables = backend.data.resources.tables as Record<string, ITable>;
@@ -68,15 +128,25 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
68
128
  ["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
69
129
  ["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
70
130
  ["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
131
+ ["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
132
+ ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
133
+ ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
134
+ ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
71
135
  ] as const) {
72
136
  tables[model].grantReadWriteData(lambda);
73
137
  lambda.addEnvironment(variable, tables[model].tableName);
74
138
  contentActions.addEnvironment(variable, tables[model].tableName);
75
139
  }
140
+ // Promoting a submission writes one draft Item (a version 1 draft; nothing is published by this function).
141
+ tables.Item.grantWriteData(lambda);
142
+ lambda.addEnvironment("PAPYRUS_READER_ITEM_TABLE", tables.Item.tableName);
76
143
  tables.PublishedItem.grantReadData(lambda);
77
144
  lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
78
145
  tables.ReaderVote.grantReadData(contentActions);
79
146
  tables.ReaderProfile.grantReadWriteData(contentActions);
147
+ // The snapshot reads submissions, and the review step writes their signals and moderation state.
148
+ tables.ReaderSubmission.grantReadWriteData(contentActions);
149
+ tables.ReaderComment.grantReadWriteData(contentActions);
80
150
 
81
151
  lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
82
152
  if (config.reservedConcurrency !== undefined) {
@@ -104,16 +174,25 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
104
174
  lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
105
175
  contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
106
176
 
177
+ if (config.moderation.ai) wireAiModeration(lambda, contentActions, schedulerRole, config.moderation.ai.apiKeyParameter);
178
+
107
179
  lambda.addPermission("InvokeFromCmsCompute", {
108
180
  principal: new ArnPrincipal(config.computeRoleArn),
109
181
  action: "lambda:InvokeFunction",
110
182
  });
111
183
 
184
+ if (config.retirementPoll) wireRetirementPoll(lambda, contentActions, tables);
185
+
112
186
  if (config.snapshot.schedule) {
113
187
  new Rule(context.createStack("reader-steering-schedule"), "ReaderSnapshotSchedule", {
114
- description: "Rebuilds the reader snapshot (time decay, Rising) and polls nothing else.",
188
+ description: "Rebuilds the reader snapshot (time decay, Rising) and, when enabled, applies Prospectus subject retirements.",
115
189
  schedule: Schedule.rate(Duration.hours(config.snapshot.intervalHours)),
116
- targets: [new LambdaFunctionTarget(contentActions, { event: RuleTargetInput.fromObject(READER_SNAPSHOT_TASK), retryAttempts: 0 })],
190
+ targets: [
191
+ new LambdaFunctionTarget(contentActions, {
192
+ event: RuleTargetInput.fromObject(config.retirementPoll ? READER_SCHEDULED_SNAPSHOT_TASK : READER_SNAPSHOT_TASK),
193
+ retryAttempts: 0,
194
+ }),
195
+ ],
117
196
  });
118
197
  }
119
198
 
@@ -1,8 +1,10 @@
1
1
  import { a } from "@aws-amplify/backend";
2
+ import { readerActions } from "./function/resource.js";
2
3
  /**
3
4
  * Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
4
5
  * tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
5
- * the siteSubject for a profile, the lowercase handle for a claim and `subjectKey#readerId#dimension` for a vote.
6
+ * the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote and
7
+ * `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
6
8
  */
7
9
  const staffOnly = (allow) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
8
10
  export function readerSteeringSchemaDefinitions() {
@@ -17,6 +19,7 @@ export function readerSteeringSchemaDefinitions() {
17
19
  karma: a.integer(),
18
20
  })
19
21
  .disableOperations(["subscriptions"])
22
+ .secondaryIndexes((index) => [index("readerId").name("profilesByReader").queryField("listReaderProfilesByReader")])
20
23
  .authorization(staffOnly),
21
24
  ReaderHandle: a
22
25
  .model({
@@ -36,5 +39,102 @@ export function readerSteeringSchemaDefinitions() {
36
39
  })
37
40
  .disableOperations(["subscriptions"])
38
41
  .authorization(staffOnly),
42
+ ReaderComment: a
43
+ .model({
44
+ id: a.id().required(),
45
+ subjectKey: a.string().required(),
46
+ readerId: a.string().required(),
47
+ body: a.string().required(),
48
+ parentId: a.string(),
49
+ ratingContext: a.json(),
50
+ state: a.string().required(),
51
+ signals: a.json(),
52
+ moderation: a.string().required(),
53
+ reportCount: a.integer(),
54
+ reviewedAt: a.datetime(),
55
+ editedAt: a.datetime(),
56
+ deletedAt: a.datetime(),
57
+ createdAt: a.datetime().required(),
58
+ })
59
+ .disableOperations(["subscriptions"])
60
+ .secondaryIndexes((index) => [index("moderation").sortKeys(["createdAt"]).name("commentsByModeration").queryField("listReaderCommentsByModerationAndCreatedAt")])
61
+ .authorization(staffOnly),
62
+ ReaderSubmission: a
63
+ .model({
64
+ id: a.id().required(),
65
+ canonicalUrl: a.string().required(),
66
+ domain: a.string().required(),
67
+ title: a.string().required(),
68
+ kind: a.string().required(),
69
+ note: a.string(),
70
+ readerId: a.string().required(),
71
+ state: a.string().required(),
72
+ similar: a.string().array(),
73
+ signals: a.json(),
74
+ extractionState: a.string().required(),
75
+ itemId: a.string(),
76
+ moderation: a.string().required(),
77
+ reportCount: a.integer(),
78
+ reviewedAt: a.datetime(),
79
+ createdAt: a.datetime().required(),
80
+ })
81
+ .disableOperations(["subscriptions"])
82
+ .secondaryIndexes((index) => [
83
+ index("domain").sortKeys(["createdAt"]).name("submissionsByDomain").queryField("listReaderSubmissionsByDomainAndCreatedAt"),
84
+ index("moderation").sortKeys(["createdAt"]).name("submissionsByModeration").queryField("listReaderSubmissionsByModerationAndCreatedAt"),
85
+ ])
86
+ .authorization(staffOnly),
87
+ ReaderReport: a
88
+ .model({
89
+ id: a.id().required(),
90
+ targetType: a.string().required(),
91
+ target: a.string().required(),
92
+ reporterReaderId: a.string().required(),
93
+ reason: a.string().required(),
94
+ state: a.string().required(),
95
+ closedAt: a.datetime(),
96
+ createdAt: a.datetime().required(),
97
+ })
98
+ .disableOperations(["subscriptions"])
99
+ .secondaryIndexes((index) => [index("target").sortKeys(["createdAt"]).name("reportsByTarget").queryField("listReaderReportsByTargetAndCreatedAt")])
100
+ .authorization(staffOnly),
101
+ ModerationAction: a
102
+ .model({
103
+ id: a.id().required(),
104
+ by: a.string().required(),
105
+ actorSub: a.string().required(),
106
+ action: a.string().required(),
107
+ targetType: a.string().required(),
108
+ target: a.string().required(),
109
+ reason: a.string(),
110
+ fromState: a.string().required(),
111
+ toState: a.string().required(),
112
+ fromModeration: a.string(),
113
+ toModeration: a.string(),
114
+ undoOf: a.string(),
115
+ undoneBy: a.string(),
116
+ at: a.datetime().required(),
117
+ })
118
+ .disableOperations(["subscriptions"])
119
+ .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
120
+ .authorization(staffOnly),
121
+ ModerationReceipt: a.customType({
122
+ status: a.string().required(),
123
+ id: a.string(),
124
+ error: a.string(),
125
+ message: a.string(),
126
+ }),
127
+ moderateReader: a
128
+ .mutation()
129
+ .arguments({
130
+ action: a.string().required(),
131
+ targetType: a.string().required(),
132
+ target: a.string().required(),
133
+ reason: a.string(),
134
+ undoOf: a.string(),
135
+ })
136
+ .returns(a.ref("ModerationReceipt"))
137
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
138
+ .handler(a.handler.function(readerActions)),
39
139
  };
40
140
  }
@@ -1,9 +1,11 @@
1
1
  import { a } from "@aws-amplify/backend";
2
+ import { readerActions } from "./function/resource";
2
3
 
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:
6
- * the siteSubject for a profile, the lowercase handle for a claim and `subjectKey#readerId#dimension` for a vote.
7
+ * the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote and
8
+ * `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
7
9
  */
8
10
  const staffOnly = (allow: any) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
9
11
 
@@ -19,6 +21,7 @@ export function readerSteeringSchemaDefinitions() {
19
21
  karma: a.integer(),
20
22
  })
21
23
  .disableOperations(["subscriptions"])
24
+ .secondaryIndexes((index) => [index("readerId").name("profilesByReader").queryField("listReaderProfilesByReader")])
22
25
  .authorization(staffOnly),
23
26
  ReaderHandle: a
24
27
  .model({
@@ -38,5 +41,102 @@ export function readerSteeringSchemaDefinitions() {
38
41
  })
39
42
  .disableOperations(["subscriptions"])
40
43
  .authorization(staffOnly),
44
+ ReaderComment: a
45
+ .model({
46
+ id: a.id().required(),
47
+ subjectKey: a.string().required(),
48
+ readerId: a.string().required(),
49
+ body: a.string().required(),
50
+ parentId: a.string(),
51
+ ratingContext: a.json(),
52
+ state: a.string().required(),
53
+ signals: a.json(),
54
+ moderation: a.string().required(),
55
+ reportCount: a.integer(),
56
+ reviewedAt: a.datetime(),
57
+ editedAt: a.datetime(),
58
+ deletedAt: a.datetime(),
59
+ createdAt: a.datetime().required(),
60
+ })
61
+ .disableOperations(["subscriptions"])
62
+ .secondaryIndexes((index) => [index("moderation").sortKeys(["createdAt"]).name("commentsByModeration").queryField("listReaderCommentsByModerationAndCreatedAt")])
63
+ .authorization(staffOnly),
64
+ ReaderSubmission: a
65
+ .model({
66
+ id: a.id().required(),
67
+ canonicalUrl: a.string().required(),
68
+ domain: a.string().required(),
69
+ title: a.string().required(),
70
+ kind: a.string().required(),
71
+ note: a.string(),
72
+ readerId: a.string().required(),
73
+ state: a.string().required(),
74
+ similar: a.string().array(),
75
+ signals: a.json(),
76
+ extractionState: a.string().required(),
77
+ itemId: a.string(),
78
+ moderation: a.string().required(),
79
+ reportCount: a.integer(),
80
+ reviewedAt: a.datetime(),
81
+ createdAt: a.datetime().required(),
82
+ })
83
+ .disableOperations(["subscriptions"])
84
+ .secondaryIndexes((index) => [
85
+ index("domain").sortKeys(["createdAt"]).name("submissionsByDomain").queryField("listReaderSubmissionsByDomainAndCreatedAt"),
86
+ index("moderation").sortKeys(["createdAt"]).name("submissionsByModeration").queryField("listReaderSubmissionsByModerationAndCreatedAt"),
87
+ ])
88
+ .authorization(staffOnly),
89
+ ReaderReport: a
90
+ .model({
91
+ id: a.id().required(),
92
+ targetType: a.string().required(),
93
+ target: a.string().required(),
94
+ reporterReaderId: a.string().required(),
95
+ reason: a.string().required(),
96
+ state: a.string().required(),
97
+ closedAt: a.datetime(),
98
+ createdAt: a.datetime().required(),
99
+ })
100
+ .disableOperations(["subscriptions"])
101
+ .secondaryIndexes((index) => [index("target").sortKeys(["createdAt"]).name("reportsByTarget").queryField("listReaderReportsByTargetAndCreatedAt")])
102
+ .authorization(staffOnly),
103
+ ModerationAction: a
104
+ .model({
105
+ id: a.id().required(),
106
+ by: a.string().required(),
107
+ actorSub: a.string().required(),
108
+ action: a.string().required(),
109
+ targetType: a.string().required(),
110
+ target: a.string().required(),
111
+ reason: a.string(),
112
+ fromState: a.string().required(),
113
+ toState: a.string().required(),
114
+ fromModeration: a.string(),
115
+ toModeration: a.string(),
116
+ undoOf: a.string(),
117
+ undoneBy: a.string(),
118
+ at: a.datetime().required(),
119
+ })
120
+ .disableOperations(["subscriptions"])
121
+ .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
122
+ .authorization(staffOnly),
123
+ ModerationReceipt: a.customType({
124
+ status: a.string().required(),
125
+ id: a.string(),
126
+ error: a.string(),
127
+ message: a.string(),
128
+ }),
129
+ moderateReader: a
130
+ .mutation()
131
+ .arguments({
132
+ action: a.string().required(),
133
+ targetType: a.string().required(),
134
+ target: a.string().required(),
135
+ reason: a.string(),
136
+ undoOf: a.string(),
137
+ })
138
+ .returns(a.ref("ModerationReceipt"))
139
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
140
+ .handler(a.handler.function(readerActions)),
41
141
  };
42
142
  }
@@ -1,7 +1,9 @@
1
+ import { newsletterPlugin } from "./newsletter/papyrus.plugin.js";
1
2
  import { readerSteeringPlugin } from "./reader-steering/papyrus.plugin.js";
2
3
  import { signupPlugin } from "./signup/papyrus.plugin.js";
3
4
  /** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
4
5
  export const KNOWN_PLUGINS = {
6
+ newsletter: newsletterPlugin,
5
7
  "reader-steering": readerSteeringPlugin,
6
8
  signup: signupPlugin,
7
9
  };
@@ -1,9 +1,11 @@
1
1
  import type { PapyrusPlugin, PluginRequest } from "./types";
2
+ import { newsletterPlugin } from "./newsletter/papyrus.plugin";
2
3
  import { readerSteeringPlugin } from "./reader-steering/papyrus.plugin";
3
4
  import { signupPlugin } from "./signup/papyrus.plugin";
4
5
 
5
6
  /** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
6
7
  export const KNOWN_PLUGINS: Record<string, PapyrusPlugin<any, any>> = {
8
+ newsletter: newsletterPlugin,
7
9
  "reader-steering": readerSteeringPlugin,
8
10
  signup: signupPlugin,
9
11
  };
@@ -10,6 +10,8 @@ export type PluginManifest = {
10
10
  /** Public contract version. Bump it when a mutation input, a model field, an element name or an attribute changes. No compatibility shims. */
11
11
  contract: number;
12
12
  models: string[];
13
+ /** Custom queries it adds (operations that read). */
14
+ queries?: string[];
13
15
  mutations: string[];
14
16
  /** What an anonymous visitor may call. Everything else is closed to guests. */
15
17
  guest: { mutations: string[] };
@@ -0,0 +1,13 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleNewsletterConfirmPage, handleNewsletterConfirmPost } from "../../../../lib/newsletter-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleNewsletterConfirmPage(request);
9
+ }
10
+
11
+ export function POST(request: NextRequest) {
12
+ return handleNewsletterConfirmPost(request);
13
+ }
@@ -0,0 +1,13 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleNewsletterPreferences } from "../../../../lib/newsletter-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleNewsletterPreferences(request);
9
+ }
10
+
11
+ export function POST(request: NextRequest) {
12
+ return handleNewsletterPreferences(request);
13
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleNewsletterSubscribe } from "../../../../lib/newsletter-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleNewsletterSubscribe(request);
9
+ }
@@ -0,0 +1,13 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleNewsletterUnsubscribe } from "../../../../lib/newsletter-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleNewsletterUnsubscribe(request);
9
+ }
10
+
11
+ export function POST(request: NextRequest) {
12
+ return handleNewsletterUnsubscribe(request);
13
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "comment-delete");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "comment-edit");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "comment");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "report");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "submit");
9
+ }