@anthusai/papyrus 2.0.0-next.4 → 2.0.0-next.40
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 +22 -3
- package/amplify/data/resource.ts +24 -3
- package/amplify/data/schema.js +2029 -1967
- package/amplify/data/schema.ts +72 -2
- package/amplify/functions/assignment-action/data-access.js +88 -0
- package/amplify/functions/assignment-action/data-access.ts +87 -0
- package/amplify/functions/content-actions/handler.py +33 -0
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/functions/editorial-checks/resource.js +43 -0
- package/amplify/functions/editorial-checks/resource.ts +60 -0
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/functions/editorial-checks/schema.ts +123 -0
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +36 -1
- package/amplify/site-backend.ts +44 -1
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +362 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsroom-article-editor.tsx +136 -11
- package/components/newsroom-board-view.tsx +6 -7
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-shell.tsx +7 -3
- package/components/newsroom-organizer.css +74 -61
- package/components/newsroom-references-view.tsx +14 -14
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +4 -4
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +27 -2
- package/components/use-editorial-checks.ts +133 -0
- package/infra/amplify-app-shell.js +76 -16
- package/infra/amplify-app-shell.ts +86 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +75 -6
- package/infra/site-config.ts +106 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +16 -0
- package/lib/editorial-checks-client.ts +212 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +27 -0
- package/lib/editorial-checks.ts +171 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/prospectus-session.ts +14 -14
- package/lib/prospectus-sign-in-routes.ts +52 -11
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +23 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +48 -4
- package/routes.manifest.json +34 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Shapes and wording for the newsroom Monitoring page. The function in src/papyrus_content/monitoring_view.py produces them. */
|
|
2
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
3
|
+
|
|
4
|
+
export type MonitoringDeliveryState = "delivered" | "failed" | "pending" | "not_applicable" | null;
|
|
5
|
+
|
|
6
|
+
export type MonitoringRelevance = {
|
|
7
|
+
publicationId: string;
|
|
8
|
+
site: string | null;
|
|
9
|
+
relevant: boolean | null;
|
|
10
|
+
label: string | null;
|
|
11
|
+
confidence: number | null;
|
|
12
|
+
delivery: {
|
|
13
|
+
state: MonitoringDeliveryState;
|
|
14
|
+
referenceId?: string | null;
|
|
15
|
+
referenceLineageId?: string | null;
|
|
16
|
+
deliveredAt?: string | null;
|
|
17
|
+
error?: string | null;
|
|
18
|
+
attempts?: number | null;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MonitoringItem = {
|
|
23
|
+
sourceId: string;
|
|
24
|
+
revision: number;
|
|
25
|
+
title: string;
|
|
26
|
+
url: string | null;
|
|
27
|
+
discussionUrl: string | null;
|
|
28
|
+
observedAt: string | null;
|
|
29
|
+
routing: string;
|
|
30
|
+
routingNote: string | null;
|
|
31
|
+
decidedAt: string | null;
|
|
32
|
+
relevance: MonitoringRelevance[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type MonitoringRun = {
|
|
36
|
+
runId: string;
|
|
37
|
+
startedAt: string;
|
|
38
|
+
status: string;
|
|
39
|
+
new: number;
|
|
40
|
+
revised: number;
|
|
41
|
+
unchanged: number;
|
|
42
|
+
itemCount: number | null;
|
|
43
|
+
errorCount: number;
|
|
44
|
+
errors: (string | null)[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type MonitoringRouterRun = {
|
|
48
|
+
runId: string;
|
|
49
|
+
startedAt: string;
|
|
50
|
+
mode: string;
|
|
51
|
+
status: string;
|
|
52
|
+
stoppedBy: string | null;
|
|
53
|
+
waitingForClassifier?: string[];
|
|
54
|
+
counts: Record<string, number>;
|
|
55
|
+
errorCount: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type MonitoringFeed = {
|
|
59
|
+
id: string;
|
|
60
|
+
sourceUrl: string | null;
|
|
61
|
+
intervalSeconds: number | null;
|
|
62
|
+
limit: number | null;
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
lastStatus: string | null;
|
|
65
|
+
lastFinishedAt: string | null;
|
|
66
|
+
nextDueAt: string | null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type MonitoringOverview = {
|
|
70
|
+
state: "ready" | "empty";
|
|
71
|
+
asOf: string;
|
|
72
|
+
notice?: "publication_not_routed";
|
|
73
|
+
scope: { mode: "publication" | "all"; publicationId: string | null; site: string | null; canViewAll: boolean };
|
|
74
|
+
feeds: MonitoringFeed[];
|
|
75
|
+
runs: MonitoringRun[];
|
|
76
|
+
routerRuns: MonitoringRouterRun[];
|
|
77
|
+
router: { mode: string; feedback: boolean; enabled: boolean; maxItemsPerRun: number | null } | null;
|
|
78
|
+
usage: {
|
|
79
|
+
day: string;
|
|
80
|
+
decisionsToday: number;
|
|
81
|
+
decisionsTotal: number;
|
|
82
|
+
ceilings: { decisionsTotal?: number; decisionsPerDay?: number; items?: number } | null;
|
|
83
|
+
} | null;
|
|
84
|
+
items: MonitoringItem[];
|
|
85
|
+
hiddenNotRelevant: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type MonitoringResult =
|
|
89
|
+
| MonitoringOverview
|
|
90
|
+
| { state: "not_configured" }
|
|
91
|
+
| { state: "unavailable" }
|
|
92
|
+
| { error: "forbidden" | "bad_request" };
|
|
93
|
+
|
|
94
|
+
const ROUTING_WORDS: Record<string, string> = {
|
|
95
|
+
collected: "Collected, waiting for the router",
|
|
96
|
+
"dry-run-validated": "Checked in a dry run, not sent",
|
|
97
|
+
decided: "Judged, delivery not finished",
|
|
98
|
+
routed: "Routed",
|
|
99
|
+
refused: "Refused by the shared Cyclotron",
|
|
100
|
+
uncertain: "Uncertain, needs a person",
|
|
101
|
+
error: "Error",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function routingLabel(routing: string): string {
|
|
105
|
+
return ROUTING_WORDS[routing] ?? routing;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Fixed-format UTC time so the page reads the same everywhere. */
|
|
109
|
+
export function formatUtc(iso: string | null | undefined): string {
|
|
110
|
+
if (!iso) return "never";
|
|
111
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
|
|
112
|
+
return match ? `${match[1]} ${match[2]} UTC` : iso;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function confidenceText(confidence: number | null): string {
|
|
116
|
+
return confidence === null ? "confidence not reported" : `${Math.round(confidence * 100)}% confidence`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function usageText(used: number, ceiling: number | undefined): string {
|
|
120
|
+
return ceiling === undefined ? `${used} used, no ceiling set` : `${used} of ${ceiling}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function everyText(seconds: number | null): string {
|
|
124
|
+
if (!seconds) return "on a schedule";
|
|
125
|
+
return seconds % 3600 === 0 ? `every ${seconds / 3600} h` : seconds % 60 === 0 ? `every ${seconds / 60} min` : `every ${seconds} s`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function referenceHref(lineageId: string, demo?: boolean): string {
|
|
129
|
+
return getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function describeRelevance(entry: MonitoringRelevance): string {
|
|
133
|
+
if (entry.relevant === null) return "Not judged yet";
|
|
134
|
+
return entry.relevant ? "Relevant" : "Not relevant";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function describeDelivery(entry: MonitoringRelevance): string {
|
|
138
|
+
switch (entry.delivery.state) {
|
|
139
|
+
case "delivered": return "In the References queue";
|
|
140
|
+
case "failed": return "Not in the References queue: delivery failed";
|
|
141
|
+
case "pending": return "Not in the References queue yet";
|
|
142
|
+
case "not_applicable": return "Nothing to deliver";
|
|
143
|
+
default: return "";
|
|
144
|
+
}
|
|
145
|
+
}
|
package/lib/newsroom-board.ts
CHANGED
|
@@ -21,6 +21,31 @@ export interface NativeBoardIssue extends KanbanIssue {
|
|
|
21
21
|
href: string;
|
|
22
22
|
/** A few lines to show under the headline: the blurb, else the subtitle. */
|
|
23
23
|
excerpt?: string;
|
|
24
|
+
/** The quiet path shown where a board would print the ID, e.g. `articles/my-slug` or `assignments/write-it`. */
|
|
25
|
+
displayId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function slugify(text: string): string {
|
|
29
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Section and slug for a content item, falling back to the publication's board path segment. */
|
|
33
|
+
export function contentItemDisplayId(version: { section?: string | null; slug?: string | null; lineageId: string }): string {
|
|
34
|
+
const section = slugify(version.section ?? "") || contentItemBoardPathSegment();
|
|
35
|
+
return `${section}/${slugify(version.slug ?? "") || version.lineageId.slice(0, 8)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Assignments have no slug: use the title as one, else a short id. */
|
|
39
|
+
export function assignmentDisplayId(assignment: { id: string; title?: string | null }): string {
|
|
40
|
+
const titleSlug = slugify(assignment.title ?? "").slice(0, 48).replace(/-+$/g, "");
|
|
41
|
+
return `assignments/${titleSlug || assignment.id.slice(0, 8)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `formatIssueId` for the Kanbus UI: the host-provided path, never the raw `kind:id` prefix. */
|
|
45
|
+
export function formatNativeIssueId(issue: KanbanIssue): string {
|
|
46
|
+
const displayId = (issue as Partial<NativeBoardIssue>).displayId;
|
|
47
|
+
if (displayId) return displayId;
|
|
48
|
+
return issue.id.replace(/^[A-Za-z]+:/, "").slice(0, 12);
|
|
24
49
|
}
|
|
25
50
|
|
|
26
51
|
export function contentItemBoardPathSegment(): string {
|
|
@@ -60,7 +85,7 @@ export function projectContentItemBoard(records: {
|
|
|
60
85
|
title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
|
|
61
86
|
priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
|
|
62
87
|
updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
|
|
63
|
-
excerpt: contentItemExcerpt(version),
|
|
88
|
+
excerpt: contentItemExcerpt(version), displayId: contentItemDisplayId(version),
|
|
64
89
|
};
|
|
65
90
|
});
|
|
66
91
|
}
|
|
@@ -73,7 +98,7 @@ export function projectAssignmentBoard(assignments: readonly AssignmentRecord[])
|
|
|
73
98
|
priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
|
|
74
99
|
description: row.summary || row.brief || undefined,
|
|
75
100
|
created_at: row.createdAt, updated_at: row.updatedAt,
|
|
76
|
-
href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
|
|
101
|
+
href: `/newsroom/assignments/${encodeURIComponent(row.id)}`, displayId: assignmentDisplayId(row),
|
|
77
102
|
}));
|
|
78
103
|
}
|
|
79
104
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
3
3
|
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
4
4
|
import {
|
|
5
5
|
ProspectusSiteAccounts,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
export const PROSPECTUS_PENDING_SIGN_IN_COOKIE = "__Host-papyrus-prospectus-pending";
|
|
22
22
|
export const PROSPECTUS_SESSION_COOKIE = "__Host-papyrus-prospectus-session";
|
|
23
23
|
export const PROSPECTUS_ACCOUNTS_ENDPOINT_ENV = "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT";
|
|
24
|
-
export const
|
|
24
|
+
export const PROSPECTUS_SESSION_SECRET_PARAMETER_ENV = "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER";
|
|
25
25
|
export const PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
|
|
26
26
|
|
|
27
27
|
const SEALED_COOKIE_FORMAT_VERSION = "v1";
|
|
@@ -101,37 +101,37 @@ export function sameSiteReturnPath(requestedReturnPath: string | null | undefine
|
|
|
101
101
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
export type ProspectusSessionSecretReader = (
|
|
104
|
+
export type ProspectusSessionSecretReader = (parameterName: string) => Promise<string | null>;
|
|
105
105
|
|
|
106
|
-
type CachedSealingSecret = {
|
|
106
|
+
type CachedSealingSecret = { parameterName: string; value: string; expiresAt: number };
|
|
107
107
|
|
|
108
108
|
let cachedSealingSecret: CachedSealingSecret | null = null;
|
|
109
|
-
let
|
|
109
|
+
let defaultSsmClient: SSMClient | null = null;
|
|
110
110
|
|
|
111
111
|
export function resetProspectusSessionSecretCache(): void {
|
|
112
112
|
cachedSealingSecret = null;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
async function
|
|
116
|
-
|
|
117
|
-
const response = await
|
|
118
|
-
return response.
|
|
115
|
+
export async function readSealingSecretFromSsm(parameterName: string, client?: Pick<SSMClient, "send">): Promise<string | null> {
|
|
116
|
+
defaultSsmClient ??= new SSMClient({});
|
|
117
|
+
const response = await (client ?? defaultSsmClient).send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
|
|
118
|
+
return response.Parameter?.Value?.trim() || null;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
export async function loadProspectusSessionSealingSecret(
|
|
122
122
|
options: { environment?: Record<string, string | undefined>; readSecret?: ProspectusSessionSecretReader; now?: number } = {},
|
|
123
123
|
): Promise<string | null> {
|
|
124
124
|
const environment = options.environment ?? process.env;
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
125
|
+
const parameterName = environment[PROSPECTUS_SESSION_SECRET_PARAMETER_ENV]?.trim();
|
|
126
|
+
if (!parameterName) return null;
|
|
127
127
|
const now = options.now ?? Date.now();
|
|
128
|
-
if (cachedSealingSecret && cachedSealingSecret.
|
|
128
|
+
if (cachedSealingSecret && cachedSealingSecret.parameterName === parameterName && cachedSealingSecret.expiresAt > now) {
|
|
129
129
|
return cachedSealingSecret.value;
|
|
130
130
|
}
|
|
131
131
|
try {
|
|
132
|
-
const value = (await (options.readSecret ??
|
|
132
|
+
const value = (await (options.readSecret ?? readSealingSecretFromSsm)(parameterName))?.trim();
|
|
133
133
|
if (!value || value.length < MINIMUM_SEALING_SECRET_LENGTH) return null;
|
|
134
|
-
cachedSealingSecret = {
|
|
134
|
+
cachedSealingSecret = { parameterName, value, expiresAt: now + PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS };
|
|
135
135
|
return value;
|
|
136
136
|
} catch (error) {
|
|
137
137
|
console.error(`Prospectus session sealing secret could not be read (${error instanceof Error ? error.name : "error"}).`);
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
publishingAccountsForSite,
|
|
26
26
|
type PublicationCapability,
|
|
27
27
|
} from "./prospectus-publication-access";
|
|
28
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* The site's sign-in routes for Prospectus accounts: start (challenge, then the
|
|
@@ -38,6 +39,8 @@ export interface ProspectusSignInRouteOptions {
|
|
|
38
39
|
siteProduct?: string | null;
|
|
39
40
|
completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
|
|
40
41
|
nowInSeconds?: () => number;
|
|
42
|
+
/** Trusted configuration the public site origin is read from; defaults to process.env. */
|
|
43
|
+
environment?: Record<string, string | undefined>;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
@@ -50,8 +53,22 @@ function currentTimeInSeconds(options: ProspectusSignInRouteOptions): number {
|
|
|
50
53
|
return options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
function siteOriginFor(request: NextRequest, options: ProspectusSignInRouteOptions): string {
|
|
57
|
+
return publicSiteOrigin(request, options.environment);
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
async function signInRuntimeFor(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<ProspectusSignInRuntime | null> {
|
|
54
|
-
return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request
|
|
61
|
+
return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(siteOriginFor(request, options));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Runs a route and answers a missing site origin with 503 site_origin_not_configured, never a localhost redirect. */
|
|
65
|
+
async function failingClosedWithoutSiteOrigin(route: () => Promise<NextResponse>): Promise<NextResponse> {
|
|
66
|
+
try {
|
|
67
|
+
return await route();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
function signInUnavailableResponse(): NextResponse {
|
|
@@ -82,7 +99,11 @@ export function signedInProspectusSession(
|
|
|
82
99
|
);
|
|
83
100
|
}
|
|
84
101
|
|
|
85
|
-
export
|
|
102
|
+
export function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
103
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignInStartRoute(request, options));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
86
107
|
const runtime = await signInRuntimeFor(request, options);
|
|
87
108
|
if (!runtime) return signInUnavailableResponse();
|
|
88
109
|
const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
|
|
@@ -99,7 +120,11 @@ export async function handleProspectusSignInStart(request: NextRequest, options:
|
|
|
99
120
|
return response;
|
|
100
121
|
}
|
|
101
122
|
|
|
102
|
-
export
|
|
123
|
+
export function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
124
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignInCallbackRoute(request, options));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function handleProspectusSignInCallbackRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
103
128
|
const runtime = await signInRuntimeFor(request, options);
|
|
104
129
|
if (!runtime) return signInUnavailableResponse();
|
|
105
130
|
const nowInSeconds = currentTimeInSeconds(options);
|
|
@@ -142,7 +167,7 @@ async function completeCallback(
|
|
|
142
167
|
accessToken: signedIn.accessToken,
|
|
143
168
|
accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
|
|
144
169
|
};
|
|
145
|
-
const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, request
|
|
170
|
+
const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, siteOriginFor(request, options)), { status: 303, headers: NO_STORE_HEADERS });
|
|
146
171
|
response.cookies.set(
|
|
147
172
|
PROSPECTUS_SESSION_COOKIE,
|
|
148
173
|
sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, session, session.accessTokenExpiresAt),
|
|
@@ -156,7 +181,11 @@ async function completeCallback(
|
|
|
156
181
|
}
|
|
157
182
|
}
|
|
158
183
|
|
|
159
|
-
export
|
|
184
|
+
export function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
185
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSessionReadRoute(request, options));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function handleProspectusSessionReadRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
160
189
|
const runtime = await signInRuntimeFor(request, options);
|
|
161
190
|
if (!runtime) return signInUnavailableResponse();
|
|
162
191
|
const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
|
|
@@ -196,9 +225,13 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
|
|
|
196
225
|
}
|
|
197
226
|
}
|
|
198
227
|
|
|
199
|
-
export
|
|
228
|
+
export function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
229
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignOutRoute(request, options));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function handleProspectusSignOutRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
200
233
|
const runtime = await signInRuntimeFor(request, options);
|
|
201
|
-
const signedOutDestination = new URL("/", request
|
|
234
|
+
const signedOutDestination = new URL("/", siteOriginFor(request, options));
|
|
202
235
|
const destination = runtime
|
|
203
236
|
? new URL(
|
|
204
237
|
`/logout?${new URLSearchParams({ client_id: runtime.cognitoClient.clientId, logout_uri: signedOutDestination.toString() })}`,
|
|
@@ -250,10 +283,14 @@ export async function currentProspectusPublishingSelection(
|
|
|
250
283
|
}
|
|
251
284
|
}
|
|
252
285
|
|
|
253
|
-
export
|
|
286
|
+
export function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
287
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusAccountSelectionRoute(request, options));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function handleProspectusAccountSelectionRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
254
291
|
const runtime = await signInRuntimeFor(request, options);
|
|
255
292
|
if (!runtime) return signInUnavailableResponse();
|
|
256
|
-
if (request.headers.get("origin") !== request
|
|
293
|
+
if (request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
257
294
|
return signInFailedResponse(403, "cross_origin_selection", "Choose an account from this site.");
|
|
258
295
|
}
|
|
259
296
|
const nowInSeconds = currentTimeInSeconds(options);
|
|
@@ -287,10 +324,14 @@ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
|
|
|
287
324
|
const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
|
|
288
325
|
const IDEMPOTENCY_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
289
326
|
|
|
290
|
-
export
|
|
327
|
+
export function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
328
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusPublishingAccountCreationRoute(request, options));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function handleProspectusPublishingAccountCreationRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
291
332
|
const runtime = await signInRuntimeFor(request, options);
|
|
292
333
|
if (!runtime) return signInUnavailableResponse();
|
|
293
|
-
if (request.headers.get("origin") !== request
|
|
334
|
+
if (request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
294
335
|
return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
|
|
295
336
|
}
|
|
296
337
|
const nowInSeconds = currentTimeInSeconds(options);
|
package/lib/relevance-status.ts
CHANGED
|
@@ -52,10 +52,10 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
|
|
|
52
52
|
},
|
|
53
53
|
calibration: { saysSure: null, isRight: null, gapPoints: null, curve: [] },
|
|
54
54
|
reviewRate: {
|
|
55
|
-
state: "onboarding", rate: 1, reason: "
|
|
55
|
+
state: "onboarding", rate: 1, reason: "Getting started: every suggestion is reviewed until suggestions earn a lower review rate.",
|
|
56
56
|
auditFloor: 0.05, confidenceThreshold: 0.7, override: null,
|
|
57
57
|
nextCheckAfterDecisions: 99,
|
|
58
|
-
nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a
|
|
58
|
+
nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a gap under 5 points between how sure suggestions say they are and how often they are right. Next check in 99 decisions.",
|
|
59
59
|
expectedReviewsPerWeek: 1,
|
|
60
60
|
},
|
|
61
61
|
lastChange: null,
|
package/lib/site-brand-tenant.ts
CHANGED
|
@@ -9,14 +9,19 @@ export function resolveBrandConfigPath(relativePath: string): string {
|
|
|
9
9
|
return path.isAbsolute(relativePath) ? relativePath : path.join(process.cwd(), relativePath);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** Optional repo files a brand may still name; undefined means the site keeps that configuration as backend data and nothing is looked up. */
|
|
13
|
+
function optionalBrandConfigPath(relativePath: string | undefined): string | undefined {
|
|
14
|
+
return relativePath ? resolveBrandConfigPath(relativePath) : undefined;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function
|
|
17
|
-
return
|
|
17
|
+
export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
18
|
+
return optionalBrandConfigPath(brand.steeringConfigPath);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function
|
|
21
|
-
return
|
|
21
|
+
export function getBrandNewsroomSectionsConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
22
|
+
return optionalBrandConfigPath(brand.newsroomSectionsConfigPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getBrandAnalysisProfilesPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
26
|
+
return optionalBrandConfigPath(brand.analysisProfilesPath);
|
|
22
27
|
}
|
package/lib/site-brand.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ComponentType } from "react";
|
|
|
3
3
|
import type { EditionPresentationFormat } from "./content-types";
|
|
4
4
|
import type { Article, ArticleVideoAsset } from "./articles";
|
|
5
5
|
import type { BlogPageBackgroundProps } from "../components/blog-page-background";
|
|
6
|
+
import type { KanbusThemeId } from "@kanbus/ui";
|
|
6
7
|
import type { SiteBrandFont } from "./brand-fonts";
|
|
7
8
|
import type { PictogramFigureProps } from "../components/pictogram-figure";
|
|
8
9
|
import papyrusSite from "papyrus-site";
|
|
@@ -118,6 +119,13 @@ export type SiteBrand = {
|
|
|
118
119
|
defaultVideoCredit?: string;
|
|
119
120
|
themePack: ThemePackId;
|
|
120
121
|
themeTokens: ThemePackTokens;
|
|
122
|
+
/**
|
|
123
|
+
* The Kanbus editorial theme the whole newsroom (sidebar, toolbar, boards,
|
|
124
|
+
* detail panel) renders in. Hard-coded per publication: editors cannot
|
|
125
|
+
* change it. Per-publication tweaks are `--kb-theme-*` overrides in the
|
|
126
|
+
* publication's `theme.css`, scoped to `[data-kanbus-theme]`.
|
|
127
|
+
*/
|
|
128
|
+
kanbusTheme: KanbusThemeId;
|
|
121
129
|
renderer: RendererConfig;
|
|
122
130
|
hosting: HostingConfig;
|
|
123
131
|
/** Static reader app when it is not co-hosted with this Papyrus checkout. */
|
|
@@ -134,9 +142,12 @@ export type SiteBrand = {
|
|
|
134
142
|
readerBasePath?: string;
|
|
135
143
|
opsChrome: OpsChrome;
|
|
136
144
|
corpusKey: string;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
/** Optional repo file for topic steering. Editorial and steering configuration is backend data; a data-driven site sets none and nothing is looked up. */
|
|
146
|
+
steeringConfigPath?: string;
|
|
147
|
+
/** Optional repo file for the local-preview demo sections only. Sections live in the backend. */
|
|
148
|
+
newsroomSectionsConfigPath?: string;
|
|
149
|
+
/** Optional repo file listing analysis profiles. */
|
|
150
|
+
analysisProfilesPath?: string;
|
|
140
151
|
publicationName: string;
|
|
141
152
|
components?: SiteBrandComponents;
|
|
142
153
|
/**
|
|
@@ -182,6 +193,7 @@ const REFERENCE_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
|
|
|
182
193
|
sectionLinkStrategy: "route",
|
|
183
194
|
themePack: "papyrus",
|
|
184
195
|
themeTokens: DEFAULT_THEME_PACK_TOKENS,
|
|
196
|
+
kanbusTheme: "broadsheet-green",
|
|
185
197
|
renderer: { kind: "pretext" },
|
|
186
198
|
hosting: { kind: "amplify-ssr" },
|
|
187
199
|
opsChrome: "app",
|
|
@@ -237,6 +249,14 @@ export function getSiteBrand(id: SiteBrandId = resolveSiteBrandId()): SiteBrand
|
|
|
237
249
|
|
|
238
250
|
export const SITE_BRAND = getSiteBrand();
|
|
239
251
|
|
|
252
|
+
/** Mirrors `DEFAULT_KANBUS_THEME` from `@kanbus/ui` (a test keeps the two equal) without a runtime import. */
|
|
253
|
+
export const FALLBACK_KANBUS_THEME: KanbusThemeId = "newspaper-gothic";
|
|
254
|
+
|
|
255
|
+
/** The newsroom theme for a brand; a brand that predates the field falls back to the default theme. */
|
|
256
|
+
export function getKanbusTheme(brand: Pick<SiteBrand, "kanbusTheme"> = SITE_BRAND): KanbusThemeId {
|
|
257
|
+
return brand.kanbusTheme ?? FALLBACK_KANBUS_THEME;
|
|
258
|
+
}
|
|
259
|
+
|
|
240
260
|
export function getContentItemName(brand: SiteBrand = SITE_BRAND): ContentItemName {
|
|
241
261
|
return brand.contentItemName ?? DEFAULT_CONTENT_ITEM_NAME;
|
|
242
262
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { getSiteEnv } from "./site-env";
|
|
3
|
+
|
|
4
|
+
type EnvironmentVariables = Record<string, string | undefined>;
|
|
5
|
+
|
|
6
|
+
export const SITE_ORIGIN_ENV = "PAPYRUS_SITE_ORIGIN";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The site's public origin cannot be read from the request on Amplify SSR (WEB_COMPUTE):
|
|
10
|
+
* there `request.nextUrl.origin` is the compute adapter's internal https://localhost:3000.
|
|
11
|
+
* It also must never be derived from Host or X-Forwarded-* headers, which a client can set.
|
|
12
|
+
* The one trusted source is PAPYRUS_SITE_ORIGIN, set by the app shell from the site's host.
|
|
13
|
+
*/
|
|
14
|
+
export class SiteOriginNotConfiguredError extends Error {
|
|
15
|
+
readonly status = 503;
|
|
16
|
+
readonly code = "site_origin_not_configured";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function siteOriginNotConfiguredResponse(error: SiteOriginNotConfiguredError): NextResponse {
|
|
20
|
+
return NextResponse.json({ error: error.code, message: error.message }, { status: error.status, headers: { "cache-control": "no-store" } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
24
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function configuredOrigin(value: string): string {
|
|
28
|
+
const invalid = () => new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} must be an https origin such as https://example.com with no path.`);
|
|
29
|
+
let parsed: URL;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(value);
|
|
32
|
+
} catch {
|
|
33
|
+
throw invalid();
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol !== "https:" || parsed.origin !== value) throw invalid();
|
|
36
|
+
return parsed.origin;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function siteEnvOrNull(environment: EnvironmentVariables): string | null {
|
|
40
|
+
try {
|
|
41
|
+
return getSiteEnv(environment);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The site's public origin, from trusted configuration.
|
|
49
|
+
* - PAPYRUS_SITE_ORIGIN set: it must be an https origin with no path; it is used as is.
|
|
50
|
+
* - Unset: `request.nextUrl.origin` only when it is loopback, SITE_ENV resolves to development
|
|
51
|
+
* and NODE_ENV is not production (a local `next dev` server). Anything else, production and staging included, throws
|
|
52
|
+
* SiteOriginNotConfiguredError so routes answer 503 instead of redirecting to localhost.
|
|
53
|
+
*/
|
|
54
|
+
export function publicSiteOrigin(request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): string {
|
|
55
|
+
const configured = (environment[SITE_ORIGIN_ENV] ?? "").trim();
|
|
56
|
+
if (configured) return configuredOrigin(configured);
|
|
57
|
+
const { hostname, origin } = request.nextUrl;
|
|
58
|
+
// SITE_ENV defaults to development when a runtime never received it, so NODE_ENV=production also closes the fallback.
|
|
59
|
+
const developing = siteEnvOrNull(environment) === "development" && environment.NODE_ENV !== "production";
|
|
60
|
+
if (isLoopbackHostname(hostname) && developing) return origin;
|
|
61
|
+
throw new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} is not set; this site cannot tell its public origin.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** An absolute URL on the site's public origin, for redirects. Fails closed like publicSiteOrigin. */
|
|
65
|
+
export function publicSiteUrl(path: string, request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): URL {
|
|
66
|
+
return new URL(path, publicSiteOrigin(request, environment));
|
|
67
|
+
}
|
package/middleware.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { newsroomHref } from "./lib/newsroom-base-path";
|
|
|
5
5
|
import { getReaderBasePath } from "./lib/reader-base-path";
|
|
6
6
|
import { isStagingGatedPath, isStaticOrApiPath } from "./lib/staging-gated-path";
|
|
7
7
|
import { getSiteEnv, isIndexable } from "./lib/site-env";
|
|
8
|
+
import { SiteOriginNotConfiguredError, publicSiteUrl, siteOriginNotConfiguredResponse } from "./lib/site-origin";
|
|
8
9
|
import { getStagingAccess } from "./lib/staging-gate";
|
|
9
10
|
import {
|
|
10
11
|
isPreviewGatedPath,
|
|
@@ -38,11 +39,11 @@ function routeRequest(request: NextRequest): NextResponse {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
if (pathname === "/newsroom" || pathname === "/newsroom/") {
|
|
41
|
-
return NextResponse.redirect(
|
|
42
|
+
return NextResponse.redirect(publicSiteUrl(`/${request.nextUrl.search}`, request));
|
|
42
43
|
}
|
|
43
44
|
if (pathname.startsWith("/newsroom/")) {
|
|
44
45
|
const target = pathname.slice("/newsroom".length) || "/";
|
|
45
|
-
return NextResponse.redirect(
|
|
46
|
+
return NextResponse.redirect(publicSiteUrl(`${target}${request.nextUrl.search}`, request));
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
if (pathname === "/" || pathname === "") {
|
|
@@ -59,6 +60,16 @@ function routeRequest(request: NextRequest): NextResponse {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
63
|
+
try {
|
|
64
|
+
return await handleRequest(request);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// Redirects name the site's public origin (request.url is localhost on Amplify); without it, fail closed.
|
|
67
|
+
if (error instanceof SiteOriginNotConfiguredError) return withRobotsHeader(siteOriginNotConfiguredResponse(error));
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function handleRequest(request: NextRequest): Promise<NextResponse> {
|
|
62
73
|
let sessionResponse: NextResponse | null = null;
|
|
63
74
|
const { pathname } = request.nextUrl;
|
|
64
75
|
const gated = isStaticPreviewEnabled()
|
|
@@ -68,7 +79,7 @@ export async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
|
68
79
|
sessionResponse = NextResponse.next();
|
|
69
80
|
const access = await getStagingAccess(request, sessionResponse);
|
|
70
81
|
if (access === "anonymous") {
|
|
71
|
-
return withRobotsHeader(NextResponse.redirect(
|
|
82
|
+
return withRobotsHeader(NextResponse.redirect(publicSiteUrl(newsroomHref(), request)));
|
|
72
83
|
}
|
|
73
84
|
if (access === "forbidden") {
|
|
74
85
|
return withRobotsHeader(new NextResponse("Staging is limited to editors and admins.", { status: 403 }));
|