@anthusai/papyrus 1.17.0 → 1.18.0-next.1

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.
@@ -808,6 +808,7 @@ const schema = a.schema({
808
808
  categoryLineageId: a.id(),
809
809
  }),
810
810
  AssignmentContext: a.customType({
811
+ editorialContext: a.json().required(),
811
812
  assignment: a.ref("AssignmentSummary"),
812
813
  doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
813
814
  targets: a.ref("AssignmentTargetSummary").array().required(),
@@ -834,6 +835,18 @@ const schema = a.schema({
834
835
  .returns(a.ref("AssignmentContext").array().required())
835
836
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
836
837
  .handler(a.handler.function(assignmentAction)),
838
+ getEditorialContext: a
839
+ .query()
840
+ .arguments({
841
+ publicationKey: a.string().required(),
842
+ sectionId: a.id(),
843
+ storyLineageId: a.id(),
844
+ assignmentId: a.id(),
845
+ maxCharacters: a.integer(),
846
+ })
847
+ .returns(a.json().required())
848
+ .authorization((allow) => [allow.groups(categoryWriteGroups)])
849
+ .handler(a.handler.function(assignmentAction)),
837
850
  getAssignmentContext: a
838
851
  .query()
839
852
  .arguments({
@@ -878,6 +878,7 @@ const schema = a.schema({
878
878
  }),
879
879
 
880
880
  AssignmentContext: a.customType({
881
+ editorialContext: a.json().required(),
881
882
  assignment: a.ref("AssignmentSummary"),
882
883
  doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
883
884
  targets: a.ref("AssignmentTargetSummary").array().required(),
@@ -907,6 +908,19 @@ const schema = a.schema({
907
908
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
908
909
  .handler(a.handler.function(assignmentAction)),
909
910
 
911
+ getEditorialContext: a
912
+ .query()
913
+ .arguments({
914
+ publicationKey: a.string().required(),
915
+ sectionId: a.id(),
916
+ storyLineageId: a.id(),
917
+ assignmentId: a.id(),
918
+ maxCharacters: a.integer(),
919
+ })
920
+ .returns(a.json().required())
921
+ .authorization((allow) => [allow.groups(categoryWriteGroups)])
922
+ .handler(a.handler.function(assignmentAction)),
923
+
910
924
  getAssignmentContext: a
911
925
  .query()
912
926
  .arguments({
@@ -715,6 +715,7 @@ const baseDefinitions = {
715
715
  categoryLineageId: a.id(),
716
716
  }),
717
717
  AssignmentContext: a.customType({
718
+ editorialContext: a.json().required(),
718
719
  assignment: a.ref("AssignmentSummary"),
719
720
  doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
720
721
  targets: a.ref("AssignmentTargetSummary").array().required(),
@@ -741,6 +742,18 @@ const baseDefinitions = {
741
742
  .returns(a.ref("AssignmentContext").array().required())
742
743
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
743
744
  .handler(a.handler.function(assignmentAction)),
745
+ getEditorialContext: a
746
+ .query()
747
+ .arguments({
748
+ publicationKey: a.string().required(),
749
+ sectionId: a.id(),
750
+ storyLineageId: a.id(),
751
+ assignmentId: a.id(),
752
+ maxCharacters: a.integer(),
753
+ })
754
+ .returns(a.json().required())
755
+ .authorization((allow) => [allow.groups(categoryWriteGroups)])
756
+ .handler(a.handler.function(assignmentAction)),
744
757
  getAssignmentContext: a
745
758
  .query()
746
759
  .arguments({
@@ -779,6 +779,7 @@ const baseDefinitions = {
779
779
  }),
780
780
 
781
781
  AssignmentContext: a.customType({
782
+ editorialContext: a.json().required(),
782
783
  assignment: a.ref("AssignmentSummary"),
783
784
  doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
784
785
  targets: a.ref("AssignmentTargetSummary").array().required(),
@@ -808,6 +809,19 @@ const baseDefinitions = {
808
809
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
809
810
  .handler(a.handler.function(assignmentAction)),
810
811
 
812
+ getEditorialContext: a
813
+ .query()
814
+ .arguments({
815
+ publicationKey: a.string().required(),
816
+ sectionId: a.id(),
817
+ storyLineageId: a.id(),
818
+ assignmentId: a.id(),
819
+ maxCharacters: a.integer(),
820
+ })
821
+ .returns(a.json().required())
822
+ .authorization((allow) => [allow.groups(categoryWriteGroups)])
823
+ .handler(a.handler.function(assignmentAction)),
824
+
811
825
  getAssignmentContext: a
812
826
  .query()
813
827
  .arguments({
@@ -1,3 +1,5 @@
1
+ import { assignmentDoctrine, resolveEditorialContext, type EditorialClient } from "../shared/editorial-context";
2
+ import { tenantIdFromEnvironment } from "../../../lib/tenant-keys";
1
3
  import { randomUUID } from "node:crypto";
2
4
  import { spawnSync } from "node:child_process";
3
5
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -7,7 +9,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
7
9
  import { Amplify } from "aws-amplify";
8
10
  import { generateClient } from "aws-amplify/data";
9
11
  import type { Schema } from "../../data/resource";
10
- import { putJsonModelPayload, readJsonModelPayload } from "../shared/model-payloads";
12
+ import { putJsonModelPayload, readJsonModelPayload, readEditorialAttachment } from "../shared/model-payloads";
11
13
 
12
14
  type AssignmentHandler =
13
15
  | Schema["claimAssignment"]["functionHandler"]
@@ -17,6 +19,7 @@ type AssignmentHandler =
17
19
  | Schema["reopenAssignment"]["functionHandler"]
18
20
  | Schema["retryImmediateAssignment"]["functionHandler"]
19
21
  | Schema["getAssignmentContext"]["functionHandler"]
22
+ | Schema["getEditorialContext"]["functionHandler"]
20
23
  | Schema["listAssignmentQueue"]["functionHandler"]
21
24
  | Schema["listAssignmentsForObject"]["functionHandler"];
22
25
  type DataClient = ReturnType<typeof generateClient<Schema>>;
@@ -32,14 +35,12 @@ const IMMEDIATE_ASSIGNMENT_TYPES = new Set(["procedure.run"]);
32
35
  const NEWSROOM_SUMMARY_PAYLOAD_ID = "knowledge-raw-payload-newsroom-summary-current";
33
36
  const NEWSROOM_SUMMARY_PAYLOAD_OWNER_KIND = "knowledgeRawPayload";
34
37
  const SUMMARY_STALE_AFTER_MS = 15 * 60 * 1000;
35
- const PUBLICATION_DOCTRINE_DEFINITIONS = [
36
- { scope: "publication", kind: "mission", label: "Editorial Mission", slug: "editorial-doctrine-mission" },
37
- { scope: "publication", kind: "policy", label: "Editorial Policy", slug: "editorial-doctrine-policy" },
38
- ] as const;
39
38
  let clientPromise: Promise<DataClient> | null = null;
39
+ let editorialEndpoint: string | undefined;
40
40
 
41
41
  export const handler = async (event: any): Promise<any> => {
42
42
  const fieldName = normalizeRequiredString(event.info?.fieldName ?? event.fieldName, "fieldName");
43
+ if (fieldName === "getEditorialContext") return getEditorialContext(event as Parameters<Schema["getEditorialContext"]["functionHandler"]>[0]);
43
44
  if (fieldName === "getAssignmentContext") return getAssignmentContext(event as Parameters<Schema["getAssignmentContext"]["functionHandler"]>[0]);
44
45
  if (fieldName === "listAssignmentQueue") return listAssignmentQueue(event as Parameters<Schema["listAssignmentQueue"]["functionHandler"]>[0]);
45
46
  if (fieldName === "listAssignmentsForObject") return listAssignmentsForObject(event as Parameters<Schema["listAssignmentsForObject"]["functionHandler"]>[0]);
@@ -315,6 +316,20 @@ async function listAssignmentsForObject(event: Parameters<Schema["listAssignment
315
316
  return contexts;
316
317
  }
317
318
 
319
+ async function editorialContext(client: DataClient, scope: { publicationKey?: string; sectionId?: string | null; storyLineageId?: string | null; assignmentId?: string | null; maxCharacters?: number | null }) {
320
+ if (!editorialEndpoint) throw new Error("Editorial context requires the configured backend endpoint.");
321
+ return resolveEditorialContext(client as unknown as EditorialClient, {
322
+ ...scope, endpoint: editorialEndpoint, tenantId: tenantIdFromEnvironment(), readAttachment: readEditorialAttachment,
323
+ });
324
+ }
325
+
326
+ async function getEditorialContext(event: Parameters<Schema["getEditorialContext"]["functionHandler"]>[0]) {
327
+ const client = await getDataClient();
328
+ return JSON.stringify(await editorialContext(client, {
329
+ ...event.arguments, publicationKey: normalizeRequiredString(event.arguments.publicationKey, "publicationKey"),
330
+ }));
331
+ }
332
+
318
333
  async function buildAssignmentContext(client: DataClient, assignment: any) {
319
334
  const [relations, events] = await Promise.all([
320
335
  listRelationPages(client, "listSemanticRelationsBySubjectState", { subjectStateKey: semanticStateKey("assignment", assignment.id) }),
@@ -329,172 +344,17 @@ async function buildAssignmentContext(client: DataClient, assignment: any) {
329
344
  label: targetLabelFromRelation(relation),
330
345
  detail: relation.predicate,
331
346
  }));
332
- const rootDeskCategory = await resolveRootDeskCategory(client, assignment, relations);
333
- const doctrine = await buildAssignmentDoctrineContext(client, rootDeskCategory);
347
+ const pack = await editorialContext(client, { assignmentId: assignment.id });
348
+ const doctrine = assignmentDoctrine(pack);
334
349
  return {
335
350
  assignment,
351
+ editorialContext: JSON.stringify(pack),
336
352
  doctrine,
337
353
  targets,
338
354
  events,
339
355
  };
340
356
  }
341
357
 
342
- async function buildAssignmentDoctrineContext(client: DataClient, rootDeskCategory: any | null): Promise<any[]> {
343
- const definitions = [
344
- ...PUBLICATION_DOCTRINE_DEFINITIONS,
345
- ...(rootDeskCategory ? [
346
- {
347
- scope: "desk",
348
- kind: "mission",
349
- label: "Desk Mission",
350
- slug: deskDoctrineSlug(rootDeskCategory.categoryKey, "mission"),
351
- categoryKey: rootDeskCategory.categoryKey,
352
- categoryLineageId: rootDeskCategory.lineageId ?? rootDeskCategory.id,
353
- },
354
- {
355
- scope: "desk",
356
- kind: "policy",
357
- label: "Desk Policies",
358
- slug: deskDoctrineSlug(rootDeskCategory.categoryKey, "policy"),
359
- categoryKey: rootDeskCategory.categoryKey,
360
- categoryLineageId: rootDeskCategory.lineageId ?? rootDeskCategory.id,
361
- },
362
- ] : []),
363
- ];
364
-
365
- const context = [];
366
- for (const definition of definitions) {
367
- const record = await getDoctrineRecordBySlug(client, definition.slug);
368
- if (!record) continue;
369
- context.push({
370
- scope: definition.scope,
371
- kind: definition.kind,
372
- label: definition.label,
373
- slug: definition.slug,
374
- body: compactStringArray(record.body),
375
- categoryKey: "categoryKey" in definition ? definition.categoryKey : null,
376
- categoryLineageId: "categoryLineageId" in definition ? definition.categoryLineageId : null,
377
- });
378
- }
379
- return context;
380
- }
381
-
382
- async function resolveRootDeskCategory(client: DataClient, assignment: any, relations: any[]): Promise<any | null> {
383
- const categoryLineageId = assignmentCategoryLineageIdFromRelations(relations);
384
- if (categoryLineageId) {
385
- const category = await getCurrentCategoryByLineage(client, categoryLineageId);
386
- return category ? resolveRootCategory(client, category) : null;
387
- }
388
-
389
- const categoryKey = normalizeOptionalString(assignment.primaryFocusCategoryKey);
390
- if (categoryKey && assignment.categorySetId) {
391
- const category = await getCurrentCategoryBySetAndKey(client, assignment.categorySetId, categoryKey);
392
- return category ? resolveRootCategory(client, category) : null;
393
- }
394
-
395
- return null;
396
- }
397
-
398
- function assignmentCategoryLineageIdFromRelations(relations: any[]): string | null {
399
- const direct = relations.find((relation) => (
400
- relation.relationState === "current"
401
- && relation.objectKind === "category"
402
- && relation.predicate === "requests_work_on"
403
- )) ?? relations.find((relation) => (
404
- relation.relationState === "current"
405
- && relation.objectKind === "category"
406
- ));
407
- return normalizeOptionalString(direct?.objectLineageId);
408
- }
409
-
410
- async function resolveRootCategory(client: DataClient, category: any): Promise<any | null> {
411
- let current = category;
412
- for (let depth = 0; depth < 10; depth += 1) {
413
- const parentCategoryKey = normalizeOptionalString(current?.parentCategoryKey);
414
- if (!parentCategoryKey) return current;
415
- const parent = await getCurrentCategoryBySetAndKey(client, current.categorySetId, parentCategoryKey);
416
- if (!parent) return current;
417
- current = parent;
418
- }
419
- return current;
420
- }
421
-
422
- async function getCurrentCategoryByLineage(client: DataClient, lineageId: string): Promise<any | null> {
423
- const model = client.models.Category as any;
424
- const query = model.listCategoriesByLineageAndVersion;
425
- if (typeof query !== "function") return null;
426
- const categories: any[] = [];
427
- let nextToken: string | null | undefined;
428
- do {
429
- const response: DataClientResult<any[]> = await query({ lineageId }, { limit: 100, nextToken });
430
- assertNoDataErrors(response.errors, "list Category lineage");
431
- categories.push(...(response.data ?? []));
432
- nextToken = response.nextToken;
433
- } while (nextToken);
434
- return selectCurrentVersion(categories);
435
- }
436
-
437
- async function getCurrentCategoryBySetAndKey(client: DataClient, categorySetId: string, categoryKey: string): Promise<any | null> {
438
- const model = client.models.Category as any;
439
- const query = model.listCategoriesBySetAndKey;
440
- if (typeof query !== "function") return null;
441
- const categories: any[] = [];
442
- let nextToken: string | null | undefined;
443
- do {
444
- const response: DataClientResult<any[]> = await query({ categorySetId, categoryKey }, { limit: 100, nextToken });
445
- assertNoDataErrors(response.errors, "list Category set/key");
446
- categories.push(...(response.data ?? []));
447
- nextToken = response.nextToken;
448
- } while (nextToken);
449
- return selectCurrentVersion(categories);
450
- }
451
-
452
- async function getDoctrineRecordBySlug(client: DataClient, slug: string): Promise<any | null> {
453
- const model = client.models.Item as any;
454
- const query = model.itemBySlug;
455
- if (typeof query !== "function") return null;
456
- const records: any[] = [];
457
- let nextToken: string | null | undefined;
458
- do {
459
- const response: DataClientResult<any[]> = await query({ slug }, { limit: 100, nextToken });
460
- assertNoDataErrors(response.errors, "query Item by slug");
461
- records.push(...(response.data ?? []));
462
- nextToken = response.nextToken;
463
- } while (nextToken);
464
- return records.find((record) => record.type === "doctrine" && record.status === "private")
465
- ?? records.find((record) => record.type === "doctrine")
466
- ?? null;
467
- }
468
-
469
- function selectCurrentVersion(records: any[]): any | null {
470
- return [...records]
471
- .filter((record) => record && record.status !== "deprecated")
472
- .sort((left, right) => {
473
- const stateDiff = versionStateRank(left.versionState) - versionStateRank(right.versionState);
474
- if (stateDiff !== 0) return stateDiff;
475
- return Number(right.versionNumber ?? 0) - Number(left.versionNumber ?? 0);
476
- })[0] ?? null;
477
- }
478
-
479
- function versionStateRank(versionState: unknown): number {
480
- if (versionState === "current") return 0;
481
- if (versionState === "draft") return 1;
482
- if (versionState === "superseded") return 8;
483
- return 5;
484
- }
485
-
486
- function deskDoctrineSlug(categoryKey: string, kind: "mission" | "policy"): string {
487
- return `desk-doctrine-${safeDoctrineKey(categoryKey)}-${kind}`;
488
- }
489
-
490
- function safeDoctrineKey(value: string): string {
491
- return value
492
- .trim()
493
- .toLowerCase()
494
- .replace(/[^a-z0-9]+/g, "-")
495
- .replace(/^-+|-+$/g, "")
496
- || "topic";
497
- }
498
358
 
499
359
  function compactStringArray(value: unknown): string[] {
500
360
  if (!Array.isArray(value)) return [];
@@ -1159,6 +1019,7 @@ async function getDataClient(): Promise<DataClient> {
1159
1019
  clientPromise = (async () => {
1160
1020
  const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(process.env as never);
1161
1021
  Amplify.configure(resourceConfig, libraryOptions);
1022
+ editorialEndpoint = resourceConfig.API?.GraphQL?.endpoint;
1162
1023
  return generateClient<Schema>();
1163
1024
  })();
1164
1025
  }
@@ -0,0 +1,160 @@
1
+ import { createHash } from "node:crypto";
2
+ import { tenantScopedValue } from "../../../lib/tenant-keys";
3
+
4
+ export type EditorialRecord = Record<string, any>;
5
+ type Result = { data?: EditorialRecord | EditorialRecord[] | null; errors?: unknown; nextToken?: string | null };
6
+ type Model = Record<string, (args: Record<string, any>, options?: Record<string, any>) => Promise<Result>>;
7
+ export type EditorialClient = { models: Record<string, Model> };
8
+ export type EditorialOptions = {
9
+ publicationKey?: string;
10
+ endpoint: string;
11
+ tenantId?: string;
12
+ sectionId?: string | null;
13
+ storyLineageId?: string | null;
14
+ assignmentId?: string | null;
15
+ maxCharacters?: number | null;
16
+ readAttachment: (attachment: EditorialRecord) => Promise<string>;
17
+ };
18
+
19
+ function checked(response: Result, operation: string) {
20
+ if (response.errors && (!Array.isArray(response.errors) || response.errors.length)) throw new Error(`${operation}: ${JSON.stringify(response.errors)}`);
21
+ return response;
22
+ }
23
+
24
+ async function pages(client: EditorialClient, model: string, index: string, args: EditorialRecord) {
25
+ const rows: EditorialRecord[] = [];
26
+ let nextToken: string | null | undefined;
27
+ do {
28
+ const response = checked(await client.models[model][index](args, { limit: 100, nextToken }), `read ${model} ${index}`);
29
+ rows.push(...(response.data as EditorialRecord[] ?? []));
30
+ nextToken = response.nextToken;
31
+ } while (nextToken);
32
+ return rows;
33
+ }
34
+
35
+ async function get(client: EditorialClient, model: string, id: string) {
36
+ const row = checked(await client.models[model].get({ id }), `read ${model}`).data as EditorialRecord | null;
37
+ if (!row) throw new Error(`${model} not found: ${id}`);
38
+ return row;
39
+ }
40
+
41
+ function tenant(row: EditorialRecord, tenantId?: string) {
42
+ if ((row.tenantId || null) !== (tenantId || null)) throw new Error("Editorial record tenant does not match the configured authoring scope.");
43
+ }
44
+
45
+ function canonical(value: any): any {
46
+ if (Array.isArray(value)) return value.map(canonical);
47
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
48
+ return value;
49
+ }
50
+ function digest(value: string) { return createHash("sha256").update(value, "utf8").digest("hex"); }
51
+ function entry(scope: string, kind: string, text: string, record: EditorialRecord) {
52
+ return { scope, kind, text, source: {
53
+ ...Object.fromEntries(["id", "lineageId", "versionNumber", "updatedAt", "contentHash"].map(key => [key, record[key] ?? null])),
54
+ sha256: digest(text),
55
+ } };
56
+ }
57
+
58
+ export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0) {
59
+ const lines = [`Editorial context: ${publication.key}`, "Source: cloud",
60
+ "Precedence: publication > section > assignment. Surface conflicts; narrower instructions do not silently override publication policy.",
61
+ "Private operating guidance; not evidence or reader-facing copy."];
62
+ for (const value of entries) lines.push("", `## ${value.scope}: ${value.kind}`, value.text);
63
+ const text = lines.join("\n") + "\n";
64
+ const totalCharacters = Array.from(text).length;
65
+ if (!Number.isInteger(maxCharacters) || maxCharacters < 0) throw new Error("Editorial context budget must be nonnegative.");
66
+ if (maxCharacters && totalCharacters > maxCharacters) throw new Error(`Editorial context budget ${maxCharacters} cannot hold ${totalCharacters} required characters; policy was not truncated.`);
67
+ const payload = { schemaVersion: 1, rendererVersion: "editorial-context/v1", publication, scope, sourceMode: "cloud",
68
+ precedence: ["publication", "section", "assignment"], entries, sources: entries.map(value => value.source), warnings, text, totalCharacters };
69
+ return { ...payload, contextHash: digest(JSON.stringify(canonical(payload))), resolvedAt: new Date().toISOString() };
70
+ }
71
+
72
+ /** Private source adapter for the same deterministic contract exposed by papyrus editorial context. */
73
+ export async function resolveEditorialContext(client: EditorialClient, options: EditorialOptions) {
74
+ if (!options.endpoint) throw new Error("Explicit publication endpoint is required for hosted editorial context.");
75
+ const corpora = await pages(client, "KnowledgeCorpus", "listKnowledgeCorporaByRoleAndName", { role: "canonical" });
76
+ const requested = options.publicationKey;
77
+ const normalized = requested?.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 140);
78
+ if (corpora.length !== 1 || !corpora[0].id?.startsWith("knowledge-corpus-") || corpora[0].role !== "canonical" || !corpora[0].name?.trim() ||
79
+ (requested !== undefined && (!normalized || corpora[0].id !== `knowledge-corpus-${normalized}`))) {
80
+ throw new Error("Requested publication does not match the hosted canonical corpus; missing or ambiguous publication binding.");
81
+ }
82
+ const publication = { key: corpora[0].id.slice("knowledge-corpus-".length), name: corpora[0].name,
83
+ corpusId: corpora[0].id, tenantId: options.tenantId ?? null, endpoint: options.endpoint };
84
+ const entries: EditorialRecord[] = [];
85
+ const warnings: string[] = [];
86
+ let sectionId = options.sectionId || null;
87
+ let storyLineageId = options.storyLineageId || null;
88
+ const assignmentId = options.assignmentId || null;
89
+ let assignment: EditorialRecord | undefined;
90
+ if (assignmentId) {
91
+ assignment = await get(client, "Assignment", assignmentId);
92
+ const assignedStory = assignment.itemLineageId;
93
+ if (storyLineageId && assignedStory && storyLineageId !== assignedStory) throw new Error("Requested story conflicts with assignment story lineage.");
94
+ storyLineageId ||= assignedStory || null;
95
+ const assignedSection = assignment.sectionKey || assignment.sectionId;
96
+ if (sectionId && assignedSection && sectionId !== assignedSection) throw new Error("Requested section conflicts with assignment section.");
97
+ sectionId ||= assignedSection || null;
98
+ warnings.push("Assignment policy snapshots are ignored; current publication and section records govern. Surface any conflicting assignment instructions.");
99
+ }
100
+ if (storyLineageId) {
101
+ const versions = await pages(client, "Item", "listItemsByLineageAndVersion", { lineageId: storyLineageId });
102
+ if (!versions.length) throw new Error(`Story not found: ${storyLineageId}`);
103
+ const story = versions.reduce((a, b) => Number(b.versionNumber || 0) > Number(a.versionNumber || 0) ? b : a);
104
+ tenant(story, options.tenantId);
105
+ if (sectionId && story.section && sectionId !== story.section) throw new Error("Requested section conflicts with story section.");
106
+ sectionId ||= story.section || null;
107
+ }
108
+ for (const kind of ["mission", "policy"]) {
109
+ const slug = `editorial-doctrine-${kind}`;
110
+ const rows = await pages(client, "Item", options.tenantId ? "itemByTenantSlug" : "itemBySlug",
111
+ options.tenantId ? { tenantSlug: tenantScopedValue(options.tenantId, slug) } : { slug });
112
+ const current = rows.filter(row => row.type === "doctrine" && row.status === "private" && row.versionState === "current");
113
+ if (current.length !== 1) throw new Error(`Publication ${kind}: expected exactly one current record, found ${current.length}.`);
114
+ const record = current[0];
115
+ tenant(record, options.tenantId);
116
+ const text = record.bodyMarkus ? String(record.bodyMarkus).trim() : (record.body ?? []).map((part: any) => String(part).trim()).filter(Boolean).join("\n\n");
117
+ if (!text) throw new Error(`Required publication ${kind} is empty.`);
118
+ entries.push(entry("publication", kind, text, record));
119
+ }
120
+ if (sectionId) {
121
+ const section = await get(client, "NewsroomSection", sectionId);
122
+ tenant(section, options.tenantId);
123
+ for (const [kind, key] of [["mission", "editorialMission"], ["policy", "editorialPolicy"], ["guidance", "assignmentGuidance"], ["kill criteria", "killCriteria"]]) {
124
+ const text = String(section[key] || "").trim();
125
+ if (text) entries.push(entry("section", kind, text, section));
126
+ else if (kind === "mission" || kind === "policy") warnings.push(`Section ${section.id} intentionally has an empty ${kind} slot; publication doctrine still applies.`);
127
+ }
128
+ }
129
+ if (assignment) {
130
+ if (options.tenantId && !storyLineageId && !sectionId) throw new Error("A tenant assignment needs a scoped story or section to establish its publication.");
131
+ const attachments = await pages(client, "ModelAttachment", "listModelAttachmentsByOwnerRoleAndSortKey", { ownerId: assignment.id });
132
+ let foundBrief = false;
133
+ for (const [role, kind] of [["assignment_brief", "brief"], ["assignment_instructions", "instructions"]]) {
134
+ const matching = attachments.filter(row => row.role === role && row.status !== "deleted");
135
+ if (matching.length > 1) throw new Error(`Ambiguous assignment ${kind} attachments.`);
136
+ if (matching.length) {
137
+ const attachment = matching[0];
138
+ if (attachment.ownerKind !== "assignment" || attachment.ownerId !== assignment.id) throw new Error("Assignment attachment owner does not match the requested scope.");
139
+ const text = (await options.readAttachment(attachment)).trim();
140
+ if (!text) throw new Error(`Assignment ${kind} attachment is empty.`);
141
+ entries.push(entry("assignment", kind, text, attachment));
142
+ foundBrief ||= kind === "brief";
143
+ }
144
+ }
145
+ if (!foundBrief && assignment.summary) {
146
+ entries.push(entry("assignment", "brief", assignment.summary, assignment));
147
+ warnings.push("No assignment brief attachment exists; the Assignment summary is supplied as its brief.");
148
+ }
149
+ }
150
+ return buildEditorialContext(publication, { sectionId, storyLineageId, assignmentId }, entries, warnings, options.maxCharacters ?? 0);
151
+ }
152
+
153
+ export function assignmentDoctrine(pack: Awaited<ReturnType<typeof resolveEditorialContext>>) {
154
+ return pack.entries.filter(value => value.scope !== "assignment").map(value => ({
155
+ scope: value.scope === "section" ? "desk" : "publication", kind: value.kind,
156
+ label: `${value.scope === "section" ? "Section" : "Editorial"} ${value.kind}`,
157
+ slug: value.scope === "section" ? `desk-section-${pack.scope.sectionId}-${value.kind.replaceAll(" ", "-")}` : `editorial-doctrine-${value.kind}`,
158
+ body: [value.text], ...(value.scope === "section" ? { categoryKey: pack.scope.sectionId, categoryLineageId: pack.scope.sectionId } : {}),
159
+ }));
160
+ }
@@ -135,6 +135,15 @@ async function readTextFromStorage(storagePath: string): Promise<string> {
135
135
  }
136
136
  }
137
137
 
138
+ /** Strict read for required private editorial payloads; storage failures propagate. */
139
+ export async function readEditorialAttachment(attachment: { storagePath?: string | null }): Promise<string> {
140
+ if (!attachment.storagePath) throw new Error("Editorial attachment has no storage path.");
141
+ const result = await s3.send(new GetObjectCommand({ Bucket: payloadBucketName(), Key: attachment.storagePath }));
142
+ const text = await result.Body?.transformToString("utf8");
143
+ if (!text?.trim()) throw new Error("Editorial attachment is empty.");
144
+ return text;
145
+ }
146
+
138
147
  function isMissingStorageKeyError(error: unknown): boolean {
139
148
  if (!error || typeof error !== "object") return false;
140
149
  const record = error as Record<string, unknown>;
@@ -3,11 +3,13 @@
3
3
  * papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]
4
4
  * papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]
5
5
  * papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
6
- * Synthesizes the app-shell stack for a publication from its own site.json, or
7
- * prints one of the generated Amplify build specs. Synth only: deploying is a
8
- * separate, reviewed step.
6
+ * papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]
7
+ * Synthesizes the app-shell stack for a publication from its own site.json,
8
+ * prints one of the generated Amplify build specs, or deploys the stack from
9
+ * inside the Amplify build of the one branch that owns it.
9
10
  */
10
11
  import fs from "node:fs";
12
+ import { spawnSync } from "node:child_process";
11
13
  import path from "node:path";
12
14
  import { createRequire } from "node:module";
13
15
  import { buildSpecFor } from "../infra/build-specs.js";
@@ -17,6 +19,7 @@ const USAGE = [
17
19
  "Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
18
20
  " papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
19
21
  " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
22
+ " papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]",
20
23
  ].join("\n");
21
24
 
22
25
  const args = process.argv.slice(2);
@@ -25,7 +28,7 @@ const opt = (name, fallback) => {
25
28
  return i >= 0 ? args[i + 1] : fallback;
26
29
  };
27
30
  const command = args[0];
28
- if (command !== "synth" && command !== "print-buildspec") {
31
+ if (command !== "synth" && command !== "print-buildspec" && command !== "deploy") {
29
32
  console.error(USAGE);
30
33
  process.exit(command ? 1 : 0);
31
34
  }
@@ -77,6 +80,41 @@ if (command === "print-buildspec") {
77
80
  process.exit(0);
78
81
  }
79
82
 
83
+ if (command === "deploy") {
84
+ const buildAppId = process.env.AWS_APP_ID;
85
+ const buildBranch = process.env.AWS_BRANCH;
86
+ const owningBranch = opt("only-branch", "main");
87
+ const owningAppId = opt("only-app-id");
88
+ if (!buildAppId || !buildBranch) {
89
+ console.log("papyrus-infra deploy: not running inside an Amplify build, skipping");
90
+ process.exit(0);
91
+ }
92
+ if (buildBranch !== owningBranch || (owningAppId && buildAppId !== owningAppId)) {
93
+ console.log(`papyrus-infra deploy: ${buildAppId}/${buildBranch} does not own the stack, skipping`);
94
+ process.exit(0);
95
+ }
96
+ const outputDirectory = path.resolve(opt("out", process.env.CDK_OUTDIR ?? "cdk.out"));
97
+ const synthCommand = [
98
+ process.execPath,
99
+ path.resolve(process.argv[1]),
100
+ "synth",
101
+ "--site",
102
+ path.resolve(opt("site", "infra/site.json")),
103
+ "--out",
104
+ outputDirectory,
105
+ "--account",
106
+ opt("account", process.env.CDK_DEFAULT_ACCOUNT ?? "335163751677"),
107
+ "--region",
108
+ opt("region", process.env.CDK_DEFAULT_REGION ?? "us-east-1"),
109
+ ].map((part) => JSON.stringify(part)).join(" ");
110
+ const deployment = spawnSync(
111
+ "npx",
112
+ ["--yes", "aws-cdk@2", "deploy", "--app", synthCommand, "--require-approval", "never", `AmplifyAppShell-${config.siteId}`],
113
+ { stdio: "inherit" },
114
+ );
115
+ process.exit(deployment.status ?? 1);
116
+ }
117
+
80
118
  let AmplifyAppShellStack;
81
119
  try {
82
120
  ({ AmplifyAppShellStack } = await import("../infra/index.js"));
@@ -43,7 +43,9 @@ function frontendSection(preBuildCommands, buildCommands, artifactsDirectory, ca
43
43
  lines.push(" build:", " commands:", ...commandLines(buildCommands, " "), " artifacts:", ` baseDirectory: ${artifactsDirectory}`, " files:", " - '**/*'", " cache:", " paths:", ...cachePaths.map((cachePath) => ` - ${cachePath}`));
44
44
  return lines;
45
45
  }
46
+ const DEPLOY_STACK_IN_BUILD = "npx papyrus-infra deploy --site infra/site.json --only-branch main";
46
47
  export function cmsProductionBuildSpec(config) {
48
+ const deployStackCommands = config.cms?.deployStackInBuild ? [DEPLOY_STACK_IN_BUILD] : [];
47
49
  return [
48
50
  "version: 1",
49
51
  "backend:",
@@ -52,6 +54,7 @@ export function cmsProductionBuildSpec(config) {
52
54
  " commands:",
53
55
  ...commandLines([
54
56
  NPM_CLEAN_INSTALL,
57
+ ...deployStackCommands,
55
58
  "if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
56
59
  "npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
57
60
  ], " "),
@@ -67,7 +67,10 @@ function frontendSection(preBuildCommands: string[], buildCommands: string[], ar
67
67
  return lines;
68
68
  }
69
69
 
70
+ const DEPLOY_STACK_IN_BUILD = "npx papyrus-infra deploy --site infra/site.json --only-branch main";
71
+
70
72
  export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): string {
73
+ const deployStackCommands = config.cms?.deployStackInBuild ? [DEPLOY_STACK_IN_BUILD] : [];
71
74
  return [
72
75
  "version: 1",
73
76
  "backend:",
@@ -77,6 +80,7 @@ export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): strin
77
80
  ...commandLines(
78
81
  [
79
82
  NPM_CLEAN_INSTALL,
83
+ ...deployStackCommands,
80
84
  "if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
81
85
  "npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
82
86
  ],
@@ -199,7 +199,7 @@ function parseStagingPreview(value) {
199
199
  }
200
200
  function parseCms(value) {
201
201
  const cmsRecord = requireObject(value, "cms");
202
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
202
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
203
203
  const buildComputeType = cmsRecord.buildComputeType;
204
204
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
205
205
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -210,6 +210,9 @@ function parseCms(value) {
210
210
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
211
211
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
212
212
  }
213
+ if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
214
+ fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
215
+ }
213
216
  const cms = {
214
217
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
215
218
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
@@ -222,6 +225,7 @@ function parseCms(value) {
222
225
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
223
226
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
224
227
  redirects: parseRedirects(cmsRecord.redirects),
228
+ deployStackInBuild: cmsRecord.deployStackInBuild,
225
229
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
226
230
  prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
227
231
  };
@@ -42,6 +42,7 @@ export type CmsConfig = {
42
42
  stagingDomainPrefix?: string;
43
43
  redirects?: CmsRedirect[];
44
44
  prospectusAccountsApiStageArn?: string;
45
+ deployStackInBuild?: boolean;
45
46
  };
46
47
 
47
48
  export type AmplifyAppShellSiteConfig = {
@@ -281,7 +282,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
281
282
 
282
283
  function parseCms(value: unknown): CmsConfig {
283
284
  const cmsRecord = requireObject(value, "cms");
284
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
285
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
285
286
  const buildComputeType = cmsRecord.buildComputeType;
286
287
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
287
288
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -292,6 +293,9 @@ function parseCms(value: unknown): CmsConfig {
292
293
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
293
294
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
294
295
  }
296
+ if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
297
+ fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
298
+ }
295
299
  const cms: CmsConfig = {
296
300
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
297
301
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
@@ -309,6 +313,7 @@ function parseCms(value: unknown): CmsConfig {
309
313
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
310
314
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
311
315
  redirects: parseRedirects(cmsRecord.redirects),
316
+ deployStackInBuild: cmsRecord.deployStackInBuild as boolean | undefined,
312
317
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
313
318
  prospectusAccountsApiStageArn: optionalString(
314
319
  cmsRecord.prospectusAccountsApiStageArn,
package/package.json CHANGED
@@ -379,7 +379,7 @@
379
379
  "optional": true
380
380
  }
381
381
  },
382
- "version": "1.17.0",
382
+ "version": "1.18.0-next.1",
383
383
  "dependencies": {
384
384
  "@aws-amplify/adapter-nextjs": "^1.7.3",
385
385
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.17.0",
2
+ "version": "1.18.0-next.1",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",