Skip to content

feat: Wave 3 — 4 large features (plugins CRUD, PAT, customizer preview, Pages real)#529

Open
tayebmokni wants to merge 4 commits into
feat/wave-2-public-site-wiringfrom
feat/wave-3-large-features
Open

feat: Wave 3 — 4 large features (plugins CRUD, PAT, customizer preview, Pages real)#529
tayebmokni wants to merge 4 commits into
feat/wave-2-public-site-wiringfrom
feat/wave-3-large-features

Conversation

@tayebmokni
Copy link
Copy Markdown
Contributor

Stacked on PR #528 (Wave 2) → PR #527 (Wave 1) → PR #523 (post-session cleanup).

Closes 4 issues from the #522 tracker

4 commits

  1. feat(api): mount /api/v1/plugins lifecycle CRUD (#500)
  2. feat(auth): mount /api/v1/me/tokens — Personal Access Tokens (#511)
  3. feat(api): customizer preview + site-editor parts endpoints (#512)
  4. feat(admin+api): wire admin Pages module to real API (#506) — adds post_type filter to posts REST

Verification

  • go build ./... clean
  • pnpm exec tsc --noEmit 0 errors
  • New tests: 14 (plugins) + 7 (PAT) + 23 (customizer+site-editor) + 22 (pages) = ~66 new tests
  • All 638 admin tests pass
  • Live curl'd each new endpoint — all 200 / 201 / 204 / 401 / 403 / 404 paths verified

Known follow-ups

  • Plugins lifecycle.PostgresStorage needs a migration creating the plugins table. Today only List handles 42P01 gracefully; Get/Install/UpdateState would 500 on a fresh DB. Filing separately.
  • PAT package is at apps/api/internal/auth/pat/. An older apps/api/internal/admin/tokens package exists with different token format — currently unmounted. Pick one + repoint admin/users/{id}/tokens.
  • Pages create-side: /pages/new POSTs {post_type:'page'} but the create handler ignores the body field (hard-coded to PostTypePost). Small body-override follow-up.

What's next

Wave 4: #514 generated api-types from OpenAPI, #521 regression tests backfill. Then close #522 tracker.

🤖 Generated with Claude Code

tib0o0o and others added 4 commits May 28, 2026 12:53
The admin Plugins page was showing \"Couldn't load plugins from the
API (HTTP 404)\" — the lifecycle Manager existed but no HTTP layer
sat on top of it.

New package: \`apps/api/internal/admin/plugins/\`

Routes (all RequireSession-wrapped at mount):
- GET    /api/v1/plugins                — list (router.Page envelope)
- GET    /api/v1/plugins/{name}         — detail w/ manifestRaw
- POST   /api/v1/plugins/install        — multipart upload, 80 MiB cap, cap:install_plugins
- POST   /api/v1/plugins/{name}/activate    — cap:activate_plugins
- POST   /api/v1/plugins/{name}/deactivate  — cap:activate_plugins
- DELETE /api/v1/plugins/{name}         — cap:install_plugins (uninstall)

HTTP status mapping: 401 (unauth), 403 (no-cap), 404 (unknown),
409 (invalid state transition).

Install streams the multipart \"bundle\" part straight into
\`Manager.Install\` — no temp file. Audit events fire from the
lifecycle Manager (plugin.installed/activated/deactivated/uninstalled).

14 tests: empty list shape, list projection, 401 unauthed list, 404
unknown get, activate auth/cap/success/conflict, deactivate, uninstall,
install content-type + cap gates.

Wired in main.go: dedicated lifecycle.NewManager against
NewPostgresStorage(pool) (memory fallback for pool-less tests),
wrapped in authmw.RequireSession. Mount registers both
\`/api/v1/plugins\` and \`/api/v1/plugins/\` so the literal-list and
\`{name}\` subtree both hit the gated handler.

Verified live:
  curl /api/v1/plugins → 200 {\"data\":[],\"pagination\":{...}}

Note: the \`plugins\` table is missing from migrations.
PostgresStorage.List handles 42P01 gracefully (returns empty),
but Get/Install/UpdateState would 500. Migration follows in a
separate commit.

Closes #500.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
Admin Settings → Tokens page was calling \`/api/v1/me/tokens\` and
getting 404. PAT issuance is table-stakes for any CI / automation
integration against a remote GoNext install.

New package: \`apps/api/internal/auth/pat/\`

Routes (RequireSession-wrapped):
- GET    /api/v1/me/tokens       — list (no secrets, only prefix)
- POST   /api/v1/me/tokens       — create + return raw secret ONCE
- DELETE /api/v1/me/tokens/{id}  — revoke

Token format: \`gn_pat_<32-byte-base64>\` (24 raw bytes from crypto/
rand, base64.RawURLEncoding). Hashed via \`packages/go/auth/password.
Hash\` with the configured \`GONEXT_AUTH_PEPPER\`. Prefix is first 8
chars of the secret tail for display.

Owner isolation: every query scoped by caller's UserID from
policy.FromContext. \"Wrong owner\" and \"not found\" both return 404
— no existence oracle for cross-user enumeration.

Migration: \`000026_personal_access_tokens.up.sql\` already shipped.

Audit: emits \`auth.pat.created\` / \`auth.pat.revoked\`.

7 tests against testcontainers Postgres: empty list, create+raw-
token-once, revoke removes row, revoke other-user returns 404, 401
anonymous, empty-name rejection, audit emission.

Verified live: full create/list/revoke round-trip works against the
running stack.

Note: an older \`apps/api/internal/admin/tokens\` package exists with
similar surface but different token format. It's currently unmounted;
choice of which to keep + repointing admin/users/{id}/tokens at one
of them is a follow-up.

Closes #511.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
Two endpoints the admin Customizer + Site Editor pages were rendering
against — both 404'd previously.

### POST /api/v1/admin/customizer/preview

Extends existing internal/admin/customizer/handler.go. Body shape
permissive — accepts both \`{settings: {...}}\` (direct override) and
\`{overrides: {...}}\` (envelope) since the admin client posts both.
Returns \`{themeSlug, cssCustomProperties}\` after merging override
onto the active theme manifest. Empty body returns base CSS.

Permissive JSON decode (no DisallowUnknownFields) since preview is
keystroke-grained; strict validation lives on PUT. Gated by
theme.customize.

6 new test cases (base CSS, override applied, envelope variant,
forbidden, unauth, invalid JSON).

### GET/PUT /api/v1/admin/site-editor/parts(/{name})

New package: internal/admin/siteeditor/. File-based — reads/writes
\`themes/{active}/parts/{name}.html\` for theme template parts the
operator edits in-browser.

Part shape: {name, area: header|footer|general, title, content}.

Security: validates name against active theme.json templateParts
declaration (undeclared name → 404 — no arbitrary file writes
allowed). Path-traversal guard rejects /, \\, .., control chars,
uppercase → 400. Atomic write via temp file + rename.

Gated by \`manage_themes\` OR \`theme.edit_parts\` (either grants).

17 tests covering list/get/put, undeclared name 404, traversal flavors
400, unauth 401, forbidden 403, missing-file empty content, MkdirAll
for fresh installs.

### main.go wiring

Both endpoints now go through sub-mux + RequireSession wrap (was
missing on the existing customizer mount — that's why
/api/v1/admin/customizer/active was previously returning 401).

Verified live:
  GET  /api/v1/admin/site-editor/parts → 200 + header + footer
  POST /api/v1/admin/customizer/preview {} → 200 base CSS
  POST /api/v1/admin/customizer/preview {paper:#112233} → 200 w/ color flipped

Closes #512.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
Pages list was hardcoded \`SEED_PAGES\` and the detail save handler was
\`console.log\`. Replace both with real CRUD against the existing
/api/v1/posts surface.

### API: post_type filter

apps/api/internal/rest/posts/model.go grows a \`PostType\` field on
ListFilter. handlers.go's parseListQuery accepts \`?post_type=post|page\`
with closed-set validation (anything else → 400 invalid_post_type).
list() resolves the discriminator at request time: filter override
wins over the mount's default PostType.

This lets the single /api/v1/posts mount serve both Posts list and
Pages list — no second mount required.

### Admin Pages list

apps/admin/src/app/(authenticated)/pages/page.tsx:
- Drops SEED_PAGES entirely.
- Server Component fetching \`/api/v1/posts?post_type=page&status=any\`
  via serverApiFetch (cookie-forwarding).
- Exported \`adaptApiPage\` and \`ApiPagePost\` types — testable in
  isolation.
- Status normalization (UI \`published\` ↔ API \`publish\`).
- Updated-at fallback chain: updated_at → published_at → created_at.
- Empty/error states stay inside the table card so layout doesn't
  shift.

### Admin Pages detail

apps/admin/src/app/(authenticated)/pages/[id]/page.tsx:
- Replaced console.log save with \`api.patch('/api/v1/posts/{id}',
  {title, slug, status})\`.
- UI status maps at the boundary (\`publish\` → \`published\` on the wire).
- Try/catch with ApiError branch surfaces an amber error banner.
- Success indicator: 3s "Saved" pip next to the Save button.
- Block editor button stays disabled (separate issue).

### Tests

22 new tests across pages/page + pages/[id]/page covering: adapter
status normalization, updated-at fallback, untitled placeholder,
slug passthrough, PATCH URL + body shape, status mapping, ApiError
path, generic Error path. 638/638 admin tests pass.

API: TestList_FilterByPostType + TestList_FilterByPostType_InvalidRejected.

### Known follow-up

The /pages/new flow built in PR #528 POSTs {post_type:'page'} in the
body but the create handler is hard-coded to PostTypePost (ignores
body). New pages today insert as post_type='post' and disappear from
the pages list. Body-override on create is a small follow-up.

Closes #506 (list + save). Create-side gap tracked separately.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants