Skip to content

fix(frontend): patched Next.js, Node 22 CI, mobile nav, error pages, mock-mode banner - #156

Merged
Eslam93 merged 27 commits into
mainfrom
fix/frontend-hardening
Nov 7, 2026
Merged

Eslam93 merged 27 commits into
mainfrom
fix/frontend-hardening

Conversation

@Eslam93

@Eslam93 Eslam93 commented Nov 7, 2026

Copy link
Copy Markdown
Owner

Why

These are the verified frontend defects from the post-interview audit:

  • next 15.1.3 is in the React2Shell range (CVE-2025-66478 / CVE-2025-55182) for anyone running next start or SSR.
  • CI ran Node 20, which is end-of-life, and didn't build the static export that actually ships.
  • Generated Markdown rendered as flat text: the prose classes did nothing because the typography plugin was never installed.
  • Phones had no navigation.
  • There were no error or 404 pages.
  • The frontend Sentry config never loaded.
  • The landing page still showed the demo login.
  • The UI couldn't tell you it was running in mock mode.

What changed

Commit Change
fix(deps) Bumps next and eslint-config-next to 15.5.29, the latest patched 15.5.x. typedRoutes moves out of experimental, as 15.5 requires.
ci All workflows run on Node 22. frontend-ci builds the static export (NEXT_OUTPUT=export) and fails if out/index.html or out/404.html is missing. frontend-ci and infra-ci get permissions: contents: read. engines.node is now >=22.
fix(ui) typography @tailwindcss/typography 0.5.20 is installed and registered, so generated content renders headings and lists. I confirmed .prose rules are in the built CSS.
fix(ui) mobile nav Menu button below md (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 gets aria-current="page".
fix(ui) error pages not-found.tsx (exported as out/404.html), error.tsx with retry, and global-error.tsx. Raw error text is never shown to users.
chore(frontend) Removes @sentry/nextjs and the four config files that never loaded: there was no withSentryConfig, and the static export doesn't run instrumentation. Backend Sentry is untouched. Docs and examples are updated.
fix(ui) copy Removes the landing-page demo credentials, the /usage placeholder page with its nav item, and the "demo window" copy.
feat(ui) Mock-mode banner in the protected layout, driven by GET /api/v1/meta (added in #155). It renders nothing while loading, on error (older backend), or in any other mode.
fix(ui) Friendly messages for the new AI_* error codes, defined once in lib/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, and NEXT_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 query retry reads error.status, but the hooks throw plain Errors, so the "never retry 401/403/422" guard never fires.
  • The RATE_LIMITED copy mentions the generate limit even on improve and image requests, which have different limits.
  • AI_RATE_LIMITED responses carry details.retry_after_seconds, because a cross-origin frontend can't read the Retry-After header. 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

Eslam93 and others added 26 commits November 6, 2026 20:25
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>
@Eslam93
Eslam93 marked this pull request as ready for review November 7, 2026 01:32
@Eslam93
Eslam93 changed the base branch from fix/backend-hardening to main November 7, 2026 03:27
#155 landed on main as one squash commit (3e1b333) whose tree is this
branch's old base, so this merge keeps this branch's tree unchanged
(-s ours) and makes main its merge base. The PR diff is again only this
branch's own changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Eslam93
Eslam93 merged commit bfac496 into main Nov 7, 2026
3 checks passed
@Eslam93
Eslam93 deleted the fix/frontend-hardening branch November 7, 2026 03:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant