@anthusai/papyrus 2.0.0-next.9 → 2.0.0-next.90

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.
Files changed (217) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/data/function-handler.js +20 -0
  4. package/amplify/data/function-handler.ts +40 -0
  5. package/amplify/data/resource.js +151 -61
  6. package/amplify/data/resource.ts +157 -61
  7. package/amplify/data/schema.js +2096 -1980
  8. package/amplify/data/schema.ts +198 -69
  9. package/amplify/functions/assignment-action/data-access.js +2 -0
  10. package/amplify/functions/assignment-action/data-access.ts +2 -0
  11. package/amplify/functions/assignment-action/handler.ts +1 -1
  12. package/amplify/functions/automation-view/handler.py +17 -0
  13. package/amplify/functions/automation-view/resource.js +24 -0
  14. package/amplify/functions/automation-view/resource.ts +34 -0
  15. package/amplify/functions/automation-view/schema.js +26 -0
  16. package/amplify/functions/automation-view/schema.ts +28 -0
  17. package/amplify/functions/automation-view/wire.js +56 -0
  18. package/amplify/functions/automation-view/wire.ts +76 -0
  19. package/amplify/functions/content-actions/handler.py +61 -2
  20. package/amplify/functions/content-actions/requirements.txt +1 -0
  21. package/amplify/functions/content-actions/resource.js +1 -1
  22. package/amplify/functions/content-actions/resource.ts +1 -1
  23. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  25. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  26. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  27. package/amplify/functions/editorial-checks/schema.js +122 -0
  28. package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
  29. package/amplify/functions/editorial-checks/wire.js +53 -0
  30. package/amplify/functions/editorial-checks/wire.ts +61 -0
  31. package/amplify/functions/monitoring-view/handler.py +11 -0
  32. package/amplify/functions/monitoring-view/resource.js +24 -0
  33. package/amplify/functions/monitoring-view/resource.ts +34 -0
  34. package/amplify/functions/monitoring-view/schema.js +17 -0
  35. package/amplify/functions/monitoring-view/schema.ts +19 -0
  36. package/amplify/functions/monitoring-view/wire.js +37 -0
  37. package/amplify/functions/monitoring-view/wire.ts +47 -0
  38. package/amplify/functions/public-forms/core.js +247 -0
  39. package/amplify/functions/public-forms/core.ts +310 -0
  40. package/amplify/functions/public-forms/handler.ts +41 -0
  41. package/amplify/functions/public-forms/origins.js +35 -0
  42. package/amplify/functions/public-forms/origins.ts +43 -0
  43. package/amplify/functions/public-forms/resource.js +9 -0
  44. package/amplify/functions/public-forms/resource.ts +10 -0
  45. package/amplify/functions/public-forms/wire.js +25 -0
  46. package/amplify/functions/public-forms/wire.ts +32 -0
  47. package/amplify/functions/shared/editorial-context.ts +63 -4
  48. package/amplify/functions/shared/python-bundle.js +20 -14
  49. package/amplify/functions/shared/python-bundle.ts +25 -18
  50. package/amplify/plugins/newsletter/config.js +245 -0
  51. package/amplify/plugins/newsletter/config.ts +305 -0
  52. package/amplify/plugins/newsletter/function/handler.py +6 -0
  53. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  54. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  55. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  56. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  57. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  58. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  59. package/amplify/plugins/newsletter/schema.js +57 -0
  60. package/amplify/plugins/newsletter/schema.ts +62 -0
  61. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  62. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  63. package/amplify/plugins/newsletter/sending.js +140 -0
  64. package/amplify/plugins/newsletter/sending.ts +186 -0
  65. package/amplify/plugins/reader-steering/config.js +182 -0
  66. package/amplify/plugins/reader-steering/config.ts +290 -0
  67. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  68. package/amplify/plugins/reader-steering/function/handler.py +1567 -0
  69. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  70. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  71. package/amplify/plugins/reader-steering/function/proposals.py +458 -0
  72. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  73. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  74. package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
  76. package/amplify/plugins/reader-steering/schema.js +229 -0
  77. package/amplify/plugins/reader-steering/schema.ts +231 -0
  78. package/amplify/plugins/registry.js +4 -2
  79. package/amplify/plugins/registry.ts +4 -2
  80. package/amplify/plugins/signup/schema.js +2 -1
  81. package/amplify/plugins/signup/schema.ts +2 -1
  82. package/amplify/plugins/types.ts +5 -0
  83. package/amplify/plugins/wire.js +1 -0
  84. package/amplify/plugins/wire.ts +1 -0
  85. package/amplify/publication/projection-lease.js +30 -0
  86. package/amplify/publication/projection-lease.ts +39 -0
  87. package/amplify/publication/redirect-and-index.js +105 -0
  88. package/amplify/publication/redirect-rules.js +73 -0
  89. package/amplify/publication/redirect-rules.ts +93 -0
  90. package/amplify/publication/s3-projection.js +7 -1
  91. package/amplify/publication/s3-projection.ts +9 -1
  92. package/amplify/site-backend-config.ts +18 -1
  93. package/amplify/site-backend-names.js +4 -2
  94. package/amplify/site-backend-names.ts +4 -2
  95. package/amplify/site-backend.js +45 -3
  96. package/amplify/site-backend.ts +51 -3
  97. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  98. package/app/api/newsletter/confirm/route.ts +13 -0
  99. package/app/api/newsletter/preferences/route.ts +13 -0
  100. package/app/api/newsletter/state/route.ts +9 -0
  101. package/app/api/newsletter/subscribe/route.ts +9 -0
  102. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  103. package/app/api/newsroom/curation/context/route.ts +9 -0
  104. package/app/api/newsroom/curation/priming/route.ts +9 -0
  105. package/app/api/newsroom/curation/publication/route.ts +9 -0
  106. package/app/api/reader/comment/delete/route.ts +9 -0
  107. package/app/api/reader/comment/edit/route.ts +9 -0
  108. package/app/api/reader/comment/route.ts +9 -0
  109. package/app/api/reader/flag/route.ts +9 -0
  110. package/app/api/reader/handle/route.ts +9 -0
  111. package/app/api/reader/me/route.ts +9 -0
  112. package/app/api/reader/rating/route.ts +9 -0
  113. package/app/api/reader/report/route.ts +9 -0
  114. package/app/api/reader/retired/route.ts +9 -0
  115. package/app/api/reader/submit/route.ts +9 -0
  116. package/app/api/reader/vote/route.ts +9 -0
  117. package/bin/papyrus-infra.mjs +20 -1
  118. package/components/automation-panel.tsx +49 -0
  119. package/components/automation-run-now.tsx +97 -0
  120. package/components/automation-view.tsx +195 -0
  121. package/components/editorial-checks-admin-panel.tsx +38 -13
  122. package/components/moderation-panel.tsx +248 -0
  123. package/components/monitoring-overview-view.tsx +241 -0
  124. package/components/monitoring-panel.tsx +40 -0
  125. package/components/news-desk-client-provider.tsx +18 -18
  126. package/components/news-desk-taxonomy-client.ts +28 -8
  127. package/components/newsletter-panel.tsx +474 -0
  128. package/components/newsletter-sections.tsx +163 -0
  129. package/components/newsroom-access-gate.tsx +16 -2
  130. package/components/newsroom-article-editor.tsx +11 -11
  131. package/components/newsroom-articles-view.tsx +6 -6
  132. package/components/newsroom-assignment-detail.tsx +6 -6
  133. package/components/newsroom-board-view.tsx +8 -3
  134. package/components/newsroom-curation-bootstrap.tsx +45 -0
  135. package/components/newsroom-ops-overview.tsx +26 -96
  136. package/components/newsroom-ops-shell.tsx +2 -2
  137. package/components/newsroom-organizer.css +33 -0
  138. package/components/newsroom-overview-skeleton.tsx +25 -0
  139. package/components/newsroom-references-view.tsx +192 -49
  140. package/components/notification-center.tsx +277 -0
  141. package/components/publication-cyclotron-panel.tsx +15 -10
  142. package/components/reader-auth-state.ts +1 -1
  143. package/components/reference-add-entry.tsx +75 -0
  144. package/components/reference-curation-controls.tsx +31 -22
  145. package/components/reference-cyclotron-priming.tsx +58 -19
  146. package/components/reference-cyclotron-suggestion.tsx +10 -6
  147. package/components/reference-priming.tsx +14 -10
  148. package/components/reference-relevance-review.tsx +3 -3
  149. package/components/reference-section-suggestion.tsx +1 -1
  150. package/components/topic-steering-workspace.tsx +237 -158
  151. package/components/use-editorial-checks.ts +12 -10
  152. package/infra/amplify-app-shell.js +94 -16
  153. package/infra/amplify-app-shell.ts +106 -18
  154. package/infra/cloud-email-config.js +32 -0
  155. package/infra/cloud-email-config.ts +39 -0
  156. package/infra/cloud-email.js +57 -0
  157. package/infra/cloud-email.test.js +69 -0
  158. package/infra/cloud-email.test.ts +66 -0
  159. package/infra/cloud-email.ts +60 -0
  160. package/infra/index.js +1 -0
  161. package/infra/secure-string-parameter-handler.js +41 -0
  162. package/infra/secure-string-parameter-handler.ts +41 -0
  163. package/infra/secure-string-parameter.js +79 -0
  164. package/infra/secure-string-parameter.ts +91 -0
  165. package/infra/site-config.js +88 -6
  166. package/infra/site-config.ts +134 -6
  167. package/lib/automation-client.ts +54 -0
  168. package/lib/automation-fixture.ts +93 -0
  169. package/lib/automation-overview.ts +252 -0
  170. package/lib/category-repository.ts +14 -12
  171. package/lib/console-web-context.ts +1 -1
  172. package/lib/curation-routes.ts +345 -0
  173. package/lib/cyclotron-suggestions.ts +4 -4
  174. package/lib/editorial-checks-backend.ts +1 -1
  175. package/lib/editorial-checks-client.ts +19 -1
  176. package/lib/editorial-checks-demo.ts +2 -2
  177. package/lib/editorial-checks-settings.ts +3 -0
  178. package/lib/editorial-checks.ts +8 -11
  179. package/lib/editorial-preview.ts +5 -5
  180. package/lib/monitoring-client.ts +29 -0
  181. package/lib/monitoring-overview-fixture.ts +51 -0
  182. package/lib/monitoring-overview.ts +145 -0
  183. package/lib/news-desk-session.ts +12 -21
  184. package/lib/newsletter-api.ts +418 -0
  185. package/lib/newsletter-client.ts +46 -0
  186. package/lib/newsletter-fixture.ts +184 -0
  187. package/lib/newsletter-model.ts +202 -0
  188. package/lib/newsroom-demo-dashboard.ts +1 -1
  189. package/lib/newsroom-nav.ts +1 -1
  190. package/lib/newsroom-organizer-nav.ts +2 -1
  191. package/lib/prospectus-session.ts +15 -14
  192. package/lib/prospectus-sign-in-routes.ts +105 -20
  193. package/lib/prospectus-site-identity.ts +46 -6
  194. package/lib/reader-api.ts +145 -0
  195. package/lib/reader-moderation.ts +220 -0
  196. package/lib/reader-retirement-route.ts +89 -0
  197. package/lib/reader-subject.md +29 -0
  198. package/lib/reader-subject.ts +53 -0
  199. package/lib/reference-review-details.ts +71 -0
  200. package/lib/reference-review-request.ts +14 -0
  201. package/lib/relevance-decisions.ts +42 -10
  202. package/lib/relevance-status.ts +2 -2
  203. package/lib/review-client.ts +117 -0
  204. package/lib/review-fixture.ts +45 -0
  205. package/lib/review-requests.ts +244 -0
  206. package/lib/semantic-graph.ts +2 -0
  207. package/lib/site-brand-tenant.ts +11 -6
  208. package/lib/site-brand.ts +6 -3
  209. package/lib/site-origin.ts +67 -0
  210. package/lib/use-reference-review-data.ts +44 -0
  211. package/middleware.ts +14 -3
  212. package/package.json +56 -3
  213. package/routes.manifest.json +215 -2
  214. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  215. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
  216. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
  217. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -25,6 +25,7 @@ import {
25
25
  publishingAccountsForSite,
26
26
  type PublicationCapability,
27
27
  } from "./prospectus-publication-access";
