@anthusai/papyrus 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -78,23 +78,29 @@ export function siteFilesContentHash(siteFiles) {
78
78
  function pipSpec() {
79
79
  return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
80
80
  }
81
+ /** What the first pip install gets: `papyrus-newsroom` with its dependencies, plus the function's own requirements. Empty means skip it. */
82
+ export function firstPipRequirements(options) {
83
+ return [...(options.noDeps ? [] : [pipSpec()]), ...(options.requirements ?? [])];
84
+ }
81
85
  function bundleFromPip(outputDir, options) {
82
86
  const extra = (process.env.PAPYRUS_PIP_EXTRA_ARGS ?? "").trim().split(/\s+/).filter(Boolean);
83
87
  const python = (process.env.PAPYRUS_PIP_PYTHON ?? "python3").trim();
84
- execFileSync(python, [
85
- "-m", "pip", "install",
86
- "--target", outputDir,
87
- "--no-cache-dir",
88
- "--platform", "manylinux2014_aarch64",
89
- "--implementation", "cp",
90
- "--python-version", "3.12",
91
- "--only-binary=:all:",
92
- // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
93
- "--ignore-requires-python",
94
- ...extra,
95
- ...(options.noDeps ? [] : [pipSpec()]),
96
- ...(options.requirements ?? []),
97
- ], { stdio: "inherit" });
88
+ const requirements = firstPipRequirements(options);
89
+ if (requirements.length > 0) {
90
+ execFileSync(python, [
91
+ "-m", "pip", "install",
92
+ "--target", outputDir,
93
+ "--no-cache-dir",
94
+ "--platform", "manylinux2014_aarch64",
95
+ "--implementation", "cp",
96
+ "--python-version", "3.12",
97
+ "--only-binary=:all:",
98
+ // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
99
+ "--ignore-requires-python",
100
+ ...extra,
101
+ ...requirements,
102
+ ], { stdio: "inherit" });
103
+ }
98
104
  if (options.noDeps) {
99
105
  execFileSync(python, [
100
106
  "-m", "pip", "install",
@@ -102,27 +102,34 @@ function pipSpec(): string {
102
102
  return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
103
103
  }
104
104
 
105
+ /** What the first pip install gets: `papyrus-newsroom` with its dependencies, plus the function's own requirements. Empty means skip it. */
106
+ export function firstPipRequirements(options: Pick<PythonBundleOptions, "noDeps" | "requirements">): string[] {
107
+ return [...(options.noDeps ? [] : [pipSpec()]), ...(options.requirements ?? [])];
108
+ }
109
+
105
110
  function bundleFromPip(outputDir: string, options: PythonBundleOptions): void {
106
111
  const extra = (process.env.PAPYRUS_PIP_EXTRA_ARGS ?? "").trim().split(/\s+/).filter(Boolean);
107
112
  const python = (process.env.PAPYRUS_PIP_PYTHON ?? "python3").trim();
108
- execFileSync(
109
- python,
110
- [
111
- "-m", "pip", "install",
112
- "--target", outputDir,
113
- "--no-cache-dir",
114
- "--platform", "manylinux2014_aarch64",
115
- "--implementation", "cp",
116
- "--python-version", "3.12",
117
- "--only-binary=:all:",
118
- // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
119
- "--ignore-requires-python",
120
- ...extra,
121
- ...(options.noDeps ? [] : [pipSpec()]),
122
- ...(options.requirements ?? []),
123
- ],
124
- { stdio: "inherit" },
125
- );
113
+ const requirements = firstPipRequirements(options);
114
+ if (requirements.length > 0) {
115
+ execFileSync(
116
+ python,
117
+ [
118
+ "-m", "pip", "install",
119
+ "--target", outputDir,
120
+ "--no-cache-dir",
121
+ "--platform", "manylinux2014_aarch64",
122
+ "--implementation", "cp",
123
+ "--python-version", "3.12",
124
+ "--only-binary=:all:",
125
+ // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
126
+ "--ignore-requires-python",
127
+ ...extra,
128
+ ...requirements,
129
+ ],
130
+ { stdio: "inherit" },
131
+ );
132
+ }
126
133
  if (options.noDeps) {
127
134
  execFileSync(
128
135
  python,
@@ -12,6 +12,9 @@ async function lookUpRedirectTarget(uri) {
12
12
  }
13
13
 
14
14
  function rewriteToIndexDocument(uri) {
15
+ if (uri === "/404") {
16
+ return "/404.html";
17
+ }
15
18
  if (uri.endsWith("/")) {
16
19
  return uri + "index.html";
17
20
  }
@@ -1,5 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ /** Mirrors DIRECTORY_HTML_PATTERN and DIRECTORY_SLASH_PATTERN in standard/config.py. */
4
+ const DIRECTORY_HTML_PATTERN = "^/(?!(?:.*/)?index\\.html$)(.+)\\.html$";
5
+ const DIRECTORY_SLASH_PATTERN = "^/((?:[^/]+/)*[^/.]+)$";
3
6
  const START_MARKER = "/*PAPYRUS_PATTERN_RULES*/";
4
7
  const END_MARKER = "/*END_PAPYRUS_PATTERN_RULES*/";
5
8
  /**
@@ -14,7 +17,9 @@ export function edgeRulesFromSettings(settings) {
14
17
  x: rule.except ?? [],
15
18
  }));
16
19
  if (settings.urls?.style === "directory") {
17
- rules.push({ p: "^/(.+)\\.html$", t: "/$1/", s: 301, x: ["/404.html"] });
20
+ // /x.html to /x/ (a directory index file such as /x/index.html is served as it is), then /x to /x/.
21
+ rules.push({ p: DIRECTORY_HTML_PATTERN, t: "/$1/", s: 301, x: ["/404.html"] });
22
+ rules.push({ p: DIRECTORY_SLASH_PATTERN, t: "/$1/", s: 301, x: ["/404"] });
18
23
  }
19
24
  return rules;
20
25
  }
@@ -4,6 +4,10 @@ import { resolve } from "node:path";
4
4
  /** One pattern redirect as the edge function reads it: regex, target with $1..$9, status, excluded paths. */
5
5
  export type EdgeRedirectRule = { p: string; t: string; s: number; x: string[] };
6
6
 
7
+ /** Mirrors DIRECTORY_HTML_PATTERN and DIRECTORY_SLASH_PATTERN in standard/config.py. */
8
+ const DIRECTORY_HTML_PATTERN = "^/(?!(?:.*/)?index\\.html$)(.+)\\.html$";
9
+ const DIRECTORY_SLASH_PATTERN = "^/((?:[^/]+/)*[^/.]+)$";
10
+
7
11
  const START_MARKER = "/*PAPYRUS_PATTERN_RULES*/";
8
12
  const END_MARKER = "/*END_PAPYRUS_PATTERN_RULES*/";
9
13
 
@@ -22,7 +26,9 @@ export function edgeRulesFromSettings(settings: ProjectionSettings): EdgeRedirec
22
26
  x: rule.except ?? [],
23
27
  }));
24
28
  if (settings.urls?.style === "directory") {
25
- rules.push({ p: "^/(.+)\\.html$", t: "/$1/", s: 301, x: ["/404.html"] });
29
+ // /x.html to /x/ (a directory index file such as /x/index.html is served as it is), then /x to /x/.
30
+ rules.push({ p: DIRECTORY_HTML_PATTERN, t: "/$1/", s: 301, x: ["/404.html"] });
31
+ rules.push({ p: DIRECTORY_SLASH_PATTERN, t: "/$1/", s: 301, x: ["/404"] });
26
32
  }
27
33
  return rules;
28
34
  }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleCurationRoute } from "../../../../../lib/curation-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleCurationRoute(request, "context");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleCurationRoute } from "../../../../../lib/curation-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleCurationRoute(request, "priming");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleCurationRoute } from "../../../../../lib/curation-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleCurationRoute(request, "publication");
9
+ }
@@ -19,6 +19,7 @@ const USAGE = [
19
19
  " papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
20
20
  " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
21
21
  " papyrus-infra deploy-edge --stack NAME --template FILE [--adoption-template FILE --adopt-resources FILE] [--parameter KEY=VALUE]... [--delete-function NAME] [--only-branch main] [--only-app-id ID] [--region R]",
22
+ " papyrus-infra compile-edge-function --static-dir web [--out FILE]",
22
23
  " papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]",
23
24
  ].join("\n");
24
25
 
@@ -36,11 +37,29 @@ const resolveEnvironmentOrExit = () => {
36
37
  }
37
38
  };
38
39
  const command = args[0];
39
- if (command !== "synth" && command !== "print-buildspec" && command !== "deploy" && command !== "deploy-edge") {
40
+ if (command !== "synth" && command !== "print-buildspec" && command !== "deploy" && command !== "deploy-edge" && command !== "compile-edge-function") {
40
41
  console.error(USAGE);
41
42
  process.exit(command ? 1 : 0);
42
43
  }
43
44
 
45
+ if (command === "compile-edge-function") {
46
+ // The viewer-request CloudFront Function with the site's projection.json rules compiled in: for an edge stack that is
47
+ // not deployed by the CMS backend, so that no site writes its own redirect function.
48
+ const staticDir = opt("static-dir");
49
+ if (!staticDir) {
50
+ console.error(USAGE);
51
+ process.exit(1);
52
+ }
53
+ const { compileRedirectFunctionSource, readEdgeConfig } = await import("../amplify/publication/redirect-rules.js");
54
+ const functionSource = fs.readFileSync(new URL("../amplify/publication/redirect-and-index.js", import.meta.url), "utf8");
55
+ const config = readEdgeConfig(staticDir);
56
+ const compiled = compileRedirectFunctionSource(functionSource, config.rules, { negotiateMarkdown: config.negotiateMarkdown });
57
+ const out = opt("out");
58
+ if (out) fs.writeFileSync(path.resolve(out), compiled);
59
+ else process.stdout.write(compiled);
60
+ process.exit(0);
61
+ }
62
+
44
63
  if (command === "deploy-edge") {
45
64
  const buildAppId = process.env.AWS_APP_ID;
46
65
  const buildBranch = process.env.AWS_BRANCH;
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import {useEffect,useState,type ReactNode} from 'react';
4
+ import {loadProspectusAccountState} from '../lib/prospectus-account-client';
5
+ import type {CurationBootstrap} from '../lib/curation-client';
6
+ import {NewsroomCurationProvider} from './newsroom-curation-provider';
7
+
8
+ type Fetcher=(input:string,init?:RequestInit)=>Promise<Response>;
9
+ const CONTEXT_PATH='/api/newsroom/curation/context';
10
+
11
+ function validBootstrap(value:unknown):value is CurationBootstrap{
12
+ const body=value as Partial<CurationBootstrap>|null;
13
+ const scope=body?.scope;
14
+ return !!scope&&[scope.accountId,scope.publicationId,scope.bindingRevision].every(item=>typeof item==='string'&&item.trim()!=='')
15
+ &&(body!.mode==='embedded'||body!.mode==='remote')&&Array.isArray(body!.capabilities);
16
+ }
17
+
18
+ /**
19
+ * Ask the server for the curation bootstrap, or null. Only a signed-in person with a selected
20
+ * account causes a context read; every other outcome (no Prospectus, signed out, no selection,
21
+ * any refusal or failure) is null, so the released CMS is unchanged.
22
+ */
23
+ export async function loadCurationBootstrap(returnPath:string,fetcher:Fetcher=fetch):Promise<CurationBootstrap|null>{
24
+ try{
25
+ const account=await loadProspectusAccountState(returnPath,fetcher);
26
+ if(account.status!=='signedIn'||!account.selectedAccountId)return null;
27
+ const response=await fetcher(CONTEXT_PATH,{credentials:'same-origin',cache:'no-store'});
28
+ if(!response.ok)return null;
29
+ const body=await response.json().catch(()=>null);
30
+ if(!validBootstrap(body)||body.scope.accountId!==account.selectedAccountId)return null;
31
+ return body;
32
+ }catch{return null;}
33
+ }
34
+
35
+ /** Mounts the curation provider for the References tab. Fails closed to no provider content. */
36
+ export function NewsroomCurationBootstrap({enabled=true,children}:{enabled?:boolean;children:ReactNode}){
37
+ const [bootstrap,setBootstrap]=useState<CurationBootstrap|null>(null);
38
+ useEffect(()=>{
39
+ if(!enabled)return;
40
+ let current=true;
41
+ void loadCurationBootstrap(window.location.pathname).then(value=>{if(current)setBootstrap(value);});
42
+ return()=>{current=false;setBootstrap(null);};
43
+ },[enabled]);
44
+ return <NewsroomCurationProvider bootstrap={bootstrap}>{children}</NewsroomCurationProvider>;
45
+ }
@@ -90,6 +90,7 @@ import { ReferenceSourcePreview } from "./reference-source-preview";
90
90
  import type { ReaderAuthSnapshot } from "./reader-auth-state";
91
91
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
92
92
  import { NewsroomOpsOverview } from "./newsroom-ops-overview";
93
+ import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
93
94
  import { NewsroomReferencesView } from "./newsroom-references-view";
94
95
  import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
95
96
  import { NewsroomTopicsView } from "./newsroom-topics-view";
@@ -3116,6 +3117,7 @@ function NewsDeskDashboard({
3116
3117
  />
3117
3118
  ) : null}
3118
3119
  {activeTab === "references" ? (
3120
+ <NewsroomCurationBootstrap enabled={!dashboard.isDemo}>
3119
3121
  <NewsroomReferencesView
3120
3122
  demo={Boolean(dashboard.isDemo)}
3121
3123
  disabled={controlsDisabled}
@@ -3153,6 +3155,7 @@ function NewsDeskDashboard({
3153
3155
  referenceAttachments={referenceAttachments}
3154
3156
  references={references}
3155
3157
  />
3158
+ </NewsroomCurationBootstrap>
3156
3159
  ) : null}
3157
3160
  {activeTab === "messages" ? (
3158
3161
  <MessagesDeskView
@@ -0,0 +1,342 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ import { SITE_BRAND } from "./site-brand";
3
+ import type { PublicationCapability } from "./prospectus-publication-access";
4
+ import {
5
+ currentProspectusPublishingSelection,
6
+ signedInProspectusSession,
7
+ type ProspectusPublishingSelection,
8
+ type ProspectusSignInRouteOptions,
9
+ } from "./prospectus-sign-in-routes";
10
+ import { resolveProspectusSignInRuntime } from "./prospectus-session";
11
+
12
+ /**
13
+ * Same-origin curation routes for the References tab. Each is an authenticated
14
+ * pass-through to the curation upstream (the central Cyclotron lab): this
15
+ * module decides who may call and what they may ask, the upstream repeats its
16
+ * own checks, and neither trusts the browser's pins as identity.
17
+ *
18
+ * Order, failing closed at each step: upstream configured, Origin and body,
19
+ * sealed session cookie, fresh Prospectus re-authorization, scope pins,
20
+ * capability, forward, then re-authorize again and verify the echoed scope
21
+ * before any byte of the answer reaches the browser.
22
+ */
23
+ export const CURATION_UPSTREAM_URL_ENV = "PAPYRUS_CURATION_UPSTREAM_URL";
24
+ export const CURATION_PUBLICATION_ID_ENV = "PAPYRUS_CURATION_PUBLICATION_ID";
25
+ export const CURATION_MAXIMUM_REQUEST_BYTES = 4_000_000;
26
+ export const CURATION_MAXIMUM_RESPONSE_BYTES = 8_000_000;
27
+ export const CURATION_UPSTREAM_TIMEOUT_MILLISECONDS = 15_000;
28
+
29
+ export type CurationRoute = "context" | "publication" | "priming";
30
+
31
+ export interface CurationRouteOptions extends ProspectusSignInRouteOptions {
32
+ /** Overrides PAPYRUS_CURATION_UPSTREAM_URL. null means not configured. */
33
+ upstreamUrl?: string | null;
34
+ /** Overrides PAPYRUS_CURATION_PUBLICATION_ID, which defaults to the site brand id. */
35
+ publicationId?: string;
36
+ fetcher?: typeof fetch;
37
+ timeoutMilliseconds?: number;
38
+ }
39
+
40
+ const NO_STORE_HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
41
+ const PIN_HEADERS = ["x-cyclotron-account", "x-papyrus-publication", "x-papyrus-binding"] as const;
42
+ const PUBLICATION_OPERATION_CAPABILITIES: Record<string, PublicationCapability> = {
43
+ decide: "decide",
44
+ review: "review",
45
+ status: "read",
46
+ history: "read",
47
+ events: "read",
48
+ replay: "read",
49
+ steer: "steer",
50
+ };
51
+ const FORWARDED_UPSTREAM_REFUSALS = new Set([400, 401, 403, 409, 413, 415, 503]);
52
+
53
+ /** Editor-facing wording for the refusal codes the curation upstream returns. Unknown codes get a generic line. */
54
+ export const UPSTREAM_REFUSAL_MESSAGES: Record<string, string> = {
55
+ pins_required: "Your account or publication selection is incomplete. Refresh the page and try again.",
56
+ invalid_request: "That request could not be understood. Refresh the page and try again.",
57
+ json_required: "That request could not be understood. Refresh the page and try again.",
58
+ bearer_invalid: "Sign in again: your session expired. Your selection is preserved.",
59
+ access_refused: "Your account does not have access to this publication.",
60
+ outside_publication: "That item is outside the publication you selected.",
61
+ account_changed: "The selected account or publication changed. Refresh and try again.",
62
+ operation_unavailable: "This action is not available yet.",
63
+ firewall_overlap: "This example is also in material kept aside for checking results, so it cannot be added.",
64
+ auto_prime_failed: "Your example could not be fully applied. Check saved history before retrying.",
65
+ request_too_large: "That request is too large. Try something smaller.",
66
+ import_too_large: "That file is too large. Try a smaller file.",
67
+ storage_limit: "This publication has reached its storage limit for imported examples.",
68
+ authority_unavailable: "Account access could not be confirmed right now. Try again shortly.",
69
+ priming_not_configured: "Importing examples is not set up for this publication yet.",
70
+ status_unavailable: "Monitoring is not available right now. Try again shortly.",
71
+ live_disabled: "Live decisions are switched off for this publication.",
72
+ no_hosted_transformer: "Adding a reference from a web address is not available. Import a file instead.",
73
+ };
74
+ const GENERIC_UPSTREAM_REFUSAL = "The curation service refused the request. Check saved history before retrying.";
75
+
76
+ class CurationRefusal extends Error {
77
+ constructor(readonly status: number, readonly code: string, message: string) {
78
+ super(message);
79
+ }
80
+ }
81
+
82
+ function refusal(status: number, code: string, message: string): CurationRefusal {
83
+ return new CurationRefusal(status, code, message);
84
+ }
85
+
86
+ function jsonResponse(body: unknown, status = 200, extraHeaders: Record<string, string> = {}): NextResponse {
87
+ return NextResponse.json(body, { status, headers: { ...NO_STORE_HEADERS, ...extraHeaders } });
88
+ }
89
+
90
+ function upstreamBase(options: CurationRouteOptions): URL | null {
91
+ const configured = options.upstreamUrl !== undefined ? options.upstreamUrl : process.env[CURATION_UPSTREAM_URL_ENV];
92
+ if (!configured?.trim()) return null;
93
+ try {
94
+ const url = new URL(configured.trim());
95
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
96
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) return null;
97
+ if (url.username || url.password) return null;
98
+ return url;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function publicationIdFor(options: CurationRouteOptions): string {
105
+ return options.publicationId ?? (process.env[CURATION_PUBLICATION_ID_ENV]?.trim() || SITE_BRAND.id);
106
+ }
107
+
108
+ /** Which capability a priming GraphQL document needs. Any hint of a mutation requires review. */
109
+ export function primingCapability(query: string): PublicationCapability {
110
+ return /\bmutation\b|\bprimingCommand\b/i.test(query) ? "review" : "read";
111
+ }
112
+
113
+ async function boundedText(request: NextRequest): Promise<string> {
114
+ const declared = Number(request.headers.get("content-length") ?? "0");
115
+ if (declared > CURATION_MAXIMUM_REQUEST_BYTES) throw refusal(413, "request_too_large", "Curation request exceeds the size limit.");
116
+ const text = await request.text();
117
+ if (Buffer.byteLength(text) > CURATION_MAXIMUM_REQUEST_BYTES) throw refusal(413, "request_too_large", "Curation request exceeds the size limit.");
118
+ return text;
119
+ }
120
+
121
+ async function boundedResponseText(response: Response): Promise<string> {
122
+ const declared = Number(response.headers.get("content-length") ?? "0");
123
+ if (declared > CURATION_MAXIMUM_RESPONSE_BYTES) throw refusal(502, "upstream_response_too_large", "The curation service answered too much.");
124
+ if (!response.body) return "";
125
+ const reader = response.body.getReader();
126
+ const chunks: Uint8Array[] = [];
127
+ let total = 0;
128
+ for (;;) {
129
+ const { done, value } = await reader.read();
130
+ if (done) break;
131
+ total += value.byteLength;
132
+ if (total > CURATION_MAXIMUM_RESPONSE_BYTES) {
133
+ await reader.cancel();
134
+ throw refusal(502, "upstream_response_too_large", "The curation service answered too much.");
135
+ }
136
+ chunks.push(value);
137
+ }
138
+ return Buffer.concat(chunks).toString("utf8");
139
+ }
140
+
141
+ function sameSelection(left: ProspectusPublishingSelection, right: ProspectusPublishingSelection | null): boolean {
142
+ return right !== null
143
+ && left.personId === right.personId
144
+ && left.accountId === right.accountId
145
+ && left.role === right.role
146
+ && left.publicationCapabilities.join() === right.publicationCapabilities.join();
147
+ }
148
+
149
+ interface ParsedRequest {
150
+ capability: PublicationCapability;
151
+ /** Raw body to forward; undefined for the context read. */
152
+ body?: string;
153
+ }
154
+
155
+ function parseRequest(route: CurationRoute, text: string | null): ParsedRequest {
156
+ if (route === "context") return { capability: "read" };
157
+ let value: unknown;
158
+ try {
159
+ value = JSON.parse(text ?? "");
160
+ } catch {
161
+ throw refusal(400, "invalid_request", "A JSON curation request is required.");
162
+ }
163
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw refusal(400, "invalid_request", "A JSON curation request is required.");
164
+ const body = value as Record<string, unknown>;
165
+ if (route === "publication") {
166
+ const keys = Object.keys(body).sort().join();
167
+ const operation = typeof body.operation === "string" ? body.operation : "";
168
+ if (keys !== "operation,payload" || !Object.hasOwn(PUBLICATION_OPERATION_CAPABILITIES, operation) || !body.payload || typeof body.payload !== "object" || Array.isArray(body.payload)) {
169
+ throw refusal(400, "invalid_request", "An explicit publication operation is required.");
170
+ }
171
+ return { capability: PUBLICATION_OPERATION_CAPABILITIES[operation], body: text! };
172
+ }
173
+ const variablesValid = body.variables === undefined || (body.variables !== null && typeof body.variables === "object" && !Array.isArray(body.variables));
174
+ if (typeof body.query !== "string" || !body.query.trim() || !variablesValid || Object.keys(body).some((key) => key !== "query" && key !== "variables")) {
175
+ throw refusal(400, "invalid_request", "An explicit priming query is required.");
176
+ }
177
+ return { capability: primingCapability(body.query), body: text! };
178
+ }
179
+
180
+ function requestPins(request: NextRequest, selection: ProspectusPublishingSelection, publicationId: string): Record<(typeof PIN_HEADERS)[number], string> {
181
+ const pins = Object.fromEntries(PIN_HEADERS.map((name) => [name, request.headers.get(name)?.trim() ?? ""])) as Record<(typeof PIN_HEADERS)[number], string>;
182
+ if (PIN_HEADERS.some((name) => !pins[name])) throw refusal(400, "pins_required", "Expected account, publication and binding pins are required.");
183
+ if (pins["x-cyclotron-account"] !== selection.accountId || pins["x-papyrus-publication"] !== publicationId) {
184
+ throw refusal(409, "account_changed", "The selected account or publication changed. Refresh and try again.");
185
+ }
186
+ return pins;
187
+ }
188
+
189
+ function authorizedCapabilities(selection: ProspectusPublishingSelection): PublicationCapability[] {
190
+ return selection.publicationCapabilities;
191
+ }
192
+
193
+ async function forward(
194
+ request: NextRequest,
195
+ route: CurationRoute,
196
+ base: URL,
197
+ options: CurationRouteOptions,
198
+ accessToken: string,
199
+ upstreamHeaders: Record<string, string>,
200
+ body: string | undefined,
201
+ ): Promise<Response> {
202
+ const target = new URL(`/api/newsroom/curation/${route}`, base);
203
+ const headers: Record<string, string> = {
204
+ ...upstreamHeaders,
205
+ authorization: `Bearer ${accessToken}`,
206
+ accept: "application/json",
207
+ };
208
+ if (body !== undefined) headers["content-type"] = "application/json";
209
+ const response = await (options.fetcher ?? fetch)(target, {
210
+ method: route === "context" ? "GET" : "POST",
211
+ headers,
212
+ body,
213
+ redirect: "manual",
214
+ cache: "no-store",
215
+ signal: AbortSignal.timeout(options.timeoutMilliseconds ?? CURATION_UPSTREAM_TIMEOUT_MILLISECONDS),
216
+ });
217
+ if (response.status >= 300 && response.status < 400) throw refusal(502, "upstream_redirect", "The curation service redirected, which is refused.");
218
+ return response;
219
+ }
220
+
221
+ function sanitizedUpstreamRefusal(status: number, text: string): CurationRefusal {
222
+ let code = "";
223
+ try {
224
+ const parsed = JSON.parse(text) as { code?: unknown };
225
+ code = typeof parsed.code === "string" ? parsed.code : "";
226
+ } catch {
227
+ code = "";
228
+ }
229
+ const known = Object.hasOwn(UPSTREAM_REFUSAL_MESSAGES, code);
230
+ return refusal(status, known ? code : "upstream_refused", known ? UPSTREAM_REFUSAL_MESSAGES[code] : GENERIC_UPSTREAM_REFUSAL);
231
+ }
232
+
233
+ function validPriming(value: unknown): Record<string, unknown> | null {
234
+ const priming = value as { listId?: unknown; classifiers?: unknown; transformers?: unknown } | null;
235
+ if (!priming || typeof priming.listId !== "string" || !priming.listId || !Array.isArray(priming.classifiers) || !priming.classifiers.length) return null;
236
+ return { listId: priming.listId, classifiers: priming.classifiers, transformers: Array.isArray(priming.transformers) ? priming.transformers : [] };
237
+ }
238
+
239
+ function contextBootstrap(value: unknown, selection: ProspectusPublishingSelection, publicationId: string): Record<string, unknown> {
240
+ const body = value as { scope?: Record<string, unknown>; mode?: unknown; priming?: unknown; capabilities?: unknown; bindingRef?: unknown } | null;
241
+ const scope = body?.scope;
242
+ if (!scope || scope.accountId !== selection.accountId || scope.publicationId !== publicationId
243
+ || typeof scope.bindingRevision !== "string" || !scope.bindingRevision
244
+ || (body!.mode !== "embedded" && body!.mode !== "remote") || !Array.isArray(body!.capabilities)) {
245
+ throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
246
+ }
247
+ const granted = new Set<string>(authorizedCapabilities(selection));
248
+ return {
249
+ scope: { accountId: scope.accountId, publicationId: scope.publicationId, bindingRevision: scope.bindingRevision },
250
+ mode: body!.mode,
251
+ priming: validPriming(body!.priming),
252
+ capabilities: body!.capabilities.filter((capability): capability is string => typeof capability === "string" && granted.has(capability)),
253
+ bindingRef: body!.bindingRef ?? null,
254
+ };
255
+ }
256
+
257
+ function verifiedPublicationEnvelope(value: unknown, pins: Record<string, string>): void {
258
+ const envelope = value as Record<string, unknown> | null;
259
+ if (!envelope || envelope.contractVersion !== "cyclotron-publication/v1"
260
+ || envelope.accountId !== pins["x-cyclotron-account"]
261
+ || envelope.publicationId !== pins["x-papyrus-publication"]
262
+ || envelope.bindingRevision !== pins["x-papyrus-binding"]) {
263
+ throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
264
+ }
265
+ }
266
+
267
+ export async function handleCurationRoute(request: NextRequest, route: CurationRoute, options: CurationRouteOptions = {}): Promise<NextResponse> {
268
+ try {
269
+ const base = upstreamBase(options);
270
+ if (!base) return jsonResponse({ error: "curation_not_configured", message: "Curation is not configured for this site." }, 503);
271
+
272
+ const origin = request.headers.get("origin");
273
+ if (route !== "context" ? origin !== request.nextUrl.origin : origin !== null && origin !== request.nextUrl.origin) {
274
+ throw refusal(403, "cross_origin", "Curation requests must come from this site.");
275
+ }
276
+ let text: string | null = null;
277
+ if (route !== "context") {
278
+ if (request.headers.get("content-type")?.split(";")[0].trim() !== "application/json") throw refusal(415, "json_required", "A JSON request is required.");
279
+ text = await boundedText(request);
280
+ }
281
+
282
+ const runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request.nextUrl.origin);
283
+ if (!runtime) return jsonResponse({ error: "prospectus_sign_in_unavailable", message: "Sign-in through Prospectus is not configured for this site." }, 503);
284
+ const nowInSeconds = options.nowInSeconds?.();
285
+ const cookieSession = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
286
+ if (!cookieSession?.selectedAccountId) throw refusal(401, "signed_out", "Sign in and choose an account first.");
287
+
288
+ const selection = await currentProspectusPublishingSelection(request, options);
289
+ if (!selection || selection.accountId !== cookieSession.selectedAccountId) throw refusal(403, "not_authorized", "Your account no longer grants access.");
290
+
291
+ const publicationId = publicationIdFor(options);
292
+ const pins = route === "context" ? null : requestPins(request, selection, publicationId);
293
+ const parsed = parseRequest(route, text);
294
+ if (!authorizedCapabilities(selection).includes(parsed.capability)) throw refusal(403, "capability_refused", "Your role does not allow this curation action.");
295
+
296
+ // The token is read from the sealed cookie again for this call, never held across calls.
297
+ const fresh = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
298
+ if (!fresh || fresh.selectedAccountId !== selection.accountId) throw refusal(401, "signed_out", "Sign in and choose an account first.");
299
+ const upstreamHeaders = pins ?? { "x-cyclotron-account": selection.accountId, "x-papyrus-publication": publicationId };
300
+ const upstream = await forward(request, route, base, options, fresh.accessToken, upstreamHeaders, parsed.body);
301
+ const upstreamText = await boundedResponseText(upstream);
302
+ if (upstream.status !== 200) {
303
+ if (FORWARDED_UPSTREAM_REFUSALS.has(upstream.status)) throw sanitizedUpstreamRefusal(upstream.status, upstreamText);
304
+ throw refusal(502, "upstream_failed", "The curation service could not complete the request. Check saved history before retrying.");
305
+ }
306
+
307
+ // Post-upstream check: nothing is released if authority or scope moved while we waited.
308
+ if (!sameSelection(selection, await currentProspectusPublishingSelection(request, options))) {
309
+ throw refusal(409, "account_changed", "The selected account or access changed. Refresh and try again.");
310
+ }
311
+ let answer: unknown;
312
+ try {
313
+ answer = JSON.parse(upstreamText);
314
+ } catch {
315
+ throw refusal(502, "upstream_invalid", "The curation service answered with an invalid response.");
316
+ }
317
+ if (pins) {
318
+ if (PIN_HEADERS.some((name) => upstream.headers.get(name) !== pins[name])) {
319
+ throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
320
+ }
321
+ if (route === "publication") verifiedPublicationEnvelope(answer, pins);
322
+ if (route === "priming" && (!answer || typeof answer !== "object" || Array.isArray(answer))) {
323
+ throw refusal(502, "upstream_invalid", "The curation service answered with an invalid response.");
324
+ }
325
+ return jsonResponse(answer, 200, pins);
326
+ }
327
+ const bootstrap = contextBootstrap(answer, selection, publicationId);
328
+ const bindingRevision = (bootstrap.scope as { bindingRevision: string }).bindingRevision;
329
+ if (upstream.headers.get("x-cyclotron-account") !== selection.accountId
330
+ || upstream.headers.get("x-papyrus-publication") !== publicationId
331
+ || upstream.headers.get("x-papyrus-binding") !== bindingRevision) {
332
+ throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
333
+ }
334
+ return jsonResponse(bootstrap, 200, {
335
+ "x-cyclotron-account": selection.accountId, "x-papyrus-publication": publicationId, "x-papyrus-binding": bindingRevision,
336
+ });
337
+ } catch (error) {
338
+ if (error instanceof CurationRefusal) return jsonResponse({ error: error.message, code: error.code }, error.status);
339
+ console.error(`Curation route failed (${error instanceof Error ? error.name : "error"}).`);
340
+ return jsonResponse({ error: "Curation is unavailable. Check saved history before retrying.", code: "curation_unavailable" }, 503);
341
+ }
342
+ }
package/package.json CHANGED
@@ -98,6 +98,7 @@
98
98
  "./components/newsroom-assignments-view": "./components/newsroom-assignments-view.tsx",
99
99
  "./components/newsroom-board-view": "./components/newsroom-board-view.tsx",
100
100
  "./components/newsroom-client-shell": "./components/newsroom-client-shell.tsx",
101
+ "./components/newsroom-curation-bootstrap": "./components/newsroom-curation-bootstrap.tsx",
101
102
  "./components/newsroom-curation-provider": "./components/newsroom-curation-provider.tsx",
102
103
  "./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
103
104
  "./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
@@ -191,6 +192,7 @@
191
192
  "./lib/content-types": "./lib/content-types.ts",
192
193
  "./lib/curation-client": "./lib/curation-client.ts",
193
194
  "./lib/curation-delegation-helpers": "./lib/curation-delegation-helpers.ts",
195
+ "./lib/curation-routes": "./lib/curation-routes.ts",
194
196
  "./lib/cyclotron-suggestions": "./lib/cyclotron-suggestions.ts",
195
197
  "./lib/define-site": "./lib/define-site.ts",
196
198
  "./lib/demo-amplify-outputs": "./lib/demo-amplify-outputs.ts",
@@ -336,6 +338,9 @@
336
338
  "./app/api/local-preview/derive/route": "./app/api/local-preview/derive/route.ts",
337
339
  "./app/api/local-preview/media/route": "./app/api/local-preview/media/route.ts",
338
340
  "./app/api/media/[...path]/route": "./app/api/media/[...path]/route.ts",
341
+ "./app/api/newsroom/curation/context/route": "./app/api/newsroom/curation/context/route.ts",
342
+ "./app/api/newsroom/curation/priming/route": "./app/api/newsroom/curation/priming/route.ts",
343
+ "./app/api/newsroom/curation/publication/route": "./app/api/newsroom/curation/publication/route.ts",
339
344
  "./app/api/revalidate/route": "./app/api/revalidate/route.ts",
340
345
  "./app/archive/page": "./app/archive/page.tsx",
341
346
  "./app/articles/[slug]/page": "./app/articles/[slug]/page.tsx",
@@ -401,7 +406,7 @@
401
406
  "optional": true
402
407
  }
403
408
  },
404
- "version": "5.3.0",
409
+ "version": "5.5.0",
405
410
  "dependencies": {
406
411
  "@aws-amplify/adapter-nextjs": "^1.7.3",
407
412
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.3.0",
2
+ "version": "5.5.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -170,6 +170,39 @@
170
170
  "dynamic": "\"force-dynamic\""
171
171
  }
172
172
  },
173
+ {
174
+ "file": "app/api/newsroom/curation/context/route.ts",
175
+ "hasDefault": false,
176
+ "exports": [
177
+ "GET"
178
+ ],
179
+ "config": {
180
+ "dynamic": "\"force-dynamic\"",
181
+ "runtime": "\"nodejs\""
182
+ }
183
+ },
184
+ {
185
+ "file": "app/api/newsroom/curation/priming/route.ts",
186
+ "hasDefault": false,
187
+ "exports": [
188
+ "POST"
189
+ ],
190
+ "config": {
191
+ "dynamic": "\"force-dynamic\"",
192
+ "runtime": "\"nodejs\""
193
+ }
194
+ },
195
+ {
196
+ "file": "app/api/newsroom/curation/publication/route.ts",
197
+ "hasDefault": false,
198
+ "exports": [
199
+ "POST"
200
+ ],
201
+ "config": {
202
+ "dynamic": "\"force-dynamic\"",
203
+ "runtime": "\"nodejs\""
204
+ }
205
+ },
173
206
  {
174
207
  "file": "app/api/revalidate/route.ts",
175
208
  "hasDefault": false,