@anthusai/papyrus 5.41.4 → 5.41.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/monitoring-view/schema.js +6 -0
- package/amplify/functions/monitoring-view/schema.ts +6 -0
- package/amplify/functions/monitoring-view/wire.js +1 -0
- package/amplify/functions/monitoring-view/wire.ts +1 -0
- package/components/monitoring-overview-view.tsx +7 -5
- package/components/monitoring-panel.tsx +33 -3
- package/components/papyrus-private-chat-panel.tsx +18 -59
- package/lib/monitoring-client.ts +11 -2
- package/lib/newsroom-chat-identity.ts +2 -2
- package/lib/papyrus-hosted-chat.ts +59 -90
- package/lib/papyrus-private-chat-client.ts +3 -1
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -20,6 +20,12 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
20
20
|
.returns(a.json())
|
|
21
21
|
.authorization((allow) => [allow.groups(editors)])
|
|
22
22
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
23
|
+
monitoringQueueEmailReference: a
|
|
24
|
+
.mutation()
|
|
25
|
+
.arguments({ input: a.json().required() })
|
|
26
|
+
.returns(a.json())
|
|
27
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
28
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
23
29
|
monitoringResolveEmailArticleLink: a
|
|
24
30
|
.mutation()
|
|
25
31
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -22,6 +22,12 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
22
22
|
.returns(a.json())
|
|
23
23
|
.authorization((allow) => [allow.groups(editors)])
|
|
24
24
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
25
|
+
monitoringQueueEmailReference: a
|
|
26
|
+
.mutation()
|
|
27
|
+
.arguments({ input: a.json().required() })
|
|
28
|
+
.returns(a.json())
|
|
29
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
30
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
25
31
|
monitoringResolveEmailArticleLink: a
|
|
26
32
|
.mutation()
|
|
27
33
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -22,6 +22,7 @@ export function monitoringViewParameterArn(region, account) {
|
|
|
22
22
|
export function wireMonitoringView(backend, emailMonitorId = "hn") {
|
|
23
23
|
const lambda = backend.monitoringView.resources.lambda;
|
|
24
24
|
const stack = Stack.of(lambda);
|
|
25
|
+
lambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${stack.region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
|
|
25
26
|
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
|
|
26
27
|
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
27
28
|
lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
|
|
@@ -28,6 +28,7 @@ export function monitoringViewParameterArn(region: string, account: string): str
|
|
|
28
28
|
export function wireMonitoringView(backend: any, emailMonitorId = "hn"): void {
|
|
29
29
|
const lambda = backend.monitoringView.resources.lambda as LambdaFunction;
|
|
30
30
|
const stack = Stack.of(lambda);
|
|
31
|
+
lambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${stack.region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
|
|
31
32
|
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
|
|
32
33
|
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
33
34
|
lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
|
|
@@ -97,8 +97,8 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
|
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
|
|
101
|
-
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen
|
|
100
|
+
export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
|
|
101
|
+
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
|
|
102
102
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
103
103
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
104
104
|
}) {
|
|
@@ -181,7 +181,7 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
|
|
|
181
181
|
if (result.error) {
|
|
182
182
|
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} of ${candidates.length} selected candidates were registered or matched. ${result.error}` }));
|
|
183
183
|
} else {
|
|
184
|
-
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Import does not approve or publish them.` }));
|
|
184
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Predictions are queued in the cloud; review them when ready. Import does not approve or publish them.` }));
|
|
185
185
|
}
|
|
186
186
|
} catch {
|
|
187
187
|
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
@@ -207,7 +207,7 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
|
|
|
207
207
|
<span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
|
|
208
208
|
{item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
|
|
209
209
|
{item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
|
|
210
|
-
<button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button>
|
|
210
|
+
{onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
|
|
211
211
|
</li>)}
|
|
212
212
|
</ul> : <p>No email submissions are available for this publication.</p>}
|
|
213
213
|
{detail && "submission" in detail ? (
|
|
@@ -238,7 +238,9 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
|
|
|
238
238
|
{busyImport ? "Adding References…" : "Add selected links to References"}
|
|
239
239
|
</button>
|
|
240
240
|
{notice ? <p role="status" data-email-import-result>{notice}</p> : null}
|
|
241
|
-
{importedReferences.length ? <
|
|
241
|
+
{importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
|
|
242
|
+
<li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
|
|
243
|
+
))}</ul> : null}
|
|
242
244
|
</section>
|
|
243
245
|
) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
|
|
244
246
|
: detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
+
import { useSearchParams } from "next/navigation";
|
|
4
5
|
import {
|
|
5
6
|
importMonitoringEmailCandidates,
|
|
6
7
|
loadMonitoringEmailSubmission,
|
|
@@ -9,12 +10,41 @@ import {
|
|
|
9
10
|
} from "../lib/monitoring-client";
|
|
10
11
|
import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
|
|
11
12
|
import type { EmailReferenceImportItem, MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
|
|
12
|
-
import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
13
|
+
import { EmailSubmissions, MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
13
14
|
|
|
14
15
|
const REFRESH_MILLISECONDS = 60_000;
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
export function MonitoringPanel(
|
|
17
|
+
/** A receipt URL mounts only the single-submission reader, never the overview. */
|
|
18
|
+
export function MonitoringPanel(props: { isAdmin: boolean; isDemo: boolean }) {
|
|
19
|
+
const searchParams = useSearchParams();
|
|
20
|
+
if (searchParams.has("receipt")) {
|
|
21
|
+
return <MonitoringEmailReceipt key={searchParams.get("receipt")} receiptId={searchParams.get("receipt") ?? ""} isDemo={props.isDemo} />;
|
|
22
|
+
}
|
|
23
|
+
return <MonitoringOverviewPanel {...props} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function MonitoringEmailReceipt({ receiptId, isDemo }: { receiptId: string; isDemo: boolean }) {
|
|
27
|
+
const [detail, setDetail] = useState<MonitoringEmailDetails | null>(null);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!receiptId || isDemo) return;
|
|
30
|
+
let live = true;
|
|
31
|
+
void loadMonitoringEmailSubmission(receiptId).then(
|
|
32
|
+
(next) => { if (live) setDetail(next); },
|
|
33
|
+
() => { if (live) setDetail({ state: "unavailable" }); },
|
|
34
|
+
);
|
|
35
|
+
return () => { live = false; };
|
|
36
|
+
}, [receiptId, isDemo]);
|
|
37
|
+
if (!receiptId) return <p role="alert">A receipt is required to open a submission.</p>;
|
|
38
|
+
if (isDemo) return <p role="status">Single email submissions require an editor session.</p>;
|
|
39
|
+
return <div data-news-desk-admin-panel="monitoring">
|
|
40
|
+
{detail === null ? <p role="status">Loading email submission…</p> :
|
|
41
|
+
<EmailSubmissions items={"submission" in detail ? [detail.submission] : []} detail={detail}
|
|
42
|
+
onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates} />}
|
|
43
|
+
</div>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Overview refreshes apply only when no receipt was requested. */
|
|
47
|
+
function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
|
|
18
48
|
const [mode, setMode] = useState<MonitoringMode>("publication");
|
|
19
49
|
const [result, setResult] = useState<MonitoringResult | null>(null);
|
|
20
50
|
const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Hub } from "aws-amplify/utils";
|
|
3
4
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import { connectNewsroomChat } from "../lib/newsroom-chat-client";
|
|
5
|
-
import { loadProspectusAccountState, selectProspectusAccount, type ProspectusAccountState } from "../lib/prospectus-account-client";
|
|
6
5
|
import { PapyrusConsoleConversation, type ConsoleConversationMessage } from "./papyrus-console-conversation";
|
|
7
6
|
import {
|
|
8
7
|
listPapyrusPrivateChatMessages,
|
|
@@ -30,9 +29,6 @@ function failureMessage(error: unknown): string {
|
|
|
30
29
|
export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
31
30
|
const [channel, setChannel] = useState<PapyrusPrivateChatChannel | null>(null);
|
|
32
31
|
const [messages, setMessages] = useState<PapyrusPrivateChatMessage[]>([]);
|
|
33
|
-
const [accountState, setAccountState] = useState<ProspectusAccountState | null>(null);
|
|
34
|
-
const [connecting, setConnecting] = useState(false);
|
|
35
|
-
const [connectionNotice, setConnectionNotice] = useState<string | null>(null);
|
|
36
32
|
const [loading, setLoading] = useState(true);
|
|
37
33
|
const [sending, setSending] = useState(false);
|
|
38
34
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -55,18 +51,13 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
55
51
|
const handleFailure = useCallback(async (nextError: unknown) => {
|
|
56
52
|
const failure = failureMessage(nextError);
|
|
57
53
|
setError(failure);
|
|
58
|
-
if (["sign_in_required", "
|
|
59
|
-
|
|
54
|
+
if (["sign_in_required", "chat_account_access_denied"].includes(failure)) {
|
|
55
|
+
++identityGeneration.current;
|
|
60
56
|
setChannel(null);
|
|
61
57
|
setMessages([]);
|
|
62
|
-
setAccountState(null);
|
|
63
58
|
retryAttempt.current = null;
|
|
64
59
|
setSending(false);
|
|
65
60
|
setLoading(false);
|
|
66
|
-
if (failure === "publishing_account_required") {
|
|
67
|
-
const state = await loadProspectusAccountState(window.location.pathname);
|
|
68
|
-
if (generation === identityGeneration.current) setAccountState(state);
|
|
69
|
-
}
|
|
70
61
|
}
|
|
71
62
|
}, []);
|
|
72
63
|
|
|
@@ -81,6 +72,8 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
81
72
|
setError(null);
|
|
82
73
|
setChannel(null);
|
|
83
74
|
setMessages([]);
|
|
75
|
+
retryAttempt.current = null;
|
|
76
|
+
setSending(false);
|
|
84
77
|
try {
|
|
85
78
|
const nextChannel = await openPapyrusPrivateChat();
|
|
86
79
|
if (generation !== identityGeneration.current) return;
|
|
@@ -94,14 +87,18 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
94
87
|
}, [handleFailure, refreshMessages]);
|
|
95
88
|
|
|
96
89
|
useEffect(() => {
|
|
97
|
-
const code = new URLSearchParams(window.location.search).get("sign_in_error");
|
|
98
|
-
if (code) setConnectionNotice(code === "newsroom_identity_mismatch"
|
|
99
|
-
? "The selected Google account did not match your newsroom session. Connect again with the same account."
|
|
100
|
-
: "The publishing-account connection did not complete. You can safely try again.");
|
|
101
90
|
void openConversation();
|
|
102
91
|
return () => { identityGeneration.current += 1; };
|
|
103
92
|
}, [openConversation]);
|
|
104
93
|
|
|
94
|
+
useEffect(() => Hub.listen("auth", ({ payload }) => {
|
|
95
|
+
if (payload.event === "tokenRefresh") {
|
|
96
|
+
void openConversation();
|
|
97
|
+
} else if (["signedOut", "signedIn", "tokenRefresh_failure"].includes(payload.event)) {
|
|
98
|
+
void handleFailure(new Error("sign_in_required"));
|
|
99
|
+
}
|
|
100
|
+
}), [handleFailure, openConversation]);
|
|
101
|
+
|
|
105
102
|
useEffect(() => {
|
|
106
103
|
if (!channel || !isWaitingForReply) return;
|
|
107
104
|
let active = true;
|
|
@@ -146,54 +143,16 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
146
143
|
}
|
|
147
144
|
}, [channel, handleFailure, sending]);
|
|
148
145
|
|
|
149
|
-
const connect = async () => {
|
|
150
|
-
setConnecting(true);
|
|
151
|
-
try {
|
|
152
|
-
const returnUrl = new URL(window.location.href);
|
|
153
|
-
returnUrl.searchParams.delete("sign_in_error");
|
|
154
|
-
const target = await connectNewsroomChat(`${returnUrl.pathname}${returnUrl.search}${returnUrl.hash}`);
|
|
155
|
-
window.location.assign(target);
|
|
156
|
-
} catch (nextError) {
|
|
157
|
-
setError(failureMessage(nextError));
|
|
158
|
-
setConnecting(false);
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
const chooseAccount = async (accountId: string) => {
|
|
162
|
-
setConnecting(true);
|
|
163
|
-
try {
|
|
164
|
-
const result = await selectProspectusAccount(accountId);
|
|
165
|
-
if ("error" in result) { setError(result.error); return; }
|
|
166
|
-
setAccountState(null);
|
|
167
|
-
await openConversation();
|
|
168
|
-
} catch (nextError) { setError(failureMessage(nextError)); }
|
|
169
|
-
finally { setConnecting(false); }
|
|
170
|
-
};
|
|
171
|
-
|
|
172
146
|
return <div className="papyrus-console__panel">
|
|
173
147
|
<header className="papyrus-console__header">
|
|
174
148
|
<p className="papyrus-console__header-label">Private chat</p>
|
|
175
149
|
<button className="papyrus-console__close" onClick={onClose} type="button" aria-label="Close console">×</button>
|
|
176
150
|
</header>
|
|
177
|
-
{channel ? <p className="papyrus-console__empty" role="status">Private conversation for
|
|
151
|
+
{channel ? <p className="papyrus-console__empty" role="status">Private conversation for this newsroom.</p> : null}
|
|
178
152
|
{!channel && !loading ? <div className="papyrus-console__access" role="alert">
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
<span>Continue with the same Google account you use in the newsroom, then choose the Account for this private conversation.</span>
|
|
183
|
-
<button className="papyrus-console__new-session" disabled={connecting} onClick={() => void connect()} type="button">{connecting ? "Connecting…" : "Connect publishing account"}</button>
|
|
184
|
-
</> : accountState?.status === "signedIn" ? <>
|
|
185
|
-
<strong>{accountState.publishingAccounts.length === 0 ? "Chat access is unavailable" : "Choose your publishing Account"}</strong>
|
|
186
|
-
{accountState.publishingAccounts.length === 0 ? <>
|
|
187
|
-
<span>None of your Accounts is enabled for this publication. Publication setup must be resolved before you can chat.</span>
|
|
188
|
-
<span>You can return to the newsroom while this is resolved. Check again after access has been corrected.</span>
|
|
189
|
-
<button className="papyrus-console__new-session" onClick={() => void openConversation()} type="button">Check again</button>
|
|
190
|
-
</> : accountState.publishingAccounts.map(account => <button className="papyrus-console__new-session" disabled={connecting} key={account.accountId} onClick={() => void chooseAccount(account.accountId)} type="button">{account.name ?? account.accountId}</button>)}
|
|
191
|
-
{error !== "publishing_account_required" ? <span>{error}</span> : null}
|
|
192
|
-
</> : <>
|
|
193
|
-
<strong>Private chat could not be opened.</strong>
|
|
194
|
-
<span>{error === "sign_in_required" ? "Your newsroom session has expired. Return to the newsroom and sign in from the account menu, then retry." : error === "chat_account_access_denied" ? "Your access to this conversation has changed. Retry to check your current access." : error === "publishing_account_required" ? "Publishing Account access could not be checked. Retry when your connection is available." : error}</span>
|
|
195
|
-
<button className="papyrus-console__new-session" onClick={() => void openConversation()} type="button">Retry</button>
|
|
196
|
-
</>}
|
|
153
|
+
<strong>Private chat could not be opened.</strong>
|
|
154
|
+
<span>{error === "sign_in_required" ? "Your newsroom session has expired. Return to the newsroom and sign in from the account menu, then retry." : error === "chat_account_access_denied" ? "Your access to this conversation has changed. Retry to check your current access." : error}</span>
|
|
155
|
+
<button className="papyrus-console__new-session" onClick={() => void openConversation()} type="button">Retry</button>
|
|
197
156
|
<button className="papyrus-console__new-session" onClick={onClose} type="button">Back to newsroom</button>
|
|
198
157
|
</div> : <PapyrusConsoleConversation
|
|
199
158
|
messages={conversationMessages}
|
|
@@ -202,7 +161,7 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
202
161
|
onSendMessage={sendMessage}
|
|
203
162
|
error={error}
|
|
204
163
|
emptyState={loading ? <p className="papyrus-console__empty">Opening private conversation…</p> : (
|
|
205
|
-
<p className="papyrus-console__empty">This conversation is private to
|
|
164
|
+
<p className="papyrus-console__empty">This conversation is private to you in this newsroom.</p>
|
|
206
165
|
)}
|
|
207
166
|
/>}
|
|
208
167
|
</div>;
|
package/lib/monitoring-client.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
MonitoringResult,
|
|
11
11
|
} from "./monitoring-overview";
|
|
12
12
|
|
|
13
|
-
type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences";
|
|
13
|
+
type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
|
|
14
14
|
type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
|
|
15
15
|
|
|
16
16
|
async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
|
|
@@ -74,10 +74,19 @@ export async function importMonitoringEmailCandidates(
|
|
|
74
74
|
} catch {
|
|
75
75
|
return { imported: references.length, references, error: "The import response could not be read. Completed links are safe to retry." };
|
|
76
76
|
}
|
|
77
|
-
if (!response?.ok || !Array.isArray(response.references)) {
|
|
77
|
+
if (!response?.ok || !Array.isArray(response.references) || response.references.length !== batch.length) {
|
|
78
78
|
return { imported: references.length, references, error: "The import was not accepted. Check your editor access and retry." };
|
|
79
79
|
}
|
|
80
80
|
references.push(...response.references);
|
|
81
|
+
for (const [index, reference] of response.references.entries()) {
|
|
82
|
+
const queued = await query<{ queued: boolean }>("monitoringQueueEmailReference",
|
|
83
|
+
`mutation MonitoringQueueEmailReference($input: AWSJSON!) { monitoringQueueEmailReference(input: $input) }`,
|
|
84
|
+
{ input: JSON.stringify({ receiptId, ...batch[index], referenceId: reference.id }) });
|
|
85
|
+
if (!("queued" in queued) || !queued.queued) {
|
|
86
|
+
return { imported: references.length, references,
|
|
87
|
+
error: "References were registered, but predictions could not be queued. Retry these selected links safely." };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
81
90
|
}
|
|
82
91
|
return { imported: references.length, references };
|
|
83
92
|
}
|
|
@@ -3,7 +3,7 @@ import type { JwksCache } from "aws-jwt-verify/jwk";
|
|
|
3
3
|
import type { NextRequest } from "next/server";
|
|
4
4
|
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
5
5
|
|
|
6
|
-
export type NewsroomChatIdentity = { subject: string };
|
|
6
|
+
export type NewsroomChatIdentity = { subject: string; issuer: string; clientId: string; expiresAt: number };
|
|
7
7
|
export type NewsroomChatIdentityResolver = (request: NextRequest) => Promise<NewsroomChatIdentity | null>;
|
|
8
8
|
|
|
9
9
|
export function newsroomChatIdentityResolver(
|
|
@@ -21,7 +21,7 @@ export function newsroomChatIdentityResolver(
|
|
|
21
21
|
if (!token) return null;
|
|
22
22
|
try {
|
|
23
23
|
const claims = await verifier.verify(token);
|
|
24
|
-
return { subject: claims.sub };
|
|
24
|
+
return { subject: claims.sub, issuer: claims.iss, clientId: auth.user_pool_client_id, expiresAt: claims.exp };
|
|
25
25
|
} catch {
|
|
26
26
|
return null;
|
|
27
27
|
}
|
|
@@ -2,52 +2,35 @@ import { resolveNewsroomChatIdentity, type NewsroomChatIdentityResolver } from "
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import type { EmbeddedChatAppDependencies } from "chatticus/chat-server";
|
|
4
4
|
import type { NextRequest } from "next/server";
|
|
5
|
-
import {
|
|
6
|
-
ProspectusAccountsError,
|
|
7
|
-
authorizeSignedInPersonForAccount,
|
|
8
|
-
} from "./prospectus-site-identity";
|
|
9
|
-
import {
|
|
10
|
-
resolveProspectusSignInRuntime,
|
|
11
|
-
signedInProspectusSession,
|
|
12
|
-
type ProspectusSignInRuntime,
|
|
13
|
-
} from "./prospectus-session";
|
|
14
|
-
import { prospectusSiteProduct, publishingAccountsForSite } from "./prospectus-publication-access";
|
|
15
5
|
import { publicSiteOrigin } from "./site-origin-value";
|
|
16
6
|
|
|
17
7
|
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
18
8
|
|
|
19
9
|
export interface PapyrusChatScope {
|
|
20
|
-
siteProduct: string;
|
|
21
|
-
accountId: string;
|
|
22
|
-
personId: string;
|
|
23
10
|
tenantId: string;
|
|
24
11
|
editorId: string;
|
|
25
12
|
}
|
|
26
13
|
|
|
27
14
|
export interface PapyrusHostedChatOptions {
|
|
28
15
|
resolveNewsroomIdentity?: NewsroomChatIdentityResolver;
|
|
29
|
-
resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
|
|
30
16
|
resolvePrivateDependencies?: () => Promise<EmbeddedChatAppDependencies | null>;
|
|
31
17
|
privateChatEnabled?: boolean;
|
|
32
|
-
siteProduct?: string | null;
|
|
33
18
|
environment?: Record<string, string | undefined>;
|
|
34
19
|
nowInSeconds?: () => number;
|
|
35
20
|
}
|
|
36
21
|
|
|
37
|
-
|
|
38
|
-
|
|
22
|
+
/** A new namespace: never aliases Account-scoped or legacy transcripts. */
|
|
23
|
+
export function derivePapyrusChatScope(publicationNamespace: string, issuer: string, subject: string): PapyrusChatScope {
|
|
24
|
+
const tenantHash = createHash("sha256").update(JSON.stringify(["newsroom-v1", publicationNamespace, issuer])).digest("hex");
|
|
25
|
+
const editorHash = createHash("sha256").update(JSON.stringify(["newsroom-v1", publicationNamespace, issuer, subject])).digest("hex");
|
|
26
|
+
return { tenantId: `papyrus-newsroom-v1-${tenantHash.slice(0, 40)}`, editorId: `papyrus-newsroom-editor-v1-${editorHash.slice(0, 40)}` };
|
|
39
27
|
}
|
|
40
28
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
personId,
|
|
47
|
-
accountId,
|
|
48
|
-
tenantId: `papyrus-${tenantHash.slice(0, 40)}`,
|
|
49
|
-
editorId: `papyrus-editor-${editorHash.slice(0, 40)}`,
|
|
50
|
-
};
|
|
29
|
+
/** Existing server storage resources bind the publication, independently of request hosts or claims. */
|
|
30
|
+
function publicationNamespace(environment: Record<string, string | undefined>, clientId: string): string | null {
|
|
31
|
+
const bucket = environment.PAPYRUS_CHAT_SESSION_BUCKET?.trim();
|
|
32
|
+
const table = environment.PAPYRUS_CHAT_MESSAGING_TABLE?.trim();
|
|
33
|
+
return bucket && table ? JSON.stringify([bucket, table, clientId]) : null;
|
|
51
34
|
}
|
|
52
35
|
|
|
53
36
|
type ResolvedPapyrusPrincipal = {
|
|
@@ -61,71 +44,24 @@ type ResolvedPapyrusPrincipal = {
|
|
|
61
44
|
|
|
62
45
|
async function principalForRequest(
|
|
63
46
|
request: NextRequest,
|
|
64
|
-
operation: "console:read" | "console:write",
|
|
65
47
|
options: PapyrusHostedChatOptions,
|
|
66
|
-
): Promise<{ principal: ResolvedPapyrusPrincipal | null; status: number;
|
|
67
|
-
|
|
68
|
-
const siteProduct = options.siteProduct !== undefined ? options.siteProduct : prospectusSiteProduct(environment);
|
|
69
|
-
if (!siteProduct) return { principal: null, status: 503 };
|
|
70
|
-
|
|
71
|
-
let runtime: ProspectusSignInRuntime | null;
|
|
72
|
-
try {
|
|
73
|
-
runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(publicSiteOrigin(request, environment));
|
|
74
|
-
} catch {
|
|
75
|
-
return { principal: null, status: 503 };
|
|
76
|
-
}
|
|
77
|
-
if (!runtime) return { principal: null, status: 503 };
|
|
78
|
-
|
|
79
|
-
let newsroomIdentity;
|
|
48
|
+
): Promise<{ principal: ResolvedPapyrusPrincipal | null; status: number; expiresAt?: number }> {
|
|
49
|
+
let identity;
|
|
80
50
|
try {
|
|
81
|
-
|
|
51
|
+
identity = await (options.resolveNewsroomIdentity ?? resolveNewsroomChatIdentity)(request);
|
|
82
52
|
} catch {
|
|
83
53
|
return { principal: null, status: 503 };
|
|
84
54
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
const directory = await runtime.accounts.accountDirectory(session.accessToken);
|
|
98
|
-
if (directory.person?.id !== session.personId) return { principal: null, status: 401 };
|
|
99
|
-
if (!publishingAccountsForSite(directory.accounts, siteProduct).some((account) => account.accountId === session.selectedAccountId)) {
|
|
100
|
-
return { principal: null, status: 403 };
|
|
101
|
-
}
|
|
102
|
-
const authorization = await authorizeSignedInPersonForAccount(
|
|
103
|
-
runtime.accounts,
|
|
104
|
-
session.accessToken,
|
|
105
|
-
session.selectedAccountId,
|
|
106
|
-
operation,
|
|
107
|
-
);
|
|
108
|
-
if (authorization.personId !== session.personId || authorization.accountId !== session.selectedAccountId || authorization.operation !== operation) {
|
|
109
|
-
return { principal: null, status: 403 };
|
|
110
|
-
}
|
|
111
|
-
const scope = derivePapyrusChatScope(siteProduct, session.selectedAccountId, session.personId);
|
|
112
|
-
return {
|
|
113
|
-
principal: {
|
|
114
|
-
kind: "user",
|
|
115
|
-
tenantId: scope.tenantId,
|
|
116
|
-
userId: scope.editorId,
|
|
117
|
-
workerId: null,
|
|
118
|
-
organizationStatus: "enabled",
|
|
119
|
-
role: "member",
|
|
120
|
-
},
|
|
121
|
-
status: 200,
|
|
122
|
-
};
|
|
123
|
-
} catch (error) {
|
|
124
|
-
if (error instanceof ProspectusAccountsError && (error.status === 401 || error.status === 403)) {
|
|
125
|
-
return { principal: null, status: error.status };
|
|
126
|
-
}
|
|
127
|
-
return { principal: null, status: 503 };
|
|
128
|
-
}
|
|
55
|
+
const now = options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
56
|
+
if (!identity || !identity.subject || !identity.issuer || !identity.clientId || !Number.isFinite(identity.expiresAt) || identity.expiresAt <= now) return { principal: null, status: 401 };
|
|
57
|
+
const namespace = publicationNamespace(options.environment ?? process.env, identity.clientId);
|
|
58
|
+
if (!namespace) return { principal: null, status: 503 };
|
|
59
|
+
const scope = derivePapyrusChatScope(namespace, identity.issuer, identity.subject);
|
|
60
|
+
return {
|
|
61
|
+
principal: { kind: "user", tenantId: scope.tenantId, userId: scope.editorId, workerId: null, organizationStatus: "enabled", role: "member" },
|
|
62
|
+
status: 200,
|
|
63
|
+
expiresAt: identity.expiresAt,
|
|
64
|
+
};
|
|
129
65
|
}
|
|
130
66
|
|
|
131
67
|
function jsonFailure(status: number, error: string): Response {
|
|
@@ -175,8 +111,8 @@ export async function handlePapyrusHostedChatRequest(
|
|
|
175
111
|
const privateChatEnabled = options.privateChatEnabled ?? environment.PAPYRUS_PRIVATE_CHAT_ENABLED === "true";
|
|
176
112
|
if (!privateChatEnabled) return jsonFailure(404, "private_chat_disabled");
|
|
177
113
|
|
|
178
|
-
const authorization = await principalForRequest(request,
|
|
179
|
-
if (!authorization.principal) return jsonFailure(authorization.status,
|
|
114
|
+
const authorization = await principalForRequest(request, options);
|
|
115
|
+
if (!authorization.principal) return jsonFailure(authorization.status, (authorization.status === 401 ? "sign_in_required" : authorization.status === 403 ? "chat_account_access_denied" : "chat_runtime_unavailable"));
|
|
180
116
|
|
|
181
117
|
let dependencies: EmbeddedChatAppDependencies | null;
|
|
182
118
|
try {
|
|
@@ -226,5 +162,38 @@ export async function handlePapyrusHostedChatRequest(
|
|
|
226
162
|
...dependencies,
|
|
227
163
|
resolvePrincipal: async () => authorization.principal,
|
|
228
164
|
});
|
|
229
|
-
|
|
165
|
+
const response = await app.fetch(forwarded);
|
|
166
|
+
if (path[0] !== "turns" || path[2] !== "stream" || !response.ok || !response.body) return response;
|
|
167
|
+
const remaining = (authorization.expiresAt! - (options.nowInSeconds?.() ?? Date.now() / 1000)) * 1000;
|
|
168
|
+
return expireChatStream(response, remaining);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** End an established stream at JWT expiry; reconnect must pass current authorization again. */
|
|
172
|
+
export function expireChatStream(response: Response, remainingMilliseconds: number): Response {
|
|
173
|
+
if (!response.body) return response;
|
|
174
|
+
const reader = response.body.getReader();
|
|
175
|
+
let closed = false;
|
|
176
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
177
|
+
const body = new ReadableStream<Uint8Array>({
|
|
178
|
+
start(controller) {
|
|
179
|
+
timer = setTimeout(() => {
|
|
180
|
+
if (closed) return;
|
|
181
|
+
closed = true;
|
|
182
|
+
controller.close();
|
|
183
|
+
void reader.cancel().catch(() => {});
|
|
184
|
+
}, Math.max(0, Math.min(remainingMilliseconds, 2_147_483_647)));
|
|
185
|
+
},
|
|
186
|
+
async pull(controller) {
|
|
187
|
+
try {
|
|
188
|
+
const next = await reader.read();
|
|
189
|
+
if (closed) return;
|
|
190
|
+
if (next.done) { closed = true; clearTimeout(timer); controller.close(); }
|
|
191
|
+
else controller.enqueue(next.value);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (!closed) { closed = true; clearTimeout(timer); controller.error(error); }
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
async cancel(reason) { closed = true; clearTimeout(timer); await reader.cancel(reason); },
|
|
197
|
+
});
|
|
198
|
+
return new Response(body, { status: response.status, headers: response.headers });
|
|
230
199
|
}
|
|
@@ -31,6 +31,8 @@ async function requestJson<T>(path: string, init: RequestInit, fetcher: PapyrusP
|
|
|
31
31
|
});
|
|
32
32
|
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
|
|
33
33
|
if (!response.ok) {
|
|
34
|
+
if (response.status === 401) throw new Error("sign_in_required");
|
|
35
|
+
if (response.status === 403) throw new Error("chat_account_access_denied");
|
|
34
36
|
const error = typeof payload?.error === "string" ? payload.error
|
|
35
37
|
: typeof payload?.detail === "string" ? payload.detail
|
|
36
38
|
: `Private chat request failed (${response.status}).`;
|
|
@@ -40,7 +42,7 @@ async function requestJson<T>(path: string, init: RequestInit, fetcher: PapyrusP
|
|
|
40
42
|
return payload as T;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
/** Open the current editor's server-derived private channel using the verified newsroom identity
|
|
45
|
+
/** Open the current editor's server-derived private channel using the verified publication-local newsroom identity. */
|
|
44
46
|
export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch = newsroomChatFetch): Promise<PapyrusPrivateChatChannel> {
|
|
45
47
|
const result = await requestJson<PapyrusPrivateChatChannel>("private-channel", { method: "POST" }, fetcher);
|
|
46
48
|
if (!result.channel_id?.trim() || !Array.isArray(result.participants)) {
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED