Repositories openvibers openvibe.blog official

openvibers/openvibe.blog

Open source blog platform: the official OpenVibe blog and a blog for every member, with drafts, revisions, series, scheduling and feeds.

Get it

Get this repository

Clone over HTTPS

A Git token is made for this copy and shown once. It can read and write the repositories you own, and expires in an hour.

git clone https://git.openvibe.codes/openvibers/openvibe.blog.git
Clone to my computer

OpenVibe Link asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.

Clone to my computer
Open in Codes

A Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.

Open in Codes
Files at main
.env.exampleBlog: account export and deletion (ADR-033) — a person's own blog closed with its posts removed as its owner would, memberships and drafts deleted, posts elsewhere made authorless (append-only rows only inside the erasure transaction), reviews kept; loopback /internal/events; subscriptions at boot (SDK v0.37.0) (#15)9 Oct 20264.5 KB
.githubopenvibe-shared v4.223.0 (was v4.214.0) (#291)19 h ago
.gitignoreOpenVibe.Blog service (Wave 16): official and member blogs on openvibe-publishing v0.2.023 Sept 202693 B
AGENTS.mdAGENTS.md: the context an agent starts from (#290)1 days ago9.4 KB
CHANGELOG.mdBlog: find and make pictures through the shared image task (#264)3 days ago4.3 KB
CITATION.cffREADME first screen and CITATION.cff (#258)3 days ago521 B
CLAUDE.mdAGENTS.md: the context an agent starts from (#290)1 days ago11 B
LICENSECharter: OpenVibe.Blog as defined in the OpenVibe realignment plan (placeholder, no code yet)21 Sept 202633.7 KB
README.mdopenvibe-shared v4.235.0 (was v4.234.0) (#307)19 min ago36.8 KB
SECURITY.mdSECURITY.md: how to report a vulnerability ([email protected], 7-day reply, scope, supported versions)24 Sept 2026986 B
STATUS.jsonopenvibe-shared v4.235.0 (was v4.234.0) (#307)19 min ago2.7 KB
deployBlog: the vhost serves security.txt like the installed copy (#21)10 Oct 2026
docsDraft weekly site devblogs through the AI writer (#252)3 days ago
migrationsBlog: a default audience for new posts, and apply it to existing posts (#280)2 days ago
package-lock.jsonopenvibe-shared v4.235.0 (was v4.234.0) (#307)19 min ago65.4 KB
package.jsonopenvibe-shared v4.235.0 (was v4.234.0) (#307)19 min ago1.6 KB
publicBlog: find and make pictures through the shared image task (#264)3 days ago
scriptsFallback when changelog tagging is synthetic (#274)2 days ago
seedOpenVibe.Blog service (Wave 16): official and member blogs on openvibe-publishing v0.2.023 Sept 2026
serveropenvibe-shared v4.228.0 (was v4.225.0) (#299)10 h ago
testopenvibe-shared v4.228.0 (was v4.225.0) (#299)10 h ago

README.md

View file

OpenVibe.Blog

The official OpenVibe blog and a blog for every member: drafts, revisions, series, scheduling, feeds.

Live site: https://openvibe.blog · API docs: https://openvibe.services · For AI: https://openvibe.blog/llms.txt
Run it: npm ci && npm run dev (Node 22 or newer) · Tests: npm test

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 files Patch notes
drafts on the official blog (below); a person reviews
and publishes each one, and 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_post output, 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/](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_refs and blog_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 (migration 0003_telemetry.sql): aggregated by route template, method and status class, never
    by raw URL; server/app.js builds the collector, server/index.js flushes it once a minute and on stop (one http.request sample per key, plus the
    active-requests, p95 latency and event-loop-lag gauges, in the openvibe-sdk 0.46 platform.telemetry-sample@1 shape), and server/worker.js deletes
    rows older than 14 days daily. /shared, static assets, health, readiness and /metrics are 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) and blog_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/start or POST /api/v1/blogs), with handle =
    Network username unless the member picks another.
  • Owners, editors and authors. Owners manage members, settings, theme and feeds.
  • Posts:
  • Drafts and immutable revisions, with optimistic concurrency. A stale edit is 412 revision.conflict and nothing is lost.
  • Diff, and revert as a new revision.
  • A published post keeps showing its published revision while newer ones are drafted.
  • 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.failed when a job gives up.
  • 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.
  • 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: ai with 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_post binding):
  • 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/evaluate with resource { service: 'blog', type: 'post', id },
    owner = the blog's owner (blogs.owner_subject) and Blog's default gate
    fallback { requirement: 'member', binding: 'blog:gated_post' } — an active member of the owner
    or, if the owner defines a perk bound to blog 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](server/clients/vip.js), the seam in server/domain/access.js.)
  • BLOG_ENTITLEMENTS_PROVIDER: vip is the default when OV_OAUTH_CLIENT_SECRET is set, else
    none (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 .json twin (403 { teaser }) and
    GET /api/v1/posts/:id (403 problem with teaser). 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 (default vip:<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](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 most
    BLOG_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 failure
    BLOG_VIP_CACHE_UNAVAILABLE_TTL_MS (2 s, and it never extends a "yes"). So once VIP stops
    granting (it applied billing.entitlement.changed and emitted vip.membership.changed), Blog
    stops admitting that reader within 30 s. Blog does not subscribe to Events yet; a delivery
    handed to ctx.vip.cache.handleEvent refuses 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.js proves both.
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 draft on the official blog (series "Patch notes") is filed when either of these
happens:

  • CHANGELOG_BATCH_SIZE changes 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 drafts. Nothing is published from the pipeline: the
draft is the only artifact, and publishing it needs a person's approval of the revision. See
server/patch-notes/:

  • stories — the commits become workstreams (sign-in, touch, phone-home, orbit, …), clustered by
    intent, a Workstream: trailer or a shared package, split at 72 hours, classified feature / fix /
    chore / internal and ranked by player impact. Lockfiles and version bumps fold into one "Under the
    hood" paragraph.
  • draft — the §3.1 template: a title and dek, three player outcomes, one H2 per story with what
    changed, why and how to see it, Under the hood, What's next from the open PRs (at most three, each
    linked), and the full commit list under <details>. OpenVibe.AI's blog.draft_post writes the
    prose from the numbered commits plus the voice card and the two newest published posts; without AI
    the deterministic post is filed.
  • voice — one card per author account (blog_voice_cards; official is seeded), edited at
    Write → How I write. It is private unless the author publishes it.
  • slop and no-looks — the banned phrases and the structures (a three-item list in every
    paragraph, a restating conclusion, an em dash every other sentence), and the owner's rule that
    public AI text never describes a person. The editor shows both.
  • review — the checks and the checklist the reviewer sees, and the hard gates that stop
    posts.publish: a banned phrase (unless the editor overrides it with a reason), a looks word, an
    unresolved [TODO], a broken link, an image without alt text, AI-only text without approval, or
    no named person's approval at all.

node scripts/patch-notes.js files a draft from the pending commits (--dry-run prints it,
--no-ai uses the deterministic post).

GET /api/v1/changelog?service=&kind=&area=&q=&limit=&before= is public (CORS *, cached for 60 s). It returns:

| Field | Contents |
|---|---|
| entries | Each entry has its site, sha, plain words, kind, areas, author, time, commit link and pull request |
| next | The cursor for the next page |
| facets | The kind and area counts in the same service and q scope (the filter chips) |
| vocabulary | The four kinds and the 30 fixed areas with their display labels |
| 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 |

Every entry carries kind (new, better, fixed or internal), one to three areas from the
fixed list (server/changelog/tags.js), and plain — the subject without its PR number or stage tag.
Library pins, merges and test-only, docs-only or CI-only commits are internal at collection without
a model; everything else is tagged by OpenVibe.AI's blog.changelog.tag task
(server/changelog/tagger.js), whose owner-chosen model answers (the cheapest good one by default;
the owner changes it on Exchange). CHANGELOG_AI_TAG=off leaves new entries for the backfill.
node scripts/backfill-changelog-tags.js tags existing entries: a dry run prints what it would do,
and --apply writes (a production database also needs --production).

GET /api/v1/changelog/devblog?service=live is public too (CORS *, cached 5 minutes): the site's
newest devblog post as { service, title, teaser, date, url, hero_image_url }, and without service
the newest devblog of each site under devblogs — the card and /updates data of plan §9.

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 /updates log.
Discoverability (roadmap §32)
  • Every page gets its robots meta, canonical and X-Robots-Tag from the
    openvibe-publishing/seo gate, with explicit reasons (for example thin (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 BlogPosting is 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_KEY set, the key file is served at /<key>.txt as text/plain.
  • Publishing, updating (a new revision or a slug change), unpublishing and deleting ping
    api.indexnow.org with 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 Cookie and Authorization.
  • Cache-Control: public, max-age=60 applies only to public, published posts and to lists of
    public posts shown to anonymous visitors.
  • Everything else is private, no-store plus X-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_subject set to deleted. Revisions
    and citations are append-only, so only the erasure transaction may clear a revision's author (and their id in
    meta.authorship.authors) or a citation's attached_by (blog.account_erasure, migration
    0002_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's blog.create), blog.blog.configure, blog.theme.set
  • blog.member.manage
  • blog.post.create, blog.post.read, blog.post.update, blog.post.publish,
    blog.post.schedule, blog.post.unpublish, blog.post.delete
  • blog.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.338.0; the source proposals stay in
[docs/capabilities-proposal/](docs/capabilities-proposal/) and
[docs/service-manifest-proposal.json](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 through
    openvibe-sdk/db; Valkey holds the per-actor limit counters (optional: without VALKEY_URL they count per process).
  • Packages (all pinned by release tarball): openvibe-publishing v1.7.0 (async PostgreSQL stores: revisions,
    schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), openvibe-contracts
    v0.97.0, openvibe-shared v4.183.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
    openvibe-sdk v0.161.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).
  • OpenVibe.Network:
  • SSO: the OAuth client blog is already seeded with redirect
    https://openvibe.blog/auth/callback.
  • JWKS.
  • identity.subject.resolve for author names.
  • OpenVibe.Community: community.comment.write, and optionally community.comment.moderate.
  • OpenVibe.Media: media.object.read for namespace blog.
  • OpenVibe.Events: events.event.publish.
  • OpenVibe.Search: consumes blog.index_document.* through its *.index_document.*
    subscription. blog is already in Search's default SEARCH_EVENT_OWNERS.
  • OpenVibe.VIP: vip.resource.policy.evaluate for members-only posts
    (OV_VIP_INTERNAL_URL, default http://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], namespace blog. Media also needs a blog tenant.
  • [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_TOKEN overrides 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_digest and blog.write_satire
    run under the ai.run.create grant 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:

  1. Runtime, health, readiness, observability: done.
  2. Canonical identity and auth: done.
  3. SSR public routes useful without JS: done.
  4. Persistence and end-to-end workflows: done; ovhost drill blog restored it on the production
    host on 2026-09-23.
  5. Capability and event registration against OpenVibe.Contracts: released in v0.97.0
    (capabilities and service manifest; no blog.* event payload schemas yet).
  6. 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.
  7. Acceptance tests: done.

The launch release did all of these in one release (2026-09-23):

  • Removes openvibe.blog from OpenVibe.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.json say deployed: 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](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.
  • CSRF: SameSite=Lax session cookie plus an HMAC form token (BLOG_FORM_SECRET) on every form
    POST.
  • XSS:
  • All HTML goes through openvibe-publishing/ssr auto-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.
  • 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, /write and /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.
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](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):

  1. Code and config:
    - Put the code at /opt/openvibe.blog and run npm ci --omit=dev on Node 22.
    - Create /etc/openvibe/blog.env (0600) from .env.example. Set these values:
    - OV_OAUTH_CLIENT_SECRET
    - BLOG_OFFICIAL_OWNERS
    - BLOG_FORM_SECRET
    - EVENTS_URL=http://127.0.0.1:4300
    - BASE_URL=https://openvibe.blog
  2. Network: set a secret for the seeded OAuth client blog, then add the grants listed above.
  3. Media: create the blog tenant/namespace.
  4. systemd: install deploy/systemd/openvibe-blog.service (port 4810, StateDirectory=openvibe-blog).
  5. nginx: install deploy/nginx/openvibe.blog.conf. /metrics is never proxied.
  6. Seed: npm run seed. It stays a draft until someone has reviewed it.
  7. Contracts: done: released in openvibe-contracts v0.97.0.
  8. Launch: in the same release, remove openvibe.blog from OpenVibe.Sites and flip the Network
    hub entry (see the launch rule above).

Part of the OpenVibe network. Built in the open by OpenVibers.

<!-- versions:start -->

  • openvibe-contracts: v0.419.0
  • openvibe-sdk: v0.161.0
  • openvibe-shared: v4.235.0
  • openvibe-publishing: v1.7.0

<!-- versions:end -->