28
+ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
28
29
 
29
30
  /**
30
31
  * The site's sign-in routes for Prospectus accounts: start (challenge, then the
@@ -38,6 +39,10 @@ export interface ProspectusSignInRouteOptions {
38
39
  siteProduct?: string | null;
39
40
  completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
40
41
  nowInSeconds?: () => number;
42
+ /** Trusted configuration the public site origin is read from; defaults to process.env. */
43
+ environment?: Record<string, string | undefined>;
44
+ /** Receives each sign-in failure line; defaults to console.error so it reaches the compute log. */
45
+ log?: (line: string) => void;
41
46
  }
42
47
 
43
48
  const NO_STORE_HEADERS = { "cache-control": "no-store" };
@@ -50,8 +55,22 @@ function currentTimeInSeconds(options: ProspectusSignInRouteOptions): number {
50
55
  return options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
51
56
  }
52
57
 
58
+ function siteOriginFor(request: NextRequest, options: ProspectusSignInRouteOptions): string {
59
+ return publicSiteOrigin(request, options.environment);
60
+ }
61
+
53
62
  async function signInRuntimeFor(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<ProspectusSignInRuntime | null> {
54
- return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request.nextUrl.origin);
63
+ return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(siteOriginFor(request, options));
64
+ }
65
+
66
+ /** Runs a route and answers a missing site origin with 503 site_origin_not_configured, never a localhost redirect. */
67
+ async function failingClosedWithoutSiteOrigin(route: () => Promise<NextResponse>): Promise<NextResponse> {
68
+ try {
69
+ return await route();
70
+ } catch (error) {
71
+ if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
72
+ throw error;
73
+ }
55
74
  }
56
75
 
57
76
  function signInUnavailableResponse(): NextResponse {
@@ -65,10 +84,37 @@ function clearSealedCookie(response: NextResponse, cookieName: string): void {
65
84
  response.cookies.set(cookieName, "", sealedCookieAttributes(0, 0));
66
85
  }
67
86
 
87
+ /** A JSON failure, for the routes the page script calls with fetch (session, account selection and creation). */
68
88
  function signInFailedResponse(status: number, code: string, message: string): NextResponse {
69
89
  return NextResponse.json({ error: code, message }, { status, headers: NO_STORE_HEADERS });
70
90
  }
71
91
 
92
+ /**
93
+ * A failed sign-in never leaves the browser on /api/auth/prospectus/callback: it answers 303 to the page the reader started from
94
+ * (or the home page when that is not known) with `sign_in_error=<code>`, which the reader plugin turns into a short message.
95
+ */
96
+ /** Which callback step failed; with the code it tells an operator where a reader's sign-in stopped. */
97
+ type SignInFailureBranch = "pending_cookie" | "provider_error" | "missing_parameters" | "completion" | "unexpected";
98
+
99
+ /**
100
+ * One structured line per failed callback: `prospectus_sign_in_failed {"code":...,"branch":...}`. It carries only the
101
+ * code, the branch, and (for errors from completing the sign-in) the HTTP status, Prospectus error code, its rule text
102
+ * (`prospectusMessage`, which names the broken rule and carries no personal data) and error name.
103
+ * Never tokens, authorization codes, state, emails or subjects.
104
+ */
105
+ function logSignInFailure(
106
+ options: ProspectusSignInRouteOptions,
107
+ entry: { code: string; branch: SignInFailureBranch; httpStatus?: number; prospectusError?: string; prospectusMessage?: string; errorName?: string },
108
+ ): void {
109
+ (options.log ?? console.error)(`prospectus_sign_in_failed ${JSON.stringify(entry)}`);
110
+ }
111
+
112
+ function signInFailedRedirect(request: NextRequest, options: ProspectusSignInRouteOptions, returnPath: string | null, code: string): NextResponse {
113
+ const target = new URL(returnPath ?? "/", siteOriginFor(request, options));
114
+ target.searchParams.set("sign_in_error", /^[a-z0-9_]{1,60}$/.test(code) ? code : "sign_in_failed");
115
+ return NextResponse.redirect(target, { status: 303, headers: NO_STORE_HEADERS });
116
+ }
117
+
72
118
  export function signedInProspectusSession(
73
119
  request: NextRequest,
74
120
  sealingSecret: string,
@@ -82,13 +128,29 @@ export function signedInProspectusSession(
82
128
  );
83
129
  }
84
130
 
85
- export async function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
131
+ /** `returnTo` from the query (a link) or, for the POST form the reader plugin's button submits, from the form body. */
132
+ async function requestedReturnPath(request: NextRequest): Promise<string | null> {
133
+ if (request.method !== "POST") return request.nextUrl.searchParams.get("returnTo");
134
+ try {
135
+ const value = (await request.formData()).get("returnTo");
136
+ return typeof value === "string" ? value : null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /** GET (a link) or POST (the reader plugin's "Sign in" button, so people are never sent to an /api URL) starts the Google sign-in with a 303 to Google. */
143
+ export function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
144
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSignInStartRoute(request, options));
145
+ }
146
+
147
+ async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
86
148
  const runtime = await signInRuntimeFor(request, options);
87
149
  if (!runtime) return signInUnavailableResponse();
88
150
  const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
89
151
  const pendingSignInCookie: PendingProspectusSignInCookie = {
90
152
  ...pendingSignIn,
91
- returnPath: sameSiteReturnPath(request.nextUrl.searchParams.get("returnTo")),
153
+ returnPath: sameSiteReturnPath(await requestedReturnPath(request)),
92
154
  };
93
155
  const response = NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
94
156
  response.cookies.set(
@@ -99,7 +161,11 @@ export async function handleProspectusSignInStart(request: NextRequest, options:
99
161
  return response;
100
162
  }
101
163
 
102
- export async function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
164
+ export function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
165
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSignInCallbackRoute(request, options));
166
+ }
167
+
168
+ async function handleProspectusSignInCallbackRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
103
169
  const runtime = await signInRuntimeFor(request, options);
