Skip to content

fix: post-session regressions + mount /api/v1/settings + /themes static#523

Open
tayebmokni wants to merge 15 commits into
mainfrom
feat/post-session-cleanup
Open

fix: post-session regressions + mount /api/v1/settings + /themes static#523
tayebmokni wants to merge 15 commits into
mainfrom
feat/post-session-cleanup

Conversation

@tayebmokni
Copy link
Copy Markdown
Contributor

Summary

13 commits cleaning up regressions introduced during the recent admin-sweep
session, plus two new feature mounts that unblock the admin Settings tab
and let the public site finally load theme CSS.

Driven by the multi-agent review tracked in #522. Each commit maps to
one or more of the GitHub issues filed there.

Reverts / regression fixes

Features (P0 architectural gaps)

Build / dev hygiene

NOT in this PR (deliberately deferred)

Verification

  • go build ./... clean (api + worker)
  • go test ./internal/auth/login/... ./internal/rest/posts/... ./internal/admin/comments/... ./internal/admin/settings/... ./internal/themes/static/... ./cmd/server/... all pass
  • pnpm exec tsc --noEmit (admin) clean — no errors after stripping ignoreBuildErrors
  • Admin vitest suite: 604 pass, 0 fail (was 601 pass / 1 fail at start of session)
  • Live container probes:
    • /api/v1/settings?group=core.site → 200 with seeded defaults (was 404)
    • /themes/active/style.css → 200 (was 404)
    • /themes/../etc/passwd → 400 invalid path

Known follow-ups surfaced by this work

  • PATCH writes to /api/v1/settings will 500packages/go/settings/postgres.go::upsertSQL writes a namespace column that doesn't exist in migration 000008. Same bug in themes/store.go and customizer/store.go. GET works fine; PATCH explodes. Will file a follow-up issue.
  • core.reading / core.writing setting groups have no registered keys yet — empty {} response is fine but the admin form renders blank. Extend packages/go/settings/core.go in a follow-up.
  • ActiveResolver in /themes/static hits Postgres per CSS request — microsecond cost today (single-row autoloaded options), but a TTL cache would be appropriate as traffic grows.

Test plan

  • go build ./... in apps/api + apps/worker
  • go test ./... in apps/api + apps/worker
  • pnpm --filter @gonext/admin run build succeeds (no TS errors, no ESLint errors)
  • pnpm --filter @gonext/admin test --run — 600+ pass
  • Rebuild + restart api + admin containers; sign in as admin@example.com
  • Visit /settings/general — form renders with API-supplied defaults, no "API not available" banner
  • Visit /appearance/customizer — color/typography editor renders with live preview
  • Visit /posts — table renders, "New post" button navigates (existing route)
  • Visit /comments — "Open post" column links to /posts/[id] (not /posts/[id]/edit)
  • curl /themes/active/style.css returns 200 with text/css
  • Public site localhost:3000 HTML head includes <link rel="stylesheet" href=".../themes/active/style.css">

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown

Heads up — this PR touches strings that often signal a security disclosure (vulnerab, CVE-, exploit, bypass, or auth bypass).

If this PR fixes or describes a real vulnerability that has not yet been publicly disclosed, please stop and use the private path:

  1. Open a private security advisory, or
  2. Email security@gonext.io with subject [SECURITY] GoNext - <summary>.

See /SECURITY.md for the full disclosure flow and /docs/16-bug-bounty.md for bounty terms.

If this is a false positive (test fixture, doc update, release notes, etc.) please ignore this comment — the check is advisory only and does not block the PR.

Matched files:

  • apps/api/internal/themes/static/handler_test.go

tib0o0o and others added 14 commits May 28, 2026 11:04
The standalone Next.js build emitted by `output: 'standalone'` does not
auto-copy `.next/static` or `public/` into the standalone tree — the
operator must. Without these, every JS chunk and theme asset 404s on
the running container.

- Copy `.next/static` and `public/` into the standalone bundle.
- Restore `rewrites()` so `/api/*` proxies to GONEXT_API_URL (browser-
  side fetches stay same-origin; the rewrite hops to the docker-internal
  hostname).
- Thread `GONEXT_API_URL` through as a build-arg so `rewrites()` (which
  evaluates at build time) sees it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
