@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.
@@ -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 ARTICLE_TYPE_STATUS = "article#published";
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
- return runWithAmplifyServerContext({
139
- nextServerContext: null,
140
- operation: async () => {
141
- readerContextClient = generateClient<Schema>({ authMode: AUTH_MODE });
142
- try {
143
- return await operation();
144
- } finally {
145
- readerContextClient = null;
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 !== "article" || item.status !== "published") return undefined;
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(ARTICLE_TYPE_STATUS);
234
+ const items = await listItemsByTypeStatus(`${ARTICLE_TYPE}#${PUBLISHED_STATUS}`);
222
235
  return items
223
- .filter((item) => item.type === "article" && item.status === "published")
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: AUTH_MODE });
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 !== PUBLISHED_STATUS) return null;
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 !== PUBLISHED_STATUS) return null;
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 !== PUBLISHED_STATUS) continue;
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() as unknown as {
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
- function getPublishedEditionModel() {
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: AUTH_MODE });
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: AUTH_MODE,
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: AUTH_MODE,
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: AUTH_MODE,
759
+ authMode: authMode(),
615
760
  limit,
616
761
  nextToken,
617
762
  sortDirection: "DESC",
@@ -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
+ }