104
170
  if (!runtime) return signInUnavailableResponse();
105
171
  const nowInSeconds = currentTimeInSeconds(options);
@@ -121,12 +187,16 @@ async function completeCallback(
121
187
  options: ProspectusSignInRouteOptions,
122
188
  nowInSeconds: number,
123
189
  ): Promise<NextResponse> {
124
- if (!pendingSignIn) return signInFailedResponse(401, "sign_in_expired", "This sign-in is no longer pending. Start again.");
190
+ const failed = (code: string, branch: SignInFailureBranch, detail: { httpStatus?: number; prospectusError?: string; prospectusMessage?: string; errorName?: string } = {}) => {
191
+ logSignInFailure(options, { code, branch, ...detail });
192
+ return signInFailedRedirect(request, options, pendingSignIn?.returnPath ?? null, code);
193
+ };
194
+ if (!pendingSignIn) return failed("sign_in_expired", "pending_cookie");
125
195
  const searchParams = request.nextUrl.searchParams;
126
- if (searchParams.get("error")) return signInFailedResponse(401, "sign_in_declined", "Google sign-in did not complete.");
196
+ if (searchParams.get("error")) return failed("sign_in_declined", "provider_error");
127
197
  const authorizationCode = searchParams.get("code");
128
198
  const returnedState = searchParams.get("state");
129
- if (!authorizationCode || !returnedState) return signInFailedResponse(400, "sign_in_incomplete", "The sign-in response is missing its code or state.");
199
+ if (!authorizationCode || !returnedState) return failed("sign_in_incomplete", "missing_parameters");
130
200
  try {
131
201
  const signedIn = await completeProspectusGoogleSignIn({
132
202
  accounts: runtime.accounts,
@@ -141,8 +211,9 @@ async function completeCallback(
141
211
  personId: signedIn.person.id,
142
212
  accessToken: signedIn.accessToken,
143
213
  accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
214
+ ...(signedIn.person.siteSubject ? { siteSubject: signedIn.person.siteSubject } : {}),
144
215
  };
145
- const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, request.nextUrl.origin), { status: 303, headers: NO_STORE_HEADERS });
216
+ const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, siteOriginFor(request, options)), { status: 303, headers: NO_STORE_HEADERS });
146
217
  response.cookies.set(
147
218
  PROSPECTUS_SESSION_COOKIE,
148
219
  sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, session, session.accessTokenExpiresAt),
