@anthusai/papyrus 5.42.1 → 5.43.0-next.2

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 (57) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/monitoring-view/schema.js +6 -0
  4. package/amplify/functions/monitoring-view/schema.ts +6 -0
  5. package/amplify/functions/monitoring-view/wire.js +1 -0
  6. package/amplify/functions/monitoring-view/wire.ts +1 -0
  7. package/amplify/functions/proposal-actions/handler.py +137 -0
  8. package/amplify/functions/proposal-actions/resource.js +15 -0
  9. package/amplify/functions/proposal-actions/resource.ts +22 -0
  10. package/amplify/functions/proposal-actions/schema.js +106 -0
  11. package/amplify/functions/proposal-actions/schema.ts +108 -0
  12. package/amplify/functions/proposal-actions/wire.js +10 -0
  13. package/amplify/functions/proposal-actions/wire.ts +13 -0
  14. package/amplify/plugins/reader-steering/function/handler.py +12 -69
  15. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  16. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  17. package/amplify/plugins/reader-steering/schema.js +0 -95
  18. package/amplify/plugins/reader-steering/schema.ts +0 -95
  19. package/amplify/site-backend.js +4 -0
  20. package/amplify/site-backend.ts +4 -0
  21. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  22. package/app/globals.css +31 -35
  23. package/app/tailwind.css +2 -0
  24. package/components/monitoring-overview-view.tsx +12 -7
  25. package/components/monitoring-panel.tsx +33 -3
  26. package/components/newsroom-mobile-navigation.tsx +23 -0
  27. package/components/newsroom-ops-shell.tsx +8 -10
  28. package/components/newsroom-organizer.css +1 -0
  29. package/components/newsroom-references-view.tsx +141 -257
  30. package/components/notification-center.tsx +1 -1
  31. package/components/papyrus-console-frame.tsx +66 -0
  32. package/components/papyrus-console-shell.tsx +12 -41
  33. package/components/papyrus-private-chat-panel.tsx +50 -16
  34. package/components/proposals-panel.tsx +110 -0
  35. package/components/proposals-view.tsx +93 -0
  36. package/components/reference-review-card.tsx +383 -0
  37. package/components/topic-steering-workspace.tsx +13 -2
  38. package/components/use-console-visibility.ts +47 -0
  39. package/lib/cli-login-discovery.ts +78 -0
  40. package/lib/monitoring-client.ts +11 -2
  41. package/lib/monitoring-overview-fixture.ts +2 -2
  42. package/lib/monitoring-overview.ts +1 -2
  43. package/lib/newsroom-chat-client.ts +30 -0
  44. package/lib/newsroom-chat-identity.ts +39 -0
  45. package/lib/papyrus-hosted-chat.ts +62 -80
  46. package/lib/papyrus-private-chat-client.ts +7 -4
  47. package/lib/proposals-admin.ts +115 -0
  48. package/lib/prospectus-session.ts +2 -0
  49. package/lib/prospectus-sign-in-routes.ts +19 -2
  50. package/lib/prospectus-site-identity.ts +7 -0
  51. package/lib/relevance-decisions.ts +9 -0
  52. package/lib/review-requests.ts +4 -2
  53. package/package.json +12 -1
  54. package/routes.manifest.json +12 -1
  55. package/src/sync.mjs +11 -2
  56. package/src/with-papyrus.mjs +1 -1
  57. /package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +0 -0
@@ -0,0 +1,39 @@
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; issuer: string; clientId: string; expiresAt: number };
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, issuer: claims.iss, clientId: auth.user_pool_client_id, expiresAt: claims.exp };
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
+ }
@@ -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
+ }
@@ -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
 
@@ -1,3 +1,4 @@
1
+ import { resolveNewsroomChatIdentity, type NewsroomChatIdentityResolver } from "./newsroom-chat-identity";
1
2
  import { NextResponse, type NextRequest } from "next/server";
