@anthusai/papyrus 2.0.0-next.7 → 2.0.0-next.71
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/data/resource.js +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2028 -1979
- package/amplify/data/schema.ts +72 -14
- package/amplify/functions/assignment-action/data-access.js +90 -0
- package/amplify/functions/assignment-action/data-access.ts +89 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +180 -0
- package/amplify/plugins/reader-steering/config.ts +280 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1554 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +255 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +184 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +210 -0
- package/amplify/plugins/reader-steering/schema.js +204 -0
- package/amplify/plugins/reader-steering/schema.ts +206 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +38 -1
- package/amplify/site-backend.ts +46 -1
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-article-editor.tsx +2 -4
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +64 -20
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +86 -6
- package/infra/site-config.ts +129 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +42 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -69
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -85
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
publishingAccountsForSite,
|
|
26
26
|
type PublicationCapability,
|
|
27
27
|
} from "./prospectus-publication-access";
|
|
28
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* The site's sign-in routes for Prospectus accounts: start (challenge, then the
|
|
@@ -38,6 +39,8 @@ export interface ProspectusSignInRouteOptions {
|
|
|
38
39
|
siteProduct?: string | null;
|
|
39
40
|
completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
|
|
40
41
|
nowInSeconds?: () => number;
|
|
42
|
+
/** Trusted configuration the public site origin is read from; defaults to process.env. */
|
|
43
|
+
environment?: Record<string, string | undefined>;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
@@ -50,8 +53,22 @@ function currentTimeInSeconds(options: ProspectusSignInRouteOptions): number {
|
|
|
50
53
|
return options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
function siteOriginFor(request: NextRequest, options: ProspectusSignInRouteOptions): string {
|
|
57
|
+
return publicSiteOrigin(request, options.environment);
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
async function signInRuntimeFor(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<ProspectusSignInRuntime | null> {
|
|
54
|
-
return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request
|
|
61
|
+
return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(siteOriginFor(request, options));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Runs a route and answers a missing site origin with 503 site_origin_not_configured, never a localhost redirect. */
|
|
65
|
+
async function failingClosedWithoutSiteOrigin(route: () => Promise<NextResponse>): Promise<NextResponse> {
|
|
66
|
+
try {
|
|
67
|
+
return await route();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
function signInUnavailableResponse(): NextResponse {
|
|
@@ -65,10 +82,21 @@ function clearSealedCookie(response: NextResponse, cookieName: string): void {
|
|
|
65
82
|
response.cookies.set(cookieName, "", sealedCookieAttributes(0, 0));
|
|
66
83
|
}
|
|
67
84
|
|
|
85
|
+
/** A JSON failure, for the routes the page script calls with fetch (session, account selection and creation). */
|
|
68
86
|
function signInFailedResponse(status: number, code: string, message: string): NextResponse {
|
|
69
87
|
return NextResponse.json({ error: code, message }, { status, headers: NO_STORE_HEADERS });
|
|
70
88
|
}
|
|
71
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A failed sign-in never leaves the browser on /api/auth/prospectus/callback: it answers 303 to the page the reader started from
|
|
92
|
+
* (or the home page when that is not known) with `sign_in_error=<code>`, which the reader plugin turns into a short message.
|
|
93
|
+
*/
|
|
94
|
+
function signInFailedRedirect(request: NextRequest, options: ProspectusSignInRouteOptions, returnPath: string | null, code: string): NextResponse {
|
|
95
|
+
const target = new URL(returnPath ?? "/", siteOriginFor(request, options));
|
|
96
|
+
target.searchParams.set("sign_in_error", /^[a-z0-9_]{1,60}$/.test(code) ? code : "sign_in_failed");
|
|
97
|
+
return NextResponse.redirect(target, { status: 303, headers: NO_STORE_HEADERS });
|
|
98
|
+
}
|
|
99
|
+
|
|
72
100
|
export function signedInProspectusSession(
|
|
73
101
|
request: NextRequest,
|
|
74
102
|
sealingSecret: string,
|
|
@@ -82,13 +110,29 @@ export function signedInProspectusSession(
|
|
|
82
110
|
);
|
|
83
111
|
}
|
|
84
112
|
|
|
85
|
-
|
|
113
|
+
/** `returnTo` from the query (a link) or, for the POST form the reader plugin's button submits, from the form body. */
|
|
114
|
+
async function requestedReturnPath(request: NextRequest): Promise<string | null> {
|
|
115
|
+
if (request.method !== "POST") return request.nextUrl.searchParams.get("returnTo");
|
|
116
|
+
try {
|
|
117
|
+
const value = (await request.formData()).get("returnTo");
|
|
118
|
+
return typeof value === "string" ? value : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** GET (a link) or POST (the reader plugin's "Sign in" button, so people are never sent to an /api URL) starts the Google sign-in with a 303 to Google. */
|
|
125
|
+
export function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
126
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignInStartRoute(request, options));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
86
130
|
const runtime = await signInRuntimeFor(request, options);
|
|
87
131
|
if (!runtime) return signInUnavailableResponse();
|
|
88
132
|
const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
|
|
89
133
|
const pendingSignInCookie: PendingProspectusSignInCookie = {
|
|
90
134
|
...pendingSignIn,
|
|
91
|
-
returnPath: sameSiteReturnPath(request
|
|
135
|
+
returnPath: sameSiteReturnPath(await requestedReturnPath(request)),
|
|
92
136
|
};
|
|
93
137
|
const response = NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
|
|
94
138
|
response.cookies.set(
|
|
@@ -99,7 +143,11 @@ export async function handleProspectusSignInStart(request: NextRequest, options:
|
|
|
99
143
|
return response;
|
|
100
144
|
}
|
|
101
145
|
|
|
102
|
-
export
|
|
146
|
+
export function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
147
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignInCallbackRoute(request, options));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function handleProspectusSignInCallbackRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
103
151
|
const runtime = await signInRuntimeFor(request, options);
|
|
104
152
|
if (!runtime) return signInUnavailableResponse();
|
|
105
153
|
const nowInSeconds = currentTimeInSeconds(options);
|
|
@@ -121,12 +169,13 @@ async function completeCallback(
|
|
|
121
169
|
options: ProspectusSignInRouteOptions,
|
|
122
170
|
nowInSeconds: number,
|
|
123
171
|
): Promise<NextResponse> {
|
|
124
|
-
|
|
172
|
+
const failed = (code: string) => signInFailedRedirect(request, options, pendingSignIn?.returnPath ?? null, code);
|
|
173
|
+
if (!pendingSignIn) return failed("sign_in_expired");
|
|
125
174
|
const searchParams = request.nextUrl.searchParams;
|
|
126
|
-
if (searchParams.get("error")) return
|
|
175
|
+
if (searchParams.get("error")) return failed("sign_in_declined");
|
|
127
176
|
const authorizationCode = searchParams.get("code");
|
|
128
177
|
const returnedState = searchParams.get("state");
|
|
129
|
-
if (!authorizationCode || !returnedState) return
|
|
178
|
+
if (!authorizationCode || !returnedState) return failed("sign_in_incomplete");
|
|
130
179
|
try {
|
|
131
180
|
const signedIn = await completeProspectusGoogleSignIn({
|
|
132
181
|
accounts: runtime.accounts,
|
|
@@ -141,8 +190,9 @@ async function completeCallback(
|
|
|
141
190
|
personId: signedIn.person.id,
|
|
142
191
|
accessToken: signedIn.accessToken,
|
|
143
192
|
accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
|
|
193
|
+
...(signedIn.person.siteSubject ? { siteSubject: signedIn.person.siteSubject } : {}),
|
|
144
194
|
};
|
|
145
|
-
const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, request
|
|
195
|
+
const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, siteOriginFor(request, options)), { status: 303, headers: NO_STORE_HEADERS });
|
|
146
196
|
response.cookies.set(
|
|
147
197
|
PROSPECTUS_SESSION_COOKIE,
|
|
148
198
|
sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, session, session.accessTokenExpiresAt),
|
|
@@ -150,13 +200,17 @@ async function completeCallback(
|
|
|
150
200
|
);
|
|
151
201
|
return response;
|
|
152
202
|
} catch (error) {
|
|
153
|
-
if (error instanceof ProspectusAccountsError) return
|
|
203
|
+
if (error instanceof ProspectusAccountsError) return failed(error.code);
|
|
154
204
|
console.error(`Prospectus sign-in failed (${error instanceof Error ? error.name : "error"}).`);
|
|
155
|
-
return
|
|
205
|
+
return failed("sign_in_failed");
|
|
156
206
|
}
|
|
157
207
|
}
|
|
158
208
|
|
|
159
|
-
export
|
|
209
|
+
export function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
210
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSessionReadRoute(request, options));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function handleProspectusSessionReadRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
160
214
|
const runtime = await signInRuntimeFor(request, options);
|
|
161
215
|
if (!runtime) return signInUnavailableResponse();
|
|
162
216
|
const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
|
|
@@ -169,11 +223,10 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
|
|
|
169
223
|
return response;
|
|
170
224
|
}
|
|
171
225
|
const publishingAccounts = publishingAccountsForSite(directory.accounts, siteProductFor(options));
|
|
226
|
+
// Only what the UI reads: no person id, siteSubject or account list ever goes to the browser.
|
|
172
227
|
return NextResponse.json(
|
|
173
228
|
{
|
|
174
229
|
signedIn: true,
|
|
175
|
-
person: directory.person,
|
|
176
|
-
accounts: directory.accounts,
|
|
177
230
|
cyclotronLabAccess: directory.accounts.some((account) =>
|
|
178
231
|
account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
|
|
179
232
|
),
|
|
@@ -196,9 +249,13 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
|
|
|
196
249
|
}
|
|
197
250
|
}
|
|
198
251
|
|
|
199
|
-
export
|
|
252
|
+
export function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
253
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignOutRoute(request, options));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function handleProspectusSignOutRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
200
257
|
const runtime = await signInRuntimeFor(request, options);
|
|
201
|
-
const signedOutDestination = new URL("/", request
|
|
258
|
+
const signedOutDestination = new URL("/", siteOriginFor(request, options));
|
|
202
259
|
const destination = runtime
|
|
203
260
|
? new URL(
|
|
204
261
|
`/logout?${new URLSearchParams({ client_id: runtime.cognitoClient.clientId, logout_uri: signedOutDestination.toString() })}`,
|
|
@@ -250,10 +307,14 @@ export async function currentProspectusPublishingSelection(
|
|
|
250
307
|
}
|
|
251
308
|
}
|
|
252
309
|
|
|
253
|
-
export
|
|
310
|
+
export function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
311
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusAccountSelectionRoute(request, options));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function handleProspectusAccountSelectionRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
254
315
|
const runtime = await signInRuntimeFor(request, options);
|
|
255
316
|
if (!runtime) return signInUnavailableResponse();
|
|
256
|
-
if (request.headers.get("origin") !== request
|
|
317
|
+
if (request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
257
318
|
return signInFailedResponse(403, "cross_origin_selection", "Choose an account from this site.");
|
|
258
319
|
}
|
|
259
320
|
const nowInSeconds = currentTimeInSeconds(options);
|
|
@@ -287,10 +348,14 @@ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
|
|
|
287
348
|
const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
|
|
288
349
|
const IDEMPOTENCY_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
289
350
|
|
|
290
|
-
export
|
|
351
|
+
export function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
352
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusPublishingAccountCreationRoute(request, options));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function handleProspectusPublishingAccountCreationRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
291
356
|
const runtime = await signInRuntimeFor(request, options);
|
|
292
357
|
if (!runtime) return signInUnavailableResponse();
|
|
293
|
-
if (request.headers.get("origin") !== request
|
|
358
|
+
if (request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
294
359
|
return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
|
|
295
360
|
}
|
|
296
361
|
const nowInSeconds = currentTimeInSeconds(options);
|
|
@@ -24,6 +24,7 @@ export type ProspectusOperation = "console:read" | "console:write" | "members:re
|
|
|
24
24
|
export interface ProspectusPerson {
|
|
25
25
|
id: string;
|
|
26
26
|
createdAt: string;
|
|
27
|
+
siteSubject?: string;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface ProspectusAccountAuthorization {
|
|
@@ -36,6 +37,20 @@ export interface ProspectusAccountAuthorization {
|
|
|
36
37
|
revision: number;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** One retired reader site subject, from `GET /v1/accounts/site-subjects/retired` (reader sites only). */
|
|
41
|
+
export interface ProspectusRetiredSiteSubject {
|
|
42
|
+
siteSubject: string;
|
|
43
|
+
reason: "deleted" | "merged";
|
|
44
|
+
survivingSiteSubject?: string;
|
|
45
|
+
retiredAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ProspectusRetiredSiteSubjectsPage {
|
|
49
|
+
retired: ProspectusRetiredSiteSubject[];
|
|
50
|
+
cursor: string;
|
|
51
|
+
more: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
export interface ProspectusDirectoryAccount {
|
|
40
55
|
id: string;
|
|
41
56
|
name: string;
|
|
@@ -100,8 +115,10 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
|
|
|
100
115
|
...(payload ? { "content-type": "application/json" } : {}),
|
|
101
116
|
};
|
|
102
117
|
const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
|
|
118
|
+
// The query string is part of what API Gateway verifies, so it has to be signed as well as sent.
|
|
119
|
+
const query: Record<string, string> = Object.fromEntries(url.searchParams.entries());
|
|
103
120
|
const signed = await signer.sign(
|
|
104
|
-
new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload }),
|
|
121
|
+
new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload, query }),
|
|
105
122
|
);
|
|
106
123
|
const response = await fetcher(url, { method, headers: signed.headers, body: payload, cache: "no-store", redirect: "error" });
|
|
107
124
|
const responseBody = await response.json().catch(() => ({}));
|
|
@@ -156,6 +173,12 @@ export class ProspectusSiteAccounts {
|
|
|
156
173
|
}) as Promise<{ account: { id: string; name: string; role: ProspectusRole } }>;
|
|
157
174
|
}
|
|
158
175
|
|
|
176
|
+
/** The calling site's retired reader subjects since `since` (omit it the first time): at most 100, oldest first. */
|
|
177
|
+
retiredSiteSubjects(since?: string): Promise<ProspectusRetiredSiteSubjectsPage> {
|
|
178
|
+
const query = since ? `?since=${encodeURIComponent(since)}` : "";
|
|
179
|
+
return this.transport({ path: `/v1/accounts/site-subjects/retired${query}`, method: "GET" }) as Promise<ProspectusRetiredSiteSubjectsPage>;
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
|
|
160
183
|
return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
|
|
161
184
|
person: ProspectusPerson | null;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
3
|
+
import { loadProspectusSessionSealingSecret, type ProspectusSignedInSession } from "./prospectus-session";
|
|
4
|
+
import { signedInProspectusSession } from "./prospectus-sign-in-routes";
|
|
5
|
+
import { readerIsStaff, readerSubject } from "./reader-subject";
|
|
6
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
7
|
+
|
|
8
|
+
export const READER_ACTIONS_FUNCTION_ENV = "PAPYRUS_READER_ACTIONS_FUNCTION";
|
|
9
|
+
export const READER_MAXIMUM_BODY_BYTES = 4096;
|
|
10
|
+
/** Larger bodies for actions that carry reader text: a URL, a title and a note; a comment of 2,000 characters. */
|
|
11
|
+
export const READER_BODY_BYTES_BY_ACTION: Partial<Record<ReaderAction, number>> = { submit: 8192, comment: 12288, "comment-edit": 12288 };
|
|
12
|
+
export const READER_MAXIMUM_SUBJECTS = 50;
|
|
13
|
+
export const READER_SUBJECT_KEY = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
14
|
+
|
|
15
|
+
export type ReaderAction = "me" | "handle" | "vote" | "rating" | "submit" | "comment" | "comment-edit" | "comment-delete" | "report" | "flag";
|
|
16
|
+
|
|
17
|
+
export interface ReaderActionEvent {
|
|
18
|
+
publication: string;
|
|
19
|
+
siteSubject: string;
|
|
20
|
+
isStaff: boolean;
|
|
21
|
+
action: ReaderAction;
|
|
22
|
+
payload: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReaderActionResult {
|
|
26
|
+
statusCode: number;
|
|
27
|
+
body: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ReaderActionInvoker = (functionName: string, event: ReaderActionEvent) => Promise<ReaderActionResult>;
|
|
31
|
+
|
|
32
|
+
export interface ReaderApiOptions {
|
|
33
|
+
environment?: Record<string, string | undefined>;
|
|
34
|
+
invoke?: ReaderActionInvoker;
|
|
35
|
+
actionsFunctionName?: string | null;
|
|
36
|
+
loadSealingSecret?: () => Promise<string | null>;
|
|
37
|
+
nowInSeconds?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
41
|
+
|
|
42
|
+
function failure(status: number, error: string, message: string, extra: Record<string, unknown> = {}, headers: Record<string, string> = {}): NextResponse {
|
|
43
|
+
return NextResponse.json({ error, message, ...extra }, { status, headers: { ...NO_STORE_HEADERS, ...headers } });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function identityUnavailableResponse(): NextResponse {
|
|
47
|
+
return failure(503, "reader_identity_unavailable", "Reader sign-in is not open yet.");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function actionsFunctionNameFrom(options: ReaderApiOptions, environment: Record<string, string | undefined>): string | null {
|
|
51
|
+
if (options.actionsFunctionName !== undefined) return options.actionsFunctionName;
|
|
52
|
+
const fromEnvironment = (environment[READER_ACTIONS_FUNCTION_ENV] ?? "").trim();
|
|
53
|
+
if (fromEnvironment) return fromEnvironment;
|
|
54
|
+
const outputs = readAmplifyOutputsFile() as { custom?: { readerSteering?: { actionsFunction?: unknown } | null } } | null;
|
|
55
|
+
const fromOutputs = outputs?.custom?.readerSteering?.actionsFunction;
|
|
56
|
+
return typeof fromOutputs === "string" && fromOutputs.trim() ? fromOutputs.trim() : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function invokeReaderActionsLambda(functionName: string, event: ReaderActionEvent): Promise<ReaderActionResult> {
|
|
60
|
+
const { InvokeCommand, LambdaClient } = await import("@aws-sdk/client-lambda");
|
|
61
|
+
const response = await new LambdaClient({}).send(
|
|
62
|
+
new InvokeCommand({ FunctionName: functionName, InvocationType: "RequestResponse", Payload: Buffer.from(JSON.stringify(event)) }),
|
|
63
|
+
);
|
|
64
|
+
if (response.FunctionError || !response.Payload) throw new Error("reader-actions failed");
|
|
65
|
+
const result = JSON.parse(Buffer.from(response.Payload).toString("utf8")) as Partial<ReaderActionResult>;
|
|
66
|
+
if (typeof result.statusCode !== "number" || typeof result.body !== "object" || result.body === null) throw new Error("reader-actions returned an unexpected shape");
|
|
67
|
+
return { statusCode: result.statusCode, body: result.body as Record<string, unknown> };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readJsonBody(request: NextRequest, maximumBytes: number): Promise<{ payload: Record<string, unknown> } | { response: NextResponse }> {
|
|
71
|
+
const contentType = (request.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
72
|
+
if (contentType !== "application/json") return { response: failure(415, "unsupported_media_type", "Send application/json.") };
|
|
73
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
74
|
+
if (Number.isFinite(declared) && declared > maximumBytes) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
75
|
+
const text = await request.text();
|
|
76
|
+
if (Buffer.byteLength(text, "utf8") > maximumBytes) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(text) as unknown;
|
|
79
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
|
|
80
|
+
return { payload: parsed as Record<string, unknown> };
|
|
81
|
+
} catch {
|
|
82
|
+
return { response: failure(400, "invalid_json", "The request body is not a JSON object.") };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function subjectsFromQuery(request: NextRequest): { payload: Record<string, unknown> } | { response: NextResponse } {
|
|
87
|
+
const raw = request.nextUrl.searchParams.get("subjects") ?? "";
|
|
88
|
+
const subjects = raw === "" ? [] : raw.split(",");
|
|
89
|
+
if (subjects.length > READER_MAXIMUM_SUBJECTS || !subjects.every((subject) => READER_SUBJECT_KEY.test(subject))) {
|
|
90
|
+
return { response: failure(400, "invalid_subjects", `Send at most ${READER_MAXIMUM_SUBJECTS} comma separated item keys.`) };
|
|
91
|
+
}
|
|
92
|
+
return { payload: { subjects } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function signedInReaderSession(request: NextRequest, options: ReaderApiOptions): Promise<ProspectusSignedInSession | null | "unavailable"> {
|
|
96
|
+
const sealingSecret = await (options.loadSealingSecret ?? (() => loadProspectusSessionSealingSecret({ environment: options.environment })))();
|
|
97
|
+
if (!sealingSecret) return "unavailable";
|
|
98
|
+
return signedInProspectusSession(request, sealingSecret, options.nowInSeconds);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One reader route: session, readerSubject (503 while Prospectus has no siteSubject), Origin, JSON size, then the
|
|
103
|
+
* reader-actions function. The siteSubject only travels to the function and never into a response.
|
|
104
|
+
*/
|
|
105
|
+
export async function handleReaderRequest(request: NextRequest, action: ReaderAction, options: ReaderApiOptions = {}): Promise<NextResponse> {
|
|
106
|
+
const environment = options.environment ?? process.env;
|
|
107
|
+
let origin: string;
|
|
108
|
+
try {
|
|
109
|
+
origin = publicSiteOrigin(request, environment);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
const session = await signedInReaderSession(request, options);
|
|
115
|
+
if (session === "unavailable") return identityUnavailableResponse();
|
|
116
|
+
if (!session) return failure(401, "signed_out", "Sign in to continue.");
|
|
117
|
+
const siteSubject = readerSubject(session, environment);
|
|
118
|
+
if (siteSubject === null) return identityUnavailableResponse();
|
|
119
|
+
|
|
120
|
+
let parsed: { payload: Record<string, unknown> } | { response: NextResponse };
|
|
121
|
+
if (action === "me") {
|
|
122
|
+
parsed = subjectsFromQuery(request);
|
|
123
|
+
} else {
|
|
124
|
+
if (request.headers.get("origin") !== origin) return failure(403, "origin_mismatch", "This request did not come from this site.");
|
|
125
|
+
parsed = await readJsonBody(request, READER_BODY_BYTES_BY_ACTION[action] ?? READER_MAXIMUM_BODY_BYTES);
|
|
126
|
+
}
|
|
127
|
+
if ("response" in parsed) return parsed.response;
|
|
128
|
+
|
|
129
|
+
const functionName = actionsFunctionNameFrom(options, environment);
|
|
130
|
+
if (!functionName) return failure(503, "reader_actions_unavailable", "Reader features are not enabled for this site.");
|
|
131
|
+
try {
|
|
132
|
+
const result = await (options.invoke ?? invokeReaderActionsLambda)(functionName, {
|
|
133
|
+
publication: new URL(origin).host,
|
|
134
|
+
siteSubject,
|
|
135
|
+
isStaff: readerIsStaff(session),
|
|
136
|
+
action,
|
|
137
|
+
payload: parsed.payload,
|
|
138
|
+
});
|
|
139
|
+
const retryAfter: Record<string, string> = typeof result.body.retryAfter === "number" ? { "retry-after": String(result.body.retryAfter) } : {};
|
|
140
|
+
return NextResponse.json(result.body, { status: result.statusCode, headers: { ...NO_STORE_HEADERS, ...retryAfter } });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error(`Reader action ${action} failed (${error instanceof Error ? error.name : "error"}).`);
|
|
143
|
+
return failure(502, "reader_actions_failed", "Reader features could not be reached.");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data side of the Administration "Moderation" panel for the reader-steering plugin: rows from the staff-readable
|
|
3
|
+
* models, shaped into a queue, a hidden list and the recent action log. Pure functions, no network: the panel loads the
|
|
4
|
+
* rows and calls the `moderateReader` mutation; everything it shows is decided here so it can be tested.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type ModerationTarget = "submission" | "comment" | "reader";
|
|
8
|
+
export type ModerationActionName = "approve" | "hide" | "unhide" | "ban" | "unban" | "limit" | "promote" | "undo";
|
|
9
|
+
|
|
10
|
+
export type SubmissionRow = {
|
|
11
|
+
id: string;
|
|
12
|
+
canonicalUrl: string;
|
|
13
|
+
domain: string;
|
|
14
|
+
title: string;
|
|
15
|
+
kind?: string | null;
|
|
16
|
+
note?: string | null;
|
|
17
|
+
readerId: string;
|
|
18
|
+
state: string;
|
|
19
|
+
moderation?: string | null;
|
|
20
|
+
reportCount?: number | null;
|
|
21
|
+
signals?: unknown;
|
|
22
|
+
itemId?: string | null;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type CommentRow = {
|
|
27
|
+
id: string;
|
|
28
|
+
subjectKey: string;
|
|
29
|
+
readerId: string;
|
|
30
|
+
body: string;
|
|
31
|
+
parentId?: string | null;
|
|
32
|
+
state: string;
|
|
33
|
+
moderation?: string | null;
|
|
34
|
+
reportCount?: number | null;
|
|
35
|
+
signals?: unknown;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ReportRow = { id: string; targetType: string; target: string; reporterReaderId: string; reason: string; state: string; createdAt: string };
|
|
40
|
+
|
|
41
|
+
export type ActionRow = {
|
|
42
|
+
id: string;
|
|
43
|
+
by: string;
|
|
44
|
+
actorSub: string;
|
|
45
|
+
action: string;
|
|
46
|
+
targetType: string;
|
|
47
|
+
target: string;
|
|
48
|
+
reason?: string | null;
|
|
49
|
+
fromState: string;
|
|
50
|
+
toState: string;
|
|
51
|
+
undoOf?: string | null;
|
|
52
|
+
undoneBy?: string | null;
|
|
53
|
+
at: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type Signal = { source: string; policy: string; detector: string; severity: string; excerpt: string };
|
|
57
|
+
|
|
58
|
+
export type ModerationRow = {
|
|
59
|
+
kind: "submission" | "comment";
|
|
60
|
+
id: string;
|
|
61
|
+
readerId: string;
|
|
62
|
+
handle: string | null;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
state: string;
|
|
65
|
+
title: string;
|
|
66
|
+
url: string | null;
|
|
67
|
+
text: string;
|
|
68
|
+
subject: string | null;
|
|
69
|
+
reports: ReportRow[];
|
|
70
|
+
signals: Signal[];
|
|
71
|
+
canPromote: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type ActionEntry = ActionRow & { label: string; undoable: boolean; byLabel: string };
|
|
75
|
+
|
|
76
|
+
const SEVERITY_ORDER: Record<string, number> = { error: 0, warn: 1, info: 2 };
|
|
77
|
+
|
|
78
|
+
export function parseSignals(value: unknown): Signal[] {
|
|
79
|
+
let parsed: unknown = value;
|
|
80
|
+
if (typeof value === "string") {
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(value);
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(parsed)) return [];
|
|
88
|
+
return parsed
|
|
89
|
+
.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
|
|
90
|
+
.map((entry) => ({
|
|
91
|
+
source: String(entry.source ?? ""),
|
|
92
|
+
policy: String(entry.policy ?? ""),
|
|
93
|
+
detector: String(entry.detector ?? ""),
|
|
94
|
+
severity: String(entry.severity ?? "info"),
|
|
95
|
+
excerpt: String(entry.excerpt ?? ""),
|
|
96
|
+
}))
|
|
97
|
+
.sort((left, right) => (SEVERITY_ORDER[left.severity] ?? 3) - (SEVERITY_ORDER[right.severity] ?? 3));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** An address a moderator may follow: http or https only, never `javascript:` or `data:`. */
|
|
101
|
+
export function safeHttpUrl(value: string | null | undefined): string | null {
|
|
102
|
+
if (!value) return null;
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(value);
|
|
105
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function newestFirst<T extends { createdAt: string; id: string }>(rows: T[]): T[] {
|
|
112
|
+
return [...rows].sort((left, right) => (left.createdAt === right.createdAt ? right.id.localeCompare(left.id) : right.createdAt.localeCompare(left.createdAt)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function reportsByTarget(reports: ReportRow[]): Map<string, ReportRow[]> {
|
|
116
|
+
const grouped = new Map<string, ReportRow[]>();
|
|
117
|
+
for (const report of reports) {
|
|
118
|
+
if (report.state !== "open") continue;
|
|
119
|
+
grouped.set(report.target, [...(grouped.get(report.target) ?? []), report]);
|
|
120
|
+
}
|
|
121
|
+
return grouped;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function submissionRow(row: SubmissionRow, handles: Record<string, string>, reports: Map<string, ReportRow[]>): ModerationRow {
|
|
125
|
+
return {
|
|
126
|
+
kind: "submission",
|
|
127
|
+
id: row.id,
|
|
128
|
+
readerId: row.readerId,
|
|
129
|
+
handle: handles[row.readerId] ?? null,
|
|
130
|
+
createdAt: row.createdAt,
|
|
131
|
+
state: row.state,
|
|
132
|
+
title: row.title,
|
|
133
|
+
url: safeHttpUrl(row.canonicalUrl),
|
|
134
|
+
text: row.note ?? "",
|
|
135
|
+
subject: row.itemId ?? row.id,
|
|
136
|
+
reports: reports.get(row.id) ?? [],
|
|
137
|
+
signals: parseSignals(row.signals),
|
|
138
|
+
canPromote: row.state === "pending",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function commentRow(row: CommentRow, handles: Record<string, string>, reports: Map<string, ReportRow[]>): ModerationRow {
|
|
143
|
+
return {
|
|
144
|
+
kind: "comment",
|
|
145
|
+
id: row.id,
|
|
146
|
+
readerId: row.readerId,
|
|
147
|
+
handle: handles[row.readerId] ?? null,
|
|
148
|
+
createdAt: row.createdAt,
|
|
149
|
+
state: row.state,
|
|
150
|
+
title: "",
|
|
151
|
+
url: null,
|
|
152
|
+
text: row.body,
|
|
153
|
+
subject: row.subjectKey,
|
|
154
|
+
reports: reports.get(row.id) ?? [],
|
|
155
|
+
signals: parseSignals(row.signals),
|
|
156
|
+
canPromote: false,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Everything waiting for a decision: new items and reported ones, newest first. Deleted comments are the author's own and are not in it. */
|
|
161
|
+
export function buildQueue(input: { submissions: SubmissionRow[]; comments: CommentRow[]; reports: ReportRow[]; handles: Record<string, string> }): ModerationRow[] {
|
|
162
|
+
const reports = reportsByTarget(input.reports);
|
|
163
|
+
const rows = [
|
|
164
|
+
...input.submissions.filter((row) => (row.moderation ?? "new") === "new").map((row) => submissionRow(row, input.handles, reports)),
|
|
165
|
+
...input.comments.filter((row) => (row.moderation ?? "new") === "new" && row.state !== "deleted").map((row) => commentRow(row, input.handles, reports)),
|
|
166
|
+
];
|
|
167
|
+
return newestFirst(rows);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Items a moderator or a pre-moderation hold keeps out of the public files, newest first, so they can be brought back. */
|
|
171
|
+
export function buildHidden(input: { submissions: SubmissionRow[]; comments: CommentRow[]; reports: ReportRow[]; handles: Record<string, string> }): ModerationRow[] {
|
|
172
|
+
const reports = reportsByTarget(input.reports);
|
|
173
|
+
return newestFirst([
|
|
174
|
+
...input.submissions.filter((row) => row.state === "hidden").map((row) => submissionRow(row, input.handles, reports)),
|
|
175
|
+
...input.comments.filter((row) => row.state === "hidden").map((row) => commentRow(row, input.handles, reports)),
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ACTION_LABELS: Record<string, string> = {
|
|
180
|
+
approve: "Approved",
|
|
181
|
+
hide: "Hidden",
|
|
182
|
+
unhide: "Shown again",
|
|
183
|
+
ban: "Banned",
|
|
184
|
+
unban: "Unbanned",
|
|
185
|
+
limit: "Limited",
|
|
186
|
+
promote: "Promoted to a draft",
|
|
187
|
+
undo: "Undid an action",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** The recent log, newest first. An action can be undone while nothing has undone it, and a promotion never. */
|
|
191
|
+
export function buildActionLog(actions: ActionRow[], limit = 50): ActionEntry[] {
|
|
192
|
+
return [...actions]
|
|
193
|
+
.sort((left, right) => (left.at === right.at ? right.id.localeCompare(left.id) : right.at.localeCompare(left.at)))
|
|
194
|
+
.slice(0, limit)
|
|
195
|
+
.map((entry) => ({
|
|
196
|
+
...entry,
|
|
197
|
+
label: ACTION_LABELS[entry.action] ?? entry.action,
|
|
198
|
+
byLabel: entry.by === "ai" ? "AI moderator" : "A moderator",
|
|
199
|
+
undoable: !entry.undoneBy && entry.action !== "promote",
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export type Receipt = { status?: string | null; id?: string | null; error?: string | null; message?: string | null };
|
|
204
|
+
|
|
205
|
+
export function receiptMessage(receipt: Receipt | null | undefined): string {
|
|
206
|
+
if (!receipt) return "The action did not answer. Reload and check.";
|
|
207
|
+
if (receipt.status === "ok") return "Done.";
|
|
208
|
+
return receipt.message || "The action was refused.";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function moderationModeText(mode: unknown): string {
|
|
212
|
+
if (mode === "off") return "Moderation is off for this site: nothing waits in a queue. You can still hide content and ban readers.";
|
|
213
|
+
if (mode === "human") return "New submissions and comments are public at once and wait here for a decision, unless this site holds them until approved.";
|
|
214
|
+
if (mode === "ai") return "An AI moderator reads each new submission and comment and allows or hides it, with a reason in the log. Its decisions are public. You can undo any of them, and what you decide it never judges again.";
|
|
215
|
+
return "Moderation is on.";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function shortReader(readerId: string, handle: string | null): string {
|
|
219
|
+
return handle ? `${handle} (${readerId.slice(0, 8)})` : `reader ${readerId.slice(0, 8)}`;
|
|
220
|
+
}
|