@anthusai/papyrus 5.42.1 → 5.43.0-next.10

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.
Files changed (83) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/category-action/handler.ts +2 -1
  4. package/amplify/functions/monitoring-view/schema.js +22 -0
  5. package/amplify/functions/monitoring-view/schema.ts +22 -0
  6. package/amplify/functions/monitoring-view/wire.js +1 -0
  7. package/amplify/functions/monitoring-view/wire.ts +1 -0
  8. package/amplify/functions/procedure-action/handler.ts +2 -10
  9. package/amplify/functions/proposal-actions/handler.py +137 -0
  10. package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +20 -3
  11. package/amplify/functions/proposal-actions/resource.js +15 -0
  12. package/amplify/functions/proposal-actions/resource.ts +22 -0
  13. package/amplify/functions/proposal-actions/schema.js +106 -0
  14. package/amplify/functions/proposal-actions/schema.ts +108 -0
  15. package/amplify/functions/proposal-actions/wire.js +10 -0
  16. package/amplify/functions/proposal-actions/wire.ts +13 -0
  17. package/amplify/plugins/reader-steering/function/handler.py +13 -72
  18. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  19. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  20. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  21. package/amplify/plugins/reader-steering/schema.js +0 -95
  22. package/amplify/plugins/reader-steering/schema.ts +0 -95
  23. package/amplify/site-backend.js +4 -0
  24. package/amplify/site-backend.ts +4 -0
  25. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  26. package/app/globals.css +31 -35
  27. package/app/tailwind.css +2 -0
  28. package/bin/papyrus-infra.mjs +21 -1
  29. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  30. package/components/monitoring-overview-view.tsx +140 -23
  31. package/components/monitoring-panel.tsx +96 -6
  32. package/components/news-desk-client-provider.tsx +8 -2
  33. package/components/news-desk-taxonomy-client.ts +2 -1
  34. package/components/newsroom-mobile-navigation.tsx +23 -0
  35. package/components/newsroom-ops-overview.tsx +6 -1
  36. package/components/newsroom-ops-shell.tsx +8 -10
  37. package/components/newsroom-organizer.css +1 -0
  38. package/components/newsroom-references-view.tsx +139 -257
  39. package/components/notification-center.tsx +345 -63
  40. package/components/papyrus-console-frame.tsx +66 -0
  41. package/components/papyrus-console-shell.tsx +14 -42
  42. package/components/papyrus-private-chat-panel.tsx +50 -16
  43. package/components/proposals-panel.tsx +107 -0
  44. package/components/proposals-view.tsx +95 -0
  45. package/components/reference-review-card.tsx +400 -0
  46. package/components/topic-steering-workspace.tsx +13 -2
  47. package/components/use-console-visibility.ts +47 -0
  48. package/infra/amplify-app-shell.js +18 -2
  49. package/infra/amplify-app-shell.ts +18 -2
  50. package/infra/chat-stack.js +222 -0
  51. package/infra/chat-stack.ts +250 -0
  52. package/infra/chat.test.js +142 -0
  53. package/infra/chat.test.ts +151 -0
  54. package/infra/index.js +1 -0
  55. package/infra/site-config.js +58 -1
  56. package/infra/site-config.ts +78 -1
  57. package/lib/cli-login-discovery.ts +78 -0
  58. package/lib/monitoring-client.ts +39 -3
  59. package/lib/monitoring-overview-fixture.ts +2 -2
  60. package/lib/monitoring-overview.ts +47 -3
  61. package/lib/monitoring-paging.ts +58 -0
  62. package/lib/newsroom-chat-client.ts +30 -0
  63. package/lib/newsroom-chat-identity.ts +40 -0
  64. package/lib/newsroom-organizer-nav.ts +1 -0
  65. package/lib/papyrus-hosted-chat.ts +62 -80
  66. package/lib/papyrus-private-chat-client.ts +7 -4
  67. package/lib/proposals-admin.ts +115 -0
  68. package/lib/proposals-client.ts +29 -0
  69. package/lib/prospectus-session.ts +2 -0
  70. package/lib/prospectus-sign-in-routes.ts +19 -2
  71. package/lib/prospectus-site-identity.ts +7 -0
  72. package/lib/reader-subject.ts +3 -2
  73. package/lib/relevance-decisions.ts +9 -0
  74. package/lib/review-client.ts +38 -1
  75. package/lib/review-drafts.ts +231 -0
  76. package/lib/review-fixture.ts +36 -2
  77. package/lib/review-requests.ts +66 -24
  78. package/lib/staff-groups.ts +23 -0
  79. package/lib/staging-gate.ts +2 -2
  80. package/package.json +16 -1
  81. package/routes.manifest.json +12 -1
  82. package/src/sync.mjs +11 -2
  83. package/src/with-papyrus.mjs +1 -1