2
3
  import {
3
4
  CYCLOTRON_LAB_PRODUCT,
@@ -36,6 +37,7 @@ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfigured
36
37
  * unregistered deployment never half-signs anyone in.
37
38
  */
38
39
  export interface ProspectusSignInRouteOptions {
40
+ resolveNewsroomIdentity?: NewsroomChatIdentityResolver;
39
41
  resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
40
42
  siteProduct?: string | null;
41
43
  completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
@@ -137,12 +139,25 @@ export function handleProspectusSignInStart(request: NextRequest, options: Prosp
137
139
  async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
138
140
  const runtime = await signInRuntimeFor(request, options);
139
141
  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
+ }
140
152
  const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
141
153
  const pendingSignInCookie: PendingProspectusSignInCookie = {
142
154
  ...pendingSignIn,
143
- returnPath: sameSiteReturnPath(await requestedReturnPath(request)),
155
+ ...(newsroomSubject ? { newsroomSubject } : {}),
156
+ returnPath: sameSiteReturnPath(newsroomConnection ? request.nextUrl.searchParams.get("returnTo") : await requestedReturnPath(request)),
144
157
  };
145
- const response = NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
158
+ const response = newsroomConnection
159
+ ? NextResponse.json({ authorizationUrl }, { headers: NO_STORE_HEADERS })
160
+ : NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
146
161
  response.cookies.set(
147
162
  PROSPECTUS_PENDING_SIGN_IN_COOKIE,
148
163
  sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_PENDING_SIGN_IN_COOKIE, pendingSignInCookie, pendingSignIn.expiresAt),
@@ -194,11 +209,13 @@ async function completeCallback(
194
209
  pendingSignIn,
195
210
  returnedState,
196
211
  authorizationCode,
212
+ expectedNewsroomSubject: pendingSignIn.newsroomSubject,
197
213
  nowInSeconds,
198
214
  ...options.completionOverrides,
199
215
  });
200
216
  const session: ProspectusSignedInSession = {
201
217
  personId: signedIn.person.id,
218
+ ...(pendingSignIn.newsroomSubject ? { newsroomSubject: pendingSignIn.newsroomSubject } : {}),
202
219
  accessToken: signedIn.accessToken,
203
220
  accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
204
221
  ...(signedIn.person.siteSubject ? { siteSubject: signedIn.person.siteSubject } : {}),
@@ -281,6 +281,7 @@ export interface CompleteProspectusGoogleSignInInput {
281
281
  pendingSignIn: PendingProspectusSignIn;
282
282
  returnedState: string;
283
283
  authorizationCode: string;
284
+ expectedNewsroomSubject?: string;
284
285
  verifyCognitoTokens?: CognitoTokenVerifier;
285
286
  fetcher?: typeof fetch;
286
287
  nowInSeconds?: number;
@@ -320,6 +321,12 @@ export async function completeProspectusGoogleSignIn(
320
321
  expiresInSeconds: Number(tokenBody.expires_in) || 0,
321
322
  };
322
323
  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
+ }
323
330
  const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn, tokens.accessToken, tokens.idToken);
324
331
  return {
325
332
  person,
@@ -1,4 +1,5 @@
1
1
  import type { SemanticRelationRecord } from "./category-repository";
2
+ import { normalizeReferenceCurationStatus } from "./reference-policy";
2
3
 
3
4
  export const RELEVANCE_RELATION = "relevance_decision_is";
4
5
 
@@ -162,3 +163,11 @@ export function curationForRelevanceReview(decision: RelevanceDecisionView, revi
162
163
  shareable: review.shareable,
163
164
  };
164
165
  }
166
+
167
+ /**
168
+ * A pending reference with a current cyclotron suggestion is waiting for an editor's answer,
169
+ * whether or not the cyclotron flagged the decision itself for review.
170
+ */
171
+ export function awaitsEditorReview(curationStatus: unknown, decision: RelevanceDecisionView | undefined): boolean {
172
+ return Boolean(decision) && normalizeReferenceCurationStatus(curationStatus) === "pending";
173
+ }
@@ -121,7 +121,7 @@ export function modeMark(mode: ReviewMode): string {
121
121
  export function nextText(request: ReviewRequest): string {
122
122
  if (request.state === "open") {
123
123
  if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you approve or reject.";
124
- if (request.mode === "soft_review") return `Proceeds automatically on ${formatWhen(request.deadline)} unless you steer it.`;
124
+ if (request.mode === "soft_review") return request.deadline ? `Proceeds automatically on ${formatWhen(request.deadline)} unless you steer it.` : "Stays open until someone handles it. It never proceeds or expires on its own.";
125
125
  return "Nothing is waiting on this. It stays here until you acknowledge it.";
126
126
  }
127
127
  if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
@@ -219,9 +219,11 @@ export type NotificationSections = {
219
219
  export function sectionReviews(requests: ReviewRequest[]): NotificationSections {
220
220
  const open = requests.filter((request) => request.state === "open");
221
221
  const byRaised = (a: ReviewRequest, b: ReviewRequest) => a.raisedAt.localeCompare(b.raisedAt);
222
+ // Requests with a deadline first, soonest first; requests with no deadline after them, oldest first.
223
+ const bySoonestDeadline = (a: ReviewRequest, b: ReviewRequest) => (a.deadline && b.deadline ? a.deadline.localeCompare(b.deadline) : a.deadline ? -1 : b.deadline ? 1 : byRaised(a, b));
222
224
  return {
223
225
  hardGates: open.filter((request) => request.mode === "hard_gate").sort(byRaised),
224
- softReviews: open.filter((request) => request.mode === "soft_review").sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? "")),
226
+ softReviews: open.filter((request) => request.mode === "soft_review").sort(bySoonestDeadline),
225
227
  flags: open.filter((request) => request.mode === "flag").sort((a, b) => b.raisedAt.localeCompare(a.raisedAt)),
226
228
  decided: requests.filter((request) => request.state !== "open").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
227
229
  };
package/package.json CHANGED
@@ -106,6 +106,7 @@
106
106
  "./components/newsroom-client-shell": "./components/newsroom-client-shell.tsx",
107
107
  "./components/newsroom-curation-bootstrap": "./components/newsroom-curation-bootstrap.tsx",
108
108
  "./components/newsroom-curation-provider": "./components/newsroom-curation-provider.tsx",
109
+ "./components/newsroom-mobile-navigation": "./components/newsroom-mobile-navigation.tsx",
109
110
  "./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
110
111
  "./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
111
112
  "./components/newsroom-overview-skeleton": "./components/newsroom-overview-skeleton.tsx",
@@ -118,6 +119,7 @@
118
119
  "./components/newsroom-topics-view": "./components/newsroom-topics-view.tsx",
119
120
  "./components/notification-center": "./components/notification-center.tsx",
120
121
  "./components/papyrus-console-conversation": "./components/papyrus-console-conversation.tsx",
122
+ "./components/papyrus-console-frame": "./components/papyrus-console-frame.tsx",
121
123
  "./components/papyrus-console-shell": "./components/papyrus-console-shell.tsx",
122
124
  "./components/papyrus-private-chat-panel": "./components/papyrus-private-chat-panel.tsx",
123
125
  "./components/pictogram-figure": "./components/pictogram-figure.tsx",
@@ -125,6 +127,8 @@
125
127
  "./components/presentation-header": "./components/presentation-header.tsx",
126
128
  "./components/presentation-rhythm-hrule": "./components/presentation-rhythm-hrule.tsx",
127
129
  "./components/priming-proof-page": "./components/priming-proof-page.tsx",
130
+ "./components/proposals-panel": "./components/proposals-panel.tsx",
131
+ "./components/proposals-view": "./components/proposals-view.tsx",
128
132
  "./components/prospectus-account-chooser": "./components/prospectus-account-chooser.tsx",
129
133
  "./components/publication-cyclotron-panel": "./components/publication-cyclotron-panel.tsx",
130
134
  "./components/reader-auth-control": "./components/reader-auth-control.tsx",
@@ -139,6 +143,7 @@
139
143
  "./components/reference-live-agreement": "./components/reference-live-agreement.tsx",
140
144
  "./components/reference-priming": "./components/reference-priming.tsx",
141
145
  "./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
146
+ "./components/reference-review-card": "./components/reference-review-card.tsx",
142
147
  "./components/reference-section-suggestion": "./components/reference-section-suggestion.tsx",
143
148
  "./components/reference-source-preview": "./components/reference-source-preview.tsx",
144
149
  "./components/settings-page": "./components/settings-page.tsx",
@@ -167,6 +172,7 @@
167
172
  "./components/ui/tooltip": "./components/ui/tooltip.tsx",
168
173
  "./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
169
174
  "./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
175
+ "./components/use-console-visibility": "./components/use-console-visibility.ts",
170
176
  "./components/use-editorial-checks": "./components/use-editorial-checks.ts",
171
177
  "./components/use-near-viewport": "./components/use-near-viewport.ts",
172
178
  "./components/use-paged-content-item-versions": "./components/use-paged-content-item-versions.ts",
@@ -194,6 +200,7 @@
194
200
  "./lib/category-dashboard": "./lib/category-dashboard.ts",
195
201
  "./lib/category-repository": "./lib/category-repository.ts",
196
202
  "./lib/chrome-text": "./lib/chrome-text.ts",
203
+ "./lib/cli-login-discovery": "./lib/cli-login-discovery.ts",
197
204
  "./lib/console-chat-client": "./lib/console-chat-client.ts",
198
205
  "./lib/console-web-context": "./lib/console-web-context.ts",
199
206
  "./lib/content-actions-client": "./lib/content-actions-client.ts",
@@ -257,6 +264,8 @@
257
264
  "./lib/newsroom-board": "./lib/newsroom-board.ts",
258
265
  "./lib/newsroom-card-layout": "./lib/newsroom-card-layout.ts",
259
266
  "./lib/newsroom-category-drilldown": "./lib/newsroom-category-drilldown.ts",
267
+ "./lib/newsroom-chat-client": "./lib/newsroom-chat-client.ts",
268
+ "./lib/newsroom-chat-identity": "./lib/newsroom-chat-identity.ts",
260
269
  "./lib/newsroom-demo-articles": "./lib/newsroom-demo-articles.ts",
261
270
  "./lib/newsroom-demo-dashboard": "./lib/newsroom-demo-dashboard.ts",
262
271
  "./lib/newsroom-demo-profile": "./lib/newsroom-demo-profile.ts",
@@ -283,6 +292,7 @@
283
292
  "./lib/presentation-footer": "./lib/presentation-footer.ts",
284
293
  "./lib/pretext-layout": "./lib/pretext-layout.ts",
285
294
  "./lib/priming-proof-report": "./lib/priming-proof-report.ts",
295
+ "./lib/proposals-admin": "./lib/proposals-admin.ts",
286
296
  "./lib/prospectus-account-client": "./lib/prospectus-account-client.ts",
287
297
  "./lib/prospectus-publication-access": "./lib/prospectus-publication-access.ts",
288
298
  "./lib/prospectus-session": "./lib/prospectus-session.ts",
@@ -356,6 +366,7 @@
356
366
  "./renderers/pretext/rhythm-blog-presentation": "./renderers/pretext/rhythm-blog-presentation.tsx",
357
367
  "./renderers/pretext/rhythm-shell-style": "./renderers/pretext/rhythm-shell-style.ts",
358
368
  "./app/%5F_preview/[[...path]]/route": "./app/%5F_preview/[[...path]]/route.ts",
369
+ "./app/.well-known/papyrus-cli.json/route": "./app/.well-known/papyrus-cli.json/route.ts",
359
370
  "./app/[year]/[month]/[day]/[articleSlug]/page": "./app/[year]/[month]/[day]/[articleSlug]/page.tsx",
360
371
  "./app/[year]/[month]/[day]/page": "./app/[year]/[month]/[day]/page.tsx",
361
372
  "./app/[year]/[month]/[day]/page/[pageNumber]/page": "./app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
@@ -456,7 +467,7 @@
456
467
  "optional": true
457
468
  }
458
469
  },
459
- "version": "5.42.1",
470
+ "version": "5.43.0-next.2",
460
471
  "dependencies": {
461
472
  "@aws-amplify/adapter-nextjs": "^1.7.3",
462
473
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.42.1",
2
+ "version": "5.43.0-next.2",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -11,6 +11,17 @@
11
11
  "dynamic": "\"force-dynamic\""
12
12
  }
13
13
  },
14
+ {
15
+ "file": "app/.well-known/papyrus-cli.json/route.ts",
16
+ "hasDefault": false,
17
+ "exports": [
18
+ "GET"
19
+ ],
20
+ "config": {
21
+ "dynamic": "\"force-dynamic\"",
22
+ "runtime": "\"nodejs\""
23
+ }
24
+ },
14
25
  {
15
26
  "file": "app/[year]/[month]/[day]/[articleSlug]/page.tsx",
16
27
  "reader": true,