@anthusai/papyrus 1.0.0-next.60 → 1.0.0-next.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/cyclotron/priming-proof/route.ts +11 -0
- package/app/cyclotron/page.tsx +13 -0
- package/app/tailwind.css +1 -0
- package/components/newsroom-curation-provider.tsx +17 -0
- package/components/newsroom-references-view.tsx +9 -0
- package/components/priming-proof-page.tsx +92 -0
- package/components/publication-cyclotron-panel.tsx +57 -0
- package/components/reference-curation-controls.tsx +77 -0
- package/components/reference-priming.tsx +18 -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/curation-client.ts +61 -0
- package/lib/priming-proof-report.ts +89 -0
- package/lib/prospectus-site-identity.ts +303 -0
- package/lib/publication-cyclotron.ts +74 -0
- package/package.json +19 -2
- package/routes.manifest.json +17 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { approvedPrimingProofReport } from "../../../../lib/priming-proof-report";
|
|
3
|
+
import { SITE_BRAND } from "../../../../lib/site-brand";
|
|
4
|
+
|
|
5
|
+
export function GET() {
|
|
6
|
+
const report = approvedPrimingProofReport(SITE_BRAND.id);
|
|
7
|
+
if (report === null) {
|
|
8
|
+
return NextResponse.json({ error: "No approved priming proof report for this publication yet." }, { status: 404 });
|
|
9
|
+
}
|
|
10
|
+
return NextResponse.json(report);
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { PrimingProofPage } from "../../components/priming-proof-page";
|
|
3
|
+
import { approvedPrimingProofReport } from "../../lib/priming-proof-report";
|
|
4
|
+
import { SITE_BRAND } from "../../lib/site-brand";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Cyclotron proof | ${SITE_BRAND.articleTitleSuffix}`,
|
|
8
|
+
description: "How often the relevance classifier agrees with editors on a fixed held-out set, before and after editor labels.",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function CyclotronProof() {
|
|
12
|
+
return <PrimingProofPage report={approvedPrimingProofReport(SITE_BRAND.id)} />;
|
|
13
|
+
}
|
package/app/tailwind.css
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {createContext,useContext,useMemo,useRef,type ReactNode} from 'react';
|
|
4
|
+
import {createCurationSession,requestCuration,type CurationBootstrap,type CurationRequest,type CurationSession} from '../lib/curation-client';
|
|
5
|
+
import type {PublicationScope} from '../lib/publication-cyclotron';
|
|
6
|
+
|
|
7
|
+
const Context=createContext<CurationSession|null>(null);
|
|
8
|
+
|
|
9
|
+
/** Host opt-in. Absence preserves the released CMS and performs no new reads. */
|
|
10
|
+
export function NewsroomCurationProvider({bootstrap,request=requestCuration,children}:{bootstrap:CurationBootstrap|null;request?:CurationRequest;children:ReactNode}){
|
|
11
|
+
const selected=useRef<PublicationScope|null>(bootstrap?.scope??null);
|
|
12
|
+
selected.current=bootstrap?.scope??null;
|
|
13
|
+
const session=useMemo(()=>bootstrap?createCurationSession(bootstrap,request,()=>selected.current):null,[bootstrap,request]);
|
|
14
|
+
return <Context.Provider value={session}>{children}</Context.Provider>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useNewsroomCuration(){return useContext(Context);}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { PrimingPanelProps } from "cyclotron/components/priming-panel";
|
|
4
|
+
import { ReferencePriming } from "./reference-priming";
|
|
5
|
+
import { ReferenceCurationControls } from "./reference-curation-controls";
|
|
6
|
+
|
|
3
7
|
import {
|
|
4
8
|
ArchiveIcon,
|
|
5
9
|
ChevronLeftIcon,
|
|
@@ -54,6 +58,8 @@ import {
|
|
|
54
58
|
} from "./news-desk-taxonomy-client";
|
|
55
59
|
|
|
56
60
|
type NewsroomReferencesViewProps = {
|
|
61
|
+
/** Present only when the host has configured optional example priming. */
|
|
62
|
+
priming?: PrimingPanelProps;
|
|
57
63
|
/** The relevance cyclotron's status snapshot, shown above the list. */
|
|
58
64
|
cyclotronStatus?: CyclotronStatus | null;
|
|
59
65
|
/** Present for editors who may ask for a manual review rate. */
|
|
@@ -391,6 +397,7 @@ function ReferenceDetailPanel({
|
|
|
391
397
|
}
|
|
392
398
|
|
|
393
399
|
export function NewsroomReferencesView({
|
|
400
|
+
priming,
|
|
394
401
|
cyclotronStatus,
|
|
395
402
|
decisions,
|
|
396
403
|
onRequestReviewRate,
|
|
@@ -507,6 +514,8 @@ export function NewsroomReferencesView({
|
|
|
507
514
|
Review source intake before it becomes accepted evidence.
|
|
508
515
|
</p>
|
|
509
516
|
</div>
|
|
517
|
+
<ReferenceCurationControls disabled={disabled} />
|
|
518
|
+
{priming && !disabled ? <ReferencePriming {...priming} /> : null}
|
|
510
519
|
{cyclotronStatus ? (
|
|
511
520
|
<div className="shrink-0">
|
|
512
521
|
<ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatProofPercentage,
|
|
3
|
+
primingProofHeadline,
|
|
4
|
+
type PrimingProofReport,
|
|
5
|
+
} from "../lib/priming-proof-report";
|
|
6
|
+
|
|
7
|
+
function ProofMethod({ heldOutSize }: { heldOutSize: number | null }) {
|
|
8
|
+
return (
|
|
9
|
+
<section aria-labelledby="priming-proof-method" className="space-y-2 text-sm leading-relaxed">
|
|
10
|
+
<h2 id="priming-proof-method" className="text-base font-semibold uppercase tracking-wide">How the number is made</h2>
|
|
11
|
+
<p>
|
|
12
|
+
Editors already decided which candidate sources belong in this publication. A fixed, balanced sample of those
|
|
13
|
+
decisions{heldOutSize === null ? "" : ` (${heldOutSize} sources)`} is frozen as the held-out set and never used
|
|
14
|
+
for teaching. A classifier that always gives the same answer agrees on half of it.
|
|
15
|
+
</p>
|
|
16
|
+
<p>
|
|
17
|
+
Editors teach the classifier by pasting a source URL, reviewing what Papyrus extracted, and choosing publish or
|
|
18
|
+
reject. Each point below starts a fresh classifier that sees only the first N of those labels, then decides
|
|
19
|
+
every held-out source once. Nothing else changes between points: the same decision model, the publication's
|
|
20
|
+
mission and policies as the only rubric, and no automatic rubric rewriting.
|
|
21
|
+
</p>
|
|
22
|
+
</section>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function PrimingProofPage({ report }: { report: PrimingProofReport | null }) {
|
|
27
|
+
if (report === null) {
|
|
28
|
+
return (
|
|
29
|
+
<main className="mx-auto max-w-3xl space-y-6 px-4 py-10" data-priming-proof="pending">
|
|
30
|
+
<header className="space-y-2 border-b pb-4">
|
|
31
|
+
<h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
|
|
32
|
+
<p className="text-muted-foreground">No approved proof run has been published for this publication yet.</p>
|
|
33
|
+
</header>
|
|
34
|
+
<ProofMethod heldOutSize={null} />
|
|
35
|
+
</main>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const { baseline, latest, agreementChangeInPoints } = primingProofHeadline(report);
|
|
39
|
+
const [positiveLabel, negativeLabel] = [report.positiveLabel, report.labels.find((label) => label !== report.positiveLabel)];
|
|
40
|
+
return (
|
|
41
|
+
<main className="mx-auto max-w-3xl space-y-8 px-4 py-10" data-priming-proof="published">
|
|
42
|
+
<header className="space-y-3 border-b pb-4">
|
|
43
|
+
<h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
|
|
44
|
+
<p className="text-lg" data-priming-proof-headline>
|
|
45
|
+
Agreement with editors went from <strong>{formatProofPercentage(baseline.agreement)}</strong> with no labels to{" "}
|
|
46
|
+
<strong>{formatProofPercentage(latest.agreement)}</strong> after {latest.labels} labels
|
|
47
|
+
{agreementChangeInPoints === null ? "" : ` (${agreementChangeInPoints >= 0 ? "+" : ""}${agreementChangeInPoints} points)`}.
|
|
48
|
+
</p>
|
|
49
|
+
<p className="text-sm text-muted-foreground">
|
|
50
|
+
{report.publication} · question: “{report.question}” · held-out set of {report.heldOut.size} sources frozen{" "}
|
|
51
|
+
{report.heldOut.frozenAt.slice(0, 10)} · run {report.ranAt.slice(0, 10)}
|
|
52
|
+
{report.decisionModel ? ` · decision model ${report.decisionModel}` : ""}
|
|
53
|
+
</p>
|
|
54
|
+
</header>
|
|
55
|
+
<section aria-labelledby="priming-proof-points" className="space-y-2">
|
|
56
|
+
<h2 id="priming-proof-points" className="text-base font-semibold uppercase tracking-wide">Before and after</h2>
|
|
57
|
+
<div className="overflow-x-auto">
|
|
58
|
+
<table className="w-full text-left text-sm tabular-nums">
|
|
59
|
+
<thead>
|
|
60
|
+
<tr className="border-b">
|
|
61
|
+
<th scope="col" className="py-2 pr-4">Editor labels</th>
|
|
62
|
+
<th scope="col" className="py-2 pr-4">Agreement</th>
|
|
63
|
+
<th scope="col" className="py-2 pr-4">Precision ({positiveLabel})</th>
|
|
64
|
+
<th scope="col" className="py-2 pr-4">Recall ({positiveLabel})</th>
|
|
65
|
+
<th scope="col" className="py-2">Labels used</th>
|
|
66
|
+
</tr>
|
|
67
|
+
</thead>
|
|
68
|
+
<tbody>
|
|
69
|
+
{report.points.map((point) => (
|
|
70
|
+
<tr key={point.labels} className="border-b" data-priming-proof-point={point.labels}>
|
|
71
|
+
<td className="py-2 pr-4">{point.labels}</td>
|
|
72
|
+
<td className="py-2 pr-4">{formatProofPercentage(point.agreement)}</td>
|
|
73
|
+
<td className="py-2 pr-4">{formatProofPercentage(point.precision)}</td>
|
|
74
|
+
<td className="py-2 pr-4">{formatProofPercentage(point.recall)}</td>
|
|
75
|
+
<td className="py-2">
|
|
76
|
+
{point.labelsByClass[positiveLabel] ?? 0} {positiveLabel}
|
|
77
|
+
{negativeLabel ? `, ${point.labelsByClass[negativeLabel] ?? 0} ${negativeLabel}` : ""}
|
|
78
|
+
</td>
|
|
79
|
+
</tr>
|
|
80
|
+
))}
|
|
81
|
+
</tbody>
|
|
82
|
+
</table>
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
<ProofMethod heldOutSize={report.heldOut.size} />
|
|
86
|
+
<p className="break-all text-xs text-muted-foreground">
|
|
87
|
+
Held-out fingerprint {report.heldOut.fingerprint}
|
|
88
|
+
{report.priming.fingerprint ? ` · labels fingerprint ${report.priming.fingerprint}` : ""}
|
|
89
|
+
</p>
|
|
90
|
+
</main>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {useRef,useState} from 'react';
|
|
4
|
+
import {ReviewControl,type ReviewControlReview} from 'cyclotron/components/review-control';
|
|
5
|
+
import {CyclotronStatusView} from 'cyclotron/components/cyclotron-status';
|
|
6
|
+
import {assertPublicationScope,publicationScopeKey,publicationReviewPayload,type PublicationStatus,type PublicationHistory,type PublicationHistoryRow,type PublicationReviewPayload} from '../lib/publication-cyclotron';
|
|
7
|
+
|
|
8
|
+
function requestId(){
|
|
9
|
+
const bytes=crypto.getRandomValues(new Uint8Array(16));
|
|
10
|
+
return Array.from(bytes,value=>value.toString(16).padStart(2,'0')).join('');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Data-only shared operator panel. The authorized host owns reads, writes and refresh. */
|
|
14
|
+
type PublicationPanelProps={mode:'embedded'|'remote';status:PublicationStatus;history:PublicationHistory;onReview?:(payload:PublicationReviewPayload)=>Promise<void>;onSteer?:(classifier:string)=>void};
|
|
15
|
+
export function PublicationCyclotronPanel(props:PublicationPanelProps){
|
|
16
|
+
assertPublicationScope(props.status,props.history);
|
|
17
|
+
// Remount all drafts, retry identities, pending messages and busy state on scope change.
|
|
18
|
+
return <PublicationControls key={publicationScopeKey(props.status)} {...props}/>;
|
|
19
|
+
}
|
|
20
|
+
function PublicationControls({mode,status,history,onReview,onSteer}:PublicationPanelProps){
|
|
21
|
+
assertPublicationScope(status,history);
|
|
22
|
+
const context=publicationScopeKey(status);const current=useRef(context);current.current=context;
|
|
23
|
+
const [message,setMessage]=useState<{context:string;key:string;error:boolean;text:string}|null>(null);const [busy,setBusy]=useState<string|null>(null);
|
|
24
|
+
const [editing,setEditing]=useState<Record<string,boolean>>({});const requests=useRef<Record<string,{signature:string;id:string}>>({});
|
|
25
|
+
async function submit(row:PublicationHistoryRow,classifier:string,review:ReviewControlReview){
|
|
26
|
+
const key=`${context}:${row.decision.decisionId}:${classifier}:${row.context?.contextId??'legacy'}`;
|
|
27
|
+
try{
|
|
28
|
+
const signature=JSON.stringify(review);const previous=requests.current[key];
|
|
29
|
+
const id=previous?.signature===signature?previous.id:requestId();requests.current[key]={signature,id};
|
|
30
|
+
const payload=publicationReviewPayload(status,row,classifier,review,id);
|
|
31
|
+
if(!onReview)throw new Error('This host has not enabled human review');
|
|
32
|
+
setBusy(key);setMessage(null);await onReview(payload);
|
|
33
|
+
if(current.current===context){setMessage({context,key,error:false,text:'Human label acknowledged.'});setEditing(old=>({...old,[key]:false}));}
|
|
34
|
+
}catch(error){if(current.current===context)setMessage({context,key,error:true,text:error instanceof Error?error.message:'Review failed; its outcome must be checked before retrying.'});}
|
|
35
|
+
finally{if(current.current===context)setBusy(null);}
|
|
36
|
+
}
|
|
37
|
+
return <section data-publication-scope={context} aria-label="Publication Cyclotron controls" className="space-y-4">
|
|
38
|
+
<header><h2 className="text-lg font-semibold">Cyclotron · {status.publicationId}</h2><p className="text-sm text-muted-foreground">{mode==='embedded'?'Classifier data owned by this deployment.':'Canonical classifier data owned by the shared remote lab.'}</p></header>
|
|
39
|
+
{status.outputs.map(output=><section key={`${context}:${output.id}`} aria-label={output.question} className="space-y-3 rounded-md border p-3">
|
|
40
|
+
<CyclotronStatusView status={status.classifiers[output.id]} classifierName={output.question} variant="card"/>
|
|
41
|
+
{onSteer?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>Evaluate classifier proposal</button>:null}
|
|
42
|
+
{history.history.map(row=>{
|
|
43
|
+
const prediction=row.decision.classifiers[output.id];if(!prediction)return null;
|
|
44
|
+
const key=`${context}:${row.decision.decisionId}:${output.id}:${row.context?.contextId??'legacy'}`;const saved=row.reviews[output.id];const historical=Boolean(row.historicalContext||row.provenanceUnavailable||row.feedbackReconciliationRequired);
|
|
45
|
+
return <article key={key} className="space-y-2 rounded-md border p-3">
|
|
46
|
+
{historical?<p className="text-sm">Historical source or binding revision · read-only</p>:null}{row.feedbackReconciliationRequired?<p role="status">Canonical feedback changed; provenance reconciliation is required before replay.</p>:null}<h3 className="font-medium">{String(row.item.title??row.decision.itemId)}</h3>
|
|
47
|
+
<p className="whitespace-pre-wrap text-sm">{String(row.item.abstract??row.item.text??'')}</p>
|
|
48
|
+
{!saved||editing[key]?<p className="text-sm" role="status">{historical?'This source revision is read-only.':!onReview?'Human review is unavailable in this session.':busy===key?'Submitting review… Your selection is preserved; wait for acknowledgement.':'Choose a label to enable Submit review. Explanation is optional.'}</p>:null}
|
|
49
|
+
<ReviewControl key={`${key}:${Boolean(editing[key])}`} itemId={`${row.decision.itemId}:${Array.from(new TextEncoder().encode(context+':'+output.id+':'+(row.context?.contextId??'legacy')),byte=>byte.toString(16).padStart(2,'0')).join('')}`} decision={{decisionId:row.decision.decisionId,...prediction,classes:output.classes}} question={output.question} mode="labels" positiveLabel={output.positiveLabel??undefined} disabled={Boolean(busy)||!onReview||historical} value={saved&&!editing[key]?{label:saved.label,reasonCode:saved.reasonCode,explanation:saved.explanation}:undefined} onReview={review=>void submit(row,output.id,{...review,itemId:row.decision.itemId})}/>
|
|
50
|
+
{message?.context===context&&message.key===key?<p role={message.error?'alert':'status'} className="text-sm">{message.text}</p>:null}
|
|
51
|
+
{saved&&onReview&&!historical&&!editing[key]?<button type="button" disabled={Boolean(busy)} onClick={()=>setEditing(old=>({...old,[key]:true}))}>Correct human label</button>:null}
|
|
52
|
+
<details><summary>Decision provenance and version</summary><pre className="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify({itemId:row.decision.itemId,source:row.item.source_uri,decisionId:row.decision.decisionId,prediction,bindingRevision:row.context?.binding.values.revision??status.bindingRevision,sourceRef:row.context?.sourceRef,contextId:row.context?.contextId},null,2)}</pre></details>
|
|
53
|
+
</article>;
|
|
54
|
+
})}
|
|
55
|
+
</section>)}
|
|
56
|
+
</section>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {useCallback,useEffect,useRef,useState} from 'react';
|
|
4
|
+
import {useNewsroomCuration} from './newsroom-curation-provider';
|
|
5
|
+
import {PublicationCyclotronPanel} from './publication-cyclotron-panel';
|
|
6
|
+
import {ReferencePriming} from './reference-priming';
|
|
7
|
+
import {Button} from './ui/button';
|
|
8
|
+
import {publicationSteeringPayload,type PublicationHistory,type PublicationStatus} from '../lib/publication-cyclotron';
|
|
9
|
+
import type {CurationSession} from '../lib/curation-client';
|
|
10
|
+
|
|
11
|
+
export function ReferenceCurationControls({disabled=false}:{disabled?:boolean}){
|
|
12
|
+
const session=useNewsroomCuration();
|
|
13
|
+
return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled}/>:null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function Controls({session,disabled}:{session:CurationSession;disabled:boolean}){
|
|
17
|
+
const [snapshot,setSnapshot]=useState<{status:PublicationStatus;history:PublicationHistory}|null>(null);
|
|
18
|
+
const [error,setError]=useState('');
|
|
19
|
+
const [loading,setLoading]=useState(true);
|
|
20
|
+
const [primingMessage,setPrimingMessage]=useState('');
|
|
21
|
+
const [steering,setSteering]=useState<string|null>(null);
|
|
22
|
+
const [rubric,setRubric]=useState('');
|
|
23
|
+
const [explanation,setExplanation]=useState('');
|
|
24
|
+
const [busy,setBusy]=useState(false);
|
|
25
|
+
const [trial,setTrial]=useState<{fingerprint:string;improved:boolean}|null>(null);
|
|
26
|
+
const [steeringMessage,setSteeringMessage]=useState('');
|
|
27
|
+
const steeringRequests=useRef<Record<string,string>>({});
|
|
28
|
+
const canReview=!disabled&&session.bootstrap.capabilities.includes('review');
|
|
29
|
+
const canSteer=!disabled&&session.bootstrap.capabilities.includes('steer');
|
|
30
|
+
const refresh=useCallback(async()=>{
|
|
31
|
+
setLoading(true);setError('');
|
|
32
|
+
try{setSnapshot(await session.snapshot());}
|
|
33
|
+
catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'Curation status unavailable');}
|
|
34
|
+
finally{setLoading(false);}
|
|
35
|
+
},[session]);
|
|
36
|
+
useEffect(()=>{
|
|
37
|
+
let active=true;
|
|
38
|
+
void session.snapshot().then(value=>{if(active){setSnapshot(value);setError('');}}).catch(failure=>{if(active){setSnapshot(null);setError(failure instanceof Error?failure.message:'Curation status unavailable');}}).finally(()=>{if(active)setLoading(false);});
|
|
39
|
+
return()=>{active=false;};
|
|
40
|
+
},[session]);
|
|
41
|
+
async function steer(action:'evaluate'|'promote'){
|
|
42
|
+
if(!snapshot||!steering||!session.bootstrap.bindingRef)return;
|
|
43
|
+
setBusy(true);setSteeringMessage('');
|
|
44
|
+
try{
|
|
45
|
+
const identity=JSON.stringify([action,steering,rubric.trim(),explanation.trim(),trial?.fingerprint??'',snapshot.status.classifiers[steering].cyclotron.fingerprint]);
|
|
46
|
+
const requestId=steeringRequests.current[identity]??=crypto.randomUUID();
|
|
47
|
+
const command=action==='evaluate'?{action,proposal:{rubric:rubric.trim()},explanation,requestId} as const:{action,trialFingerprint:trial?.fingerprint??'',explanation,requestId} as const;
|
|
48
|
+
const payload=publicationSteeringPayload(snapshot.status,steering,session.bootstrap.bindingRef,command);
|
|
49
|
+
const receipt=await session.publication.steer(payload);
|
|
50
|
+
const result=(receipt.steering as {result?:{trial_fingerprint?:string;improved?:boolean;promoted?:boolean}}|undefined)?.result;
|
|
51
|
+
if(action==='evaluate')setTrial(result?.trial_fingerprint?{fingerprint:result.trial_fingerprint,improved:result.improved===true}:null);
|
|
52
|
+
setSteeringMessage(action==='promote'?'Evaluated proposal promoted.':result?.improved?'Evaluation complete: the candidate improved the measured result.':'Evaluation complete: no improvement was reported.');
|
|
53
|
+
await refresh();
|
|
54
|
+
}catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'Proposal outcome unavailable; check before retrying');}
|
|
55
|
+
finally{setBusy(false);}
|
|
56
|
+
}
|
|
57
|
+
return <section aria-label="Publication curation" className="max-h-[55dvh] min-h-0 shrink-0 space-y-3 overflow-y-auto rounded-md border p-3" data-curation-mode={session.bootstrap.mode}>
|
|
58
|
+
<div className="flex items-center justify-between gap-3"><h3 className="font-semibold">Publication curation</h3><Button variant="outline" size="sm" disabled={loading||busy} onClick={()=>void refresh()}>Refresh curation</Button></div>
|
|
59
|
+
<p className="text-xs text-muted-foreground">{session.bootstrap.mode==='remote'?'Remote workspace':'Embedded workspace'} · {session.bootstrap.scope.publicationId}</p>
|
|
60
|
+
{loading?<p role="status">Loading publication monitoring…</p>:null}
|
|
61
|
+
{error?<p role="alert">{error}</p>:null}
|
|
62
|
+
{snapshot?<PublicationCyclotronPanel mode={session.bootstrap.mode} status={snapshot.status} history={snapshot.history}
|
|
63
|
+
onReview={canReview?async payload=>{await session.publication.review(payload);await refresh();}:undefined}
|
|
64
|
+
onSteer={canSteer&&snapshot.status.features?.steering&&session.bootstrap.bindingRef?classifier=>{setSteering(classifier);setRubric('');setExplanation('');setTrial(null);setSteeringMessage('');}:undefined}/>:null}
|
|
65
|
+
{steering?<form aria-label="Classifier proposal" className="space-y-3 rounded-md border p-3" onSubmit={event=>{event.preventDefault();void steer('evaluate');}}>
|
|
66
|
+
<h4 className="font-semibold">Evaluate classifier proposal</h4>
|
|
67
|
+
<p className="text-sm">A proposal is evaluated before an explicit promotion. Existing classifications are not relabeled by opening this form.</p>
|
|
68
|
+
<label className="block text-sm">Proposed instructions<textarea className="mt-1 block min-h-24 w-full rounded border bg-background p-2" required value={rubric} onChange={event=>{setRubric(event.target.value);setTrial(null);}}/></label>
|
|
69
|
+
<label className="block text-sm">Why make this change?<textarea className="mt-1 block min-h-20 w-full rounded border bg-background p-2" required value={explanation} onChange={event=>setExplanation(event.target.value)}/></label>
|
|
70
|
+
<div className="flex flex-wrap gap-2"><Button type="submit" disabled={busy||!canSteer||!rubric.trim()||!explanation.trim()}>Evaluate proposal</Button><Button type="button" variant="outline" disabled={busy||!canSteer||!trial?.improved||!explanation.trim()} onClick={()=>void steer('promote')}>Promote evaluated proposal</Button><Button type="button" variant="ghost" disabled={busy} onClick={()=>setSteering(null)}>Close proposal</Button></div>
|
|
71
|
+
{steeringMessage?<p role="status">{steeringMessage}</p>:null}
|
|
72
|
+
</form>:null}
|
|
73
|
+
{session.bootstrap.priming&&canReview?<ReferencePriming client={session.priming} listId={session.bootstrap.priming.listId} classifiers={session.bootstrap.priming.classifiers}
|
|
74
|
+
onAccepted={draft=>{setPrimingMessage(`Accepted ${draft.rows.length} example item${draft.rows.length===1?'':'s'} for this pinned publication. Operation remains available without priming.`);void refresh();}}/>:null}
|
|
75
|
+
{primingMessage?<p role="status">{primingMessage}</p>:null}
|
|
76
|
+
</section>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { PrimingPanel, type PrimingPanelProps } from "cyclotron/components/priming-panel";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
|
|
7
|
+
/** Papyrus supplies its transport/configuration; Cyclotron owns preview and labels. */
|
|
8
|
+
export function ReferencePriming(props: PrimingPanelProps) {
|
|
9
|
+
const [open, setOpen] = useState(false);
|
|
10
|
+
return (
|
|
11
|
+
<section className="shrink-0 space-y-3" aria-label="Representative reference examples">
|
|
12
|
+
<Button aria-expanded={open} onClick={() => setOpen((value) => !value)} variant="outline">
|
|
13
|
+
{open ? "Hide example priming" : "Curate reference examples (optional)"}
|
|
14
|
+
</Button>
|
|
15
|
+
{open ? <div className="max-h-[55dvh] space-y-3 overflow-y-auto pr-1"><p className="text-sm text-muted-foreground">Corrections to extracted content: describe what needs fixing, then enter the corrected title and abstract or summary. The note is saved with your correction; it does not automatically regenerate the content. Choose Publish / Not publish separately. An explanation of that judgment is optional.</p><PrimingPanel {...props} renderItem={props.renderItem ?? ((item) => <div className="space-y-2"><p className="whitespace-pre-wrap text-sm">{String(item.values.abstract ?? item.values.text ?? "")}</p><p className="break-all text-xs text-muted-foreground">{String(item.values.source_uri ?? "")}</p></div>)} /></div> : null}
|
|
16
|
+
</section>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -3,7 +3,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
|
|
|
3
3
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
4
4
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
5
5
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
6
|
-
import { hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
6
|
+
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
7
7
|
function environmentVariables(variables) {
|
|
8
8
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
9
9
|
}
|
|
@@ -85,6 +85,17 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
85
85
|
],
|
|
86
86
|
}));
|
|
87
87
|
}
|
|
88
|
+
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
89
|
+
const prospectusEnvironment = prospectusAccountsApiStageArn
|
|
90
|
+
? { PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) }
|
|
91
|
+
: {};
|
|
92
|
+
if (prospectusAccountsApiStageArn) {
|
|
93
|
+
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
94
|
+
sid: "InvokeProspectusAccountsAsRegisteredSite",
|
|
95
|
+
actions: ["execute-api:Invoke"],
|
|
96
|
+
resources: PROSPECTUS_PAPYRUS_SITE_ROUTES.map((route) => `${prospectusAccountsApiStageArn}/${route}`),
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
88
99
|
const cmsApp = new amplify.CfnApp(this, "App", {
|
|
89
100
|
name: cmsAppName,
|
|
90
101
|
description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
|
|
@@ -134,6 +145,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
134
145
|
SITE_ENV: "production",
|
|
135
146
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
136
147
|
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
148
|
+
...prospectusEnvironment,
|
|
137
149
|
}),
|
|
138
150
|
});
|
|
139
151
|
let stagingBranch;
|
|
@@ -153,6 +165,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
153
165
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
154
166
|
SITE_ENV: "staging",
|
|
155
167
|
...stagingMode,
|
|
168
|
+
...prospectusEnvironment,
|
|
156
169
|
NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
|
|
157
170
|
? `https://${stagingDomainName}`
|
|
158
171
|
: Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
|
|
@@ -191,6 +204,10 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
191
204
|
this.cmsAppId = cmsApp.attrAppId;
|
|
192
205
|
new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
|
|
193
206
|
new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
|
|
207
|
+
new CfnOutput(this, "CmsComputeRoleArn", {
|
|
208
|
+
value: computeRole.roleArn,
|
|
209
|
+
description: `SSR compute role for ${cmsAppName}; the roleArn of its Prospectus site registration`,
|
|
210
|
+
});
|
|
194
211
|
if (stagingEnabled) {
|
|
195
212
|
new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
|
|
196
213
|
}
|
|
@@ -4,7 +4,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
|
|
|
4
4
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
5
5
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
6
6
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
7
|
-
import { AmplifyAppShellSiteConfig, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
7
|
+
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
8
8
|
|
|
9
9
|
function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
|
|
10
10
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
@@ -96,6 +96,20 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
100
|
+
const prospectusEnvironment = prospectusAccountsApiStageArn
|
|
101
|
+
? { PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) }
|
|
102
|
+
: {};
|
|
103
|
+
if (prospectusAccountsApiStageArn) {
|
|
104
|
+
computeRole.addToPolicy(
|
|
105
|
+
new iam.PolicyStatement({
|
|
106
|
+
sid: "InvokeProspectusAccountsAsRegisteredSite",
|
|
107
|
+
actions: ["execute-api:Invoke"],
|
|
108
|
+
resources: PROSPECTUS_PAPYRUS_SITE_ROUTES.map((route) => `${prospectusAccountsApiStageArn}/${route}`),
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
const cmsApp = new amplify.CfnApp(this, "App", {
|
|
100
114
|
name: cmsAppName,
|
|
101
115
|
description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
|
|
@@ -148,6 +162,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
148
162
|
SITE_ENV: "production",
|
|
149
163
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
150
164
|
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
165
|
+
...prospectusEnvironment,
|
|
151
166
|
}),
|
|
152
167
|
});
|
|
153
168
|
|
|
@@ -168,6 +183,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
168
183
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
169
184
|
SITE_ENV: "staging",
|
|
170
185
|
...stagingMode,
|
|
186
|
+
...prospectusEnvironment,
|
|
171
187
|
NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
|
|
172
188
|
? `https://${stagingDomainName}`
|
|
173
189
|
: Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
|
|
@@ -209,6 +225,10 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
209
225
|
this.cmsAppId = cmsApp.attrAppId;
|
|
210
226
|
new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
|
|
211
227
|
new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
|
|
228
|
+
new CfnOutput(this, "CmsComputeRoleArn", {
|
|
229
|
+
value: computeRole.roleArn,
|
|
230
|
+
description: `SSR compute role for ${cmsAppName}; the roleArn of its Prospectus site registration`,
|
|
231
|
+
});
|
|
212
232
|
if (stagingEnabled) {
|
|
213
233
|
new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
|
|
214
234
|
}
|
package/infra/site-config.js
CHANGED
|
@@ -16,7 +16,24 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}
|
|
|
16
16
|
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
17
17
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
18
18
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
19
|
-
const
|
|
19
|
+
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
20
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT"];
|
|
21
|
+
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
22
|
+
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
23
|
+
"GET/v1/identities/me",
|
|
24
|
+
"POST/v1/identities/google/challenge",
|
|
25
|
+
"POST/v1/identities/google/resolve",
|
|
26
|
+
"GET/v1/accounts",
|
|
27
|
+
"GET/v1/accounts/*",
|
|
28
|
+
"POST/v1/accounts/*/authorize",
|
|
29
|
+
"GET/v1/account-directory",
|
|
30
|
+
"GET/v1/account-directory/*",
|
|
31
|
+
];
|
|
32
|
+
export function resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) {
|
|
33
|
+
const [, , , region, , apiPath] = prospectusAccountsApiStageArn.split(":");
|
|
34
|
+
const [apiId, stageName] = apiPath.split("/");
|
|
35
|
+
return `https://${apiId}.execute-api.${region}.amazonaws.com/${stageName}/`;
|
|
36
|
+
}
|
|
20
37
|
function originOfRedirectUrl(value) {
|
|
21
38
|
try {
|
|
22
39
|
return new URL(value).origin;
|
|
@@ -181,7 +198,7 @@ function parseStagingPreview(value) {
|
|
|
181
198
|
}
|
|
182
199
|
function parseCms(value) {
|
|
183
200
|
const cmsRecord = requireObject(value, "cms");
|
|
184
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview"], "cms");
|
|
201
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
|
|
185
202
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
186
203
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
187
204
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -205,6 +222,7 @@ function parseCms(value) {
|
|
|
205
222
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
206
223
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
207
224
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
225
|
+
prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
|
|
208
226
|
};
|
|
209
227
|
const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
|
|
210
228
|
if (reservedWord) {
|
|
@@ -212,7 +230,7 @@ function parseCms(value) {
|
|
|
212
230
|
}
|
|
213
231
|
for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
|
|
214
232
|
if (key in cms.environment) {
|
|
215
|
-
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix
|
|
233
|
+
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
|
|
216
234
|
}
|
|
217
235
|
}
|
|
218
236
|
if (cms.staging === false && cms.stagingDomainName) {
|
package/infra/site-config.ts
CHANGED
|
@@ -41,6 +41,7 @@ export type CmsConfig = {
|
|
|
41
41
|
stagingDomainName?: string;
|
|
42
42
|
stagingDomainPrefix?: string;
|
|
43
43
|
redirects?: CmsRedirect[];
|
|
44
|
+
prospectusAccountsApiStageArn?: string;
|
|
44
45
|
};
|
|
45
46
|
|
|
46
47
|
export type AmplifyAppShellSiteConfig = {
|
|
@@ -95,7 +96,26 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}
|
|
|
95
96
|
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
96
97
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
97
98
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
98
|
-
const
|
|
99
|
+
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
100
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT"];
|
|
101
|
+
|
|
102
|
+
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
103
|
+
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
104
|
+
"GET/v1/identities/me",
|
|
105
|
+
"POST/v1/identities/google/challenge",
|
|
106
|
+
"POST/v1/identities/google/resolve",
|
|
107
|
+
"GET/v1/accounts",
|
|
108
|
+
"GET/v1/accounts/*",
|
|
109
|
+
"POST/v1/accounts/*/authorize",
|
|
110
|
+
"GET/v1/account-directory",
|
|
111
|
+
"GET/v1/account-directory/*",
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
export function resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn: string): string {
|
|
115
|
+
const [, , , region, , apiPath] = prospectusAccountsApiStageArn.split(":");
|
|
116
|
+
const [apiId, stageName] = apiPath.split("/");
|
|
117
|
+
return `https://${apiId}.execute-api.${region}.amazonaws.com/${stageName}/`;
|
|
118
|
+
}
|
|
99
119
|
function originOfRedirectUrl(value: string): string {
|
|
100
120
|
try {
|
|
101
121
|
return new URL(value).origin;
|
|
@@ -259,7 +279,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
|
|
|
259
279
|
|
|
260
280
|
function parseCms(value: unknown): CmsConfig {
|
|
261
281
|
const cmsRecord = requireObject(value, "cms");
|
|
262
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview"], "cms");
|
|
282
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
|
|
263
283
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
264
284
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
265
285
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -288,6 +308,12 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
288
308
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
289
309
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
290
310
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
311
|
+
prospectusAccountsApiStageArn: optionalString(
|
|
312
|
+
cmsRecord.prospectusAccountsApiStageArn,
|
|
313
|
+
"cms.prospectusAccountsApiStageArn",
|
|
314
|
+
PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN,
|
|
315
|
+
"arn:aws:execute-api:<region>:<account>:<api id>/api",
|
|
316
|
+
),
|
|
291
317
|
};
|
|
292
318
|
|
|
293
319
|
const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
|
|
@@ -296,7 +322,7 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
296
322
|
}
|
|
297
323
|
for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
|
|
298
324
|
if (key in cms.environment) {
|
|
299
|
-
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix
|
|
325
|
+
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
|
|
300
326
|
}
|
|
301
327
|
}
|
|
302
328
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Priming proof reports a publication owner approved for visitors, keyed by site brand id.
|
|
3
|
+
* A report reaches this map only through a reviewed commit of its
|
|
4
|
+
* `publications/priming-proofs/<brand id>.json` file; see docs/priming-proof.md.
|
|
5
|
+
*/
|
|
6
|
+
export const APPROVED_PRIMING_PROOF_REPORTS: Record<string, unknown> = {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {createPrimingClient} from 'cyclotron/priming-client';
|
|
2
|
+
import {assertPublicationScope,createPublicationClient,publicationScopeKey,type PublicationEnvelope,type PublicationRevisionRef,type PublicationScope} from './publication-cyclotron';
|
|
3
|
+
|
|
4
|
+
export type CurationBootstrap={
|
|
5
|
+
scope:PublicationScope;
|
|
6
|
+
mode:'embedded'|'remote';
|
|
7
|
+
priming:{listId:string;classifiers:{id:string;revision:number}[]}|null;
|
|
8
|
+
capabilities:readonly string[];
|
|
9
|
+
bindingRef:PublicationRevisionRef|null;
|
|
10
|
+
};
|
|
11
|
+
export type CurationRequest=<T>(path:string,body:Record<string,unknown>,scope:Readonly<PublicationScope>)=>Promise<T>;
|
|
12
|
+
|
|
13
|
+
/** Same-origin only. The server verifies these expected pins against its session. */
|
|
14
|
+
export const requestCuration:CurationRequest=async<T,>(path:string,body:Record<string,unknown>,scope:Readonly<PublicationScope>)=>{
|
|
15
|
+
if(!path.startsWith('/api/newsroom/curation/'))throw new Error('Same-origin curation route required');
|
|
16
|
+
const controller=new AbortController();
|
|
17
|
+
const timer=setTimeout(()=>controller.abort(),15000);
|
|
18
|
+
let response:Response;
|
|
19
|
+
let result;
|
|
20
|
+
try{
|
|
21
|
+
response=await fetch(path,{method:'POST',credentials:'same-origin',signal:controller.signal,headers:{
|
|
22
|
+
'Content-Type':'application/json','X-Cyclotron-Account':scope.accountId,
|
|
23
|
+
'X-Papyrus-Publication':scope.publicationId,'X-Papyrus-Binding':scope.bindingRevision,
|
|
24
|
+
},body:JSON.stringify(body)});
|
|
25
|
+
result=await response.json().catch(()=>null);
|
|
26
|
+
}catch(error){
|
|
27
|
+
const timeout=controller.signal.aborted||(error instanceof Error&&error.name==='AbortError');
|
|
28
|
+
throw new Error(timeout?'Curation request timed out. Your selection is preserved. Check the server and saved history before retrying; do not resubmit automatically.':'Cannot reach the curation server. Your selection is preserved. Check the preview is running and saved history before retrying; do not resubmit automatically.');
|
|
29
|
+
}finally{clearTimeout(timer);}
|
|
30
|
+
if(response.status===401)throw new Error('Sign in again: the curation session expired. Your selection is preserved.');
|
|
31
|
+
if(response.status===403)throw new Error('Your session does not have permission for this curation action. Your selection is preserved.');
|
|
32
|
+
if(!response.ok||!result||result.errors)throw new Error(`Curation request failed (HTTP ${response.status}): ${result?.error??result?.errors?.[0]?.message??'invalid server response'}. Check saved history before retrying; your selection is preserved.`);
|
|
33
|
+
return result as T;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** One controller contract for both modes; no browser-selected service URL. */
|
|
37
|
+
export function createCurationSession(bootstrap:CurationBootstrap,request:CurationRequest,currentScope:()=>PublicationScope|null){
|
|
38
|
+
const pinned=Object.freeze({...bootstrap.scope});
|
|
39
|
+
const scopeKey=publicationScopeKey(pinned);
|
|
40
|
+
const assertCurrent=()=>{
|
|
41
|
+
const selected=currentScope();
|
|
42
|
+
if(!selected||publicationScopeKey(selected)!==scopeKey)throw new Error('Curation account selection changed; refresh context');
|
|
43
|
+
};
|
|
44
|
+
const publication=createPublicationClient(pinned,(operation,payload,scope)=>request<PublicationEnvelope&Record<string,unknown>>('/api/newsroom/curation/publication',{operation,payload},scope),currentScope);
|
|
45
|
+
const priming=createPrimingClient(async<T,>(query:string,variables:Record<string,unknown>):Promise<T>=>{
|
|
46
|
+
assertCurrent();
|
|
47
|
+
const result=await request<{data:T}>('/api/newsroom/curation/priming',{query,variables},pinned);
|
|
48
|
+
assertCurrent();
|
|
49
|
+
return result.data;
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
key:JSON.stringify([bootstrap.mode,scopeKey]),bootstrap,publication,priming,
|
|
53
|
+
async snapshot(){
|
|
54
|
+
const [status,history]=await Promise.all([publication.status(),publication.history()]);
|
|
55
|
+
assertCurrent();assertPublicationScope(status,history);
|
|
56
|
+
return {status,history};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type CurationSession=ReturnType<typeof createCurationSession>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { APPROVED_PRIMING_PROOF_REPORTS } from "./approved-priming-proof-reports";
|
|
3
|
+
|
|
4
|
+
export const PRIMING_PROOF_REPORT_VERSION = "cyclotron-priming-proof/v1";
|
|
5
|
+
|
|
6
|
+
const nullableRatio = z.number().min(0).max(1).nullable();
|
|
7
|
+
|
|
8
|
+
const primingProofPointSchema = z.object({
|
|
9
|
+
labels: z.number().int().min(0),
|
|
10
|
+
labelsByClass: z.record(z.string(), z.number().int().min(0)),
|
|
11
|
+
decided: z.number().int().min(0),
|
|
12
|
+
agreement: nullableRatio,
|
|
13
|
+
precision: nullableRatio,
|
|
14
|
+
recall: nullableRatio,
|
|
15
|
+
confusion: z.object({
|
|
16
|
+
truePositives: z.number().int().min(0),
|
|
17
|
+
falsePositives: z.number().int().min(0),
|
|
18
|
+
trueNegatives: z.number().int().min(0),
|
|
19
|
+
falseNegatives: z.number().int().min(0),
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const primingProofReportSchema = z.object({
|
|
24
|
+
version: z.literal(PRIMING_PROOF_REPORT_VERSION),
|
|
25
|
+
proofId: z.string().min(1),
|
|
26
|
+
publication: z.string().min(1),
|
|
27
|
+
cyclotronId: z.string().min(1),
|
|
28
|
+
question: z.string().min(1),
|
|
29
|
+
labels: z.array(z.string().min(1)).length(2),
|
|
30
|
+
positiveLabel: z.string().min(1),
|
|
31
|
+
decisionModel: z.string().nullable(),
|
|
32
|
+
ranAt: z.string().min(1),
|
|
33
|
+
heldOut: z.object({
|
|
34
|
+
size: z.number().int().min(1),
|
|
35
|
+
byLabel: z.record(z.string(), z.number().int().min(0)),
|
|
36
|
+
fingerprint: z.string().min(1),
|
|
37
|
+
frozenAt: z.string().min(1),
|
|
38
|
+
source: z.string().min(1),
|
|
39
|
+
}),
|
|
40
|
+
priming: z.object({
|
|
41
|
+
acceptedExamples: z.number().int().min(0),
|
|
42
|
+
excludedBecauseInHeldOut: z.number().int().min(0),
|
|
43
|
+
fingerprint: z.string().nullable(),
|
|
44
|
+
order: z.string().min(1),
|
|
45
|
+
}),
|
|
46
|
+
conditions: z.object({
|
|
47
|
+
freshStorePerPoint: z.boolean(),
|
|
48
|
+
rubric: z.string().min(1),
|
|
49
|
+
optimizer: z.boolean(),
|
|
50
|
+
reviewProgram: z.boolean(),
|
|
51
|
+
}),
|
|
52
|
+
points: z.array(primingProofPointSchema).min(1),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export type PrimingProofReport = z.infer<typeof primingProofReportSchema>;
|
|
56
|
+
export type PrimingProofPoint = z.infer<typeof primingProofPointSchema>;
|
|
57
|
+
|
|
58
|
+
export type PrimingProofHeadline = {
|
|
59
|
+
baseline: PrimingProofPoint;
|
|
60
|
+
latest: PrimingProofPoint;
|
|
61
|
+
agreementChangeInPoints: number | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function parsePrimingProofReport(value: unknown): PrimingProofReport {
|
|
65
|
+
const report = primingProofReportSchema.parse(value);
|
|
66
|
+
if (report.points[0].labels !== 0) {
|
|
67
|
+
throw new Error("A priming proof report must start with the zero-label baseline.");
|
|
68
|
+
}
|
|
69
|
+
return report;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function approvedPrimingProofReport(brandId: string): PrimingProofReport | null {
|
|
73
|
+
const approvedReport = APPROVED_PRIMING_PROOF_REPORTS[brandId];
|
|
74
|
+
return approvedReport === undefined ? null : parsePrimingProofReport(approvedReport);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function primingProofHeadline(report: PrimingProofReport): PrimingProofHeadline {
|
|
78
|
+
const baseline = report.points[0];
|
|
79
|
+
const latest = report.points[report.points.length - 1];
|
|
80
|
+
const agreementChangeInPoints =
|
|
81
|
+
baseline.agreement === null || latest.agreement === null
|
|
82
|
+
? null
|
|
83
|
+
: Math.round((latest.agreement - baseline.agreement) * 1000) / 10;
|
|
84
|
+
return { baseline, latest, agreementChangeInPoints };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatProofPercentage(ratio: number | null): string {
|
|
88
|
+
return ratio === null ? "n/a" : `${Math.round(ratio * 1000) / 10}%`;
|
|
89
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { defaultProvider } from "@aws-sdk/credential-provider-node";
|
|
4
|
+
import { SignatureV4 } from "@smithy/signature-v4";
|
|
5
|
+
import { HttpRequest } from "@smithy/protocol-http";
|
|
6
|
+
import { Sha256 } from "@aws-crypto/sha256-js";
|
|
7
|
+
import { CognitoJwtVerifier } from "aws-jwt-verify";
|
|
8
|
+
import type { JwksCache } from "aws-jwt-verify/jwk";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A Papyrus site's server-side view of Prospectus, the single account service.
|
|
12
|
+
*
|
|
13
|
+
* The site signs requests with its own runtime role (the Amplify SSR compute role
|
|
14
|
+
* registered in Prospectus). People sign in through the site's Google-only Cognito
|
|
15
|
+
* client; Prospectus binds that sign-in to a canonical person through a one-time
|
|
16
|
+
* challenge nonce. Papyrus keeps no account or membership tables: every answer here
|
|
17
|
+
* comes from Prospectus, and publication-level grants are never inferred from an
|
|
18
|
+
* account role.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type ProspectusRole = "owner" | "admin" | "member" | "viewer";
|
|
22
|
+
export type ProspectusOperation = "console:read" | "console:write" | "members:read" | "members:write";
|
|
23
|
+
|
|
24
|
+
export interface ProspectusPerson {
|
|
25
|
+
id: string;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProspectusAccountAuthorization {
|
|
30
|
+
allowed: true;
|
|
31
|
+
accountId: string;
|
|
32
|
+
personId: string;
|
|
33
|
+
product: string;
|
|
34
|
+
role: ProspectusRole;
|
|
35
|
+
operation: ProspectusOperation;
|
|
36
|
+
revision: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProspectusDirectoryAccount {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
role: ProspectusRole;
|
|
43
|
+
products: Array<{ product: string; status: "active" | "disabled" }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ProspectusRequest {
|
|
47
|
+
path: string;
|
|
48
|
+
method: "GET" | "POST";
|
|
49
|
+
body?: unknown;
|
|
50
|
+
humanAccessToken?: string;
|
|
51
|
+
identityToken?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ProspectusTransport = (request: ProspectusRequest) => Promise<unknown>;
|
|
55
|
+
|
|
56
|
+
export class ProspectusAccountsError extends Error {
|
|
57
|
+
constructor(
|
|
58
|
+
readonly status: number,
|
|
59
|
+
readonly code: string,
|
|
60
|
+
message: string,
|
|
61
|
+
) {
|
|
62
|
+
super(message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TemporaryRuntimeCredentials {
|
|
67
|
+
accessKeyId: string;
|
|
68
|
+
secretAccessKey: string;
|
|
69
|
+
sessionToken?: string;
|
|
70
|
+
expiration?: Date;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SignedProspectusTransportOptions {
|
|
74
|
+
endpoint: string;
|
|
75
|
+
region: string;
|
|
76
|
+
runtimeCredentials?: () => Promise<TemporaryRuntimeCredentials>;
|
|
77
|
+
fetcher?: typeof fetch;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function signedProspectusTransport(options: SignedProspectusTransportOptions): ProspectusTransport {
|
|
81
|
+
const endpoint = new URL(options.endpoint);
|
|
82
|
+
if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
83
|
+
throw new Error("Use an HTTPS Prospectus accounts endpoint.");
|
|
84
|
+
}
|
|
85
|
+
const runtimeCredentials = options.runtimeCredentials ?? defaultProvider();
|
|
86
|
+
const fetcher = options.fetcher ?? fetch;
|
|
87
|
+
return async ({ path, method, body, humanAccessToken, identityToken }) => {
|
|
88
|
+
const credentials = await runtimeCredentials();
|
|
89
|
+
if (!credentials.sessionToken || (credentials.expiration && credentials.expiration.getTime() <= Date.now())) {
|
|
90
|
+
throw new Error("Prospectus calls require the site's temporary runtime credentials; static access keys are refused.");
|
|
91
|
+
}
|
|
92
|
+
const url = new URL(`${endpoint.toString().replace(/\/$/, "")}${path}`);
|
|
93
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
94
|
+
const headers: Record<string, string> = {
|
|
95
|
+
host: url.host,
|
|
96
|
+
...(humanAccessToken ? { "x-prospectus-human-token": humanAccessToken } : {}),
|
|
97
|
+
...(identityToken ? { "x-prospectus-identity-token": identityToken } : {}),
|
|
98
|
+
...(payload ? { "content-type": "application/json" } : {}),
|
|
99
|
+
};
|
|
100
|
+
const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
|
|
101
|
+
const signed = await signer.sign(
|
|
102
|
+
new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload }),
|
|
103
|
+
);
|
|
104
|
+
const response = await fetcher(url, { method, headers: signed.headers, body: payload, cache: "no-store", redirect: "error" });
|
|
105
|
+
const responseBody = await response.json().catch(() => ({}));
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new ProspectusAccountsError(response.status, responseBody.error ?? "request_failed", responseBody.message ?? "Prospectus request failed.");
|
|
108
|
+
}
|
|
109
|
+
return responseBody;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class ProspectusSiteAccounts {
|
|
114
|
+
constructor(private readonly transport: ProspectusTransport) {}
|
|
115
|
+
|
|
116
|
+
requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number }> {
|
|
117
|
+
return this.transport({ path: "/v1/identities/google/challenge", method: "POST", body: {} }) as Promise<{
|
|
118
|
+
challengeId: string;
|
|
119
|
+
nonce: string;
|
|
120
|
+
expiresAt: number;
|
|
121
|
+
}>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
resolveGoogleSignIn(challengeId: string, humanAccessToken: string, identityToken: string): Promise<{ person: ProspectusPerson }> {
|
|
125
|
+
return this.transport({
|
|
126
|
+
path: "/v1/identities/google/resolve",
|
|
127
|
+
method: "POST",
|
|
128
|
+
body: { challengeId },
|
|
129
|
+
humanAccessToken,
|
|
130
|
+
identityToken,
|
|
131
|
+
}) as Promise<{ person: ProspectusPerson }>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
currentPerson(humanAccessToken: string): Promise<{ person: ProspectusPerson | null }> {
|
|
135
|
+
return this.transport({ path: "/v1/identities/me", method: "GET", humanAccessToken }) as Promise<{ person: ProspectusPerson | null }>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
authorizeAccountOperation(humanAccessToken: string, accountId: string, operation: ProspectusOperation): Promise<ProspectusAccountAuthorization> {
|
|
139
|
+
return this.transport({
|
|
140
|
+
path: `/v1/accounts/${encodeURIComponent(accountId)}/authorize`,
|
|
141
|
+
method: "POST",
|
|
142
|
+
body: { operation },
|
|
143
|
+
humanAccessToken,
|
|
144
|
+
}) as Promise<ProspectusAccountAuthorization>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
|
|
148
|
+
return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
|
|
149
|
+
person: ProspectusPerson | null;
|
|
150
|
+
accounts: ProspectusDirectoryAccount[];
|
|
151
|
+
}>;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface GoogleOnlyCognitoClient {
|
|
156
|
+
hostedUiOrigin: string;
|
|
157
|
+
userPoolId: string;
|
|
158
|
+
clientId: string;
|
|
159
|
+
redirectUri: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface PendingProspectusSignIn {
|
|
163
|
+
challengeId: string;
|
|
164
|
+
nonce: string;
|
|
165
|
+
state: string;
|
|
166
|
+
codeVerifier: string;
|
|
167
|
+
expiresAt: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface CognitoTokens {
|
|
171
|
+
accessToken: string;
|
|
172
|
+
idToken: string;
|
|
173
|
+
refreshToken?: string;
|
|
174
|
+
expiresInSeconds: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type CognitoTokenVerifier = (tokens: CognitoTokens, expectedNonce: string) => Promise<void>;
|
|
178
|
+
|
|
179
|
+
function base64UrlRandom(byteLength: number): string {
|
|
180
|
+
return randomBytes(byteLength).toString("base64url");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function constantTimeEquals(left: string, right: string): boolean {
|
|
184
|
+
const leftBytes = Buffer.from(left);
|
|
185
|
+
const rightBytes = Buffer.from(right);
|
|
186
|
+
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function beginProspectusGoogleSignIn(
|
|
190
|
+
accounts: ProspectusSiteAccounts,
|
|
191
|
+
cognitoClient: GoogleOnlyCognitoClient,
|
|
192
|
+
): Promise<{ authorizationUrl: string; pendingSignIn: PendingProspectusSignIn }> {
|
|
193
|
+
const challenge = await accounts.requestGoogleSignInChallenge();
|
|
194
|
+
const state = base64UrlRandom(32);
|
|
195
|
+
const codeVerifier = base64UrlRandom(48);
|
|
196
|
+
const authorizationUrl = new URL("/oauth2/authorize", cognitoClient.hostedUiOrigin);
|
|
197
|
+
authorizationUrl.search = new URLSearchParams({
|
|
198
|
+
response_type: "code",
|
|
199
|
+
client_id: cognitoClient.clientId,
|
|
200
|
+
redirect_uri: cognitoClient.redirectUri,
|
|
201
|
+
scope: "openid email profile",
|
|
202
|
+
identity_provider: "Google",
|
|
203
|
+
state,
|
|
204
|
+
nonce: challenge.nonce,
|
|
205
|
+
code_challenge: createHash("sha256").update(codeVerifier).digest("base64url"),
|
|
206
|
+
code_challenge_method: "S256",
|
|
207
|
+
}).toString();
|
|
208
|
+
return {
|
|
209
|
+
authorizationUrl: authorizationUrl.toString(),
|
|
210
|
+
pendingSignIn: { challengeId: challenge.challengeId, nonce: challenge.nonce, state, codeVerifier, expiresAt: challenge.expiresAt },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function cognitoTokenVerifier(cognitoClient: GoogleOnlyCognitoClient, signingKeyCache?: JwksCache): CognitoTokenVerifier {
|
|
215
|
+
const verifierOptions = signingKeyCache ? { jwksCache: signingKeyCache } : undefined;
|
|
216
|
+
const accessTokenVerifier = CognitoJwtVerifier.create({ userPoolId: cognitoClient.userPoolId, tokenUse: "access", clientId: cognitoClient.clientId }, verifierOptions);
|
|
217
|
+
const identityTokenVerifier = CognitoJwtVerifier.create({ userPoolId: cognitoClient.userPoolId, tokenUse: "id", clientId: cognitoClient.clientId }, verifierOptions);
|
|
218
|
+
return async (tokens, expectedNonce) => {
|
|
219
|
+
const accessClaims = await accessTokenVerifier.verify(tokens.accessToken);
|
|
220
|
+
const identityClaims = await identityTokenVerifier.verify(tokens.idToken);
|
|
221
|
+
if (identityClaims.nonce !== expectedNonce) throw new ProspectusAccountsError(401, "nonce_mismatch", "Sign-in did not carry this site's Prospectus challenge.");
|
|
222
|
+
if (identityClaims.sub !== accessClaims.sub) throw new ProspectusAccountsError(401, "token_pair_mismatch", "Access and identity tokens describe different people.");
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface CompleteProspectusGoogleSignInInput {
|
|
227
|
+
accounts: ProspectusSiteAccounts;
|
|
228
|
+
cognitoClient: GoogleOnlyCognitoClient;
|
|
229
|
+
pendingSignIn: PendingProspectusSignIn;
|
|
230
|
+
returnedState: string;
|
|
231
|
+
authorizationCode: string;
|
|
232
|
+
verifyCognitoTokens?: CognitoTokenVerifier;
|
|
233
|
+
fetcher?: typeof fetch;
|
|
234
|
+
nowInSeconds?: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function completeProspectusGoogleSignIn(
|
|
238
|
+
input: CompleteProspectusGoogleSignInInput,
|
|
239
|
+
): Promise<{ person: ProspectusPerson; accessToken: string; refreshToken?: string; accessTokenExpiresAt: number }> {
|
|
240
|
+
const nowInSeconds = input.nowInSeconds ?? Math.floor(Date.now() / 1000);
|
|
241
|
+
if (nowInSeconds >= input.pendingSignIn.expiresAt) {
|
|
242
|
+
throw new ProspectusAccountsError(401, "sign_in_expired", "Sign-in took too long. Start again.");
|
|
243
|
+
}
|
|
244
|
+
if (!constantTimeEquals(input.returnedState, input.pendingSignIn.state)) {
|
|
245
|
+
throw new ProspectusAccountsError(401, "state_mismatch", "Sign-in response does not belong to this browser's sign-in.");
|
|
246
|
+
}
|
|
247
|
+
const tokenResponse = await (input.fetcher ?? fetch)(new URL("/oauth2/token", input.cognitoClient.hostedUiOrigin), {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
250
|
+
body: new URLSearchParams({
|
|
251
|
+
grant_type: "authorization_code",
|
|
252
|
+
client_id: input.cognitoClient.clientId,
|
|
253
|
+
code: input.authorizationCode,
|
|
254
|
+
redirect_uri: input.cognitoClient.redirectUri,
|
|
255
|
+
code_verifier: input.pendingSignIn.codeVerifier,
|
|
256
|
+
}).toString(),
|
|
257
|
+
cache: "no-store",
|
|
258
|
+
redirect: "error",
|
|
259
|
+
});
|
|
260
|
+
const tokenBody = await tokenResponse.json().catch(() => ({}));
|
|
261
|
+
if (!tokenResponse.ok || typeof tokenBody.access_token !== "string" || typeof tokenBody.id_token !== "string") {
|
|
262
|
+
throw new ProspectusAccountsError(401, "code_exchange_failed", "Cognito did not complete the sign-in.");
|
|
263
|
+
}
|
|
264
|
+
const tokens: CognitoTokens = {
|
|
265
|
+
accessToken: tokenBody.access_token,
|
|
266
|
+
idToken: tokenBody.id_token,
|
|
267
|
+
refreshToken: typeof tokenBody.refresh_token === "string" ? tokenBody.refresh_token : undefined,
|
|
268
|
+
expiresInSeconds: Number(tokenBody.expires_in) || 0,
|
|
269
|
+
};
|
|
270
|
+
await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
|
|
271
|
+
const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn.challengeId, tokens.accessToken, tokens.idToken);
|
|
272
|
+
return {
|
|
273
|
+
person,
|
|
274
|
+
accessToken: tokens.accessToken,
|
|
275
|
+
refreshToken: tokens.refreshToken,
|
|
276
|
+
accessTokenExpiresAt: nowInSeconds + tokens.expiresInSeconds,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function authorizeSignedInPersonForAccount(
|
|
281
|
+
accounts: ProspectusSiteAccounts,
|
|
282
|
+
humanAccessToken: string,
|
|
283
|
+
accountId: string,
|
|
284
|
+
operation: ProspectusOperation,
|
|
285
|
+
): Promise<ProspectusAccountAuthorization> {
|
|
286
|
+
const { person } = await accounts.currentPerson(humanAccessToken);
|
|
287
|
+
if (!person) throw new ProspectusAccountsError(401, "unknown_person", "Sign in again: Prospectus does not recognize this session.");
|
|
288
|
+
const authorization = await accounts.authorizeAccountOperation(humanAccessToken, accountId, operation);
|
|
289
|
+
if (authorization.allowed !== true || authorization.personId !== person.id || authorization.accountId !== accountId || authorization.operation !== operation) {
|
|
290
|
+
throw new ProspectusAccountsError(403, "not_authorized", "Prospectus did not authorize this account operation.");
|
|
291
|
+
}
|
|
292
|
+
return authorization;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export const CYCLOTRON_LAB_PRODUCT = "cyclotron";
|
|
296
|
+
|
|
297
|
+
export async function signedInPersonHasCyclotronLabAccess(accounts: ProspectusSiteAccounts, humanAccessToken: string): Promise<boolean> {
|
|
298
|
+
const directory = await accounts.accountDirectory(humanAccessToken);
|
|
299
|
+
if (!directory.person) return false;
|
|
300
|
+
return directory.accounts.some((account) =>
|
|
301
|
+
account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type {CyclotronDecision,CyclotronReview} from 'cyclotron/sdk-types';
|
|
2
|
+
import type {CyclotronStatus} from 'cyclotron/cyclotron-status';
|
|
3
|
+
import type {ReviewControlReview} from 'cyclotron/components/review-control';
|
|
4
|
+
|
|
5
|
+
export type PublicationScope={accountId:string;publicationId:string;bindingRevision:string};
|
|
6
|
+
export type PublicationEnvelope=PublicationScope&{contractVersion:'cyclotron-publication/v1';publicationId:string;bindingRevision:string;features?:{frozenContext:boolean;replayExport:boolean;steering:boolean};outputs:Array<{id:string;question:string;classes:string[];positiveLabel:string|null}>};
|
|
7
|
+
export type PublicationStatus=PublicationEnvelope&{classifiers:Record<string,CyclotronStatus>};
|
|
8
|
+
export type PublicationRevisionRef={listId:string;itemId:string;revision:number;contentHash:string};
|
|
9
|
+
export type PublicationContext={contextId:string;decisionId:string;publicationId:string;sourceRef:PublicationRevisionRef;bindingRef:PublicationRevisionRef;source:{id:string;values:Record<string,unknown>};binding:{values:{accountId:string;publicationId:string;revision:string;outputs:string[]}}};
|
|
10
|
+
export type PublicationHistoryRow={decision:CyclotronDecision;item:Record<string,unknown>;state?:string;reviews:Record<string,CyclotronReview>;context?:PublicationContext;historicalContext?:boolean;provenanceUnavailable?:boolean;feedbackReconciliationRequired?:boolean};
|
|
11
|
+
export type PublicationHistory=PublicationEnvelope&{history:PublicationHistoryRow[]};
|
|
12
|
+
export type PublicationReviewPayload={bindingRevision:string;decisionId:string;classifier:string;predictionVersion:number;predictionFingerprint:string;requestId:string;label:string;explanation:string;reasonCode:string|null;contextId?:string};
|
|
13
|
+
export type PublicationSteeringInput={requestId:string;explanation:string}&({action:'evaluate';proposal:Record<string,unknown>}|{action:'promote';trialFingerprint:string});
|
|
14
|
+
export type PublicationSteeringPayload=PublicationSteeringInput&{bindingRevision:string;bindingRef:PublicationRevisionRef;classifier:string;expectedFingerprint:string};
|
|
15
|
+
|
|
16
|
+
function ordered(value:unknown):unknown{
|
|
17
|
+
if(Array.isArray(value))return value.map(ordered);
|
|
18
|
+
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).sort(([a],[b])=>a.localeCompare(b)).map(([key,item])=>[key,ordered(item)]));
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stable cache/component identity; length-safe even when IDs contain delimiters. */
|
|
23
|
+
export function publicationScopeKey(scope:PublicationScope):string{
|
|
24
|
+
if([scope.accountId,scope.publicationId,scope.bindingRevision].some(value=>typeof value!=='string'||!value.trim()))throw new Error('Complete publication account scope required');
|
|
25
|
+
return JSON.stringify([scope.accountId,scope.publicationId,scope.bindingRevision]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertPublicationScope(status:PublicationStatus,history:PublicationHistory){
|
|
29
|
+
if(status.contractVersion!=='cyclotron-publication/v1'||history.contractVersion!==status.contractVersion||publicationScopeKey(status)!==publicationScopeKey(history)||status.publicationId!==history.publicationId||status.bindingRevision!==history.bindingRevision)throw new Error('Publication snapshot binding mismatch');
|
|
30
|
+
const outputs=new Set(status.outputs.map(output=>output.id));
|
|
31
|
+
if(!outputs.size||outputs.size!==status.outputs.length||status.outputs.some(output=>status.classifiers[output.id]?.cyclotron.classifier!==output.id)||JSON.stringify(history.outputs)!==JSON.stringify(status.outputs))throw new Error('Publication snapshot is incomplete');
|
|
32
|
+
if(Object.keys(status.classifiers).some(id=>!outputs.has(id))||history.outputs.some(output=>!outputs.has(output.id))||history.history.some(row=>[...Object.keys(row.decision.classifiers),...Object.keys(row.reviews)].some(id=>!outputs.has(id))))throw new Error('Snapshot includes an output outside this publication');
|
|
33
|
+
if(history.history.some(row=>row.context&&(row.context.binding.values.accountId!==status.accountId||row.context.publicationId!==status.publicationId||row.context.decisionId!==row.decision.decisionId||JSON.stringify(ordered(row.context.source.values))!==JSON.stringify(ordered(row.item)))))throw new Error('Historical source context mismatch');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Explicit output target and incumbent pin; never a global review-rate mutation. */
|
|
37
|
+
export function publicationSteeringPayload(status:PublicationStatus,classifier:string,bindingRef:PublicationRevisionRef,command:PublicationSteeringInput):PublicationSteeringPayload{
|
|
38
|
+
if(!status.outputs.some(output=>output.id===classifier)||!status.classifiers[classifier])throw new Error('Steering output is outside this publication');
|
|
39
|
+
if(!command.requestId.trim()||!command.explanation.trim())throw new Error('Steering needs a stable identity and explicit explanation');
|
|
40
|
+
const fingerprint=status.classifiers[classifier].cyclotron.fingerprint;
|
|
41
|
+
if(!fingerprint)throw new Error('Active classifier fingerprint unavailable');
|
|
42
|
+
if(command.action==='evaluate'){
|
|
43
|
+
if(!Object.keys(command.proposal).length||Object.keys(command.proposal).some(key=>!['rubric','tasks','example_ids','dynamic_elements'].includes(key)))throw new Error('Native proposal fields required');
|
|
44
|
+
}else if(command.action!=='promote'||!command.trialFingerprint.trim())throw new Error('An explicitly evaluated trial is required');
|
|
45
|
+
return {...command,explanation:command.explanation.trim(),bindingRevision:status.bindingRevision,bindingRef,classifier,expectedFingerprint:fingerprint};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function publicationReviewPayload(binding:PublicationEnvelope,row:PublicationHistoryRow,classifier:string,review:ReviewControlReview,requestId:string):PublicationReviewPayload{
|
|
49
|
+
if(row.context&&row.context.binding.values.accountId!==binding.accountId)throw new Error('Review account context mismatch');
|
|
50
|
+
if(row.historicalContext||row.provenanceUnavailable||row.feedbackReconciliationRequired||row.context&&row.context.binding.values.revision!==binding.bindingRevision)throw new Error('Historical source or binding revision is read-only');
|
|
51
|
+
const output=binding.outputs.find(output=>output.id===classifier);const prediction=row.decision.classifiers[classifier];
|
|
52
|
+
if(!output||!prediction||review.decisionId!==row.decision.decisionId||review.itemId!==row.decision.itemId)throw new Error('Review does not match the displayed publication decision');
|
|
53
|
+
if(!review.label||!output.classes.includes(review.label))throw new Error('Choose an explicit classification label');
|
|
54
|
+
if(!requestId.trim())throw new Error('A stable review request identity is required');
|
|
55
|
+
return {bindingRevision:binding.bindingRevision,decisionId:row.decision.decisionId,classifier,predictionVersion:prediction.version,predictionFingerprint:prediction.fingerprint,requestId,label:review.label,explanation:review.explanation?.trim()??'',reasonCode:review.reasonCode,...(row.context?{contextId:row.context.contextId}:{})};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Host chooses an authenticated same-origin BFF; storage mode never changes UI semantics. */
|
|
59
|
+
export function createPublicationClient(scope:PublicationScope,request:(operation:string,payload:Record<string,unknown>,scope:Readonly<PublicationScope>)=>Promise<PublicationEnvelope&Record<string,unknown>>,currentScope:()=>PublicationScope|null){
|
|
60
|
+
const pinned=Object.freeze({accountId:scope.accountId,publicationId:scope.publicationId,bindingRevision:scope.bindingRevision});
|
|
61
|
+
const key=publicationScopeKey(pinned);
|
|
62
|
+
const assertSelected=()=>{const current=currentScope();if(!current||publicationScopeKey(current)!==key)throw new Error('Publication account selection changed; refresh context');};
|
|
63
|
+
const command=async(operation:string,payload:Record<string,unknown>)=>{
|
|
64
|
+
assertSelected();
|
|
65
|
+
if('bindingRevision' in payload&&payload.bindingRevision!==pinned.bindingRevision)throw new Error('Publication binding changed; refresh context');
|
|
66
|
+
const value=await request(operation,payload,pinned);
|
|
67
|
+
assertSelected();
|
|
68
|
+
if(value.contractVersion!=='cyclotron-publication/v1'||publicationScopeKey(value)!==key)throw new Error('Publication response contract mismatch');
|
|
69
|
+
return value;
|
|
70
|
+
};
|
|
71
|
+
return {scope:pinned,cacheKey:key,command,status:async()=>await command('status',{}) as PublicationStatus,history:async()=>await command('history',{}) as PublicationHistory,
|
|
72
|
+
review:async(payload:PublicationReviewPayload)=>command('review',payload),
|
|
73
|
+
steer:async(payload:PublicationSteeringPayload)=>command('steer',payload),replayRecords:async()=>command('replay',{})};
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -88,6 +88,7 @@
|
|
|
88
88
|
"./components/newsroom-assignments-view": "./components/newsroom-assignments-view.tsx",
|
|
89
89
|
"./components/newsroom-board-view": "./components/newsroom-board-view.tsx",
|
|
90
90
|
"./components/newsroom-client-shell": "./components/newsroom-client-shell.tsx",
|
|
91
|
+
"./components/newsroom-curation-provider": "./components/newsroom-curation-provider.tsx",
|
|
91
92
|
"./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
|
|
92
93
|
"./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
|
|
93
94
|
"./components/newsroom-organizer-shell": "./components/newsroom-organizer-shell.tsx",
|
|
@@ -98,11 +99,15 @@
|
|
|
98
99
|
"./components/presentation-footer": "./components/presentation-footer.tsx",
|
|
99
100
|
"./components/presentation-header": "./components/presentation-header.tsx",
|
|
100
101
|
"./components/presentation-rhythm-hrule": "./components/presentation-rhythm-hrule.tsx",
|
|
102
|
+
"./components/priming-proof-page": "./components/priming-proof-page.tsx",
|
|
103
|
+
"./components/publication-cyclotron-panel": "./components/publication-cyclotron-panel.tsx",
|
|
101
104
|
"./components/reader-auth-control": "./components/reader-auth-control.tsx",
|
|
102
105
|
"./components/reader-auth-state": "./components/reader-auth-state.ts",
|
|
103
106
|
"./components/reader-settings": "./components/reader-settings.ts",
|
|
104
107
|
"./components/reader-theme-control": "./components/reader-theme-control.tsx",
|
|
108
|
+
"./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
|
|
105
109
|
"./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
|
|
110
|
+
"./components/reference-priming": "./components/reference-priming.tsx",
|
|
106
111
|
"./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
|
|
107
112
|
"./components/reference-source-preview": "./components/reference-source-preview.tsx",
|
|
108
113
|
"./components/settings-page": "./components/settings-page.tsx",
|
|
@@ -135,6 +140,7 @@
|
|
|
135
140
|
"./lib/amplify-outputs-guard": "./lib/amplify-outputs-guard.ts",
|
|
136
141
|
"./lib/amplify-outputs-path": "./lib/amplify-outputs-path.ts",
|
|
137
142
|
"./lib/amplify-server-runtime": "./lib/amplify-server-runtime.ts",
|
|
143
|
+
"./lib/approved-priming-proof-reports": "./lib/approved-priming-proof-reports.ts",
|
|
138
144
|
"./lib/archive-data": "./lib/archive-data.ts",
|
|
139
145
|
"./lib/archive-types": "./lib/archive-types.ts",
|
|
140
146
|
"./lib/articles": "./lib/articles.ts",
|
|
@@ -153,6 +159,7 @@
|
|
|
153
159
|
"./lib/content-repository": "./lib/content-repository.ts",
|
|
154
160
|
"./lib/content-source-context": "./lib/content-source-context.ts",
|
|
155
161
|
"./lib/content-types": "./lib/content-types.ts",
|
|
162
|
+
"./lib/curation-client": "./lib/curation-client.ts",
|
|
156
163
|
"./lib/define-site": "./lib/define-site.ts",
|
|
157
164
|
"./lib/demo-amplify-outputs": "./lib/demo-amplify-outputs.ts",
|
|
158
165
|
"./lib/dev-sandbox-editor-auth": "./lib/dev-sandbox-editor-auth.ts",
|
|
@@ -200,7 +207,10 @@
|
|
|
200
207
|
"./lib/plugin-settings": "./lib/plugin-settings.ts",
|
|
201
208
|
"./lib/presentation-footer": "./lib/presentation-footer.ts",
|
|
202
209
|
"./lib/pretext-layout": "./lib/pretext-layout.ts",
|
|
210
|
+
"./lib/priming-proof-report": "./lib/priming-proof-report.ts",
|
|
211
|
+
"./lib/prospectus-site-identity": "./lib/prospectus-site-identity.ts",
|
|
203
212
|
"./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
|
|
213
|
+
"./lib/publication-cyclotron": "./lib/publication-cyclotron.ts",
|
|
204
214
|
"./lib/publication-items": "./lib/publication-items.ts",
|
|
205
215
|
"./lib/rate-limit-retry": "./lib/rate-limit-retry.ts",
|
|
206
216
|
"./lib/reader-base-path": "./lib/reader-base-path.ts",
|
|
@@ -253,10 +263,12 @@
|
|
|
253
263
|
"./app/[year]/[month]/[day]/page/[pageNumber]/page": "./app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
|
|
254
264
|
"./app/[year]/[month]/[day]/section/[sectionKey]/page": "./app/[year]/[month]/[day]/section/[sectionKey]/page.tsx",
|
|
255
265
|
"./app/api/archive/editions/route": "./app/api/archive/editions/route.ts",
|
|
266
|
+
"./app/api/cyclotron/priming-proof/route": "./app/api/cyclotron/priming-proof/route.ts",
|
|
256
267
|
"./app/api/media/[...path]/route": "./app/api/media/[...path]/route.ts",
|
|
257
268
|
"./app/api/revalidate/route": "./app/api/revalidate/route.ts",
|
|
258
269
|
"./app/archive/page": "./app/archive/page.tsx",
|
|
259
270
|
"./app/articles/[slug]/page": "./app/articles/[slug]/page.tsx",
|
|
271
|
+
"./app/cyclotron/page": "./app/cyclotron/page.tsx",
|
|
260
272
|
"./app/layout": "./app/layout.tsx",
|
|
261
273
|
"./app/newsroom/[section]/[...selection]/page": "./app/newsroom/[section]/[...selection]/page.tsx",
|
|
262
274
|
"./app/newsroom/[section]/page": "./app/newsroom/[section]/page.tsx",
|
|
@@ -319,18 +331,22 @@
|
|
|
319
331
|
"optional": true
|
|
320
332
|
}
|
|
321
333
|
},
|
|
322
|
-
"version": "1.0.0-next.
|
|
334
|
+
"version": "1.0.0-next.62",
|
|
323
335
|
"dependencies": {
|
|
324
336
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
337
|
+
"@aws-crypto/sha256-js": "5.2.0",
|
|
325
338
|
"@aws-sdk/client-cognito-identity-provider": "3.1045.0",
|
|
326
339
|
"@aws-sdk/client-lambda": "^3.1045.0",
|
|
327
340
|
"@aws-sdk/client-s3": "^3.1045.0",
|
|
328
341
|
"@aws-sdk/client-ssm": "^3.1045.0",
|
|
342
|
+
"@aws-sdk/credential-provider-node": "3.972.84",
|
|
329
343
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
|
330
344
|
"@base-ui/react": "^1.5.0",
|
|
331
345
|
"@chenglou/pretext": "^0.0.7",
|
|
332
346
|
"@kanbus/ui": "https://github.com/AnthusAI/Kanbus/releases/download/ui-v0.2.0/kanbus-ui-0.2.0.tgz",
|
|
333
347
|
"@radix-ui/colors": "3.0.0",
|
|
348
|
+
"@smithy/protocol-http": "5.4.1",
|
|
349
|
+
"@smithy/signature-v4": "5.7.4",
|
|
334
350
|
"@streamdown/cjk": "^1.0.3",
|
|
335
351
|
"@streamdown/code": "^1.1.1",
|
|
336
352
|
"@streamdown/math": "^1.0.2",
|
|
@@ -339,10 +355,11 @@
|
|
|
339
355
|
"ai": "^6.0.191",
|
|
340
356
|
"anthus-vultus": "github:AnthusAI/Vultus#af98d57",
|
|
341
357
|
"aws-amplify": "^6.17.0",
|
|
358
|
+
"aws-jwt-verify": "4.0.1",
|
|
342
359
|
"class-variance-authority": "^0.7.1",
|
|
343
360
|
"clsx": "^2.1.1",
|
|
344
361
|
"cmdk": "^1.1.1",
|
|
345
|
-
"cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.
|
|
362
|
+
"cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.2/cyclotron-0.2.2.tgz",
|
|
346
363
|
"esbuild": "^0.28.2",
|
|
347
364
|
"gray-matter": "^4.0.3",
|
|
348
365
|
"gsap": "^3.15.0",
|
package/routes.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.0.0-next.
|
|
2
|
+
"version": "1.0.0-next.62",
|
|
3
3
|
"routes": [
|
|
4
4
|
{
|
|
5
5
|
"file": "app/%5F_preview/[[...path]]/route.ts",
|
|
@@ -66,6 +66,14 @@
|
|
|
66
66
|
"revalidate": "60"
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
|
+
{
|
|
70
|
+
"file": "app/api/cyclotron/priming-proof/route.ts",
|
|
71
|
+
"hasDefault": false,
|
|
72
|
+
"exports": [
|
|
73
|
+
"GET"
|
|
74
|
+
],
|
|
75
|
+
"config": {}
|
|
76
|
+
},
|
|
69
77
|
{
|
|
70
78
|
"file": "app/api/media/[...path]/route.ts",
|
|
71
79
|
"hasDefault": false,
|
|
@@ -107,6 +115,14 @@
|
|
|
107
115
|
"revalidate": "60"
|
|
108
116
|
}
|
|
109
117
|
},
|
|
118
|
+
{
|
|
119
|
+
"file": "app/cyclotron/page.tsx",
|
|
120
|
+
"hasDefault": true,
|
|
121
|
+
"exports": [
|
|
122
|
+
"metadata"
|
|
123
|
+
],
|
|
124
|
+
"config": {}
|
|
125
|
+
},
|
|
110
126
|
{
|
|
111
127
|
"file": "app/layout.tsx",
|
|
112
128
|
"hasDefault": true,
|