@anthusai/papyrus 5.43.0-next.2 → 5.43.0-next.21

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 (85) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/category-action/handler.ts +2 -1
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  8. package/amplify/functions/monitoring-view/schema.js +16 -0
  9. package/amplify/functions/monitoring-view/schema.ts +16 -0
  10. package/amplify/functions/procedure-action/handler.ts +2 -10
  11. package/amplify/functions/proposal-actions/proposals.py +20 -3
  12. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  13. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  14. package/bin/papyrus-app.mjs +12 -1
  15. package/bin/papyrus-infra.mjs +21 -1
  16. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  17. package/components/monitoring-overview-view.tsx +143 -21
  18. package/components/monitoring-panel.tsx +64 -4
  19. package/components/needs-me-bodies.tsx +290 -0
  20. package/components/needs-me-detail.tsx +23 -0
  21. package/components/needs-me-feed.tsx +561 -0
  22. package/components/needs-me-reference-detail.tsx +77 -0
  23. package/components/needs-me-shared.tsx +39 -0
  24. package/components/news-desk-client-provider.tsx +24 -3
  25. package/components/news-desk-page.tsx +2 -1
  26. package/components/news-desk-taxonomy-client.ts +2 -1
  27. package/components/newsroom-access-gate.tsx +7 -5
  28. package/components/newsroom-client-shell.tsx +2 -1
  29. package/components/newsroom-ops-overview.tsx +64 -55
  30. package/components/newsroom-ops-shell.tsx +4 -2
  31. package/components/newsroom-organizer.css +60 -2
  32. package/components/newsroom-references-view.tsx +301 -138
  33. package/components/notification-cards.tsx +369 -0
  34. package/components/notification-center.tsx +324 -213
  35. package/components/papyrus-console-shell.tsx +2 -1
  36. package/components/proposals-panel.tsx +21 -24
  37. package/components/proposals-view.tsx +4 -2
  38. package/components/reference-capture-client.ts +110 -0
  39. package/components/reference-capture-panel.tsx +180 -0
  40. package/components/reference-feed.tsx +503 -0
  41. package/components/reference-review-card.tsx +110 -141
  42. package/components/references-page-loading.tsx +61 -0
  43. package/components/references-prefetch.ts +52 -0
  44. package/components/review-rating-grid.tsx +180 -0
  45. package/components/topic-steering-workspace.tsx +44 -9
  46. package/components/use-media-query.ts +16 -0
  47. package/components/use-reference-preview-attachments.ts +101 -0
  48. package/components/use-reference-ratings.ts +134 -0
  49. package/infra/amplify-app-shell.js +18 -2
  50. package/infra/amplify-app-shell.ts +18 -2
  51. package/infra/chat-stack.js +222 -0
  52. package/infra/chat-stack.ts +250 -0
  53. package/infra/chat.test.js +142 -0
  54. package/infra/chat.test.ts +151 -0
  55. package/infra/index.js +1 -0
  56. package/infra/site-config.js +58 -1
  57. package/infra/site-config.ts +78 -1
  58. package/lib/category-repository.ts +6 -1
  59. package/lib/content-actions-client.ts +18 -0
  60. package/lib/local-preview-feed-fixture.ts +63 -0
  61. package/lib/local-preview-synthetic-rejects.ts +41 -0
  62. package/lib/monitoring-client.ts +29 -2
  63. package/lib/monitoring-overview.ts +52 -1
  64. package/lib/monitoring-paging.ts +58 -0
  65. package/lib/needs-me-items.ts +289 -0
  66. package/lib/needs-me-ranking.ts +315 -0
  67. package/lib/newsroom-chat-identity.ts +2 -1
  68. package/lib/newsroom-nav.ts +1 -1
  69. package/lib/newsroom-organizer-nav.ts +2 -1
  70. package/lib/notification-feed.ts +212 -0
  71. package/lib/proposals-client.ts +29 -0
  72. package/lib/reader-subject.ts +3 -2
  73. package/lib/reference-capture.ts +293 -0
  74. package/lib/reference-feed-cache.ts +170 -0
  75. package/lib/reference-feed-ranking.ts +235 -0
  76. package/lib/reference-preview.ts +112 -0
  77. package/lib/review-client.ts +55 -5
  78. package/lib/review-drafts.ts +252 -0
  79. package/lib/review-fixture.ts +56 -2
  80. package/lib/review-requests.ts +64 -24
  81. package/lib/site-brand.ts +2 -0
  82. package/lib/staff-groups.ts +23 -0
  83. package/lib/staging-gate.ts +2 -2
  84. package/package.json +29 -1
  85. package/routes.manifest.json +1 -1
