@anthusai/papyrus 2.0.0-next.2 → 2.0.0-next.20

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 (93) hide show
  1. package/amplify/data/resource.js +13 -0
  2. package/amplify/data/resource.ts +15 -0
  3. package/amplify/data/schema.js +18 -0
  4. package/amplify/data/schema.ts +20 -0
  5. package/amplify/functions/assignment-action/data-access.js +88 -0
  6. package/amplify/functions/assignment-action/data-access.ts +87 -0
  7. package/amplify/functions/content-actions/handler.py +22 -0
  8. package/amplify/functions/editorial-checks/handler.py +14 -0
  9. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  10. package/amplify/functions/editorial-checks/resource.js +43 -0
  11. package/amplify/functions/editorial-checks/resource.ts +60 -0
  12. package/amplify/functions/editorial-checks/schema.js +92 -0
  13. package/amplify/functions/editorial-checks/schema.ts +94 -0
  14. package/amplify/functions/editorial-checks/wire.js +49 -0
  15. package/amplify/functions/editorial-checks/wire.ts +57 -0
  16. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  17. package/amplify/functions/monitoring-view/handler.py +11 -0
  18. package/amplify/functions/monitoring-view/resource.js +24 -0
  19. package/amplify/functions/monitoring-view/resource.ts +34 -0
  20. package/amplify/functions/monitoring-view/schema.js +16 -0
  21. package/amplify/functions/monitoring-view/schema.ts +18 -0
  22. package/amplify/functions/monitoring-view/wire.js +37 -0
  23. package/amplify/functions/monitoring-view/wire.ts +47 -0
  24. package/amplify/functions/shared/python-bundle.js +20 -14
  25. package/amplify/functions/shared/python-bundle.ts +25 -18
  26. package/amplify/publication/redirect-and-index.js +83 -0
  27. package/amplify/publication/redirect-rules.js +61 -0
  28. package/amplify/publication/redirect-rules.ts +79 -0
  29. package/amplify/publication/s3-projection.js +5 -1
  30. package/amplify/publication/s3-projection.ts +7 -1
  31. package/amplify/site-backend.js +18 -0
  32. package/amplify/site-backend.ts +24 -0
  33. package/app/api/newsroom/curation/context/route.ts +9 -0
  34. package/app/api/newsroom/curation/priming/route.ts +9 -0
  35. package/app/api/newsroom/curation/publication/route.ts +9 -0
  36. package/bin/papyrus-infra.mjs +20 -1
  37. package/components/editorial-checks-admin-panel.tsx +347 -0
  38. package/components/editorial-finding-card.tsx +162 -0
  39. package/components/editorial-findings-panel.tsx +91 -0
  40. package/components/editorial-preview-popover.tsx +103 -0
  41. package/components/markus-body-editor.tsx +213 -0
  42. package/components/markus-ir-preview.tsx +147 -6
  43. package/components/monitoring-overview-view.tsx +240 -0
  44. package/components/monitoring-panel.tsx +40 -0
  45. package/components/newsroom-article-editor.tsx +136 -11
  46. package/components/newsroom-board-view.tsx +6 -7
  47. package/components/newsroom-curation-bootstrap.tsx +45 -0
  48. package/components/newsroom-ops-shell.tsx +7 -3
  49. package/components/newsroom-organizer.css +74 -61
  50. package/components/newsroom-references-view.tsx +14 -14
  51. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  52. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  53. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  54. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  55. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  56. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  57. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  58. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  59. package/components/newsroom-theme.css +39 -0
  60. package/components/newsroom-theme.tsx +33 -0
  61. package/components/publication-cyclotron-panel.tsx +10 -10
  62. package/components/reference-add-entry.tsx +75 -0
  63. package/components/reference-curation-controls.tsx +30 -21
  64. package/components/reference-cyclotron-priming.tsx +4 -4
  65. package/components/reference-cyclotron-suggestion.tsx +1 -1
  66. package/components/reference-priming.tsx +14 -10
  67. package/components/reference-section-suggestion.tsx +1 -1
  68. package/components/topic-steering-workspace.tsx +27 -2
  69. package/components/use-editorial-checks.ts +133 -0
  70. package/lib/category-repository.ts +11 -9
  71. package/lib/curation-routes.ts +342 -0
  72. package/lib/editorial-checks-backend.ts +16 -0
  73. package/lib/editorial-checks-client.ts +194 -0
  74. package/lib/editorial-checks-demo.ts +147 -0
  75. package/lib/editorial-checks-settings.ts +27 -0
  76. package/lib/editorial-checks.ts +171 -0
  77. package/lib/editorial-preview.ts +228 -0
  78. package/lib/monitoring-client.ts +29 -0
  79. package/lib/monitoring-overview-fixture.ts +51 -0
  80. package/lib/monitoring-overview.ts +144 -0
  81. package/lib/newsroom-board.ts +27 -2
  82. package/lib/relevance-status.ts +2 -2
  83. package/lib/site-brand-tenant.ts +11 -6
  84. package/lib/site-brand.ts +23 -3
  85. package/package.json +46 -3
  86. package/routes.manifest.json +34 -1
  87. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  88. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  89. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  90. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  91. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  92. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  93. package/components/newsroom-newsprint-board.css +0 -7