@@ -150,13 +221,16 @@ async function completeCallback(
150
221
  );
151
222
  return response;
152
223
  } catch (error) {
153
- if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
154
- console.error(`Prospectus sign-in failed (${error instanceof Error ? error.name : "error"}).`);
155
- return signInFailedResponse(401, "sign_in_failed", "Sign-in could not be completed.");
224
+ if (error instanceof ProspectusAccountsError) return failed(error.code, "completion", { httpStatus: error.status, prospectusError: error.code, prospectusMessage: error.message.slice(0, 300) });
225
+ return failed("sign_in_failed", "unexpected", { errorName: error instanceof Error ? error.name : "error" });
156
226
  }
157
227
  }
158
228
 
159
- export async function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
229
+ export function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
230
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSessionReadRoute(request, options));
231
+ }
232
+
233
+ async function handleProspectusSessionReadRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
160
234
  const runtime = await signInRuntimeFor(request, options);
161
235
  if (!runtime) return signInUnavailableResponse();
162
236
  const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
@@ -169,11 +243,10 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
169
243
  return response;
170
244
  }
171
245
  const publishingAccounts = publishingAccountsForSite(directory.accounts, siteProductFor(options));
246
+ // Only what the UI reads: no person id, siteSubject or account list ever goes to the browser.
172
247
  return NextResponse.json(
173
248
  {
174
249
  signedIn: true,
175
- person: directory.person,
176
- accounts: directory.accounts,
177
250
  cyclotronLabAccess: directory.accounts.some((account) =>
178
251
  account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
179
252
  ),
@@ -196,9 +269,13 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
196
269
  }
197
270
  }
198
271
 
199
- export async function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
272
+ export function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
273
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSignOutRoute(request, options));
274
+ }
275
+
276
+ async function handleProspectusSignOutRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
200
277
  const runtime = await signInRuntimeFor(request, options);
201
- const signedOutDestination = new URL("/", request.nextUrl.origin);
278
+ const signedOutDestination = new URL("/", siteOriginFor(request, options));
202
279
  const destination = runtime
203
280
  ? new URL(
204
281
  `/logout?${new URLSearchParams({ client_id: runtime.cognitoClient.clientId, logout_uri: signedOutDestination.toString() })}`,
@@ -250,10 +327,14 @@ export async function currentProspectusPublishingSelection(
250
327
  }
251
328
  }
252
329
 
253
- export async function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
330
+ export function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
331
+ return failingClosedWithoutSiteOrigin(() => handleProspectusAccountSelectionRoute(request, options));
332
+ }
333
+
334
+ async function handleProspectusAccountSelectionRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
254
335
  const runtime = await signInRuntimeFor(request, options);
255
336
  if (!runtime) return signInUnavailableResponse();
256
- if (request.headers.get("origin") !== request.nextUrl.origin) {
337
+ if (request.headers.get("origin") !== siteOriginFor(request, options)) {
257
338
  return signInFailedResponse(403, "cross_origin_selection", "Choose an account from this site.");
258
339
  }
259
340
  const nowInSeconds = currentTimeInSeconds(options);
@@ -287,10 +368,14 @@ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
287
368
  const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
288
369
  const IDEMPOTENCY_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
289
370
 
290
- export async function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
371
+ export function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
372
+ return failingClosedWithoutSiteOrigin(() => handleProspectusPublishingAccountCreationRoute(request, options));
373
+ }
374
+
375
+ async function handleProspectusPublishingAccountCreationRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
291
376
  const runtime = await signInRuntimeFor(request, options);
292
377
  if (!runtime) return signInUnavailableResponse();
