@anthusai/papyrus 1.0.0-next.3 → 1.0.0-next.4
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/app/layout.tsx +4 -0
- package/app/robots.ts +11 -0
- package/bin/papyrus-infra.mjs +46 -16
- package/components/staging-banner.tsx +11 -0
- package/infra/amplify-app-shell.js +123 -80
- package/infra/amplify-app-shell.ts +139 -113
- package/infra/build-specs.js +86 -0
- package/infra/build-specs.ts +132 -0
- package/infra/index.js +2 -0
- package/infra/site-config.js +125 -0
- package/infra/site-config.ts +163 -0
- package/lib/cached-content-repository.ts +2 -1
- package/lib/content-repository.ts +3 -0
- package/lib/content-source-context.ts +16 -0
- package/lib/graphql-content-repository.ts +182 -37
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/middleware.ts +38 -1
- package/package.json +5 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
export type AmplifyAppShellSiteConfig = {
|
|
2
|
+
siteId: string;
|
|
3
|
+
repository: string;
|
|
4
|
+
brand: string;
|
|
5
|
+
frontend: "pretext" | "markus-static";
|
|
6
|
+
hostedZoneId: string;
|
|
7
|
+
cms: {
|
|
8
|
+
appName?: string;
|
|
9
|
+
domainName: string;
|
|
10
|
+
buildComputeType?: "STANDARD" | "STANDARD_8GB";
|
|
11
|
+
environment: Record<string, string>;
|
|
12
|
+
stagingDomainName?: string;
|
|
13
|
+
};
|
|
14
|
+
reader?: {
|
|
15
|
+
appName?: string;
|
|
16
|
+
domainName: string;
|
|
17
|
+
branchName?: string;
|
|
18
|
+
buildCommand: string;
|
|
19
|
+
baseDirectory: string;
|
|
20
|
+
environment?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
papyrusVersion: string;
|
|
23
|
+
storagePreviewPrefix?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
27
|
+
const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
28
|
+
const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
|
|
29
|
+
const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
30
|
+
const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
|
|
31
|
+
const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
|
|
32
|
+
const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
|
|
33
|
+
const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
|
|
34
|
+
|
|
35
|
+
function fail(field: string, problem: string): never {
|
|
36
|
+
throw new Error(`site.json: ${field} ${problem}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireObject(value: unknown, field: string): Record<string, unknown> {
|
|
40
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
41
|
+
fail(field, "must be an object");
|
|
42
|
+
}
|
|
43
|
+
return value as Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function requireString(value: unknown, field: string, pattern?: RegExp, patternHint?: string): string {
|
|
47
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
48
|
+
fail(field, "must be a non-empty string");
|
|
49
|
+
}
|
|
50
|
+
if (pattern && !pattern.test(value as string)) {
|
|
51
|
+
fail(field, `is invalid${patternHint ? ` (${patternHint})` : ""}: ${JSON.stringify(value)}`);
|
|
52
|
+
}
|
|
53
|
+
return value as string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function optionalString(value: unknown, field: string, pattern?: RegExp, patternHint?: string): string | undefined {
|
|
57
|
+
return value === undefined ? undefined : requireString(value, field, pattern, patternHint);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function requireStringRecord(value: unknown, field: string): Record<string, string> {
|
|
61
|
+
const record = requireObject(value, field);
|
|
62
|
+
const result: Record<string, string> = {};
|
|
63
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
64
|
+
if (typeof entry !== "string") fail(`${field}.${key}`, "must be a string");
|
|
65
|
+
result[key] = entry as string;
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rejectUnknownKeys(record: Record<string, unknown>, allowed: string[], field: string): void {
|
|
71
|
+
for (const key of Object.keys(record)) {
|
|
72
|
+
if (!allowed.includes(key)) fail(`${field}.${key}`, `is not a known field (known: ${allowed.join(", ")})`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string {
|
|
77
|
+
if (config.cms.stagingDomainName) return config.cms.stagingDomainName;
|
|
78
|
+
const [, ...rest] = config.cms.domainName.split(".");
|
|
79
|
+
return ["staging", ...rest].join(".");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConfig, "storagePreviewPrefix">): string {
|
|
83
|
+
return config.storagePreviewPrefix ?? "preview/";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
87
|
+
const record = requireObject(raw, "site");
|
|
88
|
+
rejectUnknownKeys(
|
|
89
|
+
record,
|
|
90
|
+
["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix"],
|
|
91
|
+
"site",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
95
|
+
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
96
|
+
const brand = requireString(record.brand, "brand");
|
|
97
|
+
const frontend = requireString(record.frontend, "frontend");
|
|
98
|
+
if (frontend !== "pretext" && frontend !== "markus-static") {
|
|
99
|
+
fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
|
|
100
|
+
}
|
|
101
|
+
const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
|
|
102
|
+
const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
|
|
103
|
+
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
104
|
+
|
|
105
|
+
const cmsRecord = requireObject(record.cms, "cms");
|
|
106
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
|
|
107
|
+
const buildComputeType = cmsRecord.buildComputeType;
|
|
108
|
+
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
109
|
+
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
110
|
+
}
|
|
111
|
+
const cms: AmplifyAppShellSiteConfig["cms"] = {
|
|
112
|
+
appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
|
|
113
|
+
domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
|
|
114
|
+
buildComputeType: buildComputeType as "STANDARD" | "STANDARD_8GB" | undefined,
|
|
115
|
+
environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
|
|
116
|
+
stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
let reader: AmplifyAppShellSiteConfig["reader"];
|
|
120
|
+
if (record.reader !== undefined) {
|
|
121
|
+
if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
|
|
122
|
+
const readerRecord = requireObject(record.reader, "reader");
|
|
123
|
+
rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
|
|
124
|
+
reader = {
|
|
125
|
+
appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
|
|
126
|
+
domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
|
|
127
|
+
branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
|
|
128
|
+
buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
|
|
129
|
+
baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
|
|
130
|
+
environment: readerRecord.environment === undefined ? undefined : requireStringRecord(readerRecord.environment, "reader.environment"),
|
|
131
|
+
};
|
|
132
|
+
} else if (frontend === "markus-static") {
|
|
133
|
+
fail("reader", `is required when frontend is "markus-static"`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const config: AmplifyAppShellSiteConfig = {
|
|
137
|
+
siteId,
|
|
138
|
+
repository,
|
|
139
|
+
brand,
|
|
140
|
+
frontend,
|
|
141
|
+
hostedZoneId,
|
|
142
|
+
cms,
|
|
143
|
+
reader,
|
|
144
|
+
papyrusVersion,
|
|
145
|
+
storagePreviewPrefix,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
|
|
149
|
+
if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
|
|
150
|
+
const redirectUrls = redirectValue.split(",").map((url) => url.trim());
|
|
151
|
+
const requiredOrigins = [
|
|
152
|
+
`https://${cms.domainName}/`,
|
|
153
|
+
`https://${resolveStagingDomainName(config)}/`,
|
|
154
|
+
LOCAL_DEVELOPMENT_ORIGIN,
|
|
155
|
+
];
|
|
156
|
+
for (const origin of requiredOrigins) {
|
|
157
|
+
if (!redirectUrls.includes(origin)) {
|
|
158
|
+
fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return config;
|
|
163
|
+
}
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
} from "./content-types";
|
|
11
11
|
import { graphqlContentRepository } from "./graphql-content-repository";
|
|
12
12
|
import { READER_REVALIDATE_SECONDS } from "./reader-route-config";
|
|
13
|
+
import { getContentSource } from "./site-env";
|
|
13
14
|
|
|
14
15
|
const bypassReaderCacheInDevelopment = process.env.NODE_ENV === "development";
|
|
15
16
|
|
|
@@ -34,7 +35,7 @@ function withReaderCache<T>(
|
|
|
34
35
|
tags: string[],
|
|
35
36
|
loader: () => Promise<T>,
|
|
36
37
|
): Promise<T> {
|
|
37
|
-
if (bypassReaderCacheInDevelopment) return loader();
|
|
38
|
+
if (bypassReaderCacheInDevelopment || getContentSource() === "drafts") return loader();
|
|
38
39
|
return unstable_cache(loader, cacheKey, {
|
|
39
40
|
tags,
|
|
40
41
|
revalidate: READER_REVALIDATE_SECONDS,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ContentRepository } from "./content-types";
|
|
2
2
|
import { graphqlContentRepository } from "./graphql-content-repository";
|
|
3
3
|
import { getLayoutScenario } from "./layout-scenarios";
|
|
4
|
+
import { assertContentSourceMatchesEnv } from "./site-env";
|
|
5
|
+
|
|
6
|
+
assertContentSourceMatchesEnv();
|
|
4
7
|
|
|
5
8
|
export const contentRepository: ContentRepository = {
|
|
6
9
|
loadEditionContent({ scenarioId, editionDate, editionSlug } = {}) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { getContentSource, type ContentSource } from "./site-env";
|
|
3
|
+
|
|
4
|
+
export type ContentSourceContext = {
|
|
5
|
+
source: ContentSource;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const contentSourceStorage = new AsyncLocalStorage<ContentSourceContext>();
|
|
9
|
+
|
|
10
|
+
export function runWithContentSource<T>(source: ContentSource, operation: () => Promise<T>): Promise<T> {
|
|
11
|
+
return contentSourceStorage.run({ source }, operation);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function currentContentSource(): ContentSource {
|
|
15
|
+
return contentSourceStorage.getStore()?.source ?? getContentSource();
|
|
16
|
+
}
|
|
@@ -17,11 +17,13 @@ import {
|
|
|
17
17
|
} from "./publication-items";
|
|
18
18
|
import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type BodyProjectionImage } from "./markus-body";
|
|
19
19
|
import { SITE_BRAND } from "./site-brand";
|
|
20
|
+
import { currentContentSource, runWithContentSource } from "./content-source-context";
|
|
21
|
+
import { getContentSource } from "./site-env";
|
|
20
22
|
|
|
21
|
-
const AUTH_MODE = "identityPool";
|
|
22
23
|
const DEFAULT_EDITION_SLUG = "current";
|
|
23
24
|
const PUBLISHED_STATUS = "published";
|
|
24
|
-
const
|
|
25
|
+
const DRAFT_STATUS = "draft";
|
|
26
|
+
const ARTICLE_TYPE = "article";
|
|
25
27
|
|
|
26
28
|
type DataClient = ReturnType<typeof generateClient<Schema>>;
|
|
27
29
|
type EditionPublishedAtIndexQuery = (
|
|
@@ -64,8 +66,6 @@ type GraphQLEditionItem = {
|
|
|
64
66
|
|
|
65
67
|
type GraphQLItem = {
|
|
66
68
|
id: string;
|
|
67
|
-
sourceItemId?: string | null;
|
|
68
|
-
itemLineageId?: string | null;
|
|
69
69
|
versionNumber?: number | null;
|
|
70
70
|
type: string;
|
|
71
71
|
status: string;
|
|
@@ -133,19 +133,32 @@ const IMAGE_ROLES: NonNullable<ArticleImageAsset["roles"]> = [
|
|
|
133
133
|
let readerContextClient: DataClient | null = null;
|
|
134
134
|
let buildTimeClient: DataClient | null = null;
|
|
135
135
|
|
|
136
|
+
function authMode(): "userPool" | "identityPool" {
|
|
137
|
+
return currentContentSource() === "drafts" ? "userPool" : "identityPool";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isVisibleItemStatus(status: string): boolean {
|
|
141
|
+
if (status === PUBLISHED_STATUS) return true;
|
|
142
|
+
return currentContentSource() === "drafts" && status === DRAFT_STATUS;
|
|
143
|
+
}
|
|
144
|
+
|
|
136
145
|
async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise<T> {
|
|
137
146
|
const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
const source = getContentSource();
|
|
148
|
+
const nextServerContext = source === "drafts" ? { cookies: (await import("next/headers")).cookies } : null;
|
|
149
|
+
return runWithContentSource(source, () =>
|
|
150
|
+
runWithAmplifyServerContext({
|
|
151
|
+
nextServerContext,
|
|
152
|
+
operation: async () => {
|
|
153
|
+
readerContextClient = generateClient<Schema>({ authMode: authMode() });
|
|
154
|
+
try {
|
|
155
|
+
return await operation();
|
|
156
|
+
} finally {
|
|
157
|
+
readerContextClient = null;
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
149
162
|
}
|
|
150
163
|
|
|
151
164
|
export const graphqlContentRepository: ContentRepository = {
|
|
@@ -200,7 +213,7 @@ export const graphqlContentRepository: ContentRepository = {
|
|
|
200
213
|
getArticle(slug) {
|
|
201
214
|
return withReaderGraphQLContext(async () => {
|
|
202
215
|
const item = await getItemBySlug(slug);
|
|
203
|
-
if (!item || item.type !==
|
|
216
|
+
if (!item || item.type !== ARTICLE_TYPE || !isVisibleItemStatus(item.status)) return undefined;
|
|
204
217
|
return normalizeArticle(item, await listMediaAssets(item.id));
|
|
205
218
|
});
|
|
206
219
|
},
|
|
@@ -218,9 +231,9 @@ export const graphqlContentRepository: ContentRepository = {
|
|
|
218
231
|
|
|
219
232
|
listArticleSlugs() {
|
|
220
233
|
return withReaderGraphQLContext(async () => {
|
|
221
|
-
const items = await listItemsByTypeStatus(
|
|
234
|
+
const items = await listItemsByTypeStatus(`${ARTICLE_TYPE}#${PUBLISHED_STATUS}`);
|
|
222
235
|
return items
|
|
223
|
-
.filter((item) => item.type ===
|
|
236
|
+
.filter((item) => item.type === ARTICLE_TYPE && isVisibleItemStatus(item.status))
|
|
224
237
|
.map((item) => item.slug)
|
|
225
238
|
.sort();
|
|
226
239
|
});
|
|
@@ -232,7 +245,7 @@ function getClient(): DataClient {
|
|
|
232
245
|
|
|
233
246
|
if (!buildTimeClient) {
|
|
234
247
|
getAmplifyServerRuntime();
|
|
235
|
-
buildTimeClient = generateClient<Schema>({ authMode:
|
|
248
|
+
buildTimeClient = generateClient<Schema>({ authMode: authMode() });
|
|
236
249
|
}
|
|
237
250
|
return buildTimeClient;
|
|
238
251
|
}
|
|
@@ -331,7 +344,7 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
|
|
|
331
344
|
await Promise.all(
|
|
332
345
|
editionItems.map(async (editionItem) => {
|
|
333
346
|
const item = await getItemById(editionItem.publishedItemId);
|
|
334
|
-
if (!item || item.status
|
|
347
|
+
if (!item || !isVisibleItemStatus(item.status)) return null;
|
|
335
348
|
return normalizePublicationItem(item, await listMediaAssets(item.id));
|
|
336
349
|
}),
|
|
337
350
|
)
|
|
@@ -344,7 +357,7 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
|
|
|
344
357
|
await Promise.all(
|
|
345
358
|
missingLayoutItems.map(async (itemId) => {
|
|
346
359
|
const item = await getItemBySlug(itemId);
|
|
347
|
-
if (!item || item.status
|
|
360
|
+
if (!item || !isVisibleItemStatus(item.status)) return null;
|
|
348
361
|
return normalizePublicationItem(item, await listMediaAssets(item.id));
|
|
349
362
|
}),
|
|
350
363
|
)
|
|
@@ -461,7 +474,7 @@ async function loadEditionItem(editionDate: string, itemSlug: string): Promise<P
|
|
|
461
474
|
const editionItems = await listEditionItems(edition.id);
|
|
462
475
|
for (const editionItem of editionItems) {
|
|
463
476
|
const item = await getItemById(editionItem.publishedItemId);
|
|
464
|
-
if (!item || item.slug !== itemSlug || item.status
|
|
477
|
+
if (!item || item.slug !== itemSlug || !isVisibleItemStatus(item.status)) continue;
|
|
465
478
|
return (await normalizePublicationItem(item, await listMediaAssets(item.id))) ?? undefined;
|
|
466
479
|
}
|
|
467
480
|
return undefined;
|
|
@@ -505,36 +518,168 @@ function isMissingReaderBackendError(error: unknown): boolean {
|
|
|
505
518
|
}
|
|
506
519
|
|
|
507
520
|
function getEditionPublishedAtIndexQuery(): EditionPublishedAtIndexQuery | null {
|
|
508
|
-
const editionModel = getPublishedEditionModel()
|
|
509
|
-
listPublishedEditionsByStatusAndPublishedAt?: EditionPublishedAtIndexQuery;
|
|
510
|
-
};
|
|
521
|
+
const editionModel = getPublishedEditionModel();
|
|
511
522
|
return typeof editionModel.listPublishedEditionsByStatusAndPublishedAt === "function"
|
|
512
523
|
? editionModel.listPublishedEditionsByStatusAndPublishedAt
|
|
513
524
|
: null;
|
|
514
525
|
}
|
|
515
526
|
|
|
516
|
-
|
|
527
|
+
type ListOptions = Record<string, unknown>;
|
|
528
|
+
type ListResponse<T> = Promise<GraphQLListResponse<T>>;
|
|
529
|
+
|
|
530
|
+
type EditionSourceModel = {
|
|
531
|
+
publishedEditionBySlug: (input: { slug: string }, options: ListOptions) => ListResponse<GraphQLEdition>;
|
|
532
|
+
listPublishedEditionsByStatusAndEditionDate: (
|
|
533
|
+
input: { status: string; editionDate?: { eq: string } },
|
|
534
|
+
options: ListOptions,
|
|
535
|
+
) => ListResponse<GraphQLEdition>;
|
|
536
|
+
listPublishedEditionsByStatusAndPublishedAt?: EditionPublishedAtIndexQuery;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
type EditionItemSourceModel = {
|
|
540
|
+
listPublishedEditionItemsByEditionAndSortKey: (
|
|
541
|
+
input: { publishedEditionId: string },
|
|
542
|
+
options: ListOptions,
|
|
543
|
+
) => ListResponse<GraphQLEditionItem>;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
type ItemSourceModel = {
|
|
547
|
+
get: (input: { id: string }, options: ListOptions) => Promise<GraphQLGetResponse<GraphQLItem>>;
|
|
548
|
+
publishedItemBySlug: (input: { slug: string }, options: ListOptions) => ListResponse<GraphQLItem>;
|
|
549
|
+
listPublishedItemsByTypeStatusAndPublishedAt: (input: { typeStatus: string }, options: ListOptions) => ListResponse<GraphQLItem>;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
type MediaAssetSourceModel = {
|
|
553
|
+
listPublishedMediaAssetsByItemAndSortKey: (
|
|
554
|
+
input: { publishedItemId: string },
|
|
555
|
+
options: ListOptions,
|
|
556
|
+
) => ListResponse<GraphQLMediaAsset>;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const DRAFTS_EDITION_STATUSES = [PUBLISHED_STATUS, DRAFT_STATUS];
|
|
560
|
+
|
|
561
|
+
async function listMergedAcrossStatuses<T>(
|
|
562
|
+
statuses: string[],
|
|
563
|
+
runForStatus: (status: string, pageOptions: ListOptions) => ListResponse<T>,
|
|
564
|
+
sortValue: (row: T) => string,
|
|
565
|
+
options: ListOptions,
|
|
566
|
+
): ListResponse<T> {
|
|
567
|
+
const rows: T[] = [];
|
|
568
|
+
for (const status of statuses) {
|
|
569
|
+
let pageToken: string | null | undefined;
|
|
570
|
+
do {
|
|
571
|
+
const response = await runForStatus(status, { authMode: options.authMode, limit: 100, nextToken: pageToken });
|
|
572
|
+
if (response.errors?.length) return { data: [], errors: response.errors };
|
|
573
|
+
rows.push(...((response.data ?? []).filter(Boolean) as T[]));
|
|
574
|
+
pageToken = response.nextToken;
|
|
575
|
+
} while (pageToken);
|
|
576
|
+
}
|
|
577
|
+
const direction = options.sortDirection === "DESC" ? -1 : 1;
|
|
578
|
+
rows.sort((left, right) => direction * sortValue(left).localeCompare(sortValue(right)));
|
|
579
|
+
const offset = Number(options.nextToken ?? 0) || 0;
|
|
580
|
+
const limit = typeof options.limit === "number" ? options.limit : undefined;
|
|
581
|
+
const end = limit === undefined ? rows.length : offset + limit;
|
|
582
|
+
return {
|
|
583
|
+
data: rows.slice(offset, end),
|
|
584
|
+
nextToken: end < rows.length ? String(end) : undefined,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function getPublishedEditionModel(): EditionSourceModel {
|
|
589
|
+
if (currentContentSource() === "drafts") {
|
|
590
|
+
const model = getClient().models.Edition;
|
|
591
|
+
if (!model) throw missingProjectionModelError("Edition");
|
|
592
|
+
return {
|
|
593
|
+
publishedEditionBySlug: (input, options) => model.editionBySlug(input, options) as ListResponse<GraphQLEdition>,
|
|
594
|
+
listPublishedEditionsByStatusAndEditionDate: (input, options) =>
|
|
595
|
+
listMergedAcrossStatuses<GraphQLEdition>(
|
|
596
|
+
DRAFTS_EDITION_STATUSES,
|
|
597
|
+
(status, pageOptions) =>
|
|
598
|
+
model.listEditionsByStatusAndEditionDate({ ...input, status }, pageOptions) as ListResponse<GraphQLEdition>,
|
|
599
|
+
(edition) => edition.editionDate,
|
|
600
|
+
options,
|
|
601
|
+
),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
517
604
|
const model = getClient().models.PublishedEdition;
|
|
518
605
|
if (!model) throw missingProjectionModelError("PublishedEdition");
|
|
519
|
-
return model;
|
|
606
|
+
return model as unknown as EditionSourceModel;
|
|
520
607
|
}
|
|
521
608
|
|
|
522
|
-
function getPublishedEditionItemModel() {
|
|
609
|
+
function getPublishedEditionItemModel(): EditionItemSourceModel {
|
|
610
|
+
if (currentContentSource() === "drafts") {
|
|
611
|
+
const model = getClient().models.EditionItem;
|
|
612
|
+
if (!model) throw missingProjectionModelError("EditionItem");
|
|
613
|
+
return {
|
|
614
|
+
listPublishedEditionItemsByEditionAndSortKey: async ({ publishedEditionId }, options) => {
|
|
615
|
+
const response = (await model.listEditionItemsByEditionAndSortKey(
|
|
616
|
+
{ editionId: publishedEditionId },
|
|
617
|
+
options,
|
|
618
|
+
)) as unknown as GraphQLListResponse<{ id: string; editionId: string; itemId: string; sortKey: string }>;
|
|
619
|
+
return {
|
|
620
|
+
...response,
|
|
621
|
+
data: (response.data ?? []).filter(Boolean).map((row) => ({
|
|
622
|
+
id: row.id,
|
|
623
|
+
publishedEditionId: row.editionId,
|
|
624
|
+
publishedItemId: row.itemId,
|
|
625
|
+
sortKey: row.sortKey,
|
|
626
|
+
})),
|
|
627
|
+
};
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
}
|
|
523
631
|
const model = getClient().models.PublishedEditionItem;
|
|
524
632
|
if (!model) throw missingProjectionModelError("PublishedEditionItem");
|
|
525
|
-
return model;
|
|
633
|
+
return model as unknown as EditionItemSourceModel;
|
|
526
634
|
}
|
|
527
635
|
|
|
528
|
-
function getPublishedItemModel() {
|
|
636
|
+
function getPublishedItemModel(): ItemSourceModel {
|
|
637
|
+
if (currentContentSource() === "drafts") {
|
|
638
|
+
const model = getClient().models.Item;
|
|
639
|
+
if (!model) throw missingProjectionModelError("Item");
|
|
640
|
+
return {
|
|
641
|
+
get: (input, options) => model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>,
|
|
642
|
+
publishedItemBySlug: (input, options) => model.itemBySlug(input, options) as ListResponse<GraphQLItem>,
|
|
643
|
+
listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) => {
|
|
644
|
+
const itemType = typeStatus.split("#")[0];
|
|
645
|
+
return listMergedAcrossStatuses<GraphQLItem>(
|
|
646
|
+
[DRAFT_STATUS, PUBLISHED_STATUS],
|
|
647
|
+
(status, pageOptions) =>
|
|
648
|
+
model.listItemsByTypeStatusAndPublishedAt(
|
|
649
|
+
{ typeStatus: `${itemType}#${status}` },
|
|
650
|
+
pageOptions,
|
|
651
|
+
) as ListResponse<GraphQLItem>,
|
|
652
|
+
(item) => item.publishedAt ?? "",
|
|
653
|
+
options,
|
|
654
|
+
);
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
529
658
|
const model = getClient().models.PublishedItem;
|
|
530
659
|
if (!model) throw missingProjectionModelError("PublishedItem");
|
|
531
|
-
return model;
|
|
660
|
+
return model as unknown as ItemSourceModel;
|
|
532
661
|
}
|
|
533
662
|
|
|
534
|
-
function getPublishedMediaAssetModel() {
|
|
663
|
+
function getPublishedMediaAssetModel(): MediaAssetSourceModel {
|
|
664
|
+
if (currentContentSource() === "drafts") {
|
|
665
|
+
const model = getClient().models.MediaAsset;
|
|
666
|
+
if (!model) throw missingProjectionModelError("MediaAsset");
|
|
667
|
+
return {
|
|
668
|
+
listPublishedMediaAssetsByItemAndSortKey: async ({ publishedItemId }, options) => {
|
|
669
|
+
const response = (await model.listMediaAssetsByItemAndSortKey(
|
|
670
|
+
{ itemId: publishedItemId },
|
|
671
|
+
options,
|
|
672
|
+
)) as unknown as GraphQLListResponse<Omit<GraphQLMediaAsset, "publishedItemId"> & { itemId: string }>;
|
|
673
|
+
return {
|
|
674
|
+
...response,
|
|
675
|
+
data: (response.data ?? []).filter(Boolean).map((row) => ({ ...row, publishedItemId: row.itemId })),
|
|
676
|
+
};
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
}
|
|
535
680
|
const model = getClient().models.PublishedMediaAsset;
|
|
536
681
|
if (!model) throw missingProjectionModelError("PublishedMediaAsset");
|
|
537
|
-
return model;
|
|
682
|
+
return model as unknown as MediaAssetSourceModel;
|
|
538
683
|
}
|
|
539
684
|
|
|
540
685
|
function missingProjectionModelError(modelName: string): Error {
|
|
@@ -561,7 +706,7 @@ async function listItemsByTypeStatus(typeStatus: string): Promise<GraphQLItem[]>
|
|
|
561
706
|
}
|
|
562
707
|
|
|
563
708
|
async function getItemById(id: string): Promise<GraphQLItem | null> {
|
|
564
|
-
const response = await getPublishedItemModel().get({ id }, { authMode:
|
|
709
|
+
const response = await getPublishedItemModel().get({ id }, { authMode: authMode() });
|
|
565
710
|
return readGetResponse<GraphQLItem>(response);
|
|
566
711
|
}
|
|
567
712
|
|
|
@@ -583,7 +728,7 @@ async function listAll<T>(operation: (options: Record<string, unknown>) => Promi
|
|
|
583
728
|
|
|
584
729
|
do {
|
|
585
730
|
const response = await operation({
|
|
586
|
-
authMode:
|
|
731
|
+
authMode: authMode(),
|
|
587
732
|
limit: 100,
|
|
588
733
|
nextToken,
|
|
589
734
|
});
|
|
@@ -597,7 +742,7 @@ async function listAll<T>(operation: (options: Record<string, unknown>) => Promi
|
|
|
597
742
|
|
|
598
743
|
async function listFirst<T>(operation: (options: Record<string, unknown>) => Promise<GraphQLListResponse<T>>): Promise<T[]> {
|
|
599
744
|
const response = await operation({
|
|
600
|
-
authMode:
|
|
745
|
+
authMode: authMode(),
|
|
601
746
|
limit: 1,
|
|
602
747
|
sortDirection: "DESC",
|
|
603
748
|
});
|
|
@@ -611,7 +756,7 @@ async function listPage<T>(
|
|
|
611
756
|
nextToken?: string | null,
|
|
612
757
|
): Promise<{ editions: T[]; nextToken?: string | null }> {
|
|
613
758
|
const response = await operation({
|
|
614
|
-
authMode:
|
|
759
|
+
authMode: authMode(),
|
|
615
760
|
limit,
|
|
616
761
|
nextToken,
|
|
617
762
|
sortDirection: "DESC",
|
package/lib/site-env.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type SiteEnv = "production" | "staging" | "development";
|
|
2
|
+
export type ContentSource = "published" | "drafts";
|
|
3
|
+
|
|
4
|
+
type EnvironmentVariables = Record<string, string | undefined>;
|
|
5
|
+
|
|
6
|
+
const SITE_ENVIRONMENTS: readonly SiteEnv[] = ["production", "staging", "development"];
|
|
7
|
+
const CONTENT_SOURCES: readonly ContentSource[] = ["published", "drafts"];
|
|
8
|
+
const PRODUCTION_BRANCH = "main";
|
|
9
|
+
|
|
10
|
+
export function getSiteEnv(environment: EnvironmentVariables = process.env): SiteEnv {
|
|
11
|
+
const configured = (environment.SITE_ENV ?? "").trim();
|
|
12
|
+
if (!configured) return "development";
|
|
13
|
+
if (!SITE_ENVIRONMENTS.includes(configured as SiteEnv)) {
|
|
14
|
+
throw new Error(`SITE_ENV must be one of ${SITE_ENVIRONMENTS.join(", ")}; received "${configured}".`);
|
|
15
|
+
}
|
|
16
|
+
if (configured === "production") {
|
|
17
|
+
const branch = (environment.AWS_BRANCH ?? "").trim();
|
|
18
|
+
if (branch && branch !== PRODUCTION_BRANCH) return "staging";
|
|
19
|
+
}
|
|
20
|
+
return configured as SiteEnv;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getContentSource(environment: EnvironmentVariables = process.env): ContentSource {
|
|
24
|
+
const configured = (environment.PAPYRUS_CONTENT_SOURCE ?? "").trim();
|
|
25
|
+
if (!configured) return "published";
|
|
26
|
+
if (!CONTENT_SOURCES.includes(configured as ContentSource)) {
|
|
27
|
+
throw new Error(`PAPYRUS_CONTENT_SOURCE must be one of ${CONTENT_SOURCES.join(", ")}; received "${configured}".`);
|
|
28
|
+
}
|
|
29
|
+
return configured as ContentSource;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isIndexable(environment: EnvironmentVariables = process.env): boolean {
|
|
33
|
+
return getSiteEnv(environment) === "production";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function showStagingBanner(environment: EnvironmentVariables = process.env): boolean {
|
|
37
|
+
return getSiteEnv(environment) === "staging";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function analyticsAllowed(environment: EnvironmentVariables = process.env): boolean {
|
|
41
|
+
return getSiteEnv(environment) === "production";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function assertContentSourceMatchesEnv(environment: EnvironmentVariables = process.env): void {
|
|
45
|
+
if (getContentSource(environment) !== "drafts") return;
|
|
46
|
+
const siteEnv = getSiteEnv(environment);
|
|
47
|
+
if (siteEnv !== "staging") {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`PAPYRUS_CONTENT_SOURCE=drafts requires SITE_ENV=staging; the resolved site environment is "${siteEnv}". Drafts must never be served from production or development.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { fetchAuthSession } from "aws-amplify/auth/server";
|
|
3
|
+
import { getAmplifyServerRuntime } from "./amplify-server-runtime";
|
|
4
|
+
|
|
5
|
+
export type StagingAccess = "anonymous" | "forbidden" | "ok";
|
|
6
|
+
|
|
7
|
+
const STAGING_ALLOWED_GROUPS = ["editor", "admin"];
|
|
8
|
+
|
|
9
|
+
export function groupsFromIdTokenPayload(payload: Record<string, unknown> | undefined): string[] {
|
|
10
|
+
const raw = payload?.["cognito:groups"];
|
|
11
|
+
if (Array.isArray(raw)) return raw.filter((group): group is string => typeof group === "string");
|
|
12
|
+
if (typeof raw === "string" && raw) return [raw];
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function accessForGroups(groups: string[] | null): StagingAccess {
|
|
17
|
+
if (groups === null) return "anonymous";
|
|
18
|
+
return groups.some((group) => STAGING_ALLOWED_GROUPS.includes(group)) ? "ok" : "forbidden";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function getStagingAccess(request: NextRequest, response: NextResponse): Promise<StagingAccess> {
|
|
22
|
+
const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
|
|
23
|
+
const groups = await runWithAmplifyServerContext({
|
|
24
|
+
nextServerContext: { request, response },
|
|
25
|
+
operation: async (contextSpec) => {
|
|
26
|
+
try {
|
|
27
|
+
const session = await fetchAuthSession(contextSpec);
|
|
28
|
+
const idTokenPayload = session.tokens?.idToken?.payload as Record<string, unknown> | undefined;
|
|
29
|
+
if (!session.tokens?.idToken) return null;
|
|
30
|
+
return groupsFromIdTokenPayload(idTokenPayload);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return accessForGroups(groups);
|
|
37
|
+
}
|