@anthusai/papyrus 5.42.1 → 5.43.0-next.2
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/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/monitoring-view/schema.js +6 -0
- package/amplify/functions/monitoring-view/schema.ts +6 -0
- package/amplify/functions/monitoring-view/wire.js +1 -0
- package/amplify/functions/monitoring-view/wire.ts +1 -0
- package/amplify/functions/proposal-actions/handler.py +137 -0
- package/amplify/functions/proposal-actions/resource.js +15 -0
- package/amplify/functions/proposal-actions/resource.ts +22 -0
- package/amplify/functions/proposal-actions/schema.js +106 -0
- package/amplify/functions/proposal-actions/schema.ts +108 -0
- package/amplify/functions/proposal-actions/wire.js +10 -0
- package/amplify/functions/proposal-actions/wire.ts +13 -0
- package/amplify/plugins/reader-steering/function/handler.py +12 -69
- package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
- package/amplify/plugins/reader-steering/schema.js +0 -95
- package/amplify/plugins/reader-steering/schema.ts +0 -95
- package/amplify/site-backend.js +4 -0
- package/amplify/site-backend.ts +4 -0
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/globals.css +31 -35
- package/app/tailwind.css +2 -0
- package/components/monitoring-overview-view.tsx +12 -7
- package/components/monitoring-panel.tsx +33 -3
- package/components/newsroom-mobile-navigation.tsx +23 -0
- package/components/newsroom-ops-shell.tsx +8 -10
- package/components/newsroom-organizer.css +1 -0
- package/components/newsroom-references-view.tsx +141 -257
- package/components/notification-center.tsx +1 -1
- package/components/papyrus-console-frame.tsx +66 -0
- package/components/papyrus-console-shell.tsx +12 -41
- package/components/papyrus-private-chat-panel.tsx +50 -16
- package/components/proposals-panel.tsx +110 -0
- package/components/proposals-view.tsx +93 -0
- package/components/reference-review-card.tsx +383 -0
- package/components/topic-steering-workspace.tsx +13 -2
- package/components/use-console-visibility.ts +47 -0
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/monitoring-client.ts +11 -2
- package/lib/monitoring-overview-fixture.ts +2 -2
- package/lib/monitoring-overview.ts +1 -2
- package/lib/newsroom-chat-client.ts +30 -0
- package/lib/newsroom-chat-identity.ts +39 -0
- package/lib/papyrus-hosted-chat.ts +62 -80
- package/lib/papyrus-private-chat-client.ts +7 -4
- package/lib/proposals-admin.ts +115 -0
- package/lib/prospectus-session.ts +2 -0
- package/lib/prospectus-sign-in-routes.ts +19 -2
- package/lib/prospectus-site-identity.ts +7 -0
- package/lib/relevance-decisions.ts +9 -0
- package/lib/review-requests.ts +4 -2
- package/package.json +12 -1
- package/routes.manifest.json +12 -1
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.mjs +1 -1
- /package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +0 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CognitoJwtVerifier } from "aws-jwt-verify";
|
|
2
|
+
import type { JwksCache } from "aws-jwt-verify/jwk";
|
|
3
|
+
import type { NextRequest } from "next/server";
|
|
4
|
+
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
5
|
+
|
|
6
|
+
export type NewsroomChatIdentity = { subject: string; issuer: string; clientId: string; expiresAt: number };
|
|
7
|
+
export type NewsroomChatIdentityResolver = (request: NextRequest) => Promise<NewsroomChatIdentity | null>;
|
|
8
|
+
|
|
9
|
+
export function newsroomChatIdentityResolver(
|
|
10
|
+
auth: { user_pool_id: string; user_pool_client_id: string },
|
|
11
|
+
jwksCache?: JwksCache,
|
|
12
|
+
): NewsroomChatIdentityResolver {
|
|
13
|
+
const verifier = CognitoJwtVerifier.create({
|
|
14
|
+
userPoolId: auth.user_pool_id,
|
|
15
|
+
clientId: auth.user_pool_client_id,
|
|
16
|
+
tokenUse: "access",
|
|
17
|
+
groups: ["editor", "admin"],
|
|
18
|
+
}, jwksCache ? { jwksCache } : undefined);
|
|
19
|
+
return async (request) => {
|
|
20
|
+
const token = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1];
|
|
21
|
+
if (!token) return null;
|
|
22
|
+
try {
|
|
23
|
+
const claims = await verifier.verify(token);
|
|
24
|
+
return { subject: claims.sub, issuer: claims.iss, clientId: auth.user_pool_client_id, expiresAt: claims.exp };
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let configuredResolver: { key: string; resolve: NewsroomChatIdentityResolver } | undefined;
|
|
32
|
+
|
|
33
|
+
export async function resolveNewsroomChatIdentity(request: NextRequest): Promise<NewsroomChatIdentity | null> {
|
|
34
|
+
const auth = readAmplifyOutputsFile()?.auth as { user_pool_id?: string; user_pool_client_id?: string } | undefined;
|
|
35
|
+
if (!auth?.user_pool_id || !auth.user_pool_client_id) throw new Error("Newsroom identity configuration is unavailable");
|
|
36
|
+
const key = `${auth.user_pool_id}:${auth.user_pool_client_id}`;
|
|
37
|
+
if (configuredResolver?.key !== key) configuredResolver = { key, resolve: newsroomChatIdentityResolver({ user_pool_id: auth.user_pool_id, user_pool_client_id: auth.user_pool_client_id }) };
|
|
38
|
+
return configuredResolver.resolve(request);
|
|
39
|
+
}
|
|
@@ -1,51 +1,36 @@
|
|
|
1
|
+
import { resolveNewsroomChatIdentity, type NewsroomChatIdentityResolver } from "./newsroom-chat-identity";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import type { EmbeddedChatAppDependencies } from "chatticus/chat-server";
|
|
3
4
|
import type { NextRequest } from "next/server";
|
|
4
|
-
import {
|
|
5
|
-
ProspectusAccountsError,
|
|
6
|
-
authorizeSignedInPersonForAccount,
|
|
7
|
-
} from "./prospectus-site-identity";
|
|
8
|
-
import {
|
|
9
|
-
resolveProspectusSignInRuntime,
|
|
10
|
-
signedInProspectusSession,
|
|
11
|
-
type ProspectusSignInRuntime,
|
|
12
|
-
} from "./prospectus-session";
|
|
13
|
-
import { prospectusSiteProduct, publishingAccountsForSite } from "./prospectus-publication-access";
|
|
14
5
|
import { publicSiteOrigin } from "./site-origin-value";
|
|
15
6
|
|
|
16
7
|
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
17
8
|
|
|
18
9
|
export interface PapyrusChatScope {
|
|
19
|
-
siteProduct: string;
|
|
20
|
-
accountId: string;
|
|
21
|
-
personId: string;
|
|
22
10
|
tenantId: string;
|
|
23
11
|
editorId: string;
|
|
24
12
|
}
|
|
25
13
|
|
|
26
14
|
export interface PapyrusHostedChatOptions {
|
|
27
|
-
|
|
15
|
+
resolveNewsroomIdentity?: NewsroomChatIdentityResolver;
|
|
28
16
|
resolvePrivateDependencies?: () => Promise<EmbeddedChatAppDependencies | null>;
|
|
29
17
|
privateChatEnabled?: boolean;
|
|
30
|
-
siteProduct?: string | null;
|
|
31
18
|
environment?: Record<string, string | undefined>;
|
|
32
19
|
nowInSeconds?: () => number;
|
|
33
20
|
}
|
|
34
21
|
|
|
35
|
-
|
|
36
|
-
|
|
22
|
+
/** A new namespace: never aliases Account-scoped or legacy transcripts. */
|
|
23
|
+
export function derivePapyrusChatScope(publicationNamespace: string, issuer: string, subject: string): PapyrusChatScope {
|
|
24
|
+
const tenantHash = createHash("sha256").update(JSON.stringify(["newsroom-v1", publicationNamespace, issuer])).digest("hex");
|
|
25
|
+
const editorHash = createHash("sha256").update(JSON.stringify(["newsroom-v1", publicationNamespace, issuer, subject])).digest("hex");
|
|
26
|
+
return { tenantId: `papyrus-newsroom-v1-${tenantHash.slice(0, 40)}`, editorId: `papyrus-newsroom-editor-v1-${editorHash.slice(0, 40)}` };
|
|
37
27
|
}
|
|
38
28
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
personId,
|
|
45
|
-
accountId,
|
|
46
|
-
tenantId: `papyrus-${tenantHash.slice(0, 40)}`,
|
|
47
|
-
editorId: `papyrus-editor-${editorHash.slice(0, 40)}`,
|
|
48
|
-
};
|
|
29
|
+
/** Existing server storage resources bind the publication, independently of request hosts or claims. */
|
|
30
|
+
function publicationNamespace(environment: Record<string, string | undefined>, clientId: string): string | null {
|
|
31
|
+
const bucket = environment.PAPYRUS_CHAT_SESSION_BUCKET?.trim();
|
|
32
|
+
const table = environment.PAPYRUS_CHAT_MESSAGING_TABLE?.trim();
|
|
33
|
+
return bucket && table ? JSON.stringify([bucket, table, clientId]) : null;
|
|
49
34
|
}
|
|
50
35
|
|
|
51
36
|
type ResolvedPapyrusPrincipal = {
|
|
@@ -59,60 +44,24 @@ type ResolvedPapyrusPrincipal = {
|
|
|
59
44
|
|
|
60
45
|
async function principalForRequest(
|
|
61
46
|
request: NextRequest,
|
|
62
|
-
operation: "console:read" | "console:write",
|
|
63
47
|
options: PapyrusHostedChatOptions,
|
|
64
|
-
): Promise<{ principal: ResolvedPapyrusPrincipal | null; status: number }> {
|
|
65
|
-
|
|
66
|
-
const siteProduct = options.siteProduct !== undefined ? options.siteProduct : prospectusSiteProduct(environment);
|
|
67
|
-
if (!siteProduct) return { principal: null, status: 503 };
|
|
68
|
-
|
|
69
|
-
let runtime: ProspectusSignInRuntime | null;
|
|
48
|
+
): Promise<{ principal: ResolvedPapyrusPrincipal | null; status: number; expiresAt?: number }> {
|
|
49
|
+
let identity;
|
|
70
50
|
try {
|
|
71
|
-
|
|
51
|
+
identity = await (options.resolveNewsroomIdentity ?? resolveNewsroomChatIdentity)(request);
|
|
72
52
|
} catch {
|
|
73
53
|
return { principal: null, status: 503 };
|
|
74
54
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (directory.person?.id !== session.personId) return { principal: null, status: 401 };
|
|
86
|
-
if (!publishingAccountsForSite(directory.accounts, siteProduct).some((account) => account.accountId === session.selectedAccountId)) {
|
|
87
|
-
return { principal: null, status: 403 };
|
|
88
|
-
}
|
|
89
|
-
const authorization = await authorizeSignedInPersonForAccount(
|
|
90
|
-
runtime.accounts,
|
|
91
|
-
session.accessToken,
|
|
92
|
-
session.selectedAccountId,
|
|
93
|
-
operation,
|
|
94
|
-
);
|
|
95
|
-
if (authorization.personId !== session.personId || authorization.accountId !== session.selectedAccountId || authorization.operation !== operation) {
|
|
96
|
-
return { principal: null, status: 403 };
|
|
97
|
-
}
|
|
98
|
-
const scope = derivePapyrusChatScope(siteProduct, session.selectedAccountId, session.personId);
|
|
99
|
-
return {
|
|
100
|
-
principal: {
|
|
101
|
-
kind: "user",
|
|
102
|
-
tenantId: scope.tenantId,
|
|
103
|
-
userId: scope.editorId,
|
|
104
|
-
workerId: null,
|
|
105
|
-
organizationStatus: "enabled",
|
|
106
|
-
role: "member",
|
|
107
|
-
},
|
|
108
|
-
status: 200,
|
|
109
|
-
};
|
|
110
|
-
} catch (error) {
|
|
111
|
-
if (error instanceof ProspectusAccountsError && (error.status === 401 || error.status === 403)) {
|
|
112
|
-
return { principal: null, status: error.status };
|
|
113
|
-
}
|
|
114
|
-
return { principal: null, status: 503 };
|
|
115
|
-
}
|
|
55
|
+
const now = options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
56
|
+
if (!identity || !identity.subject || !identity.issuer || !identity.clientId || !Number.isFinite(identity.expiresAt) || identity.expiresAt <= now) return { principal: null, status: 401 };
|
|
57
|
+
const namespace = publicationNamespace(options.environment ?? process.env, identity.clientId);
|
|
58
|
+
if (!namespace) return { principal: null, status: 503 };
|
|
59
|
+
const scope = derivePapyrusChatScope(namespace, identity.issuer, identity.subject);
|
|
60
|
+
return {
|
|
61
|
+
principal: { kind: "user", tenantId: scope.tenantId, userId: scope.editorId, workerId: null, organizationStatus: "enabled", role: "member" },
|
|
62
|
+
status: 200,
|
|
63
|
+
expiresAt: identity.expiresAt,
|
|
64
|
+
};
|
|
116
65
|
}
|
|
117
66
|
|
|
118
67
|
function jsonFailure(status: number, error: string): Response {
|
|
@@ -162,6 +111,9 @@ export async function handlePapyrusHostedChatRequest(
|
|
|
162
111
|
const privateChatEnabled = options.privateChatEnabled ?? environment.PAPYRUS_PRIVATE_CHAT_ENABLED === "true";
|
|
163
112
|
if (!privateChatEnabled) return jsonFailure(404, "private_chat_disabled");
|
|
164
113
|
|
|
114
|
+
const authorization = await principalForRequest(request, options);
|
|
115
|
+
if (!authorization.principal) return jsonFailure(authorization.status, (authorization.status === 401 ? "sign_in_required" : authorization.status === 403 ? "chat_account_access_denied" : "chat_runtime_unavailable"));
|
|
116
|
+
|
|
165
117
|
let dependencies: EmbeddedChatAppDependencies | null;
|
|
166
118
|
try {
|
|
167
119
|
if (options.resolvePrivateDependencies) {
|
|
@@ -178,9 +130,6 @@ export async function handlePapyrusHostedChatRequest(
|
|
|
178
130
|
}
|
|
179
131
|
if (!dependencies) return jsonFailure(503, "private_chat_storage_unavailable");
|
|
180
132
|
|
|
181
|
-
const authorization = await principalForRequest(request, request.method === "POST" ? "console:write" : "console:read", options);
|
|
182
|
-
if (!authorization.principal) return jsonFailure(authorization.status, authorization.status === 401 ? "sign_in_required" : authorization.status === 403 ? "chat_account_access_denied" : "chat_runtime_unavailable");
|
|
183
|
-
|
|
184
133
|
const headers = new Headers({ accept: request.headers.get("accept") ?? "application/json" });
|
|
185
134
|
let body: string | undefined;
|
|
186
135
|
if (request.method === "POST" && isMessageRoute) {
|
|
@@ -213,5 +162,38 @@ export async function handlePapyrusHostedChatRequest(
|
|
|
213
162
|
...dependencies,
|
|
214
163
|
resolvePrincipal: async () => authorization.principal,
|
|
215
164
|
});
|
|
216
|
-
|
|
165
|
+
const response = await app.fetch(forwarded);
|
|
166
|
+
if (path[0] !== "turns" || path[2] !== "stream" || !response.ok || !response.body) return response;
|
|
167
|
+
const remaining = (authorization.expiresAt! - (options.nowInSeconds?.() ?? Date.now() / 1000)) * 1000;
|
|
168
|
+
return expireChatStream(response, remaining);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** End an established stream at JWT expiry; reconnect must pass current authorization again. */
|
|
172
|
+
export function expireChatStream(response: Response, remainingMilliseconds: number): Response {
|
|
173
|
+
if (!response.body) return response;
|
|
174
|
+
const reader = response.body.getReader();
|
|
175
|
+
let closed = false;
|
|
176
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
177
|
+
const body = new ReadableStream<Uint8Array>({
|
|
178
|
+
start(controller) {
|
|
179
|
+
timer = setTimeout(() => {
|
|
180
|
+
if (closed) return;
|
|
181
|
+
closed = true;
|
|
182
|
+
controller.close();
|
|
183
|
+
void reader.cancel().catch(() => {});
|
|
184
|
+
}, Math.max(0, Math.min(remainingMilliseconds, 2_147_483_647)));
|
|
185
|
+
},
|
|
186
|
+
async pull(controller) {
|
|
187
|
+
try {
|
|
188
|
+
const next = await reader.read();
|
|
189
|
+
if (closed) return;
|
|
190
|
+
if (next.done) { closed = true; clearTimeout(timer); controller.close(); }
|
|
191
|
+
else controller.enqueue(next.value);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (!closed) { closed = true; clearTimeout(timer); controller.error(error); }
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
async cancel(reason) { closed = true; clearTimeout(timer); await reader.cancel(reason); },
|
|
197
|
+
});
|
|
198
|
+
return new Response(body, { status: response.status, headers: response.headers });
|
|
217
199
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { newsroomChatFetch } from "./newsroom-chat-client";
|
|
1
2
|
export type PapyrusPrivateChatChannel = {
|
|
2
3
|
channel_id: string;
|
|
3
4
|
participants: Array<{ kind: string; actor_id: string }>;
|
|
@@ -30,6 +31,8 @@ async function requestJson<T>(path: string, init: RequestInit, fetcher: PapyrusP
|
|
|
30
31
|
});
|
|
31
32
|
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
|
|
32
33
|
if (!response.ok) {
|
|
34
|
+
if (response.status === 401) throw new Error("sign_in_required");
|
|
35
|
+
if (response.status === 403) throw new Error("chat_account_access_denied");
|
|
33
36
|
const error = typeof payload?.error === "string" ? payload.error
|
|
34
37
|
: typeof payload?.detail === "string" ? payload.detail
|
|
35
38
|
: `Private chat request failed (${response.status}).`;
|
|
@@ -39,8 +42,8 @@ async function requestJson<T>(path: string, init: RequestInit, fetcher: PapyrusP
|
|
|
39
42
|
return payload as T;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
/** Open the current editor's server-derived private channel using the
|
|
43
|
-
export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch =
|
|
45
|
+
/** Open the current editor's server-derived private channel using the verified publication-local newsroom identity. */
|
|
46
|
+
export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch = newsroomChatFetch): Promise<PapyrusPrivateChatChannel> {
|
|
44
47
|
const result = await requestJson<PapyrusPrivateChatChannel>("private-channel", { method: "POST" }, fetcher);
|
|
45
48
|
if (!result.channel_id?.trim() || !Array.isArray(result.participants)) {
|
|
46
49
|
throw new Error("Private chat returned an invalid channel.");
|
|
@@ -51,7 +54,7 @@ export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch =
|
|
|
51
54
|
/** Read only the private channel returned by the host's authenticated open request. */
|
|
52
55
|
export async function listPapyrusPrivateChatMessages(
|
|
53
56
|
channelId: string,
|
|
54
|
-
fetcher: PapyrusPrivateChatFetch =
|
|
57
|
+
fetcher: PapyrusPrivateChatFetch = newsroomChatFetch,
|
|
55
58
|
): Promise<PapyrusPrivateChatMessage[]> {
|
|
56
59
|
if (!channelId.trim()) throw new Error("A private chat channel is required.");
|
|
57
60
|
const result = await requestJson<{ messages?: unknown }>(`channels/${encodeURIComponent(channelId)}/messages`, { method: "GET" }, fetcher);
|
|
@@ -64,7 +67,7 @@ export async function sendPapyrusPrivateChatMessage(
|
|
|
64
67
|
channelId: string,
|
|
65
68
|
body: string,
|
|
66
69
|
idempotencyKey: string,
|
|
67
|
-
fetcher: PapyrusPrivateChatFetch =
|
|
70
|
+
fetcher: PapyrusPrivateChatFetch = newsroomChatFetch,
|
|
68
71
|
): Promise<PapyrusPrivateChatSendResult> {
|
|
69
72
|
if (!channelId.trim() || !body.trim() || !idempotencyKey.trim()) {
|
|
70
73
|
throw new Error("A channel, message and idempotency key are required.");
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data side of the Administration "Proposals" panel, which every site has (the queue is core; the reader-steering
|
|
3
|
+
* plugin only adds reader votes). Rows come from the staff-readable `Proposal` model; an editor moves one with the
|
|
4
|
+
* `transitionProposal` mutation. Pure functions, no network, so the rules the panel shows can be tested.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type ProposalRow = {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: string;
|
|
10
|
+
title: string;
|
|
11
|
+
summary?: string | null;
|
|
12
|
+
body?: string | null;
|
|
13
|
+
state: string;
|
|
14
|
+
proposer?: string | null;
|
|
15
|
+
held?: boolean | null;
|
|
16
|
+
pinned?: boolean | null;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ProposalFilter = "open" | "draft" | "in_steering" | "editor_review" | "decided";
|
|
21
|
+
/** A button: the states it moves through in order (one call each) or, for pin and hold, the `editProposal` action it runs. */
|
|
22
|
+
export type ProposalMove = { label: string; path?: string[]; edit?: "pin" | "unpin" | "hold" | "release"; state?: string };
|
|
23
|
+
|
|
24
|
+
const STATE_LABELS: Record<string, string> = {
|
|
25
|
+
draft: "Draft",
|
|
26
|
+
in_steering: "In steering",
|
|
27
|
+
editor_review: "Editor review",
|
|
28
|
+
approved: "Approved",
|
|
29
|
+
declined: "Declined",
|
|
30
|
+
overridden_by_editor: "Decided by an editor",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const KIND_LABELS: Record<string, string> = {
|
|
34
|
+
story_draft: "Story draft",
|
|
35
|
+
new_concept: "New concept",
|
|
36
|
+
rename: "Rename",
|
|
37
|
+
merge: "Merge",
|
|
38
|
+
split: "Split",
|
|
39
|
+
new_column: "New column",
|
|
40
|
+
element_symbol: "Element symbol",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const DECIDED = new Set(["approved", "declined", "overridden_by_editor"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What an editor may do next; the function enforces the same table (overriding the community needs a reason and is not offered
|
|
47
|
+
* here). On a site without reader-steering nobody steers, so a draft goes straight to editor review (two moves) and the
|
|
48
|
+
* "in steering" wording never shows.
|
|
49
|
+
*/
|
|
50
|
+
function stateMoves(state: string, readers: boolean): ProposalMove[] {
|
|
51
|
+
if (state === "draft") {
|
|
52
|
+
return readers
|
|
53
|
+
? [{ label: "Send to steering", path: ["in_steering"] }, { label: "Decline", path: ["declined"] }]
|
|
54
|
+
: [{ label: "Move to editor review", path: ["in_steering", "editor_review"] }, { label: "Decline", path: ["declined"] }];
|
|
55
|
+
}
|
|
56
|
+
if (state === "in_steering") return [{ label: "Move to editor review", path: ["editor_review"] }];
|
|
57
|
+
if (state === "editor_review") return [{ label: "Approve", path: ["approved"] }, { label: "Decline", path: ["declined"] }];
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const PROPOSAL_FIELDS = "id kind title summary body state proposer held pinned createdAt";
|
|
62
|
+
export const LIST_PROPOSALS = `query Proposals($nextToken: String) { listProposals(limit: 500, nextToken: $nextToken) { items { ${PROPOSAL_FIELDS} } nextToken } }`;
|
|
63
|
+
export const EDIT_PROPOSAL = `mutation Edit($proposalId: String!, $action: String!) { editProposal(proposalId: $proposalId, action: $action) { status id error message } }`;
|
|
64
|
+
export const TRANSITION_PROPOSAL = `mutation Move($proposalId: String!, $state: String!, $reason: String) { transitionProposal(proposalId: $proposalId, state: $state, reason: $reason) { status id error message } }`;
|
|
65
|
+
|
|
66
|
+
export function stateLabel(state: string, readers = true): string {
|
|
67
|
+
if (!readers && state === "in_steering") return "Waiting for review";
|
|
68
|
+
return STATE_LABELS[state] ?? state;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function kindLabel(kind: string): string {
|
|
72
|
+
return KIND_LABELS[kind] ?? kind;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** AI-filed proposals say "AI"; a reader's proposal says "Reader" (their id is never shown). */
|
|
76
|
+
export function proposerLabel(proposer: string | null | undefined): string {
|
|
77
|
+
return proposer === "ai" ? "AI" : proposer ? "Reader" : "Unknown";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function proposalMoves(row: Pick<ProposalRow, "state" | "held" | "pinned">, readers = true): ProposalMove[] {
|
|
81
|
+
const edits: ProposalMove[] = [
|
|
82
|
+
{ label: row.pinned ? "Unpin" : "Pin", edit: row.pinned ? "unpin" : "pin" },
|
|
83
|
+
...(DECIDED.has(row.state) && !row.held ? [] : [{ label: row.held ? "Release hold" : "Hold", edit: (row.held ? "release" : "hold") as "release" | "hold" }]),
|
|
84
|
+
];
|
|
85
|
+
// A proposal on hold refuses every state change until it is released.
|
|
86
|
+
return [...(row.held ? [] : stateMoves(row.state, readers)), ...edits];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function sortProposals(rows: ProposalRow[]): ProposalRow[] {
|
|
90
|
+
return [...rows].sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function filterProposals(rows: ProposalRow[], filter: ProposalFilter): ProposalRow[] {
|
|
94
|
+
if (filter === "open") return rows.filter((row) => !DECIDED.has(row.state));
|
|
95
|
+
if (filter === "decided") return rows.filter((row) => DECIDED.has(row.state));
|
|
96
|
+
return rows.filter((row) => row.state === filter);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function countProposals(rows: ProposalRow[]): Record<ProposalFilter, number> {
|
|
100
|
+
return {
|
|
101
|
+
open: filterProposals(rows, "open").length,
|
|
102
|
+
draft: filterProposals(rows, "draft").length,
|
|
103
|
+
in_steering: filterProposals(rows, "in_steering").length,
|
|
104
|
+
editor_review: filterProposals(rows, "editor_review").length,
|
|
105
|
+
decided: filterProposals(rows, "decided").length,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type ProposalReceipt = { status?: string | null; id?: string | null; error?: string | null; message?: string | null };
|
|
110
|
+
|
|
111
|
+
export function proposalReceiptMessage(receipt: ProposalReceipt | null | undefined): string {
|
|
112
|
+
if (!receipt) return "The action did not answer.";
|
|
113
|
+
if (receipt.status === "ok") return "Done.";
|
|
114
|
+
return receipt.message || receipt.error || "The action was refused.";
|
|
115
|
+
}
|
|
@@ -33,6 +33,7 @@ const MINIMUM_SEALING_SECRET_LENGTH = 32;
|
|
|
33
33
|
|
|
34
34
|
export interface PendingProspectusSignInCookie extends PendingProspectusSignIn {
|
|
35
35
|
returnPath: string;
|
|
36
|
+
newsroomSubject?: string;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export interface ProspectusSignedInSession {
|
|
@@ -40,6 +41,7 @@ export interface ProspectusSignedInSession {
|
|
|
40
41
|
accessToken: string;
|
|
41
42
|
accessTokenExpiresAt: number;
|
|
42
43
|
selectedAccountId?: string;
|
|
44
|
+
newsroomSubject?: string;
|
|
43
45
|
siteSubject?: string;
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveNewsroomChatIdentity, type NewsroomChatIdentityResolver } from "./newsroom-chat-identity";
|
|
1
2
|
import { NextResponse, type NextRequest } from "next/server";
|
|
2
3
|
import {
|
|
3
4
|
CYCLOTRON_LAB_PRODUCT,
|
|
@@ -36,6 +37,7 @@ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfigured
|
|
|
36
37
|
* unregistered deployment never half-signs anyone in.
|
|
37
38
|
*/
|
|
38
39
|
export interface ProspectusSignInRouteOptions {
|
|
40
|
+
resolveNewsroomIdentity?: NewsroomChatIdentityResolver;
|
|
39
41
|
resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
|
|
40
42
|
siteProduct?: string | null;
|
|
41
43
|
completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
|
|
@@ -137,12 +139,25 @@ export function handleProspectusSignInStart(request: NextRequest, options: Prosp
|
|
|
137
139
|
async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
138
140
|
const runtime = await signInRuntimeFor(request, options);
|
|
139
141
|
if (!runtime) return signInUnavailableResponse();
|
|
142
|
+
const newsroomConnection = request.nextUrl.searchParams.get("newsroomChat") === "true";
|
|
143
|
+
let newsroomSubject: string | undefined;
|
|
144
|
+
if (newsroomConnection) {
|
|
145
|
+
if (request.method !== "POST" || request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
146
|
+
return signInFailedResponse(403, "same_origin_required", "Connect chat from this newsroom.");
|
|
147
|
+
}
|
|
148
|
+
const identity = await (options.resolveNewsroomIdentity ?? resolveNewsroomChatIdentity)(request).catch(() => null);
|
|
149
|
+
if (!identity) return signInFailedResponse(401, "sign_in_required", "An authenticated newsroom editor is required.");
|
|
150
|
+
newsroomSubject = identity.subject;
|
|
151
|
+
}
|
|
140
152
|
const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
|
|
141
153
|
const pendingSignInCookie: PendingProspectusSignInCookie = {
|
|
142
154
|
...pendingSignIn,
|
|
143
|
-
|
|
155
|
+
...(newsroomSubject ? { newsroomSubject } : {}),
|
|
156
|
+
returnPath: sameSiteReturnPath(newsroomConnection ? request.nextUrl.searchParams.get("returnTo") : await requestedReturnPath(request)),
|
|
144
157
|
};
|
|
145
|
-
const response =
|
|
158
|
+
const response = newsroomConnection
|
|
159
|
+
? NextResponse.json({ authorizationUrl }, { headers: NO_STORE_HEADERS })
|
|
160
|
+
: NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
|
|
146
161
|
response.cookies.set(
|
|
147
162
|
PROSPECTUS_PENDING_SIGN_IN_COOKIE,
|
|
148
163
|
sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_PENDING_SIGN_IN_COOKIE, pendingSignInCookie, pendingSignIn.expiresAt),
|
|
@@ -194,11 +209,13 @@ async function completeCallback(
|
|
|
194
209
|
pendingSignIn,
|
|
195
210
|
returnedState,
|
|
196
211
|
authorizationCode,
|
|
212
|
+
expectedNewsroomSubject: pendingSignIn.newsroomSubject,
|
|
197
213
|
nowInSeconds,
|
|
198
214
|
...options.completionOverrides,
|
|
199
215
|
});
|
|
200
216
|
const session: ProspectusSignedInSession = {
|
|
201
217
|
personId: signedIn.person.id,
|
|
218
|
+
...(pendingSignIn.newsroomSubject ? { newsroomSubject: pendingSignIn.newsroomSubject } : {}),
|
|
202
219
|
accessToken: signedIn.accessToken,
|
|
203
220
|
accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
|
|
204
221
|
...(signedIn.person.siteSubject ? { siteSubject: signedIn.person.siteSubject } : {}),
|
|
@@ -281,6 +281,7 @@ export interface CompleteProspectusGoogleSignInInput {
|
|
|
281
281
|
pendingSignIn: PendingProspectusSignIn;
|
|
282
282
|
returnedState: string;
|
|
283
283
|
authorizationCode: string;
|
|
284
|
+
expectedNewsroomSubject?: string;
|
|
284
285
|
verifyCognitoTokens?: CognitoTokenVerifier;
|
|
285
286
|
fetcher?: typeof fetch;
|
|
286
287
|
nowInSeconds?: number;
|
|
@@ -320,6 +321,12 @@ export async function completeProspectusGoogleSignIn(
|
|
|
320
321
|
expiresInSeconds: Number(tokenBody.expires_in) || 0,
|
|
321
322
|
};
|
|
322
323
|
await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
|
|
324
|
+
if (input.expectedNewsroomSubject) {
|
|
325
|
+
const claims = JSON.parse(Buffer.from(tokens.accessToken.split(".")[1], "base64url").toString("utf8")) as { sub?: string };
|
|
326
|
+
if (claims.sub !== input.expectedNewsroomSubject) {
|
|
327
|
+
throw new ProspectusAccountsError(403, "newsroom_identity_mismatch", "Continue with the same Google account as your newsroom session.");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
323
330
|
const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn, tokens.accessToken, tokens.idToken);
|
|
324
331
|
return {
|
|
325
332
|
person,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SemanticRelationRecord } from "./category-repository";
|
|
2
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
2
3
|
|
|
3
4
|
export const RELEVANCE_RELATION = "relevance_decision_is";
|
|
4
5
|
|
|
@@ -162,3 +163,11 @@ export function curationForRelevanceReview(decision: RelevanceDecisionView, revi
|
|
|
162
163
|
shareable: review.shareable,
|
|
163
164
|
};
|
|
164
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A pending reference with a current cyclotron suggestion is waiting for an editor's answer,
|
|
169
|
+
* whether or not the cyclotron flagged the decision itself for review.
|
|
170
|
+
*/
|
|
171
|
+
export function awaitsEditorReview(curationStatus: unknown, decision: RelevanceDecisionView | undefined): boolean {
|
|
172
|
+
return Boolean(decision) && normalizeReferenceCurationStatus(curationStatus) === "pending";
|
|
173
|
+
}
|
package/lib/review-requests.ts
CHANGED
|
@@ -121,7 +121,7 @@ export function modeMark(mode: ReviewMode): string {
|
|
|
121
121
|
export function nextText(request: ReviewRequest): string {
|
|
122
122
|
if (request.state === "open") {
|
|
123
123
|
if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you approve or reject.";
|
|
124
|
-
if (request.mode === "soft_review") return `Proceeds automatically on ${formatWhen(request.deadline)} unless you steer it
|
|
124
|
+
if (request.mode === "soft_review") return request.deadline ? `Proceeds automatically on ${formatWhen(request.deadline)} unless you steer it.` : "Stays open until someone handles it. It never proceeds or expires on its own.";
|
|
125
125
|
return "Nothing is waiting on this. It stays here until you acknowledge it.";
|
|
126
126
|
}
|
|
127
127
|
if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
|
|
@@ -219,9 +219,11 @@ export type NotificationSections = {
|
|
|
219
219
|
export function sectionReviews(requests: ReviewRequest[]): NotificationSections {
|
|
220
220
|
const open = requests.filter((request) => request.state === "open");
|
|
221
221
|
const byRaised = (a: ReviewRequest, b: ReviewRequest) => a.raisedAt.localeCompare(b.raisedAt);
|
|
222
|
+
// Requests with a deadline first, soonest first; requests with no deadline after them, oldest first.
|
|
223
|
+
const bySoonestDeadline = (a: ReviewRequest, b: ReviewRequest) => (a.deadline && b.deadline ? a.deadline.localeCompare(b.deadline) : a.deadline ? -1 : b.deadline ? 1 : byRaised(a, b));
|
|
222
224
|
return {
|
|
223
225
|
hardGates: open.filter((request) => request.mode === "hard_gate").sort(byRaised),
|
|
224
|
-
softReviews: open.filter((request) => request.mode === "soft_review").sort(
|
|
226
|
+
softReviews: open.filter((request) => request.mode === "soft_review").sort(bySoonestDeadline),
|
|
225
227
|
flags: open.filter((request) => request.mode === "flag").sort((a, b) => b.raisedAt.localeCompare(a.raisedAt)),
|
|
226
228
|
decided: requests.filter((request) => request.state !== "open").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
|
227
229
|
};
|
package/package.json
CHANGED
|
@@ -106,6 +106,7 @@
|
|
|
106
106
|
"./components/newsroom-client-shell": "./components/newsroom-client-shell.tsx",
|
|
107
107
|
"./components/newsroom-curation-bootstrap": "./components/newsroom-curation-bootstrap.tsx",
|
|
108
108
|
"./components/newsroom-curation-provider": "./components/newsroom-curation-provider.tsx",
|
|
109
|
+
"./components/newsroom-mobile-navigation": "./components/newsroom-mobile-navigation.tsx",
|
|
109
110
|
"./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
|
|
110
111
|
"./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
|
|
111
112
|
"./components/newsroom-overview-skeleton": "./components/newsroom-overview-skeleton.tsx",
|
|
@@ -118,6 +119,7 @@
|
|
|
118
119
|
"./components/newsroom-topics-view": "./components/newsroom-topics-view.tsx",
|
|
119
120
|
"./components/notification-center": "./components/notification-center.tsx",
|
|
120
121
|
"./components/papyrus-console-conversation": "./components/papyrus-console-conversation.tsx",
|
|
122
|
+
"./components/papyrus-console-frame": "./components/papyrus-console-frame.tsx",
|
|
121
123
|
"./components/papyrus-console-shell": "./components/papyrus-console-shell.tsx",
|
|
122
124
|
"./components/papyrus-private-chat-panel": "./components/papyrus-private-chat-panel.tsx",
|
|
123
125
|
"./components/pictogram-figure": "./components/pictogram-figure.tsx",
|
|
@@ -125,6 +127,8 @@
|
|
|
125
127
|
"./components/presentation-header": "./components/presentation-header.tsx",
|
|
126
128
|
"./components/presentation-rhythm-hrule": "./components/presentation-rhythm-hrule.tsx",
|
|
127
129
|
"./components/priming-proof-page": "./components/priming-proof-page.tsx",
|
|
130
|
+
"./components/proposals-panel": "./components/proposals-panel.tsx",
|
|
131
|
+
"./components/proposals-view": "./components/proposals-view.tsx",
|
|
128
132
|
"./components/prospectus-account-chooser": "./components/prospectus-account-chooser.tsx",
|
|
129
133
|
"./components/publication-cyclotron-panel": "./components/publication-cyclotron-panel.tsx",
|
|
130
134
|
"./components/reader-auth-control": "./components/reader-auth-control.tsx",
|
|
@@ -139,6 +143,7 @@
|
|
|
139
143
|
"./components/reference-live-agreement": "./components/reference-live-agreement.tsx",
|
|
140
144
|
"./components/reference-priming": "./components/reference-priming.tsx",
|
|
141
145
|
"./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
|
|
146
|
+
"./components/reference-review-card": "./components/reference-review-card.tsx",
|
|
142
147
|
"./components/reference-section-suggestion": "./components/reference-section-suggestion.tsx",
|
|
143
148
|
"./components/reference-source-preview": "./components/reference-source-preview.tsx",
|
|
144
149
|
"./components/settings-page": "./components/settings-page.tsx",
|
|
@@ -167,6 +172,7 @@
|
|
|
167
172
|
"./components/ui/tooltip": "./components/ui/tooltip.tsx",
|
|
168
173
|
"./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
|
|
169
174
|
"./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
|
|
175
|
+
"./components/use-console-visibility": "./components/use-console-visibility.ts",
|
|
170
176
|
"./components/use-editorial-checks": "./components/use-editorial-checks.ts",
|
|
171
177
|
"./components/use-near-viewport": "./components/use-near-viewport.ts",
|
|
172
178
|
"./components/use-paged-content-item-versions": "./components/use-paged-content-item-versions.ts",
|
|
@@ -194,6 +200,7 @@
|
|
|
194
200
|
"./lib/category-dashboard": "./lib/category-dashboard.ts",
|
|
195
201
|
"./lib/category-repository": "./lib/category-repository.ts",
|
|
196
202
|
"./lib/chrome-text": "./lib/chrome-text.ts",
|
|
203
|
+
"./lib/cli-login-discovery": "./lib/cli-login-discovery.ts",
|
|
197
204
|
"./lib/console-chat-client": "./lib/console-chat-client.ts",
|
|
198
205
|
"./lib/console-web-context": "./lib/console-web-context.ts",
|
|
199
206
|
"./lib/content-actions-client": "./lib/content-actions-client.ts",
|
|
@@ -257,6 +264,8 @@
|
|
|
257
264
|
"./lib/newsroom-board": "./lib/newsroom-board.ts",
|
|
258
265
|
"./lib/newsroom-card-layout": "./lib/newsroom-card-layout.ts",
|
|
259
266
|
"./lib/newsroom-category-drilldown": "./lib/newsroom-category-drilldown.ts",
|
|
267
|
+
"./lib/newsroom-chat-client": "./lib/newsroom-chat-client.ts",
|
|
268
|
+
"./lib/newsroom-chat-identity": "./lib/newsroom-chat-identity.ts",
|
|
260
269
|
"./lib/newsroom-demo-articles": "./lib/newsroom-demo-articles.ts",
|
|
261
270
|
"./lib/newsroom-demo-dashboard": "./lib/newsroom-demo-dashboard.ts",
|
|
262
271
|
"./lib/newsroom-demo-profile": "./lib/newsroom-demo-profile.ts",
|
|
@@ -283,6 +292,7 @@
|
|
|
283
292
|
"./lib/presentation-footer": "./lib/presentation-footer.ts",
|
|
284
293
|
"./lib/pretext-layout": "./lib/pretext-layout.ts",
|
|
285
294
|
"./lib/priming-proof-report": "./lib/priming-proof-report.ts",
|
|
295
|
+
"./lib/proposals-admin": "./lib/proposals-admin.ts",
|
|
286
296
|
"./lib/prospectus-account-client": "./lib/prospectus-account-client.ts",
|
|
287
297
|
"./lib/prospectus-publication-access": "./lib/prospectus-publication-access.ts",
|
|
288
298
|
"./lib/prospectus-session": "./lib/prospectus-session.ts",
|
|
@@ -356,6 +366,7 @@
|
|
|
356
366
|
"./renderers/pretext/rhythm-blog-presentation": "./renderers/pretext/rhythm-blog-presentation.tsx",
|
|
357
367
|
"./renderers/pretext/rhythm-shell-style": "./renderers/pretext/rhythm-shell-style.ts",
|
|
358
368
|
"./app/%5F_preview/[[...path]]/route": "./app/%5F_preview/[[...path]]/route.ts",
|
|
369
|
+
"./app/.well-known/papyrus-cli.json/route": "./app/.well-known/papyrus-cli.json/route.ts",
|
|
359
370
|
"./app/[year]/[month]/[day]/[articleSlug]/page": "./app/[year]/[month]/[day]/[articleSlug]/page.tsx",
|
|
360
371
|
"./app/[year]/[month]/[day]/page": "./app/[year]/[month]/[day]/page.tsx",
|
|
361
372
|
"./app/[year]/[month]/[day]/page/[pageNumber]/page": "./app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
|
|
@@ -456,7 +467,7 @@
|
|
|
456
467
|
"optional": true
|
|
457
468
|
}
|
|
458
469
|
},
|
|
459
|
-
"version": "5.
|
|
470
|
+
"version": "5.43.0-next.2",
|
|
460
471
|
"dependencies": {
|
|
461
472
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
462
473
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "5.
|
|
2
|
+
"version": "5.43.0-next.2",
|
|
3
3
|
"routes": [
|
|
4
4
|
{
|
|
5
5
|
"file": "app/%5F_preview/[[...path]]/route.ts",
|
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
"dynamic": "\"force-dynamic\""
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
|
+
{
|
|
15
|
+
"file": "app/.well-known/papyrus-cli.json/route.ts",
|
|
16
|
+
"hasDefault": false,
|
|
17
|
+
"exports": [
|
|
18
|
+
"GET"
|
|
19
|
+
],
|
|
20
|
+
"config": {
|
|
21
|
+
"dynamic": "\"force-dynamic\"",
|
|
22
|
+
"runtime": "\"nodejs\""
|
|
23
|
+
}
|
|
24
|
+
},
|
|
14
25
|
{
|
|
15
26
|
"file": "app/[year]/[month]/[day]/[articleSlug]/page.tsx",
|
|
16
27
|
"reader": true,
|