293
- if (request.headers.get("origin") !== request.nextUrl.origin) {
378
+ if (request.headers.get("origin") !== siteOriginFor(request, options)) {
294
379
  return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
295
380
  }
296
381
  const nowInSeconds = currentTimeInSeconds(options);
@@ -24,6 +24,7 @@ export type ProspectusOperation = "console:read" | "console:write" | "members:re
24
24
  export interface ProspectusPerson {
25
25
  id: string;
26
26
  createdAt: string;
27
+ siteSubject?: string;
27
28
  }
28
29
 
29
30
  export interface ProspectusAccountAuthorization {
@@ -36,6 +37,20 @@ export interface ProspectusAccountAuthorization {
36
37
  revision: number;
37
38
  }
38
39
 
40
+ /** One retired reader site subject, from `GET /v1/accounts/site-subjects/retired` (reader sites only). */
41
+ export interface ProspectusRetiredSiteSubject {
42
+ siteSubject: string;
43
+ reason: "deleted" | "merged";
44
+ survivingSiteSubject?: string;
45
+ retiredAt: string;
46
+ }
47
+
48
+ export interface ProspectusRetiredSiteSubjectsPage {
49
+ retired: ProspectusRetiredSiteSubject[];
50
+ cursor: string;
51
+ more: boolean;
52
+ }
53
+
39
54
  export interface ProspectusDirectoryAccount {
40
55
  id: string;
41
56
  name: string;
@@ -100,8 +115,10 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
100
115
  ...(payload ? { "content-type": "application/json" } : {}),
101
116
  };
102
117
  const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
118
+ // The query string is part of what API Gateway verifies, so it has to be signed as well as sent.
119
+ const query: Record<string, string> = Object.fromEntries(url.searchParams.entries());
103
120
  const signed = await signer.sign(
104
- new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload }),
121
+ new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload, query }),
105
122
  );
106
123
  const response = await fetcher(url, { method, headers: signed.headers, body: payload, cache: "no-store", redirect: "error" });
107
124
  const responseBody = await response.json().catch(() => ({}));
@@ -115,19 +132,28 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
115
132
  export class ProspectusSiteAccounts {
116
133
  constructor(private readonly transport: ProspectusTransport) {}
117
134
 
118
- requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number }> {
135
+ requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number; challengeToken?: string }> {
119
136
  return this.transport({ path: "/v1/identities/google/challenge", method: "POST", body: {} }) as Promise<{
120
137
  challengeId: string;
121
138
  nonce: string;
122
139
  expiresAt: number;
140
+ challengeToken?: string;
123
141
  }>;
124
142
  }
125
143
 
126
- resolveGoogleSignIn(challengeId: string, humanAccessToken: string, identityToken: string): Promise<{ person: ProspectusPerson }> {
144
+ /**
145
+ * When Prospectus signs its challenges it stores nothing, so the signed `challengeToken` is the only proof the
146
+ * challenge exists and must be sent back unchanged. Without a token (a service that stores challenges) the ID is sent.
147
+ */
148
+ resolveGoogleSignIn(
149
+ challenge: { challengeId: string; challengeToken?: string },
150
+ humanAccessToken: string,
151
+ identityToken: string,
152
+ ): Promise<{ person: ProspectusPerson }> {
127
153
  return this.transport({
128
154
  path: "/v1/identities/google/resolve",
129
155
  method: "POST",
130
- body: { challengeId },
156
+ body: challenge.challengeToken ? { challengeToken: challenge.challengeToken } : { challengeId: challenge.challengeId },
131
157
  humanAccessToken,
132
158
  identityToken,
133
159
  }) as Promise<{ person: ProspectusPerson }>;
@@ -156,6 +182,12 @@ export class ProspectusSiteAccounts {
156
182
  }) as Promise<{ account: { id: string; name: string; role: ProspectusRole } }>;
157
183
  }
158
184
 
185
+ /** The calling site's retired reader subjects since `since` (omit it the first time): at most 100, oldest first. */
186
+ retiredSiteSubjects(since?: string): Promise<ProspectusRetiredSiteSubjectsPage> {
187
+ const query = since ? `?since=${encodeURIComponent(since)}` : "";
188
+ return this.transport({ path: `/v1/accounts/site-subjects/retired${query}`, method: "GET" }) as Promise<ProspectusRetiredSiteSubjectsPage>;
189
+ }
190
+
159
191
  accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
160
192
  return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
161
193
  person: ProspectusPerson | null;
@@ -174,6 +206,8 @@ export interface GoogleOnlyCognitoClient {
174
206
  export interface PendingProspectusSignIn {
175
207
  challengeId: string;
176
208
  nonce: string;
209
+ /** The signed challenge from Prospectus, present when it signs challenges instead of storing them. */
210
+ challengeToken?: string;
177
211
  state: string;
178
212
  codeVerifier: string;
179
213
  expiresAt: number;
@@ -219,7 +253,13 @@ export async function beginProspectusGoogleSignIn(
219
253
  }).toString();
220
254
  return {
221
255
  authorizationUrl: authorizationUrl.toString(),
222
- pendingSignIn: { challengeId: challenge.challengeId, nonce: challenge.nonce, state, codeVerifier, expiresAt: challenge.expiresAt },
256
+ pendingSignIn: {
257
+ challengeId: challenge.challengeId,
258
+ nonce: challenge.nonce,
259
+ ...(challenge.challengeToken ? { challengeToken: challenge.challengeToken } : {}),
260
+ state, codeVerifier,
261
+ expiresAt: challenge.expiresAt,
262
+ },
223
263
  };
224
264
  }
