@anthusai/papyrus 5.38.1 → 5.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/monitoring-view/schema.js +6 -0
- package/amplify/functions/monitoring-view/schema.ts +6 -0
- package/app/api/newsroom/chat/[...path]/route.ts +17 -0
- package/app/layout.tsx +5 -1
- package/components/monitoring-overview-view.tsx +48 -2
- package/components/monitoring-panel.tsx +11 -3
- package/components/papyrus-console-conversation.css +14 -0
- package/components/papyrus-console-conversation.tsx +55 -0
- package/components/papyrus-console-shell.tsx +62 -420
- package/components/papyrus-private-chat-panel.tsx +134 -0
- package/infra/amplify-app-shell.js +2 -0
- package/infra/amplify-app-shell.ts +2 -0
- package/infra/site-config.js +4 -0
- package/infra/site-config.ts +4 -0
- package/lib/monitoring-client.ts +20 -15
- package/lib/monitoring-overview.ts +13 -0
- package/lib/papyrus-chat-admission.ts +65 -0
- package/lib/papyrus-hosted-chat.ts +217 -0
- package/lib/papyrus-private-chat-client.ts +81 -0
- package/lib/prospectus-session.ts +15 -0
- package/lib/prospectus-sign-in-routes.ts +2 -12
- package/lib/shared-chat-admission.ts +182 -0
- package/lib/site-origin-value.ts +50 -0
- package/lib/site-origin.ts +3 -62
- package/package.json +10 -1
- package/routes.manifest.json +13 -1
- package/src/with-papyrus.mjs +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { PapyrusConsoleConversation, type ConsoleConversationMessage } from "./papyrus-console-conversation";
|
|
5
|
+
import {
|
|
6
|
+
listPapyrusPrivateChatMessages,
|
|
7
|
+
openPapyrusPrivateChat,
|
|
8
|
+
sendPapyrusPrivateChatMessage,
|
|
9
|
+
type PapyrusPrivateChatChannel,
|
|
10
|
+
type PapyrusPrivateChatMessage,
|
|
11
|
+
} from "../lib/papyrus-private-chat-client";
|
|
12
|
+
|
|
13
|
+
const REFRESH_MS = 2_000;
|
|
14
|
+
|
|
15
|
+
function toConsoleMessages(messages: readonly PapyrusPrivateChatMessage[]): ConsoleConversationMessage[] {
|
|
16
|
+
return messages.map((message) => ({
|
|
17
|
+
id: message.message_id,
|
|
18
|
+
role: message.author_kind === "human" ? "USER" : "ASSISTANT",
|
|
19
|
+
text: message.body,
|
|
20
|
+
createdAt: message.created_at,
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function failureMessage(error: unknown): string {
|
|
25
|
+
return error instanceof Error && error.message.trim() ? error.message : "Private chat is unavailable.";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
29
|
+
const [channel, setChannel] = useState<PapyrusPrivateChatChannel | null>(null);
|
|
30
|
+
const [messages, setMessages] = useState<PapyrusPrivateChatMessage[]>([]);
|
|
31
|
+
const [loading, setLoading] = useState(true);
|
|
32
|
+
const [sending, setSending] = useState(false);
|
|
33
|
+
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
const retryAttempt = useRef<{ text: string; key: string } | null>(null);
|
|
35
|
+
const isWaitingForReply = messages.length > 0 && messages[messages.length - 1].author_kind === "human" &&
|
|
36
|
+
messages[messages.length - 1].addressed_to_bot_id !== null;
|
|
37
|
+
const conversationMessages = useMemo(() => {
|
|
38
|
+
const current = toConsoleMessages(messages);
|
|
39
|
+
if (!isWaitingForReply) return current;
|
|
40
|
+
const last = messages[messages.length - 1];
|
|
41
|
+
return [...current, {
|
|
42
|
+
id: `pending-reply:${last.message_id}`,
|
|
43
|
+
role: "ASSISTANT",
|
|
44
|
+
text: "",
|
|
45
|
+
pending: true,
|
|
46
|
+
}];
|
|
47
|
+
}, [isWaitingForReply, messages]);
|
|
48
|
+
|
|
49
|
+
const refreshMessages = useCallback(async (channelId: string) => {
|
|
50
|
+
const next = await listPapyrusPrivateChatMessages(channelId);
|
|
51
|
+
setMessages(next);
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const openConversation = useCallback(async () => {
|
|
55
|
+
setLoading(true);
|
|
56
|
+
setError(null);
|
|
57
|
+
try {
|
|
58
|
+
const nextChannel = await openPapyrusPrivateChat();
|
|
59
|
+
setChannel(nextChannel);
|
|
60
|
+
await refreshMessages(nextChannel.channel_id);
|
|
61
|
+
} catch (nextError) {
|
|
62
|
+
setError(failureMessage(nextError));
|
|
63
|
+
} finally {
|
|
64
|
+
setLoading(false);
|
|
65
|
+
}
|
|
66
|
+
}, [refreshMessages]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
void openConversation();
|
|
70
|
+
}, [openConversation]);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!channel || !isWaitingForReply) return;
|
|
74
|
+
let active = true;
|
|
75
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
76
|
+
const poll = async () => {
|
|
77
|
+
try {
|
|
78
|
+
await refreshMessages(channel.channel_id);
|
|
79
|
+
} catch (nextError) {
|
|
80
|
+
if (active) setError(failureMessage(nextError));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (active) timer = setTimeout(poll, REFRESH_MS);
|
|
84
|
+
};
|
|
85
|
+
timer = setTimeout(poll, REFRESH_MS);
|
|
86
|
+
return () => {
|
|
87
|
+
active = false;
|
|
88
|
+
if (timer) clearTimeout(timer);
|
|
89
|
+
};
|
|
90
|
+
}, [channel, isWaitingForReply, refreshMessages]);
|
|
91
|
+
|
|
92
|
+
const sendMessage = useCallback(async (text: string) => {
|
|
93
|
+
if (!channel || !text.trim() || sending) return;
|
|
94
|
+
const attempt = retryAttempt.current?.text === text
|
|
95
|
+
? retryAttempt.current
|
|
96
|
+
: { text, key: globalThis.crypto.randomUUID() };
|
|
97
|
+
retryAttempt.current = attempt;
|
|
98
|
+
setSending(true);
|
|
99
|
+
setError(null);
|
|
100
|
+
try {
|
|
101
|
+
const result = await sendPapyrusPrivateChatMessage(channel.channel_id, text, attempt.key);
|
|
102
|
+
setMessages((current) => current.some((message) => message.message_id === result.message.message_id)
|
|
103
|
+
? current
|
|
104
|
+
: [...current, result.message]);
|
|
105
|
+
retryAttempt.current = null;
|
|
106
|
+
} catch (nextError) {
|
|
107
|
+
setError(failureMessage(nextError));
|
|
108
|
+
} finally {
|
|
109
|
+
setSending(false);
|
|
110
|
+
}
|
|
111
|
+
}, [channel, sending]);
|
|
112
|
+
|
|
113
|
+
return <div className="papyrus-console__panel">
|
|
114
|
+
<header className="papyrus-console__header">
|
|
115
|
+
<p className="papyrus-console__header-label">Private chat</p>
|
|
116
|
+
<button className="papyrus-console__close" onClick={onClose} type="button" aria-label="Close console">×</button>
|
|
117
|
+
</header>
|
|
118
|
+
{channel ? <p className="papyrus-console__empty" role="status">Private conversation for your selected Account.</p> : null}
|
|
119
|
+
{!channel && !loading ? <div className="papyrus-console__empty" role="alert">
|
|
120
|
+
<strong>Private chat could not be opened.</strong>
|
|
121
|
+
<span>{error}</span>
|
|
122
|
+
<button className="papyrus-console__new-session" onClick={() => void openConversation()} type="button">Retry</button>
|
|
123
|
+
</div> : <PapyrusConsoleConversation
|
|
124
|
+
messages={conversationMessages}
|
|
125
|
+
isSending={sending || loading || !channel}
|
|
126
|
+
isRunning={isWaitingForReply}
|
|
127
|
+
onSendMessage={sendMessage}
|
|
128
|
+
error={error}
|
|
129
|
+
emptyState={loading ? <p className="papyrus-console__empty">Opening private conversation…</p> : (
|
|
130
|
+
<p className="papyrus-console__empty">This conversation is private to your selected Account and editor session.</p>
|
|
131
|
+
)}
|
|
132
|
+
/>}
|
|
133
|
+
</div>;
|
|
134
|
+
}
|
|
@@ -199,6 +199,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
199
199
|
environmentVariables: environmentVariables({
|
|
200
200
|
...config.cms.environment,
|
|
201
201
|
...emailEnvironment,
|
|
202
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
|
|
202
203
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
203
204
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
204
205
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
@@ -221,6 +222,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
221
222
|
buildSpec: cmsStagingBuildSpec(config),
|
|
222
223
|
environmentVariables: environmentVariables({
|
|
223
224
|
...config.cms.environment,
|
|
225
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: "false",
|
|
224
226
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
225
227
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
226
228
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
@@ -221,6 +221,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
221
221
|
environmentVariables: environmentVariables({
|
|
222
222
|
...config.cms.environment,
|
|
223
223
|
...emailEnvironment,
|
|
224
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
|
|
224
225
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
225
226
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
226
227
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
@@ -244,6 +245,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
244
245
|
buildSpec: cmsStagingBuildSpec(config),
|
|
245
246
|
environmentVariables: environmentVariables({
|
|
246
247
|
...config.cms.environment,
|
|
248
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: "false",
|
|
247
249
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
248
250
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
249
251
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
package/infra/site-config.js
CHANGED
|
@@ -273,6 +273,10 @@ function parseCms(value) {
|
|
|
273
273
|
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 and the public site origin comes from the site host");
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
|
+
const privateChatEnabled = cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED;
|
|
277
|
+
if (privateChatEnabled !== undefined && privateChatEnabled !== "true" && privateChatEnabled !== "false") {
|
|
278
|
+
fail("cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED", "must be the string \"true\" or \"false\"");
|
|
279
|
+
}
|
|
276
280
|
if (cms.siteOrigin && cms.domainName) {
|
|
277
281
|
fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
|
|
278
282
|
}
|
package/infra/site-config.ts
CHANGED
|
@@ -413,6 +413,10 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
413
413
|
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 and the public site origin comes from the site host");
|
|
414
414
|
}
|
|
415
415
|
}
|
|
416
|
+
const privateChatEnabled = cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED;
|
|
417
|
+
if (privateChatEnabled !== undefined && privateChatEnabled !== "true" && privateChatEnabled !== "false") {
|
|
418
|
+
fail("cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED", "must be the string \"true\" or \"false\"");
|
|
419
|
+
}
|
|
416
420
|
|
|
417
421
|
if (cms.siteOrigin && cms.domainName) {
|
|
418
422
|
fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
|
package/lib/monitoring-client.ts
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { generateClient } from "aws-amplify/data";
|
|
4
|
-
import type { MonitoringResult } from "./monitoring-overview";
|
|
4
|
+
import type { MonitoringEmailDetails, MonitoringResult } from "./monitoring-overview";
|
|
5
5
|
|
|
6
|
-
type
|
|
6
|
+
type QueryField = "monitoringOverview" | "monitoringEmailSubmission";
|
|
7
|
+
type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
* Reads the monitoring overview through the site's own authenticated API (user pool, editor or admin).
|
|
10
|
-
* The browser holds no table name, key or secret: the function behind the query does the read.
|
|
11
|
-
*/
|
|
12
|
-
export async function loadMonitoringOverview(scope: "publication" | "all"): Promise<MonitoringResult> {
|
|
9
|
+
async function query<T>(field: QueryField, queryText: string, variables: Record<string, string>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
|
|
13
10
|
const client = generateClient({ authMode: "userPool" });
|
|
14
11
|
let result: GraphQLResult;
|
|
15
12
|
try {
|
|
16
|
-
result = (await client.graphql({
|
|
17
|
-
query: `query MonitoringOverview($scope: String) { monitoringOverview(scope: $scope) }`,
|
|
18
|
-
variables: { scope },
|
|
19
|
-
})) as GraphQLResult;
|
|
13
|
+
result = (await client.graphql({ query: queryText, variables })) as GraphQLResult;
|
|
20
14
|
} catch (error) {
|
|
21
|
-
// Amplify throws the response when AppSync returns errors.
|
|
22
15
|
result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
|
|
23
16
|
}
|
|
24
17
|
const refused = result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`));
|
|
25
18
|
if (refused) return { error: "forbidden" };
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return (typeof raw === "string" ? JSON.parse(raw) : raw) as
|
|
19
|
+
const raw = result.data?.[field];
|
|
20
|
+
if (result.errors?.length || raw === undefined || raw === null) return { state: "unavailable" };
|
|
21
|
+
return (typeof raw === "string" ? JSON.parse(raw) : raw) as T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Reads monitoring data only through the site's authenticated editor/admin API. */
|
|
25
|
+
export async function loadMonitoringOverview(scope: "publication" | "all"): Promise<MonitoringResult> {
|
|
26
|
+
return query<MonitoringResult>("monitoringOverview",
|
|
27
|
+
`query MonitoringOverview($scope: String) { monitoringOverview(scope: $scope) }`, { scope });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Loads one receipt's safe plain-text rendering and its already extracted candidate links. */
|
|
31
|
+
export async function loadMonitoringEmailSubmission(receiptId: string): Promise<MonitoringEmailDetails> {
|
|
32
|
+
return query<MonitoringEmailDetails>("monitoringEmailSubmission",
|
|
33
|
+
`query MonitoringEmailSubmission($receiptId: String!) { monitoringEmailSubmission(receiptId: $receiptId) }`, { receiptId });
|
|
29
34
|
}
|
|
@@ -32,6 +32,18 @@ export type MonitoringItem = {
|
|
|
32
32
|
relevance: MonitoringRelevance[];
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
export type MonitoringEmailSummary = {
|
|
36
|
+
receiptId: string; observedAt: string | null; status: string; subject: string | null;
|
|
37
|
+
envelopeSender: string | null; headerFrom: string | null; warnings: string[];
|
|
38
|
+
verdicts: Record<string, string>; linkCount: number; processedCount: number; unresolvedLinkCount: number;
|
|
39
|
+
reviewRequired: boolean; errorCode: string | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type MonitoringEmailDetails = {
|
|
43
|
+
submission: MonitoringEmailSummary; safeBodyText: string;
|
|
44
|
+
candidateLinks: { url: string; title: string; resolutionStatus?: "unresolved" }[]; sourceMaterialIsUntrusted: true;
|
|
45
|
+
} | { error: "forbidden" | "bad_request" } | { state: "unavailable" | "not_configured" };
|
|
46
|
+
|
|
35
47
|
export type MonitoringRun = {
|
|
36
48
|
runId: string;
|
|
37
49
|
startedAt: string;
|
|
@@ -82,6 +94,7 @@ export type MonitoringOverview = {
|
|
|
82
94
|
ceilings: { decisionsTotal?: number; decisionsPerDay?: number; items?: number } | null;
|
|
83
95
|
} | null;
|
|
84
96
|
items: MonitoringItem[];
|
|
97
|
+
emailSubmissions?: MonitoringEmailSummary[];
|
|
85
98
|
hiddenNotRelevant: number;
|
|
86
99
|
};
|
|
87
100
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { postChatMessage, type ChatHttpTransport, type ChatMessageSubmission, type PostMessageResponse } from "chatticus/chat-client";
|
|
2
|
+
|
|
3
|
+
/** Stable author mapping supplied by the Papyrus host; never infer it from a display label or email. */
|
|
4
|
+
export interface PapyrusChatAuthor {
|
|
5
|
+
hostMappedAuthorId: string;
|
|
6
|
+
addressedToBotId: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** One logical send. Reuse this exact value only for an explicit retry of the same message. */
|
|
10
|
+
export interface PapyrusChatAdmissionAttempt {
|
|
11
|
+
readonly submission: Readonly<ChatMessageSubmission>;
|
|
12
|
+
readonly idempotencyKey: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PapyrusChatAdmissionSenderOptions {
|
|
16
|
+
/** Host-owned chat URL; no endpoint is configured or exposed by this module. */
|
|
17
|
+
url: string;
|
|
18
|
+
/** Must obtain current Papyrus auth headers on every call; token type stays a host decision. */
|
|
19
|
+
freshAuthorizationHeaders: () => Promise<Record<string, string>>;
|
|
20
|
+
fetch?: typeof globalThis.fetch;
|
|
21
|
+
/** Override only in hermetic tests. Production defaults to the platform CSPRNG. */
|
|
22
|
+
createIdempotencyKey?: () => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PapyrusChatAdmissionSender {
|
|
26
|
+
createAttempt(text: string, author: PapyrusChatAuthor): PapyrusChatAdmissionAttempt;
|
|
27
|
+
send(attempt: PapyrusChatAdmissionAttempt): Promise<PostMessageResponse>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bind Papyrus's host-owned auth and URL to Chatticus's reusable admission
|
|
32
|
+
* transport. Each send makes one POST; there is no automatic retry or auth
|
|
33
|
+
* fallback. An explicit retry sends the same attempt and refreshes headers.
|
|
34
|
+
*/
|
|
35
|
+
export function createPapyrusChatAdmissionSender(options: PapyrusChatAdmissionSenderOptions): PapyrusChatAdmissionSender {
|
|
36
|
+
const transport: ChatHttpTransport = {
|
|
37
|
+
url: options.url,
|
|
38
|
+
headers: async () => {
|
|
39
|
+
const headers = await options.freshAuthorizationHeaders();
|
|
40
|
+
const authorization = Object.entries(headers).find(([name]) => name.toLowerCase() === "authorization")?.[1];
|
|
41
|
+
if (!authorization?.trim()) throw new Error("Papyrus authorization headers are required");
|
|
42
|
+
return headers;
|
|
43
|
+
},
|
|
44
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
45
|
+
};
|
|
46
|
+
const createKey = options.createIdempotencyKey ?? (() => globalThis.crypto.randomUUID());
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
createAttempt(text, author) {
|
|
50
|
+
if (!text.trim() || !author.hostMappedAuthorId.trim()) throw new Error("A message and host-mapped author are required");
|
|
51
|
+
const submission: ChatMessageSubmission = {
|
|
52
|
+
author_kind: "human",
|
|
53
|
+
author_id: author.hostMappedAuthorId,
|
|
54
|
+
body: text,
|
|
55
|
+
addressed_to_bot_id: author.addressedToBotId,
|
|
56
|
+
};
|
|
57
|
+
const idempotencyKey = createKey();
|
|
58
|
+
if (!idempotencyKey.trim()) throw new Error("A stable message admission key is required");
|
|
59
|
+
return Object.freeze({ submission: Object.freeze(submission), idempotencyKey });
|
|
60
|
+
},
|
|
61
|
+
send(attempt) {
|
|
62
|
+
return postChatMessage(transport, attempt.submission, attempt.idempotencyKey);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { EmbeddedChatAppDependencies } from "chatticus/chat-server";
|
|
3
|
+
import type { NextRequest } from "next/server";
|
|
4
|
+
import {
|
|
5
|
+
ProspectusAccountsError,
|
|
6
|
+
authorizeSignedInPersonForAccount,
|
|
7
|
+
} from "./prospectus-site-identity";
|
|
8
|
+
import {
|
|
9
|
+
resolveProspectusSignInRuntime,
|
|
10
|
+
signedInProspectusSession,
|
|
11
|
+
type ProspectusSignInRuntime,
|
|
12
|
+
} from "./prospectus-session";
|
|
13
|
+
import { prospectusSiteProduct, publishingAccountsForSite } from "./prospectus-publication-access";
|
|
14
|
+
import { publicSiteOrigin } from "./site-origin-value";
|
|
15
|
+
|
|
16
|
+
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
17
|
+
|
|
18
|
+
export interface PapyrusChatScope {
|
|
19
|
+
siteProduct: string;
|
|
20
|
+
accountId: string;
|
|
21
|
+
personId: string;
|
|
22
|
+
tenantId: string;
|
|
23
|
+
editorId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PapyrusHostedChatOptions {
|
|
27
|
+
resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
|
|
28
|
+
resolvePrivateDependencies?: () => Promise<EmbeddedChatAppDependencies | null>;
|
|
29
|
+
privateChatEnabled?: boolean;
|
|
30
|
+
siteProduct?: string | null;
|
|
31
|
+
environment?: Record<string, string | undefined>;
|
|
32
|
+
nowInSeconds?: () => number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function siteAndAccountKey(siteProduct: string, accountId: string): string {
|
|
36
|
+
return createHash("sha256").update(JSON.stringify([siteProduct, accountId])).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function derivePapyrusChatScope(siteProduct: string, accountId: string, personId: string): PapyrusChatScope {
|
|
40
|
+
const tenantHash = siteAndAccountKey(siteProduct, accountId);
|
|
41
|
+
const editorHash = createHash("sha256").update(JSON.stringify([siteProduct, accountId, personId])).digest("hex");
|
|
42
|
+
return {
|
|
43
|
+
siteProduct,
|
|
44
|
+
personId,
|
|
45
|
+
accountId,
|
|
46
|
+
tenantId: `papyrus-${tenantHash.slice(0, 40)}`,
|
|
47
|
+
editorId: `papyrus-editor-${editorHash.slice(0, 40)}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type ResolvedPapyrusPrincipal = {
|
|
52
|
+
kind: "user";
|
|
53
|
+
tenantId: string;
|
|
54
|
+
userId: string;
|
|
55
|
+
workerId: null;
|
|
56
|
+
organizationStatus: "enabled";
|
|
57
|
+
role: "member";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
async function principalForRequest(
|
|
61
|
+
request: NextRequest,
|
|
62
|
+
operation: "console:read" | "console:write",
|
|
63
|
+
options: PapyrusHostedChatOptions,
|
|
64
|
+
): Promise<{ principal: ResolvedPapyrusPrincipal | null; status: number }> {
|
|
65
|
+
const environment = options.environment ?? process.env;
|
|
66
|
+
const siteProduct = options.siteProduct !== undefined ? options.siteProduct : prospectusSiteProduct(environment);
|
|
67
|
+
if (!siteProduct) return { principal: null, status: 503 };
|
|
68
|
+
|
|
69
|
+
let runtime: ProspectusSignInRuntime | null;
|
|
70
|
+
try {
|
|
71
|
+
runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(publicSiteOrigin(request, environment));
|
|
72
|
+
} catch {
|
|
73
|
+
return { principal: null, status: 503 };
|
|
74
|
+
}
|
|
75
|
+
if (!runtime) return { principal: null, status: 503 };
|
|
76
|
+
|
|
77
|
+
const nowInSeconds = options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
78
|
+
const session = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
|
|
79
|
+
if (!session || !session.selectedAccountId || !Number.isFinite(session.accessTokenExpiresAt) || session.accessTokenExpiresAt <= nowInSeconds) {
|
|
80
|
+
return { principal: null, status: 401 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const directory = await runtime.accounts.accountDirectory(session.accessToken);
|
|
85
|
+
if (directory.person?.id !== session.personId) return { principal: null, status: 401 };
|
|
86
|
+
if (!publishingAccountsForSite(directory.accounts, siteProduct).some((account) => account.accountId === session.selectedAccountId)) {
|
|
87
|
+
return { principal: null, status: 403 };
|
|
88
|
+
}
|
|
89
|
+
const authorization = await authorizeSignedInPersonForAccount(
|
|
90
|
+
runtime.accounts,
|
|
91
|
+
session.accessToken,
|
|
92
|
+
session.selectedAccountId,
|
|
93
|
+
operation,
|
|
94
|
+
);
|
|
95
|
+
if (authorization.personId !== session.personId || authorization.accountId !== session.selectedAccountId || authorization.operation !== operation) {
|
|
96
|
+
return { principal: null, status: 403 };
|
|
97
|
+
}
|
|
98
|
+
const scope = derivePapyrusChatScope(siteProduct, session.selectedAccountId, session.personId);
|
|
99
|
+
return {
|
|
100
|
+
principal: {
|
|
101
|
+
kind: "user",
|
|
102
|
+
tenantId: scope.tenantId,
|
|
103
|
+
userId: scope.editorId,
|
|
104
|
+
workerId: null,
|
|
105
|
+
organizationStatus: "enabled",
|
|
106
|
+
role: "member",
|
|
107
|
+
},
|
|
108
|
+
status: 200,
|
|
109
|
+
};
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof ProspectusAccountsError && (error.status === 401 || error.status === 403)) {
|
|
112
|
+
return { principal: null, status: error.status };
|
|
113
|
+
}
|
|
114
|
+
return { principal: null, status: 503 };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function jsonFailure(status: number, error: string): Response {
|
|
119
|
+
return new Response(JSON.stringify({ error }), {
|
|
120
|
+
status,
|
|
121
|
+
headers: { ...NO_STORE_HEADERS, "content-type": "application/json" },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function supportedPath(path: readonly string[]): boolean {
|
|
126
|
+
return (path.length === 1 && path[0] === "private-channel") ||
|
|
127
|
+
(path.length === 3 && path[0] === "channels" && path[2] === "messages") ||
|
|
128
|
+
(path.length === 3 && path[0] === "turns" && (path[2] === "events" || path[2] === "stream"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function chatRequestPath(path: readonly string[]): string {
|
|
132
|
+
return `/${path.map((part) => encodeURIComponent(part)).join("/")}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function handlePapyrusHostedChatRequest(
|
|
136
|
+
request: NextRequest,
|
|
137
|
+
path: readonly string[],
|
|
138
|
+
options: PapyrusHostedChatOptions = {},
|
|
139
|
+
): Promise<Response> {
|
|
140
|
+
if (!supportedPath(path) || path.some((part) => !part || part === "." || part === ".." || part.includes("/"))) {
|
|
141
|
+
return jsonFailure(404, "chat_route_not_found");
|
|
142
|
+
}
|
|
143
|
+
if (request.method !== "GET" && request.method !== "POST") return jsonFailure(405, "method_not_allowed");
|
|
144
|
+
const isMessageRoute = path[0] === "channels";
|
|
145
|
+
const isPrivateChannelRoute = path[0] === "private-channel";
|
|
146
|
+
if (isPrivateChannelRoute && request.method !== "POST") return jsonFailure(405, "method_not_allowed");
|
|
147
|
+
if ((isMessageRoute && path[2] === "messages" && request.method === "POST") || (!isMessageRoute && !isPrivateChannelRoute && request.method !== "GET")) {
|
|
148
|
+
if (!isMessageRoute && !isPrivateChannelRoute) return jsonFailure(405, "method_not_allowed");
|
|
149
|
+
}
|
|
150
|
+
if (isMessageRoute && request.method === "GET" && path[2] !== "messages") return jsonFailure(405, "method_not_allowed");
|
|
151
|
+
if ((isMessageRoute || isPrivateChannelRoute) && request.method === "POST") {
|
|
152
|
+
let origin: string;
|
|
153
|
+
try {
|
|
154
|
+
origin = publicSiteOrigin(request, options.environment ?? process.env);
|
|
155
|
+
} catch {
|
|
156
|
+
return jsonFailure(503, "chat_site_origin_unavailable");
|
|
157
|
+
}
|
|
158
|
+
if (request.headers.get("origin") !== origin) return jsonFailure(403, "same_origin_required");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const environment = options.environment ?? process.env;
|
|
162
|
+
const privateChatEnabled = options.privateChatEnabled ?? environment.PAPYRUS_PRIVATE_CHAT_ENABLED === "true";
|
|
163
|
+
if (!privateChatEnabled) return jsonFailure(404, "private_chat_disabled");
|
|
164
|
+
|
|
165
|
+
let dependencies: EmbeddedChatAppDependencies | null;
|
|
166
|
+
try {
|
|
167
|
+
if (options.resolvePrivateDependencies) {
|
|
168
|
+
dependencies = await options.resolvePrivateDependencies();
|
|
169
|
+
} else {
|
|
170
|
+
const chatRuntime = await import("chatticus/chat-runtime");
|
|
171
|
+
dependencies = chatRuntime.createSiteHostedChatDependencies(
|
|
172
|
+
chatRuntime.siteHostedChatResourcesFromEnvironment(environment),
|
|
173
|
+
chatRuntime.siteHostedChatClients(),
|
|
174
|
+
) as EmbeddedChatAppDependencies;
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
dependencies = null;
|
|
178
|
+
}
|
|
179
|
+
if (!dependencies) return jsonFailure(503, "private_chat_storage_unavailable");
|
|
180
|
+
|
|
181
|
+
const authorization = await principalForRequest(request, request.method === "POST" ? "console:write" : "console:read", options);
|
|
182
|
+
if (!authorization.principal) return jsonFailure(authorization.status, authorization.status === 401 ? "sign_in_required" : authorization.status === 403 ? "chat_account_access_denied" : "chat_runtime_unavailable");
|
|
183
|
+
|
|
184
|
+
const headers = new Headers({ accept: request.headers.get("accept") ?? "application/json" });
|
|
185
|
+
let body: string | undefined;
|
|
186
|
+
if (request.method === "POST" && isMessageRoute) {
|
|
187
|
+
const idempotencyKey = request.headers.get("idempotency-key")?.trim();
|
|
188
|
+
const input = await request.json().catch(() => null) as Record<string, unknown> | null;
|
|
189
|
+
const privateBotId = environment.PAPYRUS_CHAT_PRIVATE_BOT_ID?.trim();
|
|
190
|
+
if (!idempotencyKey || typeof input?.body !== "string" || !input.body.trim()) {
|
|
191
|
+
return jsonFailure(422, "message_body_and_idempotency_key_required");
|
|
192
|
+
}
|
|
193
|
+
if (!privateBotId) return jsonFailure(503, "private_chat_storage_unavailable");
|
|
194
|
+
headers.set("content-type", "application/json");
|
|
195
|
+
headers.set("idempotency-key", idempotencyKey);
|
|
196
|
+
body = JSON.stringify({
|
|
197
|
+
author_kind: "human",
|
|
198
|
+
author_id: authorization.principal.userId,
|
|
199
|
+
body: input.body,
|
|
200
|
+
addressed_to_bot_id: privateBotId,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const target = new URL(chatRequestPath(path) + new URL(request.url).search, request.url);
|
|
205
|
+
const forwarded = new Request(target, { method: request.method, headers, ...(body === undefined ? {} : { body }) });
|
|
206
|
+
let chatServer: typeof import("chatticus/chat-server");
|
|
207
|
+
try {
|
|
208
|
+
chatServer = await import("chatticus/chat-server");
|
|
209
|
+
} catch {
|
|
210
|
+
return jsonFailure(503, "private_chat_storage_unavailable");
|
|
211
|
+
}
|
|
212
|
+
const app = chatServer.createEmbeddedChatApp({
|
|
213
|
+
...dependencies,
|
|
214
|
+
resolvePrincipal: async () => authorization.principal,
|
|
215
|
+
});
|
|
216
|
+
return app.fetch(forwarded);
|
|
217
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export type PapyrusPrivateChatChannel = {
|
|
2
|
+
channel_id: string;
|
|
3
|
+
participants: Array<{ kind: string; actor_id: string }>;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type PapyrusPrivateChatMessage = {
|
|
7
|
+
message_id: string;
|
|
8
|
+
channel_id: string;
|
|
9
|
+
seq: number;
|
|
10
|
+
author_kind: "human" | "bot";
|
|
11
|
+
body: string;
|
|
12
|
+
addressed_to_bot_id: string | null;
|
|
13
|
+
created_at: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PapyrusPrivateChatSendResult = {
|
|
17
|
+
message: PapyrusPrivateChatMessage;
|
|
18
|
+
turn_id: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type PapyrusPrivateChatFetch = typeof globalThis.fetch;
|
|
22
|
+
|
|
23
|
+
const CHAT_API = "/api/newsroom/chat";
|
|
24
|
+
|
|
25
|
+
async function requestJson<T>(path: string, init: RequestInit, fetcher: PapyrusPrivateChatFetch): Promise<T> {
|
|
26
|
+
const response = await fetcher(`${CHAT_API}/${path}`, {
|
|
27
|
+
...init,
|
|
28
|
+
credentials: "same-origin",
|
|
29
|
+
cache: "no-store",
|
|
30
|
+
});
|
|
31
|
+
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const error = typeof payload?.error === "string" ? payload.error
|
|
34
|
+
: typeof payload?.detail === "string" ? payload.detail
|
|
35
|
+
: `Private chat request failed (${response.status}).`;
|
|
36
|
+
throw new Error(error);
|
|
37
|
+
}
|
|
38
|
+
if (!payload || typeof payload !== "object") throw new Error("Private chat returned an invalid response.");
|
|
39
|
+
return payload as T;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Open the current editor's server-derived private channel using the signed same-origin session cookie. */
|
|
43
|
+
export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch = globalThis.fetch): Promise<PapyrusPrivateChatChannel> {
|
|
44
|
+
const result = await requestJson<PapyrusPrivateChatChannel>("private-channel", { method: "POST" }, fetcher);
|
|
45
|
+
if (!result.channel_id?.trim() || !Array.isArray(result.participants)) {
|
|
46
|
+
throw new Error("Private chat returned an invalid channel.");
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read only the private channel returned by the host's authenticated open request. */
|
|
52
|
+
export async function listPapyrusPrivateChatMessages(
|
|
53
|
+
channelId: string,
|
|
54
|
+
fetcher: PapyrusPrivateChatFetch = globalThis.fetch,
|
|
55
|
+
): Promise<PapyrusPrivateChatMessage[]> {
|
|
56
|
+
if (!channelId.trim()) throw new Error("A private chat channel is required.");
|
|
57
|
+
const result = await requestJson<{ messages?: unknown }>(`channels/${encodeURIComponent(channelId)}/messages`, { method: "GET" }, fetcher);
|
|
58
|
+
if (!Array.isArray(result.messages)) throw new Error("Private chat returned an invalid message list.");
|
|
59
|
+
return result.messages as PapyrusPrivateChatMessage[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One explicit send. The browser sends neither its tenant nor author or bot identity. */
|
|
63
|
+
export async function sendPapyrusPrivateChatMessage(
|
|
64
|
+
channelId: string,
|
|
65
|
+
body: string,
|
|
66
|
+
idempotencyKey: string,
|
|
67
|
+
fetcher: PapyrusPrivateChatFetch = globalThis.fetch,
|
|
68
|
+
): Promise<PapyrusPrivateChatSendResult> {
|
|
69
|
+
if (!channelId.trim() || !body.trim() || !idempotencyKey.trim()) {
|
|
70
|
+
throw new Error("A channel, message and idempotency key are required.");
|
|
71
|
+
}
|
|
72
|
+
const result = await requestJson<PapyrusPrivateChatSendResult>(`channels/${encodeURIComponent(channelId)}/messages`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
|
|
75
|
+
body: JSON.stringify({ body }),
|
|
76
|
+
}, fetcher);
|
|
77
|
+
if (!result.message?.message_id || result.message.channel_id !== channelId) {
|
|
78
|
+
throw new Error("Private chat returned an invalid sent message.");
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
2
2
|
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
3
|
+
import type { NextRequest } from "next/server";
|
|
3
4
|
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
4
5
|
import {
|
|
5
6
|
ProspectusSiteAccounts,
|
|
@@ -42,6 +43,20 @@ export interface ProspectusSignedInSession {
|
|
|
42
43
|
siteSubject?: string;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/** Open the existing sealed Prospectus session cookie for one server request. */
|
|
47
|
+
export function signedInProspectusSession(
|
|
48
|
+
request: Pick<NextRequest, "cookies">,
|
|
49
|
+
sealingSecret: string,
|
|
50
|
+
nowInSeconds?: number,
|
|
51
|
+
): ProspectusSignedInSession | null {
|
|
52
|
+
return openProspectusCookie<ProspectusSignedInSession>(
|
|
53
|
+
sealingSecret,
|
|
54
|
+
PROSPECTUS_SESSION_COOKIE,
|
|
55
|
+
request.cookies.get(PROSPECTUS_SESSION_COOKIE)?.value,
|
|
56
|
+
nowInSeconds,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
function sealingKeyForCookie(sealingSecret: string, cookieName: string): Buffer {
|
|
46
61
|
if (sealingSecret.length < MINIMUM_SEALING_SECRET_LENGTH) throw new Error("The Prospectus session sealing secret is too short.");
|
|
47
62
|
return Buffer.from(hkdfSync("sha256", sealingSecret, SEALED_COOKIE_KEY_DERIVATION_SALT, cookieName, 32));
|