@anthusai/papyrus 5.43.0-next.6 → 5.43.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.
@@ -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",
@@ -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
 
@@ -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(
@@ -113,7 +113,8 @@ export function NewsDeskClientProvider({
113
113
  return;
114
114
  }
115
115
  if (isAuthorizationError(error)) {
116
- setShell((current) => resolveForbidden(current, access.auth));
116
+ // The session already has editor rights, so the backend refusing a read is not a missing role. Say what was refused.
117
+ setShell((current) => resolveDashboardFailure(current, access.auth, refusedReadMessage(error)));
117
118
  return;
118
119
  }
119
120
  setShell((current) => resolveDashboardFailure(
@@ -215,9 +216,14 @@ function isAuthorizationError(error: unknown): boolean {
215
216
  return /not authorized|unauthorized|access denied|forbidden/i.test(message);
216
217
  }
217
218
 
219
+ function refusedReadMessage(error: unknown): string {
220
+ const detail = error instanceof Error ? error.message : String(error ?? "");
221
+ return `The Newsroom backend refused a read for this signed-in account (${detail}). The account has editor or admin rights, so this is a backend authorization rule, not a missing role.`;
222
+ }
223
+
218
224
  function resolvePrivateReadFailure(state: NewsDeskShellState, auth: NewsDeskShellState["auth"], error: unknown, fallback: string): NewsDeskShellState {
219
225
  if (isUnauthenticatedError(error)) return resolveSignedOut(state, { status: "signedOut", label: "Signed out" });
220
- if (isAuthorizationError(error)) return resolveForbidden(state, auth);
226
+ if (isAuthorizationError(error)) return resolveDashboardFailure(state, auth, refusedReadMessage(error));
221
227
  return resolveDashboardFailure(state, auth, error instanceof Error ? error.message : fallback);
222
228
  }
223
229
 
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { loadReferenceReviewDetails } from "../lib/reference-review-details";
4
+ import { hasEditorRights } from "../lib/staff-groups";
4
5
 
5
6
  import { isEditorialWorkAssignment } from "../lib/newsroom-assignments";
6
7
  import { isLocalPreviewEnabled } from "../lib/local-preview";
@@ -838,7 +839,7 @@ export async function loadEditorResolvedAccessState(): Promise<EditorAccessState
838
839
  if (!refreshed.hasSession || refreshed.auth.status === "signedOut") {
839
840
  return { status: "signedOut", isEditor: false, isAdmin: false, auth: refreshed.auth, error: null };
840
841
  }
841
- const isEditor = refreshed.groups.some((group) => group === "editor" || group === "admin");
842
+ const isEditor = hasEditorRights(refreshed.groups);
842
843
  if (!isEditor) {
843
844
  return {
844
845
  status: "forbidden",
@@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
6
6
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
7
7
  import { CheckIcon, UserIcon } from "lucide-react";
8
8
  import { Md5 } from "@smithy/md5-js";
9
+ import { hasEditorRights } from "../lib/staff-groups";
9
10
  import {
10
11
  createConsoleMessage,
11
12
  createConsoleThread,
@@ -154,7 +155,7 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
154
155
  const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
155
156
  const internalPathname = browserPathToInternalNewsroomPath(pathname);
156
157
  const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
157
- const canUseConsole = Boolean(session?.hasSession && session.groups.some((group) => group === "editor" || group === "admin"));
158
+ const canUseConsole = Boolean(session?.hasSession && hasEditorRights(session.groups));
158
159
  const shouldOfferConsole = isNewsroomPath && canUseConsole;
159
160
  const { open, openConsole, closeConsole, toggleOpen } = useConsoleVisibility({ available: shouldOfferConsole });
160
161
 
@@ -2,6 +2,7 @@ import { CognitoJwtVerifier } from "aws-jwt-verify";
2
2
  import type { JwksCache } from "aws-jwt-verify/jwk";
3
3
  import type { NextRequest } from "next/server";
4
4
  import { readAmplifyOutputsFile } from "./amplify-outputs-path";
5
+ import { STAFF_GROUPS } from "./staff-groups";
5
6
 
6
7
  export type NewsroomChatIdentity = { subject: string; issuer: string; clientId: string; expiresAt: number };
7
8
  export type NewsroomChatIdentityResolver = (request: NextRequest) => Promise<NewsroomChatIdentity | null>;
@@ -14,7 +15,7 @@ export function newsroomChatIdentityResolver(
14
15
  userPoolId: auth.user_pool_id,
15
16
  clientId: auth.user_pool_client_id,
16
17
  tokenUse: "access",
17
- groups: ["editor", "admin"],
18
+ groups: [...STAFF_GROUPS],
18
19
  }, jwksCache ? { jwksCache } : undefined);
19
20
  return async (request) => {
20
21
  const token = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1];
@@ -1,10 +1,11 @@
1
1
  import { createHmac } from "node:crypto";
2
2
  import type { ProspectusSignedInSession } from "./prospectus-session";
3
+ import { hasEditorRights, STAFF_GROUPS } from "./staff-groups";
3
4
 
4
5
  export const SITE_SUBJECT_PATTERN = /^ss1_[A-Za-z0-9_-]{43}$/;
5
6
  export const INTERIM_SUBJECT_PATTERN = /^ssdev_[A-Za-z0-9_-]{43}$/;
6
7
  export const READER_DEV_SECRET_ENV = "PAPYRUS_READER_DEV_SECRET";
7
- export const READER_STAFF_GROUPS = ["editor", "admin"] as const;
8
+ export const READER_STAFF_GROUPS = STAFF_GROUPS;
8
9
 
9
10
  const MINIMUM_DEV_SECRET_LENGTH = 16;
10
11
  const INTERIM_SUBJECT_PREFIX = "ssdev_";
@@ -46,7 +47,7 @@ export function readerIsStaff(session: Pick<ProspectusSignedInSession, "accessTo
46
47
  try {
47
48
  const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { "cognito:groups"?: unknown };
48
49
  const groups = claims["cognito:groups"];
49
- return Array.isArray(groups) && groups.some((group) => (READER_STAFF_GROUPS as readonly unknown[]).includes(group));
50
+ return Array.isArray(groups) && hasEditorRights(groups);
50
51
  } catch {
51
52
  return false;
52
53
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Who counts as newsroom staff. `admin` has every editor right: a site owner who is in `admin` but not `editor` can do
3
+ * everything an editor can. Every editor check in the browser and in the Lambdas reads this one list.
4
+ */
5
+ export const STAFF_GROUPS = ["editor", "admin"] as const;
6
+
7
+ /** True when any group is `editor` or `admin`. */
8
+ export function hasEditorRights(groups: Iterable<unknown> | null | undefined): boolean {
9
+ if (!groups) return false;
10
+ for (const group of groups) {
11
+ if (typeof group === "string" && (STAFF_GROUPS as readonly string[]).includes(group.trim().toLowerCase())) return true;
12
+ }
13
+ return false;
14
+ }
15
+
16
+ /**
17
+ * Cognito groups from a `cognito:groups` claim, which arrives as an array, a JSON array, a bracketed list
18
+ * (`[admin Google_123]`), or a comma or space separated string depending on who forwarded it.
19
+ */
20
+ export function claimGroups(value: unknown): string[] {
21
+ const raw = Array.isArray(value) ? value.map((entry) => String(entry)) : typeof value === "string" ? value.split(/[\s,]+/) : [];
22
+ return raw.map((entry) => entry.trim().replace(/^["'[\](){}]+|["'[\](){}]+$/g, "")).filter(Boolean);
23
+ }
@@ -1,10 +1,10 @@
1
1
  import type { NextRequest, NextResponse } from "next/server";
2
2
  import { fetchAuthSession } from "aws-amplify/auth/server";
3
3
  import { getAmplifyServerRuntime } from "./amplify-server-runtime";
4
+ import { hasEditorRights } from "./staff-groups";
4
5
 
5
6
  export type StagingAccess = "anonymous" | "forbidden" | "ok";
6
7
 
7
- const STAGING_ALLOWED_GROUPS = ["editor", "admin"];
8
8
 
9
9
  export function groupsFromIdTokenPayload(payload: Record<string, unknown> | undefined): string[] {
10
10
  const raw = payload?.["cognito:groups"];
@@ -15,7 +15,7 @@ export function groupsFromIdTokenPayload(payload: Record<string, unknown> | unde
15
15
 
16
16
  export function accessForGroups(groups: string[] | null): StagingAccess {
17
17
  if (groups === null) return "anonymous";
18
- return groups.some((group) => STAGING_ALLOWED_GROUPS.includes(group)) ? "ok" : "forbidden";
18
+ return hasEditorRights(groups) ? "ok" : "forbidden";
19
19
  }
20
20
 
21
21
  export async function getStagingAccess(request: NextRequest, response: NextResponse): Promise<StagingAccess> {
package/package.json CHANGED
@@ -341,6 +341,7 @@
341
341
  "./lib/site-renderer": "./lib/site-renderer.ts",
342
342
  "./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
343
343
  "./lib/site-stack": "./lib/site-stack.ts",
344
+ "./lib/staff-groups": "./lib/staff-groups.ts",
344
345
  "./lib/staging-gate": "./lib/staging-gate.ts",
345
346
  "./lib/staging-gated-path": "./lib/staging-gated-path.ts",
346
347
  "./lib/staging-preview-object": "./lib/staging-preview-object.ts",
@@ -467,7 +468,7 @@
467
468
  "optional": true
468
469
  }
469
470
  },
470
- "version": "5.43.0-next.6",
471
+ "version": "5.43.0-next.7",
471
472
  "dependencies": {
472
473
  "@aws-amplify/adapter-nextjs": "^1.7.3",
473
474
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.43.0-next.6",
2
+ "version": "5.43.0-next.7",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",