Repository navigation
fix(frontend): patched Next.js, Node 22 CI, mobile nav, error pages, mock-mode banner - #156
Merged
Merged
Conversation
OpenAI bills gpt-5.4-mini at $0.75 input, $0.075 cached input and $4.50 output per 1M tokens; the old $0.15/$0.60 table recorded text costs 5-7.5x too low. Prices now live in a dated, sourced catalog, cached prompt tokens bill at the cached rate, and an unpriced response snapshot falls back to the configured model instead of raising after the call was paid for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OpenAI shut gpt-image-1 down on 2026-10-23, so image generation with the shipped default failed. gpt-image-2.5-flare is OpenAI's named replacement. GPT Image models bill per token, so cost now comes from response.usage at catalog rates, falling back to a labelled per-image estimate only when usage is missing; PNG output is requested to match the stored extension. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unknown or unpriced model was only discovered after the paid API call, and nothing flagged a configured model past its shutdown date. Settings now checks both OpenAI models against the catalog in openai mode: a shut-down model fails in every environment, and an unknown, wrong-kind or unpriced one fails outside local. Errors name the env var and replacement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gpt-image-1's shutdown went unnoticed until image generation broke. check_model_lifecycle fails if a configured model is unknown, unpriced, retired or within --days of a catalogued shutdown, or if OpenAI's deprecations page lists it as deprecated (substitute-only mentions don't count; an unreadable page fails as "could not verify"). Runs weekly in CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaying a rotated refresh token revoked all of the user's refresh tokens, but the 401 that followed made get_db_session roll that back, so a stolen chain stayed valid for 30 days. Commit it first; two tabs refreshing at once now do sign the user out on every device, as documented. Tests run the real get_db_session (a bare-yield override hid this); a missing Postgres fails CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The validation handler returned Pydantic's raw error dicts, whose `input` is the rejected value itself, so a weak or over-long password came straight back in the 422 body, along with Pydantic's `ctx` and `url`. Each error now keeps only `loc`, `msg` and `type`. The envelope shape and the WEAK_PASSWORD remap are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Starlette runs an Exception handler in ServerErrorMiddleware, outside every user middleware, so unhandled 500s had a null meta.request_id and no X-Request-ID, security or CORS headers. An innermost pure-ASGI catch-all now logs them, reports them to Sentry (they no longer reach its ASGI hook) and sends the INTERNAL_ERROR envelope. The old handler stays as a last resort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two unrelated ProviderError classes meant the text path never translated provider failures, so timeouts, 429s and truncated output became generic 500s, while the image path wrapped OpenAI's error text into its 502 body. One ProviderError family now carries a typed kind that a single handler maps to a fixed status, code and message; insufficient_quota isn't retried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ailing field Stage 2 dropped strict json_schema and told the model its JSON was invalid, though the usual first-pass failure is valid JSON breaking a Pydantic limit (meta_description's 160 characters), so the paid retry often failed again. Content and both improver stages now retry under the same strict schema and name each failing field with Pydantic's message, never the value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The current-user and brand-voice reads autobegin a transaction that stayed open, idle in transaction on a pooled connection, through each 5-30 s model call, so about 20 concurrent generations exhausted the 10+10 pool. Content generation and both improver stages now commit it before every provider call. The image path keeps its FOR UPDATE NOWAIT lock; that needs a schema change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing tested the prompt wording, so a changed instruction or default could ship unseen and without a PROMPT_VERSION bump. Golden files now pin the rendered system and user prompts of the four content types and both improver stages, from fixed inputs; UPDATE_GOLDENS=1 regenerates them. They match the current prompts: this commit changes no prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The LinkedIn hook asked for a "specific stat", ad copy for "concrete numbers when honest" and the persuasive improver for "concrete numbers and proof", so the model made up figures the brief never gave; the email example signed off as "The Magna team". Those three now use only facts stated in the brief, the sign-off ends in a [Your name] placeholder, and all four prompts move to v2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and Redis Compose published Postgres (password app) and the backend on every interface, and started an nginx stub and a Redis that nothing reads. /health drops its never-probed `redis: "unknown"`: in-repo consumers only check the status code, the frontend types `dependencies` as a string map, and the top-level keys are unchanged. Stale .env Redis keys are ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The frontend can't tell that a backend runs AI_PROVIDER_MODE=mock, so canned content can pass for real model output. A public route reports the mode for a persistent banner. It needs no auth, so it returns only provider_mode and app_version: no model ids, keys or other settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under the strict schema, a corrective retry that stops at the length limit or is filtered raised AI_OUTPUT_TRUNCATED or AI_CONTENT_REJECTED: the request failed after two or three billed calls and saved nothing, where it used to degrade to stage 3. The provider's error now carries the billed reply, so content and the improver degrade with attempt 1's output and count every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The corrective-retry instruction no longer claims the reply was invalid JSON, and the retry now runs under the strict schema with a note naming the failing field, yet blog_post stayed at v1 while the other prompt modules moved to v2. A row's prompt_version is the only record of which retry wording produced it; the snapshots keep only the first attempt's prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The services comment still said the integration tests skip when Postgres is unreachable, and gave a stale test count. With REQUIRE_DB=1 the job fails instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next 15.1.3 is in the React2Shell range (CVE-2025-66478 / CVE-2025-55182), which affects anyone running `next start` or SSR. Bump next and eslint-config-next to 15.5.29, the latest 15.5.x, and keep the exact pins. pnpm regenerated the lockfile. typedRoutes is stable in 15.5, so it moves out of `experimental`. `next lint` now prints a deprecation notice but still works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node 20 reached end of life in April 2026. frontend-ci, infra-ci and the backend's openapi-typescript step now run on Node 22, and the frontend's engines field follows. frontend-ci builds with NEXT_OUTPUT=export, the form the frontend ships in, and fails if out/index.html or out/404.html is missing. frontend-ci and infra-ci get a read-only GITHUB_TOKEN. The README's local CI recipe and CI table match the new build step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate-result and content-detail-dialog wrap the react-markdown output in `prose prose-sm dark:prose-invert`, but the typography plugin was never installed, so headings, lists and emphasis rendered as flat text. Add @tailwindcss/typography 0.5.20 and register it. The built CSS now has the .prose, .prose-sm and dark-mode prose-invert rules. Prettier's Tailwind plugin now recognises the prose classes, so it reorders them in the two components. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar is `hidden md:flex`, so below `md` the protected app had no navigation at all. The header now shows a menu button on small screens. It toggles a panel with the same items as the sidebar. - NAV_ITEMS and the link rendering move to components/nav-links.tsx. Sidebar and the new MobileNav both use it, so the items and the active-item highlight can't drift apart. The active link also gets aria-current="page". - The button has aria-expanded and aria-controls. The panel closes on Escape, when an item is selected and on any route change. - The panel covers the top of the page, so it also closes when a press or focus lands outside the menu. Tabbing past the last item no longer leaves the newly focused control hidden under the panel (WCAG 2.4.11). - Escape returns focus to the button only when focus was in the menu. Focus anywhere else stays where it is. - tests/mobile-nav.test.tsx covers opening, Escape with focus inside and outside the menu, selecting an item, a route change, tabbing out of the menu, and presses inside and outside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app had no error boundaries and relied on Next's bare default 404. - app/not-found.tsx: a 404 page with a link home. The static export writes it to out/404.html. - app/error.tsx: route-level boundary with a "Try again" button that calls reset(). The raw error is logged with console.error and never rendered, because its message can carry internals. - app/global-error.tsx: minimal last-resort boundary for errors in the root layout. It renders its own <html> and <body>. The pages use the v2 Button (buttonVariants for the 404 link). tests/error-page.test.tsx checks that "Try again" calls reset and that the message stays off screen. ARCHITECTURE.md's "What's next" list no longer counts 404/500 pages as pending, and its error-boundary item narrows to the section-level boundaries that are still to do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next.config.ts never wrapped the config with withSentryConfig, so the
client config was never loaded. The server and edge hooks in
instrumentation.ts don't run in the static export either. The setup
only added 153 packages and OpenTelemetry "Critical dependency"
webpack warnings to every build.
Remove sentry.{client,server,edge}.config.ts, instrumentation.ts and
@sentry/nextjs. Drop NEXT_PUBLIC_SENTRY_DSN and NEXT_PUBLIC_SENTRY_ENV
from .env.example, the frontend README and the Amplify step in
infra/DEPLOY.md. The backend's Sentry (SENTRY_DSN for FastAPI) is
unchanged, and the README now says Sentry covers the backend.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hosted demo is gone, so the landing page no longer shows the demo
login. The rest of the landing page is unchanged.
/usage was a placeholder ("Coming up after the demo window"). Delete
the page and its nav item. The settings page stops talking about a
"demo window" and shipping "after the demo"; its comment and subtitle
now say plainly that preferences are not available yet.
The frontend README drops the /usage route and the claim that the
live demo URL is the end-to-end smoke test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With AI_PROVIDER_MODE=mock the backend returns canned sample text for every request, whatever the input, and nothing in the UI said so. The protected layout now shows a warning strip between the header and the page while the backend reports provider_mode "mock". - types/api.d.ts gains GET /meta and a MetaResponse schema (provider_mode "openai" | "mock" | "bedrock", app_version string), for the backend's new public endpoint. - lib/meta/hooks.ts adds useMetaQuery. The mode only changes when the backend restarts, so staleTime is Infinity, and a failure is retried once. - components/mock-mode-banner.tsx renders the v2 warning Alert only for "mock". While /meta loads, when it fails (an older backend has no /meta) and for every other mode, it renders nothing. It sets role="status" instead of the variant's default "alert", because a standing notice should not interrupt a screen reader. - tests/mock-mode-banner.test.tsx mocks the API client and covers mock, openai, bedrock, loading, and a 404 that is retried once only. The frontend README's layout lists lib/meta/hooks.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend now reports AI-provider failures with codes of their own. The frontend had no copy for any of them and fell back to the envelope's message. lib/errors/ai-codes.ts holds the copy for AI_PROVIDER_AUTH_FAILED, AI_RATE_LIMITED, AI_UNAVAILABLE, AI_TIMEOUT, AI_OUTPUT_TRUNCATED, AI_CONTENT_REJECTED, AI_PROVIDER_MISCONFIGURED and PROVIDER_ERROR, in the same voice as the rest of the UI's error messages. Each one says whether trying again helps. The key-rejected and misconfigured messages say it is a server configuration problem, not something the user did. Mapping PROVIDER_ERROR also keeps raw upstream error text off the screen with an older backend, whose PROVIDER_ERROR message can include it. FRIENDLY_MESSAGES in lib/content/hooks.ts spreads the shared map ahead of its own entries. It is the only map behind the AI endpoints: the image, improver and brand-voice hooks reuse generateErrorMessage instead of keeping maps of their own, and lib/auth/errors.ts covers only the auth endpoints, which never call the provider. tests/ai-error-messages.test.ts goes through generateErrorMessage. It checks the copy for three codes, that the shared map has exactly the contract's codes, that the domain entries survive, and both fallbacks: the envelope's message for an unknown code and the caller's fallback when there is no envelope. The frontend README's layout lists lib/errors/ai-codes.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
These are the verified frontend defects from the post-interview audit:
next15.1.3 is in the React2Shell range (CVE-2025-66478 / CVE-2025-55182) for anyone runningnext startor SSR.proseclasses did nothing because the typography plugin was never installed.What changed
fix(deps)nextandeslint-config-nextto 15.5.29, the latest patched 15.5.x.typedRoutesmoves out ofexperimental, as 15.5 requires.ciNEXT_OUTPUT=export) and fails ifout/index.htmlorout/404.htmlis missing. frontend-ci and infra-ci getpermissions: contents: read.engines.nodeis now>=22.fix(ui)typography@tailwindcss/typography0.5.20 is installed and registered, so generated content renders headings and lists. I confirmed.proserules are in the built CSS.fix(ui)mobile navmd(aria-expanded,aria-controls). It closes on Escape, route change, picking an item, and a press or focus outside the menu. The nav items live in one module shared with the sidebar, and the active link getsaria-current="page".fix(ui)error pagesnot-found.tsx(exported asout/404.html),error.tsxwith retry, andglobal-error.tsx. Raw error text is never shown to users.chore(frontend)@sentry/nextjsand the four config files that never loaded: there was nowithSentryConfig, and the static export doesn't run instrumentation. Backend Sentry is untouched. Docs and examples are updated.fix(ui)copy/usageplaceholder page with its nav item, and the "demo window" copy.feat(ui)GET /api/v1/meta(added in #155). It renders nothing while loading, on error (older backend), or in any other mode.fix(ui)AI_*error codes, defined once inlib/errors/ai-codes.ts. The two operator-side codes say plainly that retrying won't help.Verification
All pass:
pnpm lint,pnpm format:check,pnpm typecheck,pnpm test(48 tests, up from 28),pnpm build, andNEXT_OUTPUT=export pnpm build.The banner was checked visually against a fake mock-mode API at 1024px and 375px, with no console errors.
Follow-ups noticed but not fixed here
components/providers.tsx: the global queryretryreadserror.status, but the hooks throw plainErrors, so the "never retry 401/403/422" guard never fires.RATE_LIMITEDcopy mentions the generate limit even on improve and image requests, which have different limits.AI_RATE_LIMITEDresponses carrydetails.retry_after_seconds, because a cross-origin frontend can't read theRetry-Afterheader. The UI doesn't show that wait yet.Stack
PR 3 of 3, stacked on #155 (backend hardening), which is stacked on #154. Merge those first. GitHub retargets this PR once its base merges.
🤖 Generated with Claude Code