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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/amplify/data/resource.js +67 -0
  2. package/amplify/data/resource.ts +72 -0
  3. package/amplify/data/schema.js +67 -0
  4. package/amplify/data/schema.ts +72 -0
  5. package/amplify/functions/category-action/handler.ts +172 -2
  6. package/amplify/functions/content-actions/requirements.txt +1 -1
  7. package/amplify/functions/content-actions/resource.js +1 -1
  8. package/amplify/functions/content-actions/resource.ts +1 -1
  9. package/amplify/functions/renditions/requirements.txt +1 -1
  10. package/amplify/functions/shared/curation-delegation.ts +67 -0
  11. package/amplify/functions/shared/reference-curation-metadata.ts +20 -0
  12. package/amplify/publication/renditions-function.js +1 -1
  13. package/amplify/publication/renditions-function.ts +1 -1
  14. package/components/administration-delegations-panel.tsx +204 -0
  15. package/components/board-card-excerpts.tsx +43 -27
  16. package/components/curation-delegation-client.ts +81 -0
  17. package/components/news-desk-taxonomy-client.ts +7 -0
  18. package/components/newsroom-newsprint-board.css +1 -1
  19. package/components/newsroom-organizer-shell.tsx +4 -1
  20. package/components/newsroom-organizer.css +31 -0
  21. package/components/newsroom-references-view.tsx +56 -21
  22. package/components/prospectus-account-chooser.tsx +98 -0
  23. package/components/reference-cyclotron-status.tsx +5 -5
  24. package/components/reference-cyclotron-suggestion.tsx +143 -0
  25. package/components/reference-live-agreement.tsx +62 -0
  26. package/components/topic-steering-workspace.tsx +49 -3
  27. package/lib/content-source-context.ts +9 -1
  28. package/lib/curation-delegation-helpers.ts +59 -0
  29. package/lib/cyclotron-suggestions.ts +263 -0
  30. package/lib/graphql-content-repository.ts +42 -7
  31. package/lib/local-preview-seed.ts +141 -3
  32. package/lib/local-preview-tenant.ts +27 -0
  33. package/lib/prospectus-tenant.ts +22 -0
  34. package/lib/relevance-decisions.ts +29 -0
  35. package/lib/tenant-keys.ts +32 -0
  36. package/package.json +11 -1
  37. package/routes.manifest.json +1 -1
@@ -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 {
@@ -1,14 +1,16 @@
1
- import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
1
+ import type { AssignmentRecord, MessageRecord, ReferenceRecord, SemanticRelationRecord } from "./category-repository";
2
+ import { SHADOW_AGREEMENT_SCHEMA, type ShadowAgreement } from "./cyclotron-suggestions";
2
3
  import type { EditableArticle } from "./newsroom-articles";
3
4
  import type { MediaAssetRecord } from "./newsroom-media";
4
5
  import { EMPTY_STORY_REPORTING_TRAIL, type StoryReportingTrail, type TrailVersion } from "./story-reporting-trail";
5
6
  import { contentItemStageKeys, type ContentItemVersionRecord } from "./content-item-lifecycle";
6
7
  import { getContentItemStageDefinitions } from "./site-brand";
8
+ import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId } from "./local-preview-tenant";
7
9
 
8
10
  const SEED_TIMESTAMP_BASE = Date.parse("2026-10-01T12:00:00.000Z");
9
11
  const ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000;
10
12
 
