@anthusai/papyrus 1.0.0 → 1.1.1-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/amplify/auth/prospectus-google-client.js +66 -0
  2. package/amplify/auth/prospectus-google-client.ts +71 -0
  3. package/amplify/auth/resource.js +4 -1
  4. package/amplify/auth/resource.ts +5 -1
  5. package/amplify/data/schema.js +0 -2
  6. package/amplify/data/schema.ts +0 -2
  7. package/amplify/functions/email-submission-processor/handler.py +3 -1
  8. package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
  9. package/amplify/functions/shared/email-submission.ts +109 -1
  10. package/amplify/site-backend.js +8 -1
  11. package/amplify/site-backend.ts +9 -1
  12. package/app/api/auth/prospectus/callback/route.ts +9 -0
  13. package/app/api/auth/prospectus/selection/route.ts +9 -0
  14. package/app/api/auth/prospectus/session/route.ts +9 -0
  15. package/app/api/auth/prospectus/sign-in/route.ts +9 -0
  16. package/app/api/auth/prospectus/sign-out/route.ts +9 -0
  17. package/app/tailwind.css +0 -6
  18. package/components/newsroom-board-view.tsx +2 -2
  19. package/components/priming-proof-page.tsx +7 -5
  20. package/components/topic-steering-workspace.tsx +0 -2
  21. package/infra/amplify-app-shell.js +20 -2
  22. package/infra/amplify-app-shell.ts +22 -2
  23. package/infra/site-config.js +1 -1
  24. package/infra/site-config.ts +1 -1
  25. package/lib/approved-priming-proof-reports.ts +1 -1
  26. package/lib/newsroom-board.ts +8 -5
  27. package/lib/priming-proof-report.ts +1 -0
  28. package/lib/prospectus-publication-access.ts +53 -0
  29. package/lib/prospectus-session.ts +196 -0
  30. package/lib/prospectus-sign-in-routes.ts +284 -0
  31. package/package.json +10 -8
  32. package/routes.manifest.json +56 -1
  33. package/components/ai-elements/message.tsx +0 -360
