@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110
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/auth/resource.js +16 -1
- package/amplify/auth/resource.ts +19 -1
- package/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +152 -62
- package/amplify/data/resource.ts +158 -62
- package/amplify/data/schema.js +2101 -1987
- package/amplify/data/schema.ts +197 -70
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +64 -3
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +2 -2
- package/amplify/functions/editorial-checks/requirements.txt +1 -1
- package/amplify/functions/editorial-checks/resource.js +3 -3
- package/amplify/functions/editorial-checks/resource.ts +3 -3
- package/amplify/functions/editorial-checks/schema.js +38 -8
- package/amplify/functions/editorial-checks/schema.ts +38 -8
- package/amplify/functions/editorial-checks/wire.js +4 -0
- package/amplify/functions/editorial-checks/wire.ts +4 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +29 -0
- package/amplify/functions/monitoring-view/schema.ts +31 -0
- package/amplify/functions/monitoring-view/wire.js +64 -0
- package/amplify/functions/monitoring-view/wire.ts +81 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1589 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +487 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +187 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
- package/amplify/plugins/reader-steering/schema.js +252 -0
- package/amplify/plugins/reader-steering/schema.ts +254 -0
- package/amplify/plugins/registry.js +4 -0
- package/amplify/plugins/registry.ts +4 -0
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.js +19 -0
- package/amplify/site-backend-config.ts +45 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +42 -4
- package/amplify/site-backend.ts +45 -4
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/chat/[...path]/route.ts +17 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/app/layout.tsx +5 -1
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +195 -0
- package/components/editorial-checks-admin-panel.tsx +32 -17
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +422 -0
- package/components/monitoring-panel.tsx +60 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +16 -2
- package/components/newsroom-article-editor.tsx +9 -7
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-assignment-detail.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +26 -96
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +281 -0
- package/components/papyrus-console-conversation.css +14 -0
- package/components/papyrus-console-conversation.tsx +55 -0
- package/components/papyrus-console-shell.tsx +62 -420
- package/components/papyrus-private-chat-panel.tsx +134 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +230 -144
- package/infra/amplify-app-shell.js +96 -16
- package/infra/amplify-app-shell.ts +108 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +92 -6
- package/infra/site-config.ts +138 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +252 -0
- package/lib/category-repository.ts +14 -12
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/console-web-context.ts +1 -1
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-client.ts +18 -0
- package/lib/editorial-checks-demo.ts +1 -1
- package/lib/editorial-checks.ts +8 -4
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +83 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +191 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/papyrus-chat-admission.ts +65 -0
- package/lib/papyrus-hosted-chat.ts +217 -0
- package/lib/papyrus-private-chat-client.ts +81 -0
- package/lib/prospectus-session.ts +30 -14
- package/lib/prospectus-sign-in-routes.ts +106 -31
- package/lib/prospectus-site-identity.ts +46 -6
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +120 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +246 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/shared-chat-admission.ts +182 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin-value.ts +50 -0
- package/lib/site-origin.ts +8 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +67 -3
- package/routes.manifest.json +238 -2
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.d.mts +1 -0
- package/src/with-papyrus.mjs +17 -1
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { SITE_BRAND } from "./site-brand";
|
|
3
|
+
import type { PublicationCapability } from "./prospectus-publication-access";
|
|
4
|
+
import {
|
|
5
|
+
currentProspectusPublishingSelection,
|
|
6
|
+
signedInProspectusSession,
|
|
7
|
+
type ProspectusPublishingSelection,
|
|
8
|
+
type ProspectusSignInRouteOptions,
|
|
9
|
+
} from "./prospectus-sign-in-routes";
|
|
10
|
+
import { resolveProspectusSignInRuntime } from "./prospectus-session";
|
|
11
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin } from "./site-origin";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Same-origin curation routes for the References tab. Each is an authenticated
|
|
15
|
+
* pass-through to the curation upstream (the central Cyclotron lab): this
|
|
16
|
+
* module decides who may call and what they may ask, the upstream repeats its
|
|
17
|
+
* own checks, and neither trusts the browser's pins as identity.
|
|
18
|
+
*
|
|
19
|
+
* Order, failing closed at each step: upstream configured, Origin and body,
|
|
20
|
+
* sealed session cookie, fresh Prospectus re-authorization, scope pins,
|
|
21
|
+
* capability, forward, then re-authorize again and verify the echoed scope
|
|
22
|
+
* before any byte of the answer reaches the browser.
|
|
23
|
+
*/
|
|
24
|
+
export const CURATION_UPSTREAM_URL_ENV = "PAPYRUS_CURATION_UPSTREAM_URL";
|
|
25
|
+
export const CURATION_PUBLICATION_ID_ENV = "PAPYRUS_CURATION_PUBLICATION_ID";
|
|
26
|
+
export const CURATION_MAXIMUM_REQUEST_BYTES = 4_000_000;
|
|
27
|
+
export const CURATION_MAXIMUM_RESPONSE_BYTES = 8_000_000;
|
|
28
|
+
export const CURATION_UPSTREAM_TIMEOUT_MILLISECONDS = 15_000;
|
|
29
|
+
|
|
30
|
+
export type CurationRoute = "context" | "publication" | "priming";
|
|
31
|
+
|
|
32
|
+
export interface CurationRouteOptions extends ProspectusSignInRouteOptions {
|
|
33
|
+
/** Overrides PAPYRUS_CURATION_UPSTREAM_URL. null means not configured. */
|
|
34
|
+
upstreamUrl?: string | null;
|
|
35
|
+
/** Overrides PAPYRUS_CURATION_PUBLICATION_ID, which defaults to the site brand id. */
|
|
36
|
+
publicationId?: string;
|
|
37
|
+
fetcher?: typeof fetch;
|
|
38
|
+
timeoutMilliseconds?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const NO_STORE_HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
|
|
42
|
+
const PIN_HEADERS = ["x-cyclotron-account", "x-papyrus-publication", "x-papyrus-binding"] as const;
|
|
43
|
+
const PUBLICATION_OPERATION_CAPABILITIES: Record<string, PublicationCapability> = {
|
|
44
|
+
decide: "decide",
|
|
45
|
+
review: "review",
|
|
46
|
+
status: "read",
|
|
47
|
+
history: "read",
|
|
48
|
+
events: "read",
|
|
49
|
+
replay: "read",
|
|
50
|
+
steer: "steer",
|
|
51
|
+
};
|
|
52
|
+
const FORWARDED_UPSTREAM_REFUSALS = new Set([400, 401, 403, 409, 413, 415, 503]);
|
|
53
|
+
|
|
54
|
+
/** Editor-facing wording for the refusal codes the curation upstream returns. Unknown codes get a generic line. */
|
|
55
|
+
export const UPSTREAM_REFUSAL_MESSAGES: Record<string, string> = {
|
|
56
|
+
pins_required: "Your account or publication selection is incomplete. Refresh the page and try again.",
|
|
57
|
+
invalid_request: "That request could not be understood. Refresh the page and try again.",
|
|
58
|
+
json_required: "That request could not be understood. Refresh the page and try again.",
|
|
59
|
+
bearer_invalid: "Sign in again: your session expired. Your selection is preserved.",
|
|
60
|
+
access_refused: "Your account does not have access to this publication.",
|
|
61
|
+
outside_publication: "That item is outside the publication you selected.",
|
|
62
|
+
account_changed: "The selected account or publication changed. Refresh and try again.",
|
|
63
|
+
operation_unavailable: "This action is not available yet.",
|
|
64
|
+
firewall_overlap: "This example is also in material kept aside for checking results, so it cannot be added.",
|
|
65
|
+
auto_prime_failed: "Your example could not be fully applied. Check saved history before retrying.",
|
|
66
|
+
request_too_large: "That request is too large. Try something smaller.",
|
|
67
|
+
import_too_large: "That file is too large. Try a smaller file.",
|
|
68
|
+
storage_limit: "This publication has reached its storage limit for imported examples.",
|
|
69
|
+
authority_unavailable: "Account access could not be confirmed right now. Try again shortly.",
|
|
70
|
+
priming_not_configured: "Importing examples is not set up for this publication yet.",
|
|
71
|
+
status_unavailable: "Monitoring is not available right now. Try again shortly.",
|
|
72
|
+
live_disabled: "Live decisions are switched off for this publication.",
|
|
73
|
+
no_hosted_transformer: "Adding a reference from a web address is not available. Import a file instead.",
|
|
74
|
+
};
|
|
75
|
+
const GENERIC_UPSTREAM_REFUSAL = "The curation service refused the request. Check saved history before retrying.";
|
|
76
|
+
|
|
77
|
+
class CurationRefusal extends Error {
|
|
78
|
+
constructor(readonly status: number, readonly code: string, message: string) {
|
|
79
|
+
super(message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function refusal(status: number, code: string, message: string): CurationRefusal {
|
|
84
|
+
return new CurationRefusal(status, code, message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function jsonResponse(body: unknown, status = 200, extraHeaders: Record<string, string> = {}): NextResponse {
|
|
88
|
+
return NextResponse.json(body, { status, headers: { ...NO_STORE_HEADERS, ...extraHeaders } });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function upstreamBase(options: CurationRouteOptions): URL | null {
|
|
92
|
+
const configured = options.upstreamUrl !== undefined ? options.upstreamUrl : process.env[CURATION_UPSTREAM_URL_ENV];
|
|
93
|
+
if (!configured?.trim()) return null;
|
|
94
|
+
try {
|
|
95
|
+
const url = new URL(configured.trim());
|
|
96
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
97
|
+
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) return null;
|
|
98
|
+
if (url.username || url.password) return null;
|
|
99
|
+
return url;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function publicationIdFor(options: CurationRouteOptions): string {
|
|
106
|
+
return options.publicationId ?? (process.env[CURATION_PUBLICATION_ID_ENV]?.trim() || SITE_BRAND.id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Which capability a priming GraphQL document needs. Any hint of a mutation requires review. */
|
|
110
|
+
export function primingCapability(query: string): PublicationCapability {
|
|
111
|
+
return /\bmutation\b|\bprimingCommand\b/i.test(query) ? "review" : "read";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function boundedText(request: NextRequest): Promise<string> {
|
|
115
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
116
|
+
if (declared > CURATION_MAXIMUM_REQUEST_BYTES) throw refusal(413, "request_too_large", "Curation request exceeds the size limit.");
|
|
117
|
+
const text = await request.text();
|
|
118
|
+
if (Buffer.byteLength(text) > CURATION_MAXIMUM_REQUEST_BYTES) throw refusal(413, "request_too_large", "Curation request exceeds the size limit.");
|
|
119
|
+
return text;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function boundedResponseText(response: Response): Promise<string> {
|
|
123
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
124
|
+
if (declared > CURATION_MAXIMUM_RESPONSE_BYTES) throw refusal(502, "upstream_response_too_large", "The curation service answered too much.");
|
|
125
|
+
if (!response.body) return "";
|
|
126
|
+
const reader = response.body.getReader();
|
|
127
|
+
const chunks: Uint8Array[] = [];
|
|
128
|
+
let total = 0;
|
|
129
|
+
for (;;) {
|
|
130
|
+
const { done, value } = await reader.read();
|
|
131
|
+
if (done) break;
|
|
132
|
+
total += value.byteLength;
|
|
133
|
+
if (total > CURATION_MAXIMUM_RESPONSE_BYTES) {
|
|
134
|
+
await reader.cancel();
|
|
135
|
+
throw refusal(502, "upstream_response_too_large", "The curation service answered too much.");
|
|
136
|
+
}
|
|
137
|
+
chunks.push(value);
|
|
138
|
+
}
|
|
139
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sameSelection(left: ProspectusPublishingSelection, right: ProspectusPublishingSelection | null): boolean {
|
|
143
|
+
return right !== null
|
|
144
|
+
&& left.personId === right.personId
|
|
145
|
+
&& left.accountId === right.accountId
|
|
146
|
+
&& left.role === right.role
|
|
147
|
+
&& left.publicationCapabilities.join() === right.publicationCapabilities.join();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface ParsedRequest {
|
|
151
|
+
capability: PublicationCapability;
|
|
152
|
+
/** Raw body to forward; undefined for the context read. */
|
|
153
|
+
body?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseRequest(route: CurationRoute, text: string | null): ParsedRequest {
|
|
157
|
+
if (route === "context") return { capability: "read" };
|
|
158
|
+
let value: unknown;
|
|
159
|
+
try {
|
|
160
|
+
value = JSON.parse(text ?? "");
|
|
161
|
+
} catch {
|
|
162
|
+
throw refusal(400, "invalid_request", "A JSON curation request is required.");
|
|
163
|
+
}
|
|
164
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw refusal(400, "invalid_request", "A JSON curation request is required.");
|
|
165
|
+
const body = value as Record<string, unknown>;
|
|
166
|
+
if (route === "publication") {
|
|
167
|
+
const keys = Object.keys(body).sort().join();
|
|
168
|
+
const operation = typeof body.operation === "string" ? body.operation : "";
|
|
169
|
+
if (keys !== "operation,payload" || !Object.hasOwn(PUBLICATION_OPERATION_CAPABILITIES, operation) || !body.payload || typeof body.payload !== "object" || Array.isArray(body.payload)) {
|
|
170
|
+
throw refusal(400, "invalid_request", "An explicit publication operation is required.");
|
|
171
|
+
}
|
|
172
|
+
return { capability: PUBLICATION_OPERATION_CAPABILITIES[operation], body: text! };
|
|
173
|
+
}
|
|
174
|
+
const variablesValid = body.variables === undefined || (body.variables !== null && typeof body.variables === "object" && !Array.isArray(body.variables));
|
|
175
|
+
if (typeof body.query !== "string" || !body.query.trim() || !variablesValid || Object.keys(body).some((key) => key !== "query" && key !== "variables")) {
|
|
176
|
+
throw refusal(400, "invalid_request", "An explicit priming query is required.");
|
|
177
|
+
}
|
|
178
|
+
return { capability: primingCapability(body.query), body: text! };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function requestPins(request: NextRequest, selection: ProspectusPublishingSelection, publicationId: string): Record<(typeof PIN_HEADERS)[number], string> {
|
|
182
|
+
const pins = Object.fromEntries(PIN_HEADERS.map((name) => [name, request.headers.get(name)?.trim() ?? ""])) as Record<(typeof PIN_HEADERS)[number], string>;
|
|
183
|
+
if (PIN_HEADERS.some((name) => !pins[name])) throw refusal(400, "pins_required", "Expected account, publication and binding pins are required.");
|
|
184
|
+
if (pins["x-cyclotron-account"] !== selection.accountId || pins["x-papyrus-publication"] !== publicationId) {
|
|
185
|
+
throw refusal(409, "account_changed", "The selected account or publication changed. Refresh and try again.");
|
|
186
|
+
}
|
|
187
|
+
return pins;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function authorizedCapabilities(selection: ProspectusPublishingSelection): PublicationCapability[] {
|
|
191
|
+
return selection.publicationCapabilities;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function forward(
|
|
195
|
+
request: NextRequest,
|
|
196
|
+
route: CurationRoute,
|
|
197
|
+
base: URL,
|
|
198
|
+
options: CurationRouteOptions,
|
|
199
|
+
accessToken: string,
|
|
200
|
+
upstreamHeaders: Record<string, string>,
|
|
201
|
+
body: string | undefined,
|
|
202
|
+
): Promise<Response> {
|
|
203
|
+
const target = new URL(`/api/newsroom/curation/${route}`, base);
|
|
204
|
+
const headers: Record<string, string> = {
|
|
205
|
+
...upstreamHeaders,
|
|
206
|
+
authorization: `Bearer ${accessToken}`,
|
|
207
|
+
accept: "application/json",
|
|
208
|
+
};
|
|
209
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
210
|
+
const response = await (options.fetcher ?? fetch)(target, {
|
|
211
|
+
method: route === "context" ? "GET" : "POST",
|
|
212
|
+
headers,
|
|
213
|
+
body,
|
|
214
|
+
redirect: "manual",
|
|
215
|
+
cache: "no-store",
|
|
216
|
+
signal: AbortSignal.timeout(options.timeoutMilliseconds ?? CURATION_UPSTREAM_TIMEOUT_MILLISECONDS),
|
|
217
|
+
});
|
|
218
|
+
if (response.status >= 300 && response.status < 400) throw refusal(502, "upstream_redirect", "The curation service redirected, which is refused.");
|
|
219
|
+
return response;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sanitizedUpstreamRefusal(status: number, text: string): CurationRefusal {
|
|
223
|
+
let code = "";
|
|
224
|
+
try {
|
|
225
|
+
const parsed = JSON.parse(text) as { code?: unknown };
|
|
226
|
+
code = typeof parsed.code === "string" ? parsed.code : "";
|
|
227
|
+
} catch {
|
|
228
|
+
code = "";
|
|
229
|
+
}
|
|
230
|
+
const known = Object.hasOwn(UPSTREAM_REFUSAL_MESSAGES, code);
|
|
231
|
+
return refusal(status, known ? code : "upstream_refused", known ? UPSTREAM_REFUSAL_MESSAGES[code] : GENERIC_UPSTREAM_REFUSAL);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function validPriming(value: unknown): Record<string, unknown> | null {
|
|
235
|
+
const priming = value as { listId?: unknown; classifiers?: unknown; transformers?: unknown } | null;
|
|
236
|
+
if (!priming || typeof priming.listId !== "string" || !priming.listId || !Array.isArray(priming.classifiers) || !priming.classifiers.length) return null;
|
|
237
|
+
return { listId: priming.listId, classifiers: priming.classifiers, transformers: Array.isArray(priming.transformers) ? priming.transformers : [] };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function contextBootstrap(value: unknown, selection: ProspectusPublishingSelection, publicationId: string): Record<string, unknown> {
|
|
241
|
+
const body = value as { scope?: Record<string, unknown>; mode?: unknown; priming?: unknown; capabilities?: unknown; bindingRef?: unknown } | null;
|
|
242
|
+
const scope = body?.scope;
|
|
243
|
+
if (!scope || scope.accountId !== selection.accountId || scope.publicationId !== publicationId
|
|
244
|
+
|| typeof scope.bindingRevision !== "string" || !scope.bindingRevision
|
|
245
|
+
|| (body!.mode !== "embedded" && body!.mode !== "remote") || !Array.isArray(body!.capabilities)) {
|
|
246
|
+
throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
|
|
247
|
+
}
|
|
248
|
+
const granted = new Set<string>(authorizedCapabilities(selection));
|
|
249
|
+
return {
|
|
250
|
+
scope: { accountId: scope.accountId, publicationId: scope.publicationId, bindingRevision: scope.bindingRevision },
|
|
251
|
+
mode: body!.mode,
|
|
252
|
+
priming: validPriming(body!.priming),
|
|
253
|
+
capabilities: body!.capabilities.filter((capability): capability is string => typeof capability === "string" && granted.has(capability)),
|
|
254
|
+
bindingRef: body!.bindingRef ?? null,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function verifiedPublicationEnvelope(value: unknown, pins: Record<string, string>): void {
|
|
259
|
+
const envelope = value as Record<string, unknown> | null;
|
|
260
|
+
if (!envelope || envelope.contractVersion !== "cyclotron-publication/v1"
|
|
261
|
+
|| envelope.accountId !== pins["x-cyclotron-account"]
|
|
262
|
+
|| envelope.publicationId !== pins["x-papyrus-publication"]
|
|
263
|
+
|| envelope.bindingRevision !== pins["x-papyrus-binding"]) {
|
|
264
|
+
throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function handleCurationRoute(request: NextRequest, route: CurationRoute, options: CurationRouteOptions = {}): Promise<NextResponse> {
|
|
269
|
+
try {
|
|
270
|
+
const base = upstreamBase(options);
|
|
271
|
+
if (!base) return jsonResponse({ error: "curation_not_configured", message: "Curation is not configured for this site." }, 503);
|
|
272
|
+
|
|
273
|
+
const origin = request.headers.get("origin");
|
|
274
|
+
const siteOrigin = publicSiteOrigin(request, options.environment);
|
|
275
|
+
if (route !== "context" ? origin !== siteOrigin : origin !== null && origin !== siteOrigin) {
|
|
276
|
+
throw refusal(403, "cross_origin", "Curation requests must come from this site.");
|
|
277
|
+
}
|
|
278
|
+
let text: string | null = null;
|
|
279
|
+
if (route !== "context") {
|
|
280
|
+
if (request.headers.get("content-type")?.split(";")[0].trim() !== "application/json") throw refusal(415, "json_required", "A JSON request is required.");
|
|
281
|
+
text = await boundedText(request);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(siteOrigin);
|
|
285
|
+
if (!runtime) return jsonResponse({ error: "prospectus_sign_in_unavailable", message: "Sign-in through Prospectus is not configured for this site." }, 503);
|
|
286
|
+
const nowInSeconds = options.nowInSeconds?.();
|
|
287
|
+
const cookieSession = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
|
|
288
|
+
if (!cookieSession?.selectedAccountId) throw refusal(401, "signed_out", "Sign in and choose an account first.");
|
|
289
|
+
|
|
290
|
+
const selection = await currentProspectusPublishingSelection(request, options);
|
|
291
|
+
if (!selection || selection.accountId !== cookieSession.selectedAccountId) throw refusal(403, "not_authorized", "Your account no longer grants access.");
|
|
292
|
+
|
|
293
|
+
const publicationId = publicationIdFor(options);
|
|
294
|
+
const pins = route === "context" ? null : requestPins(request, selection, publicationId);
|
|
295
|
+
const parsed = parseRequest(route, text);
|
|
296
|
+
if (!authorizedCapabilities(selection).includes(parsed.capability)) throw refusal(403, "capability_refused", "Your role does not allow this curation action.");
|
|
297
|
+
|
|
298
|
+
// The token is read from the sealed cookie again for this call, never held across calls.
|
|
299
|
+
const fresh = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
|
|
300
|
+
if (!fresh || fresh.selectedAccountId !== selection.accountId) throw refusal(401, "signed_out", "Sign in and choose an account first.");
|
|
301
|
+
const upstreamHeaders = pins ?? { "x-cyclotron-account": selection.accountId, "x-papyrus-publication": publicationId };
|
|
302
|
+
const upstream = await forward(request, route, base, options, fresh.accessToken, upstreamHeaders, parsed.body);
|
|
303
|
+
const upstreamText = await boundedResponseText(upstream);
|
|
304
|
+
if (upstream.status !== 200) {
|
|
305
|
+
if (FORWARDED_UPSTREAM_REFUSALS.has(upstream.status)) throw sanitizedUpstreamRefusal(upstream.status, upstreamText);
|
|
306
|
+
throw refusal(502, "upstream_failed", "The curation service could not complete the request. Check saved history before retrying.");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Post-upstream check: nothing is released if authority or scope moved while we waited.
|
|
310
|
+
if (!sameSelection(selection, await currentProspectusPublishingSelection(request, options))) {
|
|
311
|
+
throw refusal(409, "account_changed", "The selected account or access changed. Refresh and try again.");
|
|
312
|
+
}
|
|
313
|
+
let answer: unknown;
|
|
314
|
+
try {
|
|
315
|
+
answer = JSON.parse(upstreamText);
|
|
316
|
+
} catch {
|
|
317
|
+
throw refusal(502, "upstream_invalid", "The curation service answered with an invalid response.");
|
|
318
|
+
}
|
|
319
|
+
if (pins) {
|
|
320
|
+
if (PIN_HEADERS.some((name) => upstream.headers.get(name) !== pins[name])) {
|
|
321
|
+
throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
|
|
322
|
+
}
|
|
323
|
+
if (route === "publication") verifiedPublicationEnvelope(answer, pins);
|
|
324
|
+
if (route === "priming" && (!answer || typeof answer !== "object" || Array.isArray(answer))) {
|
|
325
|
+
throw refusal(502, "upstream_invalid", "The curation service answered with an invalid response.");
|
|
326
|
+
}
|
|
327
|
+
return jsonResponse(answer, 200, pins);
|
|
328
|
+
}
|
|
329
|
+
const bootstrap = contextBootstrap(answer, selection, publicationId);
|
|
330
|
+
const bindingRevision = (bootstrap.scope as { bindingRevision: string }).bindingRevision;
|
|
331
|
+
if (upstream.headers.get("x-cyclotron-account") !== selection.accountId
|
|
332
|
+
|| upstream.headers.get("x-papyrus-publication") !== publicationId
|
|
333
|
+
|| upstream.headers.get("x-papyrus-binding") !== bindingRevision) {
|
|
334
|
+
throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
|
|
335
|
+
}
|
|
336
|
+
return jsonResponse(bootstrap, 200, {
|
|
337
|
+
"x-cyclotron-account": selection.accountId, "x-papyrus-publication": publicationId, "x-papyrus-binding": bindingRevision,
|
|
338
|
+
});
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error instanceof CurationRefusal) return jsonResponse({ error: error.message, code: error.code }, error.status);
|
|
341
|
+
if (error instanceof SiteOriginNotConfiguredError) return jsonResponse({ error: error.message, code: error.code }, error.status);
|
|
342
|
+
console.error(`Curation route failed (${error instanceof Error ? error.name : "error"}).`);
|
|
343
|
+
return jsonResponse({ error: "Curation is unavailable. Check saved history before retrying.", code: "curation_unavailable" }, 503);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -187,8 +187,8 @@ export function curationForSuggestionChoice(decision: RelevanceDecisionView, cho
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
export type ProvenanceSummary = {
|
|
190
|
-
teachingLabelsSentence: string;
|
|
191
|
-
primingExamplesSentence: string;
|
|
190
|
+
teachingLabelsSentence: string | null;
|
|
191
|
+
primingExamplesSentence: string | null;
|
|
192
192
|
doctrineShortHash: string | null;
|
|
193
193
|
modelVersion: string | null;
|
|
194
194
|
decidedAt: string | null;
|
|
@@ -202,8 +202,8 @@ export function provenanceSummary(decision: RelevanceDecisionView): ProvenanceSu
|
|
|
202
202
|
if (!provenance) return null;
|
|
203
203
|
const plural = (count: number, singular: string) => `${count} ${singular}${count === 1 ? "" : "s"}`;
|
|
204
204
|
return {
|
|
205
|
-
teachingLabelsSentence: `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
|
|
206
|
-
primingExamplesSentence: `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
|
|
205
|
+
teachingLabelsSentence: provenance.teachingLabels === null ? null : `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
|
|
206
|
+
primingExamplesSentence: provenance.primingExamples === null ? null : `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
|
|
207
207
|
doctrineShortHash: provenance.doctrineFingerprint ? provenance.doctrineFingerprint.slice(0, SHORT_HASH_LENGTH) : null,
|
|
208
208
|
modelVersion: decision.version === null ? null : `revision ${decision.version}`,
|
|
209
209
|
decidedAt: decision.decidedAt,
|
|
@@ -161,6 +161,24 @@ export async function importStarterPack(name: string): Promise<{ created: number
|
|
|
161
161
|
return parsed(data.importEditorialStarterPack);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
export type StarterPackSummary = {
|
|
165
|
+
name: string;
|
|
166
|
+
title: string;
|
|
167
|
+
version?: string | null;
|
|
168
|
+
language?: string | null;
|
|
169
|
+
guidance?: string | null;
|
|
170
|
+
policyCount: number;
|
|
171
|
+
importedCount: number;
|
|
172
|
+
isDefault: boolean;
|
|
173
|
+
policies: { id: string; title?: string | null; severity?: string | null; tags: string[]; lanes: string[] }[];
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** The starter packs the site's Humanus ships, with how many of each pack's policies the site already has. */
|
|
177
|
+
export async function listStarterPacks(): Promise<{ humanusVersion?: string | null; packs: StarterPackSummary[] }> {
|
|
178
|
+
const data = await call<{ listEditorialStarterPacks: unknown }>(`query ListStarterPacks { listEditorialStarterPacks }`);
|
|
179
|
+
return parsed(data.listEditorialStarterPacks);
|
|
180
|
+
}
|
|
181
|
+
|
|
164
182
|
export async function tryPolicy(policy: Record<string, unknown>, text: string): Promise<EditorialCheckResult> {
|
|
165
183
|
const data = await call<{ tryEditorialPolicy: unknown }>(
|
|
166
184
|
`query TryEditorialPolicy($policy: AWSJSON!, $text: String!) { tryEditorialPolicy(policy: $policy, text: $text, format: "markus") }`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Demo-mode editorial checks: no backend, a few stock phrases, canned rewrites.
|
|
3
|
-
* The shapes match
|
|
3
|
+
* The shapes match Humanus results so the editor, the preview marks and the
|
|
4
4
|
* rewrite flow behave as they do against the real backend.
|
|
5
5
|
*/
|
|
6
6
|
import type { EditorialCheckResult, EditorialFinding, EditorialSegment, RewriteResult, Span } from "./editorial-checks";
|
package/lib/editorial-checks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Editorial checks: the
|
|
3
|
-
* over them.
|
|
2
|
+
* Editorial checks: the Humanus result shapes the newsroom uses, and pure helpers
|
|
3
|
+
* over them. Humanus counts offsets in UTF-16 code units, which is exactly how
|
|
4
4
|
* JavaScript indexes strings, so spans apply to the body text as they are.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -38,7 +38,8 @@ export type EditorialSegment = {
|
|
|
38
38
|
text?: string;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/** "skipped": Papyrus did not ask for judgments because the publication has no mission or policy for them to apply. */
|
|
42
|
+
export type LaneStatus = { status: "ran" | "disabled" | "unavailable" | "failed" | "partial" | "skipped"; reason?: string; detectors?: number };
|
|
42
43
|
|
|
43
44
|
export type EditorialCheckResult = {
|
|
44
45
|
schemaVersion: 1;
|
|
@@ -49,7 +50,8 @@ export type EditorialCheckResult = {
|
|
|
49
50
|
protected: { kind: string; start: number; end: number }[];
|
|
50
51
|
findings: EditorialFinding[];
|
|
51
52
|
coverage: unknown[];
|
|
52
|
-
lanes
|
|
53
|
+
/** lanes.section is "skipped" when the story's section has no NewsroomSection record to take a mission and policy from. */
|
|
54
|
+
lanes: { rules: LaneStatus; classifier: LaneStatus; section?: LaneStatus };
|
|
53
55
|
usage?: Record<string, number>;
|
|
54
56
|
};
|
|
55
57
|
|
|
@@ -164,8 +166,10 @@ export function describeProbability(finding: EditorialFinding): string | null {
|
|
|
164
166
|
|
|
165
167
|
export function laneNotice(result: EditorialCheckResult): string | null {
|
|
166
168
|
const classifier = result.lanes.classifier;
|
|
169
|
+
if (classifier.status === "skipped") return `Mission-based checks skipped: ${classifier.reason ?? "this publication has no mission yet"}.`;
|
|
167
170
|
if (classifier.status === "partial") return `Some sentences were not judged: ${classifier.reason ?? "the check ran out of time"}.`;
|
|
168
171
|
if (classifier.status === "failed") return `The judgment checks failed: ${classifier.reason ?? "unknown error"}. Rule checks still ran.`;
|
|
169
172
|
if (classifier.status === "unavailable" && (classifier.detectors ?? 0) === 0 && classifier.reason) return `Judgment checks are not configured: ${classifier.reason}.`;
|
|
173
|
+
if (result.lanes.section?.status === "skipped") return `Section-based checks skipped: ${result.lanes.section.reason ?? "no section configuration"}.`;
|
|
170
174
|
return null;
|
|
171
175
|
}
|
package/lib/editorial-preview.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Places
|
|
2
|
+
* Places Humanus findings on the rendered Markus preview.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Humanus reports spans in the markup; the preview renders an IR tree with no
|
|
5
5
|
* character offsets. Both sides agree on the words a reader sees, so findings
|
|
6
|
-
* are placed by text: each
|
|
6
|
+
* are placed by text: each Humanus paragraph is matched, in document order, to
|
|
7
7
|
* the rendered prose block with the same words, and each finding's plain
|
|
8
8
|
* excerpt is located inside that block. Whitespace is ignored while matching,
|
|
9
|
-
* because rendering and
|
|
9
|
+
* because rendering and Humanus collapse it differently (citations, breaks).
|
|
10
10
|
*
|
|
11
11
|
* "Rendered text" is defined by `inlineLeaves`, which the preview renderer also
|
|
12
12
|
* uses, so offsets computed here are offsets the renderer can split on.
|
|
@@ -131,7 +131,7 @@ function blockKey(text: string): string {
|
|
|
131
131
|
return compact(text).value.replace(/[*_`~]/g, "");
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/** Match every
|
|
134
|
+
/** Match every Humanus paragraph to a rendered block and place its findings and sentences there. */
|
|
135
135
|
export function planPreview(
|
|
136
136
|
document: MarkusDocument,
|
|
137
137
|
result: EditorialCheckResult,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import { emailReferenceBatches } from "./monitoring-overview";
|
|
5
|
+
import type {
|
|
6
|
+
EmailReferenceImportItem,
|
|
7
|
+
EmailReferenceImportOutcome,
|
|
8
|
+
MonitoringEmailArticleResolution,
|
|
9
|
+
MonitoringEmailDetails,
|
|
10
|
+
MonitoringResult,
|
|
11
|
+
} from "./monitoring-overview";
|
|
12
|
+
|
|
13
|
+
type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences";
|
|
14
|
+
type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
|
|
15
|
+
|
|
16
|
+
async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
|
|
17
|
+
const client = generateClient({ authMode: "userPool" });
|
|
18
|
+
let result: GraphQLResult;
|
|
19
|
+
try {
|
|
20
|
+
result = (await client.graphql({ query: queryText, variables })) as GraphQLResult;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
|
|
23
|
+
}
|
|
24
|
+
const refused = result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`));
|
|
25
|
+
if (refused) return { error: "forbidden" };
|
|
26
|
+
const raw = result.data?.[field];
|
|
27
|
+
if (result.errors?.length || raw === undefined || raw === null) return { state: "unavailable" };
|
|
28
|
+
return (typeof raw === "string" ? JSON.parse(raw) : raw) as T;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Reads monitoring data only through the site's authenticated editor/admin API. */
|
|
32
|
+
export async function loadMonitoringOverview(scope: "publication" | "all"): Promise<MonitoringResult> {
|
|
33
|
+
return query<MonitoringResult>("monitoringOverview",
|
|
34
|
+
`query MonitoringOverview($scope: String) { monitoringOverview(scope: $scope) }`, { scope });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Loads one receipt's safe plain-text rendering and its already extracted candidate links. */
|
|
38
|
+
export async function loadMonitoringEmailSubmission(receiptId: string): Promise<MonitoringEmailDetails> {
|
|
39
|
+
return query<MonitoringEmailDetails>("monitoringEmailSubmission",
|
|
40
|
+
`query MonitoringEmailSubmission($receiptId: String!) { monitoringEmailSubmission(receiptId: $receiptId) }`, { receiptId });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve one explicitly selected Beehiiv article redirect; this may register a click with the sender. */
|
|
44
|
+
export async function resolveMonitoringEmailArticleLink(receiptId: string, candidateIndex: number): Promise<MonitoringEmailArticleResolution> {
|
|
45
|
+
return query<MonitoringEmailArticleResolution>("monitoringResolveEmailArticleLink",
|
|
46
|
+
`mutation MonitoringResolveEmailArticleLink($receiptId: String!, $candidateIndex: Int!) { monitoringResolveEmailArticleLink(receiptId: $receiptId, candidateIndex: $candidateIndex) }`,
|
|
47
|
+
{ receiptId, candidateIndex });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Use the existing editor-authorized Reference intake; 20 is its request batch size, not a total limit. */
|
|
51
|
+
export async function importMonitoringEmailCandidates(
|
|
52
|
+
receiptId: string,
|
|
53
|
+
items: EmailReferenceImportItem[],
|
|
54
|
+
): Promise<EmailReferenceImportOutcome> {
|
|
55
|
+
const client = generateClient({ authMode: "userPool" });
|
|
56
|
+
const references: EmailReferenceImportOutcome["references"] = [];
|
|
57
|
+
for (const batch of emailReferenceBatches(items)) {
|
|
58
|
+
let result: GraphQLResult;
|
|
59
|
+
try {
|
|
60
|
+
result = (await client.graphql({
|
|
61
|
+
query: `mutation IntakeEmailSubmissionReferences($input: AWSJSON!) { intakeEmailSubmissionReferences(input: $input) }`,
|
|
62
|
+
variables: { input: JSON.stringify({ receiptId, items: batch }) },
|
|
63
|
+
})) as GraphQLResult;
|
|
64
|
+
} catch {
|
|
65
|
+
return { imported: references.length, references, error: "The import stopped. Completed links are safe to retry." };
|
|
66
|
+
}
|
|
67
|
+
if (result.errors?.length) {
|
|
68
|
+
return { imported: references.length, references, error: "The import stopped. Completed links are safe to retry." };
|
|
69
|
+
}
|
|
70
|
+
const raw = result.data?.intakeEmailSubmissionReferences;
|
|
71
|
+
let response: { ok?: boolean; references?: EmailReferenceImportOutcome["references"] } | null | undefined;
|
|
72
|
+
try {
|
|
73
|
+
response = (typeof raw === "string" ? JSON.parse(raw) : raw) as typeof response;
|
|
74
|
+
} catch {
|
|
75
|
+
return { imported: references.length, references, error: "The import response could not be read. Completed links are safe to retry." };
|
|
76
|
+
}
|
|
77
|
+
if (!response?.ok || !Array.isArray(response.references)) {
|
|
78
|
+
return { imported: references.length, references, error: "The import was not accepted. Check your editor access and retry." };
|
|
79
|
+
}
|
|
80
|
+
references.push(...response.references);
|
|
81
|
+
}
|
|
82
|
+
return { imported: references.length, references };
|
|
83
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { MonitoringOverview } from "./monitoring-overview";
|
|
2
|
+
|
|
3
|
+
/** A realistic overview for tests and the `?demo=1` newsroom. Public Hacker News titles only. */
|
|
4
|
+
export const FIXTURE_MONITORING_OVERVIEW: MonitoringOverview = {
|
|
5
|
+
state: "ready",
|
|
6
|
+
asOf: "2026-10-21T02:40:00Z",
|
|
7
|
+
scope: { mode: "publication", publicationId: "desk-a", site: "a.example.test", canViewAll: true },
|
|
8
|
+
feeds: [{ id: "hn", sourceUrl: "https://news.ycombinator.com/rss", intervalSeconds: 900, limit: 30, enabled: true,
|
|
9
|
+
lastStatus: "complete", lastFinishedAt: "2026-10-21T02:33:00Z", nextDueAt: "2026-10-21T02:50:00Z" }],
|
|
10
|
+
runs: [
|
|
11
|
+
{ runId: "r3", startedAt: "2026-10-21T02:33:00.000000Z", status: "complete", new: 2, revised: 1, unchanged: 27, itemCount: 30, errorCount: 0, errors: [] },
|
|
12
|
+
{ runId: "r1", startedAt: "2026-10-21T02:18:00.000000Z", status: "complete", new: 0, revised: 0, unchanged: 30, itemCount: 30, errorCount: 0, errors: [] },
|
|
13
|
+
{ runId: "r0", startedAt: "2026-10-21T02:03:00.000000Z", status: "partial", new: 1, revised: 0, unchanged: 28, itemCount: 30, errorCount: 1, errors: ["ConnectionError: timeout"] },
|
|
14
|
+
],
|
|
15
|
+
routerRuns: [{ runId: "q2", startedAt: "2026-10-21T02:37:00.000000Z", mode: "live", status: "stopped", stoppedBy: "request_ceiling_reached",
|
|
16
|
+
counts: { examined: 3, decided: 1, delivered: 1, deliveryFailed: 1, routed: 1 }, errorCount: 0 }],
|
|
17
|
+
router: { mode: "live", feedback: true, enabled: true, maxItemsPerRun: 5 },
|
|
18
|
+
usage: { day: "2026-10-21", decisionsToday: 2, decisionsTotal: 3, historyComplete: true },
|
|
19
|
+
hiddenNotRelevant: 1,
|
|
20
|
+
items: [
|
|
21
|
+
{ sourceId: "104", revision: 1, title: "Fresh Show HN", url: "https://example.org/104", discussionUrl: "https://news.ycombinator.com/item?id=104",
|
|
22
|
+
observedAt: "2026-10-21T02:30:00.000000Z", routing: "collected", routingNote: null, decidedAt: null,
|
|
23
|
+
relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: null, label: null, confidence: null, delivery: { state: null } }] },
|
|
24
|
+
{ sourceId: "105", revision: 1, title: "Broken request", url: "https://example.org/105", discussionUrl: "https://news.ycombinator.com/item?id=105",
|
|
25
|
+
observedAt: "2026-10-21T01:30:00.000000Z", routing: "error", routingNote: "invalid request: bad title", decidedAt: null,
|
|
26
|
+
relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: null, label: null, confidence: null, delivery: { state: null } }] },
|
|
27
|
+
{ sourceId: "101", revision: 1, title: "Quantum paper on arXiv", url: "https://example.org/101", discussionUrl: "https://news.ycombinator.com/item?id=101",
|
|
28
|
+
observedAt: "2026-10-21T00:10:00.000000Z", routing: "routed", routingNote: null, decidedAt: "2026-10-21T01:00:00Z",
|
|
29
|
+
relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: true, label: "include", confidence: 0.91,
|
|
30
|
+
delivery: { state: "delivered", referenceId: "ref-1", referenceLineageId: "lin-1", deliveredAt: "2026-10-21T01:00:05Z" } }] },
|
|
31
|
+
{ sourceId: "103", revision: 1, title: "Phishing kit leaks", url: "https://example.org/103", discussionUrl: "https://news.ycombinator.com/item?id=103",
|
|
32
|
+
observedAt: "2026-10-20T23:00:00.000000Z", routing: "decided", routingNote: null, decidedAt: "2026-10-20T23:10:00Z",
|
|
33
|
+
relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: true, label: "include", confidence: 0.7,
|
|
34
|
+
delivery: { state: "failed", error: "ClientError: throttled", attempts: 2 } }] },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const FIXTURE_ALL_PIPELINE_OVERVIEW: MonitoringOverview = {
|
|
39
|
+
...FIXTURE_MONITORING_OVERVIEW,
|
|
40
|
+
scope: { mode: "all", publicationId: "desk-a", site: "a.example.test", canViewAll: true },
|
|
41
|
+
hiddenNotRelevant: 0,
|
|
42
|
+
items: FIXTURE_MONITORING_OVERVIEW.items.map((item) => item.sourceId !== "101" ? item : {
|
|
43
|
+
...item,
|
|
44
|
+
relevance: [
|
|
45
|
+
...item.relevance,
|
|
46
|
+
{ publicationId: "desk-b", site: "b.example.test", relevant: false, label: "exclude", confidence: 0.8, delivery: { state: "not_applicable" } },
|
|
47
|
+
{ publicationId: "desk-c", site: "desk-c", relevant: true, label: "include", confidence: null,
|
|
48
|
+
delivery: { state: "delivered", referenceId: "ref-2", referenceLineageId: "lin-2" } },
|
|
49
|
+
],
|
|
50
|
+
}),
|
|
51
|
+
};
|