@anthusai/papyrus 1.0.0-next.61 → 1.0.0-next.63
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 +4 -0
- package/amplify/data/resource.ts +4 -0
- package/amplify/data/schema.js +4 -0
- package/amplify/data/schema.ts +4 -0
- package/app/api/cyclotron/priming-proof/route.ts +11 -0
- package/app/cyclotron/page.tsx +13 -0
- package/components/news-desk-taxonomy-client.ts +37 -1
- package/components/newsroom-board-view.tsx +7 -8
- package/components/priming-proof-page.tsx +94 -0
- package/components/topic-steering-workspace.tsx +4 -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/category-repository.ts +1 -0
- package/lib/content-item-lifecycle.ts +55 -0
- package/lib/newsroom-board.ts +60 -21
- package/lib/newsroom-nav.ts +2 -1
- package/lib/newsroom-organizer-nav.ts +5 -2
- package/lib/priming-proof-report.ts +90 -0
- package/lib/prospectus-site-identity.ts +303 -0
- package/lib/site-brand.ts +19 -0
- package/package.json +13 -1
- package/routes.manifest.json +17 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One content item, one lifecycle: the stages an Item lineage moves through.
|
|
3
|
+
* Mirrors CONTENT_ITEM_STAGES in src/papyrus_content/content_item_lifecycle.py.
|
|
4
|
+
* See docs/content-item-lifecycle.md.
|
|
5
|
+
*/
|
|
6
|
+
export const CONTENT_ITEM_STAGES = [
|
|
7
|
+
"idea",
|
|
8
|
+
"assignment",
|
|
9
|
+
"research",
|
|
10
|
+
"report",
|
|
11
|
+
"editor_select",
|
|
12
|
+
"copywriting",
|
|
13
|
+
"published",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type ContentItemStage = (typeof CONTENT_ITEM_STAGES)[number];
|
|
17
|
+
|
|
18
|
+
const CONTENT_ITEM_STAGE_LABELS: Record<ContentItemStage, string> = {
|
|
19
|
+
idea: "Idea",
|
|
20
|
+
assignment: "Assignment",
|
|
21
|
+
research: "Research",
|
|
22
|
+
report: "Report",
|
|
23
|
+
editor_select: "Editor select",
|
|
24
|
+
copywriting: "Copywriting",
|
|
25
|
+
published: "Published",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function contentItemStageLabel(stage: string): string {
|
|
29
|
+
return (CONTENT_ITEM_STAGE_LABELS as Record<string, string>)[stage] ?? stage;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ContentItemVersionRecord = {
|
|
33
|
+
id: string;
|
|
34
|
+
lineageId: string;
|
|
35
|
+
versionNumber: number;
|
|
36
|
+
stage?: string | null;
|
|
37
|
+
type: string;
|
|
38
|
+
status: string;
|
|
39
|
+
title?: string | null;
|
|
40
|
+
headline?: string | null;
|
|
41
|
+
slug: string;
|
|
42
|
+
section?: string | null;
|
|
43
|
+
updatedAt?: string | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function latestVersionPerContentItem(
|
|
47
|
+
versions: readonly ContentItemVersionRecord[],
|
|
48
|
+
): ContentItemVersionRecord[] {
|
|
49
|
+
const latestByLineage = new Map<string, ContentItemVersionRecord>();
|
|
50
|
+
for (const version of versions) {
|
|
51
|
+
const current = latestByLineage.get(version.lineageId);
|
|
52
|
+
if (!current || version.versionNumber > current.versionNumber) latestByLineage.set(version.lineageId, version);
|
|
53
|
+
}
|
|
54
|
+
return [...latestByLineage.values()];
|
|
55
|
+
}
|
package/lib/newsroom-board.ts
CHANGED
|
@@ -1,38 +1,67 @@
|
|
|
1
1
|
import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
|
|
2
2
|
import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
CONTENT_ITEM_STAGES,
|
|
5
|
+
contentItemStageLabel,
|
|
6
|
+
latestVersionPerContentItem,
|
|
7
|
+
type ContentItemVersionRecord,
|
|
8
|
+
} from "./content-item-lifecycle";
|
|
9
|
+
import { getContentItemName } from "./site-brand";
|
|
4
10
|
|
|
5
|
-
export type NativeBoardKind = "
|
|
11
|
+
export type NativeBoardKind = "contentItem" | "reference" | "assignment";
|
|
6
12
|
export interface NativeBoardIssue extends KanbanIssue {
|
|
7
|
-
|
|
13
|
+
kind: NativeBoardKind;
|
|
8
14
|
nativeId: string;
|
|
9
15
|
nativeStatus: string;
|
|
10
16
|
href: string;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
export function contentItemBoardType(): string {
|
|
20
|
+
return getContentItemName().singular.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function boardTypeForKind(kind: NativeBoardKind): string {
|
|
24
|
+
return kind === "contentItem" ? contentItemBoardType() : kind;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One card per content item, in the column for its lifecycle stage. Assignments that are work on a content item
|
|
29
|
+
* are counted on that item's card rather than shown as separate cards. References and other work keep their own
|
|
30
|
+
* identity and native status.
|
|
31
|
+
*/
|
|
14
32
|
export function projectNewsroomBoard(records: {
|
|
15
|
-
|
|
33
|
+
contentItemVersions?: readonly ContentItemVersionRecord[];
|
|
16
34
|
references?: readonly ReferenceRecord[];
|
|
17
35
|
assignments?: readonly AssignmentRecord[];
|
|
18
36
|
}): NativeBoardIssue[] {
|
|
37
|
+
const assignmentCountByContentItem = new Map<string, number>();
|
|
38
|
+
for (const assignment of records.assignments ?? []) {
|
|
39
|
+
if (assignment.itemLineageId) {
|
|
40
|
+
assignmentCountByContentItem.set(assignment.itemLineageId, (assignmentCountByContentItem.get(assignment.itemLineageId) ?? 0) + 1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const contentItemType = contentItemBoardType();
|
|
19
44
|
return [
|
|
20
|
-
...(records.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
45
|
+
...latestVersionPerContentItem(records.contentItemVersions ?? []).map(version => {
|
|
46
|
+
const stage = version.stage ?? "idea";
|
|
47
|
+
const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
|
|
48
|
+
return {
|
|
49
|
+
id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
|
|
50
|
+
title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
|
|
51
|
+
priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
|
|
52
|
+
updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
|
|
53
|
+
};
|
|
54
|
+
}),
|
|
26
55
|
...(records.references ?? []).filter(row => !row.versionState || row.versionState === "current").map(row => ({
|
|
27
|
-
id: `reference:${row.id}`, nativeId: row.id,
|
|
56
|
+
id: `reference:${row.id}`, nativeId: row.id, kind: "reference" as const, type: "reference",
|
|
28
57
|
title: row.title || row.externalItemId || row.id,
|
|
29
58
|
nativeStatus: row.curationStatus || "pending", status: `reference/${row.curationStatus || "pending"}`,
|
|
30
59
|
priority: 2, description: row.curationStatusReason || undefined,
|
|
31
60
|
updated_at: row.updatedAt || row.importedAt || undefined,
|
|
32
61
|
href: `/newsroom/references/${encodeURIComponent(row.lineageId || row.id)}`,
|
|
33
62
|
})),
|
|
34
|
-
...(records.assignments ?? []).map(row => ({
|
|
35
|
-
id: `assignment:${row.id}`, nativeId: row.id,
|
|
63
|
+
...(records.assignments ?? []).filter(row => !row.itemLineageId).map(row => ({
|
|
64
|
+
id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: "assignment",
|
|
36
65
|
title: row.title, nativeStatus: row.status, status: `assignment/${row.status}`,
|
|
37
66
|
priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
|
|
38
67
|
description: row.summary || row.brief || undefined,
|
|
@@ -42,16 +71,26 @@ export function projectNewsroomBoard(records: {
|
|
|
42
71
|
];
|
|
43
72
|
}
|
|
44
73
|
|
|
45
|
-
/**
|
|
74
|
+
/** Content items always show every lifecycle stage in order; unknown native statuses of other records stay visible. */
|
|
46
75
|
export function newsroomBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
|
|
47
|
-
const
|
|
76
|
+
const contentItemType = contentItemBoardType();
|
|
77
|
+
const kinds: NativeBoardKind[] = ["contentItem", "reference", "assignment"];
|
|
78
|
+
const contentItemStatuses = [
|
|
79
|
+
...CONTENT_ITEM_STAGES,
|
|
80
|
+
...new Set(issues.filter(issue => issue.kind === "contentItem").map(issue => issue.nativeStatus)),
|
|
81
|
+
].filter((stage, index, stages) => stages.indexOf(stage) === index)
|
|
82
|
+
.map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage), category: "native" }));
|
|
83
|
+
const otherStatuses = [...new Set(issues.filter(issue => issue.kind !== "contentItem").map(issue => issue.status))]
|
|
84
|
+
.map(key => ({ key, name: key.replace("/", " · "), category: "native" }));
|
|
85
|
+
const statuses = [...contentItemStatuses, ...otherStatuses];
|
|
48
86
|
return {
|
|
49
|
-
hierarchy: [], types:
|
|
50
|
-
statuses
|
|
87
|
+
hierarchy: [], types: kinds.map(boardTypeForKind),
|
|
88
|
+
statuses,
|
|
51
89
|
categories: [{ name: "native" }], priorities: {}, type_colors: {},
|
|
52
|
-
workflows: Object.fromEntries(
|
|
53
|
-
|
|
54
|
-
|
|
90
|
+
workflows: Object.fromEntries(kinds.map(kind => {
|
|
91
|
+
const type = boardTypeForKind(kind);
|
|
92
|
+
return [type, Object.fromEntries(statuses.filter(status => status.key.startsWith(`${type}/`)).map(status => [status.key, []]))];
|
|
93
|
+
})),
|
|
55
94
|
};
|
|
56
95
|
}
|
|
57
96
|
|
package/lib/newsroom-nav.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { newsroomHref, toPublicNewsroomPath } from "./newsroom-base-path";
|
|
2
|
+
import { getContentItemName } from "./site-brand";
|
|
2
3
|
|
|
3
4
|
export type NewsroomNavTabId =
|
|
4
5
|
| "overview"
|
|
@@ -23,7 +24,7 @@ export type NewsroomNavItem = {
|
|
|
23
24
|
|
|
24
25
|
export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
|
|
25
26
|
{ id: "overview", label: "Overview", detail: "Desk home", href: newsroomHref(), mobilePrimary: true },
|
|
26
|
-
{ id: "articles", label:
|
|
27
|
+
{ id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles"), mobilePrimary: true },
|
|
27
28
|
{ id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments"), mobilePrimary: true },
|
|
28
29
|
{ id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references"), mobilePrimary: true },
|
|
29
30
|
{ id: "messages", label: "Messages", detail: "Forum", href: newsroomHref("messages") },
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { getContentItemName } from "./site-brand";
|
|
2
|
+
|
|
3
|
+
const contentItemPluralLowercase = getContentItemName().plural.toLowerCase();
|
|
1
4
|
export type OrganizerDestination = {
|
|
2
5
|
key: string; label: string; description: string; path: string;
|
|
3
6
|
access?: "admin"; primary?: boolean; unavailable?: string;
|
|
@@ -5,8 +8,8 @@ export type OrganizerDestination = {
|
|
|
5
8
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
6
9
|
{ title: "Editorial work", items: [
|
|
7
10
|
{ key: "overview", label: "Overview", description: "See the desk and choose where to begin.", path: "/newsroom", primary: true },
|
|
8
|
-
{ key: "board", label: "Board", description:
|
|
9
|
-
{ key: "articles", label:
|
|
11
|
+
{ key: "board", label: "Board", description: `Follow ${contentItemPluralLowercase}, sources and assignments across their stages.`, path: "/newsroom/work", primary: true },
|
|
12
|
+
{ key: "articles", label: getContentItemName().plural, description: "Write, revise and prepare copy for publication.", path: "/newsroom/articles" },
|
|
10
13
|
{ key: "assignments", label: "Assignments", description: "Claim work and follow research and reporting progress.", path: "/newsroom/assignments" },
|
|
11
14
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
12
15
|
{ key: "insights", label: "Insights", description: "Discuss findings and research threads.", path: "/newsroom/insights" },
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
source: z.string().min(1),
|
|
45
|
+
order: z.string().min(1),
|
|
46
|
+
}),
|
|
47
|
+
conditions: z.object({
|
|
48
|
+
freshStorePerPoint: z.boolean(),
|
|
49
|
+
rubric: z.string().min(1),
|
|
50
|
+
optimizer: z.boolean(),
|
|
51
|
+
reviewProgram: z.boolean(),
|
|
52
|
+
}),
|
|
53
|
+
points: z.array(primingProofPointSchema).min(1),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export type PrimingProofReport = z.infer<typeof primingProofReportSchema>;
|
|
57
|
+
export type PrimingProofPoint = z.infer<typeof primingProofPointSchema>;
|
|
58
|
+
|
|
59
|
+
export type PrimingProofHeadline = {
|
|
60
|
+
baseline: PrimingProofPoint;
|
|
61
|
+
latest: PrimingProofPoint;
|
|
62
|
+
agreementChangeInPoints: number | null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function parsePrimingProofReport(value: unknown): PrimingProofReport {
|
|
66
|
+
const report = primingProofReportSchema.parse(value);
|
|
67
|
+
if (report.points[0].labels !== 0) {
|
|
68
|
+
throw new Error("A priming proof report must start with the zero-label baseline.");
|
|
69
|
+
}
|
|
70
|
+
return report;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function approvedPrimingProofReport(brandId: string): PrimingProofReport | null {
|
|
74
|
+
const approvedReport = APPROVED_PRIMING_PROOF_REPORTS[brandId];
|
|
75
|
+
return approvedReport === undefined ? null : parsePrimingProofReport(approvedReport);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function primingProofHeadline(report: PrimingProofReport): PrimingProofHeadline {
|
|
79
|
+
const baseline = report.points[0];
|
|
80
|
+
const latest = report.points[report.points.length - 1];
|
|
81
|
+
const agreementChangeInPoints =
|
|
82
|
+
baseline.agreement === null || latest.agreement === null
|
|
83
|
+
? null
|
|
84
|
+
: Math.round((latest.agreement - baseline.agreement) * 1000) / 10;
|
|
85
|
+
return { baseline, latest, agreementChangeInPoints };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatProofPercentage(ratio: number | null): string {
|
|
89
|
+
return ratio === null ? "n/a" : `${Math.round(ratio * 1000) / 10}%`;
|
|
90
|
+
}
|
|
@@ -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/lib/site-brand.ts
CHANGED
|
@@ -64,6 +64,19 @@ export type SiteBrandVideo = {
|
|
|
64
64
|
rhythm?: Record<string, unknown>;
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The publication's name for its one content item. A content item keeps one
|
|
69
|
+
* identity and one lifecycle from idea through research, reporting,
|
|
70
|
+
* copywriting and publication; only the word editors see changes per
|
|
71
|
+
* publication (for example Story, Article or Post).
|
|
72
|
+
*/
|
|
73
|
+
export type ContentItemName = {
|
|
74
|
+
singular: string;
|
|
75
|
+
plural: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const DEFAULT_CONTENT_ITEM_NAME: ContentItemName = { singular: "Story", plural: "Stories" };
|
|
79
|
+
|
|
67
80
|
export type SiteBrand = {
|
|
68
81
|
id: SiteBrandId;
|
|
69
82
|
appTitle: string;
|
|
@@ -77,6 +90,8 @@ export type SiteBrand = {
|
|
|
77
90
|
mastheadTaglineLines?: SiteBrandTaglineLine[];
|
|
78
91
|
backToHomeLabel: string;
|
|
79
92
|
articleTitleSuffix: string;
|
|
93
|
+
/** Defaults to Story / Stories, the newsroom term. */
|
|
94
|
+
contentItemName?: ContentItemName;
|
|
80
95
|
placeholderByline: string;
|
|
81
96
|
defaultPresentation: EditionPresentationFormat;
|
|
82
97
|
forcedPresentation?: EditionPresentationFormat;
|
|
@@ -204,6 +219,10 @@ export function getSiteBrand(id: SiteBrandId = resolveSiteBrandId()): SiteBrand
|
|
|
204
219
|
|
|
205
220
|
export const SITE_BRAND = getSiteBrand();
|
|
206
221
|
|
|
222
|
+
export function getContentItemName(brand: SiteBrand = SITE_BRAND): ContentItemName {
|
|
223
|
+
return brand.contentItemName ?? DEFAULT_CONTENT_ITEM_NAME;
|
|
224
|
+
}
|
|
225
|
+
|
|
207
226
|
/** Root-route config for a brand, defaulting to `{ kind: "reader" }`. */
|
|
208
227
|
export function getRootRoute(brand: SiteBrand = SITE_BRAND): RootRouteConfig {
|
|
209
228
|
return brand.rootRoute ?? { kind: "reader" };
|