@anthusai/papyrus 5.41.5 → 5.42.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.
- package/amplify/functions/monitoring-view/schema.js +0 -6
- package/amplify/functions/monitoring-view/schema.ts +0 -6
- package/amplify/functions/monitoring-view/wire.js +0 -1
- package/amplify/functions/monitoring-view/wire.ts +0 -1
- package/amplify/plugins/reader-steering/function/handler.py +22 -0
- package/amplify/plugins/reader-steering/function/proposals.py +29 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +1 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +1 -0
- package/amplify/plugins/reader-steering/schema.js +23 -0
- package/amplify/plugins/reader-steering/schema.ts +23 -0
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/components/monitoring-overview-view.tsx +14 -8
- package/components/monitoring-panel.tsx +3 -33
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/monitoring-client.ts +2 -11
- package/lib/monitoring-overview.ts +3 -0
- package/package.json +3 -1
- package/routes.manifest.json +12 -1
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.d.mts +1 -0
- package/src/with-papyrus.mjs +16 -0
|
@@ -20,12 +20,6 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
20
20
|
.returns(a.json())
|
|
21
21
|
.authorization((allow) => [allow.groups(editors)])
|
|
22
22
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
23
|
-
monitoringQueueEmailReference: a
|
|
24
|
-
.mutation()
|
|
25
|
-
.arguments({ input: a.json().required() })
|
|
26
|
-
.returns(a.json())
|
|
27
|
-
.authorization((allow) => [allow.groups(editors)])
|
|
28
|
-
.handler(functionHandler("monitoringView", monitoringView)),
|
|
29
23
|
monitoringResolveEmailArticleLink: a
|
|
30
24
|
.mutation()
|
|
31
25
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -22,12 +22,6 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
22
22
|
.returns(a.json())
|
|
23
23
|
.authorization((allow) => [allow.groups(editors)])
|
|
24
24
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
25
|
-
monitoringQueueEmailReference: a
|
|
26
|
-
.mutation()
|
|
27
|
-
.arguments({ input: a.json().required() })
|
|
28
|
-
.returns(a.json())
|
|
29
|
-
.authorization((allow) => [allow.groups(editors)])
|
|
30
|
-
.handler(functionHandler("monitoringView", monitoringView)),
|
|
31
25
|
monitoringResolveEmailArticleLink: a
|
|
32
26
|
.mutation()
|
|
33
27
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -22,7 +22,6 @@ export function monitoringViewParameterArn(region, account) {
|
|
|
22
22
|
export function wireMonitoringView(backend, emailMonitorId = "hn") {
|
|
23
23
|
const lambda = backend.monitoringView.resources.lambda;
|
|
24
24
|
const stack = Stack.of(lambda);
|
|
25
|
-
lambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${stack.region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
|
|
26
25
|
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
|
|
27
26
|
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
28
27
|
lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
|
|
@@ -28,7 +28,6 @@ export function monitoringViewParameterArn(region: string, account: string): str
|
|
|
28
28
|
export function wireMonitoringView(backend: any, emailMonitorId = "hn"): void {
|
|
29
29
|
const lambda = backend.monitoringView.resources.lambda as LambdaFunction;
|
|
30
30
|
const stack = Stack.of(lambda);
|
|
31
|
-
lambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${stack.region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
|
|
32
31
|
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
|
|
33
32
|
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
34
33
|
lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
|
|
@@ -1015,6 +1015,26 @@ class DynamoReaderStore:
|
|
|
1015
1015
|
raise
|
|
1016
1016
|
return True
|
|
1017
1017
|
|
|
1018
|
+
def list_story_drafts(self, limit: int) -> list[dict[str, Any]]:
|
|
1019
|
+
"""The story drafts of the table, only the fields ``listStoryDrafts`` may answer; at most ``limit`` (the newest if there are more)."""
|
|
1020
|
+
fields = proposal_rules.STORY_DRAFT_FIELDS
|
|
1021
|
+
names = {"#kind": "kind", **{f"#f{index}": field for index, field in enumerate(fields)}}
|
|
1022
|
+
request: dict[str, Any] = {
|
|
1023
|
+
"FilterExpression": "#kind = :kind",
|
|
1024
|
+
"ProjectionExpression": ", ".join(f"#f{index}" for index in range(len(fields))) + ", #kind",
|
|
1025
|
+
"ExpressionAttributeNames": names,
|
|
1026
|
+
"ExpressionAttributeValues": {":kind": "story_draft"},
|
|
1027
|
+
}
|
|
1028
|
+
rows: list[dict[str, Any]] = []
|
|
1029
|
+
while True:
|
|
1030
|
+
page = self._table("proposal").scan(**request)
|
|
1031
|
+
rows.extend(plain(item) for item in page.get("Items") or [])
|
|
1032
|
+
if not page.get("LastEvaluatedKey"):
|
|
1033
|
+
break
|
|
1034
|
+
request["ExclusiveStartKey"] = page["LastEvaluatedKey"]
|
|
1035
|
+
rows.sort(key=lambda row: (row.get("createdAt") or "", row.get("id") or ""), reverse=True)
|
|
1036
|
+
return [{field: row.get(field) for field in fields} for row in rows[:limit]]
|
|
1037
|
+
|
|
1018
1038
|
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool:
|
|
1019
1039
|
"""Apply ``changes`` if the state (and, when given, ``updatedAt``) is still what the caller saw; a value of ``None`` removes the attribute."""
|
|
1020
1040
|
names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
|
|
@@ -1510,6 +1530,8 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
|
|
|
1510
1530
|
"""``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
|
|
1511
1531
|
if proposal_rules.event_field_name(event) in proposal_rules.MUTATIONS:
|
|
1512
1532
|
return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds), account_id=account_id)
|
|
1533
|
+
if proposal_rules.event_field_name(event) in proposal_rules.QUERIES:
|
|
1534
|
+
return proposal_rules.handle_proposal_query(event, store, account_id=account_id)
|
|
1513
1535
|
identity = event.get("identity") or {}
|
|
1514
1536
|
claims = identity.get("claims") or {}
|
|
1515
1537
|
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
@@ -57,6 +57,11 @@ TARGET_COUNTS: dict[str, tuple[int, int]] = {
|
|
|
57
57
|
TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
|
|
58
58
|
|
|
59
59
|
MUTATIONS = ("fileProposal", "transitionProposal", "editProposal")
|
|
60
|
+
#: The one read an operator has: ``listStoryDrafts`` answers the story drafts and nothing else of them (never the body, the evidence, the
|
|
61
|
+
#: history, the proposer, a vote, the editor log or a reason), so an unattended run can avoid filing a near-duplicate of an earlier draft.
|
|
62
|
+
QUERIES = ("listStoryDrafts",)
|
|
63
|
+
STORY_DRAFT_FIELDS = ("id", "title", "summary", "targets", "state", "createdAt")
|
|
64
|
+
MAXIMUM_STORY_DRAFTS = 500
|
|
60
65
|
|
|
61
66
|
#: What ``editProposal`` does beyond moving the state. Editors and admins only: an operator is refused every one of them.
|
|
62
67
|
PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION = "pin", "unpin", "hold", "release", "exclude_votes", "undo_exclusion"
|
|
@@ -128,6 +133,8 @@ class ProposalStore(Protocol):
|
|
|
128
133
|
|
|
129
134
|
def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool: ...
|
|
130
135
|
|
|
136
|
+
def list_story_drafts(self, limit: int) -> list[dict[str, Any]]: ...
|
|
137
|
+
|
|
131
138
|
|
|
132
139
|
def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
133
140
|
"""The Cognito subject of an editor or admin, or ``None`` for anyone else."""
|
|
@@ -456,3 +463,25 @@ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_is
|
|
|
456
463
|
by=BY_OPERATOR if operator else BY_STAFF,
|
|
457
464
|
allowed=OPERATOR_TRANSITIONS if operator else None,
|
|
458
465
|
).to_dict()
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def story_draft_summary(row: dict[str, Any]) -> dict[str, Any]:
|
|
469
|
+
"""The six fields an operator may read of a story draft. ``targets`` stays the JSON text it is stored as."""
|
|
470
|
+
summary = {field: row.get(field) for field in STORY_DRAFT_FIELDS}
|
|
471
|
+
if summary["targets"] is not None and not isinstance(summary["targets"], str):
|
|
472
|
+
summary["targets"] = json.dumps(summary["targets"])
|
|
473
|
+
return summary
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def handle_proposal_query(event: dict[str, Any], store: ProposalStore, *, account_id: str | None = None) -> dict[str, Any]:
|
|
477
|
+
"""``listStoryDrafts`` from AppSync: the story drafts, newest first, with only ``STORY_DRAFT_FIELDS``. Read only.
|
|
478
|
+
|
|
479
|
+
The same caller check as :func:`handle_proposal_event`: an editor or admin, or an IAM principal of ``account_id`` (the function's own
|
|
480
|
+
account) acting as an operator. An identity-pool user and a principal of any other account are refused. Answers a receipt-shaped
|
|
481
|
+
object, never an exception.
|
|
482
|
+
"""
|
|
483
|
+
identity = event.get("identity") or {}
|
|
484
|
+
if staff_subject(identity) is None and operator_actor(identity, account_id) is None:
|
|
485
|
+
return {**refused("not_staff", "Only editors, admins and operators of this account can list story drafts.").to_dict(), "drafts": []}
|
|
486
|
+
rows = sorted(store.list_story_drafts(MAXIMUM_STORY_DRAFTS), key=lambda row: (row.get("createdAt") or "", row.get("id") or ""), reverse=True)
|
|
487
|
+
return {"status": "ok", "id": None, "error": None, "message": None, "drafts": [story_draft_summary(row) for row in rows[:MAXIMUM_STORY_DRAFTS]]}
|
|
@@ -16,6 +16,7 @@ export const readerSteeringManifest = {
|
|
|
16
16
|
contract: 1,
|
|
17
17
|
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
18
18
|
mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
|
|
19
|
+
queries: ["listStoryDrafts"],
|
|
19
20
|
guest: { mutations: [] },
|
|
20
21
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
21
22
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -23,6 +23,7 @@ export const readerSteeringManifest: PluginManifest = {
|
|
|
23
23
|
contract: 1,
|
|
24
24
|
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
25
25
|
mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
|
|
26
|
+
queries: ["listStoryDrafts"],
|
|
26
27
|
guest: { mutations: [] },
|
|
27
28
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
28
29
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -165,6 +165,22 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
165
165
|
error: a.string(),
|
|
166
166
|
message: a.string(),
|
|
167
167
|
}),
|
|
168
|
+
// What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
|
|
169
|
+
StoryDraftSummary: a.customType({
|
|
170
|
+
id: a.string().required(),
|
|
171
|
+
title: a.string().required(),
|
|
172
|
+
summary: a.string(),
|
|
173
|
+
targets: a.json(),
|
|
174
|
+
state: a.string().required(),
|
|
175
|
+
createdAt: a.string().required(),
|
|
176
|
+
}),
|
|
177
|
+
StoryDraftList: a.customType({
|
|
178
|
+
status: a.string().required(),
|
|
179
|
+
id: a.string(),
|
|
180
|
+
error: a.string(),
|
|
181
|
+
message: a.string(),
|
|
182
|
+
drafts: a.ref("StoryDraftSummary").array(),
|
|
183
|
+
}),
|
|
168
184
|
// Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
|
|
169
185
|
// schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
|
|
170
186
|
// of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
|
|
@@ -194,6 +210,13 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
194
210
|
.returns(a.ref("ProposalReceipt"))
|
|
195
211
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
196
212
|
.handler(functionHandler("readerActions", readerActions)),
|
|
213
|
+
// Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
|
|
214
|
+
// answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
|
|
215
|
+
listStoryDrafts: a
|
|
216
|
+
.query()
|
|
217
|
+
.returns(a.ref("StoryDraftList"))
|
|
218
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
219
|
+
.handler(functionHandler("readerActions", readerActions)),
|
|
197
220
|
// Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
|
|
198
221
|
// undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
|
|
199
222
|
editProposal: a
|
|
@@ -167,6 +167,22 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
167
167
|
error: a.string(),
|
|
168
168
|
message: a.string(),
|
|
169
169
|
}),
|
|
170
|
+
// What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
|
|
171
|
+
StoryDraftSummary: a.customType({
|
|
172
|
+
id: a.string().required(),
|
|
173
|
+
title: a.string().required(),
|
|
174
|
+
summary: a.string(),
|
|
175
|
+
targets: a.json(),
|
|
176
|
+
state: a.string().required(),
|
|
177
|
+
createdAt: a.string().required(),
|
|
178
|
+
}),
|
|
179
|
+
StoryDraftList: a.customType({
|
|
180
|
+
status: a.string().required(),
|
|
181
|
+
id: a.string(),
|
|
182
|
+
error: a.string(),
|
|
183
|
+
message: a.string(),
|
|
184
|
+
drafts: a.ref("StoryDraftSummary").array(),
|
|
185
|
+
}),
|
|
170
186
|
// Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
|
|
171
187
|
// schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
|
|
172
188
|
// of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
|
|
@@ -196,6 +212,13 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
196
212
|
.returns(a.ref("ProposalReceipt"))
|
|
197
213
|
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
198
214
|
.handler(functionHandler("readerActions", readerActions)),
|
|
215
|
+
// Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
|
|
216
|
+
// answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
|
|
217
|
+
listStoryDrafts: a
|
|
218
|
+
.query()
|
|
219
|
+
.returns(a.ref("StoryDraftList"))
|
|
220
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
221
|
+
.handler(functionHandler("readerActions", readerActions)),
|
|
199
222
|
// Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
|
|
200
223
|
// undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
|
|
201
224
|
editProposal: a
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleCliLoginDiscovery } from "../../../lib/cli-login-discovery";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function GET(request: NextRequest) {
|
|
8
|
+
return handleCliLoginDiscovery(request);
|
|
9
|
+
}
|
|
@@ -97,8 +97,8 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
|
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen
|
|
100
|
+
function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
|
|
101
|
+
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen: (receiptId: string) => void;
|
|
102
102
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
103
103
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
104
104
|
}) {
|
|
@@ -181,7 +181,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
181
181
|
if (result.error) {
|
|
182
182
|
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} of ${candidates.length} selected candidates were registered or matched. ${result.error}` }));
|
|
183
183
|
} else {
|
|
184
|
-
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References.
|
|
184
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Import does not approve or publish them.` }));
|
|
185
185
|
}
|
|
186
186
|
} catch {
|
|
187
187
|
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
@@ -207,7 +207,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
207
207
|
<span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
|
|
208
208
|
{item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
|
|
209
209
|
{item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
|
|
210
|
-
|
|
210
|
+
<button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button>
|
|
211
211
|
</li>)}
|
|
212
212
|
</ul> : <p>No email submissions are available for this publication.</p>}
|
|
213
213
|
{detail && "submission" in detail ? (
|
|
@@ -238,9 +238,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
238
238
|
{busyImport ? "Adding References…" : "Add selected links to References"}
|
|
239
239
|
</button>
|
|
240
240
|
{notice ? <p role="status" data-email-import-result>{notice}</p> : null}
|
|
241
|
-
{importedReferences.length ? <
|
|
242
|
-
<li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
|
|
243
|
-
))}</ul> : null}
|
|
241
|
+
{importedReferences.length ? <p><Link href={getNewsroomNavHref("/newsroom/references", false)}>Review these References</Link></p> : null}
|
|
244
242
|
</section>
|
|
245
243
|
) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
|
|
246
244
|
: detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
|
|
@@ -338,7 +336,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
338
336
|
<div className="news-desk-doctrine-card__header"><div><h3>Collected queue</h3><span>Newest first, with what the shared Cyclotron decided</span></div></div>
|
|
339
337
|
{data.items.length ? (
|
|
340
338
|
<ul className="news-desk-monitoring__queue">
|
|
341
|
-
{data.items.map((item) => <QueueItem key={`${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
339
|
+
{data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
342
340
|
</ul>
|
|
343
341
|
) : <p>No items in the queue.</p>}
|
|
344
342
|
{data.hiddenNotRelevant > 0 ? (
|
|
@@ -347,6 +345,14 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
347
345
|
</p>
|
|
348
346
|
) : null}
|
|
349
347
|
</article>
|
|
348
|
+
|
|
349
|
+
{data.otherMonitors?.length ? (
|
|
350
|
+
<details className="news-desk-delegation-note" data-monitoring-other-monitors>
|
|
351
|
+
<summary>Not routed to this site ({data.otherMonitors.length})</summary>
|
|
352
|
+
<p>These monitors collect items, but none of them is routed to this site, so their items are not shown here.</p>
|
|
353
|
+
<ul>{data.otherMonitors.map((id) => <li key={id} data-monitoring-other-monitor={id}>{id}</li>)}</ul>
|
|
354
|
+
</details>
|
|
355
|
+
) : null}
|
|
350
356
|
</>
|
|
351
357
|
);
|
|
352
358
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
import { useSearchParams } from "next/navigation";
|
|
5
4
|
import {
|
|
6
5
|
importMonitoringEmailCandidates,
|
|
7
6
|
loadMonitoringEmailSubmission,
|
|
@@ -10,41 +9,12 @@ import {
|
|
|
10
9
|
} from "../lib/monitoring-client";
|
|
11
10
|
import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
|
|
12
11
|
import type { EmailReferenceImportItem, MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
|
|
13
|
-
import {
|
|
12
|
+
import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
14
13
|
|
|
15
14
|
const REFRESH_MILLISECONDS = 60_000;
|
|
16
15
|
|
|
17
|
-
/**
|
|
18
|
-
export function MonitoringPanel(
|
|
19
|
-
const searchParams = useSearchParams();
|
|
20
|
-
if (searchParams.has("receipt")) {
|
|
21
|
-
return <MonitoringEmailReceipt key={searchParams.get("receipt")} receiptId={searchParams.get("receipt") ?? ""} isDemo={props.isDemo} />;
|
|
22
|
-
}
|
|
23
|
-
return <MonitoringOverviewPanel {...props} />;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function MonitoringEmailReceipt({ receiptId, isDemo }: { receiptId: string; isDemo: boolean }) {
|
|
27
|
-
const [detail, setDetail] = useState<MonitoringEmailDetails | null>(null);
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
if (!receiptId || isDemo) return;
|
|
30
|
-
let live = true;
|
|
31
|
-
void loadMonitoringEmailSubmission(receiptId).then(
|
|
32
|
-
(next) => { if (live) setDetail(next); },
|
|
33
|
-
() => { if (live) setDetail({ state: "unavailable" }); },
|
|
34
|
-
);
|
|
35
|
-
return () => { live = false; };
|
|
36
|
-
}, [receiptId, isDemo]);
|
|
37
|
-
if (!receiptId) return <p role="alert">A receipt is required to open a submission.</p>;
|
|
38
|
-
if (isDemo) return <p role="status">Single email submissions require an editor session.</p>;
|
|
39
|
-
return <div data-news-desk-admin-panel="monitoring">
|
|
40
|
-
{detail === null ? <p role="status">Loading email submission…</p> :
|
|
41
|
-
<EmailSubmissions items={"submission" in detail ? [detail.submission] : []} detail={detail}
|
|
42
|
-
onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates} />}
|
|
43
|
-
</div>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Overview refreshes apply only when no receipt was requested. */
|
|
47
|
-
function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
|
|
16
|
+
/** Administration, Monitoring: loads the overview, refreshes it each minute, and lets an admin widen it to the whole pipeline. */
|
|
17
|
+
export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
|
|
48
18
|
const [mode, setMode] = useState<MonitoringMode>("publication");
|
|
49
19
|
const [result, setResult] = useState<MonitoringResult | null>(null);
|
|
50
20
|
const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
3
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The public values `papyrus login <site>` needs, served at `/.well-known/papyrus-cli.json`.
|
|
7
|
+
* Every value is public (Cognito pool/client ids, hosted-UI domain, API URL). Nothing here is a
|
|
8
|
+
* secret, and the file only exists for a site whose web client registers the CLI callback.
|
|
9
|
+
*/
|
|
10
|
+
export const CLI_LOGIN_PATH = "/.well-known/papyrus-cli.json";
|
|
11
|
+
|
|
12
|
+
export interface CliLoginDocument {
|
|
13
|
+
publication: string;
|
|
14
|
+
issuer: string;
|
|
15
|
+
client_id: string;
|
|
16
|
+
oauth_domain: string;
|
|
17
|
+
graphql_endpoint: string;
|
|
18
|
+
callback_uri: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AmplifyOutputsForCliLogin {
|
|
22
|
+
auth?: {
|
|
23
|
+
user_pool_id?: string;
|
|
24
|
+
user_pool_client_id?: string;
|
|
25
|
+
aws_region?: string;
|
|
26
|
+
oauth?: { domain?: string; redirect_sign_in_uri?: string[] };
|
|
27
|
+
};
|
|
28
|
+
data?: { url?: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The CLI signs in through the site's existing public web app client, which every app-shell site
|
|
33
|
+
* registers with this callback (infra/amplify-app-shell/lib/site-config.ts). No separate client exists.
|
|
34
|
+
*/
|
|
35
|
+
export const CLI_CALLBACK_URI = "http://localhost:3001/";
|
|
36
|
+
|
|
37
|
+
export function cliLoginDocumentFromOutputs(outputs: AmplifyOutputsForCliLogin | null, siteOrigin: string): CliLoginDocument | null {
|
|
38
|
+
const userPoolId = outputs?.auth?.user_pool_id;
|
|
39
|
+
const clientId = outputs?.auth?.user_pool_client_id;
|
|
40
|
+
const domain = outputs?.auth?.oauth?.domain;
|
|
41
|
+
const endpoint = outputs?.data?.url;
|
|
42
|
+
if (!userPoolId || !clientId || !domain || !endpoint || !endpoint.startsWith("https://")) return null;
|
|
43
|
+
// Only when the client really registers the callback; otherwise the sign-in could never complete.
|
|
44
|
+
if (!outputs?.auth?.oauth?.redirect_sign_in_uri?.includes(CLI_CALLBACK_URI)) return null;
|
|
45
|
+
const region = outputs?.auth?.aws_region ?? userPoolId.split("_")[0];
|
|
46
|
+
return {
|
|
47
|
+
publication: siteOrigin,
|
|
48
|
+
issuer: `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`,
|
|
49
|
+
client_id: clientId,
|
|
50
|
+
oauth_domain: `https://${domain}`,
|
|
51
|
+
graphql_endpoint: endpoint,
|
|
52
|
+
callback_uri: CLI_CALLBACK_URI,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CliLoginDiscoveryOptions {
|
|
57
|
+
readOutputs?: () => AmplifyOutputsForCliLogin | null;
|
|
58
|
+
environment?: Record<string, string | undefined>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function handleCliLoginDiscovery(request: Pick<NextRequest, "nextUrl">, options: CliLoginDiscoveryOptions = {}): NextResponse {
|
|
62
|
+
let origin: string;
|
|
63
|
+
try {
|
|
64
|
+
origin = publicSiteOrigin(request, options.environment ?? process.env);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
const outputs = (options.readOutputs ?? (() => readAmplifyOutputsFile() as AmplifyOutputsForCliLogin | null))();
|
|
70
|
+
const document = cliLoginDocumentFromOutputs(outputs, origin);
|
|
71
|
+
if (!document) {
|
|
72
|
+
return NextResponse.json(
|
|
73
|
+
{ error: "cli_login_not_configured", message: "This site does not register the CLI sign-in callback." },
|
|
74
|
+
{ status: 404, headers: { "cache-control": "public, max-age=60" } },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return NextResponse.json(document, { headers: { "cache-control": "public, max-age=300" } });
|
|
78
|
+
}
|
package/lib/monitoring-client.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
MonitoringResult,
|
|
11
11
|
} from "./monitoring-overview";
|
|
12
12
|
|
|
13
|
-
type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences"
|
|
13
|
+
type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences";
|
|
14
14
|
type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
|
|
15
15
|
|
|
16
16
|
async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
|
|
@@ -74,19 +74,10 @@ export async function importMonitoringEmailCandidates(
|
|
|
74
74
|
} catch {
|
|
75
75
|
return { imported: references.length, references, error: "The import response could not be read. Completed links are safe to retry." };
|
|
76
76
|
}
|
|
77
|
-
if (!response?.ok || !Array.isArray(response.references)
|
|
77
|
+
if (!response?.ok || !Array.isArray(response.references)) {
|
|
78
78
|
return { imported: references.length, references, error: "The import was not accepted. Check your editor access and retry." };
|
|
79
79
|
}
|
|
80
80
|
references.push(...response.references);
|
|
81
|
-
for (const [index, reference] of response.references.entries()) {
|
|
82
|
-
const queued = await query<{ queued: boolean }>("monitoringQueueEmailReference",
|
|
83
|
-
`mutation MonitoringQueueEmailReference($input: AWSJSON!) { monitoringQueueEmailReference(input: $input) }`,
|
|
84
|
-
{ input: JSON.stringify({ receiptId, ...batch[index], referenceId: reference.id }) });
|
|
85
|
-
if (!("queued" in queued) || !queued.queued) {
|
|
86
|
-
return { imported: references.length, references,
|
|
87
|
-
error: "References were registered, but predictions could not be queued. Retry these selected links safely." };
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
81
|
}
|
|
91
82
|
return { imported: references.length, references };
|
|
92
83
|
}
|
|
@@ -20,6 +20,7 @@ export type MonitoringRelevance = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export type MonitoringItem = {
|
|
23
|
+
monitorId?: string;
|
|
23
24
|
sourceId: string;
|
|
24
25
|
revision: number;
|
|
25
26
|
title: string;
|
|
@@ -128,6 +129,8 @@ export type MonitoringOverview = {
|
|
|
128
129
|
historyComplete: boolean;
|
|
129
130
|
} | null;
|
|
130
131
|
items: MonitoringItem[];
|
|
132
|
+
/** Monitors that do not route to this site: named only, with no items. */
|
|
133
|
+
otherMonitors?: string[];
|
|
131
134
|
emailSubmissions?: MonitoringEmailSummary[];
|
|
132
135
|
hiddenNotRelevant: number;
|
|
133
136
|
};
|
package/package.json
CHANGED
|
@@ -197,6 +197,7 @@
|
|
|
197
197
|
"./lib/category-dashboard": "./lib/category-dashboard.ts",
|
|
198
198
|
"./lib/category-repository": "./lib/category-repository.ts",
|
|
199
199
|
"./lib/chrome-text": "./lib/chrome-text.ts",
|
|
200
|
+
"./lib/cli-login-discovery": "./lib/cli-login-discovery.ts",
|
|
200
201
|
"./lib/console-chat-client": "./lib/console-chat-client.ts",
|
|
201
202
|
"./lib/console-web-context": "./lib/console-web-context.ts",
|
|
202
203
|
"./lib/content-actions-client": "./lib/content-actions-client.ts",
|
|
@@ -361,6 +362,7 @@
|
|
|
361
362
|
"./renderers/pretext/rhythm-blog-presentation": "./renderers/pretext/rhythm-blog-presentation.tsx",
|
|
362
363
|
"./renderers/pretext/rhythm-shell-style": "./renderers/pretext/rhythm-shell-style.ts",
|
|
363
364
|
"./app/%5F_preview/[[...path]]/route": "./app/%5F_preview/[[...path]]/route.ts",
|
|
365
|
+
"./app/.well-known/papyrus-cli.json/route": "./app/.well-known/papyrus-cli.json/route.ts",
|
|
364
366
|
"./app/[year]/[month]/[day]/[articleSlug]/page": "./app/[year]/[month]/[day]/[articleSlug]/page.tsx",
|
|
365
367
|
"./app/[year]/[month]/[day]/page": "./app/[year]/[month]/[day]/page.tsx",
|
|
366
368
|
"./app/[year]/[month]/[day]/page/[pageNumber]/page": "./app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
|
|
@@ -461,7 +463,7 @@
|
|
|
461
463
|
"optional": true
|
|
462
464
|
}
|
|
463
465
|
},
|
|
464
|
-
"version": "5.
|
|
466
|
+
"version": "5.42.0-next.1",
|
|
465
467
|
"dependencies": {
|
|
466
468
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
467
469
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "5.
|
|
2
|
+
"version": "5.42.0-next.1",
|
|
3
3
|
"routes": [
|
|
4
4
|
{
|
|
5
5
|
"file": "app/%5F_preview/[[...path]]/route.ts",
|
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
"dynamic": "\"force-dynamic\""
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
|
+
{
|
|
15
|
+
"file": "app/.well-known/papyrus-cli.json/route.ts",
|
|
16
|
+
"hasDefault": false,
|
|
17
|
+
"exports": [
|
|
18
|
+
"GET"
|
|
19
|
+
],
|
|
20
|
+
"config": {
|
|
21
|
+
"dynamic": "\"force-dynamic\"",
|
|
22
|
+
"runtime": "\"nodejs\""
|
|
23
|
+
}
|
|
24
|
+
},
|
|
14
25
|
{
|
|
15
26
|
"file": "app/[year]/[month]/[day]/[articleSlug]/page.tsx",
|
|
16
27
|
"reader": true,
|
package/src/sync.mjs
CHANGED
|
@@ -37,6 +37,15 @@ function shimFor(route) {
|
|
|
37
37
|
return lines.join("\n") + "\n";
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Anchored .gitignore line for a literal path; escapes gitignore metacharacters (Next dynamic routes use [ ]). */
|
|
41
|
+
export function gitignoreEntry(rel) {
|
|
42
|
+
const escaped = rel
|
|
43
|
+
.replace(/[\\[\]*?]/g, "\\$&")
|
|
44
|
+
.replace(/^[#!]/, "\\$&")
|
|
45
|
+
.replace(/ +$/, (m) => m.replace(/ /g, "\\ "));
|
|
46
|
+
return "/" + escaped;
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
export function middlewareShim(manifest) {
|
|
41
50
|
const matcher = manifest.middleware.matcher ?? '["/((?!_next/static|_next/image|favicon.ico|icon).*)"]';
|
|
42
51
|
return [
|
|
@@ -101,9 +110,9 @@ export function sync({ root = process.cwd(), log = console.log, manifest: manife
|
|
|
101
110
|
// Keep generated shims out of git.
|
|
102
111
|
const giFile = path.join(root, ".gitignore");
|
|
103
112
|
const gi = fs.existsSync(giFile) ? fs.readFileSync(giFile, "utf8") : "";
|
|
104
|
-
const block = [GITIGNORE_BEGIN, ...generated.map(
|
|
113
|
+
const block = [GITIGNORE_BEGIN, ...generated.map(gitignoreEntry), "/.papyrus/", GITIGNORE_END].join("\n");
|
|
105
114
|
const re = new RegExp(`${GITIGNORE_BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${GITIGNORE_END}`);
|
|
106
|
-
fs.writeFileSync(giFile, re.test(gi) ? gi.replace(re, block) : `${gi}${gi.endsWith("\n") || !gi ? "" : "\n"}${block}\n`);
|
|
115
|
+
fs.writeFileSync(giFile, re.test(gi) ? gi.replace(re, () => block) : `${gi}${gi.endsWith("\n") || !gi ? "" : "\n"}${block}\n`);
|
|
107
116
|
|
|
108
117
|
log(`papyrus-app sync: ${generated.length} shim(s) written, ${skipped.length} site-owned file(s) kept (${PKG}@${manifest.version})${readerBasePath ? `, reader mounted at ${readerBasePath}` : ""}`);
|
|
109
118
|
for (const rel of skipped) log(` site-owned: ${rel}`);
|
package/src/with-papyrus.d.mts
CHANGED
package/src/with-papyrus.mjs
CHANGED
|
@@ -56,6 +56,18 @@ export function mergeTranspilePackages(existing = []) {
|
|
|
56
56
|
return [...new Set([...existing, ...DEFAULT_TRANSPILE_PACKAGES])];
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* chatticus's prebuilt chat runtime is written against zod 4 (`z.looseObject`) and imports bare `zod`.
|
|
61
|
+
* Publication repos pin zod 3.25 through a root `overrides` entry (Amplify constructs need it), which
|
|
62
|
+
* also flattens chatticus's own zod 4 dependency, so the bundle then fails to build. zod 3.25 ships the
|
|
63
|
+
* zod 4 API at `zod/v4`; point only chatticus's `zod` imports there, leaving every other zod import alone.
|
|
64
|
+
*/
|
|
65
|
+
export const CHATTICUS_PACKAGE_PATH = /[\\/]node_modules[\\/]chatticus[\\/]/;
|
|
66
|
+
|
|
67
|
+
export function redirectChatticusZod(resource) {
|
|
68
|
+
if (resource.request === "zod" && CHATTICUS_PACKAGE_PATH.test(resource.context ?? "")) resource.request = "zod/v4";
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
export const RUNTIME_TRACED_FILES = ["./corpora/**/*", "./amplify_outputs.json"];
|
|
60
72
|
|
|
61
73
|
export function mergeOutputFileTracingIncludes(existing = {}) {
|
|
@@ -99,6 +111,10 @@ export function withPapyrus(nextConfig = {}, options = {}) {
|
|
|
99
111
|
},
|
|
100
112
|
webpack(config, context) {
|
|
101
113
|
config.resolve.alias = { ...config.resolve.alias, ...aliases };
|
|
114
|
+
config.plugins = [
|
|
115
|
+
...(config.plugins ?? []),
|
|
116
|
+
new context.webpack.NormalModuleReplacementPlugin(/^zod$/, redirectChatticusZod),
|
|
117
|
+
];
|
|
102
118
|
return typeof nextConfig.webpack === "function" ? nextConfig.webpack(config, context) : config;
|
|
103
119
|
},
|
|
104
120
|
};
|