openvibers/openvibe.blog README.md

README.md

main 36.8 KB · 567 lines · markdown

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
10manifest 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
12and 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
19This service hosts the official OpenVibe blog and a blog for each member, with canonical URLs and
20feeds. It uses the shared publishing packages (ADR-019) and doesn't write its own versions of them.
21It 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
31The 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
32applies, 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
47The three views keep the charter's names readable without keeping a second copy of the package state.
48
49Other 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
165The owner's April ask: "AI-generated niche blogs backed by large source datasets" and a "satirical/parody type blog".
166They sit beside the blogs, not in them: a desk post is never a `blog_posts` row (whose AI text waits for a person's
167review), says on every page, feed entry and JSON twin that OpenVibe.AI wrote it and no person reviewed it, and is never
168indexed (`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
181Every 15 minutes (`BLOG_DESKS_INTERVAL_MS`) where the worker runs: the change feed of each Sources category the desks
182read is pulled (openvibe-publishing/ingest cursors in `blog_ingest_cursor`) into `blog_desk_items` (21 days; a removed
183item is dropped); the budget-recipes desk asks OpenVibe.Food for the recipes added since the newest one Blog holds (the
184owner'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
185first 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
187every 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,
189nothing dangerous) and every page and feed title says it is satire. A failed run is retried after 30 minutes with a new
190key (three attempts); the stub provider's text is kept but hidden. Staff (Network admins) may hide a post and show it
191again. `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
196repository and current release from OpenVibe.Network's registry and takes the new commits from GitHub's
197compare 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
200A **Patch notes draft** on the official blog (series "Patch notes") is filed when either of these
201happens:
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
206There is always at least a 6-hour gap between drafts. Nothing is published from the pipeline: the
207draft 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
244Every entry carries `kind` (`new`, `better`, `fixed` or `internal`), one to three `areas` from the
245fixed list (`server/changelog/tags.js`), and `plain` — the subject without its PR number or stage tag.
246Library pins, merges and test-only, docs-only or CI-only commits are `internal` at collection without
247a 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;
249the 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,
251and `--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
254newest devblog post as `{ service, title, teaser, date, url, hero_image_url }`, and without `service`
255the newest devblog of each site under `devblogs` — the card and /updates data of plan §9.
256
257OpenVibe.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
308loopback-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
310receipt 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
325Service tokens use audience `openvibe.blog`, with one capability per route. The person the service
326acts 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
334Browser and app user JWTs are judged by blog membership. The ids and the service manifest are
335released 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`
338decides them with the contracts grant rule.
339
340Called 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
370Each 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
412This 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
414existed; the launch release went out on 2026-09-23. Status against each point:
415
4161. **Runtime, health, readiness, observability:** done.
4172. **Canonical identity and auth:** done.
4183. **SSR public routes useful without JS:** done.
4194. **Persistence and end-to-end workflows:** done; `ovhost drill blog` restored it on the production
420 host on 2026-09-23.
4215. **Capability and event registration against OpenVibe.Contracts:** released in v0.97.0
422 (capabilities and service manifest; no `blog.*` event payload schemas yet).
4236. **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.
4257. **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
434A placeholder never counts as an implemented service. The site is public, but nothing is published
435on 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,
44016–22 September 2026". It's built only from verbatim quotes of commit messages and a CHANGELOG line
441on the main branches of the OpenVibers repositories (`seed/week-2026-09-16.json`). Each quote keeps
442its 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
454Reporting 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
490before any work (before the body is read): `server/http/actor-limits.js`, openvibe-sdk/limits,
491roadmap WS-R task 4. Counted: a person as `user:usr_…` (their own token or cookie, named by a service
492in `X-OV-Subject`, or an app's `on_behalf_of`); a first-party service relaying a signed-out visitor by
493the address it forwards; a service or app acting as itself by its principal; a signed-out caller by
494address. 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
496without a token (it would count as one caller for the whole network). Past a limit: `429`
497problem+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
499one 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
515Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, and the
516pages and feeds people read. `test/actor-limits.test.js`.
517
518## Development
519
520```bash
521fnm exec --using=22.22.1 npm install
522fnm exec --using=22.22.1 npm test # temp databases and in-process mocks, no network
523fnm 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
528Production deploys with `sudo ovhost deploy blog` on the host (strategy `git-checkout`: fetch,
529fast-forward `/opt/openvibe.blog`, install on a lockfile change, restart, wait for `/api/ready`).
530The 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
532release migrates it at boot. nginx serves
533`openvibe.blog` from [deploy/nginx/openvibe.blog.conf](deploy/nginx/openvibe.blog.conf).
534Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
535restart; afterwards `sudo ovhost rollback blog --to <sha>`. Nothing blocks a rollback: the schema
536code only adds tables and columns.
537
538First install (done once; kept for a rebuild):
539
5401. **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`
5482. **Network:** set a secret for the seeded OAuth client `blog`, then add the grants listed above.
5493. **Media:** create the `blog` tenant/namespace.
5504. **systemd:** install `deploy/systemd/openvibe-blog.service` (port 4810, `StateDirectory=openvibe-blog`).
5515. **nginx:** install `deploy/nginx/openvibe.blog.conf`. `/metrics` is never proxied.
5526. **Seed:** `npm run seed`. It stays a draft until someone has reviewed it.
5537. **Contracts:** done: released in openvibe-contracts v0.97.0.
5548. **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
559Part 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