@anthusai/papyrus 5.39.0 → 5.41.0

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 (40) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/content-actions/handler.py +10 -1
  6. package/amplify/functions/monitoring-view/schema.js +12 -0
  7. package/amplify/functions/monitoring-view/schema.ts +12 -0
  8. package/amplify/functions/monitoring-view/wire.js +25 -0
  9. package/amplify/functions/monitoring-view/wire.ts +32 -0
  10. package/amplify/site-backend.js +2 -1
  11. package/amplify/site-backend.ts +2 -1
  12. package/app/api/newsroom/chat/[...path]/route.ts +17 -0
  13. package/app/layout.tsx +5 -1
  14. package/components/monitoring-overview-view.tsx +182 -9
  15. package/components/monitoring-panel.tsx +23 -3
  16. package/components/notification-center.tsx +12 -8
  17. package/components/papyrus-console-conversation.css +14 -0
  18. package/components/papyrus-console-conversation.tsx +55 -0
  19. package/components/papyrus-console-shell.tsx +62 -420
  20. package/components/papyrus-private-chat-panel.tsx +134 -0
  21. package/infra/amplify-app-shell.js +2 -0
  22. package/infra/amplify-app-shell.ts +2 -0
  23. package/infra/site-config.js +4 -0
  24. package/infra/site-config.ts +4 -0
  25. package/lib/monitoring-client.ts +69 -15
  26. package/lib/monitoring-overview-fixture.ts +1 -1
  27. package/lib/monitoring-overview.ts +48 -5
  28. package/lib/papyrus-chat-admission.ts +65 -0
  29. package/lib/papyrus-hosted-chat.ts +217 -0
  30. package/lib/papyrus-private-chat-client.ts +81 -0
  31. package/lib/prospectus-session.ts +15 -0
  32. package/lib/prospectus-sign-in-routes.ts +2 -12
  33. package/lib/review-client.ts +6 -3
  34. package/lib/review-requests.ts +8 -6
  35. package/lib/shared-chat-admission.ts +182 -0
  36. package/lib/site-origin-value.ts +50 -0
  37. package/lib/site-origin.ts +3 -62
  38. package/package.json +10 -1
  39. package/routes.manifest.json +13 -1
  40. package/src/with-papyrus.mjs +1 -1
@@ -0,0 +1,81 @@
1
+ export type PapyrusPrivateChatChannel = {
2
+ channel_id: string;
3
+ participants: Array<{ kind: string; actor_id: string }>;
4
+ };
5
+
6
+ export type PapyrusPrivateChatMessage = {
7
+ message_id: string;
8
+ channel_id: string;
9
+ seq: number;
10
+ author_kind: "human" | "bot";
11
+ body: string;
12
+ addressed_to_bot_id: string | null;
13
+ created_at: string;
14
+ };
15
+
16
+ export type PapyrusPrivateChatSendResult = {
17
+ message: PapyrusPrivateChatMessage;
18
+ turn_id: string | null;
19
+ };
20
+
21
+ export type PapyrusPrivateChatFetch = typeof globalThis.fetch;
22
+
23
+ const CHAT_API = "/api/newsroom/chat";
24
+
25
+ async function requestJson<T>(path: string, init: RequestInit, fetcher: PapyrusPrivateChatFetch): Promise<T> {
26
+ const response = await fetcher(`${CHAT_API}/${path}`, {
27
+ ...init,
28
+ credentials: "same-origin",
29
+ cache: "no-store",
30
+ });
31
+ const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
32
+ if (!response.ok) {
33
+ const error = typeof payload?.error === "string" ? payload.error
34
+ : typeof payload?.detail === "string" ? payload.detail
35
+ : `Private chat request failed (${response.status}).`;
36
+ throw new Error(error);
37
+ }
38
+ if (!payload || typeof payload !== "object") throw new Error("Private chat returned an invalid response.");
39
+ return payload as T;
40
+ }
41
+
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> {
44
+ const result = await requestJson<PapyrusPrivateChatChannel>("private-channel", { method: "POST" }, fetcher);
45
+ if (!result.channel_id?.trim() || !Array.isArray(result.participants)) {
46
+ throw new Error("Private chat returned an invalid channel.");
47
+ }
48
+ return result;
49
+ }
50
+
51
+ /** Read only the private channel returned by the host's authenticated open request. */
52
+ export async function listPapyrusPrivateChatMessages(
53
+ channelId: string,
54
+ fetcher: PapyrusPrivateChatFetch = globalThis.fetch,
55
+ ): Promise<PapyrusPrivateChatMessage[]> {
56
+ if (!channelId.trim()) throw new Error("A private chat channel is required.");
57
+ const result = await requestJson<{ messages?: unknown }>(`channels/${encodeURIComponent(channelId)}/messages`, { method: "GET" }, fetcher);
58
+ if (!Array.isArray(result.messages)) throw new Error("Private chat returned an invalid message list.");
59
+ return result.messages as PapyrusPrivateChatMessage[];
60
+ }
61
+
62
+ /** One explicit send. The browser sends neither its tenant nor author or bot identity. */
63
+ export async function sendPapyrusPrivateChatMessage(
64
+ channelId: string,
65
+ body: string,
66
+ idempotencyKey: string,
67
+ fetcher: PapyrusPrivateChatFetch = globalThis.fetch,
68
+ ): Promise<PapyrusPrivateChatSendResult> {
69
+ if (!channelId.trim() || !body.trim() || !idempotencyKey.trim()) {
70
+ throw new Error("A channel, message and idempotency key are required.");
71
+ }
72
+ const result = await requestJson<PapyrusPrivateChatSendResult>(`channels/${encodeURIComponent(channelId)}/messages`, {
73
+ method: "POST",
74
+ headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
75
+ body: JSON.stringify({ body }),
76
+ }, fetcher);
77
+ if (!result.message?.message_id || result.message.channel_id !== channelId) {
78
+ throw new Error("Private chat returned an invalid sent message.");
79
+ }
80
+ return result;
81
+ }
@@ -1,5 +1,6 @@
1
1
  import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