@@ -603,6 +603,12 @@ const schema = a.schema({
603
603
  .returns(a.json())
604
604
  .authorization((allow) => [allow.groups(contentWriteGroups)])
605
605
  .handler(functionHandler("contentActions", contentActions)),
606
+ correctReferenceCapture: a
607
+ .mutation()
608
+ .arguments({ input: a.json().required() })
609
+ .returns(a.json())
610
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
611
+ .handler(functionHandler("contentActions", contentActions)),
606
612
  saveItemDraft: a
607
613
  .mutation()
608
614
  .arguments({ input: a.json().required() })
@@ -657,6 +657,13 @@ const schema = a.schema({
657
657
  .authorization((allow) => [allow.groups(contentWriteGroups)])
658
658
  .handler(functionHandler("contentActions", contentActions)),
659
659
 
660
+ correctReferenceCapture: a
661
+ .mutation()
662
+ .arguments({ input: a.json().required() })
663
+ .returns(a.json())
664
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
665
+ .handler(functionHandler("contentActions", contentActions)),
666
+
660
667
  saveItemDraft: a
661
668
  .mutation()
662
669
  .arguments({ input: a.json().required() })
@@ -530,6 +530,12 @@ function buildBaseDefinitions(includeEditions) {
530
530
  .returns(a.json())
531
531
  .authorization((allow) => [allow.groups(contentWriteGroups)])
532
532
  .handler(functionHandler("contentActions", contentActions)),
533
+ correctReferenceCapture: a
534
+ .mutation()
535
+ .arguments({ input: a.json().required() })
536
+ .returns(a.json())
537
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
538
+ .handler(functionHandler("contentActions", contentActions)),
533
539
  saveItemDraft: a
534
540
  .mutation()
535
541
  .arguments({ input: a.json().required() })
@@ -582,6 +582,13 @@ const definitions = {
582
582
  .authorization((allow) => [allow.groups(contentWriteGroups)])
583
583
  .handler(functionHandler("contentActions", contentActions)),
584
584
 
585
+ correctReferenceCapture: a
586
+ .mutation()
587
+ .arguments({ input: a.json().required() })
588
+ .returns(a.json())
589
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
590
+ .handler(functionHandler("contentActions", contentActions)),
591
+
585
592
  saveItemDraft: a
586
593
  .mutation()
587
594
  .arguments({ input: a.json().required() })
@@ -24,6 +24,7 @@ import {
24
24
  requestDelegation,
25
25
  type DelegationStore,
26
26
  } from "../shared/curation-delegation-flow";
27
+ import { claimGroups } from "../../../lib/staff-groups";
27
28
 
28
29
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
29
30
  type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
@@ -360,7 +361,7 @@ function requireSignedInPerson(event: { identity?: unknown }): { sub: string; la
360
361
  }
361
362
  const identity = event.identity as { sub?: unknown; claims?: Record<string, unknown> };
362
363
  const claims = identity.claims ?? {};
363
- const groups = Array.isArray(claims["cognito:groups"]) ? (claims["cognito:groups"] as unknown[]) : [];
364
+ const groups = claimGroups(claims["cognito:groups"]);
364
365
  return {
365
366
  sub: normalizeRequiredString(claims.sub ?? identity.sub, "identity.sub"),
366
367
  label: normalizeOptionalString(claims.email) ?? normalizeOptionalString(claims.name) ?? "Newsroom editor",
@@ -365,6 +365,14 @@ def handler(event, context):
365
365
  client, arguments, event.get("identity") or {}, receipt_store=receipt_store)
366
366
  except IntakeError as error:
367
367
  return {"ok": False, "errors": [{"code": "intake-rejected", "message": str(error)}]}
368
+ if field == "correctReferenceCapture":
369
+ from papyrus_content.reference_curation_store import CurationError, correct_reference_capture
370
+
371
+ client, _claims = create_authoring_client()
372
+ try:
373
+ return correct_reference_capture(client, arguments, event.get("identity") or {})
374
+ except CurationError as error:
375
+ return {"ok": False, "errors": [{"code": "correction-rejected", "message": str(error)}]}
368
376
  if field == "deriveMarkus":
369
377
  return derive_markus_action(arguments)
370
378
  if field == "saveItemDraft":
@@ -41,6 +41,7 @@ const ALLOWED_ROLES = new Set([
41
41
  "item_excerpt",
42
42
  "message_body",
43
43
  "metadata",
44
+ "preview",
44
45
  "ontology_concept_profile",
45
46
  "ontology_relation_explanation",
46
47
  "published_item_body",
@@ -14,6 +14,14 @@ export const monitoringViewSchemaDefinitions = {
14
14
  .returns(a.json())
15
15
  .authorization((allow) => [allow.groups(editors)])
16
16
  .handler(functionHandler("monitoringView", monitoringView)),
17
+ // One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
18
+ // the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
19
+ monitoringPage: a
20
+ .query()
21
+ .arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
22
+ .returns(a.json())
23
+ .authorization((allow) => [allow.groups(editors)])
24
+ .handler(functionHandler("monitoringView", monitoringView)),
17
25
  monitoringEmailSubmission: a
18
26
  .query()
19
27
  .arguments({ receiptId: a.string().required() })
@@ -26,6 +34,14 @@ export const monitoringViewSchemaDefinitions = {
26
34
  .returns(a.json())
27
35
  .authorization((allow) => [allow.groups(editors)])
28
36
  .handler(functionHandler("monitoringView", monitoringView)),
37
+ // Accept ("release") or Reject ("discard") one held email submission. The function validates the
38
+ // signed-in editor and that the message is routed to this publication; the receiver applies it.
39
+ monitoringDecideEmailSubmission: a
40
+ .mutation()
41
+ .arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
42
+ .returns(a.json())
43
+ .authorization((allow) => [allow.groups(editors)])
44
+ .handler(functionHandler("monitoringView", monitoringView)),
29
45
  monitoringResolveEmailArticleLink: a
30
46
  .mutation()
31
47
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -16,6 +16,14 @@ export const monitoringViewSchemaDefinitions = {
16
16
  .returns(a.json())
17
17
  .authorization((allow) => [allow.groups(editors)])
18
18
  .handler(functionHandler("monitoringView", monitoringView)),
19
+ // One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
20
+ // the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
21
+ monitoringPage: a
22
+ .query()
23
+ .arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
24
+ .returns(a.json())
25
+ .authorization((allow) => [allow.groups(editors)])
26
+ .handler(functionHandler("monitoringView", monitoringView)),
19
27
  monitoringEmailSubmission: a
20
28
  .query()
21
29
  .arguments({ receiptId: a.string().required() })
@@ -28,6 +36,14 @@ export const monitoringViewSchemaDefinitions = {
28
36
  .returns(a.json())
29
37
  .authorization((allow) => [allow.groups(editors)])
30
38
  .handler(functionHandler("monitoringView", monitoringView)),
39
+ // Accept ("release") or Reject ("discard") one held email submission. The function validates the
40
+ // signed-in editor and that the message is routed to this publication; the receiver applies it.
41
+ monitoringDecideEmailSubmission: a
42
+ .mutation()
43
+ .arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
44
+ .returns(a.json())
45
+ .authorization((allow) => [allow.groups(editors)])
46
+ .handler(functionHandler("monitoringView", monitoringView)),
31
47
  monitoringResolveEmailArticleLink: a
32
48
  .mutation()
33
49
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -3,6 +3,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
3
3
  import { Amplify } from "aws-amplify";
4
4
  import { generateClient } from "aws-amplify/data";
5
5
  import type { Schema } from "../../data/resource";
6
+ import { claimGroups } from "../../../lib/staff-groups";
6
7
 
7
8
  type DataClient = ReturnType<typeof generateClient<Schema>>;
8
9
  type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
@@ -474,20 +475,11 @@ function validateJsonSchemaInput(schema: Record<string, unknown>, input: Record<
474
475
 
475
476
  function requireAdmin(event: ProcedureEvent): void {
476
477
  const claims = objectValue(event.identity?.claims);
477
- const groups = [
478
- ...normalizeClaimGroups(claims["cognito:groups"]),
479
- ...normalizeClaimGroups(claims.groups),
480
- ];
478
+ const groups = [...claimGroups(claims["cognito:groups"]), ...claimGroups(claims.groups)];
481
479
  if (groups.includes("admin")) return;
482
480
  throw new Error("Admin role required.");
483
481
  }
484
482
 
485
- function normalizeClaimGroups(value: unknown): string[] {
486
- if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
487
- if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
488
- return [];
489
- }
490
-
491
483
  async function getDataClient(): Promise<DataClient> {
492
484
  if (!clientPromise) {
493
485
  clientPromise = (async () => {
@@ -92,6 +92,25 @@ def is_appsync_event(event: dict[str, Any]) -> bool:
92
92
  return "arguments" in event and ("info" in event or event_field_name(event) is not None)
93
93
  PROPOSER_AI = "ai"
94
94
  STAFF_GROUPS = frozenset({"editor", "admin"})
95
+
96
+ _GROUP_SEPARATORS = re.compile(r"[\s,]+")
97
+
98
+
99
+ def claim_groups(value: Any) -> list[str]:
100
+ """Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
101
+ if isinstance(value, (list, tuple, set, frozenset)):
102
+ raw = [item for item in value if isinstance(item, str)]
103
+ elif isinstance(value, str):
104
+ raw = _GROUP_SEPARATORS.split(value)
105
+ else:
106
+ return []
107
+ cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
108
+ return [item for item in cleaned if item]
109
+
110
+
111
+ def has_editor_rights(groups: Any) -> bool:
112
+ """``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
113
+ return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
95
114
  #: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
96
115
  OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
97
116
  BY_STAFF, BY_OPERATOR = "staff", "operator"
@@ -140,10 +159,8 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
140
159
  """The Cognito subject of an editor or admin, or ``None`` for anyone else."""
141
160
  claims = identity.get("claims") or {}
142
161
  groups = claims.get("cognito:groups") or identity.get("groups") or []
143
- if isinstance(groups, str):
144
- groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
145
162
  subject = identity.get("sub")
146
- if not isinstance(subject, str) or not subject or not STAFF_GROUPS.intersection(groups):
163
+ if not isinstance(subject, str) or not subject or not has_editor_rights(groups):
147
164
  return None
148
165
  return subject
149
166
 
@@ -1468,10 +1468,8 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
1468
1468
  identity = event.get("identity") or {}
1469
1469
  claims = identity.get("claims") or {}
1470
1470
  groups = claims.get("cognito:groups") or identity.get("groups") or []
1471
- if isinstance(groups, str):
1472
- groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
1473
1471
  subject = identity.get("sub")
1474
- if not isinstance(subject, str) or not subject or not moderation_rules.STAFF_GROUPS.intersection(groups):
1472
+ if not isinstance(subject, str) or not subject or not moderation_rules.has_editor_rights(groups):
1475
1473
  return moderation_rules.refused("not_staff", "Only editors and admins can moderate.").to_dict()
1476
1474
  arguments = event.get("arguments") or {}
1477
1475
  outcome = moderation_rules.apply_action(
@@ -37,6 +37,25 @@ READER_ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f
37
37
  ACTION_ID = re.compile(r"mod-[0-9a-f]{32}")
38
38
  MAXIMUM_REASON = 500
39
39
  STAFF_GROUPS = frozenset({"editor", "admin"})
40
+
41
+ _GROUP_SEPARATORS = re.compile(r"[\s,]+")
42
+
43
+
44
+ def claim_groups(value: Any) -> list[str]:
45
+ """Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
46
+ if isinstance(value, (list, tuple, set, frozenset)):
47
+ raw = [item for item in value if isinstance(item, str)]
48
+ elif isinstance(value, str):
49
+ raw = _GROUP_SEPARATORS.split(value)
50
+ else:
51
+ return []
52
+ cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
53
+ return [item for item in cleaned if item]
54
+
55
+
56
+ def has_editor_rights(groups: Any) -> bool:
57
+ """``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
58
+ return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
40
59
  ITEM_ID_PREFIX = "item-"
41
60
 
42
61
 
@@ -1,9 +1,20 @@
1
1
  #!/usr/bin/env node
2
+ import path from "node:path";
2
3
  import { sync } from "../src/sync.mjs";
3
4
 
4
5
  const [command, ...rest] = process.argv.slice(2);
5
6
  if (command === "sync") {
6
7
  sync();
8
+ } else if (command === "brand" && rest[0] === "diff") {
9
+ const { brandDiff } = await import("../src/brand.mjs");
10
+ const flag = rest.indexOf("--papyrus");
11
+ const checkout = flag === -1 ? undefined : rest[flag + 1];
12
+ try {
13
+ process.exit(brandDiff({ id: rest[1], checkout: checkout && path.resolve(checkout) }).drifted ? 1 : 0);
14
+ } catch (error) {
15
+ console.error(error.message);
16
+ process.exit(2);
17
+ }
7
18
  } else if (command === "videoml-bundle") {
8
19
  const preview = rest.includes("--preview");
9
20
  const { buildBrowserBundle } = await import("../scripts/videoml/build-browser-bundle.mjs");
@@ -15,6 +26,6 @@ if (command === "sync") {
15
26
  process.exit(1);
16
27
  }
17
28
  } else {
18
- console.error("Usage: papyrus-app sync | papyrus-app videoml-bundle [--preview]");
29
+ console.error("Usage: papyrus-app sync | papyrus-app brand diff <brand-id> [--papyrus <checkout>] | papyrus-app videoml-bundle [--preview]");
19
30
  process.exit(command ? 1 : 0);
20
31
  }
@@ -3,6 +3,7 @@
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
+ * papyrus-infra synth --site infra/site.json --part chat [--chat-lambdas DIR] [--out cdk.out] (the private chat data stack)
6
7
  * papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]
7
8
  * Synthesizes the app-shell stack for a publication from its own site.json,
8
9
  * prints one of the generated Amplify build specs, or deploys the stack from
@@ -16,6 +17,7 @@ import { resolveCdkEnvironment } from "../src/cdk-environment.mjs";
16
17
 
17
18
  const USAGE = [
18
19
  "Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
20
+ " papyrus-infra synth --site infra/site.json --part chat [--chat-lambdas DIR] [--out cdk.out] [--account A] [--region R]",
19
21
  " papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
20
22
  " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
21
23
  " papyrus-infra deploy-edge --stack NAME --template FILE [--adoption-template FILE --adopt-resources FILE] [--parameter KEY=VALUE]... [--delete-function NAME] [--only-branch main] [--only-app-id ID] [--region R]",
@@ -186,8 +188,9 @@ if (command === "deploy") {
186
188
  }
187
189
 
188
190
  let AmplifyAppShellStack;
191
+ let PapyrusChatStack;
189
192
  try {
190
- ({ AmplifyAppShellStack } = await import("../infra/index.js"));
193
+ ({ AmplifyAppShellStack, PapyrusChatStack } = await import("../infra/index.js"));
191
194
  } catch (error) {
192
195
  console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
193
196
  process.exit(1);
@@ -195,6 +198,23 @@ try {
195
198
 
196
199
  const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
197
200
  const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
201
+ if (opt("part", "shell") === "chat") {
202
+ // The site's private newsroom chat data stack (docs/private-newsroom-chat-per-site.md); deployed before the app shell enables chat.
203
+ if (!config.chat?.enabled) {
204
+ console.error(`papyrus-infra: site ${config.siteId} has no enabled chat block`);
205
+ process.exit(1);
206
+ }
207
+ const lambdaAssetDirectory = opt("chat-lambdas");
208
+ const chatStack = new PapyrusChatStack(app, {
209
+ config,
210
+ env: resolveEnvironmentOrExit(),
211
+ lambdaAssetDirectory: lambdaAssetDirectory ? path.resolve(lambdaAssetDirectory) : undefined,
212
+ description: `Private Papyrus newsroom chat data plane for ${config.siteId}.`,
213
+ });
214
+ app.synth();
215
+ console.log(`synthesized ${chatStack.stackName} -> ${path.resolve(opt("out", "cdk.out"))}`);
216
+ process.exit(0);
217
+ }
198
218
  new AmplifyAppShellStack(app, `AmplifyAppShell-${config.siteId}`, config, {
199
219
  env: resolveEnvironmentOrExit(),
200
220
  stackName: resolveStackName(config),
@@ -2,9 +2,9 @@
2
2
 
3
3
  import { fetchAuthSession, signIn, signOut } from "aws-amplify/auth";
4
4
  import { configureAmplifyClient } from "./amplify-client-provider";
5
+ import { hasEditorRights } from "../lib/staff-groups";
5
6
  import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
6
7
 
7
- const EDITOR_GROUPS = new Set(["editor", "admin"]);
8
8
 
9
9
  function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>): string[] {
10
10
  const accessPayload = session.tokens?.accessToken?.payload ?? {};
@@ -29,7 +29,7 @@ function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>
29
29
 
30
30
  function sessionHasEditorAccess(session: Awaited<ReturnType<typeof fetchAuthSession>>): boolean {
31
31
  if (!session.tokens?.accessToken) return false;
32
- return readSessionGroups(session).some((group) => EDITOR_GROUPS.has(group));
32
+ return hasEditorRights(readSessionGroups(session));
33
33
  }
34
34
 
35
35
  export async function ensureDevSandboxEditorSession(