@anthusai/papyrus 5.41.2 → 5.42.1
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/plugins/reader-steering/function/handler.py +22 -0
- package/amplify/plugins/reader-steering/function/proposals.py +29 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +1 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +1 -0
- package/amplify/plugins/reader-steering/schema.js +23 -0
- package/amplify/plugins/reader-steering/schema.ts +23 -0
- package/components/monitoring-overview-view.tsx +9 -1
- package/components/papyrus-private-chat-panel.tsx +15 -85
- package/lib/monitoring-overview.ts +3 -0
- package/lib/papyrus-hosted-chat.ts +6 -19
- package/lib/papyrus-private-chat-client.ts +4 -5
- package/lib/prospectus-session.ts +0 -2
- package/lib/prospectus-sign-in-routes.ts +2 -19
- package/lib/prospectus-site-identity.ts +0 -7
- package/package.json +1 -3
- package/routes.manifest.json +1 -1
- package/src/with-papyrus.d.mts +1 -0
- package/src/with-papyrus.mjs +16 -0
- package/lib/newsroom-chat-client.ts +0 -30
- package/lib/newsroom-chat-identity.ts +0 -39
|
@@ -1015,6 +1015,26 @@ class DynamoReaderStore:
|
|
|
1015
1015
|
raise
|
|
1016
1016
|
return True
|
|
1017
1017
|
|
|
1018
|
+
def list_story_drafts(self, limit: int) -> list[dict[str, Any]]:
|
|
1019
|
+
"""The story drafts of the table, only the fields ``listStoryDrafts`` may answer; at most ``limit`` (the newest if there are more)."""
|
|
1020
|
+
fields = proposal_rules.STORY_DRAFT_FIELDS
|
|
1021
|
+
names = {"#kind": "kind", **{f"#f{index}": field for index, field in enumerate(fields)}}
|
|
1022
|
+
request: dict[str, Any] = {
|
|
1023
|
+
"FilterExpression": "#kind = :kind",
|
|
1024
|
+
"ProjectionExpression": ", ".join(f"#f{index}" for index in range(len(fields))) + ", #kind",
|
|
1025
|
+
"ExpressionAttributeNames": names,
|
|
1026
|
+
"ExpressionAttributeValues": {":kind": "story_draft"},
|
|
1027
|
+
}
|
|
1028
|
+
rows: list[dict[str, Any]] = []
|
|
1029
|
+
while True:
|
|
1030
|
+
page = self._table("proposal").scan(**request)
|
|
1031
|
+
rows.extend(plain(item) for item in page.get("Items") or [])
|
|
1032
|
+
if not page.get("LastEvaluatedKey"):
|
|
1033
|
+
break
|
|
1034
|
+
request["ExclusiveStartKey"] = page["LastEvaluatedKey"]
|
|
1035
|
+
rows.sort(key=lambda row: (row.get("createdAt") or "", row.get("id") or ""), reverse=True)
|
|
1036
|
+
return [{field: row.get(field) for field in fields} for row in rows[:limit]]
|
|
1037
|
+
|
|
1018
1038
|
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool:
|
|
1019
1039
|
"""Apply ``changes`` if the state (and, when given, ``updatedAt``) is still what the caller saw; a value of ``None`` removes the attribute."""
|
|
1020
1040
|
names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
|
|
@@ -1510,6 +1530,8 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
|
|
|
1510
1530
|
"""``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
|
|
1511
1531
|
if proposal_rules.event_field_name(event) in proposal_rules.MUTATIONS:
|
|
1512
1532
|
return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds), account_id=account_id)
|
|
1533
|
+
if proposal_rules.event_field_name(event) in proposal_rules.QUERIES:
|
|
1534
|
+
return proposal_rules.handle_proposal_query(event, store, account_id=account_id)
|
|
1513
1535
|
identity = event.get("identity") or {}
|
|
1514
1536
|
claims = identity.get("claims") or {}
|
|
1515
1537
|
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
@@ -57,6 +57,11 @@ TARGET_COUNTS: dict[str, tuple[int, int]] = {
|
|
|
57
57
|
TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
|
|
58
58
|
|
|
59
59
|
MUTATIONS = ("fileProposal", "transitionProposal", "editProposal")
|
|
60
|
+
#: The one read an operator has: ``listStoryDrafts`` answers the story drafts and nothing else of them (never the body, the evidence, the
|
|
61
|
+
#: history, the proposer, a vote, the editor log or a reason), so an unattended run can avoid filing a near-duplicate of an earlier draft.
|
|
62
|
+
QUERIES = ("listStoryDrafts",)
|
|
63
|
+
STORY_DRAFT_FIELDS = ("id", "title", "summary", "targets", "state", "createdAt")
|
|
64
|
+
MAXIMUM_STORY_DRAFTS = 500
|
|
60
65
|
|
|
61
66
|
#: What ``editProposal`` does beyond moving the state. Editors and admins only: an operator is refused every one of them.
|
|
62
67
|
PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION = "pin", "unpin", "hold", "release", "exclude_votes", "undo_exclusion"
|
|
@@ -128,6 +133,8 @@ class ProposalStore(Protocol):
|
|
|
128
133
|
|
|
129
134
|
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool: ...
|
|
130
135
|
|
|
136
|
+
def list_story_drafts(self, limit: int) -> list[dict[str, Any]]: ...
|
|
137
|
+
|
|
131
138
|
|
|
132
139
|
def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
133
140
|
"""The Cognito subject of an editor or admin, or ``None`` for anyone else."""
|
|
@@ -456,3 +463,25 @@ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_is
|
|
|
456
463
|
by=BY_OPERATOR if operator else BY_STAFF,
|
|
457
464
|
allowed=OPERATOR_TRANSITIONS if operator else None,
|
|
458
465
|
).to_dict()
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def story_draft_summary(row: dict[str, Any]) -> dict[str, Any]:
|
|
469
|
+
"""The six fields an operator may read of a story draft. ``targets`` stays the JSON text it is stored as."""
|
|
470
|
+
summary = {field: row.get(field) for field in STORY_DRAFT_FIELDS}
|
|
471
|
+
if summary["targets"] is not None and not isinstance(summary["targets"], str):
|
|
472
|
+
summary["targets"] = json.dumps(summary["targets"])
|
|
473
|
+
return summary
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def handle_proposal_query(event: dict[str, Any], store: ProposalStore, *, account_id: str | None = None) -> dict[str, Any]:
|
|
477
|
+
"""``listStoryDrafts`` from AppSync: the story drafts, newest first, with only ``STORY_DRAFT_FIELDS``. Read only.
|
|
478
|
+
|
|
479
|
+
The same caller check as :func:`handle_proposal_event`: an editor or admin, or an IAM principal of ``account_id`` (the function's own
|
|
480
|
+
account) acting as an operator. An identity-pool user and a principal of any other account are refused. Answers a receipt-shaped
|
|
481
|
+
object, never an exception.
|
|
482
|
+
"""
|
|
483
|
+
identity = event.get("identity") or {}
|
|
484
|
+
if staff_subject(identity) is None and operator_actor(identity, account_id) is None:
|
|
485
|
+
return {**refused("not_staff", "Only editors, admins and operators of this account can list story drafts.").to_dict(), "drafts": []}
|
|
486
|
+
rows = sorted(store.list_story_drafts(MAXIMUM_STORY_DRAFTS), key=lambda row: (row.get("createdAt") or "", row.get("id") or ""), reverse=True)
|
|
487
|
+
return {"status": "ok", "id": None, "error": None, "message": None, "drafts": [story_draft_summary(row) for row in rows[:MAXIMUM_STORY_DRAFTS]]}
|
|
@@ -16,6 +16,7 @@ export const readerSteeringManifest = {
|
|
|
16
16
|
contract: 1,
|
|
17
17
|
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
18
18
|
mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
|
|
19
|
+
queries: ["listStoryDrafts"],
|
|
19
20
|
guest: { mutations: [] },
|
|
20
21
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
21
22
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -23,6 +23,7 @@ export const readerSteeringManifest: PluginManifest = {
|
|
|
23
23
|
contract: 1,
|
|
24
24
|
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
25
25
|
mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
|
|
26
|
+
queries: ["listStoryDrafts"],
|
|
26
27
|
guest: { mutations: [] },
|
|
27
28
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
28
29
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -165,6 +165,22 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
165
165
|
error: a.string(),
|
|
166
166
|
message: a.string(),
|
|
167
167
|
}),
|
|
168
|
+
// What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
|
|
169
|
+
StoryDraftSummary: a.customType({
|
|
170
|
+
id: a.string().required(),
|
|
171
|
+
title: a.string().required(),
|
|
172
|
+
summary: a.string(),
|
|
173
|
+
targets: a.json(),
|
|
174
|
+
state: a.string().required(),
|
|
175
|
+
createdAt: a.string().required(),
|
|
176
|
+
}),
|
|
177
|
+
StoryDraftList: a.customType({
|
|
178
|
+
status: a.string().required(),
|
|
179
|
+
id: a.string(),
|
|
180
|
+
error: a.string(),
|
|
181
|
+
message: a.string(),
|
|
182
|
+
drafts: a.ref("StoryDraftSummary").array(),
|
|
183
|
+
}),
|
|
168
184
|
// Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
|
|
169
185
|
// schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
|
|
170
186
|
// of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
|
|
@@ -194,6 +210,13 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
194
210
|
.returns(a.ref("ProposalReceipt"))
|
|
195
211
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
196
212
|
.handler(functionHandler("readerActions", readerActions)),
|
|
213
|
+
// Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
|
|
214
|
+
// answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
|
|
215
|
+
listStoryDrafts: a
|
|
216
|
+
.query()
|
|
217
|
+
.returns(a.ref("StoryDraftList"))
|
|
218
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
219
|
+
.handler(functionHandler("readerActions", readerActions)),
|
|
197
220
|
// Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
|
|
198
221
|
// undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
|
|
199
222
|
editProposal: a
|
|
@@ -167,6 +167,22 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
167
167
|
error: a.string(),
|
|
168
168
|
message: a.string(),
|
|
169
169
|
}),
|
|
170
|
+
// What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
|
|
171
|
+
StoryDraftSummary: a.customType({
|
|
172
|
+
id: a.string().required(),
|
|
173
|
+
title: a.string().required(),
|
|
174
|
+
summary: a.string(),
|
|
175
|
+
targets: a.json(),
|
|
176
|
+
state: a.string().required(),
|
|
177
|
+
createdAt: a.string().required(),
|
|
178
|
+
}),
|
|
179
|
+
StoryDraftList: a.customType({
|
|
180
|
+
status: a.string().required(),
|
|
181
|
+
id: a.string(),
|
|
182
|
+
error: a.string(),
|
|
183
|
+
message: a.string(),
|
|
184
|
+
drafts: a.ref("StoryDraftSummary").array(),
|
|
185
|
+
}),
|
|
170
186
|
// Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
|
|
171
187
|
// schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
|
|
172
188
|
// of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
|
|
@@ -196,6 +212,13 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
196
212
|
.returns(a.ref("ProposalReceipt"))
|
|
197
213
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
198
214
|
.handler(functionHandler("readerActions", readerActions)),
|
|
215
|
+
// Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
|
|
216
|
+
// answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
|
|
217
|
+
listStoryDrafts: a
|
|
218
|
+
.query()
|
|
219
|
+
.returns(a.ref("StoryDraftList"))
|
|
220
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
221
|
+
.handler(functionHandler("readerActions", readerActions)),
|
|
199
222
|
// Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
|
|
200
223
|
// undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
|
|
201
224
|
editProposal: a
|
|
@@ -336,7 +336,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
336
336
|
<div className="news-desk-doctrine-card__header"><div><h3>Collected queue</h3><span>Newest first, with what the shared Cyclotron decided</span></div></div>
|
|
337
337
|
{data.items.length ? (
|
|
338
338
|
<ul className="news-desk-monitoring__queue">
|
|
339
|
-
{data.items.map((item) => <QueueItem key={`${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
339
|
+
{data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
340
340
|
</ul>
|
|
341
341
|
) : <p>No items in the queue.</p>}
|
|
342
342
|
{data.hiddenNotRelevant > 0 ? (
|
|
@@ -345,6 +345,14 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
345
345
|
</p>
|
|
346
346
|
) : null}
|
|
347
347
|
</article>
|
|
348
|
+
|
|
349
|
+
{data.otherMonitors?.length ? (
|
|
350
|
+
<details className="news-desk-delegation-note" data-monitoring-other-monitors>
|
|
351
|
+
<summary>Not routed to this site ({data.otherMonitors.length})</summary>
|
|
352
|
+
<p>These monitors collect items, but none of them is routed to this site, so their items are not shown here.</p>
|
|
353
|
+
<ul>{data.otherMonitors.map((id) => <li key={id} data-monitoring-other-monitor={id}>{id}</li>)}</ul>
|
|
354
|
+
</details>
|
|
355
|
+
) : null}
|
|
348
356
|
</>
|
|
349
357
|
);
|
|
350
358
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
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
4
|
import { PapyrusConsoleConversation, type ConsoleConversationMessage } from "./papyrus-console-conversation";
|
|
7
5
|
import {
|
|
8
6
|
listPapyrusPrivateChatMessages,
|
|
@@ -30,13 +28,9 @@ function failureMessage(error: unknown): string {
|
|
|
30
28
|
export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
31
29
|
const [channel, setChannel] = useState<PapyrusPrivateChatChannel | null>(null);
|
|
32
30
|
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
31
|
const [loading, setLoading] = useState(true);
|
|
37
32
|
const [sending, setSending] = useState(false);
|
|
38
33
|
const [error, setError] = useState<string | null>(null);
|
|
39
|
-
const identityGeneration = useRef(0);
|
|
40
34
|
const retryAttempt = useRef<{ text: string; key: string } | null>(null);
|
|
41
35
|
const isWaitingForReply = messages.length > 0 && messages[messages.length - 1].author_kind === "human" &&
|
|
42
36
|
messages[messages.length - 1].addressed_to_bot_id !== null;
|
|
@@ -52,66 +46,38 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
52
46
|
}];
|
|
53
47
|
}, [isWaitingForReply, messages]);
|
|
54
48
|
|
|
55
|
-
const
|
|
56
|
-
const failure = failureMessage(nextError);
|
|
57
|
-
setError(failure);
|
|
58
|
-
if (["sign_in_required", "chat_identity_connection_required", "publishing_account_required", "chat_account_access_denied"].includes(failure)) {
|
|
59
|
-
const generation = ++identityGeneration.current;
|
|
60
|
-
setChannel(null);
|
|
61
|
-
setMessages([]);
|
|
62
|
-
setAccountState(null);
|
|
63
|
-
retryAttempt.current = null;
|
|
64
|
-
setSending(false);
|
|
65
|
-
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
|
-
}
|
|
71
|
-
}, []);
|
|
72
|
-
|
|
73
|
-
const refreshMessages = useCallback(async (channelId: string, generation: number) => {
|
|
49
|
+
const refreshMessages = useCallback(async (channelId: string) => {
|
|
74
50
|
const next = await listPapyrusPrivateChatMessages(channelId);
|
|
75
|
-
|
|
51
|
+
setMessages(next);
|
|
76
52
|
}, []);
|
|
77
53
|
|
|
78
54
|
const openConversation = useCallback(async () => {
|
|
79
|
-
const generation = ++identityGeneration.current;
|
|
80
55
|
setLoading(true);
|
|
81
56
|
setError(null);
|
|
82
|
-
setChannel(null);
|
|
83
|
-
setMessages([]);
|
|
84
57
|
try {
|
|
85
58
|
const nextChannel = await openPapyrusPrivateChat();
|
|
86
|
-
if (generation !== identityGeneration.current) return;
|
|
87
59
|
setChannel(nextChannel);
|
|
88
|
-
await refreshMessages(nextChannel.channel_id
|
|
60
|
+
await refreshMessages(nextChannel.channel_id);
|
|
89
61
|
} catch (nextError) {
|
|
90
|
-
|
|
62
|
+
setError(failureMessage(nextError));
|
|
91
63
|
} finally {
|
|
92
|
-
|
|
64
|
+
setLoading(false);
|
|
93
65
|
}
|
|
94
|
-
}, [
|
|
66
|
+
}, [refreshMessages]);
|
|
95
67
|
|
|
96
68
|
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
69
|
void openConversation();
|
|
102
|
-
return () => { identityGeneration.current += 1; };
|
|
103
70
|
}, [openConversation]);
|
|
104
71
|
|
|
105
72
|
useEffect(() => {
|
|
106
73
|
if (!channel || !isWaitingForReply) return;
|
|
107
74
|
let active = true;
|
|
108
|
-
const generation = identityGeneration.current;
|
|
109
75
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
110
76
|
const poll = async () => {
|
|
111
77
|
try {
|
|
112
|
-
await refreshMessages(channel.channel_id
|
|
78
|
+
await refreshMessages(channel.channel_id);
|
|
113
79
|
} catch (nextError) {
|
|
114
|
-
if (active
|
|
80
|
+
if (active) setError(failureMessage(nextError));
|
|
115
81
|
return;
|
|
116
82
|
}
|
|
117
83
|
if (active) timer = setTimeout(poll, REFRESH_MS);
|
|
@@ -121,11 +87,10 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
121
87
|
active = false;
|
|
122
88
|
if (timer) clearTimeout(timer);
|
|
123
89
|
};
|
|
124
|
-
}, [channel,
|
|
90
|
+
}, [channel, isWaitingForReply, refreshMessages]);
|
|
125
91
|
|
|
126
92
|
const sendMessage = useCallback(async (text: string) => {
|
|
127
93
|
if (!channel || !text.trim() || sending) return;
|
|
128
|
-
const generation = identityGeneration.current;
|
|
129
94
|
const attempt = retryAttempt.current?.text === text
|
|
130
95
|
? retryAttempt.current
|
|
131
96
|
: { text, key: globalThis.crypto.randomUUID() };
|
|
@@ -134,40 +99,16 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
134
99
|
setError(null);
|
|
135
100
|
try {
|
|
136
101
|
const result = await sendPapyrusPrivateChatMessage(channel.channel_id, text, attempt.key);
|
|
137
|
-
if (generation !== identityGeneration.current) return;
|
|
138
102
|
setMessages((current) => current.some((message) => message.message_id === result.message.message_id)
|
|
139
103
|
? current
|
|
140
104
|
: [...current, result.message]);
|
|
141
105
|
retryAttempt.current = null;
|
|
142
|
-
} catch (nextError) {
|
|
143
|
-
if (generation === identityGeneration.current) await handleFailure(nextError);
|
|
144
|
-
} finally {
|
|
145
|
-
if (generation === identityGeneration.current) setSending(false);
|
|
146
|
-
}
|
|
147
|
-
}, [channel, handleFailure, sending]);
|
|
148
|
-
|
|
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
106
|
} catch (nextError) {
|
|
157
107
|
setError(failureMessage(nextError));
|
|
158
|
-
|
|
108
|
+
} finally {
|
|
109
|
+
setSending(false);
|
|
159
110
|
}
|
|
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
|
-
};
|
|
111
|
+
}, [channel, sending]);
|
|
171
112
|
|
|
172
113
|
return <div className="papyrus-console__panel">
|
|
173
114
|
<header className="papyrus-console__header">
|
|
@@ -176,20 +117,9 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
176
117
|
</header>
|
|
177
118
|
{channel ? <p className="papyrus-console__empty" role="status">Private conversation for your selected Account.</p> : null}
|
|
178
119
|
{!channel && !loading ? <div className="papyrus-console__empty" 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>Choose your publishing Account</strong>
|
|
186
|
-
{accountState.publishingAccounts.length === 0 ? <span>This identity has no publishing Account for this site. Ask the Account owner to confirm your existing access.</span> : 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>)}
|
|
187
|
-
{error !== "publishing_account_required" ? <span>{error}</span> : null}
|
|
188
|
-
</> : <>
|
|
189
|
-
<strong>Private chat could not be opened.</strong>
|
|
190
|
-
<span>{error === "sign_in_required" ? "Your newsroom session has expired. Sign in from the account menu, then retry." : error}</span>
|
|
191
|
-
<button className="papyrus-console__new-session" onClick={() => void openConversation()} type="button">Retry</button>
|
|
192
|
-
</>}
|
|
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>
|
|
193
123
|
</div> : <PapyrusConsoleConversation
|
|
194
124
|
messages={conversationMessages}
|
|
195
125
|
isSending={sending || loading || !channel}
|
|
@@ -20,6 +20,7 @@ export type MonitoringRelevance = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export type MonitoringItem = {
|
|
23
|
+
monitorId?: string;
|
|
23
24
|
sourceId: string;
|
|
24
25
|
revision: number;
|
|
25
26
|
title: string;
|
|
@@ -128,6 +129,8 @@ export type MonitoringOverview = {
|
|
|
128
129
|
historyComplete: boolean;
|
|
129
130
|
} | null;
|
|
130
131
|
items: MonitoringItem[];
|
|
132
|
+
/** Monitors that do not route to this site: named only, with no items. */
|
|
133
|
+
otherMonitors?: string[];
|
|
131
134
|
emailSubmissions?: MonitoringEmailSummary[];
|
|
132
135
|
hiddenNotRelevant: number;
|
|
133
136
|
};
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { resolveNewsroomChatIdentity, type NewsroomChatIdentityResolver } from "./newsroom-chat-identity";
|
|
2
1
|
import { createHash } from "node:crypto";
|
|
3
2
|
import type { EmbeddedChatAppDependencies } from "chatticus/chat-server";
|
|
4
3
|
import type { NextRequest } from "next/server";
|
|
@@ -25,7 +24,6 @@ export interface PapyrusChatScope {
|
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
export interface PapyrusHostedChatOptions {
|
|
28
|
-
resolveNewsroomIdentity?: NewsroomChatIdentityResolver;
|
|
29
27
|
resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
|
|
30
28
|
resolvePrivateDependencies?: () => Promise<EmbeddedChatAppDependencies | null>;
|
|
31
29
|
privateChatEnabled?: boolean;
|
|
@@ -63,7 +61,7 @@ async function principalForRequest(
|
|
|
63
61
|
request: NextRequest,
|
|
64
62
|
operation: "console:read" | "console:write",
|
|
65
63
|
options: PapyrusHostedChatOptions,
|
|
66
|
-
): Promise<{ principal: ResolvedPapyrusPrincipal | null; status: number
|
|
64
|
+
): Promise<{ principal: ResolvedPapyrusPrincipal | null; status: number }> {
|
|
67
65
|
const environment = options.environment ?? process.env;
|
|
68
66
|
const siteProduct = options.siteProduct !== undefined ? options.siteProduct : prospectusSiteProduct(environment);
|
|
69
67
|
if (!siteProduct) return { principal: null, status: 503 };
|
|
@@ -76,22 +74,11 @@ async function principalForRequest(
|
|
|
76
74
|
}
|
|
77
75
|
if (!runtime) return { principal: null, status: 503 };
|
|
78
76
|
|
|
79
|
-
let newsroomIdentity;
|
|
80
|
-
try {
|
|
81
|
-
newsroomIdentity = await (options.resolveNewsroomIdentity ?? resolveNewsroomChatIdentity)(request);
|
|
82
|
-
} catch {
|
|
83
|
-
return { principal: null, status: 503 };
|
|
84
|
-
}
|
|
85
|
-
if (!newsroomIdentity) return { principal: null, status: 401 };
|
|
86
77
|
const nowInSeconds = options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
87
78
|
const session = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
|
|
88
|
-
if (!session || !Number.isFinite(session.accessTokenExpiresAt) || session.accessTokenExpiresAt <= nowInSeconds) {
|
|
89
|
-
return { principal: null, status:
|
|
90
|
-
}
|
|
91
|
-
if (session.newsroomSubject !== newsroomIdentity.subject) {
|
|
92
|
-
return { principal: null, status: 409, error: "chat_identity_connection_required" };
|
|
79
|
+
if (!session || !session.selectedAccountId || !Number.isFinite(session.accessTokenExpiresAt) || session.accessTokenExpiresAt <= nowInSeconds) {
|
|
80
|
+
return { principal: null, status: 401 };
|
|
93
81
|
}
|
|
94
|
-
if (!session.selectedAccountId) return { principal: null, status: 409, error: "publishing_account_required" };
|
|
95
82
|
|
|
96
83
|
try {
|
|
97
84
|
const directory = await runtime.accounts.accountDirectory(session.accessToken);
|
|
@@ -175,9 +162,6 @@ export async function handlePapyrusHostedChatRequest(
|
|
|
175
162
|
const privateChatEnabled = options.privateChatEnabled ?? environment.PAPYRUS_PRIVATE_CHAT_ENABLED === "true";
|
|
176
163
|
if (!privateChatEnabled) return jsonFailure(404, "private_chat_disabled");
|
|
177
164
|
|
|
178
|
-
const authorization = await principalForRequest(request, request.method === "POST" ? "console:write" : "console:read", options);
|
|
179
|
-
if (!authorization.principal) return jsonFailure(authorization.status, authorization.error ?? (authorization.status === 401 ? "sign_in_required" : authorization.status === 403 ? "chat_account_access_denied" : "chat_runtime_unavailable"));
|
|
180
|
-
|
|
181
165
|
let dependencies: EmbeddedChatAppDependencies | null;
|
|
182
166
|
try {
|
|
183
167
|
if (options.resolvePrivateDependencies) {
|
|
@@ -194,6 +178,9 @@ export async function handlePapyrusHostedChatRequest(
|
|
|
194
178
|
}
|
|
195
179
|
if (!dependencies) return jsonFailure(503, "private_chat_storage_unavailable");
|
|
196
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
|
+
|
|
197
184
|
const headers = new Headers({ accept: request.headers.get("accept") ?? "application/json" });
|
|
198
185
|
let body: string | undefined;
|
|
199
186
|
if (request.method === "POST" && isMessageRoute) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { newsroomChatFetch } from "./newsroom-chat-client";
|
|
2
1
|
export type PapyrusPrivateChatChannel = {
|
|
3
2
|
channel_id: string;
|
|
4
3
|
participants: Array<{ kind: string; actor_id: string }>;
|
|
@@ -40,8 +39,8 @@ async function requestJson<T>(path: string, init: RequestInit, fetcher: PapyrusP
|
|
|
40
39
|
return payload as T;
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
/** Open the current editor's server-derived private channel using the
|
|
44
|
-
export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch =
|
|
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> {
|
|
45
44
|
const result = await requestJson<PapyrusPrivateChatChannel>("private-channel", { method: "POST" }, fetcher);
|
|
46
45
|
if (!result.channel_id?.trim() || !Array.isArray(result.participants)) {
|
|
47
46
|
throw new Error("Private chat returned an invalid channel.");
|
|
@@ -52,7 +51,7 @@ export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch =
|
|
|
52
51
|
/** Read only the private channel returned by the host's authenticated open request. */
|
|
53
52
|
export async function listPapyrusPrivateChatMessages(
|
|
54
53
|
channelId: string,
|
|
55
|
-
fetcher: PapyrusPrivateChatFetch =
|
|
54
|
+
fetcher: PapyrusPrivateChatFetch = globalThis.fetch,
|
|
56
55
|
): Promise<PapyrusPrivateChatMessage[]> {
|
|
57
56
|
if (!channelId.trim()) throw new Error("A private chat channel is required.");
|
|
58
57
|
const result = await requestJson<{ messages?: unknown }>(`channels/${encodeURIComponent(channelId)}/messages`, { method: "GET" }, fetcher);
|
|
@@ -65,7 +64,7 @@ export async function sendPapyrusPrivateChatMessage(
|
|
|
65
64
|
channelId: string,
|
|
66
65
|
body: string,
|
|
67
66
|
idempotencyKey: string,
|
|
68
|
-
fetcher: PapyrusPrivateChatFetch =
|
|
67
|
+
fetcher: PapyrusPrivateChatFetch = globalThis.fetch,
|
|
69
68
|
): Promise<PapyrusPrivateChatSendResult> {
|
|
70
69
|
if (!channelId.trim() || !body.trim() || !idempotencyKey.trim()) {
|
|
71
70
|
throw new Error("A channel, message and idempotency key are required.");
|
|
@@ -33,7 +33,6 @@ const MINIMUM_SEALING_SECRET_LENGTH = 32;
|
|
|
33
33
|
|
|
34
34
|
export interface PendingProspectusSignInCookie extends PendingProspectusSignIn {
|
|
35
35
|
returnPath: string;
|
|
36
|
-
newsroomSubject?: string;
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
export interface ProspectusSignedInSession {
|
|
@@ -41,7 +40,6 @@ export interface ProspectusSignedInSession {
|
|
|
41
40
|
accessToken: string;
|
|
42
41
|
accessTokenExpiresAt: number;
|
|
43
42
|
selectedAccountId?: string;
|
|
44
|
-
newsroomSubject?: string;
|
|
45
43
|
siteSubject?: string;
|
|
46
44
|
}
|
|
47
45
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { resolveNewsroomChatIdentity, type NewsroomChatIdentityResolver } from "./newsroom-chat-identity";
|
|
2
1
|
import { NextResponse, type NextRequest } from "next/server";
|
|
3
2
|
import {
|
|
4
3
|
CYCLOTRON_LAB_PRODUCT,
|
|
@@ -37,7 +36,6 @@ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfigured
|
|
|
37
36
|
* unregistered deployment never half-signs anyone in.
|
|
38
37
|
*/
|
|
39
38
|
export interface ProspectusSignInRouteOptions {
|
|
40
|
-
resolveNewsroomIdentity?: NewsroomChatIdentityResolver;
|
|
41
39
|
resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
|
|
42
40
|
siteProduct?: string | null;
|
|
43
41
|
completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
|
|
@@ -139,25 +137,12 @@ export function handleProspectusSignInStart(request: NextRequest, options: Prosp
|
|
|
139
137
|
async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
140
138
|
const runtime = await signInRuntimeFor(request, options);
|
|
141
139
|
if (!runtime) return signInUnavailableResponse();
|
|
142
|
-
const newsroomConnection = request.nextUrl.searchParams.get("newsroomChat") === "true";
|
|
143
|
-
let newsroomSubject: string | undefined;
|
|
144
|
-
if (newsroomConnection) {
|
|
145
|
-
if (request.method !== "POST" || request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
146
|
-
return signInFailedResponse(403, "same_origin_required", "Connect chat from this newsroom.");
|
|
147
|
-
}
|
|
148
|
-
const identity = await (options.resolveNewsroomIdentity ?? resolveNewsroomChatIdentity)(request).catch(() => null);
|
|
149
|
-
if (!identity) return signInFailedResponse(401, "sign_in_required", "An authenticated newsroom editor is required.");
|
|
150
|
-
newsroomSubject = identity.subject;
|
|
151
|
-
}
|
|
152
140
|
const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
|
|
153
141
|
const pendingSignInCookie: PendingProspectusSignInCookie = {
|
|
154
142
|
...pendingSignIn,
|
|
155
|
-
|
|
156
|
-
returnPath: sameSiteReturnPath(newsroomConnection ? request.nextUrl.searchParams.get("returnTo") : await requestedReturnPath(request)),
|
|
143
|
+
returnPath: sameSiteReturnPath(await requestedReturnPath(request)),
|
|
157
144
|
};
|
|
158
|
-
const response =
|
|
159
|
-
? NextResponse.json({ authorizationUrl }, { headers: NO_STORE_HEADERS })
|
|
160
|
-
: NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
|
|
145
|
+
const response = NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
|
|
161
146
|
response.cookies.set(
|
|
162
147
|
PROSPECTUS_PENDING_SIGN_IN_COOKIE,
|
|
163
148
|
sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_PENDING_SIGN_IN_COOKIE, pendingSignInCookie, pendingSignIn.expiresAt),
|
|
@@ -209,13 +194,11 @@ async function completeCallback(
|
|
|
209
194
|
pendingSignIn,
|
|
210
195
|
returnedState,
|
|
211
196
|
authorizationCode,
|
|
212
|
-
expectedNewsroomSubject: pendingSignIn.newsroomSubject,
|
|
213
197
|
nowInSeconds,
|
|
214
198
|
...options.completionOverrides,
|
|
215
199
|
});
|
|
216
200
|
const session: ProspectusSignedInSession = {
|
|
217
201
|
personId: signedIn.person.id,
|
|
218
|
-
...(pendingSignIn.newsroomSubject ? { newsroomSubject: pendingSignIn.newsroomSubject } : {}),
|
|
219
202
|
accessToken: signedIn.accessToken,
|
|
220
203
|
accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
|
|
221
204
|
...(signedIn.person.siteSubject ? { siteSubject: signedIn.person.siteSubject } : {}),
|
|
@@ -281,7 +281,6 @@ export interface CompleteProspectusGoogleSignInInput {
|
|
|
281
281
|
pendingSignIn: PendingProspectusSignIn;
|
|
282
282
|
returnedState: string;
|
|
283
283
|
authorizationCode: string;
|
|
284
|
-
expectedNewsroomSubject?: string;
|
|
285
284
|
verifyCognitoTokens?: CognitoTokenVerifier;
|
|
286
285
|
fetcher?: typeof fetch;
|
|
287
286
|
nowInSeconds?: number;
|
|
@@ -321,12 +320,6 @@ export async function completeProspectusGoogleSignIn(
|
|
|
321
320
|
expiresInSeconds: Number(tokenBody.expires_in) || 0,
|
|
322
321
|
};
|
|
323
322
|
await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
|
|
324
|
-
if (input.expectedNewsroomSubject) {
|
|
325
|
-
const claims = JSON.parse(Buffer.from(tokens.accessToken.split(".")[1], "base64url").toString("utf8")) as { sub?: string };
|
|
326
|
-
if (claims.sub !== input.expectedNewsroomSubject) {
|
|
327
|
-
throw new ProspectusAccountsError(403, "newsroom_identity_mismatch", "Continue with the same Google account as your newsroom session.");
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
323
|
const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn, tokens.accessToken, tokens.idToken);
|
|
331
324
|
return {
|
|
332
325
|
person,
|
package/package.json
CHANGED
|
@@ -257,8 +257,6 @@
|
|
|
257
257
|
"./lib/newsroom-board": "./lib/newsroom-board.ts",
|
|
258
258
|
"./lib/newsroom-card-layout": "./lib/newsroom-card-layout.ts",
|
|
259
259
|
"./lib/newsroom-category-drilldown": "./lib/newsroom-category-drilldown.ts",
|
|
260
|
-
"./lib/newsroom-chat-client": "./lib/newsroom-chat-client.ts",
|
|
261
|
-
"./lib/newsroom-chat-identity": "./lib/newsroom-chat-identity.ts",
|
|
262
260
|
"./lib/newsroom-demo-articles": "./lib/newsroom-demo-articles.ts",
|
|
263
261
|
"./lib/newsroom-demo-dashboard": "./lib/newsroom-demo-dashboard.ts",
|
|
264
262
|
"./lib/newsroom-demo-profile": "./lib/newsroom-demo-profile.ts",
|
|
@@ -458,7 +456,7 @@
|
|
|
458
456
|
"optional": true
|
|
459
457
|
}
|
|
460
458
|
},
|
|
461
|
-
"version": "5.
|
|
459
|
+
"version": "5.42.1",
|
|
462
460
|
"dependencies": {
|
|
463
461
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
464
462
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED
package/src/with-papyrus.d.mts
CHANGED
package/src/with-papyrus.mjs
CHANGED
|
@@ -56,6 +56,18 @@ export function mergeTranspilePackages(existing = []) {
|
|
|
56
56
|
return [...new Set([...existing, ...DEFAULT_TRANSPILE_PACKAGES])];
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* chatticus's prebuilt chat runtime is written against zod 4 (`z.looseObject`) and imports bare `zod`.
|
|
61
|
+
* Publication repos pin zod 3.25 through a root `overrides` entry (Amplify constructs need it), which
|
|
62
|
+
* also flattens chatticus's own zod 4 dependency, so the bundle then fails to build. zod 3.25 ships the
|
|
63
|
+
* zod 4 API at `zod/v4`; point only chatticus's `zod` imports there, leaving every other zod import alone.
|
|
64
|
+
*/
|
|
65
|
+
export const CHATTICUS_PACKAGE_PATH = /[\\/]node_modules[\\/]chatticus[\\/]/;
|
|
66
|
+
|
|
67
|
+
export function redirectChatticusZod(resource) {
|
|
68
|
+
if (resource.request === "zod" && CHATTICUS_PACKAGE_PATH.test(resource.context ?? "")) resource.request = "zod/v4";
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
export const RUNTIME_TRACED_FILES = ["./corpora/**/*", "./amplify_outputs.json"];
|
|
60
72
|
|
|
61
73
|
export function mergeOutputFileTracingIncludes(existing = {}) {
|
|
@@ -99,6 +111,10 @@ export function withPapyrus(nextConfig = {}, options = {}) {
|
|
|
99
111
|
},
|
|
100
112
|
webpack(config, context) {
|
|
101
113
|
config.resolve.alias = { ...config.resolve.alias, ...aliases };
|
|
114
|
+
config.plugins = [
|
|
115
|
+
...(config.plugins ?? []),
|
|
116
|
+
new context.webpack.NormalModuleReplacementPlugin(/^zod$/, redirectChatticusZod),
|
|
117
|
+
];
|
|
102
118
|
return typeof nextConfig.webpack === "function" ? nextConfig.webpack(config, context) : config;
|
|
103
119
|
},
|
|
104
120
|
};
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
export type NewsroomChatFetch = typeof fetch;
|
|
2
|
-
|
|
3
|
-
export function createNewsroomChatFetch(
|
|
4
|
-
accessToken: () => Promise<string | null>,
|
|
5
|
-
fetcher: NewsroomChatFetch,
|
|
6
|
-
): NewsroomChatFetch {
|
|
7
|
-
return async (input, init) => {
|
|
8
|
-
if (typeof input !== "string" || (!input.startsWith("/api/newsroom/chat/") && !input.startsWith("/api/auth/prospectus/"))) {
|
|
9
|
-
throw new Error("Newsroom credentials can only be sent to this site's chat identity routes.");
|
|
10
|
-
}
|
|
11
|
-
const token = await accessToken();
|
|
12
|
-
if (!token) throw new Error("sign_in_required");
|
|
13
|
-
const headers = new Headers(init?.headers);
|
|
14
|
-
headers.set("authorization", `Bearer ${token}`);
|
|
15
|
-
return fetcher(input, { ...init, headers, credentials: "same-origin", cache: "no-store" });
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const newsroomChatFetch: NewsroomChatFetch = createNewsroomChatFetch(async () => {
|
|
20
|
-
const { fetchAuthSession } = await import("aws-amplify/auth");
|
|
21
|
-
const session = await fetchAuthSession();
|
|
22
|
-
return session.tokens?.accessToken?.toString() ?? null;
|
|
23
|
-
}, (input, init) => globalThis.fetch(input, init));
|
|
24
|
-
|
|
25
|
-
export async function connectNewsroomChat(returnPath: string, fetcher: NewsroomChatFetch = newsroomChatFetch): Promise<string> {
|
|
26
|
-
const response = await fetcher(`/api/auth/prospectus/sign-in?newsroomChat=true&returnTo=${encodeURIComponent(returnPath)}`, { method: "POST" });
|
|
27
|
-
const body = await response.json() as { authorizationUrl?: string; message?: string };
|
|
28
|
-
if (!response.ok || !body.authorizationUrl) throw new Error(body.message ?? "Private chat could not connect to your publishing account.");
|
|
29
|
-
return body.authorizationUrl;
|
|
30
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { CognitoJwtVerifier } from "aws-jwt-verify";
|
|
2
|
-
import type { JwksCache } from "aws-jwt-verify/jwk";
|
|
3
|
-
import type { NextRequest } from "next/server";
|
|
4
|
-
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
5
|
-
|
|
6
|
-
export type NewsroomChatIdentity = { subject: string };
|
|
7
|
-
export type NewsroomChatIdentityResolver = (request: NextRequest) => Promise<NewsroomChatIdentity | null>;
|
|
8
|
-
|
|
9
|
-
export function newsroomChatIdentityResolver(
|
|
10
|
-
auth: { user_pool_id: string; user_pool_client_id: string },
|
|
11
|
-
jwksCache?: JwksCache,
|
|
12
|
-
): NewsroomChatIdentityResolver {
|
|
13
|
-
const verifier = CognitoJwtVerifier.create({
|
|
14
|
-
userPoolId: auth.user_pool_id,
|
|
15
|
-
clientId: auth.user_pool_client_id,
|
|
16
|
-
tokenUse: "access",
|
|
17
|
-
groups: ["editor", "admin"],
|
|
18
|
-
}, jwksCache ? { jwksCache } : undefined);
|
|
19
|
-
return async (request) => {
|
|
20
|
-
const token = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1];
|
|
21
|
-
if (!token) return null;
|
|
22
|
-
try {
|
|
23
|
-
const claims = await verifier.verify(token);
|
|
24
|
-
return { subject: claims.sub };
|
|
25
|
-
} catch {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let configuredResolver: { key: string; resolve: NewsroomChatIdentityResolver } | undefined;
|
|
32
|
-
|
|
33
|
-
export async function resolveNewsroomChatIdentity(request: NextRequest): Promise<NewsroomChatIdentity | null> {
|
|
34
|
-
const auth = readAmplifyOutputsFile()?.auth as { user_pool_id?: string; user_pool_client_id?: string } | undefined;
|
|
35
|
-
if (!auth?.user_pool_id || !auth.user_pool_client_id) throw new Error("Newsroom identity configuration is unavailable");
|
|
36
|
-
const key = `${auth.user_pool_id}:${auth.user_pool_client_id}`;
|
|
37
|
-
if (configuredResolver?.key !== key) configuredResolver = { key, resolve: newsroomChatIdentityResolver({ user_pool_id: auth.user_pool_id, user_pool_client_id: auth.user_pool_client_id }) };
|
|
38
|
-
return configuredResolver.resolve(request);
|
|
39
|
-
}
|