- JavaScript 95.2%
- PLpgSQL 3.2%
- CSS 1.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .github/workflows | ||
| deploy | ||
| docs | ||
| migrations | ||
| public/css | ||
| scripts | ||
| seed | ||
| server | ||
| test | ||
| .env.example | ||
| .gitignore | ||
| CHANGELOG.md | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
| STATUS.json | ||
OpenVibe.Blog
The official OpenVibe blog and a blog for every member: drafts, revisions, series, scheduling, feeds.
Status: alpha (roadmap Wave 16, Blog half). Public at https://openvibe.blog since 2026-09-23
(the launch release also removed the domain from OpenVibe.Sites). Its capabilities and service
manifest are released in openvibe-contracts v0.97.0. The network changelog publishes Patch notes
posts on the official blog (below); the seed post stays a
draft until a person reviews it.
Domain: openvibe.blog · Port: 4810 · Service id: blog
Plan: OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.6; roadmap §15.13, §29, §32.
License: AGPL-3.0 (same as every OpenVibe service).
Purpose
This service hosts the official OpenVibe blog and a blog for each member, with canonical URLs and feeds. It uses the shared publishing packages (ADR-019) and doesn't write its own versions of them. It also uses:
- Media for attachments
- Community for comments
- OpenVibe.AI's
blog.draft_postoutput, as drafts that need review - openvibe-shared theme presets
- a VIP gate: members-only posts decided by OpenVibe.VIP (the blog owner's members), teaser and join link for everyone else, failing closed
Owns
The ten charter tables live in Blog's own PostgreSQL database (ov_blog on the host's data role, ADR-035; schema in migrations/). Where a publishing package
applies, it creates the table with the blog prefix:
| Charter table | What it is |
|---|---|
blogs |
Owned by Blog. The official blog (openvibe, served at /) and the member blogs. One per member, owned by their Network subject (usr_…). |
blog_memberships |
Owned by Blog. Roles are owner, editor and author. |
blog_posts |
Owned by Blog. Publication state: slug, state, visibility, entitlement key, published revision, series, flags. |
blog_post_revisions |
openvibe-publishing/revisions (prefix blog_post). Immutable: triggers abort UPDATE and DELETE. Drafts go in blog_post_drafts. |
blog_series |
Owned by Blog. Ordered series for each blog. |
blog_taxonomy |
A view over blog_terms (openvibe-publishing/taxonomy, prefix blog). Tags are network-wide. Categories are per blog (vocabulary category_<blog>) and can be nested. |
blog_post_terms |
A view over blog_term_links. |
blog_schedules |
A view over blog_schedule_jobs (openvibe-publishing/schedule, prefix blog). |
blog_redirects |
openvibe-publishing/seo history-aware redirects (prefix blog). |
blog_feed_settings |
Owned by Blog. RSS, Atom and JSON Feed switches, item count, and full text or summaries. |
The three views keep the charter's names readable without keeping a second copy of the package state.
Other tables in the same database:
- Package companions:
blog_post_citations,blog_post_attachments,blog_post_reviews,blog_post_discussion_refsandblog_index_revisions. event_outbox, the SDK transactional outbox.subject_projections, a display cache of Network names. It isn't authority.telemetry_samples, this service's own request telemetry (migration0003_telemetry.sql): aggregated by route template, method and status class, never by raw URL;server/app.jsbuilds the collector,server/index.jsflushes it once a minute and on stop (onehttp.requestsample per key, plus the active-requests, p95 latency and event-loop-lag gauges, in the openvibe-sdk 0.46platform.telemetry-sample@1shape), andserver/worker.jsdeletes rows older than 14 days daily./shared, static assets, health, readiness and/metricsare not counted.- The AI desks (migration
0004_ai_desks.sql):blog_ingest_cursor(Sources change cursors),blog_desk_items(the Sources items the desks may cite, 21 days) andblog_desk_posts(one post per desk and period).
Does not own
- Comments. They belong to OpenVibe.Community. A public post's thread is resolved from
EntityRef {service: 'blog', type: 'post', id}. Blog stores only the thread id and renders the thread live. - Media bytes. They belong to OpenVibe.Media. Blog stores Media object ids (
med_…) and has an explicit broken-asset state. - Entitlements. They belong to Billing/VIP.
- Identity. It belongs to OpenVibe.Network: SSO, subjects and service principals.
- Search. OpenVibe.Search indexes what Blog sends through Events.
- AI generation. OpenVibe.AI makes no provider calls from Blog.
What works
- Blogs:
- The official blog exists from the first boot. Its owners come from
BLOG_OFFICIAL_OWNERS, and Network admins act as its owners. - Every member gets a blog on first use (
/write/startorPOST /api/v1/blogs), with handle = Network username unless the member picks another. - Owners, editors and authors. Owners manage members, settings, theme and feeds.
- The official blog exists from the first boot. Its owners come from
- Posts:
- Drafts and immutable revisions, with optimistic concurrency. A stale edit is
412 revision.conflictand nothing is lost. - Diff, and revert as a new revision.
- A published post keeps showing its published revision while newer ones are drafted.
- Drafts and immutable revisions, with optimistic concurrency. A stale edit is
- Publishing:
- Publish, unpublish and soft delete (
410 Gone). - Scheduled publish or unpublish, idempotent across worker restarts (leases, and "revision N is published" as the effect).
blog.schedule.failedwhen a job gives up.
- Publish, unpublish and soft delete (
- Organizing:
- Author pages, both per blog and network-wide.
- Tags, nested categories and ordered series.
- Slug changes leave 301s from every old path, and chains collapse.
- Media and themes:
- Media-rich posts: cover, inline (
[[media:med_…]]lines) and gallery figures. - The worker re-verifies attachments against Media. Deleted or missing objects render "This media is no longer available." An outage changes nothing.
- Themes are six openvibe-shared token presets.
- Media-rich posts: cover, inline (
- Comments: Community threads on public posts, with a plain comment form. The thread is hidden when a post is deleted or stops being public.
- AI drafts:
X-OV-Origin: aiwith an OpenVibe.AI workflow run. The draft is AI-generated, noindex and can't be published or scheduled until a person records an approving review. A person editing it makes a hybrid (AI-assisted) revision. The disclosure appears on the page, in the JSON and in the Search document. - Members-only posts (OpenVibe.VIP, the
blog gated_postbinding):- Visibility
members. The blog's own members (owner, editors, authors of the post) and staff read as always. Anyone else is admitted only when OpenVibe.VIP says so:POST /api/v1/policies/evaluatewithresource { service: 'blog', type: 'post', id },owner= the blog's owner (blogs.owner_subject) and Blog's default gatefallback { requirement: 'member', binding: 'blog:gated_post' }— an active member of the owner or, if the owner defines a perk bound toblog gated_post, a member whose plan version includes it. A rule the owner sets for the post on openvibe.vip takes precedence. (server/clients/vip.js, the seam inserver/domain/access.js.) BLOG_ENTITLEMENTS_PROVIDER:vipis the default whenOV_OAUTH_CLIENT_SECRETis set, elsenone(only the blog's members and staff). Every doubt — VIP down, a refused token, no grant, no owner — is a refusal.- The official blog has no owner in VIP (its owners are memberships), so its members-only posts admit nobody beyond its members and staff.
- Everyone else gets a teaser: the title, the author's summary (never text taken from the
body; write a summary to have one) and a join link to the owner's plans
(
OV_VIP_URL/<owner username>), on the page (403), the.jsontwin (403{ teaser }) andGET /api/v1/posts/:id(403 problem withteaser). Feeds, sitemaps and listings for outsiders carry public posts only; Search gets a tombstone; the product events' document carries the teaser, never the body. entitlement_key(defaultvip:<handle>) is informational: it names the gate in the members ACL of product-event documents. VIP decides.- The client is
openvibe-vip/client, vendored verbatim in server/vip/vip-client.js (from OpenVibe.VIP 2accbeb) until VIP publishes a tag to pin. - Convergence bound. Answers are cached (
createVipCache): a "yes" at mostBLOG_VIP_CACHE_TTL_MS(30 s, and never past the entitlement's expiry), a "no"BLOG_VIP_CACHE_DENY_TTL_MS(10 s: a new member waits at most that long), a failureBLOG_VIP_CACHE_UNAVAILABLE_TTL_MS(2 s, and it never extends a "yes"). So once VIP stops granting (it appliedbilling.entitlement.changedand emittedvip.membership.changed), Blog stops admitting that reader within 30 s. Blog does not subscribe to Events yet; a delivery handed toctx.vip.cache.handleEventrefuses at once. End to end (VIP's own bound when events are lost) see OpenVibe.VIP's README, "The product cache and the convergence bound".test/members-vip.test.jsproves both.
- Visibility
Routes (server-rendered, useful without JavaScript)
| Route | What |
|---|---|
/ |
the official blog |
/@:handle |
a member's blog |
/@:handle/:slug, /@:handle/:slug.json |
a post, and the same post as data |
/@:handle/tags/:tag · /tags/:tag |
tag pages, per blog and network-wide |
/@:handle/categories/:category |
category pages; a parent includes its children, with breadcrumbs |
/@:handle/series/:series |
series pages, in order |
/@:handle/authors/:who · /authors/:who |
author pages |
/feed.xml, /atom.xml, /feed.json |
the official blog's feeds |
/@:handle/feed.xml, /@:handle/atom.xml, /@:handle/feed.json |
each member blog's feeds |
/sitemap.xml → /sitemaps/posts.xml, /sitemaps/blogs.xml |
sitemaps |
/robots.txt |
names the sitemap and states the policy for automated consumers |
/llms.txt |
orientation for language models |
/write/… |
the editor, as plain HTML forms with Network SSO and a form token |
/auth/* |
sign-in; the same session layer as OpenVibe.Community |
/api/health, /api/ready, /release.json, /metrics |
health, readiness and metrics; /metrics is loopback only |
/desks, /desks/:desk, /desks/:desk/:slug(.json), /desks/:desk/feed.xml|atom.xml |
the AI desks (below); staff POST …/hide|show |
AI desks (server/desks/)
The owner's April ask: "AI-generated niche blogs backed by large source datasets" and a "satirical/parody type blog".
They sit beside the blogs, not in them: a desk post is never a blog_posts row (whose AI text waits for a person's
review), says on every page, feed entry and JSON twin that OpenVibe.AI wrote it and no person reviewed it, and is never
indexed (noindex, not in the sitemaps).
| Desk | Sources (OpenVibe.Sources keys) | When |
|---|---|---|
| News digest | govuk-news, eu-press-corner, nasa-news-releases, noaa-news, cdc-newsroom, nist-news, federal-reserve-press |
daily, 06:00 UTC, for the day before (3 items at least) |
| Food safety digest | fda-food-recalls |
daily, 06:00 UTC (1 item at least) |
| Deals digest | dealnews-daily |
daily, 07:00 UTC (5 items at least) |
| Hosting digest | linode-types, aws-lightsail-bundles, ovhcloud-vps, vultr-plans, lowendbox-posts, lowendtalk-offers |
Mondays, for the week before |
| Crowdfunding digest | opencollective-accounts |
Mondays, for the week before (3 items at least) |
| Developer digest | nodejs-blog |
Mondays, for the week before |
| Budget recipes | OpenVibe.Food's recipe library (/recipes/library.json, OV_FOOD_URL; each recipe a food.recipe source) |
Mondays, 08:00 UTC, for the week before (3 recipes at least) |
| Is This Real? (satire) | none | daily, 14:00 UTC |
Every 15 minutes (BLOG_DESKS_INTERVAL_MS) where the worker runs: the change feed of each Sources category the desks
read is pulled (openvibe-publishing/ingest cursors in blog_ingest_cursor) into blog_desk_items (21 days; a removed
item is dropped); the budget-recipes desk asks OpenVibe.Food for the recipes added since the newest one Blog holds (the
owner's "interconnect food with blog where it creates its own blogs"). An item new to Blog is dated by when Sources first saw it, a new revision by when Blog saw it, so the
first pull never presents old items as new. When a desk's period is over, its newest items (40 at most) go to
blog.write_digest; a section the AI did not cite is left out and said so, a run that cites nothing makes no post, and
every cited item is linked under the post. The satire desk sends the next question of server/desks/catalog.js to
blog.write_satire (fiction with its own rules in OpenVibe.AI: no real people, no words in a real organisation's mouth,
nothing dangerous) and every page and feed title says it is satire. A failed run is retried after 30 minutes with a new
key (three attempts); the stub provider's text is kept but hidden. Staff (Network admins) may hide a post and show it
again. BLOG_DESKS=off stops the desks; the digests need OV_SOURCES_INTERNAL_URL, every desk OV_AI_INTERNAL_URL.
The network changelog and patch notes
server/changelog.js keeps what shipped on every OpenVibe site. Every 5 minutes it reads each service's
repository and current release from OpenVibe.Network's registry and takes the new commits from GitHub's
compare API. The first time it sees a site it also imports that site's last CHANGELOG_HISTORY commits
(60 by default). Those history entries are shown in the feed but never go into a post.
A Patch notes post on the official blog (series "Patch notes", imported authorship, grouped by site) is published when either of these happens:
CHANGELOG_BATCH_SIZEchanges have gathered.- A large release (
CHANGELOG_MAJOR_LINES, a version bump or[major]) has been quiet for 30 minutes.
There is always at least a 6-hour gap between posts. An AI-written dev-blog version is filed as a draft for review.
GET /api/v1/changelog?service=&limit=&before= is public (CORS *, cached for 60 s). It returns:
| Field | Contents |
|---|---|
entries |
Each entry has its site, sha, subject, author, time and commit link |
next |
The cursor for the next page |
latest_post |
The newest patch notes post |
posts |
The recent patch notes posts |
sites |
Only without service: each site with its count and latest time |
OpenVibe.Network proxies this feed at openvibe.network/api/v1/changelog. Every site shows it through
openvibe-shared/shipped.js:
- the "shipped X ago" line in the shared footer;
- the home pill and the recent list;
- the
/updateslog.
Discoverability (roadmap §32)
- Every page gets its robots meta, canonical and
X-Robots-Tagfrom theopenvibe-publishing/seogate, with explicit reasons (for examplethin (42 of 80 words)). Reasons show in the editor and in the JSON. - Blog's gate policy: 80 words minimum, sources optional. A short post is served and appears in feeds but is noindex.
- JSON-LD
BlogPostingis built from real fields only. Missing fields are omitted, and a publisher is set only for the official blog. - Drafts, scheduled posts, unlisted, members-only, private, unpublished, deleted and noindex posts never appear in feeds, sitemaps or Search. Feeds and sitemaps are never built for a particular viewer.
IndexNow (openvibe-shared/indexnow)
- With
INDEXNOW_KEYset, the key file is served at/<key>.txtastext/plain. - Publishing, updating (a new revision or a slug change), unpublishing and deleting ping
api.indexnow.orgwith the post's canonical URL and/sitemap.xml; the module batches and debounces (one POST per 30s window). A failed ping never takes a publish down. - Drafts, scheduled, unlisted, members-only, private and noindex posts never ping. Unset key: off — no key file, no requests.
Caching (no leaks through shared caches)
- All HTML and JSON varies on
CookieandAuthorization. Cache-Control: public, max-age=60applies only to public, published posts and to lists of public posts shown to anonymous visitors.- Everything else is
private, no-storeplusX-Robots-Tag. That covers signed-in views, drafts, previews, unlisted, members-only and private posts, 403, 404, 410 and the API. - Feeds and sitemaps are public for 5 minutes. They only ever contain public posts.
Events (SDK outbox, same transaction as the change)
| Event | Notes |
|---|---|
blog.post.created |
internal |
blog.post.published, .updated, .unpublished, .deleted |
openvibe-publishing/index-hooks publicationEvent. Public visibility only for public, listable posts. The payload is the canonical URL, state and indexability, never the body. |
blog.schedule.failed |
internal |
blog.moderation.action |
internal. Staff unpublished (post.unpublished) or deleted (post.deleted) a post that only their staff powers let them touch, for the network's moderation audit log (ADR-022, common.moderation-action@1). Never the content. |
blog.index_document.upserted / .deleted |
Documents and tombstones in search.index-document@1 form, with a monotonic index revision (createIndexSequencer). Only published, public, listable posts are upserted; every other state is a tombstone. A post that was never indexed gets no tombstone. |
Account export and deletion (ADR-033)
network.account.export_requested and network.account.deleted arrive at POST /internal/events. The route is
loopback-only (nginx answers 404 for /internal/, and the handler refuses a forwarded request) and signed with
BLOG_EVENTS_SECRET. They are answered by server/domain/account-data.js over openvibe-sdk/account-data, with one
receipt per export and deletion in account_data_events.
- Export: the person's blog, memberships, posts, revisions, drafts and reviews.
- Their own member blog: every post is removed the way its owner would remove it (
posts.remove: unpublished, out of Search and the feeds). The blog is then suspended and loses its title, description and owner; the handle stays reserved. - Deleted: their memberships, drafts and display-name cache.
- Authorless: posts they wrote on other blogs stay published, with
author_subjectset todeleted. Revisions and citations are append-only, so only the erasure transaction may clear a revision's author (and their id inmeta.authorship.authors) or a citation'sattached_by(blog.account_erasure, migration0002_account_erasure.sql); the text never changes. - Kept: post reviews, since a person's approval is what lets reviewed text stay published.
Capabilities (released in openvibe-contracts v0.97.0)
Service tokens use audience openvibe.blog, with one capability per route. The person the service
acts for goes in X-OV-Subject, and membership still applies:
blog.blog.create(the charter'sblog.create),blog.blog.configure,blog.theme.setblog.member.manageblog.post.create,blog.post.read,blog.post.update,blog.post.publish,blog.post.schedule,blog.post.unpublish,blog.post.deleteblog.feed.read
Browser and app user JWTs are judged by blog membership. The ids and the service manifest are
released in openvibe-contracts (pinned at v0.97.0; the source proposals stay in
docs/capabilities-proposal/ and
docs/service-manifest-proposal.json); server/auth/capabilities.js
decides them with the contracts grant rule.
Called elsewhere, as the service principal blog: identity.subject.resolve and
network.integration.github.read (Network), events.event.publish (Events),
community.comment.write and optionally community.comment.moderate (Community),
media.object.read (Media), vip.resource.policy.evaluate (VIP) and ai.run.create with
ai.run.read (AI, for AI drafts). The list with audiences is under
Grants the Network must hold for client blog.
Depends on
- PostgreSQL 18 and Valkey 9 (OpenVibe.Host
roles/data/, ADR-035): every read and write is async throughopenvibe-sdk/db; Valkey holds the per-actor limit counters (optional: withoutVALKEY_URLthey count per process). - Packages (all pinned by release tarball):
openvibe-publishingv1.4.1 (async PostgreSQL stores: revisions, schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout),openvibe-contractsv0.97.0,openvibe-sharedv4.15.2 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),openvibe-sdkv0.35.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing). - OpenVibe.Network:
- SSO: the OAuth client
blogis already seeded with redirecthttps://openvibe.blog/auth/callback. - JWKS.
identity.subject.resolvefor author names.
- SSO: the OAuth client
- OpenVibe.Community:
community.comment.write, and optionallycommunity.comment.moderate. - OpenVibe.Media:
media.object.readfor namespaceblog. - OpenVibe.Events:
events.event.publish. - OpenVibe.Search: consumes
blog.index_document.*through its*.index_document.*subscription.blogis already in Search's defaultSEARCH_EVENT_OWNERS. - OpenVibe.VIP:
vip.resource.policy.evaluatefor members-only posts (OV_VIP_INTERNAL_URL, defaulthttp://127.0.0.1:4620). Without it the gate fails closed.
Grants the Network must hold for client blog
Each grant is [client, capability, audience]:
[blog, identity.subject.resolve, openvibe.network][blog, events.event.publish, openvibe.events][blog, events.subscription.manage, openvibe.events](the two account subscriptions, created at boot), then, last and once the release is live,[blog, network.account.export.contribute, openvibe.network]and[blog, network.account.deletion.confirm, openvibe.network](ADR-033)[blog, community.comment.write, openvibe.community][blog, community.comment.moderate, openvibe.community](optional: hides the threads of deleted or no-longer-public posts)[blog, media.object.read, openvibe.media], namespaceblog. Media also needs ablogtenant.[blog, vip.resource.policy.evaluate, openvibe.vip]for members-only posts (until granted, only the blog's members and staff read them).[blog, network.integration.github.read, openvibe.network](optional: the changelog reads GitHub with the network's token instead of anonymously;CHANGELOG_GITHUB_TOKENoverrides it).[blog, ai.run.create, openvibe.ai]and[blog, ai.run.read, openvibe.ai]for AI drafts (the editor's "draft with AI" and the dev-blog draft of each patch notes post).[blog, sources.item.read, openvibe.sources]for the AI desks' digests (blog.write_digestandblog.write_satirerun under theai.run.creategrant above).- For OpenVibe.AI to deliver drafts:
[ai, blog.post.create, openvibe.blog]. Add[ai, blog.post.read, openvibe.blog]if it reads the drafts back.
Acceptance (automated: npm test)
| Charter / roadmap requirement | Test |
|---|---|
Scheduled publication is idempotent across worker restarts: a worker dies after applying and before completing, the app restarts on the same database, and after the lease expires the job re-runs with exactly one published event and one Search upsert. Duplicate scheduling is one job, and a failing job emits blog.schedule.failed. |
test/schedule.test.js |
| Revisions survive edits: they're immutable, 412 on a stale base, and revert makes a new revision. | test/lifecycle.test.js |
| Feeds, canonical HTML, sitemap and Search converge on publication state: publish, revise, unpublish and delete. | test/lifecycle.test.js |
| Feeds and canonical URLs stay stable across slug changes: the feed id is unchanged, old paths 301 and chains collapse. | test/slug-media.test.js |
| VIP-only and private posts never leak through cache headers, Search, feeds or sitemaps. Visibility changes tombstone with a higher revision, and the entitlement seam fails closed. | test/privacy.test.js |
Members-only posts through OpenVibe.VIP: the owner, resource and default gate are sent; members read; strangers, signed-out readers and a VIP outage get the teaser and join link, and the body is absent from pages, .json, API, feeds, sitemaps, Search and product events; a cached yes ends within the TTL and at once on vip.membership.changed. |
test/members-vip.test.js |
| A deleted Media object yields an explicit broken-asset state in the page, JSON, feed and editor, and an outage is not a deletion. | test/slug-media.test.js |
| Useful without JavaScript: write, edit, publish, comment and configure with forms only. Form tokens, the 412 message and Community threads referenced, never copied. | test/nojs-editor.test.js |
| Capability-guarded service tokens, X-OV-Subject membership, AI drafts that need a person's review, and problem+json with request ids. | test/api-ai.test.js |
| The seed post, readiness, release, robots, llms.txt and the sitemap index. | test/seed-ops.test.js |
| The contract proposals are valid against the released schemas and match the code. | test/contracts.test.js |
| Capability guards delegate to the released contracts: every guarded id is defined, and grants follow the library's rule (exact, prefix, denied, unknown). | test/capabilities.test.js |
IndexNow: off without INDEXNOW_KEY (no key route, nothing sent); with one the key file is served at /<key>.txt as text/plain and a publish or unpublish pings the post path and the sitemap; a draft never pings. |
test/indexnow.test.js |
Launch rule
This repository alone doesn't make the product live. openvibe.blog kept its placeholder on
OpenVibers/OpenVibe.Sites until all of plan §12.12
existed; the launch release went out on 2026-09-23. Status against each point:
- Runtime, health, readiness, observability: done.
- Canonical identity and auth: done.
- SSR public routes useful without JS: done.
- Persistence and end-to-end workflows: done;
ovhost drill blogrestored it on the production host on 2026-09-23. - Capability and event registration against OpenVibe.Contracts: released in v0.97.0
(capabilities and service manifest; no
blog.*event payload schemas yet). - Migration and seed strategy, threat review, sitemap/robots/feed behaviour: done. There is nothing to migrate, the seed is described below and the threat review is below.
- Acceptance tests: done.
The launch release did all of these in one release (2026-09-23):
- Removes
openvibe.blogfromOpenVibe.Sites/sites.json. - Switches routing: nginx vhost, DNS and TLS.
- Flips the Network hub entry (
server/chrome/sites.js,status: 'soon'). - Registers maturity in the ecosystem registry.
A placeholder never counts as an implemented service. The site is public, but nothing is published on it until a person reviews and publishes the seed post (below).
Seed
npm run seed creates the official blog's first post, "Released in the OpenVibe repositories,
16–22 September 2026". It's built only from verbatim quotes of commit messages and a CHANGELOG line
on the main branches of the OpenVibers repositories (seed/week-2026-09-16.json). Each quote keeps
its commit or tag, date and GitHub URL, and is attached as a citation.
- It makes no deployment claims. Several services'
STATUS.jsonsaydeployed: false, and the post says it doesn't state that anything is running. - There's no pricing copy.
- It is created as a draft. No person has reviewed it yet, so it isn't marked as reviewed.
- To publish it, a person checks the quotes and runs
npm run seed -- --publish --reviewer usr_…. That records their approving review and publishes the post.
Security and threat review
Reporting a vulnerability: SECURITY.md.
- Identity:
- Only verified Network JWTs (offline RS256 against JWKS) and service tokens for audience
openvibe.blog. - Service tokens are judged on the token alone. A bad one is refused, never downgraded to anonymous.
- Identity never comes from a body or query.
X-OV-*headers are ignored for browsers.
- Only verified Network JWTs (offline RS256 against JWKS) and service tokens for audience
- CSRF: SameSite=Lax session cookie plus an HMAC form token (
BLOG_FORM_SECRET) on every form POST. - XSS:
- All HTML goes through
openvibe-publishing/ssrauto-escaping. - Markdown uses the package's safe subset: no raw HTML and no images (media only by object id).
- Links on member blogs get
rel="nofollow ugc noopener". - CSP from helmet.
- All HTML goes through
- Private content: read decisions live in
server/domain/access.js. 404 hides drafts and private posts, and 403 (with the teaser, never the body) applies to members-only posts. A redirect never reveals the new slug of a post the reader may not see. - Leaks: see the caching and events sections above. The product events carry no body. Search gets bodies only for public, listable posts.
- SSRF: Blog makes no outbound calls to user-chosen URLs. It calls only its configured Network, Community, Media and VIP hosts.
- AI: output is never attributed to a person. It needs a person's (
usr_) review before publication or indexing, and only a signed-in person can record a review. - Abuse: rate limits on
/auth,/writeand/api/v1, both in Express and in the nginx reference, and per-actor limits (below). - Known gaps:
- Uploads go through OpenVibe.Media. The editor attaches existing
med_…ids and doesn't upload. - Media publishes no deletion event yet, so broken assets are found by polling (the worker interval).
- The Community thread visibility sync is best effort and needs
community.comment.moderate.
- Uploads go through OpenVibe.Media. The editor attaches existing
Per-actor limits
/api/v1, the editor and comment posts also limit who calls them, once req.viewer is resolved and
before any work (before the body is read): server/http/actor-limits.js, openvibe-sdk/limits,
roadmap WS-R task 4. Counted: a person as user:usr_… (their own token or cookie, named by a service
in X-OV-Subject, or an app's on_behalf_of); a first-party service relaying a signed-out visitor by
the address it forwards; a service or app acting as itself by its principal; a signed-out caller by
address. A first-party service reading for itself is not counted on reads, and neither is
GET /api/v1/changelog, which OpenVibe.Network reads for every site's "shipped" widget from loopback
without a token (it would count as one caller for the whole network). Past a limit: 429
problem+json rate_limited with Retry-After, one [Limits] log line and
blog_rate_limited_total{limit,window}. An editor form and the API route that do the same thing share
one budget.
| Routes (API and editor) | Per caller, a minute / an hour |
|---|---|
| API reads | BLOG_LIMITS_MINUTE / BLOG_LIMITS_HOUR (120 / 3000) |
Blog create (POST /blogs, /write/start) |
10 / 60 |
| Blog settings and theme; members | 30 / 300 each |
Post create (POST /blogs/:handle/posts, /write/@:handle/new) |
30 / 300 |
Post edit and revert (PATCH /posts/:id, /revert, /write/posts/:id) |
30 / 600 |
AI draft (…/posts/ai-draft, /write/@:handle/ai-draft: an OpenVibe.AI run) |
5 / 30 |
| Publish, schedule, unschedule, unpublish, reviews | 30 / 300 |
| Delete | 30 / 300 |
| Media attach and remove | 20 / 200 |
| API diffs | 30 / 600 |
Comments (POST /@:handle/:slug/comments, sent to Community) |
20 / 300 |
Never limited per actor: /api/health, /api/ready, /release.json, /metrics, sign-in, and the
pages and feeds people read. test/actor-limits.test.js.
Development
fnm exec --using=22.22.1 npm install
fnm exec --using=22.22.1 npm test # temp databases and in-process mocks, no network
fnm exec --using=22.22.1 npm run dev # http://localhost:4810 (set OV_OAUTH_CLIENT_SECRET to sign in)
Deploy (for the lead)
Production deploys with sudo ovhost deploy blog on the host (strategy git-checkout: fetch,
fast-forward /opt/openvibe.blog, install on a lockfile change, restart, wait for /api/ready).
The unit is openvibe-blog.service on 127.0.0.1:4810, the env file /etc/openvibe/blog.env. The database is
ov_blog on the host's data role (sudo /opt/openvibe.host/roles/data/add-service.sh blog writes its settings); the
release migrates it at boot. nginx serves
openvibe.blog from deploy/nginx/openvibe.blog.conf.
Rollback: ovhost puts the previous sha back by itself when /api/ready does not answer 2xx after the
restart; afterwards sudo ovhost rollback blog --to <sha>. Nothing blocks a rollback: the schema
code only adds tables and columns.
First install (done once; kept for a rebuild):
- Code and config:
- Put the code at
/opt/openvibe.blogand runnpm ci --omit=devon Node 22. - Create
/etc/openvibe/blog.env(0600) from.env.example. Set these values:OV_OAUTH_CLIENT_SECRETBLOG_OFFICIAL_OWNERSBLOG_FORM_SECRETEVENTS_URL=http://127.0.0.1:4300BASE_URL=https://openvibe.blog
- Put the code at
- Network: set a secret for the seeded OAuth client
blog, then add the grants listed above. - Media: create the
blogtenant/namespace. - systemd: install
deploy/systemd/openvibe-blog.service(port 4810,StateDirectory=openvibe-blog). - nginx: install
deploy/nginx/openvibe.blog.conf./metricsis never proxied. - Seed:
npm run seed. It stays a draft until someone has reviewed it. - Contracts: done: released in openvibe-contracts v0.97.0.
- Launch: in the same release, remove
openvibe.blogfrom OpenVibe.Sites and flip the Network hub entry (see the launch rule above).
Part of the OpenVibe network. Built in the open by OpenVibers.
- openvibe-contracts: v0.217.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2
- openvibe-publishing: v1.4.1