@anthusai/papyrus 2.0.0-next.6 → 2.0.0-next.60
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/data/resource.js +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2028 -1979
- package/amplify/data/schema.ts +72 -14
- package/amplify/functions/assignment-action/data-access.js +90 -0
- package/amplify/functions/assignment-action/data-access.ts +89 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +61 -2
- 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 +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -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 +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -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 +235 -0
- package/amplify/plugins/newsletter/config.ts +294 -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 +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -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 +173 -0
- package/amplify/plugins/reader-steering/config.ts +268 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1363 -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/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +176 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +202 -0
- package/amplify/plugins/reader-steering/schema.js +140 -0
- package/amplify/plugins/reader-steering/schema.ts +142 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- 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.ts +17 -0
- package/amplify/site-backend.js +38 -1
- package/amplify/site-backend.ts +46 -1
- 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/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/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/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-article-editor.tsx +2 -4
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +15 -10
- 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 +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +64 -20
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- 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 +86 -6
- package/infra/site-config.ts +129 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsletter-api.ts +410 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- 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/relevance-status.ts +2 -2
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +41 -3
- package/routes.manifest.json +204 -2
- package/amplify/plugins/editorial-checks/function/handler.py +0 -11
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -50
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -63
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { EditorialCheckResult, EditorialFeedbackInput, EditorialFinding, RewriteResult, Span } from "./editorial-checks";
|
|
2
2
|
import type { EditorialSettings } from "./editorial-checks-settings";
|
|
3
3
|
|
|
4
|
-
/** What the article editor needs from editorial checks: the
|
|
4
|
+
/** What the article editor needs from editorial checks: the site's GraphQL API, or the demo. */
|
|
5
5
|
export type EditorialChecksBackend = {
|
|
6
6
|
loadSettings: () => Promise<{ settings: EditorialSettings; exists: boolean }>;
|
|
7
7
|
dismissedFindingIds: (itemId: string) => Promise<Set<string>>;
|
|
@@ -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") }`,
|
|
@@ -184,7 +202,7 @@ export async function recalibrate(): Promise<{ updated: number; detectors: { pol
|
|
|
184
202
|
return parsed(data.recalibrateEditorialPolicies);
|
|
185
203
|
}
|
|
186
204
|
|
|
187
|
-
/** The
|
|
205
|
+
/** The site's GraphQL API, signed in as the editor. */
|
|
188
206
|
export const graphqlEditorialBackend: EditorialChecksBackend = {
|
|
189
207
|
loadSettings,
|
|
190
208
|
dismissedFindingIds,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Demo-mode editorial checks: no backend, a few stock phrases, canned rewrites.
|
|
3
|
-
* The shapes match
|
|
4
|
-
* rewrite flow behave as they do against the real
|
|
3
|
+
* The shapes match Humanus results so the editor, the preview marks and the
|
|
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";
|
|
7
7
|
import type { EditorialChecksBackend } from "./editorial-checks-backend";
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Runtime settings for editorial checks, stored in EditorialCheckSettings (id "settings"). */
|
|
2
2
|
export type EditorialSettings = {
|
|
3
|
+
/** Core to every site and on by default; a publication turns its checks off here. */
|
|
4
|
+
enabled: boolean;
|
|
3
5
|
lanes: { rules: boolean; classifier: boolean };
|
|
4
6
|
classifierModel: string;
|
|
5
7
|
rewriteModel: string;
|
|
@@ -12,6 +14,7 @@ export type EditorialSettings = {
|
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
export const DEFAULT_EDITORIAL_SETTINGS: EditorialSettings = {
|
|
17
|
+
enabled: true,
|
|
15
18
|
lanes: { rules: true, classifier: true },
|
|
16
19
|
classifierModel: "jev-latest",
|
|
17
20
|
rewriteModel: "gpt-6-luna",
|
package/lib/editorial-checks.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
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
|
-
import { pluginSettings, type PluginOutputs } from "./plugin-settings";
|
|
7
|
-
|
|
8
|
-
export const EDITORIAL_CHECKS_PLUGIN = "editorial-checks";
|
|
9
6
|
|
|
10
7
|
export type Span = { start: number; end: number };
|
|
11
8
|
export type Severity = "info" | "warn" | "error";
|
|
@@ -41,7 +38,8 @@ export type EditorialSegment = {
|
|
|
41
38
|
text?: string;
|
|
42
39
|
};
|
|
43
40
|
|
|
44
|
-
|
|
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 };
|
|
45
43
|
|
|
46
44
|
export type EditorialCheckResult = {
|
|
47
45
|
schemaVersion: 1;
|
|
@@ -52,7 +50,8 @@ export type EditorialCheckResult = {
|
|
|
52
50
|
protected: { kind: string; start: number; end: number }[];
|
|
53
51
|
findings: EditorialFinding[];
|
|
54
52
|
coverage: unknown[];
|
|
55
|
-
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 };
|
|
56
55
|
usage?: Record<string, number>;
|
|
57
56
|
};
|
|
58
57
|
|
|
@@ -91,10 +90,6 @@ export const UNSAVED_ITEM_ID = "unsaved";
|
|
|
91
90
|
|
|
92
91
|
const SEVERITY_ORDER: Record<Severity, number> = { error: 0, warn: 1, info: 2 };
|
|
93
92
|
|
|
94
|
-
export function editorialChecksEnabled(outputs: PluginOutputs | null | undefined): boolean {
|
|
95
|
-
return pluginSettings(outputs, EDITORIAL_CHECKS_PLUGIN) !== null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
93
|
/** Findings worth showing: not dismissed, not covering the whole document, most severe first, then in reading order. */
|
|
99
94
|
export function visibleFindings(findings: EditorialFinding[], dismissed: ReadonlySet<string>): EditorialFinding[] {
|
|
100
95
|
return findings
|
|
@@ -171,8 +166,10 @@ export function describeProbability(finding: EditorialFinding): string | null {
|
|
|
171
166
|
|
|
172
167
|
export function laneNotice(result: EditorialCheckResult): string | null {
|
|
173
168
|
const classifier = result.lanes.classifier;
|
|
169
|
+
if (classifier.status === "skipped") return `Mission-based checks skipped: ${classifier.reason ?? "this publication has no mission yet"}.`;
|
|
174
170
|
if (classifier.status === "partial") return `Some sentences were not judged: ${classifier.reason ?? "the check ran out of time"}.`;
|
|
175
171
|
if (classifier.status === "failed") return `The judgment checks failed: ${classifier.reason ?? "unknown error"}. Rule checks still ran.`;
|
|
176
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"}.`;
|
|
177
174
|
return null;
|
|
178
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,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import type { MonitoringResult } from "./monitoring-overview";
|
|
5
|
+
|
|
6
|
+
type GraphQLResult = { data?: { monitoringOverview?: unknown }; errors?: { message?: string; errorType?: string }[] };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reads the monitoring overview through the site's own authenticated API (user pool, editor or admin).
|
|
10
|
+
* The browser holds no table name, key or secret: the function behind the query does the read.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadMonitoringOverview(scope: "publication" | "all"): Promise<MonitoringResult> {
|
|
13
|
+
const client = generateClient({ authMode: "userPool" });
|
|
14
|
+
let result: GraphQLResult;
|
|
15
|
+
try {
|
|
16
|
+
result = (await client.graphql({
|
|
17
|
+
query: `query MonitoringOverview($scope: String) { monitoringOverview(scope: $scope) }`,
|
|
18
|
+
variables: { scope },
|
|
19
|
+
})) as GraphQLResult;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// Amplify throws the response when AppSync returns errors.
|
|
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
|
+
if (result.errors?.length || result.data?.monitoringOverview === undefined || result.data.monitoringOverview === null) return { state: "unavailable" };
|
|
27
|
+
const raw = result.data.monitoringOverview;
|
|
28
|
+
return (typeof raw === "string" ? JSON.parse(raw) : raw) as MonitoringResult;
|
|
29
|
+
}
|
|
@@ -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, ceilings: { decisionsTotal: 300, decisionsPerDay: 100, items: 300 } },
|
|
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
|
+
};
|