`apiBaseUrl` was exported as a module-load constant. Next.js bundles
Client Components at build time, where `typeof window === 'undefined'`
evaluates TRUE on the bundler's Node process, so the SERVER branch
won and `http://api:8080` (docker-internal) was baked into every
browser bundle — producing DNS failures on every client-side fetch.

Convert `apiBaseUrl` from a const to a function called at request
time. Server Components resolve via `GONEXT_API_URL`; Client
Components resolve via `NEXT_PUBLIC_API_URL` (empty string preserved
for same-origin + rewrites).

Updated 16 call sites across plugins, themes, marketplace, migrate,
media, setup. Added api-client.test.ts with three cases: server
branch, client branch, empty-string preservation.

server-api.ts also imports apiBaseUrl — updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
Two parallel directories had Next.js refusing to start:
"different slug names for the same dynamic path ('name' != 'plugin')".

plugins/[name] is the canonical route — it carries the PluginDetailView
and the parent /plugins/[name] detail page. The duplicate
plugins/[plugin]/[slug] subdirectory was rebased in by a stale branch
and made the param disagree at the same path depth.

Content of plugins/[plugin]/[slug]/* was byte-equivalent to
plugins/[name]/[slug]/* except for one destructure rename
(\`{ plugin, slug }\` vs \`{ name, slug }\` at params destructure) and
the param type. Adopted the [name]/[slug] copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
The API used to emit \`data: null\` for empty pgx result sets, which
crashed admin Client Components reading \`items.length\` or
\`[...items, ...res.data]\`.

Defensive coerce on three surfaces:
- media/page.tsx server prefetch (initialData)
- MediaGrid.tsx fetchPage (subsequent pages)
- FolderTree.tsx listCollections refresh

The root cause is fixed at the API layer in a separate commit
(Page[T].MarshalJSON nil → []), but keep these guards as
belt-and-braces for any non-Page[T] endpoint that returns the same
nullable shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
…nk fix

Three related fixes around the posts list surface:

1. Admin posts page.tsx now adapts the API envelope (\`{data,
   pagination}\`) to the flatter shape PostListClient expects
   (\`{posts, nextCursor, total}\`) — and maps \`published_at|updated_at\`
   onto the UI's single \`date\` field. (#515 will replace this with
   generated api-types.)

2. \`status=any\` is the admin's documented "all statuses" sentinel.
   Aliased to empty in both rest/posts/handlers.go and
   admin/comments/list.go so the chips don't 400. (#516 tracks the
   missing alias in admin/search.)

3. \`postEditHref\` returns \`/posts/\${id}\` (the actual route) — not
   \`/posts/\${id}/edit\` (404s). Propagated to two dead-linking sites
   in the comments table that were missed in the original rename
   (#504). Updated PostListClient.test.tsx assertion. Added a
   regression test in CommentListClient.test.tsx.

4. Escaped two stray \`"\` characters in posts/[id]/page.tsx:237
   (react/no-unescaped-entities ESLint error).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
…ookie forwarding

Three small admin polish fixes:

- TopHeader \`View site\` now opens NEXT_PUBLIC_SITE_URL (or
  localhost:3000 fallback) in a new tab, instead of doing nothing
  (was \`href=\"/\"\` which routed back to the admin home).

- /pages/[id] block-editor button is disabled with a \"coming soon\"
  label. The previous Link pointed at /pages/[id]/edit which 404s —
  the dedicated block editor for pages was never built. Track full
  Pages module work in #506.

- /appearance/customizer is a Server Component but its
  \`fetchActive\` called the client-side \`api.get\` which sends
  \`credentials: 'include'\` (a no-op on Node.js). Cookies never
  reached the API → 401 → \"customizer unavailable\".

  Split: client surface stays in api.ts (for the save/reset
  mutations from the client form), new api-server.ts exports
  \`fetchActiveServer\` using serverApiFetch which forwards cookies
  via next/headers. page.tsx now imports the server variant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
\`json.Marshal(Page[T]{Data: nil})\` emits \`\"data\":null\`. Admin
Client Components then \`.map\` / spread / \`.length\` on null and
crash. Confirmed leaks: /api/v1/admin/media and /api/v1/admin/webhooks
both returned \`data:null\` for empty result sets.

One-line MarshalJSON on Page[T] coerces nil → \`[]T{}\` before
encoding, using an inner-struct alias trick to avoid recursing into
its own MarshalJSON. Unblocks 8+ admin Client Components that were
defensively guarding with \`Array.isArray(x.data) ? x.data : []\` —
those guards remain as belt-and-braces but the API stops emitting
the landmine.

Two tests: nil Data marshals as \`[]\`; non-nil Data round-trips
correctly with cursor pagination preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
…496)

Sessions.Create was called with \`data: nil\` regardless of the user's
role grants. Even though \`gonext init\` writes \`{\"roles\":
[\"super_admin\"]}\` into users.meta, the session principal had no
roles, so every capability check 403'd — including for the
bootstrap super_admin.

- UserRecord gets a Roles []string field, projected from
  users.meta.roles via \`COALESCE(u.meta->'roles', '[]'::jsonb)\` and
  JSON-decoded in the SQL adapter.

- login.UserByIDLookup type added for the TOTP finalize path which
  only has userID (recovered from the intermediate token), not email.

- adapters.go grows userLookupByID(pool) mirroring the by-email
  variant.

- main.go wires UserByID into the login.Deps literal.

- service.go threads user.Roles through completeLogin in BOTH paths:
  the password-only path AND finalizeTOTP's two completion sites.
  TOTP path re-fetches via UserByID; nil-safe degradation (no roles)
  if the lookup fails for any reason.

Without this, a super_admin who enrolled in TOTP would lose admin
permanently on next sign-in.

Tests: TestFinalizeTOTP_ThreadsRolesFromUserByID asserts roles flow
through the data map. TestFinalizeTOTP_NilUserByIDDoesNotPanic
confirms the zero-Deps fallback. Existing tests unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
The worker's \`taskspec.Dispatch\` was buried inside four layers of
\`else\` branches under storage.New + NewAbortOrphansSpec +
SeedDefaults. If MinIO init or scheduler seed failed, the worker
silently registered ZERO asynq task handlers — including the content
publisher and GC that the surrounding comments promise.

The boot logs still said \"worker started\" so the failure looked
like a healthy worker that didn't process tasks. Enqueued tasks
just dead-lettered after their retry budget.

Restructure to call Dispatch ONCE, unconditionally, after every
Register attempt. Failed registers logged warnings — they won't be
in taskspec.Default(), so Dispatch is a no-op for them. cronReg
declared at outer scope so scheduler.SeedDefaults runs even when
storage failed.

Double-registration panic (the original bug this code was avoiding)
remains impossible: each spec is registered into Default() at most
once.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
Every admin Settings sub-page (general, permalinks, reading, writing,
privacy) was calling /api/v1/settings?group=<group> and getting 404
— the settings.Mount call was missing from main.go. The package
existed at packages/go/settings with Registry + PostgresStore +
MemoryStore implementations, but no HTTP layer.

Added apps/api/internal/admin/settings/ with:
- handler.go: Deps + Mount + GET/PATCH handlers + groupPrefix mapper.
  GET filters by ?group= against the registered key prefixes
  (core.site, core.permalinks, core.reading, core.writing, privacy);
  empty groups return {} (200), not 500. PATCH validates against
  the registry schema, capability-gates writes through policy.
- handler_test.go: 11 cases. Empty group returns {}. Auth required.
  Capability gate denies subscriber writes. Unknown keys 400.
  Validation errors 400. Bad JSON 400. Nil-Deps Mount errors.

Wired into main.go via a per-process registry seeded with
RegisterCore + RegisterPrivacy. Store falls back to MemoryStore
when pool is nil (test boot contexts). Routes wrapped with
authmw.RequireSession because the OptionalSession middleware was
reverted in #31.

Verified live: curl /api/v1/settings?group=core.site → 200 with
seeded core.site.{name,tagline,url,default_role}. core.reading +
core.writing return {} (no keys registered yet — fix in a separate
package change).

Known follow-up: packages/go/settings/postgres.go::upsertSQL writes
to a `namespace` column that does not exist in migration 000008.
PATCH will 500 against the live DB until that's resolved. Same bug
in themes/store.go and customizer/store.go. To be filed separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
The early-hints provider at apps/api/cmd/server/main.go:431 preloads
/themes/active/style.css, but nothing serves it — every theme asset
404'd. The public site at apps/web fell back to its globals.css
defaults; no theme tokens reached the browser.

apps/api/internal/themes/static/ (new):
- handler.go: Mount(mux, "/themes", Deps{ThemeDir, ActiveResolver,
  Logger}). Path layout: /themes/{slug}/{file...}. Sentinel slug
  "active" resolves through ActiveResolver. Path traversal rejected
  before any FS read. Content-Type per file extension. 1h
  Cache-Control. Missing/invalid → 404 (transient DB hiccups on the
  resolver shouldn't bring down public CSS).
- handler_test.go: 14 cases. Real file, nested path, HEAD short-
  circuit, path-traversal flavors, ..-segment guard, ActiveResolver
  wiring, empty resolver → 404, missing file → 404, directory →
  404, slug syntax check, Mount Deps validation, isSafeRelPath
  units, contentType units.

Wired into main.go:
- Extracted &adminthemes.PgxActiveStore{Pool: pool} to a local var
  so it's shared with both the admin themes Mount and the static
  resolver closure (single source of truth on which slug is
  currently active).
- Mount block after the admin/themes block.

apps/web/src/app/layout.tsx:
- Added <link rel="stylesheet" href="${API_URL}/themes/active/style.css">
  inside a new <head> block so the public site actually pulls the
  active theme CSS. PUBLIC_API_URL resolves via NEXT_PUBLIC_API_URL
  with localhost:8080 fallback.

Verified live:
  /themes/gn-hello/style.css → 200, text/css, 4683 bytes, cache 1h
  /themes/active/style.css   → 200, same body (resolver wired)
  /themes/.../etc/passwd     → 400 invalid path
  /themes/missing/style.css  → 404

Follow-up: ActiveResolver hits Postgres on every CSS request. The
options table autoloads so the query is microsecond-cheap today,
but a TTL cache would be appropriate as traffic grows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
…verride (#517)

Working docker-compose.override.yml is generated by each developer
locally (postgres port override, admin build-args, aligned auth
secrets, custom entrypoints). It is intentionally untracked so each
operator can set their own secrets — committing the file would
multiply the same dev secrets across every developer's machine and
set a bad precedent.

- .gitignore: ignore docker-compose.override.yml at repo root.
- docker-compose.override.example.yml: template with the exact same
  structure + placeholder secrets + inline comments explaining
  each override.
- README: a "Local dev setup" subsection documenting the copy step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
next.config.ts no longer carries \`typescript: { ignoreBuildErrors:
true }\`, so the 12 pre-existing errors that flag was hiding now
fail \`next build\` and CI's \`pnpm lint\`. Address them all here.

- media/types.ts: \`MediaAsset\` was missing four fields the detail
  view (\`MediaDetailClient.tsx\`) actually reads — \`hls_url\`,
  \`source_url\`, \`is_proxied\`, \`has_extracted_text\`. Added as
  optional per the on-wire shape (worker pipeline #476 populates
  them on a subset of asset types). Resolves 11/12 errors across
  MediaDetailClient.tsx and its test fixture.

- appearance/menus/MenusClient.tsx:157: \`Array.splice\` returns
  \`T[]\` so the destructured element typechecks as \`T | undefined\`
  under noUncheckedIndexedAccess. Added an explicit guard before
  re-inserting so the array can't get a stray undefined.

\`pnpm exec tsc --noEmit\` is now clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
cli/gonext/cmd/audit/audit.go and verify.go both declared the same
package-level symbols — ExitOK, ExitFail, ExitUsage, usage, Run,
RunOS — because the verify.go landed in PR #492 carrying its own
copy of the entry-point boilerplate that was already in audit.go.
\`go vet\` rejects this with \"X redeclared in this block\" and CI
lint-go has been red on main since the merge.

Make audit.go the single owner of the package surface:
- Add \`case \"verify\": return runVerify(args[1:], stdout, stderr)\`
  to the dispatch switch.
- Extend the usage banner with the verify subcommand and its env
  requirement (GONEXT_AUDIT_HMAC_KEY).

Strip verify.go down to just \`runVerify\` and its imports —
everything else (exit codes, Run, RunOS, usage) is owned by
audit.go.

go vet ./... clean on cli/gonext after this change. cli/gonext/cmd/
audit unit tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
@tayebmokni tayebmokni force-pushed the feat/post-session-cleanup branch from 2ad3b22 to fe63030 Compare May 28, 2026 09:04
@tayebmokni
Copy link
Copy Markdown
Contributor Author

CI status update

Re-pushed with two fixes that close the CI bar for the code this PR touches:

Now passing ✅

  • dco-check, lint-go, lint-web, test-web, lint-docs, bundle-budget, changes
  • SBOM generators (api, web, cli-gonext, plugin-seo)
  • Security disclosure scan

Still failing — but pre-existing on main, not introduced here ❌

  • `build`: "No files at apps/docs/.next" — docs build artifact step, fails before my work
  • `security-scan` (govulncheck): flags stdlib CVEs in `html.Parse` / `template.Execute` / `mail.ParseAddress` — needs Go toolchain bump
  • `test-go (1)`: `TestVerify_MissingKey`, `TestDrain_RequiresConfirmation`, `TestPluginPrefix`, `TestFailed_FiltersByQueue`, `TestDrain_HappyPath` — tests stale after a recent PR added required env vars to `config.Load()`; CLI subcommand tests don't set them
  • `test-go (2)`: `TestRun_IntegrationApplyAndRollback`, `TestLogStreamHandler_RoundTrip` — pre-existing flake / integration deps
  • `smoke`, `e2e-smoke`, `e2e-blog-loop`: depend on `build` artifact; cascade-fail

Verified on origin/main: all 6 remaining failures reproduce identically without my code. The latest main CI run (`1294d92`) is also red on all of these.

Code review

Manual review + agent review both confirm no blockers:

  • Auth bypass / privilege escalation: clean
  • Path traversal in /themes/static: defense in depth (isSafeRelPath + filepath.Clean + isUnder)
  • SQL injection in userLookupByID: parameterized pgx query
  • JSON Marshal recursion in Page[T]: local pageOut struct avoids the generic trap
  • Builds clean: api / admin tsc / cli go vet

Recommend filing follow-ups for the pre-existing CI failures rather than expanding scope here. Happy to do so if requested.

🤖 PR-management automation

#524)

Three writer SQLs INSERTed into a \`namespace\` column that the live
schema doesn't have. Migration 000008_options.up.sql defines:

    CREATE TABLE options (
        key, value, autoload, is_protected,
        created_at, updated_at, version
    );

No \`namespace\`. Live INSERT reproduces:

    ERROR: column "namespace" of relation "options" does not exist

So every admin Save through the Settings tab, theme activate, and
customizer override Save returned 500 the moment they hit Postgres.
GET worked because SELECT never referenced the column.

Drop \`namespace\` from all three upserts:
- packages/go/settings/postgres.go::upsertSQL
- apps/api/internal/admin/themes/store.go::writeActiveSQL
- apps/api/internal/admin/customizer/store.go::writeOverridesSQL

Also drop the explicit \`updated_at = now()\` — the table default
covers INSERT and the options_touch trigger bumps it on UPDATE.

The \`namespaceFor\` helper in settings/postgres.go becomes unused
but is retained with a comment so the per-plugin uninstall sweep
(which will ship a migration adding the column) can pass it back
in. The package-level docstring is corrected to match the actual
migration schema.

Update TestPostgresStore_WriteValidatesAndUpserts:
- args[3] namespace assertion removed (panic'd index-out-of-range
  after the SQL change)
- assert arg count == 3 (key, value, autoload)
- assert the SQL string itself does NOT contain "namespace" — a
  regression guard so a future re-introduction blows up in unit
  tests before it ever hits Postgres again

End-to-end verified live:

    $ curl -X PATCH -d '{"core.site.name":"Verified Live Save"}' \
        http://localhost:8080/api/v1/settings
    HTTP 200
    $ psql -c "SELECT value FROM options WHERE key='core.site.name'"
    "Verified Live Save"

Same fix applies to the themes activate + customizer save endpoints
even though the bug was identified through #499's PATCH path.

Closes #524.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment