Mirror of github.com/OpenVibers/OpenVibe.Blog
  • JavaScript 95.2%
  • PLpgSQL 3.2%
  • CSS 1.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
OpenVibers 3c943863e2
openvibe-contracts v0.217.0 (was v0.216.0) (#94)
Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.217.0 release.
2026-10-27 05:12:27 -07:00
.github/workflows Blog moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G1), async from end to end. 2026-09-28 06:49:32 -07:00
deploy Blog: the vhost serves security.txt like the installed copy (#21) 2026-10-09 18:49:29 -07:00
docs The rest of the SQLite-era cleanup (the previous commit carried only its deletions): *_DB_PATH/dbPath out of config, .env.example, the systemd unit and test helpers; the unused better-sqlite3 dependency and its lockfile entries; docs, docstrings and STATUS lines that still described SQLite or its rollback window. Reviewed independently (VERDICT: SHIP). npm test 25/25 test files passed. 2026-09-28 19:41:47 -07:00
migrations Blog: AI desks — digests from Sources' items (news, food recalls, deals, hosting, dev) and the Is This Real? satire desk, labelled and never indexed (#57) 2026-10-15 13:20:57 -07:00
public/css The blog's home shows what is on the blog: the numbers, the newest posts as cards, the week's changes across OpenVibe, the AI desks' latest, topics, members' posts, how it works, questions and links around the site (#71) 2026-10-22 07:38:58 -07:00
scripts The rest of the SQLite-era cleanup (the previous commit carried only its deletions): *_DB_PATH/dbPath out of config, .env.example, the systemd unit and test helpers; the unused better-sqlite3 dependency and its lockfile entries; docs, docstrings and STATUS lines that still described SQLite or its rollback window. Reviewed independently (VERDICT: SHIP). npm test 25/25 test files passed. 2026-09-28 19:41:47 -07:00
seed OpenVibe.Blog service (Wave 16): official and member blogs on openvibe-publishing v0.2.0 2026-09-22 19:45:13 -07:00
server A blog on the vibe preset follows the reader's OpenVibe theme (the official blog's front page pinned vibe's blue over the season's colours) (#91) 2026-10-27 03:05:28 -07:00
test A blog on the vibe preset follows the reader's OpenVibe theme (the official blog's front page pinned vibe's blue over the season's colours) (#91) 2026-10-27 03:05:28 -07:00
.env.example Blog: 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) 2026-10-08 20:03:08 -07:00
.gitignore OpenVibe.Blog service (Wave 16): official and member blogs on openvibe-publishing v0.2.0 2026-09-22 19:45:13 -07:00
CHANGELOG.md Blog: 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) 2026-10-08 20:03:08 -07:00
LICENSE Charter: OpenVibe.Blog as defined in the OpenVibe realignment plan (placeholder, no code yet) 2026-09-21 09:18:31 -07:00
package-lock.json openvibe-contracts v0.217.0 (was v0.216.0) (#94) 2026-10-27 05:12:27 -07:00
package.json openvibe-contracts v0.217.0 (was v0.216.0) (#94) 2026-10-27 05:12:27 -07:00
README.md openvibe-contracts v0.217.0 (was v0.216.0) (#94) 2026-10-27 05:12:27 -07:00
SECURITY.md SECURITY.md: how to report a vulnerability ([email protected], 7-day reply, scope, supported versions) 2026-09-23 19:27:24 -07:00
STATUS.json openvibe-contracts v0.217.0 (was v0.216.0) (#94) 2026-10-27 05:12:27 -07:00

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_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/). 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, 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 (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 post on the official blog (series "Patch notes", imported authorship, grouped by site) is published 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 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 /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.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 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.4.1 (async PostgreSQL stores: revisions, schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), openvibe-contracts v0.97.0, openvibe-shared v4.15.2 (Frame, app icon, footer, legal, release, metrics, ready, theme presets), openvibe-sdk v0.35.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.

  • 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. 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.

  • openvibe-contracts: v0.217.0
  • openvibe-sdk: v0.65.0
  • openvibe-shared: v4.15.2
  • openvibe-publishing: v1.4.1