@anthusai/papyrus 1.4.0-next.5 → 1.4.0-next.6

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.
@@ -1018,6 +1018,7 @@ const schema = a.schema({
1018
1018
  NewsroomSection: a
1019
1019
  .model({
1020
1020
  id: a.id().required(),
1021
+ tenantId: a.string(),
1021
1022
  title: a.string().required(),
1022
1023
  type: a.string().required(),
1023
1024
  editorialMission: a.string().required(),
@@ -1036,6 +1037,7 @@ const schema = a.schema({
1036
1037
  })
1037
1038
  .secondaryIndexes((index) => [
1038
1039
  index("sortOrder").sortKeys(["id"]).queryField("listNewsroomSectionsBySortOrder"),
1040
+ index("tenantId").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByTenantAndSortOrder"),
1039
1041
  index("type").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByTypeAndSortOrder"),
1040
1042
  index("enabledStatus").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByEnabledStatusAndSortOrder"),
1041
1043
  ])
@@ -1624,6 +1626,7 @@ const schema = a.schema({
1624
1626
  .model({
1625
1627
  id: a.id().required(),
1626
1628
  lineageId: a.id().required(),
1629
+ tenantId: a.string(),
1627
1630
  versionNumber: a.integer().required(),
1628
1631
  previousVersionId: a.id(),
1629
1632
  versionState: a.string().required(),
@@ -1636,6 +1639,7 @@ const schema = a.schema({
1636
1639
  typeStatus: a.string().required(),
1637
1640
  stage: a.string(),
1638
1641
  slug: a.string().required(),
1642
+ tenantSlug: a.string(),
1639
1643
  shortSlug: a.string(),
1640
1644
  section: a.string(),
1641
1645
  sectionStatus: a.string(),
@@ -1663,6 +1667,7 @@ const schema = a.schema({
1663
1667
  .secondaryIndexes((index) => [
1664
1668
  index("lineageId").sortKeys(["versionNumber"]).queryField("listItemsByLineageAndVersion"),
1665
1669
  index("slug").queryField("itemBySlug"),
1670
+ index("tenantSlug").queryField("itemByTenantSlug"),
1666
1671
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1667
1672
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1668
1673
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
@@ -1710,6 +1715,7 @@ const schema = a.schema({
1710
1715
  .model({
1711
1716
  id: a.id().required(),
1712
1717
  itemId: a.id().required(),
1718
+ tenantId: a.string(),
1713
1719
  type: a.string().required(),
1714
1720
  role: a.string().required(),
1715
1721
  sortKey: a.string().required(),
@@ -1822,11 +1828,13 @@ const schema = a.schema({
1822
1828
  id: a.id().required(),
1823
1829
  sourceItemId: a.id().required(),
1824
1830
  itemLineageId: a.id().required(),
1831
+ tenantId: a.string(),
1825
1832
  versionNumber: a.integer().required(),
1826
1833
  type: a.string().required(),
1827
1834
  status: a.string().required(),
1828
1835
  typeStatus: a.string().required(),
1829
1836
  slug: a.string().required(),
1837
+ tenantSlug: a.string(),
1830
1838
  shortSlug: a.string(),
1831
1839
  section: a.string(),
1832
1840
  sectionStatus: a.string(),
@@ -1849,6 +1857,7 @@ const schema = a.schema({
1849
1857
  })
1850
1858
  .secondaryIndexes((index) => [
1851
1859
  index("slug").queryField("publishedItemBySlug"),
1860
+ index("tenantSlug").queryField("publishedItemByTenantSlug"),
1852
1861
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listPublishedItemsByTypeStatusAndPublishedAt"),
1853
1862
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listPublishedItemsBySectionStatusAndPublishedAt"),
1854
1863
  index("itemLineageId").sortKeys(["versionNumber"]).queryField("listPublishedItemsByLineageAndVersion"),
@@ -1861,6 +1870,7 @@ const schema = a.schema({
1861
1870
  .model({
1862
1871
  id: a.id().required(),
1863
1872
  sourceMediaAssetId: a.id().required(),
1873
+ tenantId: a.string(),
1864
1874
  publishedItemId: a.id().required(),
1865
1875
  sourceItemId: a.id().required(),
1866
1876
  itemLineageId: a.id().required(),
@@ -1100,6 +1100,7 @@ const schema = a.schema({
1100
1100
  NewsroomSection: a
1101
1101
  .model({
1102
1102
  id: a.id().required(),
1103
+ tenantId: a.string(),
1103
1104
  title: a.string().required(),
1104
1105
  type: a.string().required(),
1105
1106
  editorialMission: a.string().required(),
@@ -1118,6 +1119,7 @@ const schema = a.schema({
1118
1119
  })
1119
1120
  .secondaryIndexes((index) => [
1120
1121
  index("sortOrder").sortKeys(["id"]).queryField("listNewsroomSectionsBySortOrder"),
1122
+ index("tenantId").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByTenantAndSortOrder"),
1121
1123
  index("type").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByTypeAndSortOrder"),
1122
1124
  index("enabledStatus").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByEnabledStatusAndSortOrder"),
1123
1125
  ])
@@ -1725,6 +1727,7 @@ const schema = a.schema({
1725
1727
  .model({
1726
1728
  id: a.id().required(),
1727
1729
  lineageId: a.id().required(),
1730
+ tenantId: a.string(),
1728
1731
  versionNumber: a.integer().required(),
1729
1732
  previousVersionId: a.id(),
1730
1733
  versionState: a.string().required(),
@@ -1737,6 +1740,7 @@ const schema = a.schema({
1737
1740
  typeStatus: a.string().required(),
1738
1741
  stage: a.string(),
1739
1742
  slug: a.string().required(),
1743
+ tenantSlug: a.string(),
1740
1744
  shortSlug: a.string(),
1741
1745
  section: a.string(),
1742
1746
  sectionStatus: a.string(),
@@ -1764,6 +1768,7 @@ const schema = a.schema({
1764
1768
  .secondaryIndexes((index) => [
1765
1769
  index("lineageId").sortKeys(["versionNumber"]).queryField("listItemsByLineageAndVersion"),
1766
1770
  index("slug").queryField("itemBySlug"),
1771
+ index("tenantSlug").queryField("itemByTenantSlug"),
1767
1772
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1768
1773
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1769
1774
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
@@ -1814,6 +1819,7 @@ const schema = a.schema({
1814
1819
  .model({
1815
1820
  id: a.id().required(),
1816
1821
  itemId: a.id().required(),
1822
+ tenantId: a.string(),
1817
1823
  type: a.string().required(),
1818
1824
  role: a.string().required(),
1819
1825
  sortKey: a.string().required(),
@@ -1930,11 +1936,13 @@ const schema = a.schema({
1930
1936
  id: a.id().required(),
1931
1937
  sourceItemId: a.id().required(),
1932
1938
  itemLineageId: a.id().required(),
1939
+ tenantId: a.string(),
1933
1940
  versionNumber: a.integer().required(),
1934
1941
  type: a.string().required(),
1935
1942
  status: a.string().required(),
1936
1943
  typeStatus: a.string().required(),
1937
1944
  slug: a.string().required(),
1945
+ tenantSlug: a.string(),
1938
1946
  shortSlug: a.string(),
1939
1947
  section: a.string(),
1940
1948
  sectionStatus: a.string(),
@@ -1957,6 +1965,7 @@ const schema = a.schema({
1957
1965
  })
1958
1966
  .secondaryIndexes((index) => [
1959
1967
  index("slug").queryField("publishedItemBySlug"),
1968
+ index("tenantSlug").queryField("publishedItemByTenantSlug"),
1960
1969
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listPublishedItemsByTypeStatusAndPublishedAt"),
1961
1970
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listPublishedItemsBySectionStatusAndPublishedAt"),
1962
1971
  index("itemLineageId").sortKeys(["versionNumber"]).queryField("listPublishedItemsByLineageAndVersion"),
@@ -1970,6 +1979,7 @@ const schema = a.schema({
1970
1979
  .model({
1971
1980
  id: a.id().required(),
1972
1981
  sourceMediaAssetId: a.id().required(),
1982
+ tenantId: a.string(),
1973
1983
  publishedItemId: a.id().required(),
1974
1984
  sourceItemId: a.id().required(),
1975
1985
  itemLineageId: a.id().required(),
@@ -918,6 +918,7 @@ const baseDefinitions = {
918
918
  ]),
919
919
  NewsroomSection: modelWithoutSubscriptions({
920
920
  id: a.id().required(),
921
+ tenantId: a.string(),
921
922
  title: a.string().required(),
922
923
  type: a.string().required(),
923
924
  editorialMission: a.string().required(),
@@ -936,6 +937,7 @@ const baseDefinitions = {
936
937
  })
937
938
  .secondaryIndexes((index) => [
938
939
  index("sortOrder").sortKeys(["id"]).queryField("listNewsroomSectionsBySortOrder"),
940
+ index("tenantId").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByTenantAndSortOrder"),
939
941
  index("type").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByTypeAndSortOrder"),
940
942
  index("enabledStatus").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByEnabledStatusAndSortOrder"),
941
943
  ])
@@ -1503,6 +1505,7 @@ const baseDefinitions = {
1503
1505
  Item: modelWithoutSubscriptions({
1504
1506
  id: a.id().required(),
1505
1507
  lineageId: a.id().required(),
1508
+ tenantId: a.string(),
1506
1509
  versionNumber: a.integer().required(),
1507
1510
  previousVersionId: a.id(),
1508
1511
  versionState: a.string().required(),
@@ -1515,6 +1518,7 @@ const baseDefinitions = {
1515
1518
  status: a.string().required(),
1516
1519
  typeStatus: a.string().required(),
1517
1520
  slug: a.string().required(),
1521
+ tenantSlug: a.string(),
1518
1522
  shortSlug: a.string(),
1519
1523
  section: a.string(),
1520
1524
  sectionStatus: a.string(),
@@ -1542,6 +1546,7 @@ const baseDefinitions = {
1542
1546
  .secondaryIndexes((index) => [
1543
1547
  index("lineageId").sortKeys(["versionNumber"]).queryField("listItemsByLineageAndVersion"),
1544
1548
  index("slug").queryField("itemBySlug"),
1549
+ index("tenantSlug").queryField("itemByTenantSlug"),
1545
1550
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1546
1551
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1547
1552
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
@@ -1586,6 +1591,7 @@ const baseDefinitions = {
1586
1591
  MediaAsset: modelWithoutSubscriptions({
1587
1592
  id: a.id().required(),
1588
1593
  itemId: a.id().required(),
1594
+ tenantId: a.string(),
1589
1595
  type: a.string().required(),
1590
1596
  role: a.string().required(),
1591
1597
  sortKey: a.string().required(),
@@ -1669,11 +1675,13 @@ const baseDefinitions = {
1669
1675
  id: a.id().required(),
1670
1676
  sourceItemId: a.id().required(),
1671
1677
  itemLineageId: a.id().required(),
1678
+ tenantId: a.string(),
1672
1679
  versionNumber: a.integer().required(),
1673
1680
  type: a.string().required(),
1674
1681
  status: a.string().required(),
1675
1682
  typeStatus: a.string().required(),
1676
1683
  slug: a.string().required(),
1684
+ tenantSlug: a.string(),
1677
1685
  shortSlug: a.string(),
1678
1686
  section: a.string(),
1679
1687
  sectionStatus: a.string(),
@@ -1696,6 +1704,7 @@ const baseDefinitions = {
1696
1704
  })
1697
1705
  .secondaryIndexes((index) => [
1698
1706
  index("slug").queryField("publishedItemBySlug"),
1707
+ index("tenantSlug").queryField("publishedItemByTenantSlug"),
1699
1708
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listPublishedItemsByTypeStatusAndPublishedAt"),
1700
1709
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listPublishedItemsBySectionStatusAndPublishedAt"),
1701
1710
  index("itemLineageId").sortKeys(["versionNumber"]).queryField("listPublishedItemsByLineageAndVersion"),
@@ -1707,6 +1716,7 @@ const baseDefinitions = {
1707
1716
  PublishedMediaAsset: modelWithoutSubscriptions({
1708
1717
  id: a.id().required(),
1709
1718
  sourceMediaAssetId: a.id().required(),
1719
+ tenantId: a.string(),
1710
1720
  publishedItemId: a.id().required(),
1711
1721
  sourceItemId: a.id().required(),
1712
1722
  itemLineageId: a.id().required(),
@@ -994,6 +994,7 @@ const baseDefinitions = {
994
994
 
995
995
  NewsroomSection: modelWithoutSubscriptions({
996
996
  id: a.id().required(),
997
+ tenantId: a.string(),
997
998
  title: a.string().required(),
998
999
  type: a.string().required(),
999
1000
  editorialMission: a.string().required(),
@@ -1012,6 +1013,7 @@ const baseDefinitions = {
1012
1013
  })
1013
1014
  .secondaryIndexes((index) => [
1014
1015
  index("sortOrder").sortKeys(["id"]).queryField("listNewsroomSectionsBySortOrder"),
1016
+ index("tenantId").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByTenantAndSortOrder"),
1015
1017
  index("type").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByTypeAndSortOrder"),
1016
1018
  index("enabledStatus").sortKeys(["sortOrder"]).queryField("listNewsroomSectionsByEnabledStatusAndSortOrder"),
1017
1019
  ])
@@ -1598,6 +1600,7 @@ const baseDefinitions = {
1598
1600
  Item: modelWithoutSubscriptions({
1599
1601
  id: a.id().required(),
1600
1602
  lineageId: a.id().required(),
1603
+ tenantId: a.string(),
1601
1604
  versionNumber: a.integer().required(),
1602
1605
  previousVersionId: a.id(),
1603
1606
  versionState: a.string().required(),
@@ -1610,6 +1613,7 @@ const baseDefinitions = {
1610
1613
  status: a.string().required(),
1611
1614
  typeStatus: a.string().required(),
1612
1615
  slug: a.string().required(),
1616
+ tenantSlug: a.string(),
1613
1617
  shortSlug: a.string(),
1614
1618
  section: a.string(),
1615
1619
  sectionStatus: a.string(),
@@ -1637,6 +1641,7 @@ const baseDefinitions = {
1637
1641
  .secondaryIndexes((index) => [
1638
1642
  index("lineageId").sortKeys(["versionNumber"]).queryField("listItemsByLineageAndVersion"),
1639
1643
  index("slug").queryField("itemBySlug"),
1644
+ index("tenantSlug").queryField("itemByTenantSlug"),
1640
1645
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1641
1646
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1642
1647
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
@@ -1684,6 +1689,7 @@ const baseDefinitions = {
1684
1689
  MediaAsset: modelWithoutSubscriptions({
1685
1690
  id: a.id().required(),
1686
1691
  itemId: a.id().required(),
1692
+ tenantId: a.string(),
1687
1693
  type: a.string().required(),
1688
1694
  role: a.string().required(),
1689
1695
  sortKey: a.string().required(),
@@ -1770,11 +1776,13 @@ const baseDefinitions = {
1770
1776
  id: a.id().required(),
1771
1777
  sourceItemId: a.id().required(),
1772
1778
  itemLineageId: a.id().required(),
1779
+ tenantId: a.string(),
1773
1780
  versionNumber: a.integer().required(),
1774
1781
  type: a.string().required(),
1775
1782
  status: a.string().required(),
1776
1783
  typeStatus: a.string().required(),
1777
1784
  slug: a.string().required(),
1785
+ tenantSlug: a.string(),
1778
1786
  shortSlug: a.string(),
1779
1787
  section: a.string(),
1780
1788
  sectionStatus: a.string(),
@@ -1797,6 +1805,7 @@ const baseDefinitions = {
1797
1805
  })
1798
1806
  .secondaryIndexes((index) => [
1799
1807
  index("slug").queryField("publishedItemBySlug"),
1808
+ index("tenantSlug").queryField("publishedItemByTenantSlug"),
1800
1809
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listPublishedItemsByTypeStatusAndPublishedAt"),
1801
1810
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listPublishedItemsBySectionStatusAndPublishedAt"),
1802
1811
  index("itemLineageId").sortKeys(["versionNumber"]).queryField("listPublishedItemsByLineageAndVersion"),
@@ -1809,6 +1818,7 @@ const baseDefinitions = {
1809
1818
  PublishedMediaAsset: modelWithoutSubscriptions({
1810
1819
  id: a.id().required(),
1811
1820
  sourceMediaAssetId: a.id().required(),
1821
+ tenantId: a.string(),
1812
1822
  publishedItemId: a.id().required(),
1813
1823
  sourceItemId: a.id().required(),
1814
1824
  itemLineageId: a.id().required(),
@@ -4,7 +4,7 @@ import { Amplify } from "aws-amplify";
4
4
  import { generateClient } from "aws-amplify/data";
5
5
  import type { Schema } from "../../data/resource";
6
6
  import { putJsonModelPayload, putTextModelPayload, readJsonModelPayload } from "../shared/model-payloads";
7
- import { referenceCurationMetadata } from "../shared/reference-curation-metadata";
7
+ import { curationActorKind, referenceCurationMetadata } from "../shared/reference-curation-metadata";
8
8
 
9
9
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
10
10
  type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
@@ -234,6 +234,7 @@ async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandle
234
234
  referenceLineageId,
235
235
  decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
236
236
  shareable: event.arguments.shareable ?? null,
237
+ actorKind: curationActorKind(event.identity),
237
238
  }),
238
239
  { filename: "metadata.json", now },
239
240
  );
@@ -1,5 +1,5 @@
1
1
  PyYAML>=6.0.2,<7
2
2
  citeproc-py>=0.8.2,<1
3
- anthus-markus==0.5.1
3
+ anthus-markus==0.7.0
4
4
  Pillow>=11.3,<12
5
5
  awscrt>=0.23,<1
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { pythonLambdaCode } from "../shared/python-bundle.js";
8
8
  const dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const projectRoot = path.resolve(dirname, "../../..");
10
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
10
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
11
11
  function installCheckoutRequirements(outputDir) {
12
12
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
13
13
  execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
@@ -10,7 +10,7 @@ import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
10
10
  const dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const projectRoot = path.resolve(dirname, "../../..");
12
12
 
13
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
13
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
14
14
 
15
15
  function installCheckoutRequirements(outputDir: string): void {
16
16
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
@@ -1,4 +1,4 @@
1
1
  PyYAML>=6.0.2,<7
2
2
  citeproc-py>=0.8.2,<1
3
- anthus-markus==0.5.1
3
+ anthus-markus==0.7.0
4
4
  Pillow>=11.3,<12
@@ -12,6 +12,20 @@ export type ReferenceCurationMetadataInput = {
12
12
  referenceLineageId: string
13
13
  decisionRelationId?: string | null
14
14
  shareable?: boolean | null
15
+ actorKind?: CurationActorKind | null
16
+ }
17
+
18
+ export type CurationActorKind = "human" | "service"
19
+
20
+ /**
21
+ * A curation is human only when the caller signed in through the user pool, so
22
+ * AppSync supplied a verified subject claim. Request arguments never decide it;
23
+ * IAM-signed service callers and agents have no such claim.
24
+ */
25
+ export function curationActorKind(identity: unknown): CurationActorKind {
26
+ const claims = (identity as {claims?: Record<string, unknown>} | null | undefined)?.claims
27
+ const subject = claims?.sub
28
+ return typeof subject === "string" && subject.trim() !== "" ? "human" : "service"
15
29
  }
16
30
 
17
31
  export function referenceCurationMetadata(input: ReferenceCurationMetadataInput): Record<string, unknown> {
@@ -23,6 +37,7 @@ export function referenceCurationMetadata(input: ReferenceCurationMetadataInput)
23
37
  curationReasonCode: input.reasonCode,
24
38
  referenceId: input.referenceId,
25
39
  referenceLineageId: input.referenceLineageId,
40
+ ...(input.actorKind ? {actorKind: input.actorKind} : {}),
26
41
  ...(decisionRelationId ? {decisionRelationId} : {}),
27
42
  ...(decisionRelationId ? {shareable: input.shareable === true} : {}),
28
43
  }
@@ -11,7 +11,7 @@ import { pythonLambdaCode } from "../functions/shared/python-bundle.js";
11
11
  import { RENDITIONS_EPHEMERAL_STORAGE_MB, RENDITIONS_MEDIA_PREFIX, RENDITIONS_MEMORY_MB, RENDITIONS_TIMEOUT_SECONDS, renditionsEnvironment, renditionsEventPattern, } from "./renditions-settings.js";
12
12
  const dirname = path.dirname(fileURLToPath(import.meta.url));
13
13
  const projectRoot = path.resolve(dirname, "../..");
14
- export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
14
+ export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12"];
15
15
  function installCheckoutRequirements(outputDir) {
16
16
  const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
17
17
  execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
@@ -22,7 +22,7 @@ import {
22
22
  const dirname = path.dirname(fileURLToPath(import.meta.url));
23
23
  const projectRoot = path.resolve(dirname, "../..");
24
24
 
25
- export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
25
+ export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12"];
26
26
 
27
27
  export type RenditionsFunctionProps = {
28
28
  siteBucket: s3.IBucket;
@@ -9,7 +9,7 @@ function CardExcerpt({ text }: { text: string }) {
9
9
  const [expanded, setExpanded] = useState(false);
10
10
  return <div className="board-card-excerpt" data-board-card-excerpt data-expanded={expanded}>
11
11
  <p>{text}</p>
12
- {text.length > 90 && <button aria-expanded={expanded} onClick={event => { event.stopPropagation(); setExpanded(open => !open); }} onKeyDown={event => event.stopPropagation()} type="button">{expanded ? "Less" : "More"}</button>}
12
+ {text.length > 130 && <button aria-expanded={expanded} onClick={event => { event.stopPropagation(); setExpanded(open => !open); }} onKeyDown={event => event.stopPropagation()} type="button">{expanded ? "Less" : "More"}</button>}
13
13
  </div>;
14
14
  }
15
15
 
@@ -51,6 +51,6 @@
51
51
  .papyrus-newsprint-board .issue-card .issue-accent-bar { height: auto !important; margin: 0 !important; width: 100% !important; padding: 0 !important; min-height: 0; }
52
52
  .papyrus-newsprint-board .issue-card .issue-card-content { padding-top: 2px !important; gap: 4px; }
53
53
  .papyrus-newsprint-board .issue-card h3 { margin: 0; }
54
- .board-card-excerpt p { margin: 0; font-size: 13px; line-height: 1.4; color: var(--org-ink); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
54
+ .board-card-excerpt p { margin: 0; font-size: 13px; line-height: 1.4; color: var(--org-ink); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
55
55
  .board-card-excerpt[data-expanded="true"] p { display: block; -webkit-line-clamp: unset; }
56
56
  .board-card-excerpt button { background: none; border: 0; padding: 0; font: inherit; font-size: 12px; text-decoration: underline; text-underline-offset: 3px; color: var(--org-ink); cursor: pointer; }
@@ -10,6 +10,7 @@ import { getNewsroomNavHref } from "../lib/newsroom-nav";
10
10
  import { SITE_BRAND } from "../lib/site-brand";
11
11
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
12
12
  import { ReaderAuthControl } from "./reader-auth-control";
13
+ import { ProspectusAccountChooser, useProspectusAccountSummary } from "./prospectus-account-chooser";
13
14
  import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger } from "./ui/sheet";
14
15
  import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator } from "./ui/dropdown-menu";
15
16
  import type { NewsroomOpsShellProps } from "./newsroom-ops-shell";
@@ -54,6 +55,7 @@ function Account({ placement }: { placement: "sidebar" | "header" }) {
54
55
  const session = useOptionalNewsDeskClient();
55
56
  const auth = session?.shell.auth ?? { status: "loading" as const, label: "Checking sign-in" };
56
57
  const [open, setOpen] = useState(false);
58
+ const accountSummary = useProspectusAccountSummary();
57
59
  useEffect(() => {
58
60
  const media = matchMedia("(max-width: 900px)");
59
61
  const close = () => setOpen(false);
@@ -62,11 +64,12 @@ function Account({ placement }: { placement: "sidebar" | "header" }) {
62
64
  }, []);
63
65
  return <DropdownMenu open={open} onOpenChange={setOpen}>
64
66
  <DropdownMenuTrigger id={`organizer-account-trigger-${placement}`} className={`organizer-account organizer-account-${placement}`} aria-label="Account menu" title="Account menu">
65
- <UserRound size={18} aria-hidden="true" /><span><strong>{auth.label}</strong><small>{auth.status === "signedIn" ? "Account & preferences" : auth.status === "signedOut" ? "Not signed in" : "Checking session"}</small></span>
67
+ <UserRound size={18} aria-hidden="true" /><span><strong>{auth.label}</strong><small>{accountSummary ?? (auth.status === "signedIn" ? "Account & preferences" : auth.status === "signedOut" ? "Not signed in" : "Checking session")}</small></span>
66
68
  </DropdownMenuTrigger>
67
69
  <DropdownMenuContent className="organizer-account-popup" side={placement === "sidebar" ? "top" : "bottom"} align="end">
68
70
  <DropdownMenuGroup>
69
71
  <DropdownMenuLabel>{auth.label}</DropdownMenuLabel><DropdownMenuSeparator />
72
+ <ProspectusAccountChooser />
70
73
  <DropdownMenuItem render={<Link href={getNewsroomNavHref(ORGANIZER_SETTINGS_DESTINATION.path)} />}><Settings aria-hidden="true" />{ORGANIZER_SETTINGS_DESTINATION.label}</DropdownMenuItem>
71
74
  <ReaderAuthControl authState={auth} renderAction={({ disabled, onClick, signedIn }) => <DropdownMenuItem disabled={disabled} closeOnClick={false} onClick={onClick}>
72
75
  {signedIn ? <LogOut aria-hidden="true" /> : <LogIn aria-hidden="true" />}{signedIn ? "Sign out" : "Sign in"}
@@ -265,3 +265,12 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
265
265
  .news-desk-columns--taxonomy .category-steering-category-card button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;padding:4px 12px;border:2px solid var(--org-ink);border-radius:0;background:transparent;color:var(--org-ink);font:700 12px/1.2 Newsroom UI,system-ui,sans-serif;letter-spacing:.05em;text-transform:uppercase;cursor:pointer}
266
266
  .news-desk-columns--taxonomy .category-steering-category-card button:disabled{border-color:var(--org-line);opacity:.6;cursor:default}
267
267
  .news-desk-columns--taxonomy .news-desk-rail-column .category-steering-section__header{border-bottom:0;padding-bottom:4px}
268
+ .organizer-account-chooser{padding:8px 12px;border-bottom:1px solid var(--org-line);display:grid;gap:6px}
269
+ .organizer-account-chooser ul{list-style:none;margin:0;padding:0;display:grid}
270
+ .organizer-account-chooser li button{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;text-align:left;background:none;border:0;border-left:3px solid transparent;padding:6px 8px;font:inherit;color:var(--org-ink);cursor:pointer}
271
+ .organizer-account-chooser li button[data-selected]{border-left-color:var(--org-ink);font-weight:700}
272
+ .organizer-account-chooser li button:disabled{opacity:.6;cursor:default}
273
+ .organizer-account-chooser li button span{display:grid;gap:1px}
274
+ .organizer-account-chooser li button small,.organizer-account-chooser-note{font-size:12px;color:var(--org-ink);margin:0}
275
+ .organizer-account-chooser-heading{margin:0;font:600 11px/1.4 Newsroom UI,system-ui,sans-serif;letter-spacing:.07em;text-transform:uppercase}
276
+ .organizer-account-chooser-link{color:var(--org-ink);text-decoration:underline;text-underline-offset:3px}
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Check } from "lucide-react";
5
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
6
+ import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId, setLocalPreviewTenantId } from "../lib/local-preview-tenant";
7
+ import { loadProspectusAccountState, selectProspectusAccount, type ProspectusAccountState } from "../lib/prospectus-account-client";
8
+
9
+ function previewAccountState(): ProspectusAccountState {
10
+ return {
11
+ status: "signedIn",
12
+ publishingAccounts: LOCAL_PREVIEW_TENANTS.map(tenant => ({ accountId: tenant.accountId, name: tenant.name, role: tenant.role })),
13
+ selectedAccountId: getLocalPreviewTenantId(),
14
+ cyclotronLabAccess: true,
15
+ };
16
+ }
17
+
18
+ const ACCOUNT_STATE_EVENT = "papyrus:prospectus-account-state";
19
+
20
+ function summaryOf(state: ProspectusAccountState): string | null {
21
+ if (state.status !== "signedIn") return null;
22
+ const selected = state.publishingAccounts.find(account => account.accountId === state.selectedAccountId);
23
+ return selected ? `${selected.name ?? selected.accountId} · ${roleLabel(selected.role)}` : "Choose an account";
24
+ }
25
+
26
+ const roleLabel = (role: string) => role.replace(/_/g, " ");
27
+
28
+ /** The signed-in person's current publishing account as "Name · role", or null when the site has no Prospectus accounts. */
29
+ export function useProspectusAccountSummary(): string | null {
30
+ const [summary, setSummary] = useState<string | null>(null);
31
+ useEffect(() => {
32
+ let cancelled = false;
33
+ const returnPath = `${window.location.pathname}${window.location.search}`;
34
+ (isLocalPreviewEnabled() ? Promise.resolve(previewAccountState()) : loadProspectusAccountState(returnPath)).then(loaded => {
35
+ if (!cancelled) setSummary(summaryOf(loaded));
36
+ });
37
+ const onChange = (event: Event) => setSummary(summaryOf((event as CustomEvent<ProspectusAccountState>).detail));
38
+ window.addEventListener(ACCOUNT_STATE_EVENT, onChange);
39
+ return () => { cancelled = true; window.removeEventListener(ACCOUNT_STATE_EVENT, onChange); };
40
+ }, []);
41
+ return summary;
42
+ }
43
+
44
+ /**
45
+ * The publishing account the signed-in person is acting as. It appears only on sites that have Prospectus
46
+ * accounts configured; a site with its own user pool never shows it. Choosing an account records the choice
47
+ * in the sign-in session and reloads so every page reads the new account.
48
+ */
49
+ export function ProspectusAccountChooser() {
50
+ const [state, setState] = useState<ProspectusAccountState>({ status: "unavailable" });
51
+ const [error, setError] = useState<string | null>(null);
52
+ const [pending, setPending] = useState<string | null>(null);
53
+ useEffect(() => {
54
+ let cancelled = false;
55
+ const returnPath = `${window.location.pathname}${window.location.search}`;
56
+ (isLocalPreviewEnabled() ? Promise.resolve(previewAccountState()) : loadProspectusAccountState(returnPath))
57
+ .then(loaded => { if (!cancelled) setState(loaded); });
58
+ return () => { cancelled = true; };
59
+ }, []);
60
+ if (state.status === "unavailable") return null;
61
+ if (state.status === "signedOut") {
62
+ return <div className="organizer-account-chooser" data-prospectus-account-chooser="signed-out">
63
+ <a className="organizer-account-chooser-link" href={state.signInUrl}>Sign in with Prospectus</a>
64
+ </div>;
65
+ }
66
+ const choose = async (accountId: string) => {
67
+ if (accountId === state.selectedAccountId || pending) return;
68
+ setPending(accountId); setError(null);
69
+ if (isLocalPreviewEnabled()) {
70
+ setLocalPreviewTenantId(accountId);
71
+ window.location.reload();
72
+ return;
73
+ }
74
+ const result = await selectProspectusAccount(accountId);
75
+ setPending(null);
76
+ if ("error" in result) { setError(result.error); return; }
77
+ window.location.reload();
78
+ };
79
+ return <div className="organizer-account-chooser" data-prospectus-account-chooser="signed-in">
80
+ <p className="organizer-account-chooser-heading">Publishing account</p>
81
+ {state.publishingAccounts.length === 0
82
+ ? <p className="organizer-account-chooser-note">No publishing accounts yet.</p>
83
+ : <ul role="listbox" aria-label="Publishing account">
84
+ {state.publishingAccounts.map(account => {
85
+ const selected = account.accountId === state.selectedAccountId;
86
+ return <li key={account.accountId} role="presentation">
87
+ <button aria-selected={selected} data-selected={selected || undefined} disabled={pending !== null} onClick={() => { void choose(account.accountId); }} role="option" type="button">
88
+ <span><strong>{account.name ?? account.accountId}</strong><small>{roleLabel(account.role)}</small></span>
89
+ {selected ? <Check size={14} aria-label="Selected" /> : null}
90
+ </button>
91
+ </li>;
92
+ })}
93
+ </ul>}
94
+ {!state.selectedAccountId && state.publishingAccounts.length > 0 ? <p className="organizer-account-chooser-note">Choose the account you are working as.</p> : null}
95
+ {state.cyclotronLabAccess ? <p className="organizer-account-chooser-note" data-cyclotron-lab-access>Cyclotron lab access is on for you.</p> : null}
96
+ {error ? <p className="organizer-account-chooser-note" role="alert">{error}</p> : null}
97
+ </div>;
98
+ }
@@ -1,10 +1,13 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { getContentSource, type ContentSource } from "./site-env";
3
+ import { tenantIdFromEnvironment } from "./tenant-keys";
3
4
 
4
5
  export type ContentSourceContext = {
5
6
  source: ContentSource;
6
7
  /** Request-scoped data client (drafts mode: carries the visitor's session cookies). */
7
8
  client?: unknown;
9
+ /** Tenant of the selected Prospectus account; falls back to PAPYRUS_TENANT_ID. */
10
+ tenantId?: string;
8
11
  };
9
12
 
10
13
  const contentSourceStorage = new AsyncLocalStorage<ContentSourceContext>();
@@ -13,8 +16,13 @@ export function runWithContentSource<T>(
13
16
  source: ContentSource,
14
17
  operation: () => Promise<T>,
15
18
  client?: unknown,
19
+ tenantId?: string,
16
20
  ): Promise<T> {
17
- return contentSourceStorage.run({ source, client }, operation);
21
+ return contentSourceStorage.run({ source, client, tenantId }, operation);
22
+ }
23
+
24
+ export function currentTenantId(): string | undefined {
25
+ return contentSourceStorage.getStore()?.tenantId ?? tenantIdFromEnvironment();
18
26
  }
19
27
 
20
28
  export function currentRequestClient<T>(): T | undefined {
@@ -20,7 +20,9 @@ import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type
20
20
  import { SITE_BRAND } from "./site-brand";
21
21
  import { shouldFetchVideoScripts } from "./video-mode";
22
22
  import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
23
- import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
23
+ import { selectedTenantIdFromCookies } from "./prospectus-tenant";
24
+ import { currentContentSource, currentRequestClient, currentTenantId, runWithContentSource } from "./content-source-context";
25
+ import { rowBelongsToTenant, tenantScopedValue } from "./tenant-keys";
24
26
  import { withRateLimitRetry } from "./rate-limit-retry";
25
27
  import { getContentSource } from "./site-env";
26
28
 
@@ -70,6 +72,7 @@ type GraphQLEditionItem = {
70
72
 
71
73
  type GraphQLItem = {
72
74
  id: string;
75
+ tenantId?: string | null;
73
76
  versionNumber?: number | null;
74
77
  type: string;
75
78
  status: string;
@@ -152,9 +155,10 @@ async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise
152
155
  // Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
153
156
  // kept in the request's async context (never a module global) so one visitor's session cannot serve another.
154
157
  const { cookies } = await import("next/headers");
155
- await cookies();
158
+ const cookieStore = await cookies();
156
159
  const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
157
- return runWithContentSource(source, operation, client);
160
+ const selectedTenantId = await selectedTenantIdFromCookies(cookieStore);
161
+ return runWithContentSource(source, operation, client, selectedTenantId);
158
162
  }
159
163
  return withRateLimitRetry(() =>
160
164
  runWithGuestAmplifyContext(() =>
@@ -587,6 +591,10 @@ type ItemSourceModel = {
587
591
  listPublishedItemsByTypeStatusAndPublishedAt: (input: { typeStatus: string }, options: ListOptions) => ListResponse<GraphQLItem>;
588
592
  };
589
593
 
594
+ type TenantSlugSourceModel = {
595
+ publishedItemByTenantSlug: (input: { tenantSlug: string }, options: ListOptions) => ListResponse<GraphQLItem>;
596
+ };
597
+
590
598
  type MediaAssetSourceModel = {
591
599
  listPublishedMediaAssetsByItemAndSortKey: (
592
600
  input: { publishedItemId: string },
@@ -671,20 +679,33 @@ function getPublishedEditionItemModel(): EditionItemSourceModel {
671
679
  return model as unknown as EditionItemSourceModel;
672
680
  }
673
681
 
682
+ async function getItemWithinTenant(
683
+ tenantId: string | undefined,
684
+ lookup: Promise<GraphQLGetResponse<GraphQLItem>>,
685
+ ): Promise<GraphQLGetResponse<GraphQLItem>> {
686
+ const response = await lookup;
687
+ return rowBelongsToTenant(tenantId, response.data) ? response : { ...response, data: null };
688
+ }
689
+
674
690
  function getPublishedItemModel(): ItemSourceModel {
691
+ const tenantId = currentTenantId();
675
692
  if (currentContentSource() === "drafts") {
676
693
  const model = getClient().models.Item;
677
694
  if (!model) throw missingProjectionModelError("Item");
678
695
  return {
679
- get: (input, options) => model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>,
680
- publishedItemBySlug: (input, options) => model.itemBySlug(input, options) as ListResponse<GraphQLItem>,
696
+ get: (input, options) =>
697
+ getItemWithinTenant(tenantId, model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>),
698
+ publishedItemBySlug: ({ slug }, options) =>
699
+ (tenantId
700
+ ? model.itemByTenantSlug({ tenantSlug: tenantScopedValue(tenantId, slug) }, options)
701
+ : model.itemBySlug({ slug }, options)) as ListResponse<GraphQLItem>,
681
702
  listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) => {
682
703
  const itemType = typeStatus.split("#")[0];
683
704
  return listMergedAcrossStatuses<GraphQLItem>(
684
705
  [DRAFT_STATUS, PUBLISHED_STATUS],
685
706
  (status, pageOptions) =>
686
707
  model.listItemsByTypeStatusAndPublishedAt(
687
- { typeStatus: `${itemType}#${status}` },
708
+ { typeStatus: tenantScopedValue(tenantId, `${itemType}#${status}`) },
688
709
  pageOptions,
689
710
  ) as ListResponse<GraphQLItem>,
690
711
  (item) => item.publishedAt ?? "",
@@ -695,7 +716,21 @@ function getPublishedItemModel(): ItemSourceModel {
695
716
  }
696
717
  const model = getClient().models.PublishedItem;
697
718
  if (!model) throw missingProjectionModelError("PublishedItem");
698
- return model as unknown as ItemSourceModel;
719
+ const publishedModel = model as unknown as ItemSourceModel;
720
+ if (!tenantId) return publishedModel;
721
+ return {
722
+ get: (input, options) => getItemWithinTenant(tenantId, publishedModel.get(input, options)),
723
+ publishedItemBySlug: ({ slug }, options) =>
724
+ (model as unknown as TenantSlugSourceModel).publishedItemByTenantSlug(
725
+ { tenantSlug: tenantScopedValue(tenantId, slug) },
726
+ options,
727
+ ),
728
+ listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) =>
729
+ publishedModel.listPublishedItemsByTypeStatusAndPublishedAt(
730
+ { typeStatus: tenantScopedValue(tenantId, typeStatus) },
731
+ options,
732
+ ),
733
+ };
699
734
  }
700
735
 
701
736
  function getPublishedMediaAssetModel(): MediaAssetSourceModel {
@@ -4,11 +4,12 @@ import type { MediaAssetRecord } from "./newsroom-media";
4
4
  import { EMPTY_STORY_REPORTING_TRAIL, type StoryReportingTrail, type TrailVersion } from "./story-reporting-trail";
5
5
  import { contentItemStageKeys, type ContentItemVersionRecord } from "./content-item-lifecycle";
6
6
  import { getContentItemStageDefinitions } from "./site-brand";
7
+ import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId } from "./local-preview-tenant";
7
8
 
8
9
  const SEED_TIMESTAMP_BASE = Date.parse("2026-10-01T12:00:00.000Z");
9
10
  const ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000;
10
11
 
11
- const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION: readonly (readonly string[])[] = [
12
+ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ZERO: readonly (readonly string[])[] = [
12
13
  ["Three sources confirm the zoning reversal", "Reporting packet: school board redistricting"],
13
14
  ["Draft in progress: the last ferry crossing", "Draft in progress: neighborhood bakery profile"],
14
15
  ["Illustration brief: flooded streets, aerial view", "Illustration brief: the market at dawn"],
@@ -18,6 +19,24 @@ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION: readonly (readonly string[])[] =
18
19
  ["Spiked: the unconfirmed council rumor", "Spiked: the recycled press release"],
19
20
  ];
20
21
 
22
+ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ONE: readonly (readonly string[])[] = [
23
+ ["Field note: spore dispersal on a damp morning", "Field note: the dung beetle census"],
24
+ ["Draft in progress: why fungi throw their caps", "Draft in progress: the compost experiment"],
25
+ ["Illustration brief: a cap in flight, close up", "Illustration brief: the mycelium network"],
26
+ ["Layout pass: the monthly field guide", "Layout pass: the reader mailbag"],
27
+ ["Published: a season of mushroom sightings", "Published: how the forest floor recycles"],
28
+ ["Held for spring: the truffle hunt", "Evergreen: a beginner's guide to identification"],
29
+ ["Spiked: the unverified foraging claim", "Spiked: the duplicate field report"],
30
+ ];
31
+
32
+ const SEED_CONTENT_ITEM_HEADLINES_BY_TENANT = [SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ZERO, SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ONE] as const;
33
+
34
+ function headlinesForSelectedTenant(): readonly (readonly string[])[] {
35
+ const tenantId = getLocalPreviewTenantId();
36
+ const tenant = LOCAL_PREVIEW_TENANTS.find(candidate => candidate.accountId === tenantId) ?? LOCAL_PREVIEW_TENANTS[0];
37
+ return SEED_CONTENT_ITEM_HEADLINES_BY_TENANT[tenant.fixtureHeadlineSet] ?? SEED_CONTENT_ITEM_HEADLINES_BY_TENANT[0];
38
+ }
39
+
21
40
  function isoTimestampHoursBeforeBase(hours: number): string {
22
41
  return new Date(SEED_TIMESTAMP_BASE - hours * ONE_HOUR_IN_MILLISECONDS).toISOString();
23
42
  }
@@ -29,7 +48,8 @@ function slugFromHeadline(headline: string): string {
29
48
  export function createLocalPreviewContentItemVersions(): ContentItemVersionRecord[] {
30
49
  const stageKeys = contentItemStageKeys(getContentItemStageDefinitions());
31
50
  return stageKeys.flatMap((stageKey, stageIndex) => {
32
- const headlines = SEED_CONTENT_ITEM_HEADLINES_BY_POSITION[stageIndex % SEED_CONTENT_ITEM_HEADLINES_BY_POSITION.length];
51
+ const tenantHeadlines = headlinesForSelectedTenant();
52
+ const headlines = tenantHeadlines[stageIndex % tenantHeadlines.length];
33
53
  return headlines.map((headline, headlineIndex) => {
34
54
  const slug = slugFromHeadline(headline);
35
55
  return {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The local preview stands in for the multi-tenant newsroom: choosing a publishing account in the account menu
3
+ * switches which publication's content the preview shows. The choice is a cookie so server and browser agree.
4
+ */
5
+ export const LOCAL_PREVIEW_TENANT_COOKIE = "papyrus-preview-tenant";
6
+ export const LOCAL_PREVIEW_DEFAULT_TENANT_ID = "preview-papyrus";
7
+
8
+ export type LocalPreviewTenant = { accountId: string; name: string; role: string; fixtureHeadlineSet: number };
9
+
10
+ export const LOCAL_PREVIEW_TENANTS: readonly LocalPreviewTenant[] = [
11
+ { accountId: LOCAL_PREVIEW_DEFAULT_TENANT_ID, name: "Papyrus", role: "publisher", fixtureHeadlineSet: 0 },
12
+ { accountId: "preview-pilobolus", name: "Pilobolus", role: "editor", fixtureHeadlineSet: 1 },
13
+ ];
14
+
15
+ export function previewTenantIdFromCookieHeader(cookieHeader: string | null | undefined): string {
16
+ const match = cookieHeader?.split(";").map(part => part.trim()).find(part => part.startsWith(`${LOCAL_PREVIEW_TENANT_COOKIE}=`));
17
+ const value = match ? decodeURIComponent(match.slice(LOCAL_PREVIEW_TENANT_COOKIE.length + 1)) : "";
18
+ return LOCAL_PREVIEW_TENANTS.some(tenant => tenant.accountId === value) ? value : LOCAL_PREVIEW_DEFAULT_TENANT_ID;
19
+ }
20
+
21
+ export function getLocalPreviewTenantId(): string {
22
+ return typeof document === "undefined" ? LOCAL_PREVIEW_DEFAULT_TENANT_ID : previewTenantIdFromCookieHeader(document.cookie);
23
+ }
24
+
25
+ export function setLocalPreviewTenantId(accountId: string): void {
26
+ document.cookie = `${LOCAL_PREVIEW_TENANT_COOKIE}=${encodeURIComponent(accountId)}; path=/; max-age=31536000; samesite=lax`;
27
+ }
@@ -0,0 +1,22 @@
1
+ import {
2
+ PROSPECTUS_SESSION_COOKIE,
3
+ loadProspectusSessionSealingSecret,
4
+ openProspectusCookie,
5
+ type ProspectusSignedInSession,
6
+ } from "./prospectus-session";
7
+ import { tenantIdForProspectusAccount } from "./tenant-keys";
8
+
9
+ type CookieReader = { get(name: string): { value: string } | undefined };
10
+
11
+ /**
12
+ * The tenant of the publishing account the signed-in person selected, read from the sealed Prospectus session
13
+ * cookie. Returns undefined on a site without Prospectus, with no session, or with no account selected, so the
14
+ * caller falls back to the site's own tenant (PAPYRUS_TENANT_ID).
15
+ */
16
+ export async function selectedTenantIdFromCookies(cookies: CookieReader, loadSecret: () => Promise<string | null> = loadProspectusSessionSealingSecret): Promise<string | undefined> {
17
+ const sealingSecret = await loadSecret();
18
+ if (!sealingSecret) return undefined;
19
+ const session = openProspectusCookie<ProspectusSignedInSession>(sealingSecret, PROSPECTUS_SESSION_COOKIE, cookies.get(PROSPECTUS_SESSION_COOKIE)?.value);
20
+ if (!session || session.accessTokenExpiresAt * 1000 <= Date.now()) return undefined;
21
+ return tenantIdForProspectusAccount(session.selectedAccountId);
22
+ }
@@ -0,0 +1,32 @@
1
+ export const TENANT_ID_ENVIRONMENT_VARIABLE = "PAPYRUS_TENANT_ID";
2
+ const TENANT_KEY_SEPARATOR = "#";
3
+ const TENANT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
4
+
5
+ export function validateTenantId(tenantId: string): string {
6
+ if (!TENANT_ID_PATTERN.test(tenantId)) {
7
+ throw new Error(`Tenant id "${tenantId}" must be lowercase letters, digits and hyphens, starting with a letter or digit.`);
8
+ }
9
+ return tenantId;
10
+ }
11
+
12
+ export function tenantIdFromEnvironment(environment: NodeJS.ProcessEnv = process.env): string | undefined {
13
+ const configured = environment[TENANT_ID_ENVIRONMENT_VARIABLE]?.trim();
14
+ return configured ? validateTenantId(configured) : undefined;
15
+ }
16
+
17
+ export function tenantScopedValue(tenantId: string | undefined, value: string): string {
18
+ if (!tenantId) return value;
19
+ const prefix = `${tenantId}${TENANT_KEY_SEPARATOR}`;
20
+ return value.startsWith(prefix) ? value : `${prefix}${value}`;
21
+ }
22
+
23
+ export function rowBelongsToTenant(tenantId: string | undefined, row: { tenantId?: string | null } | null | undefined): boolean {
24
+ if (!tenantId || !row) return true;
25
+ return row.tenantId === tenantId;
26
+ }
27
+
28
+ /** The tenant a Prospectus publishing account maps to: its id as lowercase letters, digits and hyphens. */
29
+ export function tenantIdForProspectusAccount(accountId: string | null | undefined): string | undefined {
30
+ const normalized = (accountId ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
31
+ return normalized ? validateTenantId(normalized) : undefined;
32
+ }
package/package.json CHANGED
@@ -101,6 +101,7 @@
101
101
  "./components/presentation-header": "./components/presentation-header.tsx",
102
102
  "./components/presentation-rhythm-hrule": "./components/presentation-rhythm-hrule.tsx",
103
103
  "./components/priming-proof-page": "./components/priming-proof-page.tsx",
104
+ "./components/prospectus-account-chooser": "./components/prospectus-account-chooser.tsx",
104
105
  "./components/publication-cyclotron-panel": "./components/publication-cyclotron-panel.tsx",
105
106
  "./components/reader-auth-control": "./components/reader-auth-control.tsx",
106
107
  "./components/reader-auth-state": "./components/reader-auth-state.ts",
@@ -191,6 +192,7 @@
191
192
  "./lib/layout-types": "./lib/layout-types.ts",
192
193
  "./lib/layout-warnings": "./lib/layout-warnings.ts",
193
194
  "./lib/local-preview-seed": "./lib/local-preview-seed.ts",
195
+ "./lib/local-preview-tenant": "./lib/local-preview-tenant.ts",
194
196
  "./lib/local-preview": "./lib/local-preview.ts",
195
197
  "./lib/markus-body": "./lib/markus-body.ts",
196
198
  "./lib/markus-ir": "./lib/markus-ir.ts",
@@ -233,6 +235,7 @@
233
235
  "./lib/prospectus-session": "./lib/prospectus-session.ts",
234
236
  "./lib/prospectus-sign-in-routes": "./lib/prospectus-sign-in-routes.ts",
235
237
  "./lib/prospectus-site-identity": "./lib/prospectus-site-identity.ts",
238
+ "./lib/prospectus-tenant": "./lib/prospectus-tenant.ts",
236
239
  "./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
237
240
  "./lib/publication-cyclotron": "./lib/publication-cyclotron.ts",
238
241
  "./lib/publication-items": "./lib/publication-items.ts",
@@ -266,6 +269,7 @@
266
269
  "./lib/staging-gated-path": "./lib/staging-gated-path.ts",
267
270
  "./lib/staging-preview-object": "./lib/staging-preview-object.ts",
268
271
  "./lib/story-reporting-trail": "./lib/story-reporting-trail.ts",
272
+ "./lib/tenant-keys": "./lib/tenant-keys.ts",
269
273
  "./lib/test-editor-override": "./lib/test-editor-override.ts",
270
274
  "./lib/text-columns": "./lib/text-columns.ts",
271
275
  "./lib/themed-image": "./lib/themed-image.ts",
@@ -364,7 +368,7 @@
364
368
  "optional": true
365
369
  }
366
370
  },
367
- "version": "1.4.0-next.5",
371
+ "version": "1.4.0-next.6",
368
372
  "dependencies": {
369
373
  "@aws-amplify/adapter-nextjs": "^1.7.3",
370
374
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.4.0-next.5",
2
+ "version": "1.4.0-next.6",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",