@anthusai/papyrus 5.19.0 → 5.20.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/data/resource.js +1 -0
- package/amplify/data/resource.ts +1 -0
- package/amplify/data/schema.js +1 -0
- package/amplify/data/schema.ts +1 -0
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +3 -0
- package/amplify/functions/editorial-checks/handler.py +2 -2
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/plugins/reader-steering/config.js +111 -0
- package/amplify/plugins/reader-steering/config.ts +158 -0
- package/amplify/plugins/reader-steering/function/handler.py +544 -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 +139 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +162 -0
- package/amplify/plugins/reader-steering/schema.js +40 -0
- package/amplify/plugins/reader-steering/schema.ts +42 -0
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +3 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -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/retired/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/components/publication-cyclotron-panel.tsx +6 -1
- package/components/reference-curation-controls.tsx +1 -1
- package/components/reference-cyclotron-priming.tsx +54 -15
- package/infra/amplify-app-shell.js +15 -1
- package/infra/amplify-app-shell.ts +17 -1
- package/infra/site-config.js +12 -2
- package/infra/site-config.ts +19 -2
- package/lib/editorial-checks.ts +6 -2
- package/lib/prospectus-session.ts +1 -0
- package/lib/prospectus-sign-in-routes.ts +2 -2
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +143 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/package.json +9 -1
- package/routes.manifest.json +56 -1
|
@@ -4,7 +4,7 @@ import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
|
4
4
|
import { SecureStringParameter } from "./secure-string-parameter.js";
|
|
5
5
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
6
6
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
7
|
-
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
7
|
+
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
8
8
|
function environmentVariables(variables) {
|
|
9
9
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
10
10
|
}
|
|
@@ -123,6 +123,20 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
123
123
|
resources: [prospectusSessionSealing.parameterArn],
|
|
124
124
|
}));
|
|
125
125
|
}
|
|
126
|
+
if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
|
|
127
|
+
// Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
|
|
128
|
+
// route through the CMS route /api/reader/retired, which it authenticates with this secret.
|
|
129
|
+
const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
|
|
130
|
+
parameterName: resolveReaderPollSecretParameterName(config),
|
|
131
|
+
description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
|
|
132
|
+
});
|
|
133
|
+
prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
|
|
134
|
+
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
135
|
+
sid: "ReadReaderPollSecret",
|
|
136
|
+
actions: ["ssm:GetParameter"],
|
|
137
|
+
resources: [readerPollSecret.parameterArn],
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
126
140
|
if (prospectusAccountsApiStageArn) {
|
|
127
141
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
128
142
|
sid: "InvokeProspectusAccountsAsRegisteredSite",
|
|
@@ -5,7 +5,7 @@ import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
|
5
5
|
import { SecureStringParameter } from "./secure-string-parameter";
|
|
6
6
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
7
7
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
8
|
-
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
8
|
+
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
9
9
|
|
|
10
10
|
function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
|
|
11
11
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
@@ -136,6 +136,22 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
136
136
|
}),
|
|
137
137
|
);
|
|
138
138
|
}
|
|
139
|
+
if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
|
|
140
|
+
// Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
|
|
141
|
+
// route through the CMS route /api/reader/retired, which it authenticates with this secret.
|
|
142
|
+
const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
|
|
143
|
+
parameterName: resolveReaderPollSecretParameterName(config),
|
|
144
|
+
description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
|
|
145
|
+
});
|
|
146
|
+
prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
|
|
147
|
+
computeRole.addToPolicy(
|
|
148
|
+
new iam.PolicyStatement({
|
|
149
|
+
sid: "ReadReaderPollSecret",
|
|
150
|
+
actions: ["ssm:GetParameter"],
|
|
151
|
+
resources: [readerPollSecret.parameterArn],
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
139
155
|
if (prospectusAccountsApiStageArn) {
|
|
140
156
|
computeRole.addToPolicy(
|
|
141
157
|
new iam.PolicyStatement({
|
package/infra/site-config.js
CHANGED
|
@@ -35,7 +35,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
35
35
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
36
36
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
37
37
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
38
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_SITE_ORIGIN"];
|
|
38
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
39
39
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
40
40
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
41
41
|
"GET/v1/identities/me",
|
|
@@ -63,6 +63,9 @@ function originOfRedirectUrl(value) {
|
|
|
63
63
|
export function resolveProspectusSessionSealingParameterName(config) {
|
|
64
64
|
return `/papyrus/${config.siteId}/prospectus-session-sealing`;
|
|
65
65
|
}
|
|
66
|
+
export function resolveReaderPollSecretParameterName(config) {
|
|
67
|
+
return `/papyrus/${config.siteId}/reader-poll-secret`;
|
|
68
|
+
}
|
|
66
69
|
export function resolveRevalidateSecretParameterName(config) {
|
|
67
70
|
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
68
71
|
}
|
|
@@ -222,7 +225,7 @@ function parseStagingPreview(value) {
|
|
|
222
225
|
}
|
|
223
226
|
function parseCms(value) {
|
|
224
227
|
const cmsRecord = requireObject(value, "cms");
|
|
225
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
|
|
228
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
|
|
226
229
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
227
230
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
228
231
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -233,6 +236,12 @@ function parseCms(value) {
|
|
|
233
236
|
if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
|
|
234
237
|
fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
|
|
235
238
|
}
|
|
239
|
+
if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
|
|
240
|
+
fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
|
|
241
|
+
}
|
|
242
|
+
if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
|
|
243
|
+
fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
|
|
244
|
+
}
|
|
236
245
|
if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
|
|
237
246
|
fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
|
|
238
247
|
}
|
|
@@ -250,6 +259,7 @@ function parseCms(value) {
|
|
|
250
259
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
251
260
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
252
261
|
deployStackInBuild: cmsRecord.deployStackInBuild,
|
|
262
|
+
readerRetirementPoll: cmsRecord.readerRetirementPoll,
|
|
253
263
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
254
264
|
prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
|
|
255
265
|
};
|
package/infra/site-config.ts
CHANGED
|
@@ -44,6 +44,12 @@ export type CmsConfig = {
|
|
|
44
44
|
stagingDomainPrefix?: string;
|
|
45
45
|
redirects?: CmsRedirect[];
|
|
46
46
|
prospectusAccountsApiStageArn?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The site is a public-reader site that polls Prospectus for retired reader subjects. The template then generates a
|
|
49
|
+
* shared secret that the scheduled snapshot run presents to the CMS route `/api/reader/retired` (requires
|
|
50
|
+
* `prospectusAccountsApiStageArn`; docs/reader-steering.md).
|
|
51
|
+
*/
|
|
52
|
+
readerRetirementPoll?: boolean;
|
|
47
53
|
deployStackInBuild?: boolean;
|
|
48
54
|
};
|
|
49
55
|
|
|
@@ -146,7 +152,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
146
152
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
147
153
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
148
154
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
149
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_SITE_ORIGIN"];
|
|
155
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
150
156
|
|
|
151
157
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
152
158
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
@@ -177,6 +183,10 @@ export function resolveProspectusSessionSealingParameterName(config: Pick<Amplif
|
|
|
177
183
|
return `/papyrus/${config.siteId}/prospectus-session-sealing`;
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
export function resolveReaderPollSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
|
|
187
|
+
return `/papyrus/${config.siteId}/reader-poll-secret`;
|
|
188
|
+
}
|
|
189
|
+
|
|
180
190
|
export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
|
|
181
191
|
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
182
192
|
}
|
|
@@ -336,7 +346,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
|
|
|
336
346
|
|
|
337
347
|
function parseCms(value: unknown): CmsConfig {
|
|
338
348
|
const cmsRecord = requireObject(value, "cms");
|
|
339
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
|
|
349
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
|
|
340
350
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
341
351
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
342
352
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -347,6 +357,12 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
347
357
|
if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
|
|
348
358
|
fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
|
|
349
359
|
}
|
|
360
|
+
if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
|
|
361
|
+
fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
|
|
362
|
+
}
|
|
363
|
+
if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
|
|
364
|
+
fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
|
|
365
|
+
}
|
|
350
366
|
if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
|
|
351
367
|
fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
|
|
352
368
|
}
|
|
@@ -369,6 +385,7 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
369
385
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
370
386
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
371
387
|
deployStackInBuild: cmsRecord.deployStackInBuild as boolean | undefined,
|
|
388
|
+
readerRetirementPoll: cmsRecord.readerRetirementPoll as boolean | undefined,
|
|
372
389
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
373
390
|
prospectusAccountsApiStageArn: optionalString(
|
|
374
391
|
cmsRecord.prospectusAccountsApiStageArn,
|
package/lib/editorial-checks.ts
CHANGED
|
@@ -38,7 +38,8 @@ export type EditorialSegment = {
|
|
|
38
38
|
text?: string;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/** "skipped": Papyrus did not ask for judgments because the publication has no mission or policy for them to apply. */
|
|
42
|
+
export type LaneStatus = { status: "ran" | "disabled" | "unavailable" | "failed" | "partial" | "skipped"; reason?: string; detectors?: number };
|
|
42
43
|
|
|
43
44
|
export type EditorialCheckResult = {
|
|
44
45
|
schemaVersion: 1;
|
|
@@ -49,7 +50,8 @@ export type EditorialCheckResult = {
|
|
|
49
50
|
protected: { kind: string; start: number; end: number }[];
|
|
50
51
|
findings: EditorialFinding[];
|
|
51
52
|
coverage: unknown[];
|
|
52
|
-
lanes
|
|
53
|
+
/** lanes.section is "skipped" when the story's section has no NewsroomSection record to take a mission and policy from. */
|
|
54
|
+
lanes: { rules: LaneStatus; classifier: LaneStatus; section?: LaneStatus };
|
|
53
55
|
usage?: Record<string, number>;
|
|
54
56
|
};
|
|
55
57
|
|
|
@@ -164,8 +166,10 @@ export function describeProbability(finding: EditorialFinding): string | null {
|
|
|
164
166
|
|
|
165
167
|
export function laneNotice(result: EditorialCheckResult): string | null {
|
|
166
168
|
const classifier = result.lanes.classifier;
|
|
169
|
+
if (classifier.status === "skipped") return `Mission-based checks skipped: ${classifier.reason ?? "this publication has no mission yet"}.`;
|
|
167
170
|
if (classifier.status === "partial") return `Some sentences were not judged: ${classifier.reason ?? "the check ran out of time"}.`;
|
|
168
171
|
if (classifier.status === "failed") return `The judgment checks failed: ${classifier.reason ?? "unknown error"}. Rule checks still ran.`;
|
|
169
172
|
if (classifier.status === "unavailable" && (classifier.detectors ?? 0) === 0 && classifier.reason) return `Judgment checks are not configured: ${classifier.reason}.`;
|
|
173
|
+
if (result.lanes.section?.status === "skipped") return `Section-based checks skipped: ${result.lanes.section.reason ?? "no section configuration"}.`;
|
|
170
174
|
return null;
|
|
171
175
|
}
|
|
@@ -166,6 +166,7 @@ async function completeCallback(
|
|
|
166
166
|
personId: signedIn.person.id,
|
|
167
167
|
accessToken: signedIn.accessToken,
|
|
168
168
|
accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
|
|
169
|
+
...(signedIn.person.siteSubject ? { siteSubject: signedIn.person.siteSubject } : {}),
|
|
169
170
|
};
|
|
170
171
|
const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, siteOriginFor(request, options)), { status: 303, headers: NO_STORE_HEADERS });
|
|
171
172
|
response.cookies.set(
|
|
@@ -198,11 +199,10 @@ async function handleProspectusSessionReadRoute(request: NextRequest, options: P
|
|
|
198
199
|
return response;
|
|
199
200
|
}
|
|
200
201
|
const publishingAccounts = publishingAccountsForSite(directory.accounts, siteProductFor(options));
|
|
202
|
+
// Only what the UI reads: no person id, siteSubject or account list ever goes to the browser.
|
|
201
203
|
return NextResponse.json(
|
|
202
204
|
{
|
|
203
205
|
signedIn: true,
|
|
204
|
-
person: directory.person,
|
|
205
|
-
accounts: directory.accounts,
|
|
206
206
|
cyclotronLabAccess: directory.accounts.some((account) =>
|
|
207
207
|
account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
|
|
208
208
|
),
|
|
@@ -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,143 @@
|
|
|
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
|
+
export const READER_MAXIMUM_SUBJECTS = 50;
|
|
11
|
+
export const READER_SUBJECT_KEY = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
12
|
+
|
|
13
|
+
export type ReaderAction = "me" | "handle" | "vote" | "rating";
|
|
14
|
+
|
|
15
|
+
export interface ReaderActionEvent {
|
|
16
|
+
publication: string;
|
|
17
|
+
siteSubject: string;
|
|
18
|
+
isStaff: boolean;
|
|
19
|
+
action: ReaderAction;
|
|
20
|
+
payload: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ReaderActionResult {
|
|
24
|
+
statusCode: number;
|
|
25
|
+
body: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ReaderActionInvoker = (functionName: string, event: ReaderActionEvent) => Promise<ReaderActionResult>;
|
|
29
|
+
|
|
30
|
+
export interface ReaderApiOptions {
|
|
31
|
+
environment?: Record<string, string | undefined>;
|
|
32
|
+
invoke?: ReaderActionInvoker;
|
|
33
|
+
actionsFunctionName?: string | null;
|
|
34
|
+
loadSealingSecret?: () => Promise<string | null>;
|
|
35
|
+
nowInSeconds?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
39
|
+
|
|
40
|
+
function failure(status: number, error: string, message: string, extra: Record<string, unknown> = {}, headers: Record<string, string> = {}): NextResponse {
|
|
41
|
+
return NextResponse.json({ error, message, ...extra }, { status, headers: { ...NO_STORE_HEADERS, ...headers } });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function identityUnavailableResponse(): NextResponse {
|
|
45
|
+
return failure(503, "reader_identity_unavailable", "Reader sign-in is not open yet.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function actionsFunctionNameFrom(options: ReaderApiOptions, environment: Record<string, string | undefined>): string | null {
|
|
49
|
+
if (options.actionsFunctionName !== undefined) return options.actionsFunctionName;
|
|
50
|
+
const fromEnvironment = (environment[READER_ACTIONS_FUNCTION_ENV] ?? "").trim();
|
|
51
|
+
if (fromEnvironment) return fromEnvironment;
|
|
52
|
+
const outputs = readAmplifyOutputsFile() as { custom?: { readerSteering?: { actionsFunction?: unknown } | null } } | null;
|
|
53
|
+
const fromOutputs = outputs?.custom?.readerSteering?.actionsFunction;
|
|
54
|
+
return typeof fromOutputs === "string" && fromOutputs.trim() ? fromOutputs.trim() : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function invokeReaderActionsLambda(functionName: string, event: ReaderActionEvent): Promise<ReaderActionResult> {
|
|
58
|
+
const { InvokeCommand, LambdaClient } = await import("@aws-sdk/client-lambda");
|
|
59
|
+
const response = await new LambdaClient({}).send(
|
|
60
|
+
new InvokeCommand({ FunctionName: functionName, InvocationType: "RequestResponse", Payload: Buffer.from(JSON.stringify(event)) }),
|
|
61
|
+
);
|
|
62
|
+
if (response.FunctionError || !response.Payload) throw new Error("reader-actions failed");
|
|
63
|
+
const result = JSON.parse(Buffer.from(response.Payload).toString("utf8")) as Partial<ReaderActionResult>;
|
|
64
|
+
if (typeof result.statusCode !== "number" || typeof result.body !== "object" || result.body === null) throw new Error("reader-actions returned an unexpected shape");
|
|
65
|
+
return { statusCode: result.statusCode, body: result.body as Record<string, unknown> };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readJsonBody(request: NextRequest): Promise<{ payload: Record<string, unknown> } | { response: NextResponse }> {
|
|
69
|
+
const contentType = (request.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
70
|
+
if (contentType !== "application/json") return { response: failure(415, "unsupported_media_type", "Send application/json.") };
|
|
71
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
72
|
+
if (Number.isFinite(declared) && declared > READER_MAXIMUM_BODY_BYTES) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
73
|
+
const text = await request.text();
|
|
74
|
+
if (Buffer.byteLength(text, "utf8") > READER_MAXIMUM_BODY_BYTES) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(text) as unknown;
|
|
77
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
|
|
78
|
+
return { payload: parsed as Record<string, unknown> };
|
|
79
|
+
} catch {
|
|
80
|
+
return { response: failure(400, "invalid_json", "The request body is not a JSON object.") };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function subjectsFromQuery(request: NextRequest): { payload: Record<string, unknown> } | { response: NextResponse } {
|
|
85
|
+
const raw = request.nextUrl.searchParams.get("subjects") ?? "";
|
|
86
|
+
const subjects = raw === "" ? [] : raw.split(",");
|
|
87
|
+
if (subjects.length > READER_MAXIMUM_SUBJECTS || !subjects.every((subject) => READER_SUBJECT_KEY.test(subject))) {
|
|
88
|
+
return { response: failure(400, "invalid_subjects", `Send at most ${READER_MAXIMUM_SUBJECTS} comma separated item keys.`) };
|
|
89
|
+
}
|
|
90
|
+
return { payload: { subjects } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function signedInReaderSession(request: NextRequest, options: ReaderApiOptions): Promise<ProspectusSignedInSession | null | "unavailable"> {
|
|
94
|
+
const sealingSecret = await (options.loadSealingSecret ?? (() => loadProspectusSessionSealingSecret({ environment: options.environment })))();
|
|
95
|
+
if (!sealingSecret) return "unavailable";
|
|
96
|
+
return signedInProspectusSession(request, sealingSecret, options.nowInSeconds);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One reader route: session, readerSubject (503 while Prospectus has no siteSubject), Origin, JSON size, then the
|
|
101
|
+
* reader-actions function. The siteSubject only travels to the function and never into a response.
|
|
102
|
+
*/
|
|
103
|
+
export async function handleReaderRequest(request: NextRequest, action: ReaderAction, options: ReaderApiOptions = {}): Promise<NextResponse> {
|
|
104
|
+
const environment = options.environment ?? process.env;
|
|
105
|
+
let origin: string;
|
|
106
|
+
try {
|
|
107
|
+
origin = publicSiteOrigin(request, environment);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
const session = await signedInReaderSession(request, options);
|
|
113
|
+
if (session === "unavailable") return identityUnavailableResponse();
|
|
114
|
+
if (!session) return failure(401, "signed_out", "Sign in to continue.");
|
|
115
|
+
const siteSubject = readerSubject(session, environment);
|
|
116
|
+
if (siteSubject === null) return identityUnavailableResponse();
|
|
117
|
+
|
|
118
|
+
let parsed: { payload: Record<string, unknown> } | { response: NextResponse };
|
|
119
|
+
if (action === "me") {
|
|
120
|
+
parsed = subjectsFromQuery(request);
|
|
121
|
+
} else {
|
|
122
|
+
if (request.headers.get("origin") !== origin) return failure(403, "origin_mismatch", "This request did not come from this site.");
|
|
123
|
+
parsed = await readJsonBody(request);
|
|
124
|
+
}
|
|
125
|
+
if ("response" in parsed) return parsed.response;
|
|
126
|
+
|
|
127
|
+
const functionName = actionsFunctionNameFrom(options, environment);
|
|
128
|
+
if (!functionName) return failure(503, "reader_actions_unavailable", "Reader features are not enabled for this site.");
|
|
129
|
+
try {
|
|
130
|
+
const result = await (options.invoke ?? invokeReaderActionsLambda)(functionName, {
|
|
131
|
+
publication: new URL(origin).host,
|
|
132
|
+
siteSubject,
|
|
133
|
+
isStaff: readerIsStaff(session),
|
|
134
|
+
action,
|
|
135
|
+
payload: parsed.payload,
|
|
136
|
+
});
|
|
137
|
+
const retryAfter: Record<string, string> = typeof result.body.retryAfter === "number" ? { "retry-after": String(result.body.retryAfter) } : {};
|
|
138
|
+
return NextResponse.json(result.body, { status: result.statusCode, headers: { ...NO_STORE_HEADERS, ...retryAfter } });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error(`Reader action ${action} failed (${error instanceof Error ? error.name : "error"}).`);
|
|
141
|
+
return failure(502, "reader_actions_failed", "Reader features could not be reached.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
3
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
4
|
+
import { resolveProspectusSignInRuntime, type ProspectusSignInRuntime } from "./prospectus-session";
|
|
5
|
+
import { ProspectusAccountsError } from "./prospectus-site-identity";
|
|
6
|
+
import { secretsMatch } from "./revalidate-secret";
|
|
7
|
+
|
|
8
|
+
export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARAMETER";
|
|
9
|
+
export const READER_POLL_SECRET_HEADER = "x-papyrus-reader-poll-secret";
|
|
10
|
+
export const READER_POLL_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
|
|
11
|
+
const CURSOR_PATTERN = /^[A-Za-z0-9._~=+/-]{1,512}$/;
|
|
12
|
+
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
13
|
+
|
|
14
|
+
export interface ReaderRetirementRouteOptions {
|
|
15
|
+
environment?: Record<string, string | undefined>;
|
|
16
|
+
readSecret?: (parameterName: string) => Promise<string | null>;
|
|
17
|
+
resolveRuntime?: (siteOrigin: string) => Promise<Pick<ProspectusSignInRuntime, "accounts"> | null>;
|
|
18
|
+
now?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type CachedSecret = { parameterName: string; value: string; expiresAt: number };
|
|
22
|
+
let cachedSecret: CachedSecret | null = null;
|
|
23
|
+
let defaultSsmClient: SSMClient | null = null;
|
|
24
|
+
|
|
25
|
+
export function resetReaderPollSecretCache(): void {
|
|
26
|
+
cachedSecret = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readSecretFromSsm(parameterName: string): Promise<string | null> {
|
|
30
|
+
defaultSsmClient ??= new SSMClient({});
|
|
31
|
+
const response = await defaultSsmClient.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
|
|
32
|
+
return response.Parameter?.Value?.trim() || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loadReaderPollSecret(options: ReaderRetirementRouteOptions, environment: Record<string, string | undefined>): Promise<string | null> {
|
|
36
|
+
const parameterName = environment[READER_POLL_SECRET_PARAMETER_ENV]?.trim();
|
|
37
|
+
if (!parameterName) return null;
|
|
38
|
+
const now = options.now ?? Date.now();
|
|
39
|
+
if (cachedSecret && cachedSecret.parameterName === parameterName && cachedSecret.expiresAt > now) return cachedSecret.value;
|
|
40
|
+
try {
|
|
41
|
+
const value = (await (options.readSecret ?? readSecretFromSsm)(parameterName))?.trim();
|
|
42
|
+
if (!value) return null;
|
|
43
|
+
cachedSecret = { parameterName, value, expiresAt: now + READER_POLL_SECRET_CACHE_MILLISECONDS };
|
|
44
|
+
return value;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error(`Reader poll secret could not be read (${error instanceof Error ? error.name : "error"}).`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function failure(status: number, error: string, message: string): NextResponse {
|
|
52
|
+
return NextResponse.json({ error, message }, { status, headers: NO_STORE_HEADERS });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The scheduled snapshot run's way to Prospectus's retired-subject route. Prospectus admits one caller per site, this
|
|
57
|
+
* app's compute role, so the run cannot call it directly: it calls this route with a shared secret from SSM, and the route
|
|
58
|
+
* calls Prospectus as the compute role and passes the answer through. Anyone without the secret gets 401, and a site
|
|
59
|
+
* without the secret parameter gets 503, so this is never open.
|
|
60
|
+
*/
|
|
61
|
+
export async function handleReaderRetiredSubjects(request: NextRequest, options: ReaderRetirementRouteOptions = {}): Promise<NextResponse> {
|
|
62
|
+
const environment = options.environment ?? process.env;
|
|
63
|
+
const expected = await loadReaderPollSecret(options, environment);
|
|
64
|
+
if (!expected) return failure(503, "reader_poll_unavailable", "The retirement poll is not configured for this site.");
|
|
65
|
+
if (!secretsMatch(request.headers.get(READER_POLL_SECRET_HEADER), expected)) return failure(401, "unauthorized", "Missing or wrong poll secret.");
|
|
66
|
+
|
|
67
|
+
const since = request.nextUrl.searchParams.get("since") ?? undefined;
|
|
68
|
+
const unknownParameters = [...request.nextUrl.searchParams.keys()].filter((key) => key !== "since");
|
|
69
|
+
if (unknownParameters.length > 0 || (since !== undefined && !CURSOR_PATTERN.test(since))) {
|
|
70
|
+
return failure(400, "invalid_since", "Send only a since cursor.");
|
|
71
|
+
}
|
|
72
|
+
let siteOrigin: string;
|
|
73
|
+
try {
|
|
74
|
+
siteOrigin = publicSiteOrigin(request, environment);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
const runtime = await (options.resolveRuntime ?? ((origin) => resolveProspectusSignInRuntime(origin, { environment })))(siteOrigin);
|
|
80
|
+
if (!runtime) return failure(503, "prospectus_sign_in_unavailable", "Prospectus is not configured for this site.");
|
|
81
|
+
try {
|
|
82
|
+
const page = await runtime.accounts.retiredSiteSubjects(since);
|
|
83
|
+
return NextResponse.json({ retired: page.retired, cursor: page.cursor, more: page.more }, { headers: NO_STORE_HEADERS });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof ProspectusAccountsError) return failure(error.status === 400 ? 400 : 502, error.code, error.message);
|
|
86
|
+
console.error(`Prospectus retired subjects failed (${error instanceof Error ? error.name : "error"}).`);
|
|
87
|
+
return failure(502, "prospectus_unavailable", "Prospectus could not be reached.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Reader subject
|
|
2
|
+
|
|
3
|
+
`readerSubject(session)` in `reader-subject.ts` is the only place a reader's identity for the reader API is
|
|
4
|
+
decided. It returns the per-site pairwise `siteSubject` (`ss1_` plus 43 base64url characters) that Prospectus
|
|
5
|
+
puts on `person.siteSubject`, or `null`.
|
|
6
|
+
|
|
7
|
+
## Production
|
|
8
|
+
|
|
9
|
+
Only the `siteSubject` the sealed session cookie carries counts. While Prospectus does not return one, the
|
|
10
|
+
accessor returns `null`, every reader route answers `503 reader_identity_unavailable`, and the reader features
|
|
11
|
+
stay off. There is no other flag. The global Prospectus person id is never used as a reader key.
|
|
12
|
+
|
|
13
|
+
## Development and test only
|
|
14
|
+
|
|
15
|
+
When `SITE_ENV` is `development` or `test`, `NODE_ENV` is not `production` and `PAPYRUS_READER_DEV_SECRET`
|
|
16
|
+
holds at least 16 characters, a session without a `siteSubject` gets the interim value
|
|
17
|
+
`ssdev_` plus `base64url(HMAC-SHA-256(devSecret, personId))`.
|
|
18
|
+
|
|
19
|
+
That path never runs in production. No remap from `ssdev_` values to `ss1_` values is ever needed: production
|
|
20
|
+
reader writes stay closed until `siteSubject` is live, so no production row is ever keyed on an interim value.
|
|
21
|
+
The `reader-actions` function also refuses `ssdev_` subjects unless the site was synthesized with a
|
|
22
|
+
development or test `SITE_ENV`.
|
|
23
|
+
|
|
24
|
+
## Staff
|
|
25
|
+
|
|
26
|
+
`readerIsStaff(session)` reads the `cognito:groups` claim from the access token inside the sealed session
|
|
27
|
+
cookie (`editor` or `admin`). The cookie is written by the site's own callback route, so the token is trusted
|
|
28
|
+
the way the rest of the session is. The reader API passes the result to the function as `isStaff`; clients never
|
|
29
|
+
send it.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import type { ProspectusSignedInSession } from "./prospectus-session";
|
|
3
|
+
|
|
4
|
+
export const SITE_SUBJECT_PATTERN = /^ss1_[A-Za-z0-9_-]{43}$/;
|
|
5
|
+
export const INTERIM_SUBJECT_PATTERN = /^ssdev_[A-Za-z0-9_-]{43}$/;
|
|
6
|
+
export const READER_DEV_SECRET_ENV = "PAPYRUS_READER_DEV_SECRET";
|
|
7
|
+
export const READER_STAFF_GROUPS = ["editor", "admin"] as const;
|
|
8
|
+
|
|
9
|
+
const MINIMUM_DEV_SECRET_LENGTH = 16;
|
|
10
|
+
const INTERIM_SUBJECT_PREFIX = "ssdev_";
|
|
11
|
+
const INTERIM_ENVIRONMENTS = ["development", "test"];
|
|
12
|
+
|
|
13
|
+
type EnvironmentVariables = Record<string, string | undefined>;
|
|
14
|
+
|
|
15
|
+
export function interimSubjectsAllowed(environment: EnvironmentVariables = process.env): boolean {
|
|
16
|
+
if (environment.NODE_ENV === "production") return false;
|
|
17
|
+
const siteEnvironment = (environment.SITE_ENV ?? "").trim();
|
|
18
|
+
if (!INTERIM_ENVIRONMENTS.includes(siteEnvironment)) return false;
|
|
19
|
+
return (environment[READER_DEV_SECRET_ENV] ?? "").length >= MINIMUM_DEV_SECRET_LENGTH;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function interimSubject(personId: string, devSecret: string): string {
|
|
23
|
+
return INTERIM_SUBJECT_PREFIX + createHmac("sha256", devSecret).update(personId).digest("base64url");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The reader's per-site pairwise id, or null. Production reads only the siteSubject Prospectus returned;
|
|
28
|
+
* the interim `ssdev_` value exists in development and test only (lib/reader-subject.md).
|
|
29
|
+
*/
|
|
30
|
+
export function readerSubject(
|
|
31
|
+
session: Pick<ProspectusSignedInSession, "personId" | "siteSubject"> | null | undefined,
|
|
32
|
+
environment: EnvironmentVariables = process.env,
|
|
33
|
+
): string | null {
|
|
34
|
+
if (!session) return null;
|
|
35
|
+
if (typeof session.siteSubject === "string" && SITE_SUBJECT_PATTERN.test(session.siteSubject)) return session.siteSubject;
|
|
36
|
+
if (!interimSubjectsAllowed(environment) || !session.personId) return null;
|
|
37
|
+
return interimSubject(session.personId, environment[READER_DEV_SECRET_ENV] as string);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Staff membership from the Cognito groups claim of the access token that the sealed session cookie carries. */
|
|
41
|
+
export function readerIsStaff(session: Pick<ProspectusSignedInSession, "accessToken"> | null | undefined): boolean {
|
|
42
|
+
const token = session?.accessToken;
|
|
43
|
+
if (typeof token !== "string") return false;
|
|
44
|
+
const payload = token.split(".")[1];
|
|
45
|
+
if (!payload) return false;
|
|
46
|
+
try {
|
|
47
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { "cognito:groups"?: unknown };
|
|
48
|
+
const groups = claims["cognito:groups"];
|
|
49
|
+
return Array.isArray(groups) && groups.some((group) => (READER_STAFF_GROUPS as readonly unknown[]).includes(group));
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|