@anthusai/papyrus 5.30.0 → 5.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/plugins/reader-steering/config.js +8 -1
- package/amplify/plugins/reader-steering/config.ts +13 -1
- package/amplify/plugins/reader-steering/function/handler.py +209 -18
- package/amplify/plugins/reader-steering/function/proposals.py +255 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +10 -2
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +10 -2
- package/amplify/plugins/reader-steering/schema.js +65 -1
- package/amplify/plugins/reader-steering/schema.ts +65 -1
- package/app/api/reader/flag/route.ts +9 -0
- package/lib/reader-api.ts +1 -1
- package/package.json +2 -1
- package/routes.manifest.json +12 -1
|
@@ -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,
|
|
@@ -30,6 +30,7 @@ if _FUNCTION_DIRECTORY not in sys.path: # a Lambda already has its code directo
|
|
|
30
30
|
sys.path.insert(0, _FUNCTION_DIRECTORY)
|
|
31
31
|
import ai_moderation # noqa: E402
|
|
32
32
|
import moderation as moderation_rules # noqa: E402
|
|
33
|
+
import proposals as proposal_rules # noqa: E402
|
|
33
34
|
|
|
34
35
|
SITE_SUBJECT = re.compile(r"ss1_[A-Za-z0-9_-]{43}")
|
|
35
36
|
INTERIM_SUBJECT = re.compile(r"ssdev_[A-Za-z0-9_-]{43}")
|
|
@@ -54,6 +55,21 @@ ACTION_RATING = "rating"
|
|
|
54
55
|
ACTION_SUBMIT = "submit"
|
|
55
56
|
ACTION_COMMENT = "comment"
|
|
56
57
|
ACTION_REPORT = "report"
|
|
58
|
+
ACTION_FLAG = "flag"
|
|
59
|
+
SUBJECT_TYPE_ITEM = "item"
|
|
60
|
+
SUBJECT_TYPE_SUBMISSION = "submission"
|
|
61
|
+
SUBJECT_TYPES = (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION, "story", "proposal", "concept")
|
|
62
|
+
DEFAULT_SUBJECT_TYPES = (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION)
|
|
63
|
+
SUBJECT_ID = re.compile(r"[A-Za-z0-9._:-]{1,190}")
|
|
64
|
+
VOTE_DIMENSION = "up"
|
|
65
|
+
VOTE_PAYLOAD_KEYS = frozenset({"subject", "value"})
|
|
66
|
+
FLAG_WRONG = "wrong"
|
|
67
|
+
FLAG_BELONGS_UNDER = "belongs_under"
|
|
68
|
+
FLAG_BETTER_NAME = "better_name"
|
|
69
|
+
FLAG_KINDS = (FLAG_WRONG, FLAG_BELONGS_UNDER, FLAG_BETTER_NAME)
|
|
70
|
+
FLAG_PAYLOAD_KEYS = frozenset({"subject", "kind", "reason", "target", "name"})
|
|
71
|
+
MAXIMUM_FLAG_REASON = 500
|
|
72
|
+
MAXIMUM_SUGGESTED_NAME = 120
|
|
57
73
|
RATE_BUCKET_REPORT = "report"
|
|
58
74
|
REPORT_ID_PREFIX = "rep-"
|
|
59
75
|
MAXIMUM_REPORT_REASON = 500
|
|
@@ -127,6 +143,7 @@ class ReaderSettings:
|
|
|
127
143
|
promote_section: str = "unsectioned"
|
|
128
144
|
ai_model: str = ai_moderation.DEFAULT_MODEL
|
|
129
145
|
ai_key_parameter: str = ""
|
|
146
|
+
subject_types: tuple[str, ...] = DEFAULT_SUBJECT_TYPES
|
|
130
147
|
|
|
131
148
|
|
|
132
149
|
def settings_from_environment(environment: dict[str, str] | None = None) -> ReaderSettings:
|
|
@@ -154,6 +171,7 @@ def settings_from_environment(environment: dict[str, str] | None = None) -> Read
|
|
|
154
171
|
reports_per_day=int((raw.get("moderation") or {}).get("reportsPerDay", defaults.reports_per_day)),
|
|
155
172
|
ai_model=str(((raw.get("moderation") or {}).get("ai") or {}).get("model", defaults.ai_model)),
|
|
156
173
|
ai_key_parameter=str(((raw.get("moderation") or {}).get("ai") or {}).get("apiKeyParameter", defaults.ai_key_parameter)),
|
|
174
|
+
subject_types=tuple((raw.get("subjects") or {}).get("types") or defaults.subject_types),
|
|
157
175
|
promote_item_type=str((raw.get("submissions") or {}).get("itemType", defaults.promote_item_type)),
|
|
158
176
|
promote_section=str((raw.get("submissions") or {}).get("section", defaults.promote_section)),
|
|
159
177
|
)
|
|
@@ -172,6 +190,8 @@ class ReaderStore(Protocol):
|
|
|
172
190
|
|
|
173
191
|
def delete_vote(self, vote_id: str) -> None: ...
|
|
174
192
|
|
|
193
|
+
def put_flag(self, flag: dict[str, Any], now_iso: str) -> None: ...
|
|
194
|
+
|
|
175
195
|
def is_published(self, subject_key: str) -> bool: ...
|
|
176
196
|
|
|
177
197
|
def get_submission(self, submission_id: str) -> dict[str, Any] | None: ...
|
|
@@ -229,6 +249,30 @@ def vote_id(subject_key: str, reader_id: str, dimension: str) -> str:
|
|
|
229
249
|
return f"{subject_key}#{reader_id}#{dimension}"
|
|
230
250
|
|
|
231
251
|
|
|
252
|
+
def subject_key(subject_type: str, subject_id: str) -> str:
|
|
253
|
+
"""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."""
|
|
254
|
+
return subject_id if subject_type in (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION) else f"{subject_type}:{subject_id}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def legacy_subject_type(key: str) -> str:
|
|
258
|
+
"""The type of a bare subject key: a submission id, otherwise an item."""
|
|
259
|
+
return SUBJECT_TYPE_SUBMISSION if SUBMISSION_ID.fullmatch(key) else SUBJECT_TYPE_ITEM
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def parse_subject(raw: Any, settings: ReaderSettings) -> tuple[tuple[str, str] | None, dict[str, Any] | None]:
|
|
263
|
+
"""``({type, id}) -> (type, id)``, or a refusal. A bare string is a subject written before subject types: its type is read from its shape."""
|
|
264
|
+
if isinstance(raw, str):
|
|
265
|
+
raw = {"type": legacy_subject_type(raw), "id": raw}
|
|
266
|
+
if not isinstance(raw, dict) or set(raw) != {"type", "id"} or not isinstance(raw["type"], str) or not isinstance(raw["id"], str):
|
|
267
|
+
return None, refusal(422, "invalid_subject", "Send the subject as {type, id}.")
|
|
268
|
+
subject_type, subject_id = raw["type"], raw["id"]
|
|
269
|
+
if subject_type not in SUBJECT_TYPES or subject_type not in settings.subject_types:
|
|
270
|
+
return None, refusal(422, "unknown_subject_type", "That kind of subject cannot be voted on here.")
|
|
271
|
+
if not SUBJECT_ID.fullmatch(subject_id):
|
|
272
|
+
return None, refusal(422, "invalid_subject", "Send the id of the subject.")
|
|
273
|
+
return (subject_type, subject_id), None
|
|
274
|
+
|
|
275
|
+
|
|
232
276
|
def valid_site_subject(value: Any, settings: ReaderSettings) -> bool:
|
|
233
277
|
if not isinstance(value, str):
|
|
234
278
|
return False
|
|
@@ -321,13 +365,18 @@ def public_profile(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
321
365
|
|
|
322
366
|
|
|
323
367
|
def my_state(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], settings: ReaderSettings) -> dict[str, Any]:
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
368
|
+
raw_subjects = payload.get("subjects", [])
|
|
369
|
+
if not isinstance(raw_subjects, list) or len(raw_subjects) > MAXIMUM_SUBJECTS:
|
|
370
|
+
return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} subjects.")
|
|
371
|
+
subjects: list[str] = []
|
|
372
|
+
for raw in raw_subjects:
|
|
373
|
+
if isinstance(raw, str) and SUBJECT_KEY.fullmatch(raw):
|
|
374
|
+
subjects.append(raw)
|
|
375
|
+
continue
|
|
376
|
+
parsed, _ = parse_subject(raw, settings) if isinstance(raw, dict) else (None, None)
|
|
377
|
+
if parsed is None:
|
|
378
|
+
return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} subject keys or {{type, id}} subjects.")
|
|
379
|
+
subjects.append(subject_key(*parsed))
|
|
331
380
|
dimensions = [*TOGGLE_DIMENSIONS, *settings.dimensions]
|
|
332
381
|
wanted = {vote_id(subject, profile["readerId"], dimension): (subject, dimension) for subject in subjects for dimension in dimensions}
|
|
333
382
|
found = store.get_vote_values(list(wanted))
|
|
@@ -335,7 +384,9 @@ def my_state(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any
|
|
|
335
384
|
for stored_id, value in found.items():
|
|
336
385
|
subject, dimension = wanted[stored_id]
|
|
337
386
|
votes.setdefault(subject, {})[dimension] = value
|
|
338
|
-
|
|
387
|
+
# The reader's own up (1) or down (-1) vote per requested subject, by subject key (the key format of items.json).
|
|
388
|
+
subject_votes = {subject: held["up"] for subject, held in votes.items() if held.get("up") in (1, -1)}
|
|
389
|
+
return reply(200, {**public_profile(profile), "votes": votes, "subjectVotes": subject_votes})
|
|
339
390
|
|
|
340
391
|
|
|
341
392
|
def set_handle(
|
|
@@ -375,8 +426,11 @@ def react(
|
|
|
375
426
|
now_seconds: float,
|
|
376
427
|
schedule_snapshot: Callable[[], None],
|
|
377
428
|
) -> dict[str, Any]:
|
|
378
|
-
|
|
379
|
-
|
|
429
|
+
parsed, refused = parse_subject(subject, settings)
|
|
430
|
+
if refused:
|
|
431
|
+
return refused
|
|
432
|
+
subject_type, subject_id = parsed
|
|
433
|
+
subject = subject_key(subject_type, subject_id)
|
|
380
434
|
if not isinstance(dimension, str) or dimension not in allowed_dimensions:
|
|
381
435
|
return refusal(422, "invalid_dimension", "That dimension is not available.")
|
|
382
436
|
if profile.get("state") == STATE_BANNED:
|
|
@@ -386,7 +440,7 @@ def react(
|
|
|
386
440
|
limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
|
|
387
441
|
if limited:
|
|
388
442
|
return limited
|
|
389
|
-
if not subject_is_open(store,
|
|
443
|
+
if not subject_is_open(store, subject_type, subject_id):
|
|
390
444
|
return refusal(404, "unknown_subject", "That item is not published.")
|
|
391
445
|
stored_id = vote_id(subject, profile["readerId"], dimension)
|
|
392
446
|
if value is None:
|
|
@@ -396,6 +450,7 @@ def react(
|
|
|
396
450
|
{
|
|
397
451
|
"id": stored_id,
|
|
398
452
|
"subjectKey": subject,
|
|
453
|
+
"subjectType": subject_type,
|
|
399
454
|
"readerId": profile["readerId"],
|
|
400
455
|
"dimension": dimension,
|
|
401
456
|
"value": value,
|
|
@@ -407,12 +462,17 @@ def react(
|
|
|
407
462
|
return reply(200, {"subject": subject, "dimension": dimension, "value": value})
|
|
408
463
|
|
|
409
464
|
|
|
410
|
-
def subject_is_open(store: ReaderStore,
|
|
411
|
-
"""A published item, or a submission that is visible (pending or promoted); hidden submissions cannot be voted on.
|
|
412
|
-
|
|
413
|
-
|
|
465
|
+
def subject_is_open(store: ReaderStore, subject_type: str, subject_id: str) -> bool:
|
|
466
|
+
"""A published item, or a submission that is visible (pending or promoted); hidden submissions cannot be voted on.
|
|
467
|
+
|
|
468
|
+
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.
|
|
469
|
+
"""
|
|
470
|
+
if subject_type == SUBJECT_TYPE_SUBMISSION:
|
|
471
|
+
submission = store.get_submission(subject_id)
|
|
414
472
|
return submission is not None and submission.get("state") in VISIBLE_SUBMISSION_STATES
|
|
415
|
-
|
|
473
|
+
if subject_type == SUBJECT_TYPE_ITEM:
|
|
474
|
+
return store.is_published(subject_id)
|
|
475
|
+
return True
|
|
416
476
|
|
|
417
477
|
|
|
418
478
|
def clean_text(value: Any, label: str, maximum: int, *, required: bool) -> tuple[str | None, dict[str, Any] | None]:
|
|
@@ -606,7 +666,7 @@ def comment_action(
|
|
|
606
666
|
limited = within_rate_limit(store, profile, RATE_BUCKET_COMMENT, new_account_limit(profile, settings, now_seconds, settings.comments_per_hour), now_seconds)
|
|
607
667
|
if limited:
|
|
608
668
|
return limited
|
|
609
|
-
if not subject_is_open(store, subject):
|
|
669
|
+
if not subject_is_open(store, legacy_subject_type(subject), subject):
|
|
610
670
|
return refusal(404, "unknown_subject", "That item is not published.")
|
|
611
671
|
if parent_id is not None:
|
|
612
672
|
parent = store.get_comment(parent_id)
|
|
@@ -677,6 +737,75 @@ def report_action(
|
|
|
677
737
|
return reply(200, {"reported": True})
|
|
678
738
|
|
|
679
739
|
|
|
740
|
+
def flag_action(
|
|
741
|
+
store: ReaderStore,
|
|
742
|
+
profile: dict[str, Any],
|
|
743
|
+
payload: dict[str, Any],
|
|
744
|
+
settings: ReaderSettings,
|
|
745
|
+
is_staff: bool,
|
|
746
|
+
now_seconds: float,
|
|
747
|
+
schedule_snapshot: Callable[[], None],
|
|
748
|
+
) -> dict[str, Any]:
|
|
749
|
+
"""A reader flags a subject as wrong, as belonging under another subject, or as needing a better name; a reason is required.
|
|
750
|
+
|
|
751
|
+
One flag per reader, subject and kind: flagging again replaces the reason. Flags are stored apart from votes and reach the
|
|
752
|
+
snapshot as counts only. The stored row holds the readerId, never a siteSubject.
|
|
753
|
+
"""
|
|
754
|
+
if set(payload) - FLAG_PAYLOAD_KEYS:
|
|
755
|
+
return refusal(422, "invalid_flag", "Send subject, kind, reason, and target or name for the kind.")
|
|
756
|
+
kind = payload.get("kind")
|
|
757
|
+
if kind not in FLAG_KINDS:
|
|
758
|
+
return refusal(422, "invalid_flag_kind", f"Send kind as one of {', '.join(FLAG_KINDS)}.")
|
|
759
|
+
parsed, refused = parse_subject(payload.get("subject"), settings)
|
|
760
|
+
if refused:
|
|
761
|
+
return refused
|
|
762
|
+
reason = payload.get("reason")
|
|
763
|
+
if not isinstance(reason, str) or not reason.strip() or len(reason.strip()) > MAXIMUM_FLAG_REASON or CONTROL_CHARACTERS.search(reason):
|
|
764
|
+
return refusal(422, "invalid_reason", f"Give a reason in plain text of at most {MAXIMUM_FLAG_REASON} characters.")
|
|
765
|
+
if EMAIL_ADDRESS.search(reason):
|
|
766
|
+
return refusal(422, "invalid_reason", "A reason cannot contain an email address.")
|
|
767
|
+
target_key: str | None = None
|
|
768
|
+
suggested: str | None = None
|
|
769
|
+
if kind == FLAG_BELONGS_UNDER:
|
|
770
|
+
target, refused = parse_subject(payload.get("target"), settings) if isinstance(payload.get("target"), dict) else (None, True)
|
|
771
|
+
if refused or "name" in payload:
|
|
772
|
+
return refusal(422, "invalid_target", "Send target as {type, id} for belongs_under.")
|
|
773
|
+
target_key = subject_key(*target)
|
|
774
|
+
if target_key == subject_key(*parsed):
|
|
775
|
+
return refusal(422, "invalid_target", "A subject cannot belong under itself.")
|
|
776
|
+
elif kind == FLAG_BETTER_NAME:
|
|
777
|
+
suggested, refused = clean_text(payload.get("name"), "name", MAXIMUM_SUGGESTED_NAME, required=True)
|
|
778
|
+
if refused or "target" in payload:
|
|
779
|
+
return refused or refusal(422, "invalid_name", "Send name for better_name.")
|
|
780
|
+
elif "target" in payload or "name" in payload:
|
|
781
|
+
return refusal(422, "invalid_flag", "wrong takes only a reason.")
|
|
782
|
+
if profile.get("state") == STATE_LIMITED:
|
|
783
|
+
return refusal(403, "reader_limited", "This reader cannot flag right now.")
|
|
784
|
+
limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
|
|
785
|
+
if limited:
|
|
786
|
+
return limited
|
|
787
|
+
subject_type, subject_id = parsed
|
|
788
|
+
if not subject_is_open(store, subject_type, subject_id):
|
|
789
|
+
return refusal(404, "unknown_subject", "That subject cannot be flagged.")
|
|
790
|
+
key = subject_key(subject_type, subject_id)
|
|
791
|
+
store.put_flag(
|
|
792
|
+
{
|
|
793
|
+
"id": f"{key}#{profile['readerId']}#{kind}",
|
|
794
|
+
"subjectKey": key,
|
|
795
|
+
"subjectType": subject_type,
|
|
796
|
+
"readerId": profile["readerId"],
|
|
797
|
+
"kind": kind,
|
|
798
|
+
"reason": reason.strip(),
|
|
799
|
+
"targetKey": target_key,
|
|
800
|
+
"suggestedName": suggested,
|
|
801
|
+
"voterRole": ROLE_EDITOR if is_staff else ROLE_READER,
|
|
802
|
+
},
|
|
803
|
+
iso(now_seconds),
|
|
804
|
+
)
|
|
805
|
+
schedule_snapshot()
|
|
806
|
+
return reply(200, {"subject": payload["subject"], "kind": kind})
|
|
807
|
+
|
|
808
|
+
|
|
680
809
|
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
810
|
if set(payload) - allowed:
|
|
682
811
|
return None, refusal(422, "invalid_comment", "Send only " + ", ".join(sorted(allowed)) + ".")
|
|
@@ -740,7 +869,23 @@ def delete_comment_action(
|
|
|
740
869
|
return reply(200, {"id": comment["id"], "state": COMMENT_DELETED})
|
|
741
870
|
|
|
742
871
|
|
|
872
|
+
def typed_vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
|
|
873
|
+
"""``{subject: {type, id}, value: 1 | -1 | null}``: an up or down vote on any enabled subject type; ``null`` removes the reader's vote."""
|
|
874
|
+
value = payload.get("value")
|
|
875
|
+
if set(payload) - VOTE_PAYLOAD_KEYS or isinstance(value, bool) or value not in (1, -1, None):
|
|
876
|
+
return refusal(422, "invalid_vote", "Send only subject and value, and value as 1, -1 or null.")
|
|
877
|
+
answer = react(
|
|
878
|
+
store, profile, subject=payload.get("subject"), dimension=VOTE_DIMENSION, allowed_dimensions=(VOTE_DIMENSION,),
|
|
879
|
+
value=value, is_staff=is_staff, settings=settings, now_seconds=now_seconds, schedule_snapshot=schedule_snapshot,
|
|
880
|
+
)
|
|
881
|
+
if answer["statusCode"] == 200:
|
|
882
|
+
answer = reply(200, {"subject": payload["subject"], "value": value})
|
|
883
|
+
return answer
|
|
884
|
+
|
|
885
|
+
|
|
743
886
|
def vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
|
|
887
|
+
if isinstance(payload.get("subject"), dict):
|
|
888
|
+
return typed_vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot)
|
|
744
889
|
on = payload.get("on")
|
|
745
890
|
if not isinstance(on, bool):
|
|
746
891
|
return refusal(422, "invalid_vote", "Send on as true or false.")
|
|
@@ -794,6 +939,8 @@ def handle_event(
|
|
|
794
939
|
return delete_comment_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
|
|
795
940
|
if action == ACTION_REPORT:
|
|
796
941
|
return report_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
|
|
942
|
+
if action == ACTION_FLAG:
|
|
943
|
+
return flag_action(store, profile, payload, settings, event["isStaff"], now_seconds, schedule_snapshot)
|
|
797
944
|
return refusal(400, "unknown_action", "Unknown action.")
|
|
798
945
|
|
|
799
946
|
|
|
@@ -832,6 +979,8 @@ class DynamoReaderStore:
|
|
|
832
979
|
report_table: str = "",
|
|
833
980
|
action_table: str = "",
|
|
834
981
|
item_table: str = "",
|
|
982
|
+
proposal_table: str = "",
|
|
983
|
+
flag_table: str = "",
|
|
835
984
|
) -> None:
|
|
836
985
|
self._resource = resource
|
|
837
986
|
self._names = {
|
|
@@ -845,11 +994,45 @@ class DynamoReaderStore:
|
|
|
845
994
|
"report": report_table,
|
|
846
995
|
"action": action_table,
|
|
847
996
|
"item": item_table,
|
|
997
|
+
"proposal": proposal_table,
|
|
998
|
+
"flag": flag_table,
|
|
848
999
|
}
|
|
849
1000
|
|
|
850
1001
|
def _table(self, key: str) -> Any:
|
|
851
1002
|
return self._resource.Table(self._names[key])
|
|
852
1003
|
|
|
1004
|
+
def get_proposal(self, proposal_id: str) -> dict[str, Any] | None:
|
|
1005
|
+
item = self._table("proposal").get_item(Key={"id": proposal_id}, ConsistentRead=True).get("Item")
|
|
1006
|
+
return plain(item) if item else None
|
|
1007
|
+
|
|
1008
|
+
def create_proposal(self, row: dict[str, Any]) -> bool:
|
|
1009
|
+
item = {**_without_none(row), "__typename": "Proposal", "updatedAt": row["createdAt"]}
|
|
1010
|
+
try:
|
|
1011
|
+
self._table("proposal").put_item(Item=item, ConditionExpression="attribute_not_exists(id)")
|
|
1012
|
+
except Exception as error:
|
|
1013
|
+
if is_conditional_failure(error):
|
|
1014
|
+
return False
|
|
1015
|
+
raise
|
|
1016
|
+
return True
|
|
1017
|
+
|
|
1018
|
+
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool:
|
|
1019
|
+
names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
|
|
1020
|
+
assignments = ", ".join(f"#c{index} = :c{index}" for index in range(len(changes)))
|
|
1021
|
+
values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values())}}
|
|
1022
|
+
try:
|
|
1023
|
+
self._table("proposal").update_item(
|
|
1024
|
+
Key={"id": proposal_id},
|
|
1025
|
+
UpdateExpression=f"SET {assignments}, #u = :now",
|
|
1026
|
+
ConditionExpression="#st = :expected",
|
|
1027
|
+
ExpressionAttributeNames=names,
|
|
1028
|
+
ExpressionAttributeValues=values,
|
|
1029
|
+
)
|
|
1030
|
+
except Exception as error:
|
|
1031
|
+
if is_conditional_failure(error):
|
|
1032
|
+
return False
|
|
1033
|
+
raise
|
|
1034
|
+
return True
|
|
1035
|
+
|
|
853
1036
|
def get_profile(self, site_subject: str) -> dict[str, Any] | None:
|
|
854
1037
|
item = self._table("profile").get_item(Key={"id": site_subject}, ConsistentRead=True).get("Item")
|
|
855
1038
|
return plain(item) if item else None
|
|
@@ -914,13 +1097,14 @@ class DynamoReaderStore:
|
|
|
914
1097
|
self._table("vote").update_item(
|
|
915
1098
|
Key={"id": vote["id"]},
|
|
916
1099
|
UpdateExpression=(
|
|
917
|
-
"SET #t = :t, subjectKey = :s, readerId = :r, #d = :d, #v = :v, voterRole = :role, "
|
|
1100
|
+
"SET #t = :t, subjectKey = :s, subjectType = :st, readerId = :r, #d = :d, #v = :v, voterRole = :role, "
|
|
918
1101
|
"createdAt = if_not_exists(createdAt, :now), updatedAt = :now"
|
|
919
1102
|
),
|
|
920
1103
|
ExpressionAttributeNames={"#t": "__typename", "#d": "dimension", "#v": "value"},
|
|
921
1104
|
ExpressionAttributeValues={
|
|
922
1105
|
":t": "ReaderVote",
|
|
923
1106
|
":s": vote["subjectKey"],
|
|
1107
|
+
":st": vote.get("subjectType") or legacy_subject_type(vote["subjectKey"]),
|
|
924
1108
|
":r": vote["readerId"],
|
|
925
1109
|
":d": vote["dimension"],
|
|
926
1110
|
":v": vote["value"],
|
|
@@ -932,6 +1116,9 @@ class DynamoReaderStore:
|
|
|
932
1116
|
def delete_vote(self, vote_id_: str) -> None:
|
|
933
1117
|
self._table("vote").delete_item(Key={"id": vote_id_})
|
|
934
1118
|
|
|
1119
|
+
def put_flag(self, flag: dict[str, Any], now_iso: str) -> None:
|
|
1120
|
+
self._table("flag").put_item(Item=_without_none({**flag, "__typename": "ReaderFlag", "createdAt": now_iso, "updatedAt": now_iso}))
|
|
1121
|
+
|
|
935
1122
|
def is_published(self, subject_key: str) -> bool:
|
|
936
1123
|
item = self._table("published").get_item(Key={"id": PUBLISHED_ID_PREFIX + subject_key}, ProjectionExpression="#s", ExpressionAttributeNames={"#s": "status"}).get("Item")
|
|
937
1124
|
return bool(item) and item.get("status") == "published"
|
|
@@ -1259,6 +1446,8 @@ def store_from_environment(environment: dict[str, str] | None = None) -> DynamoR
|
|
|
1259
1446
|
report_table=environment.get("PAPYRUS_READER_REPORT_TABLE", ""),
|
|
1260
1447
|
action_table=environment.get("PAPYRUS_READER_ACTION_TABLE", ""),
|
|
1261
1448
|
item_table=environment.get("PAPYRUS_READER_ITEM_TABLE", ""),
|
|
1449
|
+
proposal_table=environment.get("PAPYRUS_READER_PROPOSAL_TABLE", ""),
|
|
1450
|
+
flag_table=environment.get("PAPYRUS_READER_FLAG_TABLE", ""),
|
|
1262
1451
|
)
|
|
1263
1452
|
|
|
1264
1453
|
|
|
@@ -1312,6 +1501,8 @@ def run_ai_worker(
|
|
|
1312
1501
|
|
|
1313
1502
|
def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float) -> dict[str, Any]:
|
|
1314
1503
|
"""``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
|
|
1504
|
+
if (event.get("info") or {}).get("fieldName") in proposal_rules.MUTATIONS:
|
|
1505
|
+
return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds))
|
|
1315
1506
|
identity = event.get("identity") or {}
|
|
1316
1507
|
claims = identity.get("claims") or {}
|
|
1317
1508
|
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""The proposal record: what may be filed, which state changes are allowed, and who may make them.
|
|
2
|
+
|
|
3
|
+
A proposal is a change to the map written down where readers can see it: a story draft, a new concept, a rename, a merge,
|
|
4
|
+
a split, a new column or an element symbol. It is filed by AI (a reader proposer is part of the record but has no route yet)
|
|
5
|
+
and steered by votes; an editor decides. Staff reach this through the ``fileProposal`` and ``transitionProposal``
|
|
6
|
+
mutations (editors and admins only; the function checks the groups again). Every state change is appended to the proposal's
|
|
7
|
+
``history`` in the same conditional write that changes the state, so the history and the state cannot disagree.
|
|
8
|
+
|
|
9
|
+
Nothing here knows a person id. The proposer is ``"ai"`` or a reader's siteSubject, which is stored and never published; the
|
|
10
|
+
actor of a change is the staff member's Cognito subject, which is stored and never published either (the public file shows
|
|
11
|
+
"AI" or "Editor"). Votes are not counted here: they belong to the vote model and the snapshot joins them.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import uuid
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Protocol
|
|
21
|
+
|
|
22
|
+
KINDS = ("story_draft", "new_concept", "rename", "merge", "split", "new_column", "element_symbol")
|
|
23
|
+
DRAFT, IN_STEERING, EDITOR_REVIEW, APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR = (
|
|
24
|
+
"draft",
|
|
25
|
+
"in_steering",
|
|
26
|
+
"editor_review",
|
|
27
|
+
"approved",
|
|
28
|
+
"declined",
|
|
29
|
+
"overridden_by_editor",
|
|
30
|
+
)
|
|
31
|
+
STATES = (DRAFT, IN_STEERING, EDITOR_REVIEW, APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR)
|
|
32
|
+
|
|
33
|
+
#: Every allowed state change. Anything not listed is refused. Approved, declined and overridden are final.
|
|
34
|
+
TRANSITIONS: dict[str, tuple[str, ...]] = {
|
|
35
|
+
DRAFT: (IN_STEERING, DECLINED),
|
|
36
|
+
IN_STEERING: (EDITOR_REVIEW,),
|
|
37
|
+
EDITOR_REVIEW: (APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR),
|
|
38
|
+
}
|
|
39
|
+
#: The changes that need a reason. Every reason is public; this one is also required, because it explains a decision against the community.
|
|
40
|
+
REASON_REQUIRED = frozenset({OVERRIDDEN_BY_EDITOR})
|
|
41
|
+
#: An override says what the editor decided against the community's view: approved or declined. Approve and decline are their own result.
|
|
42
|
+
OVERRIDE_RESULTS = (APPROVED, DECLINED)
|
|
43
|
+
|
|
44
|
+
#: How many target subjects each kind names, as (minimum, maximum), and the types a target may have.
|
|
45
|
+
TARGET_COUNTS: dict[str, tuple[int, int]] = {
|
|
46
|
+
"story_draft": (0, 10),
|
|
47
|
+
"new_concept": (0, 10),
|
|
48
|
+
"rename": (1, 1),
|
|
49
|
+
"merge": (2, 10),
|
|
50
|
+
"split": (1, 1),
|
|
51
|
+
"new_column": (0, 10),
|
|
52
|
+
"element_symbol": (1, 1),
|
|
53
|
+
}
|
|
54
|
+
TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
|
|
55
|
+
|
|
56
|
+
MUTATIONS = ("fileProposal", "transitionProposal")
|
|
57
|
+
PROPOSER_AI = "ai"
|
|
58
|
+
STAFF_GROUPS = frozenset({"editor", "admin"})
|
|
59
|
+
PROPOSAL_ID = re.compile(r"prop-[0-9a-f]{32}")
|
|
60
|
+
TARGET_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:\-]{0,127}")
|
|
61
|
+
MAXIMUM_TITLE = 200
|
|
62
|
+
MAXIMUM_SUMMARY = 300
|
|
63
|
+
MAXIMUM_BODY = 50_000
|
|
64
|
+
MAXIMUM_REASON = 500
|
|
65
|
+
MAXIMUM_EVIDENCE_SOURCE = 200
|
|
66
|
+
MAXIMUM_EVIDENCE_SIGNALS = 1_000_000
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Outcome:
|
|
71
|
+
status: str # "ok" or "refused"
|
|
72
|
+
id: str | None = None
|
|
73
|
+
error: str | None = None
|
|
74
|
+
message: str | None = None
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, Any]:
|
|
77
|
+
return {"status": self.status, "id": self.id, "error": self.error, "message": self.message}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def refused(error: str, message: str) -> Outcome:
|
|
81
|
+
return Outcome("refused", error=error, message=message)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ProposalStore(Protocol):
|
|
85
|
+
def get_proposal(self, proposal_id: str) -> dict[str, Any] | None: ...
|
|
86
|
+
|
|
87
|
+
def create_proposal(self, row: dict[str, Any]) -> bool: ...
|
|
88
|
+
|
|
89
|
+
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
93
|
+
"""The Cognito subject of an editor or admin, or ``None`` for anyone else."""
|
|
94
|
+
claims = identity.get("claims") or {}
|
|
95
|
+
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
96
|
+
if isinstance(groups, str):
|
|
97
|
+
groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
|
|
98
|
+
subject = identity.get("sub")
|
|
99
|
+
if not isinstance(subject, str) or not subject or not STAFF_GROUPS.intersection(groups):
|
|
100
|
+
return None
|
|
101
|
+
return subject
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def clean_text(value: Any, limit: int) -> str | None:
|
|
105
|
+
if not isinstance(value, str):
|
|
106
|
+
return None
|
|
107
|
+
text = value.strip()
|
|
108
|
+
return text if text and len(text) <= limit else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_json(value: Any) -> Any:
|
|
112
|
+
"""AppSync delivers AWSJSON arguments as strings; the CLI and tests may pass the parsed value."""
|
|
113
|
+
if isinstance(value, str):
|
|
114
|
+
try:
|
|
115
|
+
return json.loads(value)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return None
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def clean_targets(kind: str, raw: Any) -> list[dict[str, str]] | Outcome:
|
|
122
|
+
parsed = parse_json(raw) if raw is not None else []
|
|
123
|
+
if not isinstance(parsed, list):
|
|
124
|
+
return refused("invalid_targets", "targets must be a list of {type, id}.")
|
|
125
|
+
minimum, maximum = TARGET_COUNTS[kind]
|
|
126
|
+
targets: list[dict[str, str]] = []
|
|
127
|
+
for entry in parsed:
|
|
128
|
+
if not isinstance(entry, dict) or entry.get("type") not in TARGET_TYPES or not isinstance(entry.get("id"), str) or not TARGET_ID.fullmatch(entry["id"]):
|
|
129
|
+
return refused("invalid_targets", f"Each target is {{type, id}} with a type of {', '.join(TARGET_TYPES)}.")
|
|
130
|
+
target = {"type": entry["type"], "id": entry["id"]}
|
|
131
|
+
if target in targets:
|
|
132
|
+
return refused("invalid_targets", "A target is named twice.")
|
|
133
|
+
targets.append(target)
|
|
134
|
+
if not minimum <= len(targets) <= maximum:
|
|
135
|
+
wanted = f"{minimum}" if minimum == maximum else f"{minimum} to {maximum}"
|
|
136
|
+
return refused("invalid_targets", f"A {kind} proposal names {wanted} target(s).")
|
|
137
|
+
return targets
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def clean_evidence(raw: Any) -> dict[str, Any] | None | Outcome:
|
|
141
|
+
parsed = parse_json(raw) if raw is not None else None
|
|
142
|
+
if parsed is None:
|
|
143
|
+
return None
|
|
144
|
+
if not isinstance(parsed, dict):
|
|
145
|
+
return refused("invalid_evidence", "evidence must be {signals, source}.")
|
|
146
|
+
signals, source = parsed.get("signals"), parsed.get("source")
|
|
147
|
+
if not isinstance(signals, int) or isinstance(signals, bool) or not 0 <= signals <= MAXIMUM_EVIDENCE_SIGNALS:
|
|
148
|
+
return refused("invalid_evidence", "evidence.signals must be a whole number of signals.")
|
|
149
|
+
if source is not None and clean_text(source, MAXIMUM_EVIDENCE_SOURCE) is None:
|
|
150
|
+
return refused("invalid_evidence", "evidence.source must be short text.")
|
|
151
|
+
return {"signals": signals, "source": source.strip() if isinstance(source, str) else None}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def file_proposal(arguments: dict[str, Any], *, actor_sub: str, now_iso: str, proposal_id: str | None = None) -> tuple[dict[str, Any] | None, Outcome | None]:
|
|
155
|
+
"""The new row for a proposal in ``draft``, or the refusal. The proposer is AI: reader filing has no route yet."""
|
|
156
|
+
kind = arguments.get("kind")
|
|
157
|
+
if kind not in KINDS:
|
|
158
|
+
return None, refused("invalid_kind", f"kind must be one of {', '.join(KINDS)}.")
|
|
159
|
+
title = clean_text(arguments.get("title"), MAXIMUM_TITLE)
|
|
160
|
+
if title is None:
|
|
161
|
+
return None, refused("invalid_title", f"A proposal needs a title of at most {MAXIMUM_TITLE} characters.")
|
|
162
|
+
body = arguments.get("body") # Markus source, kept byte for byte
|
|
163
|
+
if not isinstance(body, str) or not body.strip() or len(body) > MAXIMUM_BODY:
|
|
164
|
+
return None, refused("invalid_body", "A proposal needs a body (Markus), at most 50,000 characters.")
|
|
165
|
+
summary = arguments.get("summary")
|
|
166
|
+
if summary is not None and clean_text(summary, MAXIMUM_SUMMARY) is None:
|
|
167
|
+
return None, refused("invalid_summary", f"summary is one line of at most {MAXIMUM_SUMMARY} characters.")
|
|
168
|
+
targets = clean_targets(kind, arguments.get("targets"))
|
|
169
|
+
if isinstance(targets, Outcome):
|
|
170
|
+
return None, targets
|
|
171
|
+
evidence = clean_evidence(arguments.get("evidence"))
|
|
172
|
+
if isinstance(evidence, Outcome):
|
|
173
|
+
return None, evidence
|
|
174
|
+
identifier = proposal_id or "prop-" + uuid.uuid4().hex
|
|
175
|
+
row: dict[str, Any] = {
|
|
176
|
+
"id": identifier,
|
|
177
|
+
"kind": kind,
|
|
178
|
+
"title": title,
|
|
179
|
+
"body": body,
|
|
180
|
+
"targets": json.dumps(targets),
|
|
181
|
+
"proposer": PROPOSER_AI,
|
|
182
|
+
"state": DRAFT,
|
|
183
|
+
"history": json.dumps([{"from": None, "to": DRAFT, "by": "ai", "actor": actor_sub, "reason": None, "at": now_iso}]),
|
|
184
|
+
"createdAt": now_iso,
|
|
185
|
+
}
|
|
186
|
+
if summary is not None:
|
|
187
|
+
row["summary"] = summary.strip()
|
|
188
|
+
if evidence is not None:
|
|
189
|
+
row["evidence"] = json.dumps(evidence)
|
|
190
|
+
return row, None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_sub: str, now_iso: str, result: Any = None) -> dict[str, Any] | Outcome:
|
|
194
|
+
"""The changes that move ``row`` to ``to_state``, or why it may not move."""
|
|
195
|
+
state = row.get("state")
|
|
196
|
+
if to_state not in STATES:
|
|
197
|
+
return refused("invalid_state", f"state must be one of {', '.join(STATES)}.")
|
|
198
|
+
if to_state not in TRANSITIONS.get(state, ()):
|
|
199
|
+
return refused("transition_not_allowed", f"A proposal in {state} cannot move to {to_state}.")
|
|
200
|
+
text = None
|
|
201
|
+
if reason is not None and str(reason).strip():
|
|
202
|
+
text = clean_text(reason, MAXIMUM_REASON)
|
|
203
|
+
if text is None:
|
|
204
|
+
return refused("invalid_reason", f"A reason is plain text of at most {MAXIMUM_REASON} characters.")
|
|
205
|
+
if to_state in REASON_REQUIRED and text is None:
|
|
206
|
+
return refused("reason_required", "Overriding the community needs a public reason.")
|
|
207
|
+
if to_state == OVERRIDDEN_BY_EDITOR:
|
|
208
|
+
if result not in OVERRIDE_RESULTS:
|
|
209
|
+
return refused("result_required", f"An override says what the editor decided: {' or '.join(OVERRIDE_RESULTS)}.")
|
|
210
|
+
elif result is not None:
|
|
211
|
+
return refused("result_not_allowed", "Only an override carries a result; approve and decline are their own.")
|
|
212
|
+
entry = {"from": state, "to": to_state, "by": "staff", "actor": actor_sub, "reason": text, "at": now_iso}
|
|
213
|
+
if result is not None:
|
|
214
|
+
entry["result"] = result
|
|
215
|
+
history = [*(parse_json(row.get("history")) or []), entry]
|
|
216
|
+
return {"state": to_state, "history": json.dumps(history)}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def apply_transition(store: ProposalStore, *, proposal_id: Any, to_state: Any, reason: Any, actor_sub: str, now_iso: str, result: Any = None) -> Outcome:
|
|
220
|
+
if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
|
|
221
|
+
return refused("invalid_proposal", "That is not a proposal id.")
|
|
222
|
+
row = store.get_proposal(proposal_id)
|
|
223
|
+
if row is None:
|
|
224
|
+
return refused("not_found", "There is no such proposal.")
|
|
225
|
+
plan = transition_plan(row, to_state, reason, actor_sub=actor_sub, now_iso=now_iso, result=result)
|
|
226
|
+
if isinstance(plan, Outcome):
|
|
227
|
+
return plan
|
|
228
|
+
if not store.change_proposal(proposal_id, plan, row["state"], now_iso):
|
|
229
|
+
return refused("target_changed", "Someone else changed that proposal first.")
|
|
230
|
+
return Outcome("ok", id=proposal_id)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str) -> dict[str, Any]:
|
|
234
|
+
"""``fileProposal`` or ``transitionProposal`` from AppSync. Answers a receipt, never an exception."""
|
|
235
|
+
actor = staff_subject(event.get("identity") or {})
|
|
236
|
+
if actor is None:
|
|
237
|
+
return refused("not_staff", "Only editors and admins can file or decide proposals.").to_dict()
|
|
238
|
+
arguments = event.get("arguments") or {}
|
|
239
|
+
field = (event.get("info") or {}).get("fieldName")
|
|
240
|
+
if field == "fileProposal":
|
|
241
|
+
row, problem = file_proposal(arguments, actor_sub=actor, now_iso=now_iso)
|
|
242
|
+
if problem is not None:
|
|
243
|
+
return problem.to_dict()
|
|
244
|
+
if not store.create_proposal(row):
|
|
245
|
+
return refused("exists", "That proposal already exists.").to_dict()
|
|
246
|
+
return Outcome("ok", id=row["id"]).to_dict()
|
|
247
|
+
return apply_transition(
|
|
248
|
+
store,
|
|
249
|
+
proposal_id=arguments.get("proposalId"),
|
|
250
|
+
to_state=arguments.get("state"),
|
|
251
|
+
reason=arguments.get("reason"),
|
|
252
|
+
result=arguments.get("result"),
|
|
253
|
+
actor_sub=actor,
|
|
254
|
+
now_iso=now_iso,
|
|
255
|
+
).to_dict()
|
|
@@ -14,8 +14,8 @@ 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"],
|
|
18
|
-
mutations: ["moderateReader"],
|
|
17
|
+
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
18
|
+
mutations: ["moderateReader", "fileProposal", "transitionProposal"],
|
|
19
19
|
guest: { mutations: [] },
|
|
20
20
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
21
21
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -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 },
|
|
@@ -100,6 +102,7 @@ export const readerSteeringPlugin = {
|
|
|
100
102
|
publicSettings: (config) => ({
|
|
101
103
|
contract: readerSteeringManifest.contract,
|
|
102
104
|
dimensions: config.dimensions,
|
|
105
|
+
subjects: { types: config.subjects.types },
|
|
103
106
|
moderation: { mode: config.moderation.mode },
|
|
104
107
|
}),
|
|
105
108
|
wire(backend, config, context) {
|
|
@@ -110,10 +113,12 @@ export const readerSteeringPlugin = {
|
|
|
110
113
|
["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
|
|
111
114
|
["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
|
|
112
115
|
["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
|
|
116
|
+
["PAPYRUS_READER_FLAG_TABLE", "ReaderFlag"],
|
|
113
117
|
["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
|
|
114
118
|
["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
|
|
115
119
|
["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
|
|
116
120
|
["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
|
|
121
|
+
["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
|
|
117
122
|
]) {
|
|
118
123
|
tables[model].grantReadWriteData(lambda);
|
|
119
124
|
lambda.addEnvironment(variable, tables[model].tableName);
|
|
@@ -125,10 +130,13 @@ export const readerSteeringPlugin = {
|
|
|
125
130
|
tables.PublishedItem.grantReadData(lambda);
|
|
126
131
|
lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
|
|
127
132
|
tables.ReaderVote.grantReadData(contentActions);
|
|
133
|
+
tables.ReaderFlag.grantReadData(contentActions);
|
|
128
134
|
tables.ReaderProfile.grantReadWriteData(contentActions);
|
|
129
135
|
// The snapshot reads submissions, and the review step writes their signals and moderation state.
|
|
130
136
|
tables.ReaderSubmission.grantReadWriteData(contentActions);
|
|
131
137
|
tables.ReaderComment.grantReadWriteData(contentActions);
|
|
138
|
+
// The snapshot reads proposals and freezes the community result of one that reaches editor review.
|
|
139
|
+
tables.Proposal.grantReadWriteData(contentActions);
|
|
132
140
|
lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
|
|
133
141
|
if (config.reservedConcurrency !== undefined) {
|
|
134
142
|
lambda.node.defaultChild.addPropertyOverride("ReservedConcurrentExecutions", config.reservedConcurrency);
|
|
@@ -21,8 +21,8 @@ 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"],
|
|
25
|
-
mutations: ["moderateReader"],
|
|
24
|
+
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
25
|
+
mutations: ["moderateReader", "fileProposal", "transitionProposal"],
|
|
26
26
|
guest: { mutations: [] },
|
|
27
27
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
28
28
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -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 },
|
|
@@ -118,6 +120,7 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
|
|
|
118
120
|
publicSettings: (config) => ({
|
|
119
121
|
contract: readerSteeringManifest.contract,
|
|
120
122
|
dimensions: config.dimensions,
|
|
123
|
+
subjects: { types: config.subjects.types },
|
|
121
124
|
moderation: { mode: config.moderation.mode },
|
|
122
125
|
}),
|
|
123
126
|
wire(backend, config, context: PluginWiringContext) {
|
|
@@ -129,10 +132,12 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
|
|
|
129
132
|
["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
|
|
130
133
|
["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
|
|
131
134
|
["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
|
|
135
|
+
["PAPYRUS_READER_FLAG_TABLE", "ReaderFlag"],
|
|
132
136
|
["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
|
|
133
137
|
["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
|
|
134
138
|
["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
|
|
135
139
|
["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
|
|
140
|
+
["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
|
|
136
141
|
] as const) {
|
|
137
142
|
tables[model].grantReadWriteData(lambda);
|
|
138
143
|
lambda.addEnvironment(variable, tables[model].tableName);
|
|
@@ -144,10 +149,13 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
|
|
|
144
149
|
tables.PublishedItem.grantReadData(lambda);
|
|
145
150
|
lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
|
|
146
151
|
tables.ReaderVote.grantReadData(contentActions);
|
|
152
|
+
tables.ReaderFlag.grantReadData(contentActions);
|
|
147
153
|
tables.ReaderProfile.grantReadWriteData(contentActions);
|
|
148
154
|
// The snapshot reads submissions, and the review step writes their signals and moderation state.
|
|
149
155
|
tables.ReaderSubmission.grantReadWriteData(contentActions);
|
|
150
156
|
tables.ReaderComment.grantReadWriteData(contentActions);
|
|
157
|
+
// The snapshot reads proposals and freezes the community result of one that reaches editor review.
|
|
158
|
+
tables.Proposal.grantReadWriteData(contentActions);
|
|
151
159
|
|
|
152
160
|
lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
|
|
153
161
|
if (config.reservedConcurrency !== undefined) {
|
|
@@ -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(),
|
|
@@ -118,6 +134,54 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
118
134
|
.disableOperations(["subscriptions"])
|
|
119
135
|
.secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
|
|
120
136
|
.authorization(staffOnly),
|
|
137
|
+
Proposal: a
|
|
138
|
+
.model({
|
|
139
|
+
id: a.id().required(),
|
|
140
|
+
kind: a.string().required(),
|
|
141
|
+
title: a.string().required(),
|
|
142
|
+
summary: a.string(),
|
|
143
|
+
body: a.string().required(),
|
|
144
|
+
targets: a.json().required(),
|
|
145
|
+
evidence: a.json(),
|
|
146
|
+
proposer: a.string().required(),
|
|
147
|
+
state: a.string().required(),
|
|
148
|
+
history: a.json().required(),
|
|
149
|
+
communityResult: a.json(),
|
|
150
|
+
createdAt: a.datetime().required(),
|
|
151
|
+
})
|
|
152
|
+
.disableOperations(["subscriptions"])
|
|
153
|
+
.secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
|
|
154
|
+
.authorization(staffOnly),
|
|
155
|
+
ProposalReceipt: a.customType({
|
|
156
|
+
status: a.string().required(),
|
|
157
|
+
id: a.string(),
|
|
158
|
+
error: a.string(),
|
|
159
|
+
message: a.string(),
|
|
160
|
+
}),
|
|
161
|
+
fileProposal: a
|
|
162
|
+
.mutation()
|
|
163
|
+
.arguments({
|
|
164
|
+
kind: a.string().required(),
|
|
165
|
+
title: a.string().required(),
|
|
166
|
+
body: a.string().required(),
|
|
167
|
+
summary: a.string(),
|
|
168
|
+
targets: a.json(),
|
|
169
|
+
evidence: a.json(),
|
|
170
|
+
})
|
|
171
|
+
.returns(a.ref("ProposalReceipt"))
|
|
172
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
173
|
+
.handler(a.handler.function(readerActions)),
|
|
174
|
+
transitionProposal: a
|
|
175
|
+
.mutation()
|
|
176
|
+
.arguments({
|
|
177
|
+
proposalId: a.string().required(),
|
|
178
|
+
state: a.string().required(),
|
|
179
|
+
reason: a.string(),
|
|
180
|
+
result: a.string(),
|
|
181
|
+
})
|
|
182
|
+
.returns(a.ref("ProposalReceipt"))
|
|
183
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
184
|
+
.handler(a.handler.function(readerActions)),
|
|
121
185
|
ModerationReceipt: a.customType({
|
|
122
186
|
status: a.string().required(),
|
|
123
187
|
id: a.string(),
|
|
@@ -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(),
|
|
@@ -120,6 +136,54 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
120
136
|
.disableOperations(["subscriptions"])
|
|
121
137
|
.secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
|
|
122
138
|
.authorization(staffOnly),
|
|
139
|
+
Proposal: a
|
|
140
|
+
.model({
|
|
141
|
+
id: a.id().required(),
|
|
142
|
+
kind: a.string().required(),
|
|
143
|
+
title: a.string().required(),
|
|
144
|
+
summary: a.string(),
|
|
145
|
+
body: a.string().required(),
|
|
146
|
+
targets: a.json().required(),
|
|
147
|
+
evidence: a.json(),
|
|
148
|
+
proposer: a.string().required(),
|
|
149
|
+
state: a.string().required(),
|
|
150
|
+
history: a.json().required(),
|
|
151
|
+
communityResult: a.json(),
|
|
152
|
+
createdAt: a.datetime().required(),
|
|
153
|
+
})
|
|
154
|
+
.disableOperations(["subscriptions"])
|
|
155
|
+
.secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
|
|
156
|
+
.authorization(staffOnly),
|
|
157
|
+
ProposalReceipt: a.customType({
|
|
158
|
+
status: a.string().required(),
|
|
159
|
+
id: a.string(),
|
|
160
|
+
error: a.string(),
|
|
161
|
+
message: a.string(),
|
|
162
|
+
}),
|
|
163
|
+
fileProposal: a
|
|
164
|
+
.mutation()
|
|
165
|
+
.arguments({
|
|
166
|
+
kind: a.string().required(),
|
|
167
|
+
title: a.string().required(),
|
|
168
|
+
body: a.string().required(),
|
|
169
|
+
summary: a.string(),
|
|
170
|
+
targets: a.json(),
|
|
171
|
+
evidence: a.json(),
|
|
172
|
+
})
|
|
173
|
+
.returns(a.ref("ProposalReceipt"))
|
|
174
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
175
|
+
.handler(a.handler.function(readerActions)),
|
|
176
|
+
transitionProposal: a
|
|
177
|
+
.mutation()
|
|
178
|
+
.arguments({
|
|
179
|
+
proposalId: a.string().required(),
|
|
180
|
+
state: a.string().required(),
|
|
181
|
+
reason: a.string(),
|
|
182
|
+
result: a.string(),
|
|
183
|
+
})
|
|
184
|
+
.returns(a.ref("ProposalReceipt"))
|
|
185
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
186
|
+
.handler(a.handler.function(readerActions)),
|
|
123
187
|
ModerationReceipt: a.customType({
|
|
124
188
|
status: a.string().required(),
|
|
125
189
|
id: a.string(),
|
|
@@ -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": "5.
|
|
437
|
+
"version": "5.31.0",
|
|
437
438
|
"dependencies": {
|
|
438
439
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
439
440
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "5.
|
|
2
|
+
"version": "5.31.0",
|
|
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,
|