@anthusai/papyrus 2.0.0-next.66 → 2.0.0-next.68

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.
@@ -5,8 +5,9 @@ export const MODERATION_MODES = ["off", "human", "ai"];
5
5
  export const AI_KEY_PARAMETER = /^\/papyrus\/[a-z0-9][a-z0-9-]{0,62}\/openai-api-key$/;
6
6
  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
+ export const SUBJECT_TYPES = ["item", "submission", "story", "proposal", "concept"];
8
9
  export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
9
- const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation"]);
10
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
10
11
  function object(raw, label, known) {
11
12
  if (raw === undefined)
12
13
  return {};
@@ -111,6 +112,11 @@ export function resolveReaderSteeringConfig(request) {
111
112
  throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
112
113
  }
113
114
  }
115
+ const subjects = object(raw.subjects, "subjects", ["types"]);
116
+ const subjectTypes = subjects.types === undefined ? ["item", "submission"] : subjects.types;
117
+ if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => SUBJECT_TYPES.includes(type))) {
118
+ throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
119
+ }
114
120
  const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
115
121
  const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
116
122
  const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
@@ -154,6 +160,7 @@ export function resolveReaderSteeringConfig(request) {
154
160
  itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
155
161
  section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
156
162
  },
163
+ subjects: { types: [...new Set(subjectTypes)] },
157
164
  moderation: {
158
165
  mode: mode,
159
166
  preModeration,
@@ -7,6 +7,7 @@ export const MODERATION_MODES = ["off", "human", "ai"] as const;
7
7
  export const AI_KEY_PARAMETER = /^\/papyrus\/[a-z0-9][a-z0-9-]{0,62}\/openai-api-key$/;
8
8
  export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
9
9
  export const SUBMISSION_KIND_NAME = /^[a-z][a-z0-9-]{0,23}$/;
10
+ export const SUBJECT_TYPES = ["item", "submission", "story", "proposal", "concept"] as const;
10
11
  export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
11
12
 
12
13
  export type ReaderSteeringConfig = {
@@ -63,6 +64,11 @@ export type ReaderSteeringConfig = {
63
64
  /** The section of that draft. */
64
65
  section: string;
65
66
  };
67
+ /**
68
+ * The kinds of subject a signed-in reader may vote on and flag, by `{type, id}`. Default: items and submissions, which is
69
+ * what readers could vote on before subject types existed. `story`, `proposal` and `concept` are opt-in.
70
+ */
71
+ subjects: { types: (typeof SUBJECT_TYPES)[number][] };
66
72
  moderation: {
67
73
  /**
68
74
  * `human`: new submissions and comments are public at once and wait in the Administration queue (the default for a pilot).
@@ -109,7 +115,7 @@ export type ReaderSteeringConfig = {
109
115
  retirementPoll: boolean;
110
116
  };
111
117
 
112
- const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation"]);
118
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
113
119
 
114
120
  function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
115
121
  if (raw === undefined) return {};
@@ -206,6 +212,11 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
206
212
  throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
207
213
  }
208
214
  }
215
+ const subjects = object(raw.subjects, "subjects", ["types"]);
216
+ const subjectTypes = subjects.types === undefined ? ["item", "submission"] : subjects.types;
217
+ if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => (SUBJECT_TYPES as readonly unknown[]).includes(type))) {
218
+ throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
219
+ }
209
220
  const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
210
221
  const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
211
222
  const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
@@ -249,6 +260,7 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
249
260
  itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
250
261
  section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
251
262
  },
263
+ subjects: { types: [...new Set(subjectTypes)] as (typeof SUBJECT_TYPES)[number][] },
252
264
  moderation: {
253
265
  mode: mode as (typeof MODERATION_MODES)[number],
254
266
  preModeration,
@@ -54,6 +54,21 @@ ACTION_RATING = "rating"
54
54
  ACTION_SUBMIT = "submit"
55
55
  ACTION_COMMENT = "comment"
56
56
  ACTION_REPORT = "report"
57
+ ACTION_FLAG = "flag"
58
+ SUBJECT_TYPE_ITEM = "item"
59
+ SUBJECT_TYPE_SUBMISSION = "submission"
60
+ SUBJECT_TYPES = (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION, "story", "proposal", "concept")
61
+ DEFAULT_SUBJECT_TYPES = (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION)
62
+ SUBJECT_ID = re.compile(r"[A-Za-z0-9._:-]{1,190}")
63
+ VOTE_DIMENSION = "up"
64
+ VOTE_PAYLOAD_KEYS = frozenset({"subject", "value"})
65
+ FLAG_WRONG = "wrong"
66
+ FLAG_BELONGS_UNDER = "belongs_under"
67
+ FLAG_BETTER_NAME = "better_name"
68
+ FLAG_KINDS = (FLAG_WRONG, FLAG_BELONGS_UNDER, FLAG_BETTER_NAME)
69
+ FLAG_PAYLOAD_KEYS = frozenset({"subject", "kind", "reason", "target", "name"})
70
+ MAXIMUM_FLAG_REASON = 500
71
+ MAXIMUM_SUGGESTED_NAME = 120
57
72
  RATE_BUCKET_REPORT = "report"
58
73
  REPORT_ID_PREFIX = "rep-"
59
74
  MAXIMUM_REPORT_REASON = 500
@@ -127,6 +142,7 @@ class ReaderSettings:
127
142
  promote_section: str = "unsectioned"
128
143
  ai_model: str = ai_moderation.DEFAULT_MODEL
129
144
  ai_key_parameter: str = ""
145
+ subject_types: tuple[str, ...] = DEFAULT_SUBJECT_TYPES
130
146
 
131
147
 
132
148
  def settings_from_environment(environment: dict[str, str] | None = None) -> ReaderSettings:
@@ -154,6 +170,7 @@ def settings_from_environment(environment: dict[str, str] | None = None) -> Read
154
170
  reports_per_day=int((raw.get("moderation") or {}).get("reportsPerDay", defaults.reports_per_day)),
155
171
  ai_model=str(((raw.get("moderation") or {}).get("ai") or {}).get("model", defaults.ai_model)),
156
172
  ai_key_parameter=str(((raw.get("moderation") or {}).get("ai") or {}).get("apiKeyParameter", defaults.ai_key_parameter)),
173
+ subject_types=tuple((raw.get("subjects") or {}).get("types") or defaults.subject_types),
157
174
  promote_item_type=str((raw.get("submissions") or {}).get("itemType", defaults.promote_item_type)),
158
175
  promote_section=str((raw.get("submissions") or {}).get("section", defaults.promote_section)),
159
176
  )
@@ -172,6 +189,8 @@ class ReaderStore(Protocol):
172
189
 
173
190
  def delete_vote(self, vote_id: str) -> None: ...
174
191
 
192
+ def put_flag(self, flag: dict[str, Any], now_iso: str) -> None: ...
193
+
175
194
  def is_published(self, subject_key: str) -> bool: ...
176
195
 
177
196
  def get_submission(self, submission_id: str) -> dict[str, Any] | None: ...
@@ -229,6 +248,30 @@ def vote_id(subject_key: str, reader_id: str, dimension: str) -> str:
229
248
  return f"{subject_key}#{reader_id}#{dimension}"
230
249
 
231
250
 
251
+ def subject_key(subject_type: str, subject_id: str) -> str:
252
+ """The key a vote is stored under: items and submissions keep their bare id (so every vote written before subject types stays valid); other types are prefixed."""
253
+ return subject_id if subject_type in (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION) else f"{subject_type}:{subject_id}"
254
+
255
+
256
+ def legacy_subject_type(key: str) -> str:
257
+ """The type of a bare subject key: a submission id, otherwise an item."""
258
+ return SUBJECT_TYPE_SUBMISSION if SUBMISSION_ID.fullmatch(key) else SUBJECT_TYPE_ITEM
259
+
260
+
261
+ def parse_subject(raw: Any, settings: ReaderSettings) -> tuple[tuple[str, str] | None, dict[str, Any] | None]:
262
+ """``({type, id}) -> (type, id)``, or a refusal. A bare string is a subject written before subject types: its type is read from its shape."""
263
+ if isinstance(raw, str):
264
+ raw = {"type": legacy_subject_type(raw), "id": raw}
265
+ if not isinstance(raw, dict) or set(raw) != {"type", "id"} or not isinstance(raw["type"], str) or not isinstance(raw["id"], str):
266
+ return None, refusal(422, "invalid_subject", "Send the subject as {type, id}.")
267
+ subject_type, subject_id = raw["type"], raw["id"]
268
+ if subject_type not in SUBJECT_TYPES or subject_type not in settings.subject_types:
269
+ return None, refusal(422, "unknown_subject_type", "That kind of subject cannot be voted on here.")
270
+ if not SUBJECT_ID.fullmatch(subject_id):
271
+ return None, refusal(422, "invalid_subject", "Send the id of the subject.")
272
+ return (subject_type, subject_id), None
273
+
274
+
232
275
  def valid_site_subject(value: Any, settings: ReaderSettings) -> bool:
233
276
  if not isinstance(value, str):
234
277
  return False
@@ -321,13 +364,18 @@ def public_profile(profile: dict[str, Any]) -> dict[str, Any]:
321
364
 
322
365
 
323
366
  def my_state(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], settings: ReaderSettings) -> dict[str, Any]:
324
- subjects = payload.get("subjects", [])
325
- if (
326
- not isinstance(subjects, list)
327
- or len(subjects) > MAXIMUM_SUBJECTS
328
- or not all(isinstance(subject, str) and SUBJECT_KEY.fullmatch(subject) for subject in subjects)
329
- ):
330
- return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} item keys.")
367
+ raw_subjects = payload.get("subjects", [])
368
+ if not isinstance(raw_subjects, list) or len(raw_subjects) > MAXIMUM_SUBJECTS:
369
+ return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} subjects.")
370
+ subjects: list[str] = []
371
+ for raw in raw_subjects:
372
+ if isinstance(raw, str) and SUBJECT_KEY.fullmatch(raw):
373
+ subjects.append(raw)
374
+ continue
375
+ parsed, _ = parse_subject(raw, settings) if isinstance(raw, dict) else (None, None)
376
+ if parsed is None:
377
+ return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} subject keys or {{type, id}} subjects.")
378
+ subjects.append(subject_key(*parsed))
331
379
  dimensions = [*TOGGLE_DIMENSIONS, *settings.dimensions]
332
380
  wanted = {vote_id(subject, profile["readerId"], dimension): (subject, dimension) for subject in subjects for dimension in dimensions}
333
381
  found = store.get_vote_values(list(wanted))
@@ -375,8 +423,11 @@ def react(
375
423
  now_seconds: float,
376
424
  schedule_snapshot: Callable[[], None],
377
425
  ) -> dict[str, Any]:
378
- if not isinstance(subject, str) or not SUBJECT_KEY.fullmatch(subject):
379
- return refusal(422, "invalid_subject", "Send the key of a published item.")
426
+ parsed, refused = parse_subject(subject, settings)
427
+ if refused:
428
+ return refused
429
+ subject_type, subject_id = parsed
430
+ subject = subject_key(subject_type, subject_id)
380
431
  if not isinstance(dimension, str) or dimension not in allowed_dimensions:
381
432
  return refusal(422, "invalid_dimension", "That dimension is not available.")
382
433
  if profile.get("state") == STATE_BANNED:
@@ -386,7 +437,7 @@ def react(
386
437
  limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
387
438
  if limited:
388
439
  return limited
389
- if not subject_is_open(store, subject):
440
+ if not subject_is_open(store, subject_type, subject_id):
390
441
  return refusal(404, "unknown_subject", "That item is not published.")
391
442
  stored_id = vote_id(subject, profile["readerId"], dimension)
392
443
  if value is None:
@@ -396,6 +447,7 @@ def react(
396
447
  {
397
448
  "id": stored_id,
398
449
  "subjectKey": subject,
450
+ "subjectType": subject_type,
399
451
  "readerId": profile["readerId"],
400
452
  "dimension": dimension,
401
453
  "value": value,
@@ -407,12 +459,17 @@ def react(
407
459
  return reply(200, {"subject": subject, "dimension": dimension, "value": value})
408
460
 
409
461
 
410
- def subject_is_open(store: ReaderStore, subject: str) -> bool:
411
- """A published item, or a submission that is visible (pending or promoted); hidden submissions cannot be voted on."""
412
- if SUBMISSION_ID.fullmatch(subject):
413
- submission = store.get_submission(subject)
462
+ def subject_is_open(store: ReaderStore, subject_type: str, subject_id: str) -> bool:
463
+ """A published item, or a submission that is visible (pending or promoted); hidden submissions cannot be voted on.
464
+
465
+ The other subject types have no record in this plugin (stories, proposals and concepts live in the site's own data), so any well-formed id is open.
466
+ """
467
+ if subject_type == SUBJECT_TYPE_SUBMISSION:
468
+ submission = store.get_submission(subject_id)
414
469
  return submission is not None and submission.get("state") in VISIBLE_SUBMISSION_STATES
415
- return store.is_published(subject)
470
+ if subject_type == SUBJECT_TYPE_ITEM:
471
+ return store.is_published(subject_id)
472
+ return True
416
473
 
417
474
 
418
475
  def clean_text(value: Any, label: str, maximum: int, *, required: bool) -> tuple[str | None, dict[str, Any] | None]:
@@ -606,7 +663,7 @@ def comment_action(
606
663
  limited = within_rate_limit(store, profile, RATE_BUCKET_COMMENT, new_account_limit(profile, settings, now_seconds, settings.comments_per_hour), now_seconds)
607
664
  if limited:
608
665
  return limited
609
- if not subject_is_open(store, subject):
666
+ if not subject_is_open(store, legacy_subject_type(subject), subject):
610
667
  return refusal(404, "unknown_subject", "That item is not published.")
611
668
  if parent_id is not None:
612
669
  parent = store.get_comment(parent_id)
@@ -677,6 +734,75 @@ def report_action(
677
734
  return reply(200, {"reported": True})
678
735
 
679
736
 
737
+ def flag_action(
738
+ store: ReaderStore,
739
+ profile: dict[str, Any],
740
+ payload: dict[str, Any],
741
+ settings: ReaderSettings,
742
+ is_staff: bool,
743
+ now_seconds: float,
744
+ schedule_snapshot: Callable[[], None],
745
+ ) -> dict[str, Any]:
746
+ """A reader flags a subject as wrong, as belonging under another subject, or as needing a better name; a reason is required.
747
+
748
+ One flag per reader, subject and kind: flagging again replaces the reason. Flags are stored apart from votes and reach the
749
+ snapshot as counts only. The stored row holds the readerId, never a siteSubject.
750
+ """
751
+ if set(payload) - FLAG_PAYLOAD_KEYS:
752
+ return refusal(422, "invalid_flag", "Send subject, kind, reason, and target or name for the kind.")
753
+ kind = payload.get("kind")
754
+ if kind not in FLAG_KINDS:
755
+ return refusal(422, "invalid_flag_kind", f"Send kind as one of {', '.join(FLAG_KINDS)}.")
756
+ parsed, refused = parse_subject(payload.get("subject"), settings)
757
+ if refused:
758
+ return refused
759
+ reason = payload.get("reason")
760
+ if not isinstance(reason, str) or not reason.strip() or len(reason.strip()) > MAXIMUM_FLAG_REASON or CONTROL_CHARACTERS.search(reason):
761
+ return refusal(422, "invalid_reason", f"Give a reason in plain text of at most {MAXIMUM_FLAG_REASON} characters.")
762
+ if EMAIL_ADDRESS.search(reason):
763
+ return refusal(422, "invalid_reason", "A reason cannot contain an email address.")
764
+ target_key: str | None = None
765
+ suggested: str | None = None
766
+ if kind == FLAG_BELONGS_UNDER:
767
+ target, refused = parse_subject(payload.get("target"), settings) if isinstance(payload.get("target"), dict) else (None, True)
768
+ if refused or "name" in payload:
769
+ return refusal(422, "invalid_target", "Send target as {type, id} for belongs_under.")
770
+ target_key = subject_key(*target)
771
+ if target_key == subject_key(*parsed):
772
+ return refusal(422, "invalid_target", "A subject cannot belong under itself.")
773
+ elif kind == FLAG_BETTER_NAME:
774
+ suggested, refused = clean_text(payload.get("name"), "name", MAXIMUM_SUGGESTED_NAME, required=True)
775
+ if refused or "target" in payload:
776
+ return refused or refusal(422, "invalid_name", "Send name for better_name.")
777
+ elif "target" in payload or "name" in payload:
778
+ return refusal(422, "invalid_flag", "wrong takes only a reason.")
779
+ if profile.get("state") == STATE_LIMITED:
780
+ return refusal(403, "reader_limited", "This reader cannot flag right now.")
781
+ limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
782
+ if limited:
783
+ return limited
784
+ subject_type, subject_id = parsed
785
+ if not subject_is_open(store, subject_type, subject_id):
786
+ return refusal(404, "unknown_subject", "That subject cannot be flagged.")
787
+ key = subject_key(subject_type, subject_id)
788
+ store.put_flag(
789
+ {
790
+ "id": f"{key}#{profile['readerId']}#{kind}",
791
+ "subjectKey": key,
792
+ "subjectType": subject_type,
793
+ "readerId": profile["readerId"],
794
+ "kind": kind,
795
+ "reason": reason.strip(),
796
+ "targetKey": target_key,
797
+ "suggestedName": suggested,
798
+ "voterRole": ROLE_EDITOR if is_staff else ROLE_READER,
799
+ },
800
+ iso(now_seconds),
801
+ )
802
+ schedule_snapshot()
803
+ return reply(200, {"subject": payload["subject"], "kind": kind})
804
+
805
+
680
806
  def own_comment(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], allowed: frozenset[str]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
681
807
  if set(payload) - allowed:
682
808
  return None, refusal(422, "invalid_comment", "Send only " + ", ".join(sorted(allowed)) + ".")
@@ -740,7 +866,23 @@ def delete_comment_action(
740
866
  return reply(200, {"id": comment["id"], "state": COMMENT_DELETED})
741
867
 
742
868
 
869
+ def typed_vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
870
+ """``{subject: {type, id}, value: 1 | -1 | null}``: an up or down vote on any enabled subject type; ``null`` removes the reader's vote."""
871
+ value = payload.get("value")
872
+ if set(payload) - VOTE_PAYLOAD_KEYS or isinstance(value, bool) or value not in (1, -1, None):
873
+ return refusal(422, "invalid_vote", "Send only subject and value, and value as 1, -1 or null.")
874
+ answer = react(
875
+ store, profile, subject=payload.get("subject"), dimension=VOTE_DIMENSION, allowed_dimensions=(VOTE_DIMENSION,),
876
+ value=value, is_staff=is_staff, settings=settings, now_seconds=now_seconds, schedule_snapshot=schedule_snapshot,
877
+ )
878
+ if answer["statusCode"] == 200:
879
+ answer = reply(200, {"subject": payload["subject"], "value": value})
880
+ return answer
881
+
882
+
743
883
  def vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
884
+ if isinstance(payload.get("subject"), dict):
885
+ return typed_vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot)
744
886
  on = payload.get("on")
745
887
  if not isinstance(on, bool):
746
888
  return refusal(422, "invalid_vote", "Send on as true or false.")
@@ -794,6 +936,8 @@ def handle_event(
794
936
  return delete_comment_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
795
937
  if action == ACTION_REPORT:
796
938
  return report_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
939
+ if action == ACTION_FLAG:
940
+ return flag_action(store, profile, payload, settings, event["isStaff"], now_seconds, schedule_snapshot)
797
941
  return refusal(400, "unknown_action", "Unknown action.")
798
942
 
799
943
 
@@ -832,6 +976,7 @@ class DynamoReaderStore:
832
976
  report_table: str = "",
833
977
  action_table: str = "",
834
978
  item_table: str = "",
979
+ flag_table: str = "",
835
980
  ) -> None:
836
981
  self._resource = resource
837
982
  self._names = {
@@ -845,6 +990,7 @@ class DynamoReaderStore:
845
990
  "report": report_table,
846
991
  "action": action_table,
847
992
  "item": item_table,
993
+ "flag": flag_table,
848
994
  }
849
995
 
850
996
  def _table(self, key: str) -> Any:
@@ -914,13 +1060,14 @@ class DynamoReaderStore:
914
1060
  self._table("vote").update_item(
915
1061
  Key={"id": vote["id"]},
916
1062
  UpdateExpression=(
917
- "SET #t = :t, subjectKey = :s, readerId = :r, #d = :d, #v = :v, voterRole = :role, "
1063
+ "SET #t = :t, subjectKey = :s, subjectType = :st, readerId = :r, #d = :d, #v = :v, voterRole = :role, "
918
1064
  "createdAt = if_not_exists(createdAt, :now), updatedAt = :now"
919
1065
  ),
920
1066
  ExpressionAttributeNames={"#t": "__typename", "#d": "dimension", "#v": "value"},
921
1067
  ExpressionAttributeValues={
922
1068
  ":t": "ReaderVote",
923
1069
  ":s": vote["subjectKey"],
1070
+ ":st": vote.get("subjectType") or legacy_subject_type(vote["subjectKey"]),
924
1071
  ":r": vote["readerId"],
925
1072
  ":d": vote["dimension"],
926
1073
  ":v": vote["value"],
@@ -932,6 +1079,9 @@ class DynamoReaderStore:
932
1079
  def delete_vote(self, vote_id_: str) -> None:
933
1080
  self._table("vote").delete_item(Key={"id": vote_id_})
934
1081
 
1082
+ def put_flag(self, flag: dict[str, Any], now_iso: str) -> None:
1083
+ self._table("flag").put_item(Item=_without_none({**flag, "__typename": "ReaderFlag", "createdAt": now_iso, "updatedAt": now_iso}))
1084
+
935
1085
  def is_published(self, subject_key: str) -> bool:
936
1086
  item = self._table("published").get_item(Key={"id": PUBLISHED_ID_PREFIX + subject_key}, ProjectionExpression="#s", ExpressionAttributeNames={"#s": "status"}).get("Item")
937
1087
  return bool(item) and item.get("status") == "published"
@@ -1259,6 +1409,7 @@ def store_from_environment(environment: dict[str, str] | None = None) -> DynamoR
1259
1409
  report_table=environment.get("PAPYRUS_READER_REPORT_TABLE", ""),
1260
1410
  action_table=environment.get("PAPYRUS_READER_ACTION_TABLE", ""),
1261
1411
  item_table=environment.get("PAPYRUS_READER_ITEM_TABLE", ""),
1412
+ flag_table=environment.get("PAPYRUS_READER_FLAG_TABLE", ""),
1262
1413
  )
1263
1414
 
1264
1415
 
@@ -14,7 +14,7 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
14
14
  export const readerSteeringManifest = {
15
15
  name: "reader-steering",
16
16
  contract: 1,
17
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
17
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
18
18
  mutations: ["moderateReader"],
19
19
  guest: { mutations: [] },
20
20
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
@@ -26,6 +26,7 @@ export function readerActionsSettings(config, allowInterimSubjects) {
26
26
  dimensions: config.dimensions,
27
27
  limits: config.limits,
28
28
  submissions: config.submissions,
29
+ subjects: config.subjects,
29
30
  comments: config.comments,
30
31
  moderation: config.moderation,
31
32
  reservedHandles: config.reservedHandles,
@@ -38,6 +39,7 @@ export function readerSnapshotSettings(config) {
38
39
  return {
39
40
  dimensions: config.dimensions,
40
41
  submissions: config.submissions,
42
+ subjects: config.subjects,
41
43
  karma: config.karma,
42
44
  ranking: config.ranking,
43
45
  limits: { newAccountHours: config.limits.newAccountHours },
@@ -110,6 +112,7 @@ export const readerSteeringPlugin = {
110
112
  ["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
111
113
  ["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
112
114
  ["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
115
+ ["PAPYRUS_READER_FLAG_TABLE", "ReaderFlag"],
113
116
  ["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
114
117
  ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
115
118
  ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
@@ -125,6 +128,7 @@ export const readerSteeringPlugin = {
125
128
  tables.PublishedItem.grantReadData(lambda);
126
129
  lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
127
130
  tables.ReaderVote.grantReadData(contentActions);
131
+ tables.ReaderFlag.grantReadData(contentActions);
128
132
  tables.ReaderProfile.grantReadWriteData(contentActions);
129
133
  // The snapshot reads submissions, and the review step writes their signals and moderation state.
130
134
  tables.ReaderSubmission.grantReadWriteData(contentActions);
@@ -21,7 +21,7 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
21
21
  export const readerSteeringManifest: PluginManifest = {
22
22
  name: "reader-steering",
23
23
  contract: 1,
24
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
24
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
25
25
  mutations: ["moderateReader"],
26
26
  guest: { mutations: [] },
27
27
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
@@ -34,6 +34,7 @@ export function readerActionsSettings(config: ReaderSteeringConfig, allowInterim
34
34
  dimensions: config.dimensions,
35
35
  limits: config.limits,
36
36
  submissions: config.submissions,
37
+ subjects: config.subjects,
37
38
  comments: config.comments,
38
39
  moderation: config.moderation,
39
40
  reservedHandles: config.reservedHandles,
@@ -47,6 +48,7 @@ export function readerSnapshotSettings(config: ReaderSteeringConfig) {
47
48
  return {
48
49
  dimensions: config.dimensions,
49
50
  submissions: config.submissions,
51
+ subjects: config.subjects,
50
52
  karma: config.karma,
51
53
  ranking: config.ranking,
52
54
  limits: { newAccountHours: config.limits.newAccountHours },
@@ -129,6 +131,7 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
129
131
  ["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
130
132
  ["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
131
133
  ["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
134
+ ["PAPYRUS_READER_FLAG_TABLE", "ReaderFlag"],
132
135
  ["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
133
136
  ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
134
137
  ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
@@ -144,6 +147,7 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
144
147
  tables.PublishedItem.grantReadData(lambda);
145
148
  lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
146
149
  tables.ReaderVote.grantReadData(contentActions);
150
+ tables.ReaderFlag.grantReadData(contentActions);
147
151
  tables.ReaderProfile.grantReadWriteData(contentActions);
148
152
  // The snapshot reads submissions, and the review step writes their signals and moderation state.
149
153
  tables.ReaderSubmission.grantReadWriteData(contentActions);
@@ -3,7 +3,7 @@ import { readerActions } from "./function/resource.js";
3
3
  /**
4
4
  * Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
5
5
  * 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, `subjectKey#readerId#dimension` for a vote and
6
+ * 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
7
7
  * `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
8
8
  */
9
9
  const staffOnly = (allow) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
@@ -32,6 +32,7 @@ export function readerSteeringSchemaDefinitions() {
32
32
  .model({
33
33
  id: a.id().required(),
34
34
  subjectKey: a.string().required(),
35
+ subjectType: a.string(),
35
36
  readerId: a.string().required(),
36
37
  dimension: a.string().required(),
37
38
  value: a.integer().required(),
@@ -39,6 +40,21 @@ export function readerSteeringSchemaDefinitions() {
39
40
  })
40
41
  .disableOperations(["subscriptions"])
41
42
  .authorization(staffOnly),
43
+ ReaderFlag: a
44
+ .model({
45
+ id: a.id().required(),
46
+ subjectKey: a.string().required(),
47
+ subjectType: a.string().required(),
48
+ readerId: a.string().required(),
49
+ kind: a.string().required(),
50
+ reason: a.string().required(),
51
+ targetKey: a.string(),
52
+ suggestedName: a.string(),
53
+ voterRole: a.string().required(),
54
+ createdAt: a.datetime().required(),
55
+ })
56
+ .disableOperations(["subscriptions"])
57
+ .authorization(staffOnly),
42
58
  ReaderComment: a
43
59
  .model({
44
60
  id: a.id().required(),
@@ -4,7 +4,7 @@ import { readerActions } from "./function/resource";
4
4
  /**
5
5
  * Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
6
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 and
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
8
  * `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
9
9
  */
10
10
  const staffOnly = (allow: any) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
@@ -34,6 +34,7 @@ export function readerSteeringSchemaDefinitions() {
34
34
  .model({
35
35
  id: a.id().required(),
36
36
  subjectKey: a.string().required(),
37
+ subjectType: a.string(),
37
38
  readerId: a.string().required(),
38
39
  dimension: a.string().required(),
39
40
  value: a.integer().required(),
@@ -41,6 +42,21 @@ export function readerSteeringSchemaDefinitions() {
41
42
  })
42
43
  .disableOperations(["subscriptions"])
43
44
  .authorization(staffOnly),
45
+ ReaderFlag: a
46
+ .model({
47
+ id: a.id().required(),
48
+ subjectKey: a.string().required(),
49
+ subjectType: a.string().required(),
50
+ readerId: a.string().required(),
51
+ kind: a.string().required(),
52
+ reason: a.string().required(),
53
+ targetKey: a.string(),
54
+ suggestedName: a.string(),
55
+ voterRole: a.string().required(),
56
+ createdAt: a.datetime().required(),
57
+ })
58
+ .disableOperations(["subscriptions"])
59
+ .authorization(staffOnly),
44
60
  ReaderComment: a
45
61
  .model({
46
62
  id: a.id().required(),
@@ -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, "flag");
9
+ }
package/lib/reader-api.ts CHANGED
@@ -12,7 +12,7 @@ export const READER_BODY_BYTES_BY_ACTION: Partial<Record<ReaderAction, number>>
12
12
  export const READER_MAXIMUM_SUBJECTS = 50;
13
13
  export const READER_SUBJECT_KEY = /^[A-Za-z0-9._:-]{1,200}$/;
14
14
 
15
- export type ReaderAction = "me" | "handle" | "vote" | "rating" | "submit" | "comment" | "comment-edit" | "comment-delete" | "report";
15
+ export type ReaderAction = "me" | "handle" | "vote" | "rating" | "submit" | "comment" | "comment-edit" | "comment-delete" | "report" | "flag";
16
16
 
17
17
  export interface ReaderActionEvent {
18
18
  publication: string;
package/package.json CHANGED
@@ -361,6 +361,7 @@
361
361
  "./app/api/reader/comment/delete/route": "./app/api/reader/comment/delete/route.ts",
362
362
  "./app/api/reader/comment/edit/route": "./app/api/reader/comment/edit/route.ts",
363
363
  "./app/api/reader/comment/route": "./app/api/reader/comment/route.ts",
364
+ "./app/api/reader/flag/route": "./app/api/reader/flag/route.ts",
364
365
  "./app/api/reader/handle/route": "./app/api/reader/handle/route.ts",
365
366
  "./app/api/reader/me/route": "./app/api/reader/me/route.ts",
366
367
  "./app/api/reader/rating/route": "./app/api/reader/rating/route.ts",
@@ -433,7 +434,7 @@
433
434
  "optional": true
434
435
  }
435
436
  },
436
- "version": "2.0.0-next.66",
437
+ "version": "2.0.0-next.68",
437
438
  "dependencies": {
438
439
  "@aws-amplify/adapter-nextjs": "^1.7.3",
439
440
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.0-next.66",
2
+ "version": "2.0.0-next.68",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -295,6 +295,17 @@
295
295
  "runtime": "\"nodejs\""
296
296
  }
297
297
  },
298
+ {
299
+ "file": "app/api/reader/flag/route.ts",
300
+ "hasDefault": false,
301
+ "exports": [
302
+ "POST"
303
+ ],
304
+ "config": {
305
+ "dynamic": "\"force-dynamic\"",
306
+ "runtime": "\"nodejs\""
307
+ }
308
+ },
298
309
  {
299
310
  "file": "app/api/reader/handle/route.ts",
300
311
  "hasDefault": false,