| 1 | import { revalidatePath } from "next/cache"; |
| 2 | import { NextResponse } from "next/server"; |
| 3 | import { BodyReadError, readBoundedBody } from "@/lib/bounded-body"; |
| 4 | import { |
| 5 | approveDigestRecord, |
| 6 | claimDraft, |
| 7 | clearDraftResolution, |
| 8 | clearPostOutcomeUnknown, |
| 9 | getPostOutcomeUnknown, |
| 10 | markPostOutcomeUnknown, |
| 11 | deleteDigestRecord, |
| 12 | deleteDraft, |
| 13 | getAgentEnv, |
| 14 | getDraft, |
| 15 | getDraftResolution, |
| 16 | markDraftResolved, |
| 17 | parseDraftKey, |
| 18 | releaseDraftClaim, |
| 19 | reviewedBodyHash, |
| 20 | validateSession, |
| 21 | type CommunityAgentEnv, |
| 22 | type DraftClaimResult, |
| 23 | } from "@/lib/community-agent"; |
| 24 | |
| 25 | export const dynamic = "force-dynamic"; |
| 26 | |
| 27 | async function checkAuth(req: Request, env: CommunityAgentEnv): Promise<{ ok: boolean; status?: number; error?: string }> { |
| 28 | if (!env.MAINTAINER_TOKEN) { |
| 29 | return { ok: false, status: 503, error: "MAINTAINER_TOKEN not configured" }; |
| 30 | } |
| 31 | |
| 32 | const cookieHeader = req.headers.get("cookie") ?? ""; |
| 33 | let sid: string | undefined; |
| 34 | for (const c of cookieHeader.split(";")) { |
| 35 | const [name, ...rest] = c.trim().split("="); |
| 36 | if (name === "mt_sid") { |
| 37 | sid = rest.join("="); |
| 38 | break; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | if (!sid || !(await validateSession(env.CURATED_KV, sid))) { |
| 43 | return { ok: false, status: 401, error: "unauthorized" }; |
| 44 | } |
| 45 | return { ok: true }; |
| 46 | } |
| 47 | |
| 48 | const ALLOWED_ACTIONS = new Set(["post", "discard"]); |
| 49 | const ALLOWED_ORIGINS = new Set(["https://codewhale.net", "https://www.codewhale.net"]); |
| 50 | const MAX_BODY_BYTES = 65_536; |
| 51 | |
| 52 | /** Refresh the ISR copy of /digest after its published set changes. */ |
| 53 | function revalidateDigestPage() { |
| 54 | try { |
| 55 | revalidatePath("/[locale]/digest", "page"); |
| 56 | } catch (e) { |
| 57 | // The page still refreshes on its hourly revalidate. |
| 58 | console.error("digest revalidation failed", e); |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * A GitHub 4xx other than 408/429 means the post was not created, so the |
| 64 | * claim can be released. A 5xx, 408 or 429 can come back after GitHub already |
| 65 | * created the comment or issue, so its outcome is unknown. |
| 66 | */ |
| 67 | function githubDefinitelyRejected(status: number): boolean { |
| 68 | return status >= 400 && status < 500 && status !== 408 && status !== 429; |
| 69 | } |
| 70 | |
| 71 | /** The answer for a claim that was not taken. */ |
| 72 | function claimRefused(result: Exclude<DraftClaimResult, { ok: true }>) { |
| 73 | if (result.reason === "unconfirmed") { |
| 74 | return NextResponse.json({ error: "could not confirm the draft claim; nothing was done, retry" }, { status: 503 }); |
| 75 | } |
| 76 | if (result.reason === "resolved") { |
| 77 | return NextResponse.json({ error: `draft already ${result.resolution.state}` }, { status: 409 }); |
| 78 | } |
| 79 | return NextResponse.json( |
| 80 | { error: "another request is acting on this draft; check GitHub, then retry in a minute" }, |
| 81 | { status: 409 } |
| 82 | ); |
| 83 | } |
| 84 | |
| 85 | const discarded = () => NextResponse.json({ ok: true, action: "discarded" }); |
| 86 | |
| 87 | export async function POST(req: Request) { |
| 88 | const env = await getAgentEnv(); |
| 89 | |
| 90 | const origin = req.headers.get("origin"); |
| 91 | if (origin && !ALLOWED_ORIGINS.has(origin)) { |
| 92 | return NextResponse.json({ error: "forbidden origin" }, { status: 403 }); |
| 93 | } |
| 94 | |
| 95 | const auth = await checkAuth(req, env); |
| 96 | if (!auth.ok) { |
| 97 | return NextResponse.json( |
| 98 | { error: auth.error ?? "unauthorized" }, |
| 99 | { status: auth.status ?? 401, headers: { "Cache-Control": "no-store" } } |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | // Count the real body bytes (Content-Length is only an early rejection) |
| 104 | // and answer malformed JSON with a 400 instead of an unhandled 500. |
| 105 | let body: unknown; |
| 106 | try { |
| 107 | const bytes = await readBoundedBody(req, MAX_BODY_BYTES); |
| 108 | body = JSON.parse(new TextDecoder().decode(bytes)); |
| 109 | } catch (e) { |
| 110 | if (e instanceof BodyReadError) { |
| 111 | return NextResponse.json({ error: e.message }, { status: e.status }); |
| 112 | } |
| 113 | return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); |
| 114 | } |
| 115 | if (!body || typeof body !== "object" || Array.isArray(body)) { |
| 116 | return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); |
| 117 | } |
| 118 | const { action, draftKey, editedBody, lang, reviewedSha256 } = body as { |
| 119 | action?: unknown; |
| 120 | draftKey?: unknown; |
| 121 | editedBody?: unknown; |
| 122 | lang?: unknown; |
| 123 | reviewedSha256?: unknown; |
| 124 | }; |
| 125 | |
| 126 | if (typeof action !== "string" || !ALLOWED_ACTIONS.has(action)) { |
| 127 | return NextResponse.json({ error: "unknown action" }, { status: 400 }); |
| 128 | } |
| 129 | if (typeof draftKey !== "string" || !draftKey || draftKey.length > 256) { |
| 130 | return NextResponse.json({ error: "missing or invalid draftKey" }, { status: 400 }); |
| 131 | } |
| 132 | const parsedKey = parseDraftKey(draftKey); |
| 133 | if (!parsedKey) { |
| 134 | return NextResponse.json({ error: "invalid draftKey namespace" }, { status: 400 }); |
| 135 | } |
| 136 | if (editedBody !== undefined && typeof editedBody !== "string") { |
| 137 | return NextResponse.json({ error: "invalid editedBody" }, { status: 400 }); |
| 138 | } |
| 139 | if (editedBody !== undefined && editedBody.length > MAX_BODY_BYTES) { |
| 140 | return NextResponse.json({ error: "editedBody too long" }, { status: 413 }); |
| 141 | } |
| 142 | if (lang !== undefined && lang !== "en" && lang !== "zh") { |
| 143 | return NextResponse.json({ error: "invalid lang" }, { status: 400 }); |
| 144 | } |
| 145 | if (typeof reviewedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(reviewedSha256)) { |
| 146 | return NextResponse.json({ error: "missing or invalid reviewedSha256" }, { status: 400 }); |
| 147 | } |
| 148 | const reviewLang = lang === "zh" ? "zh" : "en"; |
| 149 | |
| 150 | const draft = await getDraft(env.CURATED_KV, draftKey); |
| 151 | if (!draft) { |
| 152 | return NextResponse.json({ error: "draft not found" }, { status: 404 }); |
| 153 | } |
| 154 | // Act only on the exact text the maintainer was shown. A draft regenerated |
| 155 | // after the admin page loaded must be reviewed again, not posted unseen. |
| 156 | const originalBody = reviewLang === "zh" ? draft.bodyZh : draft.bodyEn; |
| 157 | if ((await reviewedBodyHash(originalBody)) !== reviewedSha256) { |
| 158 | return NextResponse.json( |
| 159 | { error: "draft changed since it was loaded; reload and review it again" }, |
| 160 | { status: 409 } |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | if (action === "discard") { |
| 165 | // A posted draft is already public on GitHub (and, for a digest, on |
| 166 | // /digest); discarding it would silently unpublish or relabel it. |
| 167 | const resolution = await getDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id); |
| 168 | if (draft.posted || resolution?.state === "posted" || resolution?.state === "posting") { |
| 169 | return NextResponse.json({ error: "draft already posted" }, { status: 409 }); |
| 170 | } |
| 171 | // A repeated discard (double click) is a no-op, not an error: whether the |
| 172 | // first one already finished or is still running. |
| 173 | if (resolution?.state === "discarded") return discarded(); |
| 174 | // Hold the same claim a post takes, so a discard and a post of one draft |
| 175 | // do not both run. |
| 176 | let result: DraftClaimResult; |
| 177 | try { |
| 178 | result = await claimDraft(env, parsedKey.type, parsedKey.id, "discard"); |
| 179 | } catch (e) { |
| 180 | return NextResponse.json({ error: `could not claim draft: ${String(e)}` }, { status: 500 }); |
| 181 | } |
| 182 | if (!result.ok) { |
| 183 | if (result.reason === "resolved" && result.resolution.state === "discarded") return discarded(); |
| 184 | if (result.reason === "held" && result.holder === "discard") return discarded(); |
| 185 | return claimRefused(result); |
| 186 | } |
| 187 | const claim = result.claim; |
| 188 | let recorded = false; |
| 189 | try { |
| 190 | // The marker stops the next cron run from regenerating this draft. |
| 191 | await markDraftResolved(env.CURATED_KV, parsedKey.type, parsedKey.id, "discarded"); |
| 192 | await deleteDraft(env.CURATED_KV, draftKey); |
| 193 | if (draft.type === "digest") { |
| 194 | await deleteDigestRecord(env.CURATED_KV, draft.id); |
| 195 | } |
| 196 | recorded = true; |
| 197 | } catch (e) { |
| 198 | return NextResponse.json({ error: `discard failed: ${String(e)}` }, { status: 500 }); |
| 199 | } finally { |
| 200 | // The marker (or, if it failed, the draft) now carries the decision. |
| 201 | await releaseDraftClaim(env.CURATED_KV, claim, { recorded }).catch(() => undefined); |
| 202 | } |
| 203 | if (draft.type === "digest") revalidateDigestPage(); |
| 204 | return discarded(); |
| 205 | } |
| 206 | |
| 207 | if (action === "post") { |
| 208 | if (!env.MAINTAINER_GITHUB_PAT) { |
| 209 | return NextResponse.json({ error: "MAINTAINER_GITHUB_PAT not configured" }, { status: 500 }); |
| 210 | } |
| 211 | if (draft.type !== "digest" && !draft.targetNumber) { |
| 212 | return NextResponse.json({ error: "no target number" }, { status: 400 }); |
| 213 | } |
| 214 | |
| 215 | // Posting is not idempotent on GitHub: refuse a draft that is already |
| 216 | // posted or has a post in flight (second tab, retry after a partial |
| 217 | // failure), then claim it before the GitHub call. |
| 218 | if (draft.posted) { |
| 219 | return NextResponse.json({ error: "draft already posted" }, { status: 409 }); |
| 220 | } |
| 221 | const resolution = await getDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id); |
| 222 | if (resolution) { |
| 223 | return NextResponse.json({ error: `draft already ${resolution.state}` }, { status: 409 }); |
| 224 | } |
| 225 | let result: DraftClaimResult; |
| 226 | try { |
| 227 | result = await claimDraft(env, parsedKey.type, parsedKey.id, "post"); |
| 228 | } catch (e) { |
| 229 | return NextResponse.json({ error: `could not claim draft: ${String(e)}` }, { status: 500 }); |
| 230 | } |
| 231 | if (!result.ok) return claimRefused(result); |
| 232 | const heldClaim = result.claim; |
| 233 | try { |
| 234 | // Keeps the cron from regenerating the draft while the post is in flight. |
| 235 | await markDraftResolved(env.CURATED_KV, parsedKey.type, parsedKey.id, "posting"); |
| 236 | } catch (e) { |
| 237 | await releaseDraftClaim(env.CURATED_KV, heldClaim).catch(() => undefined); |
| 238 | return NextResponse.json({ error: `could not claim draft: ${String(e)}` }, { status: 500 }); |
| 239 | } |
| 240 | |
| 241 | const commentBody = editedBody ?? originalBody; |
| 242 | |
| 243 | // GitHub has no idempotency key. If an earlier attempt's outcome was |
| 244 | // unknown, the post it may have created is looked for (from shortly |
| 245 | // before that attempt) instead of posting blind a second time. |
| 246 | const identity = await reviewedBodyHash(JSON.stringify({ |
| 247 | repo: env.GITHUB_REPO ?? "codewhale-hq/CodeWhale", type: draft.type, |
| 248 | target: draft.targetNumber, body: commentBody, |
| 249 | })); |
| 250 | let unknownAt: string | null; |
| 251 | try { |
| 252 | unknownAt = await getPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, identity); |
| 253 | } catch (error) { |
| 254 | await clearDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id).catch(() => undefined); |
| 255 | await releaseDraftClaim(env.CURATED_KV, heldClaim).catch(() => undefined); |
| 256 | const changed = error instanceof Error && error.message === "unresolved post has different text or target"; |
| 257 | return NextResponse.json({ error: changed |
| 258 | ? "an earlier post with different text or target is unresolved; check GitHub before retrying; nothing was posted" |
| 259 | : "could not read the earlier post receipt; nothing was posted" }, { status: changed ? 409 : 503 }); |
| 260 | } |
| 261 | const lookSince = unknownAt ? new Date(Date.parse(unknownAt) - 10 * 60 * 1000).toISOString() : null; |
| 262 | const githubFind = async (url: string, authScheme: "token" | "Bearer", matches: (item: Record<string, unknown>) => boolean): Promise<Record<string, unknown> | undefined> => { |
| 263 | const signal = AbortSignal.timeout(30_000); |
| 264 | for (let page = 1; page <= 10; page++) { |
| 265 | const pageUrl = new URL(url); |
| 266 | pageUrl.searchParams.set("page", String(page)); |
| 267 | const res = await fetch(pageUrl.toString(), { |
| 268 | headers: { |
| 269 | Accept: "application/vnd.github+json", |
| 270 | Authorization: `${authScheme} ${env.MAINTAINER_GITHUB_PAT}`, |
| 271 | "X-GitHub-Api-Version": "2022-11-28", |
| 272 | }, signal, |
| 273 | }); |
| 274 | if (!res.ok) throw new Error(`GitHub ${res.status}`); |
| 275 | const list: unknown = JSON.parse(new TextDecoder().decode(await readBoundedBody(res, 2 * 1024 * 1024))); |
| 276 | if (!Array.isArray(list) || list.some(item => !item || typeof item !== "object")) throw new Error("GitHub returned no valid list"); |
| 277 | const found = list.find(matches); |
| 278 | if (found) return found; |
| 279 | if (list.length < 100) return undefined; |
| 280 | } |
| 281 | // A capped search is unknown, never proof that GitHub lacks the post. |
| 282 | throw new Error("GitHub reconciliation page limit exceeded"); |
| 283 | }; |
| 284 | // This request posted nothing, so the draft is free again. |
| 285 | const lookupFailed = async () => { |
| 286 | try { |
| 287 | await clearDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id); |
| 288 | await releaseDraftClaim(env.CURATED_KV, heldClaim); |
| 289 | } catch { /* the claim expires on its own */ } |
| 290 | return NextResponse.json( |
| 291 | { error: "could not check GitHub for the post an earlier attempt may have created; nothing was posted, retry" }, |
| 292 | { status: 502 } |
| 293 | ); |
| 294 | }; |
| 295 | const alreadyPosted = "An earlier attempt whose outcome was unknown had already posted this; it was not posted again."; |
| 296 | |
| 297 | // After GitHub accepted the post, bookkeeping failures must not turn into |
| 298 | // an error the maintainer would "fix" by posting again. |
| 299 | const recordPosted = async (): Promise<string | undefined> => { |
| 300 | try { |
| 301 | await markDraftResolved(env.CURATED_KV, parsedKey.type, parsedKey.id, "posted"); |
| 302 | // The marker now carries the decision, so a later claim sees it; a |
| 303 | // reopened draft (new activity clears the marker) is postable again. |
| 304 | await env.CURATED_KV?.put(draftKey, JSON.stringify(draft), { expirationTtl: 60 * 60 * 24 * 7 }); |
| 305 | await clearPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, heldClaim); |
| 306 | await releaseDraftClaim(env.CURATED_KV, heldClaim, { recorded: true }).catch(() => undefined); |
| 307 | return undefined; |
| 308 | } catch (e) { |
| 309 | return `Posted to GitHub, but saving the draft state failed (${String(e)}). Do not post it again; it may reappear as pending.`; |
| 310 | } |
| 311 | }; |
| 312 | |
| 313 | const postToGitHub = async (url: string, payload: unknown, authScheme: "token" | "Bearer") => { |
| 314 | await markPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, heldClaim, identity); |
| 315 | try { |
| 316 | return await fetch(url, { |
| 317 | method: "POST", |
| 318 | headers: { |
| 319 | Accept: "application/vnd.github+json", |
| 320 | Authorization: `${authScheme} ${env.MAINTAINER_GITHUB_PAT}`, |
| 321 | "X-GitHub-Api-Version": "2022-11-28", |
| 322 | "Content-Type": "application/json", |
| 323 | }, |
| 324 | signal: AbortSignal.timeout(30_000), |
| 325 | body: JSON.stringify(payload), |
| 326 | }); |
| 327 | } catch { |
| 328 | // Outcome unknown: keep the short-lived claim so an immediate retry |
| 329 | // cannot double-post. |
| 330 | return null; |
| 331 | } |
| 332 | }; |
| 333 | const unknownOutcome = async (detail: string) => { |
| 334 | // Remembered past the claim, so a later retry looks before posting. |
| 335 | // The durable receipt was persisted before the outbound POST. |
| 336 | return NextResponse.json( |
| 337 | { error: `${detail}; check GitHub before retrying (retry unlocks in 15 minutes)` }, |
| 338 | { status: 502 } |
| 339 | ); |
| 340 | }; |
| 341 | const githubFailed = async (res: Response) => { |
| 342 | const text = await res.text().catch(() => ""); |
| 343 | if (!githubDefinitelyRejected(res.status)) { |
| 344 | // Keep the claim: GitHub may have created the post anyway. |
| 345 | return unknownOutcome(`GitHub ${res.status}: ${text}`); |
| 346 | } |
| 347 | try { |
| 348 | await clearPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, heldClaim); |
| 349 | await clearDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id); |
| 350 | await releaseDraftClaim(env.CURATED_KV, heldClaim); |
| 351 | } catch { /* receipt remains; the next retry must reconcile */ } |
| 352 | return NextResponse.json({ error: `GitHub ${res.status}: ${text}` }, { status: 502 }); |
| 353 | }; |
| 354 | |
| 355 | if (draft.type === "digest") { |
| 356 | const digestBody = commentBody; |
| 357 | const firstLine = digestBody.split("\n")[0].replace(/^#+\s*/, "").trim(); |
| 358 | const title = firstLine || `Weekly Digest ${draft.id}`; |
| 359 | |
| 360 | const digestRepo = env.GITHUB_REPO ?? "codewhale-hq/CodeWhale"; |
| 361 | const issuesUrl = `https://api.github.com/repos/${digestRepo}/issues`; |
| 362 | |
| 363 | let issue: { number?: number; html_url?: string } | undefined; |
| 364 | if (lookSince) { |
| 365 | try { |
| 366 | const listUrl = `${issuesUrl}?labels=digest&state=all&since=${encodeURIComponent(lookSince)}&per_page=100`; |
| 367 | const found = await githubFind(listUrl, "token", (item) => !item.pull_request && item.title === title && item.body === digestBody); |
| 368 | if (found) { |
| 369 | issue = { |
| 370 | number: typeof found.number === "number" ? found.number : undefined, |
| 371 | html_url: typeof found.html_url === "string" ? found.html_url : undefined, |
| 372 | }; |
| 373 | } |
| 374 | } catch { |
| 375 | return lookupFailed(); |
| 376 | } |
| 377 | } |
| 378 | const reconciled = issue !== undefined; |
| 379 | if (!issue) { |
| 380 | let digestRes: Response | null; |
| 381 | try { digestRes = await postToGitHub(issuesUrl, { title, body: digestBody, labels: ["digest"] }, "token"); } |
| 382 | catch { return lookupFailed(); } |
| 383 | if (!digestRes) return unknownOutcome("GitHub request failed"); |
| 384 | if (!digestRes.ok) return githubFailed(digestRes); |
| 385 | issue = await digestRes.json().catch(() => ({})) as { number?: number; html_url?: string }; |
| 386 | } |
| 387 | |
| 388 | draft.posted = true; |
| 389 | if (typeof issue.number === "number") draft.targetNumber = issue.number; |
| 390 | if (typeof issue.html_url === "string") draft.targetUrl = issue.html_url; |
| 391 | let warning = await recordPosted(); |
| 392 | if (reconciled) warning ??= alreadyPosted; |
| 393 | |
| 394 | // Publishing to /digest is the approval, for the language shown to the |
| 395 | // maintainer only, and only of the record that renders to exactly the |
| 396 | // text they reviewed. An edited digest is posted to GitHub but not |
| 397 | // published there, since the record holds the unedited text. |
| 398 | let published = false; |
| 399 | if (editedBody === undefined || editedBody === originalBody) { |
| 400 | try { |
| 401 | const approval = await approveDigestRecord(env.CURATED_KV, draft, reviewLang); |
| 402 | published = approval === "published"; |
| 403 | if (published) revalidateDigestPage(); |
| 404 | else if (approval === "missing") { |
| 405 | warning ??= "Posted to GitHub, but the weekly record is gone, so /digest does not show it."; |
| 406 | } else { |
| 407 | warning ??= "Posted to GitHub, but the stored weekly record does not match the reviewed text, so /digest does not show it."; |
| 408 | } |
| 409 | } catch (e) { |
| 410 | warning ??= `Posted to GitHub, but publishing the digest page failed (${String(e)}).`; |
| 411 | } |
| 412 | } else { |
| 413 | warning ??= "Posted to GitHub. The text was edited, so /digest does not show this week."; |
| 414 | } |
| 415 | |
| 416 | return NextResponse.json({ |
| 417 | ok: true, |
| 418 | action: "posted", |
| 419 | number: issue.number, |
| 420 | url: issue.html_url, |
| 421 | published, |
| 422 | ...(warning ? { warning } : {}), |
| 423 | }); |
| 424 | } |
| 425 | |
| 426 | const repo = env.GITHUB_REPO ?? "codewhale-hq/CodeWhale"; |
| 427 | const commentUrl = `https://api.github.com/repos/${repo}/issues/${draft.targetNumber}/comments`; |
| 428 | |
| 429 | let reconciled = false; |
| 430 | if (lookSince) { |
| 431 | try { |
| 432 | const listUrl = `${commentUrl}?since=${encodeURIComponent(lookSince)}&per_page=100`; |
| 433 | reconciled = (await githubFind(listUrl, "Bearer", (comment) => comment.body === commentBody)) !== undefined; |
| 434 | } catch { |
| 435 | return lookupFailed(); |
| 436 | } |
| 437 | } |
| 438 | if (!reconciled) { |
| 439 | let ghRes: Response | null; |
| 440 | try { ghRes = await postToGitHub(commentUrl, { body: commentBody }, "Bearer"); } |
| 441 | catch { return lookupFailed(); } |
| 442 | if (!ghRes) return unknownOutcome("GitHub request failed"); |
| 443 | if (!ghRes.ok) return githubFailed(ghRes); |
| 444 | } |
| 445 | |
| 446 | // Mark as posted |
| 447 | draft.posted = true; |
| 448 | const warning = (await recordPosted()) ?? (reconciled ? alreadyPosted : undefined); |
| 449 | |
| 450 | return NextResponse.json({ ok: true, action: "posted", ...(warning ? { warning } : {}) }); |
| 451 | } |
| 452 | |
| 453 | // ALLOWED_ACTIONS guard above means this is unreachable. |
| 454 | return NextResponse.json({ error: "unknown action" }, { status: 400 }); |
| 455 | } |
| 456 |