80eca1bc7c
git-subtree-dir: cms/core git-subtree-split: 2f52ec7536ee87b4f097d169e1cb0f6a85aebe84
150 lines
7.5 KiB
Markdown
150 lines
7.5 KiB
Markdown
# openbureau-core
|
||
|
||
A generic, schema-driven Hugo CMS engine, extracted from the openbureau site.
|
||
A site provides one config (collections + plugins); the core gives it an admin,
|
||
content CRUD over `content/**/*.md` (gray-matter), real Hugo preview/publish,
|
||
Supabase auth, uploads and git backup. openbureau and karimgabrielevarano.xyz
|
||
both consume it.
|
||
|
||
## How a site uses it
|
||
|
||
1. Point `CMS_CONFIG` at a config module (e.g. `/site/cms.config.js`).
|
||
2. Mount the site repo at `SITE_DIR` (default `/site`). Run the container.
|
||
|
||
```
|
||
SITE_DIR=/site
|
||
CMS_CONFIG=/site/cms.config.js
|
||
SUPABASE_URL=… SUPABASE_SERVICE_KEY=… JWT_SECRET=… ADMIN_EMAILS=…
|
||
```
|
||
|
||
## Config API
|
||
|
||
```js
|
||
export default {
|
||
site: 'name',
|
||
admins: ['you@example.com'], // bootstrap admins (env ADMIN_EMAILS still wins)
|
||
auth: 'supabase', // 'supabase' (GoTrue) | 'local' (file JWTs, no DB)
|
||
plugins: ['dialog'], // optional features (see Plugins)
|
||
collections: [
|
||
{
|
||
kind: 'project', // internal id
|
||
label: 'Portfolio', // shown in the admin
|
||
order: 0, // sort order in the list
|
||
path: 'portfolio/:slug', // file pattern under content/ (:params captured)
|
||
// index: true, // matches _index.md (a section)
|
||
// fallback: true, // matches anything not matched above
|
||
// sections: [...], // fixed choices for a :section param
|
||
statKey: 'projects', // key in the stats response
|
||
fields: [ // editor form, in order
|
||
{ name: 'title', type: 'string', required: true },
|
||
{ name: 'images', type: 'list', of: { src: 'image', name: 'string' } },
|
||
{ name: 'body', type: 'markdown' },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
```
|
||
|
||
`path` patterns: literal segments must match; `:param` segments are captured
|
||
(the last one is the slug). `index: true` → `_index.md`. `fallback: true` →
|
||
everything else. The engine derives content classification, the `buildPath`, the
|
||
list ordering, the stats counters and the admin editor from this.
|
||
|
||
Field `type`s: `string · text · markdown · slug · date · number · bool · select
|
||
(options) · image · list · list(of:{…})`.
|
||
|
||
## Plugins
|
||
|
||
Everything beyond the generic engine is a **plugin**. A site opts into plugins via
|
||
`plugins: [...]` in its config; core's plugin manager loads them and wires them in.
|
||
The engine stays lean; openbureau = core + its plugins + its config.
|
||
|
||
A plugin is a module at `api/src/plugins/<name>/index.js` exporting a manifest:
|
||
|
||
```js
|
||
export default {
|
||
name: 'dialog',
|
||
routes: [ // mounted under /api
|
||
{ path: '/forums', app: forums, public: true }, // skips requireAuth
|
||
{ path: '/comments', app: comments }, // auth required
|
||
{ path: '/admin/forums', app: adminForums, admin: true }, // admins only
|
||
],
|
||
onBoot: async (ctx) => {}, // run once at startup (e.g. syncLibrary)
|
||
onPublish: async (ctx, { path }) => {}, // hook after a publish build
|
||
onPreview: async (ctx, { path }) => {}, // hook after a preview build
|
||
migrations: ['001_forums.sql'], // applied to Postgres/Supabase on boot
|
||
stats: async (ctx) => ({ forums, threads, comments }), // merged into /api/stats
|
||
admin: { /* UI panels the admin SPA mounts for this plugin */ },
|
||
};
|
||
```
|
||
|
||
The **plugin manager** (core): reads `config.plugins`, imports each module, mounts
|
||
its routes in the right auth tier (public → before `requireAuth`, the rest after,
|
||
`admin: true` behind `requireAdmin`), registers its boot/publish/preview/stats
|
||
hooks, and runs its migrations. The admin SPA loads UI from
|
||
`admin/src/plugins/<name>` for enabled plugins only.
|
||
|
||
First plugin: **`dialog`** — the comment/forum subsystem (Supabase
|
||
`forums/threads/comments`, library↔thread sync, forum admin UI). openbureau enables
|
||
it; kgva doesn't, so none of its routes, hooks, tables or stats load there.
|
||
|
||
## Auth providers
|
||
|
||
Auth is a config seam (`auth: 'supabase' | 'local'`, default `supabase`) so a site
|
||
can run **without a database**. The engine already verifies tokens locally — HS256
|
||
against `JWT_SECRET`, no GoTrue roundtrip ([api/src/auth.js]) — so a provider only
|
||
has to *issue* tokens and *store users*.
|
||
|
||
- **`supabase`** (today): GoTrue logs the user in and is the user store; the engine
|
||
verifies the JWT. Needed when there are multiple editors or the `dialog` plugin
|
||
(its forums/threads/comments live in Postgres). This is the heavy path.
|
||
- **`local`**: file-based users (bcrypt hashes) + self-signed HS256 JWTs of the
|
||
**same claim shape** (`sub`, `email`, `app_metadata.role`, `exp`), so `requireAuth`
|
||
and roles are unchanged. No GoTrue, no Postgres, no PostgREST. A dialog-less site
|
||
(e.g. kgva, single admin) then runs as just **Node + Hugo + nginx** (~80 MB vs the
|
||
~1–2 GB Supabase suite). Trade-off: you own password hashing; you lose GoTrue's
|
||
password-reset / rate-limiting — fine for a single-admin CMS.
|
||
|
||
What a pluginless core needs from Supabase is *only* the auth half anyway: content
|
||
is Markdown on disk, uploads are local FS (`static/images`, via sharp), and
|
||
PostgREST/tables are a `dialog`-plugin need. `local` drops that half too.
|
||
|
||
## Architecture
|
||
|
||
- `api/` — Node engine (Hono): `auth` (Supabase JWT), `files` (gray-matter
|
||
CRUD), `hugo` (coalesced build / preview / publish + git), `routes/*`,
|
||
`ratelimit`, `config` (this loader).
|
||
- `admin/` — React/Vite SPA; renders list + editor from the config schema.
|
||
- `examples/` — `openbureau.config.js`, `kgva.config.js` (the two consumers).
|
||
|
||
## Status / migration stages
|
||
|
||
This repo currently holds the **engine as extracted** plus the config API and the
|
||
two target configs. Generalisation is staged because the api and admin share a
|
||
data contract and openbureau is in production — they must change together and be
|
||
tested on the live Supabase/Hugo stack, not flipped blind.
|
||
|
||
- [x] Repo, engine, config loader, example configs (openbureau + kgva)
|
||
- [x] `files.js` classify / order / buildPath driven by `collections`
|
||
(`api/src/collections.js` + tests; behaviour 1:1 vs openbureau.config.js)
|
||
- [x] `stats.js` counters driven by `collections` (`statKey` / `draftStatKey`),
|
||
dialog counts gated by `hasPlugin('dialog')`
|
||
- [~] **plugin manager** built + unit-tested (`api/src/plugin-manager.js`,
|
||
`api/test/plugin-manager.test.js`): loads `config.plugins`, mounts routes by
|
||
auth tier, runs boot/publish/preview hooks, merges stats, surfaces migrations.
|
||
NOT yet wired into `index.js` (lands with stage 5, needs the live stack);
|
||
admin plugin-UI loading still TODO
|
||
- [~] extract openbureau's extras into plugins — first **`dialog`**: manifest
|
||
scaffold at `api/src/plugins/dialog/index.js` wraps the existing dialog
|
||
modules (routes by tier, `syncLibrary` on boot/publish, forum counters as
|
||
`stats`), loaded + structurally tested (`api/test/dialog-plugin.test.js`).
|
||
TODO (live-tested): wire it into `index.js`, capture the Supabase DDL into
|
||
`migrations/001_dialog.sql`, move the dialog source under `plugins/dialog/`
|
||
- [ ] `index.js` / `publish.js`: no hard-coded dialog — everything via the manager
|
||
- [ ] admin: editor + sidebar groups rendered from the config schema
|
||
- [ ] **auth provider** (`supabase` | `local`): `local` = file users + self-signed
|
||
HS256 JWTs (same claim shape), no DB — lets a dialog-less core run Supabase-free
|
||
- [ ] cut openbureau over to consume core (= core + `dialog` plugin + its config),
|
||
behaviour identical — tested on the live stack
|
||
- [ ] onboard kgva as the second consumer (`auth: 'local'`, no plugins, DB-less)
|