@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.
@@ -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 handleFailure = useCallback(async (nextError: unknown) => {
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
- if (generation === identityGeneration.current) setMessages(next);
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, generation);
60
+ await refreshMessages(nextChannel.channel_id);
89
61
  } catch (nextError) {
90
- if (generation === identityGeneration.current) await handleFailure(nextError);
62
+ setError(failureMessage(nextError));
91
63
  } finally {
92
- if (generation === identityGeneration.current) setLoading(false);
64
+ setLoading(false);
93
65
  }
94
- }, [handleFailure, refreshMessages]);
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, generation);
78
+ await refreshMessages(channel.channel_id);
113
79
  } catch (nextError) {
114
- if (active && generation === identityGeneration.current) await handleFailure(nextError);
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, handleFailure, isWaitingForReply, refreshMessages]);
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
- setConnecting(false);
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
- {error === "chat_identity_connection_required" ? <>
180
- <strong>Connect your publishing account</strong>
181
- {connectionNotice ? <span>{connectionNotice}</span> : null}
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; error?: string }> {
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: 409, error: "chat_identity_connection_required" };
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 verified newsroom identity and connected account session. */
44
- export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch = newsroomChatFetch): Promise<PapyrusPrivateChatChannel> {
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 = newsroomChatFetch,
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 = newsroomChatFetch,
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
- ...(newsroomSubject ? { newsroomSubject } : {}),
156
- returnPath: sameSiteReturnPath(newsroomConnection ? request.nextUrl.searchParams.get("returnTo") : await requestedReturnPath(request)),
143
+ returnPath: sameSiteReturnPath(await requestedReturnPath(request)),
157
144
  };
158
- const response = newsroomConnection
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.41.2",
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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.41.2",
2
+ "version": "5.42.1",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -9,3 +9,4 @@ export function staticGenerationSettings(): {
9
9
  staticGenerationMaxConcurrency: number;
10
10
  staticGenerationRetryCount: number;
11
11
  };
12
+ export function redirectChatticusZod(resource: { request: string; context?: string }): void;
@@ -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
- }