# 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](#the-network-changelog-and-patch-notes)); 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_`) 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/`), 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:`) 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 `
`. 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 `/.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`](#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 `/.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](https://github.com/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 ```bash 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 `. 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](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). - openvibe-contracts: v0.419.0 - openvibe-sdk: v0.161.0 - openvibe-shared: v4.241.0 - openvibe-publishing: v1.7.0