225
265
 
@@ -280,7 +320,7 @@ export async function completeProspectusGoogleSignIn(
280
320
  expiresInSeconds: Number(tokenBody.expires_in) || 0,
281
321
  };
282
322
  await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
283
- const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn.challengeId, tokens.accessToken, tokens.idToken);
323
+ const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn, tokens.accessToken, tokens.idToken);
284
324
  return {
285
325
  person,
286
326
  accessToken: tokens.accessToken,
@@ -0,0 +1,145 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ import { readAmplifyOutputsFile } from "./amplify-outputs-path";
3
+ import { loadProspectusSessionSealingSecret, type ProspectusSignedInSession } from "./prospectus-session";
4
+ import { signedInProspectusSession } from "./prospectus-sign-in-routes";
5
+ import { readerIsStaff, readerSubject } from "./reader-subject";
6
+ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
7
+
8
+ export const READER_ACTIONS_FUNCTION_ENV = "PAPYRUS_READER_ACTIONS_FUNCTION";
9
+ export const READER_MAXIMUM_BODY_BYTES = 4096;
10
+ /** Larger bodies for actions that carry reader text: a URL, a title and a note; a comment of 2,000 characters. */
11
+ export const READER_BODY_BYTES_BY_ACTION: Partial<Record<ReaderAction, number>> = { submit: 8192, comment: 12288, "comment-edit": 12288 };
12
+ export const READER_MAXIMUM_SUBJECTS = 50;
13
+ export const READER_SUBJECT_KEY = /^[A-Za-z0-9._:-]{1,200}$/;
14
+
15
+ export type ReaderAction = "me" | "handle" | "vote" | "rating" | "submit" | "comment" | "comment-edit" | "comment-delete" | "report" | "flag";
16
+
17
+ export interface ReaderActionEvent {
18
+ publication: string;
19
+ siteSubject: string;
20
+ isStaff: boolean;
21
+ action: ReaderAction;
22
+ payload: Record<string, unknown>;
23
+ }
24
+
25
+ export interface ReaderActionResult {
26
+ statusCode: number;
27
+ body: Record<string, unknown>;
28
+ }
29
+
30
+ export type ReaderActionInvoker = (functionName: string, event: ReaderActionEvent) => Promise<ReaderActionResult>;
31
+
32
+ export interface ReaderApiOptions {
33
+ environment?: Record<string, string | undefined>;
34
+ invoke?: ReaderActionInvoker;
35
+ actionsFunctionName?: string | null;
36
+ loadSealingSecret?: () => Promise<string | null>;
37
+ nowInSeconds?: number;
38
+ }
39
+
40
+ const NO_STORE_HEADERS = { "cache-control": "no-store" };
41
+
42
+ function failure(status: number, error: string, message: string, extra: Record<string, unknown> = {}, headers: Record<string, string> = {}): NextResponse {
43
+ return NextResponse.json({ error, message, ...extra }, { status, headers: { ...NO_STORE_HEADERS, ...headers } });
44
+ }
45
+
46
+ export function identityUnavailableResponse(): NextResponse {
47
+ return failure(503, "reader_identity_unavailable", "Reader sign-in is not open yet.");
48
+ }
49
+
50
+ function actionsFunctionNameFrom(options: ReaderApiOptions, environment: Record<string, string | undefined>): string | null {
51
+ if (options.actionsFunctionName !== undefined) return options.actionsFunctionName;
52
+ const fromEnvironment = (environment[READER_ACTIONS_FUNCTION_ENV] ?? "").trim();
53
+ if (fromEnvironment) return fromEnvironment;
54
+ const outputs = readAmplifyOutputsFile() as { custom?: { readerSteering?: { actionsFunction?: unknown } | null } } | null;
55
+ const fromOutputs = outputs?.custom?.readerSteering?.actionsFunction;
56
+ return typeof fromOutputs === "string" && fromOutputs.trim() ? fromOutputs.trim() : null;
57
+ }
58
+
59
+ export async function invokeReaderActionsLambda(functionName: string, event: ReaderActionEvent): Promise<ReaderActionResult> {
60
+ const { InvokeCommand, LambdaClient } = await import("@aws-sdk/client-lambda");
61
+ const response = await new LambdaClient({}).send(
62
+ new InvokeCommand({ FunctionName: functionName, InvocationType: "RequestResponse", Payload: Buffer.from(JSON.stringify(event)) }),
63
+ );
64
+ if (response.FunctionError || !response.Payload) throw new Error("reader-actions failed");
65
+ const result = JSON.parse(Buffer.from(response.Payload).toString("utf8")) as Partial<ReaderActionResult>;
66
+ if (typeof result.statusCode !== "number" || typeof result.body !== "object" || result.body === null) throw new Error("reader-actions returned an unexpected shape");
67
+ return { statusCode: result.statusCode, body: result.body as Record<string, unknown> };
68
+ }
69
+
70
+ async function readJsonBody(request: NextRequest, maximumBytes: number): Promise<{ payload: Record<string, unknown> } | { response: NextResponse }> {
71
+ const contentType = (request.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
72
+ if (contentType !== "application/json") return { response: failure(415, "unsupported_media_type", "Send application/json.") };
73
+ const declared = Number(request.headers.get("content-length") ?? "0");
74
+ if (Number.isFinite(declared) && declared > maximumBytes) return { response: failure(413, "payload_too_large", "The request body is too large.") };
75
+ const text = await request.text();
76
+ if (Buffer.byteLength(text, "utf8") > maximumBytes) return { response: failure(413, "payload_too_large", "The request body is too large.") };
77
+ try {
78
+ const parsed = JSON.parse(text) as unknown;
79
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
80
+ return { payload: parsed as Record<string, unknown> };
81
+ } catch {
82
+ return { response: failure(400, "invalid_json", "The request body is not a JSON object.") };
83
+ }
84
+ }
85
+
86
+ function subjectsFromQuery(request: NextRequest): { payload: Record<string, unknown> } | { response: NextResponse } {
87
+ const raw = request.nextUrl.searchParams.get("subjects") ?? "";
88
+ const subjects = raw === "" ? [] : raw.split(",");
89
+ if (subjects.length > READER_MAXIMUM_SUBJECTS || !subjects.every((subject) => READER_SUBJECT_KEY.test(subject))) {
90
+ return { response: failure(400, "invalid_subjects", `Send at most ${READER_MAXIMUM_SUBJECTS} comma separated item keys.`) };
91
+ }
92
+ return { payload: { subjects } };
93
+ }
94
+
95
+ async function signedInReaderSession(request: NextRequest, options: ReaderApiOptions): Promise<ProspectusSignedInSession | null | "unavailable"> {
96
+ const sealingSecret = await (options.loadSealingSecret ?? (() => loadProspectusSessionSealingSecret({ environment: options.environment })))();
97
+ if (!sealingSecret) return "unavailable";
98
+ return signedInProspectusSession(request, sealingSecret, options.nowInSeconds);
99
+ }
100
+
101
+ /**
102
+ * One reader route: session, readerSubject (503 while Prospectus has no siteSubject), Origin, JSON size, then the
103
+ * reader-actions function. The siteSubject only travels to the function and never into a response.
104
+ */
105
+ export async function handleReaderRequest(request: NextRequest, action: ReaderAction, options: ReaderApiOptions = {}): Promise<NextResponse> {
106
+ const environment = options.environment ?? process.env;
107
+ let origin: string;
108
+ try {
109
+ origin = publicSiteOrigin(request, environment);
110
+ } catch (error) {
111
+ if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
112
+ throw error;
113
+ }
114
+ const session = await signedInReaderSession(request, options);
115
+ if (session === "unavailable") return identityUnavailableResponse();
116
+ if (!session) return failure(401, "signed_out", "Sign in to continue.");
117
+ const siteSubject = readerSubject(session, environment);
118
+ if (siteSubject === null) return identityUnavailableResponse();
119
+
120
+ let parsed: { payload: Record<string, unknown> } | { response: NextResponse };
121
+ if (action === "me") {
122
+ parsed = subjectsFromQuery(request);
123
+ } else {
124
+ if (request.headers.get("origin") !== origin) return failure(403, "origin_mismatch", "This request did not come from this site.");
125
+ parsed = await readJsonBody(request, READER_BODY_BYTES_BY_ACTION[action] ?? READER_MAXIMUM_BODY_BYTES);
126
+ }
127
+ if ("response" in parsed) return parsed.response;
128
+
129
+ const functionName = actionsFunctionNameFrom(options, environment);
130
+ if (!functionName) return failure(503, "reader_actions_unavailable", "Reader features are not enabled for this site.");
131
+ try {
132
+ const result = await (options.invoke ?? invokeReaderActionsLambda)(functionName, {
133
+ publication: new URL(origin).host,
134
+ siteSubject,
135
+ isStaff: readerIsStaff(session),
136
+ action,
137
+ payload: parsed.payload,
138
+ });
139
+ const retryAfter: Record<string, string> = typeof result.body.retryAfter === "number" ? { "retry-after": String(result.body.retryAfter) } : {};
140
+ return NextResponse.json(result.body, { status: result.statusCode, headers: { ...NO_STORE_HEADERS, ...retryAfter } });
141
+ } catch (error) {
142
+ console.error(`Reader action ${action} failed (${error instanceof Error ? error.name : "error"}).`);
143
+ return failure(502, "reader_actions_failed", "Reader features could not be reached.");
144
+ }
145
+ }