@@ -0,0 +1,66 @@
1
+ import { CfnOutput } from "aws-cdk-lib";
2
+ import { CfnUserPoolClient, CfnUserPoolIdentityProvider } from "aws-cdk-lib/aws-cognito";
3
+ /**
4
+ * The Google-only app client a Papyrus site registers with Prospectus
5
+ * (Prospectus docs/accounts/GOOGLE_IDENTITY.md, policy google-only-unlinked-v1).
6
+ * It lives on the site's existing user pool next to the email + Google web
7
+ * client, but admits only the built-in Google provider through code + PKCE from
8
+ * the site server, with every native, SRP, custom and client-credentials path
9
+ * left off. Sessions last one access token: the server keeps no refresh token.
10
+ */
11
+ export const PROSPECTUS_SIGN_IN_CALLBACK_PATH = "/api/auth/prospectus/callback";
12
+ export const PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES = 60;
13
+ export function prospectusSignInCallbackUrls(redirectUrls) {
14
+ const origins = [];
15
+ for (const redirectUrl of redirectUrls) {
16
+ try {
17
+ const origin = new URL(redirectUrl).origin;
18
+ if (origin !== "null" && !origins.includes(origin))
19
+ origins.push(origin);
20
+ }
21
+ catch {
22
+ continue;
23
+ }
24
+ }
25
+ return origins.map((origin) => `${origin}${PROSPECTUS_SIGN_IN_CALLBACK_PATH}`);
26
+ }
27
+ export function prospectusSignInLogoutUrls(redirectUrls) {
28
+ return prospectusSignInCallbackUrls(redirectUrls).map((callbackUrl) => `${new URL(callbackUrl).origin}/`);
29
+ }
30
+ export function addProspectusGoogleOnlyClient(authStack, userPool, redirectUrls) {
31
+ const callbackUrls = prospectusSignInCallbackUrls(redirectUrls);
32
+ if (callbackUrls.length === 0)
33
+ throw new Error("The Prospectus Google-only client needs at least one site origin.");
34
+ const googleOnlyClient = new CfnUserPoolClient(authStack, "ProspectusGoogleOnlyClient", {
35
+ userPoolId: userPool.userPoolId,
36
+ clientName: "prospectus-google-only",
37
+ generateSecret: false,
38
+ supportedIdentityProviders: ["Google"],
39
+ allowedOAuthFlowsUserPoolClient: true,
40
+ allowedOAuthFlows: ["code"],
41
+ allowedOAuthScopes: ["openid", "email", "profile"],
42
+ callbackUrLs: callbackUrls,
43
+ logoutUrLs: prospectusSignInLogoutUrls(redirectUrls),
44
+ explicitAuthFlows: ["ALLOW_REFRESH_TOKEN_AUTH"],
45
+ enableTokenRevocation: true,
46
+ preventUserExistenceErrors: "ENABLED",
47
+ accessTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
48
+ idTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
49
+ refreshTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
50
+ tokenValidityUnits: { accessToken: "minutes", idToken: "minutes", refreshToken: "minutes" },
51
+ });
52
+ const googleIdentityProviders = authStack.node
53
+ .findAll()
54
+ .filter((construct) => construct instanceof CfnUserPoolIdentityProvider && construct.providerName === "Google");
55
+ for (const googleIdentityProvider of googleIdentityProviders)
56
+ googleOnlyClient.addDependency(googleIdentityProvider);
57
+ new CfnOutput(authStack, "ProspectusGoogleOnlyClientId", {
58
+ value: googleOnlyClient.ref,
59
+ description: "clientId for this site's Prospectus registration (accounts/papyrus-sites.json)",
60
+ });
61
+ new CfnOutput(authStack, "ProspectusGoogleOnlyIssuer", {
62
+ value: `https://cognito-idp.${authStack.region}.amazonaws.com/${userPool.userPoolId}`,
63
+ description: "issuer for this site's Prospectus registration (accounts/papyrus-sites.json)",
64
+ });
65
+ return googleOnlyClient;
66
+ }
@@ -0,0 +1,71 @@
1
+ import { CfnOutput, type Stack } from "aws-cdk-lib";
2
+ import { CfnUserPoolClient, CfnUserPoolIdentityProvider, type IUserPool } from "aws-cdk-lib/aws-cognito";
3
+
4
+ /**
5
+ * The Google-only app client a Papyrus site registers with Prospectus
6
+ * (Prospectus docs/accounts/GOOGLE_IDENTITY.md, policy google-only-unlinked-v1).
7
+ * It lives on the site's existing user pool next to the email + Google web
8
+ * client, but admits only the built-in Google provider through code + PKCE from
9
+ * the site server, with every native, SRP, custom and client-credentials path
10
+ * left off. Sessions last one access token: the server keeps no refresh token.
11
+ */
12
+ export const PROSPECTUS_SIGN_IN_CALLBACK_PATH = "/api/auth/prospectus/callback";
13
+ export const PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES = 60;
14
+
15
+ export function prospectusSignInCallbackUrls(redirectUrls: readonly string[]): string[] {
16
+ const origins: string[] = [];
17
+ for (const redirectUrl of redirectUrls) {
18
+ try {
19
+ const origin = new URL(redirectUrl).origin;
20
+ if (origin !== "null" && !origins.includes(origin)) origins.push(origin);
21
+ } catch {
22
+ continue;
23
+ }
24
+ }
25
+ return origins.map((origin) => `${origin}${PROSPECTUS_SIGN_IN_CALLBACK_PATH}`);
26
+ }
27
+
28
+ export function prospectusSignInLogoutUrls(redirectUrls: readonly string[]): string[] {
29
+ return prospectusSignInCallbackUrls(redirectUrls).map((callbackUrl) => `${new URL(callbackUrl).origin}/`);
30
+ }
31
+
32
+ export function addProspectusGoogleOnlyClient(
33
+ authStack: Stack,
34
+ userPool: IUserPool,
35
+ redirectUrls: readonly string[],
36
+ ): CfnUserPoolClient {
37
+ const callbackUrls = prospectusSignInCallbackUrls(redirectUrls);
38
+ if (callbackUrls.length === 0) throw new Error("The Prospectus Google-only client needs at least one site origin.");
39
+ const googleOnlyClient = new CfnUserPoolClient(authStack, "ProspectusGoogleOnlyClient", {
40
+ userPoolId: userPool.userPoolId,
41
+ clientName: "prospectus-google-only",
42
+ generateSecret: false,
43
+ supportedIdentityProviders: ["Google"],
44
+ allowedOAuthFlowsUserPoolClient: true,
45
+ allowedOAuthFlows: ["code"],
46
+ allowedOAuthScopes: ["openid", "email", "profile"],
47
+ callbackUrLs: callbackUrls,
48
+ logoutUrLs: prospectusSignInLogoutUrls(redirectUrls),
49
+ explicitAuthFlows: ["ALLOW_REFRESH_TOKEN_AUTH"],
50
+ enableTokenRevocation: true,
51
+ preventUserExistenceErrors: "ENABLED",
52
+ accessTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
53
+ idTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
54
+ refreshTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
55
+ tokenValidityUnits: { accessToken: "minutes", idToken: "minutes", refreshToken: "minutes" },
56
+ });
57
+ const googleIdentityProviders = authStack.node
58
+ .findAll()
59
+ .filter((construct): construct is CfnUserPoolIdentityProvider => construct instanceof CfnUserPoolIdentityProvider && construct.providerName === "Google");
60
+ for (const googleIdentityProvider of googleIdentityProviders) googleOnlyClient.addDependency(googleIdentityProvider);
61
+
62
+ new CfnOutput(authStack, "ProspectusGoogleOnlyClientId", {
63
+ value: googleOnlyClient.ref,
64
+ description: "clientId for this site's Prospectus registration (accounts/papyrus-sites.json)",
65
+ });
66
+ new CfnOutput(authStack, "ProspectusGoogleOnlyIssuer", {
67
+ value: `https://cognito-idp.${authStack.region}.amazonaws.com/${userPool.userPoolId}`,
68
+ description: "issuer for this site's Prospectus registration (accounts/papyrus-sites.json)",
69
+ });
70
+ return googleOnlyClient;
71
+ }
@@ -32,8 +32,11 @@ export function authConfigFromEnv() {
32
32
  applyCognitoDomainPrefix: process.env.PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX === "true",
33
33
  };
