@anthusai/papyrus 1.0.0-next.61 → 1.0.0-next.62
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/app/api/cyclotron/priming-proof/route.ts +11 -0
- package/app/cyclotron/page.tsx +13 -0
- package/components/priming-proof-page.tsx +92 -0
- package/infra/amplify-app-shell.js +18 -1
- package/infra/amplify-app-shell.ts +21 -1
- package/infra/site-config.js +21 -3
- package/infra/site-config.ts +29 -3
- package/lib/approved-priming-proof-reports.ts +6 -0
- package/lib/priming-proof-report.ts +89 -0
- package/lib/prospectus-site-identity.ts +303 -0
- package/package.json +12 -1
- package/routes.manifest.json +17 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { approvedPrimingProofReport } from "../../../../lib/priming-proof-report";
|
|
3
|
+
import { SITE_BRAND } from "../../../../lib/site-brand";
|
|
4
|
+
|
|
5
|
+
export function GET() {
|
|
6
|
+
const report = approvedPrimingProofReport(SITE_BRAND.id);
|
|
7
|
+
if (report === null) {
|
|
8
|
+
return NextResponse.json({ error: "No approved priming proof report for this publication yet." }, { status: 404 });
|
|
9
|
+
}
|
|
10
|
+
return NextResponse.json(report);
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { PrimingProofPage } from "../../components/priming-proof-page";
|
|
3
|
+
import { approvedPrimingProofReport } from "../../lib/priming-proof-report";
|
|
4
|
+
import { SITE_BRAND } from "../../lib/site-brand";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Cyclotron proof | ${SITE_BRAND.articleTitleSuffix}`,
|
|
8
|
+
description: "How often the relevance classifier agrees with editors on a fixed held-out set, before and after editor labels.",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function CyclotronProof() {
|
|
12
|
+
return <PrimingProofPage report={approvedPrimingProofReport(SITE_BRAND.id)} />;
|
|
13
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatProofPercentage,
|
|
3
|
+
primingProofHeadline,
|
|
4
|
+
type PrimingProofReport,
|
|
5
|
+
} from "../lib/priming-proof-report";
|
|
6
|
+
|
|
7
|
+
function ProofMethod({ heldOutSize }: { heldOutSize: number | null }) {
|
|
8
|
+
return (
|
|
9
|
+
<section aria-labelledby="priming-proof-method" className="space-y-2 text-sm leading-relaxed">
|
|
10
|
+
<h2 id="priming-proof-method" className="text-base font-semibold uppercase tracking-wide">How the number is made</h2>
|
|
11
|
+
<p>
|
|
12
|
+
Editors already decided which candidate sources belong in this publication. A fixed, balanced sample of those
|
|
13
|
+
decisions{heldOutSize === null ? "" : ` (${heldOutSize} sources)`} is frozen as the held-out set and never used
|
|
14
|
+
for teaching. A classifier that always gives the same answer agrees on half of it.
|
|
15
|
+
</p>
|
|
16
|
+
<p>
|
|
17
|
+
Editors teach the classifier by pasting a source URL, reviewing what Papyrus extracted, and choosing publish or
|
|
18
|
+
reject. Each point below starts a fresh classifier that sees only the first N of those labels, then decides
|
|
19
|
+
every held-out source once. Nothing else changes between points: the same decision model, the publication's
|
|
20
|
+
mission and policies as the only rubric, and no automatic rubric rewriting.
|
|
21
|
+
</p>
|
|
22
|
+
</section>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function PrimingProofPage({ report }: { report: PrimingProofReport | null }) {
|
|
27
|
+
if (report === null) {
|
|
28
|
+
return (
|
|
29
|
+
<main className="mx-auto max-w-3xl space-y-6 px-4 py-10" data-priming-proof="pending">
|
|
30
|
+
<header className="space-y-2 border-b pb-4">
|
|
31
|
+
<h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
|
|
32
|
+
<p className="text-muted-foreground">No approved proof run has been published for this publication yet.</p>
|
|
33
|
+
</header>
|
|
34
|
+
<ProofMethod heldOutSize={null} />
|
|
35
|
+
</main>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const { baseline, latest, agreementChangeInPoints } = primingProofHeadline(report);
|
|
39
|
+
const [positiveLabel, negativeLabel] = [report.positiveLabel, report.labels.find((label) => label !== report.positiveLabel)];
|
|
40
|
+
return (
|
|
41
|
+
<main className="mx-auto max-w-3xl space-y-8 px-4 py-10" data-priming-proof="published">
|
|
42
|
+
<header className="space-y-3 border-b pb-4">
|
|
43
|
+
<h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
|
|
44
|
+
<p className="text-lg" data-priming-proof-headline>
|
|
45
|
+
Agreement with editors went from <strong>{formatProofPercentage(baseline.agreement)}</strong> with no labels to{" "}
|
|
46
|
+
<strong>{formatProofPercentage(latest.agreement)}</strong> after {latest.labels} labels
|
|
47
|
+
{agreementChangeInPoints === null ? "" : ` (${agreementChangeInPoints >= 0 ? "+" : ""}${agreementChangeInPoints} points)`}.
|
|
48
|
+
</p>
|
|
49
|
+
<p className="text-sm text-muted-foreground">
|
|
50
|
+
{report.publication} · question: “{report.question}” · held-out set of {report.heldOut.size} sources frozen{" "}
|
|
51
|
+
{report.heldOut.frozenAt.slice(0, 10)} · run {report.ranAt.slice(0, 10)}
|
|
52
|
+
{report.decisionModel ? ` · decision model ${report.decisionModel}` : ""}
|
|
53
|
+
</p>
|
|
54
|
+
</header>
|
|
55
|
+
<section aria-labelledby="priming-proof-points" className="space-y-2">
|
|
56
|
+
<h2 id="priming-proof-points" className="text-base font-semibold uppercase tracking-wide">Before and after</h2>
|
|
57
|
+
<div className="overflow-x-auto">
|
|
58
|
+
<table className="w-full text-left text-sm tabular-nums">
|
|
59
|
+
<thead>
|
|
60
|
+
<tr className="border-b">
|
|
61
|
+
<th scope="col" className="py-2 pr-4">Editor labels</th>
|
|
62
|
+
<th scope="col" className="py-2 pr-4">Agreement</th>
|
|
63
|
+
<th scope="col" className="py-2 pr-4">Precision ({positiveLabel})</th>
|
|
64
|
+
<th scope="col" className="py-2 pr-4">Recall ({positiveLabel})</th>
|
|
65
|
+
<th scope="col" className="py-2">Labels used</th>
|
|
66
|
+
</tr>
|
|
67
|
+
</thead>
|
|
68
|
+
<tbody>
|
|
69
|
+
{report.points.map((point) => (
|
|
70
|
+
<tr key={point.labels} className="border-b" data-priming-proof-point={point.labels}>
|
|
71
|
+
<td className="py-2 pr-4">{point.labels}</td>
|
|
72
|
+
<td className="py-2 pr-4">{formatProofPercentage(point.agreement)}</td>
|
|
73
|
+
<td className="py-2 pr-4">{formatProofPercentage(point.precision)}</td>
|
|
74
|
+
<td className="py-2 pr-4">{formatProofPercentage(point.recall)}</td>
|
|
75
|
+
<td className="py-2">
|
|
76
|
+
{point.labelsByClass[positiveLabel] ?? 0} {positiveLabel}
|
|
77
|
+
{negativeLabel ? `, ${point.labelsByClass[negativeLabel] ?? 0} ${negativeLabel}` : ""}
|
|
78
|
+
</td>
|
|
79
|
+
</tr>
|
|
80
|
+
))}
|
|
81
|
+
</tbody>
|
|
82
|
+
</table>
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
<ProofMethod heldOutSize={report.heldOut.size} />
|
|
86
|
+
<p className="break-all text-xs text-muted-foreground">
|
|
87
|
+
Held-out fingerprint {report.heldOut.fingerprint}
|
|
88
|
+
{report.priming.fingerprint ? ` · labels fingerprint ${report.priming.fingerprint}` : ""}
|
|
89
|
+
</p>
|
|
90
|
+
</main>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -3,7 +3,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
|
|
|
3
3
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
4
4
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
5
5
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
6
|
-
import { hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
6
|
+
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
7
7
|
function environmentVariables(variables) {
|
|
8
8
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
9
9
|
}
|
|
@@ -85,6 +85,17 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
85
85
|
],
|
|
86
86
|
}));
|
|
87
87
|
}
|
|
88
|
+
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
89
|
+
const prospectusEnvironment = prospectusAccountsApiStageArn
|
|
90
|
+
? { PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) }
|
|
91
|
+
: {};
|
|
92
|
+
if (prospectusAccountsApiStageArn) {
|
|
93
|
+
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
94
|
+
sid: "InvokeProspectusAccountsAsRegisteredSite",
|
|
95
|
+
actions: ["execute-api:Invoke"],
|
|
96
|
+
resources: PROSPECTUS_PAPYRUS_SITE_ROUTES.map((route) => `${prospectusAccountsApiStageArn}/${route}`),
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
88
99
|
const cmsApp = new amplify.CfnApp(this, "App", {
|
|
89
100
|
name: cmsAppName,
|
|
90
101
|
description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
|
|
@@ -134,6 +145,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
134
145
|
SITE_ENV: "production",
|
|
135
146
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
136
147
|
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
148
|
+
...prospectusEnvironment,
|
|
137
149
|
}),
|
|
138
150
|
});
|
|
139
151
|
let stagingBranch;
|
|
@@ -153,6 +165,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
153
165
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
154
166
|
SITE_ENV: "staging",
|
|
155
167
|
...stagingMode,
|
|
168
|
+
...prospectusEnvironment,
|
|
156
169
|
NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
|
|
157
170
|
? `https://${stagingDomainName}`
|
|
158
171
|
: Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
|
|
@@ -191,6 +204,10 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
191
204
|
this.cmsAppId = cmsApp.attrAppId;
|
|
192
205
|
new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
|
|
193
206
|
new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
|
|
207
|
+
new CfnOutput(this, "CmsComputeRoleArn", {
|
|
208
|
+
value: computeRole.roleArn,
|
|
209
|
+
description: `SSR compute role for ${cmsAppName}; the roleArn of its Prospectus site registration`,
|
|
210
|
+
});
|
|
194
211
|
if (stagingEnabled) {
|
|
195
212
|
new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
|
|
196
213
|
}
|
|
@@ -4,7 +4,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
|
|
|
4
4
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
5
5
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
6
6
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
7
|
-
import { AmplifyAppShellSiteConfig, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
7
|
+
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
8
8
|
|
|
9
9
|
function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
|
|
10
10
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
@@ -96,6 +96,20 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
100
|
+
const prospectusEnvironment = prospectusAccountsApiStageArn
|
|
101
|
+
? { PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) }
|
|
102
|
+
: {};
|
|
103
|
+
if (prospectusAccountsApiStageArn) {
|
|
104
|
+
computeRole.addToPolicy(
|
|
105
|
+
new iam.PolicyStatement({
|
|
106
|
+
sid: "InvokeProspectusAccountsAsRegisteredSite",
|
|
107
|
+
actions: ["execute-api:Invoke"],
|
|
108
|
+
resources: PROSPECTUS_PAPYRUS_SITE_ROUTES.map((route) => `${prospectusAccountsApiStageArn}/${route}`),
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
const cmsApp = new amplify.CfnApp(this, "App", {
|
|
100
114
|
name: cmsAppName,
|
|
101
115
|
description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
|
|
@@ -148,6 +162,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
148
162
|
SITE_ENV: "production",
|
|
149
163
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
150
164
|
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
165
|
+
...prospectusEnvironment,
|
|
151
166
|
}),
|
|
152
167
|
});
|
|
153
168
|
|
|
@@ -168,6 +183,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
168
183
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
169
184
|
SITE_ENV: "staging",
|
|
170
185
|
...stagingMode,
|
|
186
|
+
...prospectusEnvironment,
|
|
171
187
|
NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
|
|
172
188
|
? `https://${stagingDomainName}`
|
|
173
189
|
: Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
|
|
@@ -209,6 +225,10 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
209
225
|
this.cmsAppId = cmsApp.attrAppId;
|
|
210
226
|
new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
|
|
211
227
|
new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
|
|
228
|
+
new CfnOutput(this, "CmsComputeRoleArn", {
|
|
229
|
+
value: computeRole.roleArn,
|
|
230
|
+
description: `SSR compute role for ${cmsAppName}; the roleArn of its Prospectus site registration`,
|
|
231
|
+
});
|
|
212
232
|
if (stagingEnabled) {
|
|
213
233
|
new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
|
|
214
234
|
}
|
package/infra/site-config.js
CHANGED
|
@@ -16,7 +16,24 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}
|
|
|
16
16
|
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
17
17
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
18
18
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
19
|
-
const
|
|
19
|
+
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
20
|
+
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"];
|
|
21
|
+
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
22
|
+
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
23
|
+
"GET/v1/identities/me",
|
|
24
|
+
"POST/v1/identities/google/challenge",
|
|
25
|
+
"POST/v1/identities/google/resolve",
|
|
26
|
+
"GET/v1/accounts",
|
|
27
|
+
"GET/v1/accounts/*",
|
|
28
|
+
"POST/v1/accounts/*/authorize",
|
|
29
|
+
"GET/v1/account-directory",
|
|
30
|
+
"GET/v1/account-directory/*",
|
|
31
|
+
];
|
|
32
|
+
export function resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) {
|
|
33
|
+
const [, , , region, , apiPath] = prospectusAccountsApiStageArn.split(":");
|
|
34
|
+
const [apiId, stageName] = apiPath.split("/");
|
|
35
|
+
return `https://${apiId}.execute-api.${region}.amazonaws.com/${stageName}/`;
|
|
36
|
+
}
|
|
20
37
|
function originOfRedirectUrl(value) {
|
|
21
38
|
try {
|
|
22
39
|
return new URL(value).origin;
|
|
@@ -181,7 +198,7 @@ function parseStagingPreview(value) {
|
|
|
181
198
|
}
|
|
182
199
|
function parseCms(value) {
|
|
183
200
|
const cmsRecord = requireObject(value, "cms");
|
|
184
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview"], "cms");
|
|
201
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
|
|
185
202
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
186
203
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
187
204
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -205,6 +222,7 @@ function parseCms(value) {
|
|
|
205
222
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
206
223
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
207
224
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
225
|
+
prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
|
|
208
226
|
};
|
|
209
227
|
const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
|
|
210
228
|
if (reservedWord) {
|
|
@@ -212,7 +230,7 @@ function parseCms(value) {
|
|
|
212
230
|
}
|
|
213
231
|
for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
|
|
214
232
|
if (key in cms.environment) {
|
|
215
|
-
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix
|
|
233
|
+
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
|
|
216
234
|
}
|
|
217
235
|
}
|
|
218
236
|
if (cms.staging === false && cms.stagingDomainName) {
|
package/infra/site-config.ts
CHANGED
|
@@ -41,6 +41,7 @@ export type CmsConfig = {
|
|
|
41
41
|
stagingDomainName?: string;
|
|
42
42
|
stagingDomainPrefix?: string;
|
|
43
43
|
redirects?: CmsRedirect[];
|
|
44
|
+
prospectusAccountsApiStageArn?: string;
|
|
44
45
|
};
|
|
45
46
|
|
|
46
47
|
export type AmplifyAppShellSiteConfig = {
|
|
@@ -95,7 +96,26 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}
|
|
|
95
96
|
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
96
97
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
97
98
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
98
|
-
const
|
|
99
|
+
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
100
|
+
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"];
|
|
101
|
+
|
|
102
|
+
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
103
|
+
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
104
|
+
"GET/v1/identities/me",
|
|
105
|
+
"POST/v1/identities/google/challenge",
|
|
106
|
+
"POST/v1/identities/google/resolve",
|
|
107
|
+
"GET/v1/accounts",
|
|
108
|
+
"GET/v1/accounts/*",
|
|
109
|
+
"POST/v1/accounts/*/authorize",
|
|
110
|
+
"GET/v1/account-directory",
|
|
111
|
+
"GET/v1/account-directory/*",
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
export function resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn: string): string {
|
|
115
|
+
const [, , , region, , apiPath] = prospectusAccountsApiStageArn.split(":");
|
|
116
|
+
const [apiId, stageName] = apiPath.split("/");
|
|
117
|
+
return `https://${apiId}.execute-api.${region}.amazonaws.com/${stageName}/`;
|
|
118
|
+
}
|
|
99
119
|
function originOfRedirectUrl(value: string): string {
|
|
100
120
|
try {
|
|
101
121
|
return new URL(value).origin;
|
|
@@ -259,7 +279,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
|
|
|
259
279
|
|
|
260
280
|
function parseCms(value: unknown): CmsConfig {
|
|
261
281
|
const cmsRecord = requireObject(value, "cms");
|
|
262
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview"], "cms");
|
|
282
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
|
|
263
283
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
264
284
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
265
285
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -288,6 +308,12 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
288
308
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
289
309
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
290
310
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
311
|
+
prospectusAccountsApiStageArn: optionalString(
|
|
312
|
+
cmsRecord.prospectusAccountsApiStageArn,
|
|
313
|
+
"cms.prospectusAccountsApiStageArn",
|
|
314
|
+
PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN,
|
|
315
|
+
"arn:aws:execute-api:<region>:<account>:<api id>/api",
|
|
316
|
+
),
|
|
291
317
|
};
|
|
292
318
|
|
|
293
319
|
const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
|
|
@@ -296,7 +322,7 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
296
322
|
}
|
|
297
323
|
for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
|
|
298
324
|
if (key in cms.environment) {
|
|
299
|
-
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix
|
|
325
|
+
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
|
|
300
326
|
}
|
|
301
327
|
}
|
|
302
328
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Priming proof reports a publication owner approved for visitors, keyed by site brand id.
|
|
3
|
+
* A report reaches this map only through a reviewed commit of its
|
|
4
|
+
* `publications/priming-proofs/<brand id>.json` file; see docs/priming-proof.md.
|
|
5
|
+
*/
|
|
6
|
+
export const APPROVED_PRIMING_PROOF_REPORTS: Record<string, unknown> = {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { APPROVED_PRIMING_PROOF_REPORTS } from "./approved-priming-proof-reports";
|
|
3
|
+
|
|
4
|
+
export const PRIMING_PROOF_REPORT_VERSION = "cyclotron-priming-proof/v1";
|
|
5
|
+
|
|
6
|
+
const nullableRatio = z.number().min(0).max(1).nullable();
|
|
7
|
+
|
|
8
|
+
const primingProofPointSchema = z.object({
|
|
9
|
+
labels: z.number().int().min(0),
|
|
10
|
+
labelsByClass: z.record(z.string(), z.number().int().min(0)),
|
|
11
|
+
decided: z.number().int().min(0),
|
|
12
|
+
agreement: nullableRatio,
|
|
13
|
+
precision: nullableRatio,
|
|
14
|
+
recall: nullableRatio,
|
|
15
|
+
confusion: z.object({
|
|
16
|
+
truePositives: z.number().int().min(0),
|
|
17
|
+
falsePositives: z.number().int().min(0),
|
|
18
|
+
trueNegatives: z.number().int().min(0),
|
|
19
|
+
falseNegatives: z.number().int().min(0),
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const primingProofReportSchema = z.object({
|
|
24
|
+
version: z.literal(PRIMING_PROOF_REPORT_VERSION),
|
|
25
|
+
proofId: z.string().min(1),
|
|
26
|
+
publication: z.string().min(1),
|
|
27
|
+
cyclotronId: z.string().min(1),
|
|
28
|
+
question: z.string().min(1),
|
|
29
|
+
labels: z.array(z.string().min(1)).length(2),
|
|
30
|
+
positiveLabel: z.string().min(1),
|
|
31
|
+
decisionModel: z.string().nullable(),
|
|
32
|
+
ranAt: z.string().min(1),
|
|
33
|
+
heldOut: z.object({
|
|
34
|
+
size: z.number().int().min(1),
|
|
35
|
+
byLabel: z.record(z.string(), z.number().int().min(0)),
|
|
36
|
+
fingerprint: z.string().min(1),
|
|
37
|
+
frozenAt: z.string().min(1),
|
|
38
|
+
source: z.string().min(1),
|
|
39
|
+
}),
|
|
40
|
+
priming: z.object({
|
|
41
|
+
acceptedExamples: z.number().int().min(0),
|
|
42
|
+
excludedBecauseInHeldOut: z.number().int().min(0),
|
|
43
|
+
fingerprint: z.string().nullable(),
|
|
44
|
+
order: z.string().min(1),
|
|
45
|
+
}),
|
|
46
|
+
conditions: z.object({
|
|
47
|
+
freshStorePerPoint: z.boolean(),
|
|
48
|
+
rubric: z.string().min(1),
|
|
49
|
+
optimizer: z.boolean(),
|
|
50
|
+
reviewProgram: z.boolean(),
|
|
51
|
+
}),
|
|
52
|
+
points: z.array(primingProofPointSchema).min(1),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export type PrimingProofReport = z.infer<typeof primingProofReportSchema>;
|
|
56
|
+
export type PrimingProofPoint = z.infer<typeof primingProofPointSchema>;
|
|
57
|
+
|
|
58
|
+
export type PrimingProofHeadline = {
|
|
59
|
+
baseline: PrimingProofPoint;
|
|
60
|
+
latest: PrimingProofPoint;
|
|
61
|
+
agreementChangeInPoints: number | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function parsePrimingProofReport(value: unknown): PrimingProofReport {
|
|
65
|
+
const report = primingProofReportSchema.parse(value);
|
|
66
|
+
if (report.points[0].labels !== 0) {
|
|
67
|
+
throw new Error("A priming proof report must start with the zero-label baseline.");
|
|
68
|
+
}
|
|
69
|
+
return report;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function approvedPrimingProofReport(brandId: string): PrimingProofReport | null {
|
|
73
|
+
const approvedReport = APPROVED_PRIMING_PROOF_REPORTS[brandId];
|
|
74
|
+
return approvedReport === undefined ? null : parsePrimingProofReport(approvedReport);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function primingProofHeadline(report: PrimingProofReport): PrimingProofHeadline {
|
|
78
|
+
const baseline = report.points[0];
|
|
79
|
+
const latest = report.points[report.points.length - 1];
|
|
80
|
+
const agreementChangeInPoints =
|
|
81
|
+
baseline.agreement === null || latest.agreement === null
|
|
82
|
+
? null
|
|
83
|
+
: Math.round((latest.agreement - baseline.agreement) * 1000) / 10;
|
|
84
|
+
return { baseline, latest, agreementChangeInPoints };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatProofPercentage(ratio: number | null): string {
|
|
88
|
+
return ratio === null ? "n/a" : `${Math.round(ratio * 1000) / 10}%`;
|
|
89
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { defaultProvider } from "@aws-sdk/credential-provider-node";
|
|
4
|
+
import { SignatureV4 } from "@smithy/signature-v4";
|
|
5
|
+
import { HttpRequest } from "@smithy/protocol-http";
|
|
6
|
+
import { Sha256 } from "@aws-crypto/sha256-js";
|
|
7
|
+
import { CognitoJwtVerifier } from "aws-jwt-verify";
|
|
8
|
+
import type { JwksCache } from "aws-jwt-verify/jwk";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A Papyrus site's server-side view of Prospectus, the single account service.
|
|
12
|
+
*
|
|
13
|
+
* The site signs requests with its own runtime role (the Amplify SSR compute role
|
|
14
|
+
* registered in Prospectus). People sign in through the site's Google-only Cognito
|
|
15
|
+
* client; Prospectus binds that sign-in to a canonical person through a one-time
|
|
16
|
+
* challenge nonce. Papyrus keeps no account or membership tables: every answer here
|
|
17
|
+
* comes from Prospectus, and publication-level grants are never inferred from an
|
|
18
|
+
* account role.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type ProspectusRole = "owner" | "admin" | "member" | "viewer";
|
|
22
|
+
export type ProspectusOperation = "console:read" | "console:write" | "members:read" | "members:write";
|
|
23
|
+
|
|
24
|
+
export interface ProspectusPerson {
|
|
25
|
+
id: string;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProspectusAccountAuthorization {
|
|
30
|
+
allowed: true;
|
|
31
|
+
accountId: string;
|
|
32
|
+
personId: string;
|
|
33
|
+
product: string;
|
|
34
|
+
role: ProspectusRole;
|
|
35
|
+
operation: ProspectusOperation;
|
|
36
|
+
revision: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProspectusDirectoryAccount {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
role: ProspectusRole;
|
|
43
|
+
products: Array<{ product: string; status: "active" | "disabled" }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ProspectusRequest {
|
|
47
|
+
path: string;
|
|
48
|
+
method: "GET" | "POST";
|
|
49
|
+
body?: unknown;
|
|
50
|
+
humanAccessToken?: string;
|
|
51
|
+
identityToken?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ProspectusTransport = (request: ProspectusRequest) => Promise<unknown>;
|
|
55
|
+
|
|
56
|
+
export class ProspectusAccountsError extends Error {
|
|
57
|
+
constructor(
|
|
58
|
+
readonly status: number,
|
|
59
|
+
readonly code: string,
|
|
60
|
+
message: string,
|
|
61
|
+
) {
|
|
62
|
+
super(message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TemporaryRuntimeCredentials {
|
|
67
|
+
accessKeyId: string;
|
|
68
|
+
secretAccessKey: string;
|
|
69
|
+
sessionToken?: string;
|
|
70
|
+
expiration?: Date;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SignedProspectusTransportOptions {
|
|
74
|
+
endpoint: string;
|
|
75
|
+
region: string;
|
|
76
|
+
runtimeCredentials?: () => Promise<TemporaryRuntimeCredentials>;
|
|
77
|
+
fetcher?: typeof fetch;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function signedProspectusTransport(options: SignedProspectusTransportOptions): ProspectusTransport {
|
|
81
|
+
const endpoint = new URL(options.endpoint);
|
|
82
|
+
if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
83
|
+
throw new Error("Use an HTTPS Prospectus accounts endpoint.");
|
|
84
|
+
}
|
|
85
|
+
const runtimeCredentials = options.runtimeCredentials ?? defaultProvider();
|
|
86
|
+
const fetcher = options.fetcher ?? fetch;
|
|
87
|
+
return async ({ path, method, body, humanAccessToken, identityToken }) => {
|
|
88
|
+
const credentials = await runtimeCredentials();
|
|
89
|
+
if (!credentials.sessionToken || (credentials.expiration && credentials.expiration.getTime() <= Date.now())) {
|
|
90
|
+
throw new Error("Prospectus calls require the site's temporary runtime credentials; static access keys are refused.");
|
|
91
|
+
}
|
|
92
|
+
const url = new URL(`${endpoint.toString().replace(/\/$/, "")}${path}`);
|
|
93
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
94
|
+
const headers: Record<string, string> = {
|
|
95
|
+
host: url.host,
|
|
96
|
+
...(humanAccessToken ? { "x-prospectus-human-token": humanAccessToken } : {}),
|
|
97
|
+
...(identityToken ? { "x-prospectus-identity-token": identityToken } : {}),
|
|
98
|
+
...(payload ? { "content-type": "application/json" } : {}),
|
|
99
|
+
};
|
|
100
|
+
const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
|
|
101
|
+
const signed = await signer.sign(
|
|
102
|
+
new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload }),
|
|
103
|
+
);
|
|
104
|
+
const response = await fetcher(url, { method, headers: signed.headers, body: payload, cache: "no-store", redirect: "error" });
|
|
105
|
+
const responseBody = await response.json().catch(() => ({}));
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new ProspectusAccountsError(response.status, responseBody.error ?? "request_failed", responseBody.message ?? "Prospectus request failed.");
|
|
108
|
+
}
|
|
109
|
+
return responseBody;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class ProspectusSiteAccounts {
|
|
114
|
+
constructor(private readonly transport: ProspectusTransport) {}
|
|
115
|
+
|
|
116
|
+
requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number }> {
|
|
117
|
+
return this.transport({ path: "/v1/identities/google/challenge", method: "POST", body: {} }) as Promise<{
|
|
118
|
+
challengeId: string;
|
|
119
|
+
nonce: string;
|
|
120
|
+
expiresAt: number;
|
|
121
|
+
}>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
resolveGoogleSignIn(challengeId: string, humanAccessToken: string, identityToken: string): Promise<{ person: ProspectusPerson }> {
|
|
125
|
+
return this.transport({
|
|
126
|
+
path: "/v1/identities/google/resolve",
|
|
127
|
+
method: "POST",
|
|
128
|
+
body: { challengeId },
|
|
129
|
+
humanAccessToken,
|
|
130
|
+
identityToken,
|
|
131
|
+
}) as Promise<{ person: ProspectusPerson }>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
currentPerson(humanAccessToken: string): Promise<{ person: ProspectusPerson | null }> {
|
|
135
|
+
return this.transport({ path: "/v1/identities/me", method: "GET", humanAccessToken }) as Promise<{ person: ProspectusPerson | null }>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
authorizeAccountOperation(humanAccessToken: string, accountId: string, operation: ProspectusOperation): Promise<ProspectusAccountAuthorization> {
|
|
139
|
+
return this.transport({
|
|
140
|
+
path: `/v1/accounts/${encodeURIComponent(accountId)}/authorize`,
|
|
141
|
+
method: "POST",
|
|
142
|
+
body: { operation },
|
|
143
|
+
humanAccessToken,
|
|
144
|
+
}) as Promise<ProspectusAccountAuthorization>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
|
|
148
|
+
return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
|
|
149
|
+
person: ProspectusPerson | null;
|
|
150
|
+
accounts: ProspectusDirectoryAccount[];
|
|
151
|
+
}>;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface GoogleOnlyCognitoClient {
|
|
156
|
+
hostedUiOrigin: string;
|
|
157
|
+
userPoolId: string;
|
|
158
|
+
clientId: string;
|
|
159
|
+
redirectUri: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface PendingProspectusSignIn {
|
|
163
|
+
challengeId: string;
|
|
164
|
+
nonce: string;
|
|
165
|
+
state: string;
|
|
166
|
+
codeVerifier: string;
|
|
167
|
+
expiresAt: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface CognitoTokens {
|
|
171
|
+
accessToken: string;
|
|
172
|
+
idToken: string;
|
|
173
|
+
refreshToken?: string;
|
|
174
|
+
expiresInSeconds: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type CognitoTokenVerifier = (tokens: CognitoTokens, expectedNonce: string) => Promise<void>;
|
|
178
|
+
|
|
179
|
+
function base64UrlRandom(byteLength: number): string {
|
|
180
|
+
return randomBytes(byteLength).toString("base64url");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function constantTimeEquals(left: string, right: string): boolean {
|
|
184
|
+
const leftBytes = Buffer.from(left);
|
|
185
|
+
const rightBytes = Buffer.from(right);
|
|
186
|
+
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function beginProspectusGoogleSignIn(
|
|
190
|
+
accounts: ProspectusSiteAccounts,
|
|
191
|
+
cognitoClient: GoogleOnlyCognitoClient,
|
|
192
|
+
): Promise<{ authorizationUrl: string; pendingSignIn: PendingProspectusSignIn }> {
|
|
193
|
+
const challenge = await accounts.requestGoogleSignInChallenge();
|
|
194
|
+
const state = base64UrlRandom(32);
|
|
195
|
+
const codeVerifier = base64UrlRandom(48);
|
|
196
|
+
const authorizationUrl = new URL("/oauth2/authorize", cognitoClient.hostedUiOrigin);
|
|
197
|
+
authorizationUrl.search = new URLSearchParams({
|
|
198
|
+
response_type: "code",
|
|
199
|
+
client_id: cognitoClient.clientId,
|
|
200
|
+
redirect_uri: cognitoClient.redirectUri,
|
|
201
|
+
scope: "openid email profile",
|
|
202
|
+
identity_provider: "Google",
|
|
203
|
+
state,
|
|
204
|
+
nonce: challenge.nonce,
|
|
205
|
+
code_challenge: createHash("sha256").update(codeVerifier).digest("base64url"),
|
|
206
|
+
code_challenge_method: "S256",
|
|
207
|
+
}).toString();
|
|
208
|
+
return {
|
|
209
|
+
authorizationUrl: authorizationUrl.toString(),
|
|
210
|
+
pendingSignIn: { challengeId: challenge.challengeId, nonce: challenge.nonce, state, codeVerifier, expiresAt: challenge.expiresAt },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function cognitoTokenVerifier(cognitoClient: GoogleOnlyCognitoClient, signingKeyCache?: JwksCache): CognitoTokenVerifier {
|
|
215
|
+
const verifierOptions = signingKeyCache ? { jwksCache: signingKeyCache } : undefined;
|
|
216
|
+
const accessTokenVerifier = CognitoJwtVerifier.create({ userPoolId: cognitoClient.userPoolId, tokenUse: "access", clientId: cognitoClient.clientId }, verifierOptions);
|
|
217
|
+
const identityTokenVerifier = CognitoJwtVerifier.create({ userPoolId: cognitoClient.userPoolId, tokenUse: "id", clientId: cognitoClient.clientId }, verifierOptions);
|
|
218
|
+
return async (tokens, expectedNonce) => {
|
|
219
|
+
const accessClaims = await accessTokenVerifier.verify(tokens.accessToken);
|
|
220
|
+
const identityClaims = await identityTokenVerifier.verify(tokens.idToken);
|
|
221
|
+
if (identityClaims.nonce !== expectedNonce) throw new ProspectusAccountsError(401, "nonce_mismatch", "Sign-in did not carry this site's Prospectus challenge.");
|
|
222
|
+
if (identityClaims.sub !== accessClaims.sub) throw new ProspectusAccountsError(401, "token_pair_mismatch", "Access and identity tokens describe different people.");
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface CompleteProspectusGoogleSignInInput {
|
|
227
|
+
accounts: ProspectusSiteAccounts;
|
|
228
|
+
cognitoClient: GoogleOnlyCognitoClient;
|
|
229
|
+
pendingSignIn: PendingProspectusSignIn;
|
|
230
|
+
returnedState: string;
|
|
231
|
+
authorizationCode: string;
|
|
232
|
+
verifyCognitoTokens?: CognitoTokenVerifier;
|
|
233
|
+
fetcher?: typeof fetch;
|
|
234
|
+
nowInSeconds?: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function completeProspectusGoogleSignIn(
|
|
238
|
+
input: CompleteProspectusGoogleSignInInput,
|
|
239
|
+
): Promise<{ person: ProspectusPerson; accessToken: string; refreshToken?: string; accessTokenExpiresAt: number }> {
|
|
240
|
+
const nowInSeconds = input.nowInSeconds ?? Math.floor(Date.now() / 1000);
|
|
241
|
+
if (nowInSeconds >= input.pendingSignIn.expiresAt) {
|
|
242
|
+
throw new ProspectusAccountsError(401, "sign_in_expired", "Sign-in took too long. Start again.");
|
|
243
|
+
}
|
|
244
|
+
if (!constantTimeEquals(input.returnedState, input.pendingSignIn.state)) {
|
|
245
|
+
throw new ProspectusAccountsError(401, "state_mismatch", "Sign-in response does not belong to this browser's sign-in.");
|
|
246
|
+
}
|
|
247
|
+
const tokenResponse = await (input.fetcher ?? fetch)(new URL("/oauth2/token", input.cognitoClient.hostedUiOrigin), {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
250
|
+
body: new URLSearchParams({
|
|
251
|
+
grant_type: "authorization_code",
|
|
252
|
+
client_id: input.cognitoClient.clientId,
|
|
253
|
+
code: input.authorizationCode,
|
|
254
|
+
redirect_uri: input.cognitoClient.redirectUri,
|
|
255
|
+
code_verifier: input.pendingSignIn.codeVerifier,
|
|
256
|
+
}).toString(),
|
|
257
|
+
cache: "no-store",
|
|
258
|
+
redirect: "error",
|
|
259
|
+
});
|
|
260
|
+
const tokenBody = await tokenResponse.json().catch(() => ({}));
|
|
261
|
+
if (!tokenResponse.ok || typeof tokenBody.access_token !== "string" || typeof tokenBody.id_token !== "string") {
|
|
262
|
+
throw new ProspectusAccountsError(401, "code_exchange_failed", "Cognito did not complete the sign-in.");
|
|
263
|
+
}
|
|
264
|
+
const tokens: CognitoTokens = {
|
|
265
|
+
accessToken: tokenBody.access_token,
|
|
266
|
+
idToken: tokenBody.id_token,
|
|
267
|
+
refreshToken: typeof tokenBody.refresh_token === "string" ? tokenBody.refresh_token : undefined,
|
|
268
|
+
expiresInSeconds: Number(tokenBody.expires_in) || 0,
|
|
269
|
+
};
|
|
270
|
+
await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
|
|
271
|
+
const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn.challengeId, tokens.accessToken, tokens.idToken);
|
|
272
|
+
return {
|
|
273
|
+
person,
|
|
274
|
+
accessToken: tokens.accessToken,
|
|
275
|
+
refreshToken: tokens.refreshToken,
|
|
276
|
+
accessTokenExpiresAt: nowInSeconds + tokens.expiresInSeconds,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function authorizeSignedInPersonForAccount(
|
|
281
|
+
accounts: ProspectusSiteAccounts,
|
|
282
|
+
humanAccessToken: string,
|
|
283
|
+
accountId: string,
|
|
284
|
+
operation: ProspectusOperation,
|
|
285
|
+
): Promise<ProspectusAccountAuthorization> {
|
|
286
|
+
const { person } = await accounts.currentPerson(humanAccessToken);
|
|
287
|
+
if (!person) throw new ProspectusAccountsError(401, "unknown_person", "Sign in again: Prospectus does not recognize this session.");
|
|
288
|
+
const authorization = await accounts.authorizeAccountOperation(humanAccessToken, accountId, operation);
|
|
289
|
+
if (authorization.allowed !== true || authorization.personId !== person.id || authorization.accountId !== accountId || authorization.operation !== operation) {
|
|
290
|
+
throw new ProspectusAccountsError(403, "not_authorized", "Prospectus did not authorize this account operation.");
|
|
291
|
+
}
|
|
292
|
+
return authorization;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export const CYCLOTRON_LAB_PRODUCT = "cyclotron";
|
|
296
|
+
|
|
297
|
+
export async function signedInPersonHasCyclotronLabAccess(accounts: ProspectusSiteAccounts, humanAccessToken: string): Promise<boolean> {
|
|
298
|
+
const directory = await accounts.accountDirectory(humanAccessToken);
|
|
299
|
+
if (!directory.person) return false;
|
|
300
|
+
return directory.accounts.some((account) =>
|
|
301
|
+
account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
|
|
302
|
+
);
|
|
303
|
+
}
|
package/package.json
CHANGED
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
"./components/presentation-footer": "./components/presentation-footer.tsx",
|
|
100
100
|
"./components/presentation-header": "./components/presentation-header.tsx",
|
|
101
101
|
"./components/presentation-rhythm-hrule": "./components/presentation-rhythm-hrule.tsx",
|
|
102
|
+
"./components/priming-proof-page": "./components/priming-proof-page.tsx",
|
|
102
103
|
"./components/publication-cyclotron-panel": "./components/publication-cyclotron-panel.tsx",
|
|
103
104
|
"./components/reader-auth-control": "./components/reader-auth-control.tsx",
|
|
104
105
|
"./components/reader-auth-state": "./components/reader-auth-state.ts",
|
|
@@ -139,6 +140,7 @@
|
|
|
139
140
|
"./lib/amplify-outputs-guard": "./lib/amplify-outputs-guard.ts",
|
|
140
141
|
"./lib/amplify-outputs-path": "./lib/amplify-outputs-path.ts",
|
|
141
142
|
"./lib/amplify-server-runtime": "./lib/amplify-server-runtime.ts",
|
|
143
|
+
"./lib/approved-priming-proof-reports": "./lib/approved-priming-proof-reports.ts",
|
|
142
144
|
"./lib/archive-data": "./lib/archive-data.ts",
|
|
143
145
|
"./lib/archive-types": "./lib/archive-types.ts",
|
|
144
146
|
"./lib/articles": "./lib/articles.ts",
|
|
@@ -205,6 +207,8 @@
|
|
|
205
207
|
"./lib/plugin-settings": "./lib/plugin-settings.ts",
|
|
206
208
|
"./lib/presentation-footer": "./lib/presentation-footer.ts",
|
|
207
209
|
"./lib/pretext-layout": "./lib/pretext-layout.ts",
|
|
210
|
+
"./lib/priming-proof-report": "./lib/priming-proof-report.ts",
|
|
211
|
+
"./lib/prospectus-site-identity": "./lib/prospectus-site-identity.ts",
|
|
208
212
|
"./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
|
|
209
213
|
"./lib/publication-cyclotron": "./lib/publication-cyclotron.ts",
|
|
210
214
|
"./lib/publication-items": "./lib/publication-items.ts",
|
|
@@ -259,10 +263,12 @@
|
|
|
259
263
|
"./app/[year]/[month]/[day]/page/[pageNumber]/page": "./app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
|
|
260
264
|
"./app/[year]/[month]/[day]/section/[sectionKey]/page": "./app/[year]/[month]/[day]/section/[sectionKey]/page.tsx",
|
|
261
265
|
"./app/api/archive/editions/route": "./app/api/archive/editions/route.ts",
|
|
266
|
+
"./app/api/cyclotron/priming-proof/route": "./app/api/cyclotron/priming-proof/route.ts",
|
|
262
267
|
"./app/api/media/[...path]/route": "./app/api/media/[...path]/route.ts",
|
|
263
268
|
"./app/api/revalidate/route": "./app/api/revalidate/route.ts",
|
|
264
269
|
"./app/archive/page": "./app/archive/page.tsx",
|
|
265
270
|
"./app/articles/[slug]/page": "./app/articles/[slug]/page.tsx",
|
|
271
|
+
"./app/cyclotron/page": "./app/cyclotron/page.tsx",
|
|
266
272
|
"./app/layout": "./app/layout.tsx",
|
|
267
273
|
"./app/newsroom/[section]/[...selection]/page": "./app/newsroom/[section]/[...selection]/page.tsx",
|
|
268
274
|
"./app/newsroom/[section]/page": "./app/newsroom/[section]/page.tsx",
|
|
@@ -325,18 +331,22 @@
|
|
|
325
331
|
"optional": true
|
|
326
332
|
}
|
|
327
333
|
},
|
|
328
|
-
"version": "1.0.0-next.
|
|
334
|
+
"version": "1.0.0-next.62",
|
|
329
335
|
"dependencies": {
|
|
330
336
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
337
|
+
"@aws-crypto/sha256-js": "5.2.0",
|
|
331
338
|
"@aws-sdk/client-cognito-identity-provider": "3.1045.0",
|
|
332
339
|
"@aws-sdk/client-lambda": "^3.1045.0",
|
|
333
340
|
"@aws-sdk/client-s3": "^3.1045.0",
|
|
334
341
|
"@aws-sdk/client-ssm": "^3.1045.0",
|
|
342
|
+
"@aws-sdk/credential-provider-node": "3.972.84",
|
|
335
343
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
|
336
344
|
"@base-ui/react": "^1.5.0",
|
|
337
345
|
"@chenglou/pretext": "^0.0.7",
|
|
338
346
|
"@kanbus/ui": "https://github.com/AnthusAI/Kanbus/releases/download/ui-v0.2.0/kanbus-ui-0.2.0.tgz",
|
|
339
347
|
"@radix-ui/colors": "3.0.0",
|
|
348
|
+
"@smithy/protocol-http": "5.4.1",
|
|
349
|
+
"@smithy/signature-v4": "5.7.4",
|
|
340
350
|
"@streamdown/cjk": "^1.0.3",
|
|
341
351
|
"@streamdown/code": "^1.1.1",
|
|
342
352
|
"@streamdown/math": "^1.0.2",
|
|
@@ -345,6 +355,7 @@
|
|
|
345
355
|
"ai": "^6.0.191",
|
|
346
356
|
"anthus-vultus": "github:AnthusAI/Vultus#af98d57",
|
|
347
357
|
"aws-amplify": "^6.17.0",
|
|
358
|
+
"aws-jwt-verify": "4.0.1",
|
|
348
359
|
"class-variance-authority": "^0.7.1",
|
|
349
360
|
"clsx": "^2.1.1",
|
|
350
361
|
"cmdk": "^1.1.1",
|
package/routes.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.0.0-next.
|
|
2
|
+
"version": "1.0.0-next.62",
|
|
3
3
|
"routes": [
|
|
4
4
|
{
|
|
5
5
|
"file": "app/%5F_preview/[[...path]]/route.ts",
|
|
@@ -66,6 +66,14 @@
|
|
|
66
66
|
"revalidate": "60"
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
|
+
{
|
|
70
|
+
"file": "app/api/cyclotron/priming-proof/route.ts",
|
|
71
|
+
"hasDefault": false,
|
|
72
|
+
"exports": [
|
|
73
|
+
"GET"
|
|
74
|
+
],
|
|
75
|
+
"config": {}
|
|
76
|
+
},
|
|
69
77
|
{
|
|
70
78
|
"file": "app/api/media/[...path]/route.ts",
|
|
71
79
|
"hasDefault": false,
|
|
@@ -107,6 +115,14 @@
|
|
|
107
115
|
"revalidate": "60"
|
|
108
116
|
}
|
|
109
117
|
},
|
|
118
|
+
{
|
|
119
|
+
"file": "app/cyclotron/page.tsx",
|
|
120
|
+
"hasDefault": true,
|
|
121
|
+
"exports": [
|
|
122
|
+
"metadata"
|
|
123
|
+
],
|
|
124
|
+
"config": {}
|
|
125
|
+
},
|
|
110
126
|
{
|
|
111
127
|
"file": "app/layout.tsx",
|
|
112
128
|
"hasDefault": true,
|