11
- const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION: readonly (readonly string[])[] = [
13
+ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ZERO: readonly (readonly string[])[] = [
12
14
  ["Three sources confirm the zoning reversal", "Reporting packet: school board redistricting"],
13
15
  ["Draft in progress: the last ferry crossing", "Draft in progress: neighborhood bakery profile"],
14
16
  ["Illustration brief: flooded streets, aerial view", "Illustration brief: the market at dawn"],
@@ -18,6 +20,24 @@ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION: readonly (readonly string[])[] =
18
20
  ["Spiked: the unconfirmed council rumor", "Spiked: the recycled press release"],
19
21
  ];
20
22
 
23
+ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ONE: readonly (readonly string[])[] = [
24
+ ["Field note: spore dispersal on a damp morning", "Field note: the dung beetle census"],
25
+ ["Draft in progress: why fungi throw their caps", "Draft in progress: the compost experiment"],
26
+ ["Illustration brief: a cap in flight, close up", "Illustration brief: the mycelium network"],
27
+ ["Layout pass: the monthly field guide", "Layout pass: the reader mailbag"],
28
+ ["Published: a season of mushroom sightings", "Published: how the forest floor recycles"],
29
+ ["Held for spring: the truffle hunt", "Evergreen: a beginner's guide to identification"],
30
+ ["Spiked: the unverified foraging claim", "Spiked: the duplicate field report"],
31
+ ];
32
+
33
+ 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;
34
+
35
+ function headlinesForSelectedTenant(): readonly (readonly string[])[] {
36
+ const tenantId = getLocalPreviewTenantId();
37
+ const tenant = LOCAL_PREVIEW_TENANTS.find(candidate => candidate.accountId === tenantId) ?? LOCAL_PREVIEW_TENANTS[0];
38
+ return SEED_CONTENT_ITEM_HEADLINES_BY_TENANT[tenant.fixtureHeadlineSet] ?? SEED_CONTENT_ITEM_HEADLINES_BY_TENANT[0];
39
+ }
40
+
21
41
  function isoTimestampHoursBeforeBase(hours: number): string {
22
42
  return new Date(SEED_TIMESTAMP_BASE - hours * ONE_HOUR_IN_MILLISECONDS).toISOString();
23
43
  }
@@ -29,7 +49,8 @@ function slugFromHeadline(headline: string): string {
29
49
  export function createLocalPreviewContentItemVersions(): ContentItemVersionRecord[] {
30
50
  const stageKeys = contentItemStageKeys(getContentItemStageDefinitions());
31
51
  return stageKeys.flatMap((stageKey, stageIndex) => {
32
- const headlines = SEED_CONTENT_ITEM_HEADLINES_BY_POSITION[stageIndex % SEED_CONTENT_ITEM_HEADLINES_BY_POSITION.length];
52
+ const tenantHeadlines = headlinesForSelectedTenant();
53
+ const headlines = tenantHeadlines[stageIndex % tenantHeadlines.length];
33
54
  return headlines.map((headline, headlineIndex) => {
34
55
  const slug = slugFromHeadline(headline);
35
56
  return {
@@ -146,3 +167,120 @@ export function createLocalPreviewReportingTrail(lineageId: string): StoryReport
146
167
  export function createLocalPreviewMediaAssets(_itemId: string): MediaAssetRecord[] {
147
168
  return [];
148
169
  }
170
+
171
+ type PreviewSuggestionPlan = {
172
+ label: "include" | "exclude";
173
+ confidence: number;
174
+ curation: "none" | "human" | "service" | "delegated";
175
+ provenance: boolean;
176
+ };
177
+
178
+ const PREVIEW_SUGGESTION_PLANS: readonly PreviewSuggestionPlan[] = [
179
+ { label: "include", confidence: 0.91, curation: "none", provenance: true },
180
+ { label: "include", confidence: 0.86, curation: "human", provenance: true },
181
+ { label: "exclude", confidence: 0.72, curation: "service", provenance: true },
182
+ { label: "exclude", confidence: 0.88, curation: "delegated", provenance: true },
183
+ { label: "exclude", confidence: 0.64, curation: "none", provenance: false },
184
+ ];
185
+
186
+ const PREVIEW_CYCLOTRON_ID = "papyrus-relevance";
187
+
188
+ /**
189
+ * Fixture data for reviewing the cyclotron suggestion UI without a backend:
190
+ * suggestions with provenance on the first references, a mix of human and
191
+ * non-human curation messages, and a live-agreement payload.
192
+ */
193
+ export function createLocalPreviewCyclotronFixtures(references: ReferenceRecord[]): {
194
+ relations: SemanticRelationRecord[];
195
+ messages: MessageRecord[];
196
+ shadowAgreement: ShadowAgreement;
197
+ } {
198
+ const relations: SemanticRelationRecord[] = [];
199
+ const messages: MessageRecord[] = [];
200
+ references.slice(0, PREVIEW_SUGGESTION_PLANS.length).forEach((reference, index) => {
201
+ const plan = PREVIEW_SUGGESTION_PLANS[index];
202
+ const lineageId = reference.lineageId ?? reference.id;
203
+ const decidedAt = isoTimestampHoursBeforeBase(40 - index);
204
+ relations.push({
205
+ id: `preview-relevance-relation-${index}`,
206
+ relationState: "current",
207
+ predicate: "relevance_decision_is",
208
+ subjectKind: "reference",
209
+ subjectId: reference.id,
210
+ subjectLineageId: lineageId,
211
+ objectKind: "semanticNode",
212
+ objectId: `semantic-node-relevance-${plan.label}-v1`,
213
+ objectLineageId: `semantic-node-relevance-${plan.label}`,
214
+ subjectStateKey: `reference#${lineageId}#current`,
215
+ objectStateKey: `semanticNode#semantic-node-relevance-${plan.label}#current`,
216
+ objectSubjectStateKey: `semanticNode#semantic-node-relevance-${plan.label}#current#reference`,
217
+ predicateObjectStateKey: `relevance_decision_is#semanticNode#semantic-node-relevance-${plan.label}#current`,
218
+ subjectVersionKey: `reference#${reference.id}`,
219
+ objectVersionKey: `semanticNode#semantic-node-relevance-${plan.label}-v1`,
220
+ confidence: plan.confidence,
221
+ modelVersion: "4",
222
+ reviewRecommended: false,
223
+ metadata: JSON.stringify({
224
+ decisionId: `preview-decision-${index}`,
225
+ label: plan.label,
226
+ probabilities: { include: plan.label === "include" ? plan.confidence : 1 - plan.confidence, exclude: plan.label === "exclude" ? plan.confidence : 1 - plan.confidence },
227
+ version: 4,
228
+ decidedAt,
229
+ labels: ["include", "exclude"],
230
+ positiveLabel: "include",
231
+ question: "Should this source be used as evidence for the publication?",
232
+ cyclotronId: PREVIEW_CYCLOTRON_ID,
233
+ ...(plan.provenance ? {
234
+ provenance: {
235
+ labelSet: ["include", "exclude"],
236
+ doctrineFingerprint: "3f9c2a71d80b46e5",
237
+ teachingFingerprint: "b71e04c9aa5d2f13",
238
+ teachingLabels: 37,
239
+ primingExamples: 6,
240
+ },
241
+ } : {}),
242
+ }),
243
+ } as SemanticRelationRecord);
244
+ if (plan.curation !== "none") {
245
+ const createdAt = isoTimestampHoursBeforeBase(30 - index);
246
+ messages.push({
247
+ id: `preview-curation-message-${index}`,
248
+ messageKind: "reference_curation",
249
+ messageDomain: "commentary",
250
+ status: "active",
251
+ summary: `${reference.title ?? lineageId}: curated`,
252
+ authorLabel: plan.curation === "delegated" ? "Maya Okafor" : null,
253
+ source: "newsroom",
254
+ createdAt,
255
+ updatedAt: createdAt,
256
+ metadata: JSON.stringify({
257
+ action: reference.curationStatus === "rejected" ? "reject" : "accept",
258
+ curationStatus: reference.curationStatus,
259
+ referenceLineageId: lineageId,
260
+ actorKind: plan.curation === "service" ? "service" : "human",
261
+ ...(plan.curation === "delegated" ? {
262
+ attestation: "delegated",
263
+ relayedBy: "newsroom-reporter",
264
+ delegationId: "curation-delegation-9b1f6c24-5d0e-4a7b-8c31-2e6f0a1d7b45",
265
+ instruction: "Reject that one, it duplicates the filing from Tuesday.",
266
+ } : {}),
267
+ }),
268
+ });
269
+ }
270
+ });
271
+ return {
272
+ relations,
273
+ messages,
274
+ shadowAgreement: {
275
+ schema: SHADOW_AGREEMENT_SCHEMA,
276
+ comparisons: 23,
277
+ agreement: 0.87,
278
+ precision: 0.9,
279
+ recall: 0.82,
280
+ confusion: { truePositive: 9, falsePositive: 1, falseNegative: 2, trueNegative: 11 },
281
+ computedAt: isoTimestampHoursBeforeBase(3),
282
+ positiveLabel: "include",
283
+ loop: { enabled: true, stoppedBecause: "daily decision cap", lastSweepAt: isoTimestampHoursBeforeBase(3), maxDecisionsPerDay: 50 },
284
+ },
285
+ };
286
+ }
@@ -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
+ }
@@ -2,6 +2,15 @@ import type { SemanticRelationRecord } from "./category-repository";
2
2
 
3
3
  export const RELEVANCE_RELATION = "relevance_decision_is";
4
4
 
5
+ /** What produced a suggestion beyond the model version, as the sweep recorded it. */
6
+ export type RelevanceProvenance = {
7
+ labelSet: string[];
8
+ doctrineFingerprint: string | null;
9
+ teachingFingerprint: string | null;
10
+ teachingLabels: number;
11
+ primingExamples: number;
12
+ };
13
+
5
14
  /** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
6
15
  export type RelevanceDecisionView = {
7
16
  decisionRelationId: string;
@@ -19,6 +28,10 @@ export type RelevanceDecisionView = {
19
28
  /** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
20
29
  reviewControl: "thumbs" | "labels";
21
30
  cyclotronId: string | null;
31
+ /** When the cyclotron made the suggestion, if the decision recorded it. */
32
+ decidedAt: string | null;
33
+ /** Null for decisions made before provenance was recorded. */
34
+ provenance: RelevanceProvenance | null;
22
35
  };
23
36
 
24
37
  /** What an editor submits from the review control. */
@@ -49,6 +62,20 @@ function parseMetadata(value: unknown): Record<string, unknown> {
49
62
  }
50
63
  }
51
64
 
65
+ function parseProvenance(value: unknown): RelevanceProvenance | null {
66
+ const record = parseMetadata(value);
67
+ if (!Object.keys(record).length) return null;
68
+ const count = (entry: unknown) => (Number.isFinite(Number(entry)) ? Number(entry) : 0);
69
+ const text = (entry: unknown) => (typeof entry === "string" && entry ? entry : null);
70
+ return {
71
+ labelSet: Array.isArray(record.labelSet) ? record.labelSet.map(String) : [],
72
+ doctrineFingerprint: text(record.doctrineFingerprint),
73
+ teachingFingerprint: text(record.teachingFingerprint),
74
+ teachingLabels: count(record.teachingLabels),
75
+ primingExamples: count(record.primingExamples),
76
+ };
77
+ }
78
+
52
79
  /**
53
80
  * Current decisions keyed by reference lineage, from relations the Newsroom
54
81
  * already loads: no extra request per row.
@@ -77,6 +104,8 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
77
104
  question: typeof metadata.question === "string" ? metadata.question : null,
78
105
  reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
79
106
  cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
107
+ decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
108
+ provenance: parseProvenance(metadata.provenance),
80
109
  });
81
110
  }
82
111
  return decisions;
@@ -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
@@ -64,6 +64,7 @@
64
64
  "types": "./infra/amplify-app-shell.ts",
65
65
  "default": "./infra/index.js"
66
66
  },
67
+ "./components/administration-delegations-panel": "./components/administration-delegations-panel.tsx",
67
68
  "./components/ai-elements/conversation": "./components/ai-elements/conversation.tsx",
68
69
  "./components/ai-elements/model-selector": "./components/ai-elements/model-selector.tsx",
69
70
  "./components/ai-elements/prompt-input": "./components/ai-elements/prompt-input.tsx",
@@ -76,6 +77,7 @@
76
77
  "./components/article-video": "./components/article-video.tsx",
77
78
  "./components/blog-page-background": "./components/blog-page-background.tsx",
78
79
  "./components/board-card-excerpts": "./components/board-card-excerpts.tsx",
80
+ "./components/curation-delegation-client": "./components/curation-delegation-client.ts",
79
81
  "./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
80
82
  "./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
81
83
  "./components/news-desk-client-provider": "./components/news-desk-client-provider.tsx",
@@ -101,6 +103,7 @@
101
103
  "./components/presentation-header": "./components/presentation-header.tsx",
102
104
  "./components/presentation-rhythm-hrule": "./components/presentation-rhythm-hrule.tsx",
103
105
  "./components/priming-proof-page": "./components/priming-proof-page.tsx",
106
+ "./components/prospectus-account-chooser": "./components/prospectus-account-chooser.tsx",
104
107
  "./components/publication-cyclotron-panel": "./components/publication-cyclotron-panel.tsx",
105
108
  "./components/reader-auth-control": "./components/reader-auth-control.tsx",
106
109
  "./components/reader-auth-state": "./components/reader-auth-state.ts",
@@ -109,6 +112,8 @@
109
112
  "./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
110
113
  "./components/reference-cyclotron-priming": "./components/reference-cyclotron-priming.tsx",
111
114
  "./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
115
+ "./components/reference-cyclotron-suggestion": "./components/reference-cyclotron-suggestion.tsx",
116
+ "./components/reference-live-agreement": "./components/reference-live-agreement.tsx",
112
117
  "./components/reference-priming": "./components/reference-priming.tsx",
113
118
  "./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
114
119
  "./components/reference-source-preview": "./components/reference-source-preview.tsx",
@@ -171,6 +176,8 @@
171
176
  "./lib/content-source-context": "./lib/content-source-context.ts",
172
177
  "./lib/content-types": "./lib/content-types.ts",
173
178
  "./lib/curation-client": "./lib/curation-client.ts",
179
+ "./lib/curation-delegation-helpers": "./lib/curation-delegation-helpers.ts",
180
+ "./lib/cyclotron-suggestions": "./lib/cyclotron-suggestions.ts",
174
181
  "./lib/define-site": "./lib/define-site.ts",
175
182
  "./lib/demo-amplify-outputs": "./lib/demo-amplify-outputs.ts",
176
183
  "./lib/dev-sandbox-editor-auth": "./lib/dev-sandbox-editor-auth.ts",
@@ -191,6 +198,7 @@
191
198
  "./lib/layout-types": "./lib/layout-types.ts",
192
199
  "./lib/layout-warnings": "./lib/layout-warnings.ts",
193
200
  "./lib/local-preview-seed": "./lib/local-preview-seed.ts",
201
+ "./lib/local-preview-tenant": "./lib/local-preview-tenant.ts",
194
202
  "./lib/local-preview": "./lib/local-preview.ts",
195
203
  "./lib/markus-body": "./lib/markus-body.ts",
196
204
  "./lib/markus-ir": "./lib/markus-ir.ts",
@@ -233,6 +241,7 @@
233
241
  "./lib/prospectus-session": "./lib/prospectus-session.ts",
234
242
  "./lib/prospectus-sign-in-routes": "./lib/prospectus-sign-in-routes.ts",
235
243
  "./lib/prospectus-site-identity": "./lib/prospectus-site-identity.ts",
244
+ "./lib/prospectus-tenant": "./lib/prospectus-tenant.ts",
236
245
  "./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
237
246
  "./lib/publication-cyclotron": "./lib/publication-cyclotron.ts",
238
247
  "./lib/publication-items": "./lib/publication-items.ts",
@@ -266,6 +275,7 @@
266
275
  "./lib/staging-gated-path": "./lib/staging-gated-path.ts",
267
276
  "./lib/staging-preview-object": "./lib/staging-preview-object.ts",
268
277
  "./lib/story-reporting-trail": "./lib/story-reporting-trail.ts",
278
+ "./lib/tenant-keys": "./lib/tenant-keys.ts",
269
279
  "./lib/test-editor-override": "./lib/test-editor-override.ts",
270
280
  "./lib/text-columns": "./lib/text-columns.ts",
271
281
  "./lib/themed-image": "./lib/themed-image.ts",
@@ -364,7 +374,7 @@
364
374
  "optional": true
365
375
  }
366
376
  },
367
- "version": "1.4.0-next.5",
377
+ "version": "1.4.0-next.7",
368
378
  "dependencies": {
369
379
  "@aws-amplify/adapter-nextjs": "^1.7.3",
370
380
  "@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.7",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",