34
34
  }
35
+ export function siteAuthRedirectUrls(config) {
36
+ return config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
37
+ }
35
38
  export function defineSiteAuth(config) {
36
- const authRedirectUrls = expandNewsroomRedirectUrls(config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS, config.newsroomBasePath);
39
+ const authRedirectUrls = expandNewsroomRedirectUrls(siteAuthRedirectUrls(config), config.newsroomBasePath);
37
40
  const disableGoogleOAuth = config.disableGoogleOAuth === true;
38
41
  return defineAuth({
39
42
  loginWith: {
@@ -37,9 +37,13 @@ export function authConfigFromEnv(): PapyrusAuthConfig {
37
37
  };
38
38
  }
39
39
 
40
+ export function siteAuthRedirectUrls(config: PapyrusAuthConfig): string[] {
41
+ return config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
42
+ }
43
+
40
44
  export function defineSiteAuth(config: PapyrusAuthConfig) {
41
45
  const authRedirectUrls = expandNewsroomRedirectUrls(
42
- config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS,
46
+ siteAuthRedirectUrls(config),
43
47
  config.newsroomBasePath,
44
48
  );
45
49
  const disableGoogleOAuth = config.disableGoogleOAuth === true;
@@ -815,7 +815,6 @@ const baseDefinitions = {
815
815
  ])
816
816
  .authorization((allow) => [
817
817
  allow.groups(categoryWriteGroups).to(categoryAppendOnlyOperations),
818
- allow.authenticated("identityPool").to(authoringOperations),
819
818
  ]),
820
819
  AssignmentEvent: modelWithoutSubscriptions({
821
820
  id: a.id().required(),
@@ -1395,7 +1394,6 @@ const baseDefinitions = {
1395
1394
  ])
1396
1395
  .authorization((allow) => [
1397
1396
  allow.groups(categoryWriteGroups).to(["read", "create", "update"]),
1398
- allow.authenticated("identityPool").to(authoringOperations),
1399
1397
  ]),
1400
1398
  ModelAttachment: modelWithoutSubscriptions({
1401
1399
  id: a.id().required(),
@@ -886,7 +886,6 @@ const baseDefinitions = {
886
886
  ])
887
887
  .authorization((allow) => [
888
888
  allow.groups(categoryWriteGroups).to(categoryAppendOnlyOperations),
889
- allow.authenticated("identityPool").to(authoringOperations),
890
889
  ]),
891
890
 
892
891
  AssignmentEvent: modelWithoutSubscriptions({
@@ -1486,7 +1485,6 @@ const baseDefinitions = {
1486
1485
  ])
1487
1486
  .authorization((allow) => [
1488
1487
  allow.groups(categoryWriteGroups).to(["read", "create", "update"]),
1489
- allow.authenticated("identityPool").to(authoringOperations),
1490
1488
  ]),
1491
1489
 
1492
1490
  ModelAttachment: modelWithoutSubscriptions({
@@ -30,8 +30,10 @@ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
30
30
  event.get("corpusKey")
31
31
  or event.get("corpus_key")
32
32
  or os.environ.get("PAPYRUS_INBOUND_EMAIL_CORPUS_KEY")
33
- or "AI-ML-research"
33
+ or ""
34
34
  ).strip()
35
+ if not corpus_key:
36
+ raise ValueError("corpusKey or PAPYRUS_INBOUND_EMAIL_CORPUS_KEY is required.")
35
37
 
36
38
  from papyrus_content.graphql_authoring import PapyrusGraphQLAuthoringClient
37
39
  from papyrus_newsroom.email_submissions import process_email_submission_message
@@ -2,6 +2,13 @@ import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
2
2
  import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
3
3
  import {
4
4
  buildEmailSubmissionMessageInput,
5
+ isInboundSenderAuthenticated,
6
+ readInboundSenderAuthenticationFromRawMime,
7
+ readInboundScanVerdictsFromSesReceipt,
8
+ REJECTION_KIND_UNAUTHENTICATED_SENDER,
9
+ UNAUTHENTICATED_SENDER_RESPONSE_ERROR,
10
+ UNVERIFIED_SENDER_AUTHENTICATION,
11
+ type InboundSenderAuthentication,
5
12
  classifyInboundEmailIntake,
6
13
  countInboundAttachments,
7
14
  extractDirectCitations,
@@ -49,6 +56,7 @@ type InboundPayload = {
49
56
  attachmentCount: number;
50
57
  userComposedText: string;
51
58
  parentSubmissionMessageId: string | null;
59
+ senderAuthentication: InboundSenderAuthentication;
52
60
  };
53
61
 
54
62
  type ExistingMessage = {
@@ -123,7 +131,8 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
123
131
  const resolvedMessageId = messageId ?? `message-email-submission-${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
124
132
  const dataClient = await getLambdaDataClient();
125
133
  const profileId = await lookupRegisteredUserProfileId(dataClient, inbound.senderEmail);
126
- const authorized = Boolean(profileId);
134
+ const senderAuthenticated = isInboundSenderAuthenticated(inbound.senderAuthentication, inbound.senderEmail);
135
+ const authorized = Boolean(profileId) && senderAuthenticated;
127
136
  const citations = authorized
128
137
  ? (inbound.citations.length > 0
129
138
  ? inbound.citations
@@ -143,7 +152,9 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
143
152
 
144
153
  let status = authorized ? "received" : "rejected";
145
154
  let responseStatus = authorized ? "PENDING" : "REJECTED";
146
- let responseError: string | null = authorized ? null : UNREGISTERED_SENDER_RESPONSE_ERROR;
155
+ let responseError: string | null = authorized
156
+ ? null
157
+ : (senderAuthenticated ? UNREGISTERED_SENDER_RESPONSE_ERROR : UNAUTHENTICATED_SENDER_RESPONSE_ERROR);
147
158
 
148
159
  if (authorized && looksLikeResearchAssignmentRequest(inbound.bodyText, citations.length)) {
149
160
  status = "rejected";
@@ -173,7 +184,8 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
173
184
  s3Bucket: inbound.s3Bucket,
174
185
  s3Key: inbound.s3Key,
175
186
  authorized,
176
- authorUserProfileId: profileId,
187
+ rejectionKind: senderAuthenticated ? undefined : REJECTION_KIND_UNAUTHENTICATED_SENDER,
188
+ authorUserProfileId: authorized ? profileId : null,
177
189
  authorLabel: inbound.senderEmail || "unknown-sender",
178
190
  citations,
179
191
  status,
@@ -205,7 +217,7 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
205
217
 
206
218
  if (authorized && responseStatus === "PENDING" && PROCESSOR_FUNCTION_NAME) {
207
219
  await invokeEmailProcessor(resolvedMessageId);
208
- } else if (responseStatus === "REJECTED" && PROCESSOR_FUNCTION_NAME) {
220
+ } else if (responseStatus === "REJECTED" && senderAuthenticated && PROCESSOR_FUNCTION_NAME) {
209
221
  await invokeEmailProcessor(resolvedMessageId, { sendFeedbackOnly: true });
210
222
  }
211
223
 
@@ -390,6 +402,7 @@ async function resolveFromSesMail(
390
402
  senderEmail: loaded.senderEmail || senderEmail,
391
403
  recipients: loaded.recipients.length > 0 ? loaded.recipients : recipients,
392
404
  subject: loaded.subject !== "(no subject)" ? loaded.subject : subject,
405
+ senderAuthentication: mergeScanVerdicts(loaded.senderAuthentication, readInboundScanVerdictsFromSesReceipt(receipt)),
393
406
  };
394
407
  }
395
408
 
@@ -407,6 +420,7 @@ async function resolveFromSesMail(
407
420
  attachmentCount: 0,
408
421
  userComposedText: "",
409
422
  parentSubmissionMessageId: null,
423
+ senderAuthentication: { ...UNVERIFIED_SENDER_AUTHENTICATION, ...readInboundScanVerdictsFromSesReceipt(receipt) },
410
424
  });
411
425
  }
412
426
 
@@ -433,6 +447,7 @@ async function loadInboundFromS3Object(
433
447
  attachmentCount: countInboundAttachments(rawBytes),
434
448
  userComposedText: extractUserComposedReplyText(intake.bodyText),
435
449
  parentSubmissionMessageId: null,
450
+ senderAuthentication: readInboundSenderAuthenticationFromRawMime(rawBytes),
436
451
  });
437
452
  }
438
453
 
@@ -446,6 +461,19 @@ function enrichInboundPayload(payload: InboundPayload): InboundPayload {
446
461
  };
447
462
  }
448
463
 
464
+ function mergeScanVerdicts(
465
+ authentication: InboundSenderAuthentication,
466
+ receiptVerdicts: Pick<InboundSenderAuthentication, "spamVerdict" | "virusVerdict">,
467
+ ): InboundSenderAuthentication {
468
+ const worst = (fromMime: string, fromReceipt: string) =>
469
+ fromMime === "fail" || fromReceipt === "fail" ? "fail" : fromMime !== "unknown" ? fromMime : fromReceipt;
470
+ return {
471
+ ...authentication,
472
+ spamVerdict: worst(authentication.spamVerdict, receiptVerdicts.spamVerdict),
473
+ virusVerdict: worst(authentication.virusVerdict, receiptVerdicts.virusVerdict),
474
+ };
475
+ }
476
+
449
477
  function sesMessageIdFromObjectKey(key: string): string | null {
450
478
  const basename = key.split("/").pop() ?? "";
451
479
  const match = basename.match(/^[A-Za-z0-9_-]+$/);
@@ -11,6 +11,113 @@ export const REJECTION_KIND_UNREGISTERED_SENDER = "unregistered_sender";
11
11
  export const UNREGISTERED_SENDER_RESPONSE_ERROR =
12
12
  "This submission was not accepted because only registered Papyrus users may send reference submissions by email.";
13
13
 
14
+ export const UNAUTHENTICATED_SENDER_RESPONSE_ERROR =
15
+ "This submission was not accepted because the sender could not be authenticated (SPF or DKIM must pass).";
16
+ export const REJECTION_KIND_UNAUTHENTICATED_SENDER = "unauthenticated_sender";
17
+
18
+ export type InboundSenderAuthentication = {
19
+ spfPassDomains: string[];
20
+ dkimPassDomains: string[];
21
+ spamVerdict: string;
22
+ virusVerdict: string;
23
+ };
24
+
25
+ const TRUSTED_AUTHENTICATION_RESULTS_SERVER_ID = "amazonses.com";
26
+
27
+ export const UNVERIFIED_SENDER_AUTHENTICATION: InboundSenderAuthentication = {
28
+ spfPassDomains: [],
29
+ dkimPassDomains: [],
30
+ spamVerdict: "unknown",
31
+ virusVerdict: "unknown",
32
+ };
33
+
34
+ function domainOfAddress(address: string): string {
35
+ return normalizeEmailAddress(address).split("@")[1] ?? "";
36
+ }
37
+
38
+ function domainsAreAligned(authenticatedDomain: string, fromDomain: string): boolean {
39
+ if (!authenticatedDomain || !fromDomain) return false;
40
+ return authenticatedDomain === fromDomain
41
+ || fromDomain.endsWith(`.${authenticatedDomain}`)
42
+ || authenticatedDomain.endsWith(`.${fromDomain}`);
43
+ }
44
+
45
+ export function isInboundSenderAuthenticated(
46
+ authentication: InboundSenderAuthentication,
47
+ fromAddress: string,
48
+ ): boolean {
49
+ if (authentication.spamVerdict === "fail" || authentication.virusVerdict === "fail") return false;
50
+ const fromDomain = domainOfAddress(fromAddress);
51
+ return [...authentication.spfPassDomains, ...authentication.dkimPassDomains]
52
+ .some((domain) => domainsAreAligned(domain, fromDomain));
53
+ }
54
+
55
+ export function readInboundScanVerdictsFromSesReceipt(
56
+ receipt: Record<string, unknown> | undefined,
57
+ ): Pick<InboundSenderAuthentication, "spamVerdict" | "virusVerdict"> {
58
+ const verdictStatus = (key: string): string => {
59
+ const verdict = receipt?.[key];
60
+ const status = verdict && typeof verdict === "object" ? (verdict as { status?: unknown }).status : null;
61
+ return typeof status === "string" ? status.trim().toLowerCase() : "unknown";
62
+ };
63
+ return { spamVerdict: verdictStatus("spamVerdict"), virusVerdict: verdictStatus("virusVerdict") };
64
+ }
65
+
66
+ function readHeaderBlock(rawBytes: Uint8Array): string[] {
67
+ const raw = Buffer.from(rawBytes).toString("utf8");
68
+ const headerBlockEnd = raw.search(/\r?\n\r?\n/);
69
+ const headerBlock = headerBlockEnd === -1 ? raw : raw.slice(0, headerBlockEnd);
70
+ return headerBlock.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/);
71
+ }
72
+
73
+ function firstHeaderValue(headerLines: string[], name: string): string | null {
74
+ const prefix = new RegExp(`^${name}:\\s*`, "i");
75
+ const line = headerLines.find((entry) => prefix.test(entry));
76
+ return line ? line.replace(prefix, "") : null;
77
+ }
78
+
79
+ export function readInboundSenderAuthenticationFromRawMime(rawBytes: Uint8Array): InboundSenderAuthentication {
80
+ const headerLines = readHeaderBlock(rawBytes);
81
+ const verdictHeader = (name: string): string =>
82
+ (firstHeaderValue(headerLines, name) ?? "unknown").trim().toLowerCase();
83
+ const scanVerdicts = {
84
+ spamVerdict: verdictHeader("X-SES-Spam-Verdict"),
85
+ virusVerdict: verdictHeader("X-SES-Virus-Verdict"),
86
+ };
87
+ const topmostResults = firstHeaderValue(headerLines, "Authentication-Results");
88
+ if (!topmostResults) return { ...UNVERIFIED_SENDER_AUTHENTICATION, ...scanVerdicts };
89
+ const [serverId, ...parts] = topmostResults.replace(/\([^)]*\)/g, "").split(";").map((part) => part.trim());
90
+ if (serverId.toLowerCase() !== TRUSTED_AUTHENTICATION_RESULTS_SERVER_ID) {
91
+ return { ...UNVERIFIED_SENDER_AUTHENTICATION, ...scanVerdicts };
92
+ }
93
+
94
+ const spfPassDomains: string[] = [];
95
+ const dkimPassDomains: string[] = [];
96
+ let spfPassed = false;
97
+ let envelopeFromDomain = "";
98
+ let heloDomain = "";
99
+ for (const part of parts) {
100
+ const result = /^(spf|dkim)\s*=\s*([a-z]+)/i.exec(part);
101
+ if (result) {
102
+ const passed = result[2].toLowerCase() === "pass";
103
+ if (result[1].toLowerCase() === "spf") spfPassed = spfPassed || passed;
104
+ else if (passed) {
105
+ const signingDomain = /header\.(?:i|d)\s*=\s*@?([^\s;]+)/i.exec(part)?.[1];
106
+ if (signingDomain) dkimPassDomains.push(signingDomain.toLowerCase().replace(/^.*@/, ""));
107
+ }
108
+ }
109
+ const envelopeFrom = /^(?:envelope-from|smtp\.mailfrom)\s*=\s*(\S+)/i.exec(part)?.[1];
110
+ if (envelopeFrom) envelopeFromDomain = domainOfAddress(envelopeFrom.replace(/^<|>$/g, ""));
111
+ const helo = /^(?:helo|smtp\.helo)\s*=\s*(\S+)/i.exec(part)?.[1];
112
+ if (helo) heloDomain = helo.toLowerCase();
113
+ }
114
+ if (spfPassed) {
115
+ const spfDomain = envelopeFromDomain || heloDomain;
116
+ if (spfDomain) spfPassDomains.push(spfDomain);
117
+ }
118
+ return { spfPassDomains, dkimPassDomains, ...scanVerdicts };
119
+ }
120
+
14
121
  const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+/gi;
15
122
  const DOI_PATTERN = /\b10\.\d{4,9}\/[-._;()/:A-Z0-9]+/gi;
16
123
  const RESEARCH_ASSIGNMENT_PHRASES = [
@@ -337,6 +444,7 @@ export function buildEmailSubmissionMessageInput(input: {
337
444
  s3Bucket: string | null;
338
445
  s3Key: string | null;
339
446
  authorized: boolean;
447
+ rejectionKind?: string;
340
448
  authorUserProfileId: string | null;
341
449
  authorLabel: string;
342
450
  citations: Array<Record<string, unknown>>;
@@ -378,7 +486,7 @@ export function buildEmailSubmissionMessageInput(input: {
378
486
  ...(input.authorized
379
487
  ? {}
380
488
  : {
381
- rejectionKind: REJECTION_KIND_UNREGISTERED_SENDER,
489
+ rejectionKind: input.rejectionKind ?? REJECTION_KIND_UNREGISTERED_SENDER,
382
490
  }),
383
491
  directCitationCount: input.citations.length,
384
492
  directCitations: input.citations,
@@ -17,7 +17,8 @@ import { assertValidPublicationConfig } from "./site-backend-config.js";
17
17
  import { revalidateSecretReadStatement } from "./revalidate-secret-policy.js";
18
18
  import { revalidateDistributionEnvironment, revalidateInvalidationStatement, validateRevalidateDistributionIds, } from "./revalidate-invalidation-settings.js";
19
19
  import { addInvalidationPathAlarm } from "./publication/invalidation-alarm.js";
20
- import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
20
+ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource.js";
21
+ import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client.js";
21
22
  import { defineSiteData } from "./data/resource.js";
22
23
  import { resolvePlugins } from "./plugins/registry.js";
23
24
  import { wirePlugins } from "./plugins/wire.js";
@@ -107,6 +108,9 @@ export function defineSiteBackend(papyrusSite) {
107
108
  && authConfig.disableGoogleOAuth !== true) {
108
109
  applyCognitoDomainPrefix(backend.auth.stack, requestedCognitoDomainPrefix);
109
110
  }
111
+ const prospectusGoogleOnlyClient = authConfig.disableGoogleOAuth === true
112
+ ? undefined
113
+ : addProspectusGoogleOnlyClient(backend.auth.stack, backend.auth.resources.userPool, siteAuthRedirectUrls(authConfig));
110
114
  const amplifyBackendDir = dirname(fileURLToPath(import.meta.url));
111
115
  // Repo root in a Papyrus checkout; package root when installed from npm.
112
116
  const projectRoot = resolve(amplifyBackendDir, "..");
@@ -546,6 +550,9 @@ export function defineSiteBackend(papyrusSite) {
546
550
  }));
547
551
  backend.addOutput({
548
552
  custom: {
553
+ prospectusSignIn: prospectusGoogleOnlyClient
554
+ ? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
555
+ : null,
549
556
  inboundEmail: enableInboundEmail
550
557
  ? {
551
558
  domain: inboundEmailDomain,
@@ -24,7 +24,8 @@ import {
24
24
  validateRevalidateDistributionIds,
25
25
  } from "./revalidate-invalidation-settings";
26
26
  import { addInvalidationPathAlarm } from "./publication/invalidation-alarm";
27
- import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource";
27
+ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource";
28
+ import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client";
28
29
  import { defineSiteData } from "./data/resource";
29
30
  import { resolvePlugins } from "./plugins/registry";
30
31
  import { wirePlugins } from "./plugins/wire";
@@ -137,6 +138,10 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
137
138
  applyCognitoDomainPrefix(backend.auth.stack, requestedCognitoDomainPrefix);
138
139
  }
139
140
 
141
+ const prospectusGoogleOnlyClient = authConfig.disableGoogleOAuth === true
142
+ ? undefined
143
+ : addProspectusGoogleOnlyClient(backend.auth.stack, backend.auth.resources.userPool, siteAuthRedirectUrls(authConfig));
144
+
140
145
  const amplifyBackendDir = dirname(fileURLToPath(import.meta.url));
141
146
  // Repo root in a Papyrus checkout; package root when installed from npm.
142
147
  const projectRoot = resolve(amplifyBackendDir, "..");
@@ -682,6 +687,9 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
682
687
 
683
688
  backend.addOutput({
684
689
  custom: {
690
+ prospectusSignIn: prospectusGoogleOnlyClient
691
+ ? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
692
+ : null,
685
693
  inboundEmail: enableInboundEmail
686
694
  ? {
687
695
  domain: inboundEmailDomain,
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleProspectusSignInCallback } from "../../../../../lib/prospectus-sign-in-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleProspectusSignInCallback(request);
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleProspectusAccountSelection } from "../../../../../lib/prospectus-sign-in-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleProspectusAccountSelection(request);
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleProspectusSessionRead } from "../../../../../lib/prospectus-sign-in-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleProspectusSessionRead(request);
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleProspectusSignInStart } from "../../../../../lib/prospectus-sign-in-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleProspectusSignInStart(request);
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleProspectusSignOut } from "../../../../../lib/prospectus-sign-in-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleProspectusSignOut(request);
9
+ }
package/app/tailwind.css CHANGED
@@ -3,14 +3,8 @@
3
3
  @source "../components/**/*.{ts,tsx}";
4
4
  @source "../app/**/*.{ts,tsx}";
5
5
  @source "../lib/**/*.{ts,tsx}";
6
- @source "../../../streamdown/dist/*.js";
7
- @source "../../../@streamdown/code/dist/*.js";
8
- @source "../../../@streamdown/cjk/dist/*.js";
9
- @source "../../../@streamdown/math/dist/*.js";
10
- @source "../../../@streamdown/mermaid/dist/*.js";
11
6
  @import "tw-animate-css";
12
7
  @import "shadcn/tailwind.css";
13
- @import "streamdown/styles.css";
14
8
 
15
9
  @custom-variant dark (&:is(.dark *));
16
10
 
@@ -5,7 +5,7 @@ import Link from "next/link";
5
5
  import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter } from "@kanbus/ui";
6
6
  import "@kanbus/ui/styles/kanban.css";
7
7
  import "./newsroom-newsprint-board.css";
8
- import { canReadNewsroomBoard, newsroomBoardConfig, projectNewsroomBoard, type NativeBoardIssue } from "../lib/newsroom-board";
8
+ import { canReadNewsroomBoard, contentItemBoardType, newsroomBoardConfig, projectNewsroomBoard, type NativeBoardIssue } from "../lib/newsroom-board";
9
9
  import { getContentItemName, SITE_BRAND } from "../lib/site-brand";
10
10
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
11
11
  import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions, loadNewsroomReferencePage } from "./news-desk-taxonomy-client";
@@ -24,7 +24,7 @@ export function NewsroomBoardView() {
24
24
  const theme = useResolvedPapyrusTheme();
25
25
  const [lowMotion, setLowMotion] = useState(false);
26
26
  const [issues, setIssues] = useState<NativeBoardIssue[]>([]);
27
- const [filter, setFilter] = useState("all");
27
+ const [filter, setFilter] = useState(contentItemBoardType());
28
28
  const [view, setView] = useState<"board" | "list">("board");
29
29
  const [revision, setRevision] = useState(0);
30
30
  const [selected, setSelected] = useState<string | null>(null);
@@ -4,7 +4,7 @@ import {
4
4
  type PrimingProofReport,
5
5
  } from "../lib/priming-proof-report";
6
6
 
7
- function ProofMethod({ heldOutSize }: { heldOutSize: number | null }) {
7
+ function ProofMethod({ heldOutSize, teachingSource }: { heldOutSize: number | null; teachingSource: string | null }) {
8
8
  return (
9
9
  <section aria-labelledby="priming-proof-method" className="space-y-2 text-sm leading-relaxed">
10
10
  <h2 id="priming-proof-method" className="text-base font-semibold uppercase tracking-wide">How the number is made</h2>
@@ -14,8 +14,10 @@ function ProofMethod({ heldOutSize }: { heldOutSize: number | null }) {
14
14
  for teaching. A classifier that always gives the same answer agrees on half of it.
15
15
  </p>
16
16
  <p>
17
- Editors teach the classifier by pasting a source URL, reviewing what Papyrus extracted, and choosing publish or
18
- reject. Each point below starts a fresh classifier that sees only the first N of those labels, then decides
17
+ Editors teach the classifier with labels: their other reviews of candidate sources, and sources they paste,
18
+ check after Papyrus extracts them, and mark publish or reject.
19
+ {teachingSource === null ? "" : ` This run taught from ${teachingSource}.`} Each point below starts a fresh
20
+ classifier that sees only the first N of those labels, then decides
19
21
  every held-out source once. Nothing else changes between points: the same decision model, the publication&apos;s
20
22
  mission and policies as the only rubric, and no automatic rubric rewriting.
21
23
  </p>
@@ -31,7 +33,7 @@ export function PrimingProofPage({ report }: { report: PrimingProofReport | null
31
33
  <h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
32
34
  <p className="text-muted-foreground">No approved proof run has been published for this publication yet.</p>
33
35
  </header>
34
- <ProofMethod heldOutSize={null} />
36
+ <ProofMethod heldOutSize={null} teachingSource={null} />
35
37
  </main>
36
38
  );
37
39
  }
@@ -82,7 +84,7 @@ export function PrimingProofPage({ report }: { report: PrimingProofReport | null
82
84
  </table>
83
85
  </div>
84
86
  </section>
85
- <ProofMethod heldOutSize={report.heldOut.size} />
87
+ <ProofMethod heldOutSize={report.heldOut.size} teachingSource={report.priming.source} />
86
88
  <p className="break-all text-xs text-muted-foreground">
87
89
  Held-out fingerprint {report.heldOut.fingerprint}
88
90
  {report.priming.fingerprint ? ` · labels fingerprint ${report.priming.fingerprint}` : ""}
@@ -263,7 +263,6 @@ type TopicProposalEditState = {
263
263
  };
264
264
  type AnalysisCommandPlanEntry = {
265
265
  label: string;
266
- cwd: string;
267
266
  executable: string;
268
267
  args: string[];
269
268
  metadata?: Record<string, unknown>;
@@ -13607,7 +13606,6 @@ function buildAnalysisDestructivePreview({
13607
13606
  function analysisCommand(label: string, args: string[], mode: AnalysisReindexMode): AnalysisCommandPlanEntry {
13608
13607
  return {
13609
13608
  label,
13610
- cwd: "/Users/ryan/Projects/Biblicus",
13611
13609
  executable: "uv",
13612
13610
  args: ["run", "--extra", "topic-modeling", "biblicus", ...args],
13613
13611
  metadata: { mode },