2
2
  import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
3
+ import type { NextRequest } from "next/server";
3
4
  import { readAmplifyOutputsFile } from "./amplify-outputs-path";
4
5
  import {
5
6
  ProspectusSiteAccounts,
@@ -42,6 +43,20 @@ export interface ProspectusSignedInSession {
42
43
  siteSubject?: string;
43
44
  }
44
45
 
46
+ /** Open the existing sealed Prospectus session cookie for one server request. */
47
+ export function signedInProspectusSession(
48
+ request: Pick<NextRequest, "cookies">,
49
+ sealingSecret: string,
50
+ nowInSeconds?: number,
51
+ ): ProspectusSignedInSession | null {
52
+ return openProspectusCookie<ProspectusSignedInSession>(
53
+ sealingSecret,
54
+ PROSPECTUS_SESSION_COOKIE,
55
+ request.cookies.get(PROSPECTUS_SESSION_COOKIE)?.value,
56
+ nowInSeconds,
57
+ );
58
+ }
59
+
45
60
  function sealingKeyForCookie(sealingSecret: string, cookieName: string): Buffer {
46
61
  if (sealingSecret.length < MINIMUM_SEALING_SECRET_LENGTH) throw new Error("The Prospectus session sealing secret is too short.");
47
62
  return Buffer.from(hkdfSync("sha256", sealingSecret, SEALED_COOKIE_KEY_DERIVATION_SALT, cookieName, 32));
@@ -12,6 +12,7 @@ import {
12
12
  PROSPECTUS_SESSION_COOKIE,
13
13
  openProspectusCookie,
14
14
  resolveProspectusSignInRuntime,
15
+ signedInProspectusSession,
15
16
  sameSiteReturnPath,
16
17
  sealProspectusCookie,
17
18
  sealedCookieAttributes,
@@ -115,18 +116,7 @@ function signInFailedRedirect(request: NextRequest, options: ProspectusSignInRou
115
116
  return NextResponse.redirect(target, { status: 303, headers: NO_STORE_HEADERS });
116
117
  }
117
118
 
118
- export function signedInProspectusSession(
119
- request: NextRequest,
120
- sealingSecret: string,
121
- nowInSeconds?: number,
122
- ): ProspectusSignedInSession | null {
123
- return openProspectusCookie<ProspectusSignedInSession>(
124
- sealingSecret,
125
- PROSPECTUS_SESSION_COOKIE,
126
- request.cookies.get(PROSPECTUS_SESSION_COOKIE)?.value,
127
- nowInSeconds,
128
- );
129
- }
119
+ export { signedInProspectusSession } from "./prospectus-session";
130
120
 
131
121
  /** `returnTo` from the query (a link) or, for the POST form the reader plugin's button submits, from the form body. */
132
122
  async function requestedReturnPath(request: NextRequest): Promise<string | null> {
@@ -100,18 +100,21 @@ export async function markReviewRead(userKey: string, requestId: string, now: Da
100
100
 
101
101
  const MAX_TITLE_LOOKUPS = 40;
102
102
 
103
- /** Titles of the stories that review requests are about, by lineage id. A story that cannot be read simply has no entry (the card falls back to the id). */
104
- export async function loadStoryTitles(ids: string[]): Promise<Record<string, string>> {
103
+ /** Titles of the stories that review requests are about, by lineage id, and the ids of stories that no longer exist (no version of the lineage remains). A story that cannot be read simply has no entry (the card falls back to the id). */
104
+ export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<string, string>; deleted: string[] }> {
105
105
  const titles: Record<string, string> = {};
106
+ const deleted: string[] = [];
106
107
  await Promise.all([...new Set(ids)].slice(0, MAX_TITLE_LOOKUPS).map(async (id) => {
107
108
  const result = await run(
108
109
  `query StoryTitle($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title } } }`,
109
110
  { lineageId: id },
110
111
  );
112
+ // Only a clean, empty answer means the story is gone; a failed lookup says nothing.
113
+ if (!result.errors?.length && result.data?.listItemsByLineageAndVersion && !(result.data.listItemsByLineageAndVersion.items ?? []).filter(Boolean).length) deleted.push(id);
111
114
  const items = (result.data?.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as { versionNumber?: number; headline?: string; title?: string }[];
112
115
  const newest = items.sort((a, b) => (b.versionNumber ?? 0) - (a.versionNumber ?? 0))[0];
113
116
  const title = newest?.headline || newest?.title;
114
117
  if (title) titles[id] = title;
115
118
  }));
116
- return titles;
119
+ return { titles, deleted };
117
120
  }
@@ -13,7 +13,7 @@ import type { RelevanceDecisionView } from "./relevance-decisions";
13
13
 
14
14
  export type ReviewMode = "flag" | "soft_review" | "hard_gate";
15
15
  export type ReviewState = "open" | "answered" | "timed_out" | "withdrawn";
16
- export type ReviewDecision = "approve" | "reject" | "feedback";
16
+ export type ReviewDecision = "approve" | "acknowledge" | "reject" | "feedback";
17
17
  export type ReviewSteer = "adjust" | "not_this_kind";
18
18
 
19
19
  export type ReviewAnswer = { decision: ReviewDecision; feedback: string | null; steer: ReviewSteer | null; by: string; at: string };
@@ -89,6 +89,7 @@ export type AnswerInput = { decision: ReviewDecision; feedback?: string | null;
89
89
  /** The update for an answer, or an Error saying why it is refused. Applied with the condition state = open. */
90
90
  export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: string, now: Date): Record<string, unknown> {
91
91
  if (request.state !== "open") throw new Error(`This request is ${request.state.replace("_", " ")}${request.state === "timed_out" ? ` (${PROCEEDED_WITHOUT_REVIEW})` : ""} and can no longer be answered.`);
92
+ if (input.decision === "acknowledge" && request.mode !== "flag") throw new Error("Only a flag is acknowledged; approve or reject this one.");
92
93
  const feedback = input.feedback?.trim() || null;
93
94
  const steer = input.steer ?? null;
94
95
  if (input.decision === "feedback" && !feedback && !steer) throw new Error("Write a note, or choose \"Not this kind of thing\".");
@@ -126,12 +127,13 @@ export function nextText(request: ReviewRequest): string {
126
127
  if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
127
128
  if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
128
129
  const answer = request.answer;
129
- return answer ? `${answerWords(answer)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
130
+ return answer ? `${answerWords(answer, request)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
130
131
  }
131
132
 
132
- export function answerWords(answer: ReviewAnswer): string {
133
+ export function answerWords(answer: ReviewAnswer, request?: Pick<ReviewRequest, "mode">): string {
133
134
  if (answer.steer === "not_this_kind") return "Steered: not this kind of thing";
134
- if (answer.decision === "approve") return "Approved";
135
+ if (answer.decision === "acknowledge") return "Acknowledged";
136
+ if (answer.decision === "approve") return request?.mode === "flag" ? "Acknowledged" : "Approved";
135
137
  if (answer.decision === "reject") return "Rejected";
136
138
  return "Steered with a note";
137
139
  }
@@ -146,8 +148,8 @@ export function steeringText(request: ReviewRequest): string | null {
146
148
  }
147
149
 
148
150
  /** What a request is about, in words: a story shows its title (the id when the title is not known), anything else its type and id. */
149
- export function subjectLabel(subject: { type: string; id: string }, titles: Readonly<Record<string, string>> = {}): string {
150
- if (subject.type === "story") return titles[subject.id] ?? `story ${subject.id}`;
151
+ export function subjectLabel(subject: { type: string; id: string }, titles: Readonly<Record<string, string>> = {}, deleted: ReadonlySet<string> = new Set()): string {
152
+ if (subject.type === "story") return deleted.has(subject.id) ? `story ${subject.id} (story deleted)` : titles[subject.id] ?? `story ${subject.id}`;
151
153
  return `${subject.type.replace("_", " ")} ${subject.id}`;
152
154
  }
153
155
 
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Code-local trust contract for a site-hosted shared chat adapter.
3
+ *
4
+ * This module does not verify JWT signatures and is not an HTTP endpoint. The
5
+ * hosting server must inject a real verifier and authoritative membership
6
+ * resolver. Never construct VerifiedChatCredential from request JSON.
7
+ */
8
+
9
+ export type TokenUse = string;
10
+
11
+ /** Claims returned only after the injected host verifier has validated the JWT. */
12
+ export interface VerifiedChatCredential {
13
+ subject: string;
14
+ issuer: string;
15
+ audiences: readonly string[];
16
+ clientId?: string;
17
+ tokenUse?: TokenUse;
18
+ expiresAtEpochSeconds: number;
19
+ notBeforeEpochSeconds?: number;
20
+ issuedAtEpochSeconds?: number;
21
+ }
22
+
23
+ export interface AudienceRule {
24
+ /** Explicitly choose which JWT claim is authoritative for this host. */
25
+ claim: "aud" | "client_id";
26
+ expectedValue: string;
27
+ }
28
+
29
+ export interface ChatHostTrustProfile {
30
+ issuer: string;
31
+ audienceRule: AudienceRule;
32
+ /** Must be configured by the host after verifying its actual token contract. */
33
+ acceptedTokenUses: readonly TokenUse[];
34
+ configuredSiteId: string;
35
+ clockSkewSeconds?: number;
36
+ }
37
+
38
+ export interface ChatRequest {
39
+ authorization?: string;
40
+ channelId: string;
41
+ idempotencyKey: string;
42
+ message: { text: string; authorSub?: string; accountId?: string; siteId?: string };
43
+ }
44
+
45
+ export interface ChatMembership {
46
+ accountId: string;
47
+ siteId: string;
48
+ editorSub: string;
49
+ active: boolean;
50
+ /** Host-derived authorization for the editorial chat surface (editor/admin policy). */
51
+ canUseEditorialChat: boolean;
52
+ }
53
+
54
+ export interface TranscriptOwner {
55
+ accountId: string;
56
+ siteId: string;
57
+ editorSub: string;
58
+ channelId: string;
59
+ }
60
+
61
+ export interface SharedChatAdmissionDependencies {
62
+ trust: ChatHostTrustProfile;
63
+ nowEpochSeconds?: () => number;
64
+ verifyCredential: (rawCredential: string) => Promise<VerifiedChatCredential | null>;
65
+ resolveMembership: (subject: string, configuredSiteId: string) => Promise<ChatMembership | null>;
66
+ resolveTranscriptOwner: (channelId: string) => Promise<TranscriptOwner | null>;
67
+ }
68
+
69
+ export interface AdmittedChatMessage {
70
+ owner: TranscriptOwner;
71
+ idempotencyKey: string;
72
+ /** Stable canonical representation used for payload-reuse detection. */
73
+ payloadFingerprint: string;
74
+ }
75
+
76
+ export class ChatAdmissionError extends Error {
77
+ readonly code: "unauthorized" | "forbidden" | "invalid_request" | "idempotency_conflict";
78
+
79
+ constructor(code: ChatAdmissionError["code"]) {
80
+ super(code);
81
+ this.name = "ChatAdmissionError";
82
+ this.code = code;
83
+ }
84
+ }
85
+
86
+ const reject = (code: ChatAdmissionError["code"]): never => { throw new ChatAdmissionError(code); };
87
+
88
+ function bearerToken(value: string | undefined): string {
89
+ const match = value?.match(/^Bearer ([^\s]+)$/i);
90
+ if (!match) return reject("unauthorized");
91
+ return match[1];
92
+ }
93
+
94
+ function assertCredential(credential: VerifiedChatCredential, trust: ChatHostTrustProfile, now: number): void {
95
+ const skew = Math.max(0, trust.clockSkewSeconds ?? 0);
96
+ if (!trust.issuer || credential.issuer !== trust.issuer) reject("unauthorized");
97
+ if (!trust.acceptedTokenUses.length || !credential.tokenUse || !trust.acceptedTokenUses.includes(credential.tokenUse)) reject("unauthorized");
98
+ if (!Number.isFinite(credential.expiresAtEpochSeconds) || now - skew >= credential.expiresAtEpochSeconds) reject("unauthorized");
99
+ if (credential.notBeforeEpochSeconds !== undefined && now + skew < credential.notBeforeEpochSeconds) reject("unauthorized");
100
+ if (credential.issuedAtEpochSeconds !== undefined && now + skew < credential.issuedAtEpochSeconds) reject("unauthorized");
101
+
102
+ const audienceMatches = trust.audienceRule.claim === "aud"
103
+ ? credential.audiences.includes(trust.audienceRule.expectedValue)
104
+ : credential.clientId === trust.audienceRule.expectedValue;
105
+ if (!audienceMatches) reject("unauthorized");
106
+ }
107
+
108
+ /** Authenticate, derive the current host membership, and enforce private channel ownership. */
109
+ export async function admitSharedChatMessage(
110
+ request: ChatRequest,
111
+ dependencies: SharedChatAdmissionDependencies,
112
+ ): Promise<AdmittedChatMessage> {
113
+ if (!request.channelId || !request.message.text.trim() || !/^[A-Za-z0-9._~-]{16,128}$/.test(request.idempotencyKey)) {
114
+ return reject("invalid_request");
115
+ }
116
+
117
+ const rawCredential = bearerToken(request.authorization);
118
+ const credential = await dependencies.verifyCredential(rawCredential);
119
+ if (!credential) return reject("unauthorized");
120
+ const now = (dependencies.nowEpochSeconds ?? (() => Date.now() / 1000))();
121
+ assertCredential(credential, dependencies.trust, now);
122
+
123
+ const membership = await dependencies.resolveMembership(credential.subject, dependencies.trust.configuredSiteId);
124
+ if (!membership || !membership.active || !membership.canUseEditorialChat
125
+ || membership.editorSub !== credential.subject || membership.siteId !== dependencies.trust.configuredSiteId) {
126
+ return reject("forbidden");
127
+ }
128
+ if ((request.message.authorSub && request.message.authorSub !== membership.editorSub)
129
+ || (request.message.accountId && request.message.accountId !== membership.accountId)
130
+ || (request.message.siteId && request.message.siteId !== membership.siteId)) {
131
+ return reject("forbidden");
132
+ }
133
+
134
+ const expectedOwner: TranscriptOwner = {
135
+ accountId: membership.accountId,
136
+ siteId: membership.siteId,
137
+ editorSub: membership.editorSub,
138
+ channelId: request.channelId,
139
+ };
140
+ const actualOwner = await dependencies.resolveTranscriptOwner(request.channelId);
141
+ if (!actualOwner || actualOwner.accountId !== expectedOwner.accountId || actualOwner.siteId !== expectedOwner.siteId
142
+ || actualOwner.editorSub !== expectedOwner.editorSub || actualOwner.channelId !== expectedOwner.channelId) {
143
+ return reject("forbidden");
144
+ }
145
+
146
+ return {
147
+ owner: expectedOwner,
148
+ idempotencyKey: request.idempotencyKey,
149
+ payloadFingerprint: JSON.stringify([request.channelId, request.message.text]),
150
+ };
151
+ }
152
+
153
+ export interface IdempotencyRequest<T> {
154
+ /** Storage must atomically scope key by the derived owner and operation. */
155
+ scope: string;
156
+ key: string;
157
+ payloadFingerprint: string;
158
+ run: () => Promise<T>;
159
+ }
160
+
161
+ /**
162
+ * Store contract for replay-safe writes. Production implementations must be
163
+ * atomic/durable (for example conditional put + canonical result); this module
164
+ * deliberately supplies no in-memory production store.
165
+ */
166
+ export interface ChatIdempotencyStore {
167
+ executeOnce<T>(request: IdempotencyRequest<T>): Promise<T>;
168
+ }
169
+
170
+ export function runAdmittedChatMessage<T>(
171
+ admission: AdmittedChatMessage,
172
+ store: ChatIdempotencyStore,
173
+ operation: () => Promise<T>,
174
+ ): Promise<T> {
175
+ const { accountId, siteId, editorSub, channelId } = admission.owner;
176
+ return store.executeOnce({
177
+ scope: JSON.stringify(["chat-message", accountId, siteId, editorSub, channelId]),
178
+ key: admission.idempotencyKey,
179
+ payloadFingerprint: admission.payloadFingerprint,
180
+ run: operation,
181
+ });
182
+ }
@@ -0,0 +1,50 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { getSiteEnv } from "./site-env";
3
+
4
+ type EnvironmentVariables = Record<string, string | undefined>;
5
+
6
+ export const SITE_ORIGIN_ENV = "PAPYRUS_SITE_ORIGIN";
7
+
8
+ export class SiteOriginNotConfiguredError extends Error {
9
+ readonly status = 503;
10
+ readonly code = "site_origin_not_configured";
11
+ }
12
+
13
+ function isLoopbackHostname(hostname: string): boolean {
14
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
15
+ }
16
+
17
+ function configuredOrigin(value: string): string {
18
+ const invalid = () => new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} must be an https origin such as https://example.com with no path.`);
19
+ let parsed: URL;
20
+ try {
21
+ parsed = new URL(value);
22
+ } catch {
23
+ throw invalid();
24
+ }
25
+ if (parsed.protocol !== "https:" || parsed.origin !== value) throw invalid();
26
+ return parsed.origin;
27
+ }
28
+
29
+ function siteEnvOrNull(environment: EnvironmentVariables): string | null {
30
+ try {
31
+ return getSiteEnv(environment);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** The site's public origin from trusted configuration, never Host or forwarded headers. */
38
+ export function publicSiteOrigin(request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): string {
39
+ const configured = (environment[SITE_ORIGIN_ENV] ?? "").trim();
40
+ if (configured) return configuredOrigin(configured);
41
+ const { hostname, origin } = request.nextUrl;
42
+ const developing = siteEnvOrNull(environment) === "development" && environment.NODE_ENV !== "production";
43
+ if (isLoopbackHostname(hostname) && developing) return origin;
44
+ throw new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} is not set; this site cannot tell its public origin.`);
45
+ }
46
+
47
+ /** An absolute URL on the site's trusted public origin. */
48
+ export function publicSiteUrl(path: string, request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): URL {
49
+ return new URL(path, publicSiteOrigin(request, environment));
50
+ }
@@ -1,67 +1,8 @@
1
- import { NextResponse, type NextRequest } from "next/server";
2
- import { getSiteEnv } from "./site-env";
1
+ import { NextResponse } from "next/server";
2
+ import { SiteOriginNotConfiguredError } from "./site-origin-value";
3
3
 
4
- type EnvironmentVariables = Record<string, string | undefined>;
5
-
6
- export const SITE_ORIGIN_ENV = "PAPYRUS_SITE_ORIGIN";
7
-
8
- /**
9
- * The site's public origin cannot be read from the request on Amplify SSR (WEB_COMPUTE):
10
- * there `request.nextUrl.origin` is the compute adapter's internal https://localhost:3000.
11
- * It also must never be derived from Host or X-Forwarded-* headers, which a client can set.
12
- * The one trusted source is PAPYRUS_SITE_ORIGIN, set by the app shell from the site's host.
13
- */
14
- export class SiteOriginNotConfiguredError extends Error {
15
- readonly status = 503;
16
- readonly code = "site_origin_not_configured";
17
- }
4
+ export { SITE_ORIGIN_ENV, SiteOriginNotConfiguredError, publicSiteOrigin, publicSiteUrl } from "./site-origin-value";
18
5
 
19
6
  export function siteOriginNotConfiguredResponse(error: SiteOriginNotConfiguredError): NextResponse {
20
7
  return NextResponse.json({ error: error.code, message: error.message }, { status: error.status, headers: { "cache-control": "no-store" } });
21
8
  }
22
-
23
- function isLoopbackHostname(hostname: string): boolean {
24
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
25
- }
26
-
27
- function configuredOrigin(value: string): string {
28
- const invalid = () => new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} must be an https origin such as https://example.com with no path.`);
29
- let parsed: URL;
30
- try {
31
- parsed = new URL(value);
32
- } catch {
33
- throw invalid();
34
- }
35
- if (parsed.protocol !== "https:" || parsed.origin !== value) throw invalid();
36
- return parsed.origin;
37
- }
38
-
39
- function siteEnvOrNull(environment: EnvironmentVariables): string | null {
40
- try {
41
- return getSiteEnv(environment);
42
- } catch {
43
- return null;
44
- }
45
- }
46
-
47
- /**
48
- * The site's public origin, from trusted configuration.
49
- * - PAPYRUS_SITE_ORIGIN set: it must be an https origin with no path; it is used as is.
50
- * - Unset: `request.nextUrl.origin` only when it is loopback, SITE_ENV resolves to development
51
- * and NODE_ENV is not production (a local `next dev` server). Anything else, production and staging included, throws
52
- * SiteOriginNotConfiguredError so routes answer 503 instead of redirecting to localhost.
53
- */
54
- export function publicSiteOrigin(request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): string {
55
- const configured = (environment[SITE_ORIGIN_ENV] ?? "").trim();
56
- if (configured) return configuredOrigin(configured);
57
- const { hostname, origin } = request.nextUrl;
58
- // SITE_ENV defaults to development when a runtime never received it, so NODE_ENV=production also closes the fallback.
59
- const developing = siteEnvOrNull(environment) === "development" && environment.NODE_ENV !== "production";
60
- if (isLoopbackHostname(hostname) && developing) return origin;
61
- throw new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} is not set; this site cannot tell its public origin.`);
62
- }
63
-
64
- /** An absolute URL on the site's public origin, for redirects. Fails closed like publicSiteOrigin. */
65
- export function publicSiteUrl(path: string, request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): URL {
66
- return new URL(path, publicSiteOrigin(request, environment));
67
- }
package/package.json CHANGED
@@ -117,7 +117,9 @@
117
117
  "./components/newsroom-theme": "./components/newsroom-theme.tsx",
118
118
  "./components/newsroom-topics-view": "./components/newsroom-topics-view.tsx",
119
119
  "./components/notification-center": "./components/notification-center.tsx",
120
+ "./components/papyrus-console-conversation": "./components/papyrus-console-conversation.tsx",
120
121
  "./components/papyrus-console-shell": "./components/papyrus-console-shell.tsx",
122
+ "./components/papyrus-private-chat-panel": "./components/papyrus-private-chat-panel.tsx",
121
123
  "./components/pictogram-figure": "./components/pictogram-figure.tsx",
122
124
  "./components/presentation-footer": "./components/presentation-footer.tsx",
123
125
  "./components/presentation-header": "./components/presentation-header.tsx",
@@ -271,7 +273,10 @@
271
273
  "./lib/newsroom-sections": "./lib/newsroom-sections.ts",
272
274
  "./lib/newsroom-topics": "./lib/newsroom-topics.ts",
273
275
  "./lib/oauth-redirect-priority": "./lib/oauth-redirect-priority.ts",
276
+ "./lib/papyrus-chat-admission": "./lib/papyrus-chat-admission.ts",
277
+ "./lib/papyrus-hosted-chat": "./lib/papyrus-hosted-chat.ts",
274
278
  "./lib/papyrus-object-kinds": "./lib/papyrus-object-kinds.ts",
279
+ "./lib/papyrus-private-chat-client": "./lib/papyrus-private-chat-client.ts",
275
280
  "./lib/papyrus-web-locations": "./lib/papyrus-web-locations.ts",
276
281
  "./lib/pilobol-sample": "./lib/pilobol-sample.ts",
277
282
  "./lib/plugin-settings": "./lib/plugin-settings.ts",
@@ -315,11 +320,13 @@
315
320
  "./lib/robots-policy": "./lib/robots-policy.ts",
316
321
  "./lib/root-route-redirect": "./lib/root-route-redirect.ts",
317
322
  "./lib/semantic-graph": "./lib/semantic-graph.ts",
323
+ "./lib/shared-chat-admission": "./lib/shared-chat-admission.ts",
318
324
  "./lib/shared-credentials-provider": "./lib/shared-credentials-provider.ts",
319
325
  "./lib/signup-csv": "./lib/signup-csv.ts",
320
326
  "./lib/site-brand-tenant": "./lib/site-brand-tenant.ts",
321
327
  "./lib/site-brand": "./lib/site-brand.ts",
322
328
  "./lib/site-env": "./lib/site-env.ts",
329
+ "./lib/site-origin-value": "./lib/site-origin-value.ts",
323
330
  "./lib/site-origin": "./lib/site-origin.ts",
324
331
  "./lib/site-renderer": "./lib/site-renderer.ts",
325
332
  "./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
@@ -369,6 +376,7 @@
369
376
  "./app/api/newsletter/state/route": "./app/api/newsletter/state/route.ts",
370
377
  "./app/api/newsletter/subscribe/route": "./app/api/newsletter/subscribe/route.ts",
371
378
  "./app/api/newsletter/unsubscribe/route": "./app/api/newsletter/unsubscribe/route.ts",
379
+ "./app/api/newsroom/chat/[...path]/route": "./app/api/newsroom/chat/[...path]/route.ts",
372
380
  "./app/api/newsroom/curation/context/route": "./app/api/newsroom/curation/context/route.ts",
373
381
  "./app/api/newsroom/curation/priming/route": "./app/api/newsroom/curation/priming/route.ts",
374
382
  "./app/api/newsroom/curation/publication/route": "./app/api/newsroom/curation/publication/route.ts",
@@ -448,7 +456,7 @@
448
456
  "optional": true
449
457
  }
450
458
  },
451
- "version": "5.39.0",
459
+ "version": "5.41.0",
452
460
  "dependencies": {
453
461
  "@aws-amplify/adapter-nextjs": "^1.7.3",
454
462
  "@aws-crypto/sha256-js": "5.2.0",
@@ -485,6 +493,7 @@
485
493
  "anthus-vultus": "github:AnthusAI/Vultus#af98d57",
486
494
  "aws-amplify": "^6.17.0",
487
495
  "aws-jwt-verify": "4.0.1",
496
+ "chatticus": "https://github.com/AnthusAI/Chatticus/archive/db862e386119f97922cf303fd00ef0505d86a08a.tar.gz",
488
497
  "class-variance-authority": "^0.7.1",
489
498
  "clsx": "^2.1.1",
490
499
  "cmdk": "^1.1.1",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.39.0",
2
+ "version": "5.41.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -229,6 +229,18 @@
229
229
  "runtime": "\"nodejs\""
230
230
  }
231
231
  },
232
+ {
233
+ "file": "app/api/newsroom/chat/[...path]/route.ts",
234
+ "hasDefault": false,
235
+ "exports": [
236
+ "GET",
237
+ "POST"
238
+ ],
239
+ "config": {
240
+ "dynamic": "\"force-dynamic\"",
241
+ "runtime": "\"nodejs\""
242
+ }
243
+ },
232
244
  {
233
245
  "file": "app/api/newsroom/curation/context/route.ts",
234
246
  "hasDefault": false,
@@ -50,7 +50,7 @@ export function staticGenerationSettings() {
50
50
  };
51
51
  }
52
52
 
53
- export const DEFAULT_TRANSPILE_PACKAGES = ["@anthusai/papyrus", "cyclotron"];
53
+ export const DEFAULT_TRANSPILE_PACKAGES = ["@anthusai/papyrus", "cyclotron", "chatticus"];
54
54
 
55
55
  export function mergeTranspilePackages(existing = []) {
56
56
  return [...new Set([...existing, ...DEFAULT_TRANSPILE_PACKAGES])];