Content Management that can withstand the plundering hoards

Forge is an independent Go module in forge/, using the frontend's net/http listener model. It has no CMS storage, rendering-engine, controller or Kubernetes API dependency. Its UI is embedded HTML/CSS/JavaScript with server-rendered public pages and Goldmark GFM rendering; raw HTML/unsafe URL rendering is disabled. See the operator/user guide for setup and workflows, and return to the reference index for other interfaces.

Public API

All routes below support unauthenticated GET/HEAD. kind is exactly plugins or themes; objects from the other catalogue return 404. All collection routes accept offset and limit (default 24, maximum 100) and return {items: [], total, offset, limit}. Item lists additionally accept q to search the published name/description. Results are ordered newest publication first, with item ID breaking ties. User lists use stable ID order.

Route Response
/api/v1/{kind} Visible published items.
/api/v1/{kind}/{id} Published item details.
/api/v1/{kind}/{id}/download Attachment ZIP; ETag, Last-Modified, conditional and byte-range requests.
/api/v1/users Makers with visible published work, biographies, totals and items.
/api/v1/users/{id} One public maker, including visible items.
/api/v1/users/{id}/items Paginated visible items belonging to that maker.

Public item JSON contains id, kind, ownerId, packageId, name, version, license, description (Markdown source), publishedAt (RFC3339), downloads, size and downloadUrl (origin-relative). It never contains a private draft. Public maker JSON contains id, name, biography (Markdown source), downloads and items. No email, subject, token or issuer appears in these public records. An unpublished/hidden item or suspended owner returns 404 for all public item routes; makers with no remaining visible items return 404 too.

The HTML routes are /, /plugins, /themes, /{kind}/{id}, /users and /users/{id}. The public catalogue and user pages render without JavaScript; member/admin mutations and the local name/version filter use embedded JavaScript.

Member API

These routes require forge_member or forge_admin. Browser requests use an HttpOnly encrypted/signed reference cookie. A validated Bearer token is also supported. Issuer and subject together identify the owner; email/name never determine access. The account ID is the first 16 bytes of SHA-256(issuer + NUL + subject), hex encoded.

Method and route Request/response
GET /api/v1/member/me {profile, roles, csrfToken}. Ensures the account profile exists.
PUT /api/v1/member/profile {name, biography, revision} → updated profile.
GET /api/v1/member/{kind} Paginated owned items, including private drafts/public snapshots.
POST /api/v1/member/{kind} {name, version, license, description} → 201 created private item.
GET /api/v1/member/{kind}/{id} One owned item.
PUT /api/v1/member/{kind}/{id} {name, version, license, description, revision} → updated draft/item.
PUT /api/v1/member/{kind}/{id}/archive Raw ZIP body; X-Forge-Revision header → updated draft/item.
PUT /api/v1/member/{kind}/{id}/publication {published: true/false, revision} → updated item.

Profile JSON is {id, name, biography, suspended, revision}. Private item JSON is {id, kind, ownerId, draft, release, downloads, hidden, revision, createdAt}. The draft includes editable metadata plus blob, size, packageId and packageVersion, which only the upload endpoint can set. release is null or a snapshot of the draft with a publishedAt timestamp. Revisions increment on metadata, upload, publication and moderation changes, but not downloads.

For browser writes, bootstrap /api/v1/member/me and send its csrfToken as X-CSRF-Token, plus a matching Origin/Referer. The token is HMAC-bound to the authenticated session ID. Bearer writes need no CSRF token or Origin header. GET never publishes, deletes or changes visibility. First authenticated access creates a profile, but the profile remains absent publicly until publication.

JSON bodies are limited to 512 KiB; unknown fields/trailing JSON are rejected. Names/licenses are at most 160 bytes, versions 128 bytes and Markdown fields 256 KiB. ZIP bodies are limited to 64 MiB, 4096 entries and 256 MiB total expanded data. Validation checks all entry CRCs, the correct root manifest and version, unsafe/duplicate paths and non-regular entries. Forge never extracts or executes packages. Full package/schema/renderer compatibility validation remains the CMS installer's responsibility. The initial release keeps the current draft and current public snapshot, rather than exposing a release-history API.

/member is the workshop/profile/create UI; /member/{kind}/{id} edits an item.

Management and authentication

Management requires forge_admin, including the /admin UI. It inherits member capabilities but is still subject to ownership checks on member routes.

Method and route Behaviour
GET /api/v1/admin/summary Account/item/published/download totals and build version.
GET /api/v1/admin/users Paginated profiles including suspensions.
GET /api/v1/admin/items Paginated items including drafts and hidden status.
PATCH /api/v1/admin/users/{id} {suspended: boolean, revision}; rejects self-suspension.
PATCH /api/v1/admin/items/{id} {hidden: boolean, revision}; owners cannot override a hide.

OAuth endpoints follow CMS authentication: GET /oauth2/start?redirect=/member, GET /oauth2/callback, POST /oauth2/logout and POST /api/session/refresh. GET /api/me returns the OIDC identity. Login uses single-use browser-bound state, nonce, and PKCE for public clients; verification checks issuer, audience, expiry and signatures. Logout and refresh require same-origin CSRF protection. Session cookies and encryption derivation use Forge-specific domains. OIDC transport policy checks discovery, lazy JWKS calls and redirects, including the opt-in loopback-only HTTP exception.

Errors are JSON {error: "message"} for APIs: 400 invalid data, 401 missing/invalid authentication, 403 role/ownership/suspension/CSRF rejection, 404 invisible/missing object, 409 stale revision, 413 oversized archive, 503 auth unavailable. Some OAuth and HTTP router errors use plain text. Browser authentication failures redirect to login. Writes use optimistic revisions to reject stale tabs rather than silently overwrite; reload and review edits after a conflict.

Storage and telemetry

catalogue.json uses schema version 1. A process-level flock ensures one writer per data directory. Updates clone the catalogue under a mutex, sync a temporary file, rename it and sync the directory. Failed pre-rename writes do not change memory. An error syncing the directory after a completed rename is logged and returned, with memory updated to match the installed catalogue. Reload before retrying a mutation with an ambiguous error.

Uploads sync the SHA-256 blob before referencing it from a draft. Old blobs are retained, so a failed catalogue save cannot invalidate a published snapshot. Publishing checks the package's immutable ID reservation and version match. Read visibility uses suspension, moderation and publication together. Download counts are committed after successful response writes; recording failures are logged, so a transferred archive can be undercounted if storage fails at that point. Counts do not deduplicate clients or range requests.

The separate internal listener provides health/readiness and Prometheus, with outer build identity/security/HTTP metrics middleware matching the public listener. Metric families include http_requests_total, http_request_duration_seconds, http_response_size_bytes, http_requests_in_flight, forge_accounts, forge_items, forge_account_http_requests_total, forge_account_items, forge_account_downloads_total, forge_item_views_total and forge_item_downloads_total. Stored IDs bound account/item labels to real catalogue objects; HTTP method labels use the frontend's fixed method set.

The Docker build runs format checks, vet and Go tests before stamping the binary. The workspace, container pipeline and standalone image all include Forge. See Forge's README for the optional real-browser workflow.