@anthusai/papyrus 1.0.0-next.63 → 1.0.0-next.65
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/auth/prospectus-google-client.js +66 -0
- package/amplify/auth/prospectus-google-client.ts +71 -0
- package/amplify/auth/resource.js +4 -1
- package/amplify/auth/resource.ts +5 -1
- package/amplify/data/schema.js +0 -2
- package/amplify/data/schema.ts +0 -2
- package/amplify/functions/email-submission-processor/handler.py +3 -1
- package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
- package/amplify/functions/shared/email-submission.ts +109 -1
- package/amplify/site-backend.js +8 -1
- package/amplify/site-backend.ts +9 -1
- package/app/api/auth/prospectus/callback/route.ts +9 -0
- package/app/api/auth/prospectus/selection/route.ts +9 -0
- package/app/api/auth/prospectus/session/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +9 -0
- package/app/api/auth/prospectus/sign-out/route.ts +9 -0
- package/app/tailwind.css +0 -6
- package/components/topic-steering-workspace.tsx +0 -2
- package/infra/amplify-app-shell.js +20 -2
- package/infra/amplify-app-shell.ts +22 -2
- package/infra/site-config.js +1 -1
- package/infra/site-config.ts +1 -1
- package/lib/prospectus-publication-access.ts +53 -0
- package/lib/prospectus-session.ts +196 -0
- package/lib/prospectus-sign-in-routes.ts +284 -0
- package/package.json +10 -8
- package/routes.manifest.json +56 -1
- 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
|
+
}
|
package/amplify/auth/resource.js
CHANGED
|
@@ -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
|
|
39
|
+
const authRedirectUrls = expandNewsroomRedirectUrls(siteAuthRedirectUrls(config), config.newsroomBasePath);
|
|
37
40
|
const disableGoogleOAuth = config.disableGoogleOAuth === true;
|
|
38
41
|
return defineAuth({
|
|
39
42
|
loginWith: {
|
package/amplify/auth/resource.ts
CHANGED
|
@@ -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
|
|
46
|
+
siteAuthRedirectUrls(config),
|
|
43
47
|
config.newsroomBasePath,
|
|
44
48
|
);
|
|
45
49
|
const disableGoogleOAuth = config.disableGoogleOAuth === true;
|
package/amplify/data/schema.js
CHANGED
|
@@ -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(),
|
package/amplify/data/schema.ts
CHANGED
|
@@ -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 "
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
package/amplify/site-backend.js
CHANGED
|
@@ -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,
|
package/amplify/site-backend.ts
CHANGED
|
@@ -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
|
|
|
@@ -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 },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ArnFormat, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
|
|
2
2
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
4
|
+
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
|
|
4
5
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
5
6
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
6
7
|
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
@@ -86,9 +87,26 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
86
87
|
}));
|
|
87
88
|
}
|
|
88
89
|
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
89
|
-
const
|
|
90
|
-
?
|
|
90
|
+
const prospectusSessionSealingSecret = prospectusAccountsApiStageArn
|
|
91
|
+
? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
|
|
92
|
+
description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
|
|
93
|
+
generateSecretString: { passwordLength: 64, excludePunctuation: true },
|
|
94
|
+
})
|
|
95
|
+
: undefined;
|
|
96
|
+
const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealingSecret
|
|
97
|
+
? {
|
|
98
|
+
PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
|
|
99
|
+
PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
|
|
100
|
+
PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
|
|
101
|
+
}
|
|
91
102
|
: {};
|
|
103
|
+
if (prospectusSessionSealingSecret) {
|
|
104
|
+
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
105
|
+
sid: "ReadProspectusSessionSealingSecret",
|
|
106
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
107
|
+
resources: [prospectusSessionSealingSecret.secretArn],
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
92
110
|
if (prospectusAccountsApiStageArn) {
|
|
93
111
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
94
112
|
sid: "InvokeProspectusAccountsAsRegisteredSite",
|