@@ -0,0 +1,94 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { editorialChecks } from "./resource";
3
+
4
+ const editors = ["editor", "admin"];
5
+
6
+ /**
7
+ * Core to every site's data API. Policies, settings and editors' decisions live in Papyrus; Humanus checks
8
+ * content against them. Editors read policies and record decisions; admins
9
+ * manage policies and settings. Only the function (IAM) calls Humanus.
10
+ */
11
+ export const editorialChecksSchemaDefinitions = {
12
+ EditorialPolicy: a
13
+ .model({
14
+ id: a.id().required(),
15
+ policyKey: a.string().required(),
16
+ scope: a.string().required(),
17
+ version: a.integer().required(),
18
+ title: a.string().required(),
19
+ guidance: a.string().required(),
20
+ severity: a.string().required(),
21
+ enabled: a.boolean().required(),
22
+ detectors: a.json().required(),
23
+ examples: a.json(),
24
+ rewriteGuidance: a.string(),
25
+ source: a.string().required(),
26
+ })
27
+ .disableOperations(["subscriptions"])
28
+ .authorization((allow) => [allow.group("editor").to(["read"]), allow.group("admin")]),
29
+ EditorialCheckSettings: a
30
+ .model({
31
+ id: a.id().required(),
32
+ settings: a.json().required(),
33
+ })
34
+ .disableOperations(["subscriptions"])
35
+ .authorization((allow) => [allow.group("editor").to(["read"]), allow.group("admin")]),
36
+ EditorialFeedback: a
37
+ .model({
38
+ id: a.id().required(),
39
+ itemId: a.string().required(),
40
+ findingId: a.string().required(),
41
+ policyId: a.string().required(),
42
+ detectorId: a.string().required(),
43
+ lane: a.string().required(),
44
+ probability: a.float(),
45
+ decision: a.string().required(),
46
+ reason: a.string(),
47
+ text: a.string(),
48
+ })
49
+ .disableOperations(["subscriptions"])
50
+ .secondaryIndexes((index) => [index("itemId").queryField("listEditorialFeedbackByItem")])
51
+ .authorization((allow) => [allow.group("editor").to(["create", "read"]), allow.group("admin")]),
52
+ checkEditorial: a
53
+ .query()
54
+ .arguments({ body: a.string().required(), format: a.string(), section: a.string() })
55
+ .returns(a.json())
56
+ .authorization((allow) => [allow.groups(editors)])
57
+ .handler(a.handler.function(editorialChecks)),
58
+ suggestEditorialRewrite: a
59
+ .query()
60
+ .arguments({
61
+ body: a.string().required(),
62
+ format: a.string(),
63
+ section: a.string(),
64
+ finding: a.json(),
65
+ selection: a.json(),
66
+ note: a.string(),
67
+ })
68
+ .returns(a.json())
69
+ .authorization((allow) => [allow.groups(editors)])
70
+ .handler(a.handler.function(editorialChecks)),
71
+ draftEditorialQuestions: a
72
+ .query()
73
+ .arguments({ title: a.string().required(), guidance: a.string().required(), examples: a.json() })
74
+ .returns(a.json())
75
+ .authorization((allow) => [allow.group("admin")])
76
+ .handler(a.handler.function(editorialChecks)),
77
+ tryEditorialPolicy: a
78
+ .query()
79
+ .arguments({ policy: a.json().required(), text: a.string().required(), format: a.string() })
80
+ .returns(a.json())
81
+ .authorization((allow) => [allow.group("admin")])
82
+ .handler(a.handler.function(editorialChecks)),
83
+ importEditorialStarterPack: a
84
+ .mutation()
85
+ .arguments({ name: a.string().required() })
86
+ .returns(a.json())
87
+ .authorization((allow) => [allow.group("admin")])
88
+ .handler(a.handler.function(editorialChecks)),
89
+ recalibrateEditorialPolicies: a
90
+ .mutation()
91
+ .returns(a.json())
92
+ .authorization((allow) => [allow.group("admin")])
93
+ .handler(a.handler.function(editorialChecks)),
94
+ };
@@ -0,0 +1,49 @@
1
+ import { secret } from "@aws-amplify/backend";
2
+ import { Stack } from "aws-cdk-lib";
3
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ /** The model operations the function performs with its own role. The two context reads are its least-privilege access to private policy. */
5
+ export const EDITORIAL_CHECKS_FUNCTION_FIELDS = [
6
+ "Query/fields/listKnowledgeCorporaByRoleAndName",
7
+ "Query/fields/getEditorialContext",
8
+ "Query/fields/listEditorialPolicies",
9
+ "Query/fields/getEditorialCheckSettings",
10
+ "Query/fields/listEditorialFeedbacks",
11
+ "Mutation/fields/createEditorialPolicy",
12
+ "Mutation/fields/updateEditorialPolicy",
13
+ ];
14
+ export const EDITORIAL_CHECKS_SECRETS = ["TYPESAFE_API_KEY", "OPENAI_API_KEY"];
15
+ /**
16
+ * SSM parameters holding the provider keys. A Python function reads its secret()
17
+ * values itself (Amplify only resolves them for Node functions), so it needs read
18
+ * access to the app's branch and shared secret paths, and to the sandbox paths.
19
+ */
20
+ export function editorialChecksSecretParameters(region, account, appId) {
21
+ const arn = (path) => `arn:aws:ssm:${region}:${account}:parameter/amplify/${path}`;
22
+ return EDITORIAL_CHECKS_SECRETS.flatMap((name) => [
23
+ arn(`papyrus/*/${name}`),
24
+ arn(`shared/papyrus/${name}`),
25
+ ...(appId ? [arn(`${appId}/*/${name}`), arn(`shared/${appId}/${name}`)] : []),
26
+ ]);
27
+ }
28
+ /**
29
+ * Environment, secrets and least-privilege IAM for the editorial-checks function. Every site gets
30
+ * these; whether checks run is a stored setting (EditorialCheckSettings.enabled), not a deploy flag.
31
+ */
32
+ export function wireEditorialChecks(backend) {
33
+ const lambda = backend.editorialChecks.resources.lambda;
34
+ const stack = Stack.of(lambda);
35
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
36
+ for (const name of EDITORIAL_CHECKS_SECRETS)
37
+ backend.editorialChecks.addEnvironment(name, secret(name));
38
+ lambda.addToRolePolicy(new PolicyStatement({
39
+ effect: Effect.ALLOW,
40
+ actions: ["ssm:GetParameter", "ssm:GetParameters"],
41
+ resources: editorialChecksSecretParameters(stack.region, stack.account, (process.env.AWS_APP_ID ?? "").trim()),
42
+ }));
43
+ const apiId = backend.data.resources.graphqlApi.apiId;
44
+ lambda.addToRolePolicy(new PolicyStatement({
45
+ effect: Effect.ALLOW,
46
+ actions: ["appsync:GraphQL"],
47
+ resources: EDITORIAL_CHECKS_FUNCTION_FIELDS.map((field) => `arn:aws:appsync:${stack.region}:${stack.account}:apis/${apiId}/types/${field}`),
48
+ }));
49
+ }
@@ -0,0 +1,57 @@
1
+ import { secret } from "@aws-amplify/backend";
2
+ import { Stack } from "aws-cdk-lib";
3
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
5
+
6
+ /** The model operations the function performs with its own role. The two context reads are its least-privilege access to private policy. */
7
+ export const EDITORIAL_CHECKS_FUNCTION_FIELDS = [
8
+ "Query/fields/listKnowledgeCorporaByRoleAndName",
9
+ "Query/fields/getEditorialContext",
10
+ "Query/fields/listEditorialPolicies",
11
+ "Query/fields/getEditorialCheckSettings",
12
+ "Query/fields/listEditorialFeedbacks",
13
+ "Mutation/fields/createEditorialPolicy",
14
+ "Mutation/fields/updateEditorialPolicy",
15
+ ];
16
+
17
+ export const EDITORIAL_CHECKS_SECRETS = ["TYPESAFE_API_KEY", "OPENAI_API_KEY"];
18
+
19
+ /**
20
+ * SSM parameters holding the provider keys. A Python function reads its secret()
21
+ * values itself (Amplify only resolves them for Node functions), so it needs read
22
+ * access to the app's branch and shared secret paths, and to the sandbox paths.
23
+ */
24
+ export function editorialChecksSecretParameters(region: string, account: string, appId: string): string[] {
25
+ const arn = (path: string) => `arn:aws:ssm:${region}:${account}:parameter/amplify/${path}`;
26
+ return EDITORIAL_CHECKS_SECRETS.flatMap((name) => [
27
+ arn(`papyrus/*/${name}`),
28
+ arn(`shared/papyrus/${name}`),
29
+ ...(appId ? [arn(`${appId}/*/${name}`), arn(`shared/${appId}/${name}`)] : []),
30
+ ]);
31
+ }
32
+
33
+ /**
34
+ * Environment, secrets and least-privilege IAM for the editorial-checks function. Every site gets
35
+ * these; whether checks run is a stored setting (EditorialCheckSettings.enabled), not a deploy flag.
36
+ */
37
+ export function wireEditorialChecks(backend: any): void {
38
+ const lambda = backend.editorialChecks.resources.lambda as LambdaFunction;
39
+ const stack = Stack.of(lambda);
40
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
41
+ for (const name of EDITORIAL_CHECKS_SECRETS) backend.editorialChecks.addEnvironment(name, secret(name));
42
+ lambda.addToRolePolicy(
43
+ new PolicyStatement({
44
+ effect: Effect.ALLOW,
45
+ actions: ["ssm:GetParameter", "ssm:GetParameters"],
46
+ resources: editorialChecksSecretParameters(stack.region, stack.account, (process.env.AWS_APP_ID ?? "").trim()),
47
+ }),
48
+ );
49
+ const apiId = backend.data.resources.graphqlApi.apiId as string;
50
+ lambda.addToRolePolicy(
51
+ new PolicyStatement({
52
+ effect: Effect.ALLOW,
53
+ actions: ["appsync:GraphQL"],
54
+ resources: EDITORIAL_CHECKS_FUNCTION_FIELDS.map((field) => `arn:aws:appsync:${stack.region}:${stack.account}:apis/${apiId}/types/${field}`),
55
+ }),
56
+ );
57
+ }
@@ -49,6 +49,7 @@ const ALLOWED_ROLES = new Set([
49
49
  "graph_export",
50
50
  "raw_payload",
51
51
  "priming_outcome",
52
+ "priming_attempts",
52
53
  ]);