@@ -37,9 +37,39 @@ export type MonitoringEmailSummary = {
37
37
  receiptId: string; observedAt: string | null; status: string; subject: string | null;
38
38
  envelopeSender: string | null; headerFrom: string | null; warnings: string[];
39
39
  verdicts: Record<string, string>; linkCount: number; processedCount: number; unresolvedLinkCount: number;
40
- reviewRequired: boolean; errorCode: string | null;
40
+ reviewRequired: boolean; errorCode: string | null; quarantine: EmailQuarantine | null;
41
41
  };
42
42
 
43
+ /** Where a held email submission stands. `null` means it never needed review. */
44
+ export type EmailQuarantineState =
45
+ | "quarantined" | "release_requested" | "released" | "discard_requested" | "discarded"
46
+ | "expired" | "decision_failed" | "release_failed";
47
+ export type EmailQuarantine = {
48
+ state: EmailQuarantineState;
49
+ decision: { action: "accept" | "reject"; by: string; at: string | null; reason: string } | null;
50
+ failure: string | null;
51
+ };
52
+ export type EmailDecisionResult =
53
+ | { ok: true; already: boolean; quarantine: EmailQuarantine }
54
+ | { error: "forbidden" | "bad_request" | "already_decided" | "not_quarantined" | "expired"; quarantine?: EmailQuarantine }
55
+ | { state: "unavailable" | "not_configured" };
56
+
57
+ export const QUARANTINE_LABELS: Record<EmailQuarantineState, string> = {
58
+ quarantined: "Held for review",
59
+ release_requested: "Accepted, being queued",
60
+ released: "Accepted",
61
+ discard_requested: "Rejected, being closed",
62
+ discarded: "Rejected",
63
+ expired: "Expired",
64
+ decision_failed: "Decision could not be applied",
65
+ release_failed: "Accepted, retrying",
66
+ };
67
+
68
+ /** Links and import are available only once a message is accepted (or never needed review). */
69
+ export function emailSubmissionAccepted(quarantine: EmailQuarantine | null): boolean {
70
+ return quarantine === null || quarantine.state === "released";
71
+ }
72
+
43
73
  export type MonitoringEmailDetails = {
44
74
  submission: MonitoringEmailSummary & { corpusId: string | null }; safeBodyText: string;
45
75
  candidateLinks: { url: string; title: string; resolutionStatus?: "unresolved" }[]; sourceMaterialIsUntrusted: true;
@@ -47,6 +77,8 @@ export type MonitoringEmailDetails = {
47
77
 
48
78
  export type MonitoringEmailArticleResolution = {
49
79
  url: string; title: string; resolved: true;
80
+ /** Present when the original link could not be followed and the destination was found by searching the headline. */
81
+ foundVia?: "search"; search?: { query: string; matchScore: number; provider: string; matchedTitle?: string };
50
82
  } | { error: "forbidden" | "bad_request" | "not_resolvable" | "resolution_failed"; reason?: string } | { state: "unavailable" };
51
83
 
52
84
  export type EmailReferenceImportItem = { candidateIndex: number; source_uri: string };
@@ -106,13 +138,23 @@ export type MonitoringFeed = {
106
138
  id: string;
107
139
  sourceUrl: string | null;
108
140
  intervalSeconds: number | null;
109
- limit: number | null;
110
141
  enabled: boolean;
111
142
  lastStatus: string | null;
112
143
  lastFinishedAt: string | null;
113
144
  nextDueAt: string | null;
114
145
  };
115
146
 
147
+ /** The lists on the page that are paged. Page sizes are the server's; there is no total. */
148
+ export type MonitoringListName = "runs" | "routerRuns" | "items" | "emailSubmissions";
149
+ export type MonitoringCursors = Partial<Record<MonitoringListName, string | null>>;
150
+ export type MonitoringListRows = {
151
+ runs: MonitoringRun; routerRuns: MonitoringRouterRun; items: MonitoringItem; emailSubmissions: MonitoringEmailSummary;
152
+ };
153
+ /** One more page of one list. `cursor` is null when the list has ended. */
154
+ export type MonitoringPage<L extends MonitoringListName = MonitoringListName> =
155
+ | { list: L; rows: MonitoringListRows[L][]; cursor: string | null; hiddenNotRelevant?: number }
156
+ | { error: "forbidden" | "bad_request" } | { state: "unavailable" | "not_configured" };
157
+
116
158
  export type MonitoringOverview = {
117
159
  state: "ready" | "empty";
118
160
  asOf: string;
@@ -121,7 +163,7 @@ export type MonitoringOverview = {
121
163
  feeds: MonitoringFeed[];
122
164
  runs: MonitoringRun[];
123
165
  routerRuns: MonitoringRouterRun[];
124
- router: { mode: string; feedback: boolean; enabled: boolean; maxItemsPerRun: number | null } | null;
166
+ router: { mode: string; feedback: boolean; enabled: boolean } | null;
125
167
  usage: {
126
168
  day: string;
127
169
  decisionsToday: number;
@@ -133,6 +175,8 @@ export type MonitoringOverview = {
133
175
  otherMonitors?: string[];
134
176
  emailSubmissions?: MonitoringEmailSummary[];
135
177
  hiddenNotRelevant: number;
178
+ /** Where each list continues; null or missing when it has no more rows. */
179
+ cursors?: MonitoringCursors;
136
180
  };
137
181
 
138
182
  export type MonitoringResult =
@@ -0,0 +1,58 @@
1
+ /** Merging loaded pages into the Monitoring lists. Kept free of other imports so the panel's tests can load it alone. */
2
+ import type {
3
+ MonitoringCursors,
4
+ MonitoringItem,
5
+ MonitoringListName,
6
+ MonitoringListRows,
7
+ MonitoringOverview,
8
+ } from "./monitoring-overview";
9
+
10
+ /** Rows the editor has loaded beyond the first page of each list, and where each list continues. */
11
+ export type MonitoringLoadedPages = Partial<{ [L in MonitoringListName]: { rows: MonitoringListRows[L][]; cursor: string | null } }>;
12
+
13
+ const ROW_KEYS: { [L in MonitoringListName]: (row: MonitoringListRows[L]) => string } = {
14
+ runs: (row) => `${row.runId}`,
15
+ routerRuns: (row) => `${row.runId}`,
16
+ items: (row) => `${row.monitorId ?? ""}|${row.sourceId}`,
17
+ emailSubmissions: (row) => row.receiptId,
18
+ };
19
+
20
+ /**
21
+ * The first page followed by loaded pages, with a row seen twice kept once. The refresh re-reads the first page, so
22
+ * a row that moved onto it from a later page must not be listed again; for items the highest revision wins.
23
+ */
24
+ export function mergeListPages<L extends MonitoringListName>(list: L, first: MonitoringListRows[L][], loaded: MonitoringListRows[L][]): MonitoringListRows[L][] {
25
+ const keyOf = ROW_KEYS[list] as (row: MonitoringListRows[L]) => string;
26
+ const seen = new Map<string, number>();
27
+ const merged: MonitoringListRows[L][] = [];
28
+ for (const row of [...first, ...loaded]) {
29
+ const key = keyOf(row);
30
+ const at = seen.get(key);
31
+ if (at === undefined) {
32
+ seen.set(key, merged.length);
33
+ merged.push(row);
34
+ } else if (list === "items" && (row as MonitoringItem).revision > (merged[at] as MonitoringItem).revision) {
35
+ merged[at] = row;
36
+ }
37
+ }
38
+ return merged;
39
+ }
40
+
41
+ /**
42
+ * What the page shows: the refreshed first page of every list plus whatever the editor loaded, with each list's
43
+ * cursor. With `held` the email list is replaced by the held-for-review list (its own pages).
44
+ */
45
+ export function withLoadedPages(overview: MonitoringOverview, loaded: MonitoringLoadedPages, held: MonitoringLoadedPages["emailSubmissions"] | null): MonitoringOverview {
46
+ const cursors: MonitoringCursors = {};
47
+ const lists: Partial<Record<MonitoringListName, unknown[]>> = {};
48
+ for (const list of ["runs", "routerRuns", "items", "emailSubmissions"] as const) {
49
+ const extra = loaded[list];
50
+ lists[list] = mergeListPages(list, (overview[list] ?? []) as never[], (extra?.rows ?? []) as never[]) as unknown[];
51
+ cursors[list] = extra ? extra.cursor : overview.cursors?.[list] ?? null;
52
+ }
53
+ if (held) {
54
+ lists.emailSubmissions = held.rows;
55
+ cursors.emailSubmissions = held.cursor;
56
+ }
57
+ return { ...overview, ...(lists as Pick<MonitoringOverview, MonitoringListName>), cursors };
58
+ }
@@ -0,0 +1,30 @@
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
+ }
@@ -0,0 +1,40 @@
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
+ import { STAFF_GROUPS } from "./staff-groups";
6
+
7
+ export type NewsroomChatIdentity = { subject: string; issuer: string; clientId: string; expiresAt: number };
8
+ export type NewsroomChatIdentityResolver = (request: NextRequest) => Promise<NewsroomChatIdentity | null>;
9
+
10
+ export function newsroomChatIdentityResolver(
11
+ auth: { user_pool_id: string; user_pool_client_id: string },
12
+ jwksCache?: JwksCache,
13
+ ): NewsroomChatIdentityResolver {
14
+ const verifier = CognitoJwtVerifier.create({
15
+ userPoolId: auth.user_pool_id,
16
+ clientId: auth.user_pool_client_id,
17
+ tokenUse: "access",
18
+ groups: [...STAFF_GROUPS],
19
+ }, jwksCache ? { jwksCache } : undefined);
20
+ return async (request) => {
21
+ const token = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1];
22
+ if (!token) return null;
23
+ try {
24
+ const claims = await verifier.verify(token);
25
+ return { subject: claims.sub, issuer: claims.iss, clientId: auth.user_pool_client_id, expiresAt: claims.exp };
26
+ } catch {
27
+ return null;
28
+ }
29
+ };
30
+ }
31
+
32
+ let configuredResolver: { key: string; resolve: NewsroomChatIdentityResolver } | undefined;
33
+
34
+ export async function resolveNewsroomChatIdentity(request: NextRequest): Promise<NewsroomChatIdentity | null> {
35
+ const auth = readAmplifyOutputsFile()?.auth as { user_pool_id?: string; user_pool_client_id?: string } | undefined;
36
+ if (!auth?.user_pool_id || !auth.user_pool_client_id) throw new Error("Newsroom identity configuration is unavailable");
37
+ const key = `${auth.user_pool_id}:${auth.user_pool_client_id}`;
38
+ if (configuredResolver?.key !== key) configuredResolver = { key, resolve: newsroomChatIdentityResolver({ user_pool_id: auth.user_pool_id, user_pool_client_id: auth.user_pool_client_id }) };
39
+ return configuredResolver.resolve(request);
40
+ }
@@ -11,6 +11,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
11
11
  { key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
12
12
  { key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
13
13
  { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
14
+ { key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },
14
15
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
15
16
  ] },
16
17
  { title: "Research & knowledge", items: [
@@ -1,51 +1,36 @@
1
+ import { resolveNewsroomChatIdentity, type NewsroomChatIdentityResolver } from "./newsroom-chat-identity";
1
2
  import { createHash } from "node:crypto";
2
3
  import type { EmbeddedChatAppDependencies } from "chatticus/chat-server";
3
4
  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
5
  import { publicSiteOrigin } from "./site-origin-value";
15
6
 
16
7
  const NO_STORE_HEADERS = { "cache-control": "no-store" };
17
8
 
18
9
  export interface PapyrusChatScope {
19
- siteProduct: string;
20
- accountId: string;
21
- personId: string;
22
10
  tenantId: string;
23
11
  editorId: string;
24
12
  }
25
13
 
26
14
  export interface PapyrusHostedChatOptions {
27
- resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
15
+ resolveNewsroomIdentity?: NewsroomChatIdentityResolver;
28
16
  resolvePrivateDependencies?: () => Promise<EmbeddedChatAppDependencies | null>;
29
17
  privateChatEnabled?: boolean;
30
- siteProduct?: string | null;
31
18
  environment?: Record<string, string | undefined>;
32
19
  nowInSeconds?: () => number;
33
20
  }
34
21
 
35
- function siteAndAccountKey(siteProduct: string, accountId: string): string {
36
- return createHash("sha256").update(JSON.stringify([siteProduct, accountId])).digest("hex");
22
+ /** A new namespace: never aliases Account-scoped or legacy transcripts. */
23
+ export function derivePapyrusChatScope(publicationNamespace: string, issuer: string, subject: string): PapyrusChatScope {
24
+ const tenantHash = createHash("sha256").update(JSON.stringify(["newsroom-v1", publicationNamespace, issuer])).digest("hex");
25
+ const editorHash = createHash("sha256").update(JSON.stringify(["newsroom-v1", publicationNamespace, issuer, subject])).digest("hex");
26
+ return { tenantId: `papyrus-newsroom-v1-${tenantHash.slice(0, 40)}`, editorId: `papyrus-newsroom-editor-v1-${editorHash.slice(0, 40)}` };
37
27
  }
38
28
 
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
- };
29
+ /** Existing server storage resources bind the publication, independently of request hosts or claims. */
30
+ function publicationNamespace(environment: Record<string, string | undefined>, clientId: string): string | null {
31
+ const bucket = environment.PAPYRUS_CHAT_SESSION_BUCKET?.trim();
32
+ const table = environment.PAPYRUS_CHAT_MESSAGING_TABLE?.trim();
33
+ return bucket && table ? JSON.stringify([bucket, table, clientId]) : null;
49
34
  }
50
35
 
51
36
  type ResolvedPapyrusPrincipal = {
@@ -59,60 +44,24 @@ type ResolvedPapyrusPrincipal = {
59
44
 
60
45
  async function principalForRequest(
61
46
  request: NextRequest,
62
- operation: "console:read" | "console:write",
63
47
  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;
48
+ ): Promise<{ principal: ResolvedPapyrusPrincipal | null; status: number; expiresAt?: number }> {
49
+ let identity;
70
50
  try {
71
- runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(publicSiteOrigin(request, environment));
51
+ identity = await (options.resolveNewsroomIdentity ?? resolveNewsroomChatIdentity)(request);
72
52
  } catch {
73
53
  return { principal: null, status: 503 };
74
54
  }
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
- }
55
+ const now = options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
56
+ if (!identity || !identity.subject || !identity.issuer || !identity.clientId || !Number.isFinite(identity.expiresAt) || identity.expiresAt <= now) return { principal: null, status: 401 };
57
+ const namespace = publicationNamespace(options.environment ?? process.env, identity.clientId);
58
+ if (!namespace) return { principal: null, status: 503 };
59
+ const scope = derivePapyrusChatScope(namespace, identity.issuer, identity.subject);
60
+ return {
61
+ principal: { kind: "user", tenantId: scope.tenantId, userId: scope.editorId, workerId: null, organizationStatus: "enabled", role: "member" },
62
+ status: 200,
63
+ expiresAt: identity.expiresAt,
64
+ };
116
65
  }
117
66
 
118
67
  function jsonFailure(status: number, error: string): Response {
@@ -162,6 +111,9 @@ export async function handlePapyrusHostedChatRequest(
162
111
  const privateChatEnabled = options.privateChatEnabled ?? environment.PAPYRUS_PRIVATE_CHAT_ENABLED === "true";
163
112
  if (!privateChatEnabled) return jsonFailure(404, "private_chat_disabled");
164
113
 
114
+ const authorization = await principalForRequest(request, options);
115
+ if (!authorization.principal) return jsonFailure(authorization.status, (authorization.status === 401 ? "sign_in_required" : authorization.status === 403 ? "chat_account_access_denied" : "chat_runtime_unavailable"));
116
+
165
117
  let dependencies: EmbeddedChatAppDependencies | null;
166
118
  try {
167
119
  if (options.resolvePrivateDependencies) {
@@ -178,9 +130,6 @@ export async function handlePapyrusHostedChatRequest(
178
130
  }
179
131
  if (!dependencies) return jsonFailure(503, "private_chat_storage_unavailable");
180
132
 
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
133
  const headers = new Headers({ accept: request.headers.get("accept") ?? "application/json" });
185
134
  let body: string | undefined;
186
135
  if (request.method === "POST" && isMessageRoute) {
@@ -213,5 +162,38 @@ export async function handlePapyrusHostedChatRequest(
213
162
  ...dependencies,
214
163
  resolvePrincipal: async () => authorization.principal,
215
164
  });
216
- return app.fetch(forwarded);
165
+ const response = await app.fetch(forwarded);
166
+ if (path[0] !== "turns" || path[2] !== "stream" || !response.ok || !response.body) return response;
167
+ const remaining = (authorization.expiresAt! - (options.nowInSeconds?.() ?? Date.now() / 1000)) * 1000;
168
+ return expireChatStream(response, remaining);
169
+ }
170
+
171
+ /** End an established stream at JWT expiry; reconnect must pass current authorization again. */
172
+ export function expireChatStream(response: Response, remainingMilliseconds: number): Response {
173
+ if (!response.body) return response;
174
+ const reader = response.body.getReader();
175
+ let closed = false;
176
+ let timer: ReturnType<typeof setTimeout>;
177
+ const body = new ReadableStream<Uint8Array>({
178
+ start(controller) {
179
+ timer = setTimeout(() => {
180
+ if (closed) return;
181
+ closed = true;
182
+ controller.close();
183
+ void reader.cancel().catch(() => {});
184
+ }, Math.max(0, Math.min(remainingMilliseconds, 2_147_483_647)));
185
+ },
186
+ async pull(controller) {
187
+ try {
188
+ const next = await reader.read();
189
+ if (closed) return;
190
+ if (next.done) { closed = true; clearTimeout(timer); controller.close(); }
191
+ else controller.enqueue(next.value);
192
+ } catch (error) {
193
+ if (!closed) { closed = true; clearTimeout(timer); controller.error(error); }
194
+ }
195
+ },
196
+ async cancel(reason) { closed = true; clearTimeout(timer); await reader.cancel(reason); },
197
+ });
198
+ return new Response(body, { status: response.status, headers: response.headers });
217
199
  }
@@ -1,3 +1,4 @@
1
+ import { newsroomChatFetch } from "./newsroom-chat-client";
1
2
  export type PapyrusPrivateChatChannel = {
2
3
  channel_id: string;
3
4
  participants: Array<{ kind: string; actor_id: string }>;
@@ -30,6 +31,8 @@ async function requestJson<T>(path: string, init: RequestInit, fetcher: PapyrusP
30
31
  });
31
32
  const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
32
33
  if (!response.ok) {
34
+ if (response.status === 401) throw new Error("sign_in_required");
35
+ if (response.status === 403) throw new Error("chat_account_access_denied");
33
36
  const error = typeof payload?.error === "string" ? payload.error
34
37
  : typeof payload?.detail === "string" ? payload.detail
35
38
  : `Private chat request failed (${response.status}).`;
@@ -39,8 +42,8 @@ async function requestJson<T>(path: string, init: RequestInit, fetcher: PapyrusP
39
42
  return payload as T;
40
43
  }
41
44
 
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
+ /** Open the current editor's server-derived private channel using the verified publication-local newsroom identity. */
46
+ export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch = newsroomChatFetch): Promise<PapyrusPrivateChatChannel> {
44
47
  const result = await requestJson<PapyrusPrivateChatChannel>("private-channel", { method: "POST" }, fetcher);
45
48
  if (!result.channel_id?.trim() || !Array.isArray(result.participants)) {
46
49
  throw new Error("Private chat returned an invalid channel.");
@@ -51,7 +54,7 @@ export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch =
51
54
  /** Read only the private channel returned by the host's authenticated open request. */
52
55
  export async function listPapyrusPrivateChatMessages(
53
56
  channelId: string,
54
- fetcher: PapyrusPrivateChatFetch = globalThis.fetch,
57
+ fetcher: PapyrusPrivateChatFetch = newsroomChatFetch,
55
58
  ): Promise<PapyrusPrivateChatMessage[]> {
56
59
  if (!channelId.trim()) throw new Error("A private chat channel is required.");
57
60
  const result = await requestJson<{ messages?: unknown }>(`channels/${encodeURIComponent(channelId)}/messages`, { method: "GET" }, fetcher);
@@ -64,7 +67,7 @@ export async function sendPapyrusPrivateChatMessage(
64
67
  channelId: string,
65
68
  body: string,
66
69
  idempotencyKey: string,
67
- fetcher: PapyrusPrivateChatFetch = globalThis.fetch,
70
+ fetcher: PapyrusPrivateChatFetch = newsroomChatFetch,
68
71
  ): Promise<PapyrusPrivateChatSendResult> {
69
72
  if (!channelId.trim() || !body.trim() || !idempotencyKey.trim()) {
70
73
  throw new Error("A channel, message and idempotency key are required.");
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The data side of the Administration "Proposals" panel, which every site has (the queue is core; the reader-steering
3
+ * plugin only adds reader votes). Rows come from the staff-readable `Proposal` model; an editor moves one with the
4
+ * `transitionProposal` mutation. Pure functions, no network, so the rules the panel shows can be tested.
5
+ */
6
+
7
+ export type ProposalRow = {
8
+ id: string;
9
+ kind: string;
10
+ title: string;
11
+ summary?: string | null;
12
+ body?: string | null;
13
+ state: string;
14
+ proposer?: string | null;
15
+ held?: boolean | null;
16
+ pinned?: boolean | null;
17
+ createdAt: string;
18
+ };
19
+
20
+ export type ProposalFilter = "open" | "draft" | "in_steering" | "editor_review" | "decided";
21
+ /** A button: the states it moves through in order (one call each) or, for pin and hold, the `editProposal` action it runs. */
22
+ export type ProposalMove = { label: string; path?: string[]; edit?: "pin" | "unpin" | "hold" | "release"; state?: string };
23
+
24
+ const STATE_LABELS: Record<string, string> = {
25
+ draft: "Draft",
26
+ in_steering: "In steering",
27
+ editor_review: "Editor review",
28
+ approved: "Approved",
29
+ declined: "Declined",
30
+ overridden_by_editor: "Decided by an editor",
31
+ };
32
+
33
+ const KIND_LABELS: Record<string, string> = {
34
+ story_draft: "Story draft",
35
+ new_concept: "New concept",
36
+ rename: "Rename",
37
+ merge: "Merge",
38
+ split: "Split",
39
+ new_column: "New column",
40
+ element_symbol: "Element symbol",
41
+ };
42
+
43
+ const DECIDED = new Set(["approved", "declined", "overridden_by_editor"]);
44
+
45
+ /**
46
+ * What an editor may do next; the function enforces the same table (overriding the community needs a reason and is not offered
47
+ * here). On a site without reader-steering nobody steers, so a draft goes straight to editor review (two moves) and the
48
+ * "in steering" wording never shows.
49
+ */
50
+ function stateMoves(state: string, readers: boolean): ProposalMove[] {
51
+ if (state === "draft") {
52
+ return readers
53
+ ? [{ label: "Send to steering", path: ["in_steering"] }, { label: "Decline", path: ["declined"] }]
54
+ : [{ label: "Move to editor review", path: ["in_steering", "editor_review"] }, { label: "Decline", path: ["declined"] }];
55
+ }
56
+ if (state === "in_steering") return [{ label: "Move to editor review", path: ["editor_review"] }];
57
+ if (state === "editor_review") return [{ label: "Approve", path: ["approved"] }, { label: "Decline", path: ["declined"] }];
58
+ return [];
59
+ }
60
+
61
+ export const PROPOSAL_FIELDS = "id kind title summary body state proposer held pinned createdAt";
62
+ export const LIST_PROPOSALS = `query Proposals($nextToken: String) { listProposals(limit: 500, nextToken: $nextToken) { items { ${PROPOSAL_FIELDS} } nextToken } }`;
63
+ export const EDIT_PROPOSAL = `mutation Edit($proposalId: String!, $action: String!) { editProposal(proposalId: $proposalId, action: $action) { status id error message } }`;
64
+ export const TRANSITION_PROPOSAL = `mutation Move($proposalId: String!, $state: String!, $reason: String) { transitionProposal(proposalId: $proposalId, state: $state, reason: $reason) { status id error message } }`;
65
+
66
+ export function stateLabel(state: string, readers = true): string {
67
+ if (!readers && state === "in_steering") return "Waiting for review";
68
+ return STATE_LABELS[state] ?? state;
69
+ }
70
+
71
+ export function kindLabel(kind: string): string {
72
+ return KIND_LABELS[kind] ?? kind;
73
+ }
74
+
75
+ /** AI-filed proposals say "AI"; a reader's proposal says "Reader" (their id is never shown). */
76
+ export function proposerLabel(proposer: string | null | undefined): string {
77
+ return proposer === "ai" ? "AI" : proposer ? "Reader" : "Unknown";
78
+ }
79
+
80
+ export function proposalMoves(row: Pick<ProposalRow, "state" | "held" | "pinned">, readers = true): ProposalMove[] {
81
+ const edits: ProposalMove[] = [
82
+ { label: row.pinned ? "Unpin" : "Pin", edit: row.pinned ? "unpin" : "pin" },
83
+ ...(DECIDED.has(row.state) && !row.held ? [] : [{ label: row.held ? "Release hold" : "Hold", edit: (row.held ? "release" : "hold") as "release" | "hold" }]),
84
+ ];
85
+ // A proposal on hold refuses every state change until it is released.
86
+ return [...(row.held ? [] : stateMoves(row.state, readers)), ...edits];
87
+ }
88
+
89
+ export function sortProposals(rows: ProposalRow[]): ProposalRow[] {
90
+ return [...rows].sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id));
91
+ }
92
+
93
+ export function filterProposals(rows: ProposalRow[], filter: ProposalFilter): ProposalRow[] {
94
+ if (filter === "open") return rows.filter((row) => !DECIDED.has(row.state));
95
+ if (filter === "decided") return rows.filter((row) => DECIDED.has(row.state));
96
+ return rows.filter((row) => row.state === filter);
97
+ }
98
+
99
+ export function countProposals(rows: ProposalRow[]): Record<ProposalFilter, number> {
100
+ return {
101
+ open: filterProposals(rows, "open").length,
102
+ draft: filterProposals(rows, "draft").length,
103
+ in_steering: filterProposals(rows, "in_steering").length,
104
+ editor_review: filterProposals(rows, "editor_review").length,
105
+ decided: filterProposals(rows, "decided").length,
106
+ };
107
+ }
108
+
109
+ export type ProposalReceipt = { status?: string | null; id?: string | null; error?: string | null; message?: string | null };
110
+
111
+ export function proposalReceiptMessage(receipt: ProposalReceipt | null | undefined): string {
112
+ if (!receipt) return "The action did not answer.";
113
+ if (receipt.status === "ok") return "Done.";
114
+ return receipt.message || receipt.error || "The action was refused.";
115
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import { EDIT_PROPOSAL, TRANSITION_PROPOSAL, type ProposalMove, type ProposalReceipt } from "./proposals-admin";
5
+
6
+ type GraphResult = { data?: Record<string, ProposalReceipt | null>; errors?: { message?: string }[] };
7
+
8
+ async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
9
+ const client = generateClient({ authMode: "userPool" });
10
+ const result = (await client.graphql({ query, variables })) as GraphResult;
11
+ if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
12
+ return result.data?.[field] ?? { status: "refused", message: "The action did not answer." };
13
+ }
14
+
15
+ /** Moves a proposal through `path`, one `transitionProposal` call per state, stopping at the first refusal. */
16
+ export async function transitionProposalPath(proposalId: string, path: readonly string[], reason = ""): Promise<ProposalReceipt> {
17
+ let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
18
+ for (const state of path) {
19
+ receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
20
+ if (receipt.status !== "ok") break;
21
+ }
22
+ return receipt;
23
+ }
24
+
25
+ /** One button of the Proposals panel: a path of states, or `editProposal` for pin and hold. */
26
+ export async function performProposalMove(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
27
+ if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
28
+ return transitionProposalPath(proposalId, move.path ?? [], reason);
29
+ }
@@ -33,6 +33,7 @@ const MINIMUM_SEALING_SECRET_LENGTH = 32;
33
33
 
34
34
  export interface PendingProspectusSignInCookie extends PendingProspectusSignIn {
35
35
  returnPath: string;
36
+ newsroomSubject?: string;
36
37
  }
37
38
 
38
39
  export interface ProspectusSignedInSession {
@@ -40,6 +41,7 @@ export interface ProspectusSignedInSession {
40
41
  accessToken: string;
41
42
  accessTokenExpiresAt: number;
42
43
  selectedAccountId?: string;
44
+ newsroomSubject?: string;
43
45
  siteSubject?: string;
44
46
  }
45
47