0002 Blog editor, canonical content and editorial identity¶
Context and problem statement¶
Kionga publishes an engineering blog, written in a separate editorial workspace
(/editorial.html) and read on public pages without signing in. Three questions had
to be answered together:
- Which editor do authors use, given that the console and public site are plain scripts with no build step?
- What is the canonical stored form of a post, and how is it made safe to publish?
- Who may write and publish, given that publishing to a public site is a different trust domain from operating ML infrastructure?
Decision drivers¶
- No bundler: every browser dependency must load as a plain script embedded with
go:embed. - Structured content that can be diffed per block and rendered on the server, without parsing HTML from the browser.
- Deterministic revision comparison.
- Alt text on every image, and images only from Kionga's own media endpoint.
- Publishing rights independent of ML administration rights, with per-person roles that editorial administrators manage themselves.
- Local mode has no identity-provider groups.
Considered options¶
Editor:
- Editor.js
Tiptap- Lexical
- A Markdown text area (the previous editor)
- Quill
Editorial identity:
- A separate editorial membership with its own session, on top of the platform identity
- An OIDC group claim such as
kionga-editors - An editorial role inside
UserAccess - Reusing the ML platform roles
Decision outcome¶
Chosen options:
- Editor.js, because it ships browser-ready UMD builds, saves structured block
JSON, and gives every block a stable ID. Editor.js 2.31.7 and the official header,
list, quote, code, delimiter, embed, table, inline-code, marker and warning tools are
vendored as upstream UMD builds under
platform/go/cmd/gateway/web/vendor/editorjs/with their licenses. - Block JSON as canonical content, rendered and sanitized on the server, because it is structured, comparable per block, and rendered without trusting browser HTML.
- A separate editorial membership and session, because it is the only option that keeps publishing out of ML administration and works in local mode.
Consequences¶
- Good, because each block registers one global from a browser-ready file, so nothing needs a build step.
- Good, because blocks are matched by ID, not position, so revision comparison is deterministic.
- Good, because a stolen console cookie does not reach the public site: editorial access needs its own session.
- Good, because ML administrators have no publishing rights unless they are members, and writers need no platform administration reach.
- Bad, because
@editorjs/imagecould not be used (see below), so Kionga maintains a small image tool. - Bad, because Editor.js has no undo or redo module; Kionga keeps a block-level
snapshot history in
editorial/state.js, and text-level undo is the browser's own. - Neutral, because Markdown remains only as an optional import and export.
Confirmation¶
platform/tests/ui/vendor-integrity.test.cjsrecomputes the vendored hashes on every gate run and fails if a page references a vendored file without its pinned hash.platform/go/internal/editorial/workflow_test.goand the editorial tests inplatform/go/internal/httpapi/blog_test.gocover roles, transitions and publishing rules;platform/tests/e2e/editorial.spec.cjscovers author, editor, the public page and a denied ML user in a browser.
Pros and cons of the options¶
Editor.js¶
- Good, because it ships browser-ready UMD files and saves
{blocks: [{id, type, data}]}. - Good, because block IDs are stable.
- Bad, because there is no undo or redo module.
Tiptap¶
- Bad, because it is distributed as ES modules over ProseMirror, so a production setup needs a bundler or a large import map of ProseMirror packages.
- Bad, because its output is ProseMirror JSON or HTML, which pushes HTML sanitizing of the whole document to the server anyway.
Lexical¶
- Bad, because it has the same bundler requirement (React-first packaging, ESM only); vanilla use is possible but not documented for a no-build setup.
Markdown text area¶
- Bad, because authors had to know Markdown, there was no media workflow and no structured diff.
Quill¶
- Bad, because its Delta format is not block-structured, and image and embed handling would still need custom tools.
@editorjs/image¶
Evaluated and not used.
- Bad, because it has no alt-text field, no alignment, no upload progress, and stores
a free URL. Publishing requires alt text and images must come only from Kionga's
media endpoint, so the workspace ships a small local block tool
(
editorial/image-tool.js) that stores amedia_id.
Separate editorial membership and session¶
- Good, because publishing is a separate trust domain from operating ML infrastructure.
- Good, because editorial administrators manage per-person roles themselves.
OIDC group claim¶
- Bad, because local mode has no groups, and memberships need per-person roles that editorial administrators manage themselves. Rejected for now.
Editorial role inside UserAccess¶
- Bad, because it would put publishing back under ML administrators.
Reusing ML roles¶
- Bad, because every platform operator would become a publisher and every writer a platform user with administration reach.
More information¶
Supply chain¶
platform/scripts/vendor-manifest.py compares every vendored file with the SHA-256
that the jsDelivr package API publishes for the same pinned npm version (a channel
independent of the download) and writes MANIFEST.json with the SHA-384 used for
integrity. editorial.html loads each script with integrity;
blog.html pins highlight.js 11.11.1 ES modules through an import map integrity
table. The npm latest dist-tag of @editorjs/editorjs points at a 0.0.0 preview of
a rewrite; 2.31.7 (the next tag) is the latest 2.x release and is the pinned version.
Canonical content¶
- Stored: block JSON (
editorial_post.blocks). Markdown is never required. - On save,
editorial.NormalizeBlocksvalidates every block type, clamps enumerated values and rebuilds inline HTML from an allowlist (b,i,a[href],code,mark,br,s,u). Links getrel="noopener noreferrer nofollow"; only HTTP, HTTPS,mailto:, site-relative and#links survive. - On read,
editorial.Renderturns blocks into HTML and passes the result through abluemondaypolicy narrowed to exactly what the renderer emits: images only from/media/blog/<id>/<480|960|1600>, iframes only fromyoutube-nocookie.com/embed/<id>andplayer.vimeo.com/video/<id>with asandbox, GitHubgistembeds as plain links, nostyle, nodata:URLs. The embed URL a client sends is ignored; it is rebuilt from the validated source URL. - The public page inserts only
rendered_htmlas markup; every other field is escaped in the browser. - Posts stored as Markdown (including the seed post) are converted to blocks once at
startup (
MigrateLegacy), keeping ID, slug, author, status and dates, and keeping the original inlegacy_markdown.
Editorial identity¶
Editorial access is a membership (document kind editorial_member, roles author,
editor, editorial_admin) on top of the same verified platform identity (local
account or OIDC), with its own cookie.
POST /api/v1/editorial/sessionmintskionga_editorial(Path=/,HttpOnly,SameSite=Strict,Securebehind TLS or OIDC, four-hour lifetime) only for an active member. The server stores only the SHA-256 of the token.- Every
/api/v1/editorial/*request needs that session (403otherwise), the membership is read again on every request (removal or suspension is immediate), and the session subject must equal the signed-in platform subject, so a leftover cookie cannot be used by another person on a shared browser. Mutations withSec-Fetch-Site: cross-siteare refused on top ofSameSite. - The gateway RBAC lets every human principal reach
/api/v1/editorial/*and lets the membership decide; service identities and API keys never reach it. - Bootstrap: while no active
editorial_adminexists, the local bootstrap administrator (local mode) or an ML administrator may grant themselves the firsteditorial_adminthroughPOST /api/v1/editorial/bootstrap. The call is audited and serialized by a create-only claim document. The last active editorial administrator cannot be demoted, suspended or removed, so bootstrap does not reopen.
Workflow, revisions, scheduling, media¶
- States:
draft,in_review,scheduledorpublished; unpublish returns todraft;archiveandrestore. Authors create, edit their own drafts and submit or withdraw; editors also review, request changes, publish, schedule, unpublish and archive; editorial administrators also manage members and settings. Publishing requires a title, a summary, at least 20 words, and alt text on every image and the cover. - Every content save writes an immutable
blog_revision; unchanged autosaves do nothing.PUTtakesbase_revision; a stale base returns409with the latest post. Restore creates a new revision and keeps the post's slug. - Scheduled publishing reuses the scheduler pattern: a lease document picks one replica, and the per-post row lock plus a status check make double publication impossible even if two replicas both act as leader.
- Media: multipart upload up to 10 MB, type detected from the bytes (JPEG, PNG, WebP,
GIF; file name and client type ignored), dimensions bounded before decoding, EXIF
orientation applied, then re-encoded (which drops all metadata) into 480, 960 and
1600 pixel variants. Bytes go to a
MediaStore: any S3 store (RustFS locally) with SigV4 presigned requests, or the file system for local development and tests. Uploads arependinguntil a save references them (attached); pending items older than 24 hours are removed./media/blog/{id}/{variant}is public only while the media is attached to a published post that still references it.
The record was written with the editorial workspace and block-based blog in the
platform repository (commit 10eb048, 2026-10-09). The user guide is
engineering blog.