53
54
  const ALLOWED_MEDIA_TYPES = new Set([
54
55
  "application/json",
@@ -0,0 +1,11 @@
1
+ """The monitoring-view function: `monitoringOverview` for editors and admins, read-only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from papyrus_content.monitoring_view import handle
8
+
9
+
10
+ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
11
+ return handle(event)
@@ -0,0 +1,24 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import { pythonLambdaCode } from "../shared/python-bundle.js";
5
+ /** AppSync gives a resolver 30 seconds; a read of one small table needs a few. */
6
+ export const MONITORING_VIEW_TIMEOUT_SECONDS = 15;
7
+ /** stdlib plus the runtime's boto3: the bundle is `papyrus_content` and nothing else. */
8
+ export const monitoringView = defineFunction((scope) => new Function(scope, "papyrus-monitoring-view", {
9
+ runtime: Runtime.PYTHON_3_12,
10
+ architecture: Architecture.ARM_64,
11
+ handler: "handler.handler",
12
+ timeout: Duration.seconds(MONITORING_VIEW_TIMEOUT_SECONDS),
13
+ memorySize: 256,
14
+ code: pythonLambdaCode({ functionDir: "monitoring-view", modules: ["papyrus_content"], noDeps: true }, [
15
+ "bash",
16
+ "-c",
17
+ [
18
+ "set -euo pipefail",
19
+ "mkdir -p /asset-output/papyrus_content",
20
+ "cp amplify/functions/monitoring-view/handler.py /asset-output/handler.py",
21
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
22
+ ].join(" && "),
23
+ ]),
24
+ }), { resourceGroupName: "data" });
@@ -0,0 +1,34 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import type { Construct } from "constructs";
5
+ import { pythonLambdaCode } from "../shared/python-bundle";
6
+
7
+ /** AppSync gives a resolver 30 seconds; a read of one small table needs a few. */
8
+ export const MONITORING_VIEW_TIMEOUT_SECONDS = 15;
9
+
10
+ /** stdlib plus the runtime's boto3: the bundle is `papyrus_content` and nothing else. */
11
+ export const monitoringView = defineFunction(
12
+ (scope: Construct) =>
13
+ new Function(scope, "papyrus-monitoring-view", {
14
+ runtime: Runtime.PYTHON_3_12,
15
+ architecture: Architecture.ARM_64,
16
+ handler: "handler.handler",
17
+ timeout: Duration.seconds(MONITORING_VIEW_TIMEOUT_SECONDS),
18
+ memorySize: 256,
19
+ code: pythonLambdaCode(
20
+ { functionDir: "monitoring-view", modules: ["papyrus_content"], noDeps: true },
21
+ [
22
+ "bash",
23
+ "-c",
24
+ [
25
+ "set -euo pipefail",
26
+ "mkdir -p /asset-output/papyrus_content",
27
+ "cp amplify/functions/monitoring-view/handler.py /asset-output/handler.py",
28
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
29
+ ].join(" && "),
30
+ ],
31
+ ),
32
+ }),
33
+ { resourceGroupName: "data" },
34
+ );
@@ -0,0 +1,16 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { monitoringView } from "./resource.js";
3
+ const editors = ["editor", "admin"];
4
+ /**
5
+ * Core to every site's data API. The Monitoring page reads the shared monitoring pipeline through
6
+ * this one query. AppSync admits editors and admins only; the function checks the groups again,
7
+ * and returns the whole shared pipeline to admins alone.
8
+ */
9
+ export const monitoringViewSchemaDefinitions = {
10
+ monitoringOverview: a
11
+ .query()
12
+ .arguments({ scope: a.string() })
13
+ .returns(a.json())
14
+ .authorization((allow) => [allow.groups(editors)])
15
+ .handler(a.handler.function(monitoringView)),
16
+ };
@@ -0,0 +1,18 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { monitoringView } from "./resource";
3
+
4
+ const editors = ["editor", "admin"];
5
+
6
+ /**
7
+ * Core to every site's data API. The Monitoring page reads the shared monitoring pipeline through
8
+ * this one query. AppSync admits editors and admins only; the function checks the groups again,
9
+ * and returns the whole shared pipeline to admins alone.
10
+ */
11
+ export const monitoringViewSchemaDefinitions = {
12
+ monitoringOverview: a
13
+ .query()
14
+ .arguments({ scope: a.string() })
15
+ .returns(a.json())
16
+ .authorization((allow) => [allow.groups(editors)])
17
+ .handler(a.handler.function(monitoringView)),
18
+ };
@@ -0,0 +1,37 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ /** The monitoring stack writes the table's name here (infra/monitoring/papyrus-monitoring.yaml). */
4
+ export const MONITORING_TABLE_PARAMETER = "/papyrus/monitoring/table-name";
5
+ /** Reads only: a single item or a key-range query. No Scan, no writes, no other table. */
6
+ export const MONITORING_VIEW_TABLE_ACTIONS = ["dynamodb:GetItem", "dynamodb:Query"];
7
+ /**
8
+ * The monitoring table is auto-named by CloudFormation (`papyrus-monitoring-MonitoringTable-<id>`), so
9
+ * the grant is the stack's table name prefix, not a wildcard on the account.
10
+ */
11
+ export function monitoringViewTableArns(region, account) {
12
+ const table = `arn:aws:dynamodb:${region}:${account}:table/papyrus-monitoring-MonitoringTable-*`;
13
+ return [table, `${table}/index/routing`];
14
+ }
15
+ export function monitoringViewParameterArn(region, account) {
16
+ return `arn:aws:ssm:${region}:${account}:parameter${MONITORING_TABLE_PARAMETER}`;
17
+ }
18
+ /**
19
+ * Environment and least-privilege IAM for the monitoring-view function. A site outside the monitoring
20
+ * account simply finds no parameter and the page says monitoring is not configured.
21
+ */
22
+ export function wireMonitoringView(backend) {
23
+ const lambda = backend.monitoringView.resources.lambda;
24
+ const stack = Stack.of(lambda);
25
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
26
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
27
+ lambda.addToRolePolicy(new PolicyStatement({
28
+ effect: Effect.ALLOW,
29
+ actions: ["ssm:GetParameter"],
30
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
31
+ }));
32
+ lambda.addToRolePolicy(new PolicyStatement({
33
+ effect: Effect.ALLOW,
34
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
35
+ resources: monitoringViewTableArns(stack.region, stack.account),
36
+ }));
37
+ }
@@ -0,0 +1,47 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
4
+
5
+ /** The monitoring stack writes the table's name here (infra/monitoring/papyrus-monitoring.yaml). */
6
+ export const MONITORING_TABLE_PARAMETER = "/papyrus/monitoring/table-name";
7
+
8
+ /** Reads only: a single item or a key-range query. No Scan, no writes, no other table. */
9
+ export const MONITORING_VIEW_TABLE_ACTIONS = ["dynamodb:GetItem", "dynamodb:Query"];
10
+
11
+ /**
12
+ * The monitoring table is auto-named by CloudFormation (`papyrus-monitoring-MonitoringTable-<id>`), so
13
+ * the grant is the stack's table name prefix, not a wildcard on the account.
14
+ */
15
+ export function monitoringViewTableArns(region: string, account: string): string[] {
16
+ const table = `arn:aws:dynamodb:${region}:${account}:table/papyrus-monitoring-MonitoringTable-*`;
17
+ return [table, `${table}/index/routing`];
18
+ }
19
+
20
+ export function monitoringViewParameterArn(region: string, account: string): string {
21
+ return `arn:aws:ssm:${region}:${account}:parameter${MONITORING_TABLE_PARAMETER}`;
22
+ }
23
+
24
+ /**
25
+ * Environment and least-privilege IAM for the monitoring-view function. A site outside the monitoring
26
+ * account simply finds no parameter and the page says monitoring is not configured.
27
+ */
28
+ export function wireMonitoringView(backend: any): void {
29
+ const lambda = backend.monitoringView.resources.lambda as LambdaFunction;
30
+ const stack = Stack.of(lambda);
31
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
32
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
33
+ lambda.addToRolePolicy(
34
+ new PolicyStatement({
35
+ effect: Effect.ALLOW,
36
+ actions: ["ssm:GetParameter"],
37
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
38
+ }),
39
+ );
40
+ lambda.addToRolePolicy(
41
+ new PolicyStatement({
42
+ effect: Effect.ALLOW,
43
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
44
+ resources: monitoringViewTableArns(stack.region, stack.account),
45
+ }),
46
+ );
47
+ }
@@ -78,23 +78,29 @@ export function siteFilesContentHash(siteFiles) {
78
78
  function pipSpec() {
79
79
  return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
80
80
  }
81
+ /** What the first pip install gets: `papyrus-newsroom` with its dependencies, plus the function's own requirements. Empty means skip it. */
82
+ export function firstPipRequirements(options) {
83
+ return [...(options.noDeps ? [] : [pipSpec()]), ...(options.requirements ?? [])];
84
+ }
81
85
  function bundleFromPip(outputDir, options) {
82
86
  const extra = (process.env.PAPYRUS_PIP_EXTRA_ARGS ?? "").trim().split(/\s+/).filter(Boolean);
83
87
  const python = (process.env.PAPYRUS_PIP_PYTHON ?? "python3").trim();
84
- execFileSync(python, [
85
- "-m", "pip", "install",
86
- "--target", outputDir,
87
- "--no-cache-dir",
88
- "--platform", "manylinux2014_aarch64",
89
- "--implementation", "cp",
90
- "--python-version", "3.12",
91
- "--only-binary=:all:",
92
- // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
93
- "--ignore-requires-python",
94
- ...extra,
95
- ...(options.noDeps ? [] : [pipSpec()]),
96
- ...(options.requirements ?? []),
97
- ], { stdio: "inherit" });
88
+ const requirements = firstPipRequirements(options);
89
+ if (requirements.length > 0) {
90
+ execFileSync(python, [
91
+ "-m", "pip", "install",
92
+ "--target", outputDir,
93
+ "--no-cache-dir",
94
+ "--platform", "manylinux2014_aarch64",
95
+ "--implementation", "cp",
96
+ "--python-version", "3.12",
97
+ "--only-binary=:all:",
98
+ // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
99
+ "--ignore-requires-python",
100
+ ...extra,
101
+ ...requirements,
102
+ ], { stdio: "inherit" });
103
+ }
98
104
  if (options.noDeps) {
99
105
  execFileSync(python, [
100
106
  "-m", "pip", "install",
@@ -102,27 +102,34 @@ function pipSpec(): string {
102
102
  return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
103
103
  }
104
104
 
105
+ /** What the first pip install gets: `papyrus-newsroom` with its dependencies, plus the function's own requirements. Empty means skip it. */
106
+ export function firstPipRequirements(options: Pick<PythonBundleOptions, "noDeps" | "requirements">): string[] {
107
+ return [...(options.noDeps ? [] : [pipSpec()]), ...(options.requirements ?? [])];
108
+ }
109
+
105
110
  function bundleFromPip(outputDir: string, options: PythonBundleOptions): void {
106
111
  const extra = (process.env.PAPYRUS_PIP_EXTRA_ARGS ?? "").trim().split(/\s+/).filter(Boolean);
107
112
  const python = (process.env.PAPYRUS_PIP_PYTHON ?? "python3").trim();
108
- execFileSync(
109
- python,
110
- [
111
- "-m", "pip", "install",
112
- "--target", outputDir,
113
- "--no-cache-dir",
114
- "--platform", "manylinux2014_aarch64",
115
- "--implementation", "cp",
116
- "--python-version", "3.12",
117
- "--only-binary=:all:",
118
- // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
119
- "--ignore-requires-python",
120
- ...extra,
121
- ...(options.noDeps ? [] : [pipSpec()]),
122
- ...(options.requirements ?? []),
123
- ],
124
- { stdio: "inherit" },
125
- );
113
+ const requirements = firstPipRequirements(options);
114
+ if (requirements.length > 0) {
115
+ execFileSync(
116
+ python,
117
+ [
118
+ "-m", "pip", "install",
119
+ "--target", outputDir,
120
+ "--no-cache-dir",
121
+ "--platform", "manylinux2014_aarch64",
122
+ "--implementation", "cp",
123
+ "--python-version", "3.12",
124
+ "--only-binary=:all:",
125
+ // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
126
+ "--ignore-requires-python",
127
+ ...extra,
128
+ ...requirements,
129
+ ],
130
+ { stdio: "inherit" },
131
+ );
132
+ }
126
133
  if (options.noDeps) {
127
134
  execFileSync(
128
135
  python,
@@ -12,6 +12,9 @@ async function lookUpRedirectTarget(uri) {
12
12
  }
13
13
 
14
14
  function rewriteToIndexDocument(uri) {
15
+ if (uri === "/404") {
16
+ return "/404.html";
17
+ }
15
18
  if (uri.endsWith("/")) {
16
19
  return uri + "index.html";
17
20
  }
@@ -22,6 +25,72 @@ function rewriteToIndexDocument(uri) {
22
25
  return uri;
23
26
  }
24
27
 
28
+ // Pattern redirects: data from the site's projection.json, spliced between the markers at deploy
29
+ // time by redirect-rules.ts. Each rule is {p: regex, t: target with $1..$9, s: status, x: excluded paths}.
30
+ const patternRules = /*PAPYRUS_PATTERN_RULES*/[]/*END_PAPYRUS_PATTERN_RULES*/;
31
+
32
+ function lookUpPatternRedirect(uri) {
33
+ for (let index = 0; index < patternRules.length; index += 1) {
34
+ const rule = patternRules[index];
35
+ if (rule.x.indexOf(uri) !== -1) {
36
+ continue;
37
+ }
38
+ const match = new RegExp(rule.p).exec(uri);
39
+ if (match !== null) {
40
+ return {
41
+ status: rule.s,
42
+ target: rule.t.replace(/\$([0-9])/g, function (whole, group) {
43
+ return match[Number(group)] || "";
44
+ }),
45
+ };
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ // Content negotiation for agents: when the site publishes Markdown twins (standard front end, agents.markdown), a request
52
+ // whose Accept header prefers text/markdown over text/html is rewritten to the .md object before the cache lookup. The
53
+ // flag is spliced in by redirect-rules.ts at deploy time.
54
+ const negotiateMarkdown = /*PAPYRUS_NEGOTIATE_MARKDOWN*/false/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/;
55
+
56
+ function qualityOf(accept, mediaType) {
57
+ let quality = 0;
58
+ const parts = accept.toLowerCase().split(",");
59
+ for (let index = 0; index < parts.length; index += 1) {
60
+ const fields = parts[index].split(";");
61
+ if (fields[0].trim() !== mediaType) {
62
+ continue;
63
+ }
64
+ quality = 1;
65
+ for (let field = 1; field < fields.length; field += 1) {
66
+ const pair = fields[field].trim();
67
+ if (pair.indexOf("q=") === 0) {
68
+ quality = Number(pair.substring(2));
69
+ if (isNaN(quality)) {
70
+ quality = 0;
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return quality;
76
+ }
77
+
78
+ function prefersMarkdown(request) {
79
+ const header = request.headers && request.headers.accept;
80
+ if (!header || !header.value) {
81
+ return false;
82
+ }
83
+ const markdown = qualityOf(header.value, "text/markdown");
84
+ return markdown > 0 && markdown > qualityOf(header.value, "text/html");
85
+ }
86
+
87
+ function markdownTwin(uri) {
88
+ if (uri === "/404.html" || uri.length < 6 || uri.substring(uri.length - 5) !== ".html") {
89
+ return null;
90
+ }
91
+ return uri.substring(0, uri.length - 5) + ".md";
92
+ }
93
+
25
94
  async function handler(event) {
26
95
  const request = event.request;
27
96
  const redirectTarget = await lookUpRedirectTarget(request.uri);
@@ -32,6 +101,20 @@ async function handler(event) {
32
101
  headers: { location: { value: redirectTarget } },
33
102
  };
34
103
  }
104
+ const patternRedirect = lookUpPatternRedirect(request.uri);
105
+ if (patternRedirect !== null) {
106
+ return {
107
+ statusCode: patternRedirect.status,
108
+ statusDescription: "Moved",
109
+ headers: { location: { value: patternRedirect.target } },
110
+ };
111
+ }
35
112
  request.uri = rewriteToIndexDocument(request.uri);
113
+ if (negotiateMarkdown && prefersMarkdown(request)) {
114
+ const twin = markdownTwin(request.uri);
115
+ if (twin !== null) {
116
+ request.uri = twin;
117
+ }
118
+ }
36
119
  return request;
37
120
  }