@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.
- package/amplify/data/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/monitoring-view/schema.js +22 -0
- package/amplify/functions/monitoring-view/schema.ts +22 -0
- package/amplify/functions/monitoring-view/wire.js +1 -0
- package/amplify/functions/monitoring-view/wire.ts +1 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/handler.py +137 -0
- package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +20 -3
- package/amplify/functions/proposal-actions/resource.js +15 -0
- package/amplify/functions/proposal-actions/resource.ts +22 -0
- package/amplify/functions/proposal-actions/schema.js +106 -0
- package/amplify/functions/proposal-actions/schema.ts +108 -0
- package/amplify/functions/proposal-actions/wire.js +10 -0
- package/amplify/functions/proposal-actions/wire.ts +13 -0
- package/amplify/plugins/reader-steering/function/handler.py +13 -72
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
- package/amplify/plugins/reader-steering/schema.js +0 -95
- package/amplify/plugins/reader-steering/schema.ts +0 -95
- package/amplify/site-backend.js +4 -0
- package/amplify/site-backend.ts +4 -0
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/globals.css +31 -35
- package/app/tailwind.css +2 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +140 -23
- package/components/monitoring-panel.tsx +96 -6
- package/components/news-desk-client-provider.tsx +8 -2
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-mobile-navigation.tsx +23 -0
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-ops-shell.tsx +8 -10
- package/components/newsroom-organizer.css +1 -0
- package/components/newsroom-references-view.tsx +139 -257
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-frame.tsx +66 -0
- package/components/papyrus-console-shell.tsx +14 -42
- package/components/papyrus-private-chat-panel.tsx +50 -16
- package/components/proposals-panel.tsx +107 -0
- package/components/proposals-view.tsx +95 -0
- package/components/reference-review-card.tsx +400 -0
- package/components/topic-steering-workspace.tsx +13 -2
- package/components/use-console-visibility.ts +47 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/monitoring-client.ts +39 -3
- package/lib/monitoring-overview-fixture.ts +2 -2
- package/lib/monitoring-overview.ts +47 -3
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-chat-client.ts +30 -0
- package/lib/newsroom-chat-identity.ts +40 -0
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/papyrus-hosted-chat.ts +62 -80
- package/lib/papyrus-private-chat-client.ts +7 -4
- package/lib/proposals-admin.ts +115 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/prospectus-session.ts +2 -0
- package/lib/prospectus-sign-in-routes.ts +19 -2
- package/lib/prospectus-site-identity.ts +7 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/relevance-decisions.ts +9 -0
- package/lib/review-client.ts +38 -1
- package/lib/review-drafts.ts +231 -0
- package/lib/review-fixture.ts +36 -2
- package/lib/review-requests.ts +66 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +16 -1
- package/routes.manifest.json +12 -1
- package/src/sync.mjs +11 -2
- 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
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
identity = await (options.resolveNewsroomIdentity ?? resolveNewsroomChatIdentity)(request);
|
|
72
52
|
} catch {
|
|
73
53
|
return { principal: null, status: 503 };
|
|
74
54
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch =
|
|
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 =
|
|
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 =
|
|
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
|
|