openvibers/openvibe.blog README.md
README.md
| 1 | # OpenVibe.Blog |
| 2 | |
| 3 | > The official OpenVibe blog and a blog for every member: drafts, revisions, series, scheduling, feeds. |
| 4 | |
| 5 | **Live site:** https://openvibe.blog · **API docs:** https://openvibe.services · **For AI:** https://openvibe.blog/llms.txt |
| 6 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | **Status:** alpha (roadmap Wave 16, Blog half). **Public at https://openvibe.blog since 2026-09-23** |
| 9 | (the launch release also removed the domain from OpenVibe.Sites). Its capabilities and service |
| 10 | manifest are released in openvibe-contracts v0.97.0. The network changelog files Patch notes |
| 11 | **drafts** on the official blog ([below](#the-network-changelog-and-patch-notes)); a person reviews |
| 12 | and publishes each one, and the seed post stays a draft until a person reviews it. |
| 13 | **Domain:** `openvibe.blog` · **Port:** 4810 · **Service id:** `blog` |
| 14 | **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.6; roadmap §15.13, §29, §32. |
| 15 | **License:** AGPL-3.0 (same as every OpenVibe service). |
| 16 | |
| 17 | ## Purpose |
| 18 | |
| 19 | This service hosts the official OpenVibe blog and a blog for each member, with canonical URLs and |
| 20 | feeds. It uses the shared publishing packages (ADR-019) and doesn't write its own versions of them. |
| 21 | It also uses: |
| 22 | |
| 23 | - Media for attachments |
| 24 | - Community for comments |
| 25 | - OpenVibe.AI's `blog.draft_post` output, as drafts that need review |
| 26 | - openvibe-shared theme presets |
| 27 | - a VIP gate: members-only posts decided by OpenVibe.VIP (the blog owner's members), teaser and join link for everyone else, failing closed |
| 28 | |
| 29 | ## Owns |
| 30 | |
| 31 | 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 |
| 32 | applies, it creates the table with the `blog` prefix: |
| 33 | |
| 34 | | Charter table | What it is | |
| 35 | |---|---| |
| 36 | | `blogs` | Owned by Blog. The official blog (`openvibe`, served at `/`) and the member blogs. One per member, owned by their Network subject (`usr_…`). | |
| 37 | | `blog_memberships` | Owned by Blog. Roles are owner, editor and author. | |
| 38 | | `blog_posts` | Owned by Blog. Publication state: slug, state, visibility, entitlement key, published revision, series, flags. | |
| 39 | | `blog_post_revisions` | `openvibe-publishing/revisions` (prefix `blog_post`). Immutable: triggers abort UPDATE and DELETE. Drafts go in `blog_post_drafts`. | |
| 40 | | `blog_series` | Owned by Blog. Ordered series for each blog. | |
| 41 | | `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. | |
| 42 | | `blog_post_terms` | A view over `blog_term_links`. | |
| 43 | | `blog_schedules` | A view over `blog_schedule_jobs` (`openvibe-publishing/schedule`, prefix `blog`). | |
| 44 | | `blog_redirects` | `openvibe-publishing/seo` history-aware redirects (prefix `blog`). | |
| 45 | | `blog_feed_settings` | Owned by Blog. RSS, Atom and JSON Feed switches, item count, and full text or summaries. | |
| 46 | |
| 47 | The three views keep the charter's names readable without keeping a second copy of the package state. |
| 48 | |
| 49 | Other tables in the same database: |
| 50 | |
| 51 | - Package companions: `blog_post_citations`, `blog_post_attachments`, `blog_post_reviews`, |
| 52 | `blog_post_discussion_refs` and `blog_index_revisions`. |
| 53 | - `event_outbox`, the SDK transactional outbox. |
| 54 | - `subject_projections`, a display cache of Network names. It isn't authority. |
| 55 | - `telemetry_samples`, this service's own request telemetry (migration `0003_telemetry.sql`): aggregated by route template, method and status class, never |
| 56 | 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 |
| 57 | 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 |
| 58 | rows older than 14 days daily. `/shared`, static assets, health, readiness and `/metrics` are not counted. |
| 59 | - The AI desks (migration `0004_ai_desks.sql`): `blog_ingest_cursor` (Sources change cursors), `blog_desk_items` (the |
| 60 | Sources items the desks may cite, 21 days) and `blog_desk_posts` (one post per desk and period). |
| 61 | |
| 62 | ## Does not own |
| 63 | |
| 64 | - **Comments.** They belong to OpenVibe.Community. A public post's thread is resolved from |
| 65 | `EntityRef {service: 'blog', type: 'post', id}`. Blog stores only the thread id and renders the |
| 66 | thread live. |
| 67 | - **Media bytes.** They belong to OpenVibe.Media. Blog stores Media object ids (`med_…`) and has an |
| 68 | explicit broken-asset state. |
| 69 | - **Entitlements.** They belong to Billing/VIP. |
| 70 | - **Identity.** It belongs to OpenVibe.Network: SSO, subjects and service principals. |
| 71 | - **Search.** OpenVibe.Search indexes what Blog sends through Events. |
| 72 | - **AI generation.** OpenVibe.AI makes no provider calls from Blog. |
| 73 | |
| 74 | ## What works |
| 75 | |
| 76 | - **Blogs:** |
| 77 | - The official blog exists from the first boot. Its owners come from `BLOG_OFFICIAL_OWNERS`, |
| 78 | and Network admins act as its owners. |
| 79 | - Every member gets a blog on first use (`/write/start` or `POST /api/v1/blogs`), with handle = |
| 80 | Network username unless the member picks another. |
| 81 | - Owners, editors and authors. Owners manage members, settings, theme and feeds. |
| 82 | - **Posts:** |
| 83 | - Drafts and immutable revisions, with optimistic concurrency. A stale edit is `412 |
| 84 | revision.conflict` and nothing is lost. |
| 85 | - Diff, and revert as a new revision. |
| 86 | - A published post keeps showing its published revision while newer ones are drafted. |
| 87 | - **Publishing:** |
| 88 | - Publish, unpublish and soft delete (`410 Gone`). |
| 89 | - Scheduled publish or unpublish, idempotent across worker restarts (leases, and "revision N is |
| 90 | published" as the effect). |
| 91 | - `blog.schedule.failed` when a job gives up. |
| 92 | - **Organizing:** |
| 93 | - Author pages, both per blog and network-wide. |
| 94 | - Tags, nested categories and ordered series. |
| 95 | - Slug changes leave 301s from every old path, and chains collapse. |
| 96 | - **Media and themes:** |
| 97 | - Media-rich posts: cover, inline (`[[media:med_…]]` lines) and gallery figures. |
| 98 | - The worker re-verifies attachments against Media. Deleted or missing objects render |
| 99 | "This media is no longer available." An outage changes nothing. |
| 100 | - Themes are six openvibe-shared token presets. |
| 101 | - **Comments:** Community threads on public posts, with a plain comment form. The thread is hidden |
| 102 | when a post is deleted or stops being public. |
| 103 | - **AI drafts:** `X-OV-Origin: ai` with an OpenVibe.AI workflow run. The draft is AI-generated, |
| 104 | noindex and can't be published or scheduled until a person records an approving review. A person |
| 105 | editing it makes a hybrid (AI-assisted) revision. The disclosure appears on the page, in the JSON |
| 106 | and in the Search document. |
| 107 | - **Members-only posts (OpenVibe.VIP, the `blog gated_post` binding):** |
| 108 | - Visibility `members`. The blog's own members (owner, editors, authors of the post) and staff |
| 109 | read as always. Anyone else is admitted only when OpenVibe.VIP says so: |
| 110 | `POST /api/v1/policies/evaluate` with `resource { service: 'blog', type: 'post', id }`, |
| 111 | `owner` = the blog's owner (`blogs.owner_subject`) and Blog's default gate |
| 112 | `fallback { requirement: 'member', binding: 'blog:gated_post' }` — an active member of the owner |
| 113 | or, if the owner defines a perk bound to `blog gated_post`, a member whose plan version includes |
| 114 | it. A rule the owner sets for the post on openvibe.vip takes precedence. |
| 115 | ([server/clients/vip.js](server/clients/vip.js), the seam in `server/domain/access.js`.) |
| 116 | - `BLOG_ENTITLEMENTS_PROVIDER`: `vip` is the default when `OV_OAUTH_CLIENT_SECRET` is set, else |
| 117 | `none` (only the blog's members and staff). Every doubt — VIP down, a refused token, no grant, |
| 118 | no owner — is a refusal. |
| 119 | - The official blog has no owner in VIP (its owners are memberships), so its members-only posts |
| 120 | admit nobody beyond its members and staff. |
| 121 | - Everyone else gets a **teaser**: the title, the author's summary (never text taken from the |
| 122 | body; write a summary to have one) and a join link to the owner's plans |
| 123 | (`OV_VIP_URL/<owner username>`), on the page (403), the `.json` twin (403 `{ teaser }`) and |
| 124 | `GET /api/v1/posts/:id` (403 problem with `teaser`). Feeds, sitemaps and listings for outsiders |
| 125 | carry public posts only; Search gets a tombstone; the product events' document carries the |
| 126 | teaser, never the body. |
| 127 | - `entitlement_key` (default `vip:<handle>`) is informational: it names the gate in the members |
| 128 | ACL of product-event documents. VIP decides. |
| 129 | - The client is `openvibe-vip/client`, vendored verbatim in |
| 130 | [server/vip/vip-client.js](server/vip/vip-client.js) (from OpenVibe.VIP 2accbeb) until VIP |
| 131 | publishes a tag to pin. |
| 132 | - **Convergence bound.** Answers are cached (`createVipCache`): a "yes" at most |
| 133 | `BLOG_VIP_CACHE_TTL_MS` (30 s, and never past the entitlement's expiry), a "no" |
| 134 | `BLOG_VIP_CACHE_DENY_TTL_MS` (10 s: a new member waits at most that long), a failure |
| 135 | `BLOG_VIP_CACHE_UNAVAILABLE_TTL_MS` (2 s, and it never extends a "yes"). So once VIP stops |
| 136 | granting (it applied `billing.entitlement.changed` and emitted `vip.membership.changed`), Blog |
| 137 | stops admitting that reader within 30 s. Blog does not subscribe to Events yet; a delivery |
| 138 | handed to `ctx.vip.cache.handleEvent` refuses at once. End to end (VIP's own bound when events |
| 139 | are lost) see OpenVibe.VIP's README, "The product cache and the convergence bound". |
| 140 | `test/members-vip.test.js` proves both. |
| 141 | |
| 142 | ### Routes (server-rendered, useful without JavaScript) |
| 143 | |
| 144 | | Route | What | |
| 145 | |---|---| |
| 146 | | `/` | the official blog | |
| 147 | | `/@:handle` | a member's blog | |
| 148 | | `/@:handle/:slug`, `/@:handle/:slug.json` | a post, and the same post as data | |
| 149 | | `/@:handle/tags/:tag` · `/tags/:tag` | tag pages, per blog and network-wide | |
| 150 | | `/@:handle/categories/:category` | category pages; a parent includes its children, with breadcrumbs | |
| 151 | | `/@:handle/series/:series` | series pages, in order | |
| 152 | | `/@:handle/authors/:who` · `/authors/:who` | author pages | |
| 153 | | `/feed.xml`, `/atom.xml`, `/feed.json` | the official blog's feeds | |
| 154 | | `/@:handle/feed.xml`, `/@:handle/atom.xml`, `/@:handle/feed.json` | each member blog's feeds | |
| 155 | | `/sitemap.xml` → `/sitemaps/posts.xml`, `/sitemaps/blogs.xml` | sitemaps | |
| 156 | | `/robots.txt` | names the sitemap and states the policy for automated consumers | |
| 157 | | `/llms.txt` | orientation for language models | |
| 158 | | `/write/…` | the editor, as plain HTML forms with Network SSO and a form token | |
| 159 | | `/auth/*` | sign-in; the same session layer as OpenVibe.Community | |
| 160 | | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | health, readiness and metrics; `/metrics` is loopback only | |
| 161 | | `/desks`, `/desks/:desk`, `/desks/:desk/:slug(.json)`, `/desks/:desk/feed.xml\|atom.xml` | the AI desks (below); staff `POST …/hide\|show` | |
| 162 | |
| 163 | ### AI desks (`server/desks/`) |
| 164 | |
| 165 | The owner's April ask: "AI-generated niche blogs backed by large source datasets" and a "satirical/parody type blog". |
| 166 | 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 |
| 167 | review), says on every page, feed entry and JSON twin that OpenVibe.AI wrote it and no person reviewed it, and is never |
| 168 | indexed (`noindex`, not in the sitemaps). |
| 169 | |
| 170 | | Desk | Sources (OpenVibe.Sources keys) | When | |
| 171 | |---|---|---| |
| 172 | | 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) | |
| 173 | | Food safety digest | `fda-food-recalls` | daily, 06:00 UTC (1 item at least) | |
| 174 | | Deals digest | `dealnews-daily` | daily, 07:00 UTC (5 items at least) | |
| 175 | | Hosting digest | `linode-types`, `aws-lightsail-bundles`, `ovhcloud-vps`, `vultr-plans`, `lowendbox-posts`, `lowendtalk-offers` | Mondays, for the week before | |
| 176 | | Crowdfunding digest | `opencollective-accounts` | Mondays, for the week before (3 items at least) | |
| 177 | | Developer digest | `nodejs-blog` | Mondays, for the week before | |
| 178 | | 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) | |
| 179 | | Is This Real? (satire) | none | daily, 14:00 UTC | |
| 180 | |
| 181 | Every 15 minutes (`BLOG_DESKS_INTERVAL_MS`) where the worker runs: the change feed of each Sources category the desks |
| 182 | read is pulled (openvibe-publishing/ingest cursors in `blog_ingest_cursor`) into `blog_desk_items` (21 days; a removed |
| 183 | item is dropped); the budget-recipes desk asks OpenVibe.Food for the recipes added since the newest one Blog holds (the |
| 184 | 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 |
| 185 | first pull never presents old items as new. When a desk's period is over, its newest items (40 at most) go to |
| 186 | `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 |
| 187 | every cited item is linked under the post. The satire desk sends the next question of `server/desks/catalog.js` to |
| 188 | `blog.write_satire` (fiction with its own rules in OpenVibe.AI: no real people, no words in a real organisation's mouth, |
| 189 | nothing dangerous) and every page and feed title says it is satire. A failed run is retried after 30 minutes with a new |
| 190 | key (three attempts); the stub provider's text is kept but hidden. Staff (Network admins) may hide a post and show it |
| 191 | again. `BLOG_DESKS=off` stops the desks; the digests need `OV_SOURCES_INTERNAL_URL`, every desk `OV_AI_INTERNAL_URL`. |
| 192 | |
| 193 | ### The network changelog and patch notes |
| 194 | |
| 195 | `server/changelog.js` keeps what shipped on every OpenVibe site. Every 5 minutes it reads each service's |
| 196 | repository and current release from OpenVibe.Network's registry and takes the new commits from GitHub's |
| 197 | compare API. The first time it sees a site it also imports that site's last `CHANGELOG_HISTORY` commits |
| 198 | (60 by default). Those history entries are shown in the feed but never go into a post. |
| 199 | |
| 200 | A **Patch notes draft** on the official blog (series "Patch notes") is filed when either of these |
| 201 | happens: |
| 202 | |
| 203 | - `CHANGELOG_BATCH_SIZE` changes have gathered. |
| 204 | - A large release (`CHANGELOG_MAJOR_LINES`, a version bump or `[major]`) has been quiet for 30 minutes. |
| 205 | |
| 206 | There is always at least a 6-hour gap between drafts. Nothing is published from the pipeline: the |
| 207 | draft is the only artifact, and publishing it needs a person's approval of the revision. See |
| 208 | `server/patch-notes/`: |
| 209 | |
| 210 | - **stories** — the commits become workstreams (sign-in, touch, phone-home, orbit, …), clustered by |
| 211 | intent, a `Workstream:` trailer or a shared package, split at 72 hours, classified feature / fix / |
| 212 | chore / internal and ranked by player impact. Lockfiles and version bumps fold into one "Under the |
| 213 | hood" paragraph. |
| 214 | - **draft** — the §3.1 template: a title and dek, three player outcomes, one H2 per story with what |
| 215 | changed, why and how to see it, Under the hood, What's next from the open PRs (at most three, each |
| 216 | linked), and the full commit list under `<details>`. OpenVibe.AI's `blog.draft_post` writes the |
| 217 | prose from the numbered commits plus the voice card and the two newest published posts; without AI |
| 218 | the deterministic post is filed. |
| 219 | - **voice** — one card per author account (`blog_voice_cards`; `official` is seeded), edited at |
| 220 | **Write → How I write**. It is private unless the author publishes it. |
| 221 | - **slop** and **no-looks** — the banned phrases and the structures (a three-item list in every |
| 222 | paragraph, a restating conclusion, an em dash every other sentence), and the owner's rule that |
| 223 | public AI text never describes a person. The editor shows both. |
| 224 | - **review** — the checks and the checklist the reviewer sees, and the hard gates that stop |
| 225 | `posts.publish`: a banned phrase (unless the editor overrides it with a reason), a looks word, an |
| 226 | unresolved `[TODO]`, a broken link, an image without alt text, AI-only text without approval, or |
| 227 | no named person's approval at all. |
| 228 | |
| 229 | `node scripts/patch-notes.js` files a draft from the pending commits (`--dry-run` prints it, |
| 230 | `--no-ai` uses the deterministic post). |
| 231 | |
| 232 | `GET /api/v1/changelog?service=&kind=&area=&q=&limit=&before=` is public (CORS `*`, cached for 60 s). It returns: |
| 233 | |
| 234 | | Field | Contents | |
| 235 | |---|---| |
| 236 | | `entries` | Each entry has its site, sha, plain words, kind, areas, author, time, commit link and pull request | |
| 237 | | `next` | The cursor for the next page | |
| 238 | | `facets` | The kind and area counts in the same service and `q` scope (the filter chips) | |
| 239 | | `vocabulary` | The four kinds and the 30 fixed areas with their display labels | |
| 240 | | `latest_post` | The newest patch notes post | |
| 241 | | `posts` | The recent patch notes posts | |
| 242 | | `sites` | Only without `service`: each site with its count and latest time | |
| 243 | |
| 244 | Every entry carries `kind` (`new`, `better`, `fixed` or `internal`), one to three `areas` from the |
| 245 | fixed list (`server/changelog/tags.js`), and `plain` — the subject without its PR number or stage tag. |
| 246 | Library pins, merges and test-only, docs-only or CI-only commits are `internal` at collection without |
| 247 | a model; everything else is tagged by OpenVibe.AI's `blog.changelog.tag` task |
| 248 | (`server/changelog/tagger.js`), whose owner-chosen model answers (the cheapest good one by default; |
| 249 | the owner changes it on Exchange). `CHANGELOG_AI_TAG=off` leaves new entries for the backfill. |
| 250 | `node scripts/backfill-changelog-tags.js` tags existing entries: a dry run prints what it would do, |
| 251 | and `--apply` writes (a production database also needs `--production`). |
| 252 | |
| 253 | `GET /api/v1/changelog/devblog?service=live` is public too (CORS `*`, cached 5 minutes): the site's |
| 254 | newest devblog post as `{ service, title, teaser, date, url, hero_image_url }`, and without `service` |
| 255 | the newest devblog of each site under `devblogs` — the card and /updates data of plan §9. |
| 256 | |
| 257 | OpenVibe.Network proxies this feed at `openvibe.network/api/v1/changelog`. Every site shows it through |
| 258 | `openvibe-shared/shipped.js`: |
| 259 | |
| 260 | - the "shipped X ago" line in the shared footer; |
| 261 | - the home pill and the recent list; |
| 262 | - the `/updates` log. |
| 263 | |
| 264 | ### Discoverability (roadmap §32) |
| 265 | |
| 266 | - Every page gets its robots meta, canonical and `X-Robots-Tag` from the |
| 267 | `openvibe-publishing/seo` gate, with explicit reasons (for example `thin (42 of 80 words)`). |
| 268 | Reasons show in the editor and in the JSON. |
| 269 | - Blog's gate policy: 80 words minimum, sources optional. A short post is served and appears in |
| 270 | feeds but is noindex. |
| 271 | - JSON-LD `BlogPosting` is built from real fields only. Missing fields are omitted, and a |
| 272 | publisher is set only for the official blog. |
| 273 | - Drafts, scheduled posts, unlisted, members-only, private, unpublished, deleted and noindex posts |
| 274 | never appear in feeds, sitemaps or Search. Feeds and sitemaps are never built for a particular |
| 275 | viewer. |
| 276 | |
| 277 | ### IndexNow (openvibe-shared/indexnow) |
| 278 | |
| 279 | - With `INDEXNOW_KEY` set, the key file is served at `/<key>.txt` as `text/plain`. |
| 280 | - Publishing, updating (a new revision or a slug change), unpublishing and deleting ping |
| 281 | `api.indexnow.org` with the post's canonical URL and `/sitemap.xml`; the module batches and |
| 282 | debounces (one POST per 30s window). A failed ping never takes a publish down. |
| 283 | - Drafts, scheduled, unlisted, members-only, private and noindex posts never ping. Unset key: off — |
| 284 | no key file, no requests. |
| 285 | |
| 286 | ### Caching (no leaks through shared caches) |
| 287 | |
| 288 | - All HTML and JSON varies on `Cookie` and `Authorization`. |
| 289 | - `Cache-Control: public, max-age=60` applies only to public, published posts and to lists of |
| 290 | public posts shown to anonymous visitors. |
| 291 | - Everything else is `private, no-store` plus `X-Robots-Tag`. That covers signed-in views, drafts, |
| 292 | previews, unlisted, members-only and private posts, 403, 404, 410 and the API. |
| 293 | - Feeds and sitemaps are public for 5 minutes. They only ever contain public posts. |
| 294 | |
| 295 | ### Events (SDK outbox, same transaction as the change) |
| 296 | |
| 297 | | Event | Notes | |
| 298 | |---|---| |
| 299 | | `blog.post.created` | internal | |
| 300 | | `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. | |
| 301 | | `blog.schedule.failed` | internal | |
| 302 | | `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. | |
| 303 | | `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. | |
| 304 | |
| 305 | ### Account export and deletion (ADR-033) |
| 306 | |
| 307 | `network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events`. The route is |
| 308 | loopback-only (nginx answers 404 for `/internal/`, and the handler refuses a forwarded request) and signed with |
| 309 | `BLOG_EVENTS_SECRET`. They are answered by `server/domain/account-data.js` over `openvibe-sdk/account-data`, with one |
| 310 | receipt per export and deletion in `account_data_events`. |
| 311 | |
| 312 | - **Export:** the person's blog, memberships, posts, revisions, drafts and reviews. |
| 313 | - **Their own member blog:** every post is removed the way its owner would remove it (`posts.remove`: unpublished, |
| 314 | out of Search and the feeds). The blog is then suspended and loses its title, description and owner; the handle |
| 315 | stays reserved. |
| 316 | - **Deleted:** their memberships, drafts and display-name cache. |
| 317 | - **Authorless:** posts they wrote on other blogs stay published, with `author_subject` set to `deleted`. Revisions |
| 318 | and citations are append-only, so only the erasure transaction may clear a revision's author (and their id in |
| 319 | `meta.authorship.authors`) or a citation's `attached_by` (`blog.account_erasure`, migration |
| 320 | `0002_account_erasure.sql`); the text never changes. |
| 321 | - **Kept:** post reviews, since a person's approval is what lets reviewed text stay published. |
| 322 | |
| 323 | ## Capabilities (released in openvibe-contracts v0.97.0) |
| 324 | |
| 325 | Service tokens use audience `openvibe.blog`, with one capability per route. The person the service |
| 326 | acts for goes in `X-OV-Subject`, and membership still applies: |
| 327 | |
| 328 | - `blog.blog.create` (the charter's `blog.create`), `blog.blog.configure`, `blog.theme.set` |
| 329 | - `blog.member.manage` |
| 330 | - `blog.post.create`, `blog.post.read`, `blog.post.update`, `blog.post.publish`, |
| 331 | `blog.post.schedule`, `blog.post.unpublish`, `blog.post.delete` |
| 332 | - `blog.feed.read` |
| 333 | |
| 334 | Browser and app user JWTs are judged by blog membership. The ids and the service manifest are |
| 335 | released in openvibe-contracts (pinned at v0.338.0; the source proposals stay in |
| 336 | [docs/capabilities-proposal/](docs/capabilities-proposal/) and |
| 337 | [docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)); `server/auth/capabilities.js` |
| 338 | decides them with the contracts grant rule. |
| 339 | |
| 340 | Called elsewhere, as the service principal `blog`: `identity.subject.resolve` and |
| 341 | `network.integration.github.read` (Network), `events.event.publish` (Events), |
| 342 | `community.comment.write` and optionally `community.comment.moderate` (Community), |
| 343 | `media.object.read` (Media), `vip.resource.policy.evaluate` (VIP) and `ai.run.create` with |
| 344 | `ai.run.read` (AI, for AI drafts). The list with audiences is under |
| 345 | [Grants the Network must hold for client `blog`](#grants-the-network-must-hold-for-client-blog). |
| 346 | |
| 347 | ## Depends on |
| 348 | |
| 349 | - **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through |
| 350 | `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process). |
| 351 | - **Packages** (all pinned by release tarball): `openvibe-publishing` v1.7.0 (async PostgreSQL stores: revisions, |
| 352 | schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), `openvibe-contracts` |
| 353 | v0.97.0, `openvibe-shared` v4.183.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets), |
| 354 | `openvibe-sdk` v0.166.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing). |
| 355 | - **OpenVibe.Network:** |
| 356 | - SSO: the OAuth client `blog` is already seeded with redirect |
| 357 | `https://openvibe.blog/auth/callback`. |
| 358 | - JWKS. |
| 359 | - `identity.subject.resolve` for author names. |
| 360 | - **OpenVibe.Community:** `community.comment.write`, and optionally `community.comment.moderate`. |
| 361 | - **OpenVibe.Media:** `media.object.read` for namespace `blog`. |
| 362 | - **OpenVibe.Events:** `events.event.publish`. |
| 363 | - **OpenVibe.Search:** consumes `blog.index_document.*` through its `*.index_document.*` |
| 364 | subscription. `blog` is already in Search's default `SEARCH_EVENT_OWNERS`. |
| 365 | - **OpenVibe.VIP:** `vip.resource.policy.evaluate` for members-only posts |
| 366 | (`OV_VIP_INTERNAL_URL`, default `http://127.0.0.1:4620`). Without it the gate fails closed. |
| 367 | |
| 368 | ### Grants the Network must hold for client `blog` |
| 369 | |
| 370 | Each grant is `[client, capability, audience]`: |
| 371 | |
| 372 | - `[blog, identity.subject.resolve, openvibe.network]` |
| 373 | - `[blog, events.event.publish, openvibe.events]` |
| 374 | - `[blog, events.subscription.manage, openvibe.events]` (the two account subscriptions, created at boot), then, last and |
| 375 | once the release is live, `[blog, network.account.export.contribute, openvibe.network]` and |
| 376 | `[blog, network.account.deletion.confirm, openvibe.network]` (ADR-033) |
| 377 | - `[blog, community.comment.write, openvibe.community]` |
| 378 | - `[blog, community.comment.moderate, openvibe.community]` (optional: hides the threads of deleted |
| 379 | or no-longer-public posts) |
| 380 | - `[blog, media.object.read, openvibe.media]`, namespace `blog`. Media also needs a `blog` tenant. |
| 381 | - `[blog, vip.resource.policy.evaluate, openvibe.vip]` for members-only posts (until granted, only the |
| 382 | blog's members and staff read them). |
| 383 | - `[blog, network.integration.github.read, openvibe.network]` (optional: the changelog reads GitHub |
| 384 | with the network's token instead of anonymously; `CHANGELOG_GITHUB_TOKEN` overrides it). |
| 385 | - `[blog, ai.run.create, openvibe.ai]` and `[blog, ai.run.read, openvibe.ai]` for AI drafts (the |
| 386 | editor's "draft with AI" and the dev-blog draft of each patch notes post). |
| 387 | - `[blog, sources.item.read, openvibe.sources]` for the AI desks' digests (`blog.write_digest` and `blog.write_satire` |
| 388 | run under the `ai.run.create` grant above). |
| 389 | - For OpenVibe.AI to deliver drafts: `[ai, blog.post.create, openvibe.blog]`. Add |
| 390 | `[ai, blog.post.read, openvibe.blog]` if it reads the drafts back. |
| 391 | |
| 392 | ## Acceptance (automated: `npm test`) |
| 393 | |
| 394 | | Charter / roadmap requirement | Test | |
| 395 | |---|---| |
| 396 | | 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` | |
| 397 | | Revisions survive edits: they're immutable, 412 on a stale base, and revert makes a new revision. | `test/lifecycle.test.js` | |
| 398 | | Feeds, canonical HTML, sitemap and Search converge on publication state: publish, revise, unpublish and delete. | `test/lifecycle.test.js` | |
| 399 | | 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` | |
| 400 | | 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` | |
| 401 | | 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` | |
| 402 | | 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` | |
| 403 | | 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` | |
| 404 | | 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` | |
| 405 | | The seed post, readiness, release, robots, llms.txt and the sitemap index. | `test/seed-ops.test.js` | |
| 406 | | The contract proposals are valid against the released schemas and match the code. | `test/contracts.test.js` | |
| 407 | | 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` | |
| 408 | | 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` | |
| 409 | |
| 410 | ## Launch rule |
| 411 | |
| 412 | This repository alone doesn't make the product live. `openvibe.blog` kept its placeholder on |
| 413 | [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of plan §12.12 |
| 414 | existed; the launch release went out on 2026-09-23. Status against each point: |
| 415 | |
| 416 | 1. **Runtime, health, readiness, observability:** done. |
| 417 | 2. **Canonical identity and auth:** done. |
| 418 | 3. **SSR public routes useful without JS:** done. |
| 419 | 4. **Persistence and end-to-end workflows:** done; `ovhost drill blog` restored it on the production |
| 420 | host on 2026-09-23. |
| 421 | 5. **Capability and event registration against OpenVibe.Contracts:** released in v0.97.0 |
| 422 | (capabilities and service manifest; no `blog.*` event payload schemas yet). |
| 423 | 6. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:** done. There is |
| 424 | nothing to migrate, the seed is described below and the threat review is below. |
| 425 | 7. **Acceptance tests:** done. |
| 426 | |
| 427 | **The launch release did all of these in one release (2026-09-23):** |
| 428 | |
| 429 | - Removes `openvibe.blog` from `OpenVibe.Sites/sites.json`. |
| 430 | - Switches routing: nginx vhost, DNS and TLS. |
| 431 | - Flips the Network hub entry (`server/chrome/sites.js`, `status: 'soon'`). |
| 432 | - Registers maturity in the ecosystem registry. |
| 433 | |
| 434 | A placeholder never counts as an implemented service. The site is public, but nothing is published |
| 435 | on it until a person reviews and publishes the seed post (below). |
| 436 | |
| 437 | ## Seed |
| 438 | |
| 439 | `npm run seed` creates the official blog's first post, "Released in the OpenVibe repositories, |
| 440 | 16–22 September 2026". It's built only from verbatim quotes of commit messages and a CHANGELOG line |
| 441 | on the main branches of the OpenVibers repositories (`seed/week-2026-09-16.json`). Each quote keeps |
| 442 | its commit or tag, date and GitHub URL, and is attached as a citation. |
| 443 | |
| 444 | - It makes no deployment claims. Several services' `STATUS.json` say `deployed: false`, and the |
| 445 | post says it doesn't state that anything is running. |
| 446 | - There's no pricing copy. |
| 447 | - It is created as a **draft**. No person has reviewed it yet, so it isn't marked as reviewed. |
| 448 | - To publish it, a person checks the quotes and runs |
| 449 | `npm run seed -- --publish --reviewer usr_…`. That records their approving review and publishes |
| 450 | the post. |
| 451 | |
| 452 | ## Security and threat review |
| 453 | |
| 454 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). |
| 455 | |
| 456 | - **Identity:** |
| 457 | - Only verified Network JWTs (offline RS256 against JWKS) and service tokens for audience |
| 458 | `openvibe.blog`. |
| 459 | - Service tokens are judged on the token alone. A bad one is refused, never downgraded to |
| 460 | anonymous. |
| 461 | - Identity never comes from a body or query. `X-OV-*` headers are ignored for browsers. |
| 462 | - **CSRF:** SameSite=Lax session cookie plus an HMAC form token (`BLOG_FORM_SECRET`) on every form |
| 463 | POST. |
| 464 | - **XSS:** |
| 465 | - All HTML goes through `openvibe-publishing/ssr` auto-escaping. |
| 466 | - Markdown uses the package's safe subset: no raw HTML and no images (media only by object id). |
| 467 | - Links on member blogs get `rel="nofollow ugc noopener"`. |
| 468 | - CSP from helmet. |
| 469 | - **Private content:** read decisions live in `server/domain/access.js`. 404 hides drafts and |
| 470 | private posts, and 403 (with the teaser, never the body) applies to members-only posts. A redirect never reveals the new slug of a |
| 471 | post the reader may not see. |
| 472 | - **Leaks:** see the caching and events sections above. The product events carry no body. Search |
| 473 | gets bodies only for public, listable posts. |
| 474 | - **SSRF:** Blog makes no outbound calls to user-chosen URLs. It calls only its configured Network, |
| 475 | Community, Media and VIP hosts. |
| 476 | - **AI:** output is never attributed to a person. It needs a person's (`usr_`) review before |
| 477 | publication or indexing, and only a signed-in person can record a review. |
| 478 | - **Abuse:** rate limits on `/auth`, `/write` and `/api/v1`, both in Express and in the nginx |
| 479 | reference, and per-actor limits (below). |
| 480 | - **Known gaps:** |
| 481 | - Uploads go through OpenVibe.Media. The editor attaches existing `med_…` ids and doesn't |
| 482 | upload. |
| 483 | - Media publishes no deletion event yet, so broken assets are found by polling (the worker |
| 484 | interval). |
| 485 | - The Community thread visibility sync is best effort and needs `community.comment.moderate`. |
| 486 | |
| 487 | ### Per-actor limits |
| 488 | |
| 489 | `/api/v1`, the editor and comment posts also limit who calls them, once `req.viewer` is resolved and |
| 490 | before any work (before the body is read): `server/http/actor-limits.js`, openvibe-sdk/limits, |
| 491 | roadmap WS-R task 4. Counted: a person as `user:usr_…` (their own token or cookie, named by a service |
| 492 | in `X-OV-Subject`, or an app's `on_behalf_of`); a first-party service relaying a signed-out visitor by |
| 493 | the address it forwards; a service or app acting as itself by its principal; a signed-out caller by |
| 494 | address. A first-party service reading for itself is not counted on reads, and neither is |
| 495 | `GET /api/v1/changelog`, which OpenVibe.Network reads for every site's "shipped" widget from loopback |
| 496 | without a token (it would count as one caller for the whole network). Past a limit: `429` |
| 497 | problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and |
| 498 | `blog_rate_limited_total{limit,window}`. An editor form and the API route that do the same thing share |
| 499 | one budget. |
| 500 | |
| 501 | | Routes (API and editor) | Per caller, a minute / an hour | |
| 502 | |---|---| |
| 503 | | API reads | `BLOG_LIMITS_MINUTE` / `BLOG_LIMITS_HOUR` (120 / 3000) | |
| 504 | | Blog create (`POST /blogs`, `/write/start`) | 10 / 60 | |
| 505 | | Blog settings and theme; members | 30 / 300 each | |
| 506 | | Post create (`POST /blogs/:handle/posts`, `/write/@:handle/new`) | 30 / 300 | |
| 507 | | Post edit and revert (`PATCH /posts/:id`, `/revert`, `/write/posts/:id`) | 30 / 600 | |
| 508 | | AI draft (`…/posts/ai-draft`, `/write/@:handle/ai-draft`: an OpenVibe.AI run) | 5 / 30 | |
| 509 | | Publish, schedule, unschedule, unpublish, reviews | 30 / 300 | |
| 510 | | Delete | 30 / 300 | |
| 511 | | Media attach and remove | 20 / 200 | |
| 512 | | API diffs | 30 / 600 | |
| 513 | | Comments (`POST /@:handle/:slug/comments`, sent to Community) | 20 / 300 | |
| 514 | |
| 515 | Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, and the |
| 516 | pages and feeds people read. `test/actor-limits.test.js`. |
| 517 | |
| 518 | ## Development |
| 519 | |
| 520 | ```bash |
| 521 | fnm exec --using=22.22.1 npm install |
| 522 | fnm exec --using=22.22.1 npm test # temp databases and in-process mocks, no network |
| 523 | fnm exec --using=22.22.1 npm run dev # http://localhost:4810 (set OV_OAUTH_CLIENT_SECRET to sign in) |
| 524 | ``` |
| 525 | |
| 526 | ## Deploy (for the lead) |
| 527 | |
| 528 | Production deploys with `sudo ovhost deploy blog` on the host (strategy `git-checkout`: fetch, |
| 529 | fast-forward `/opt/openvibe.blog`, install on a lockfile change, restart, wait for `/api/ready`). |
| 530 | The unit is `openvibe-blog.service` on `127.0.0.1:4810`, the env file `/etc/openvibe/blog.env`. The database is |
| 531 | `ov_blog` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh blog` writes its settings); the |
| 532 | release migrates it at boot. nginx serves |
| 533 | `openvibe.blog` from [deploy/nginx/openvibe.blog.conf](deploy/nginx/openvibe.blog.conf). |
| 534 | Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the |
| 535 | restart; afterwards `sudo ovhost rollback blog --to <sha>`. Nothing blocks a rollback: the schema |
| 536 | code only adds tables and columns. |
| 537 | |
| 538 | First install (done once; kept for a rebuild): |
| 539 | |
| 540 | 1. **Code and config:** |
| 541 | - Put the code at `/opt/openvibe.blog` and run `npm ci --omit=dev` on Node 22. |
| 542 | - Create `/etc/openvibe/blog.env` (0600) from `.env.example`. Set these values: |
| 543 | - `OV_OAUTH_CLIENT_SECRET` |
| 544 | - `BLOG_OFFICIAL_OWNERS` |
| 545 | - `BLOG_FORM_SECRET` |
| 546 | - `EVENTS_URL=http://127.0.0.1:4300` |
| 547 | - `BASE_URL=https://openvibe.blog` |
| 548 | 2. **Network:** set a secret for the seeded OAuth client `blog`, then add the grants listed above. |
| 549 | 3. **Media:** create the `blog` tenant/namespace. |
| 550 | 4. **systemd:** install `deploy/systemd/openvibe-blog.service` (port 4810, `StateDirectory=openvibe-blog`). |
| 551 | 5. **nginx:** install `deploy/nginx/openvibe.blog.conf`. `/metrics` is never proxied. |
| 552 | 6. **Seed:** `npm run seed`. It stays a draft until someone has reviewed it. |
| 553 | 7. **Contracts:** done: released in openvibe-contracts v0.97.0. |
| 554 | 8. **Launch:** in the same release, remove `openvibe.blog` from OpenVibe.Sites and flip the Network |
| 555 | hub entry (see the launch rule above). |
| 556 | |
| 557 | --- |
| 558 | |
| 559 | Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). |
| 560 | |
| 561 | <!-- versions:start --> |
| 562 | - openvibe-contracts: v0.419.0 |
| 563 | - openvibe-sdk: v0.166.0 |
| 564 | - openvibe-shared: v4.241.0 |
| 565 | - openvibe-publishing: v1.7.0 |
| 566 | <!-- versions:end --> |
| 567 |