openvibers/openvibe.blog Commits
README (purpose, owns, does not own, depends on, acceptance, launch rule, threat review), STATUS alpha W16, .env.example, deploy (systemd unit, nginx vhost on 4810), CI on Node 22.22.1 (contracts check continue-on-error until release), capability and service manifest proposals
Changes
nnew file mode 100644@@@ -0,0 +1,66 @@++# ═══════════════════════════════════════════════════════════════++# openvibe.blog — environment configuration (names only; never commit values)++# Copy to .env for development. Production: /etc/openvibe/blog.env (0600).++# ═══════════════════════════════════════════════════════════════++++# Server++PORT=4810++HOST=127.0.0.1++NODE_ENV=development++# Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it++BASE_URL=http://localhost:4810++# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)++TRUST_PROXY=2++++# SQLite database (production unit: /var/lib/openvibe-blog/blog.db)++BLOG_DB_PATH=./data/blog.db++++# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# OAuth client `blog` (redirect https://openvibe.blog/auth/callback). The same client is Blog's++# service principal svc:blog for client-credentials grants (see README, "Grants").++OV_OAUTH_CLIENT_ID=blog++OV_OAUTH_CLIENT_SECRET=++# Production: https://openvibe.blog/auth/callback (default: BASE_URL + /auth/callback)++#OV_OAUTH_REDIRECT_URI=++# Cookies: Secure by default in production; set false for plain-http local development++#COOKIE_SECURE=false++# Signs the editor/comment form tokens (CSRF). Unset: random per process.++#BLOG_FORM_SECRET=++++# The official blog (served at /): owners are Network subjects (usr_…), comma-separated++BLOG_OFFICIAL_OWNERS=++#BLOG_OFFICIAL_TITLE=The OpenVibe blog++#BLOG_OFFICIAL_DESCRIPTION=News and release notes from the OpenVibe network.++++# OpenVibe.Media — attachments are Media object ids; bytes are served by Media++OV_MEDIA_URL=https://openvibe.media++OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100++# Media tenant/namespace for Blog's objects (media.object.read is granted for it)++BLOG_MEDIA_APP=blog++#BLOG_MEDIA_VERIFY_INTERVAL_MS=600000++++# OpenVibe.Community — comment threads (referenced, never copied)++OV_COMMUNITY_URL=https://openvibe.community++OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200++++# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set++#EVENTS_URL=http://127.0.0.1:4300++#EVENTS_RELAY_INTERVAL_MS=2000++++# Scheduled publication worker (in-process); off: BLOG_WORKER=off++#BLOG_WORKER=on++#BLOG_SCHEDULE_INTERVAL_MS=15000++#BLOG_WORKER_ID=++++# Members-only (VIP) posts: 'none' = no entitlement service yet → fails closed++# (only the blog's members and staff can read them)++#BLOG_ENTITLEMENTS_PROVIDER=none++++# Browser origins that may call /api/v1 with a Bearer Network JWT++#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.media++++# release.json (openvibe-shared/release): the deployed commit when git is not available++#RELEASE_COMMIT=++#MIN_CLIENT_RELEASE=nnew file mode 100644@@@ -0,0 +1,20 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1 # production runs Node 22++ cache: npm++ - run: npm ci --no-audit --no-fund++ - run: npm test++ # Blog's service manifest and capabilities are proposals (docs/) until the lead releases them++ # in openvibe-contracts; until then the check reports "no service manifest blog".++ - run: npx openvibe-contracts-check --service blog --src server++ continue-on-error: true@@@ -2,77 +2,318 @@> The official OpenVibe blog and a blog for every member: drafts, revisions, series, scheduling, feeds.--**Status:** placeholder — planning only, no runnable code yet.--**Domain:** `openvibe.blog`--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.6.++**Status:** alpha (roadmap Wave 16, Blog half). The service runs and its tests pass. It is **not++deployed**, `openvibe.blog` still shows its placeholder from OpenVibe.Sites, and its capabilities and++service manifest are proposals that the next openvibe-contracts release has to include.++**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--Official and user blogs with canonical URLs and feeds, reusing Media, Community comments, AI drafting workflows, themes and optional VIP gating.++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++- an optional VIP gate: members-only posts with an entitlement check that fails closed## Owns--- `blogs`, `blog_memberships`, `blog_posts`, `blog_post_revisions`, `blog_series`, `blog_taxonomy`, `blog_post_terms`, `blog_schedules`, `blog_redirects`, `blog_feed_settings`++The ten charter tables live in Blog's own SQLite (`BLOG_DB_PATH`). Where a publishing package++applies, it creates the table with the `blog` prefix:++++| Charter table | What it is |++|---|---|++| `blogs` | Owned by Blog. The official blog (`openvibe`, served at `/`) and the member blogs. One per member, owned by their Network subject (`usr_…`). |++| `blog_memberships` | Owned by Blog. Roles are owner, editor and author. |++| `blog_posts` | Owned by Blog. Publication state: slug, state, visibility, entitlement key, published revision, series, flags. |++| `blog_post_revisions` | `openvibe-publishing/revisions` (prefix `blog_post`). Immutable: triggers abort UPDATE and DELETE. Drafts go in `blog_post_drafts`. |++| `blog_series` | Owned by Blog. Ordered series for each blog. |++| `blog_taxonomy` | A view over `blog_terms` (`openvibe-publishing/taxonomy`, prefix `blog`). Tags are network-wide. Categories are per blog (vocabulary `category_<blog>`) and can be nested. |++| `blog_post_terms` | A view over `blog_term_links`. |++| `blog_schedules` | A view over `blog_schedule_jobs` (`openvibe-publishing/schedule`, prefix `blog`). |++| `blog_redirects` | `openvibe-publishing/seo` history-aware redirects (prefix `blog`). |++| `blog_feed_settings` | Owned by Blog. RSS, Atom and JSON Feed switches, item count, and full text or summaries. |++++The three views keep the charter's names readable without keeping a second copy of the package state.++++Other tables in the same database:++++- Package companions: `blog_post_citations`, `blog_post_attachments`, `blog_post_reviews`,++ `blog_post_discussion_refs` and `blog_index_revisions`.++- `event_outbox`, the SDK transactional outbox.++- `subject_projections`, a display cache of Network names. It isn't authority.## Does not own--- comments (Community)--- media bytes (Media)--- entitlements (Billing/VIP)++- **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 (VIP) posts:**++ - Visibility `members` with an `entitlement_key` in the ACL.++ - The entitlement seam (`server/domain/access.js`) admits nobody while++ `BLOG_ENTITLEMENTS_PROVIDER=none`, the default because no entitlement service exists yet. Only++ the blog's members and staff can read these posts.--## Planned surfaces++### Routes (server-rendered, useful without JavaScript)--- blog creation and configuration per eligible account, draft/preview/revision/publish/unpublish/schedule, tags/categories/series, RSS/Atom and sitemaps, theme inheritance, official release notes++| 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 |--## Data (authority tables / families)++### Discoverability (roadmap §32)--- see above++- 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.--## Capabilities and events++### Caching (no leaks through shared caches)--- `blog.create`, `blog.post.create|update|publish|schedule|unpublish`, `blog.feed.read`, `blog.theme.set`++- 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: ``blog.post.created|published|updated|unpublished|deleted``, ``blog.schedule.failed``++### 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.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. |++++### Capabilities (proposed: `docs/capabilities-proposal/`)++++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. Until the proposals are released, grants++for these ids are decided locally with the contracts library's matching rule++(`server/auth/capabilities.js`). Nothing changes when they are released. The service manifest++proposal is `docs/service-manifest-proposal.json`.## Depends on--- shared publishing packages--- OpenVibe.Network--- OpenVibe.Media--- OpenVibe.Community--- themes--- Search--- optional OpenVibe.VIP++- **Packages** (all pinned by release tarball): `openvibe-publishing` v0.2.0 (revisions, schedule,++ taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr), `openvibe-contracts`++ v0.13.0, `openvibe-shared` v1.3.0 (chrome, app icon, footer, legal, release, metrics, ready,++ theme presets), `openvibe-sdk` v0.2.2 (events outbox, service tokens).++- **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`.++- **Optional:** an entitlement service (OpenVibe.VIP or Billing's `billing.entitlement.check`) for++ members-only posts. Until one exists, the gate fails closed.++++### Grants the Network must hold for client `blog`--## Acceptance (must be true before "done")++Each grant is `[client, capability, audience]`:--- scheduled publication is idempotent across worker restarts--- feeds, canonical HTML, sitemap and search converge on publication state--- VIP-only/private posts never leak through cache, feed or search--- a deleted media object yields an explicit broken-asset state++- `[blog, identity.subject.resolve, openvibe.network]`++- `[blog, events.event.publish, openvibe.events]`++- `[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.++- 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.--## Bootstrap / extraction source++## Acceptance (automated: `npm test`)--No current implementation; bootstraps from the shared publishing runtime (Wave 14).++| 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` |++| 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` |## Launch rule--This repository does not make the product real, and the domain keeps its placeholder page on--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the--following exist here (plan §12.12):----1. an owning runtime with health/readiness endpoints and observability;--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);--3. server-rendered or static public routes that are useful without JavaScript;--4. real persistence and end-to-end workflows;--5. capability and event registration against `OpenVibe.Contracts`;--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;--7. acceptance tests proving the advertised functionality.----The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an--implemented service.++This repository alone doesn't make the product live. `openvibe.blog` keeps its placeholder on++[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of plan §12.12++exists. 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.++5. **Capability and event registration against OpenVibe.Contracts:** proposals are in `docs/`,++ waiting on the release.++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 does all of these in one release:**++++- 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, and this README doesn't call the service++live.++++## 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++++- **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 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 and Media 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.++- **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`.++++## 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)++++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:** release the capability and manifest proposals in openvibe-contracts. Then CI's++ contracts check can drop `continue-on-error`.++8. **Launch:** in the same release, remove `openvibe.blog` from OpenVibe.Sites and flip the Network++ hub entry (see the launch rule above).---@@@ -1,7 +1,19 @@{"repository": "OpenVibers/OpenVibe.Blog",-- "stage": "placeholder",-- "code": false,++ "stage": "alpha",++ "code": true,++ "wave": "W16","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "updated": "2026-09-21"++ "runtime": "node 22, express 4, better-sqlite3 11",++ "port": 4810,++ "domain": "openvibe.blog",++ "packages": ["openvibe-publishing v0.2.0", "openvibe-contracts v0.13.0", "openvibe-shared v1.3.0", "openvibe-sdk v0.2.2"],++ "capabilities": ["blog.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"],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "entitlements": "none (members-only posts fail closed until an entitlement service exists)",++ "seedPost": "draft (verbatim quotes; not reviewed by a person)",++ "deployed": false,++ "placeholderInSites": true,++ "updated": "2026-09-22"}nnew file mode 100644@@@ -0,0 +1,103 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.blog — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.blog.conf — keep them in sync and reload nginx++## after changing it. The Node app (port 4810, systemd unit openvibe-blog) renders every page;++## nginx terminates TLS, redirects www → apex, rate-limits auth, editor and API, and compresses text.++##++## No proxy_cache here on purpose: the app decides cacheability per response (Cache-Control++## public only for public posts and lists shown to anonymous visitors; private, no-store for++## everything else, Vary: Cookie, Authorization). A cache added in front must honour those headers.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=ovblog_api:10m rate=10r/s;++limit_req_zone $binary_remote_addr zone=ovblog_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=ovblog_write:10m rate=5r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.blog www.openvibe.blog;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.blog$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.blog;++++ ssl_certificate /etc/letsencrypt/live/openvibe.blog/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.blog/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.blog$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.blog;++++ ssl_certificate /etc/letsencrypt/live/openvibe.blog/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.blog/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.blog.access.log;++ error_log /var/log/nginx/openvibe.blog.error.log;++++ # Posts are at most 200 000 characters; forms are URL-encoded (no uploads: media lives in Media).++ client_max_body_size 1m;++++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/feed+json image/svg+xml text/plain application/xml text/xml application/rss+xml application/atom+xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── Network SSO session endpoints (strict) ──────────────++ location /auth/ {++ limit_req zone=ovblog_auth burst=10 nodelay;++ proxy_pass http://127.0.0.1:4810;++ }++++ # ── The editor (forms) ──────────────────────────────────++ location /write {++ limit_req zone=ovblog_write burst=20 nodelay;++ proxy_pass http://127.0.0.1:4810;++ }++++ # ── API ─────────────────────────────────────────────────++ location /api/ {++ limit_req zone=ovblog_api burst=30 nodelay;++ proxy_pass http://127.0.0.1:4810;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4810/metrics) ──++ location = /metrics { return 404; }++++ # ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──++ location / {++ proxy_pass http://127.0.0.1:4810;++ proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,31 @@++[Unit]++Description=OpenVibe.Blog — the official OpenVibe blog and a blog for every member++# Network (sign-in, JWKS, service tokens) and Events (the outbox relay) should be up first, but Blog++# degrades gracefully without them: pages and feeds serve, sign-in and events wait (see /api/ready).++After=network.target++Wants=openvibe-network.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.blog++EnvironmentFile=/etc/openvibe/blog.env++Environment=NODE_ENV=production++Environment=PORT=4810++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.blog/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++TimeoutStopSec=10++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# Blog's own database lives in /var/lib/openvibe-blog; the code tree stays read-only.++StateDirectory=openvibe-blog++Environment=BLOG_DB_PATH=/var/lib/openvibe-blog/blog.db++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "blog.blog.configure",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Change a blog's title, description, language and feed settings (RSS, Atom, JSON Feed on/off, item count, full text or summaries). Only an owner of the blog (or staff on the official blog) acting through X-OV-Subject.",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-write",++ "events": [],++ "implementedBy": [++ "PATCH /api/v1/blogs/:handle"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "blog.blog.create",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Get-or-create the blog of the acting member (X-OV-Subject, a usr_ subject): one member blog per subject, created on first use, owned by that subject, handle = the Network username unless another free handle is given. The charter's blog.create as a three-segment id. Browsers use their Network user JWT instead.",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-write",++ "events": [],++ "implementedBy": [++ "POST /api/v1/blogs"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "blog.feed.read",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Read a blog's JSON Feed through the API (public, published, listable posts only; the same items as /@handle/feed.json). Browsers and crawlers read the public RSS, Atom and JSON Feed URLs without any capability.",++ "permissions": [++ "blog:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "blog-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/blogs/:handle/feed"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "blog.member.manage",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "List, add, change and remove a blog's members (owner, editor, author) by Network subject. A member blog keeps at least one owner. Owners only.",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-write",++ "events": [],++ "implementedBy": [++ "GET /api/v1/blogs/:handle/members",++ "PUT /api/v1/blogs/:handle/members/:subject",++ "DELETE /api/v1/blogs/:handle/members/:subject"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "blog.post.create",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Create a draft post (revision 1) on a blog the acting member belongs to. With X-OV-Origin: ai the draft is AI-generated output (OpenVibe.AI blog.draft_post; authorship.workflow { id, runId } required): never attributed to the person, noindex, and not publishable until a person reviews it.",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-write",++ "events": [++ "blog.post.created"++ ],++ "implementedBy": [++ "POST /api/v1/blogs/:handle/posts"++ ]++}nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "id": "blog.post.delete",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Delete a post: readers get 410, Search a tombstone, the Community thread is hidden; immutable revisions are kept. Editors, owners, the author, and staff (moderation).",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-write",++ "events": [++ "blog.post.deleted",++ "blog.index_document.upserted",++ "blog.index_document.deleted"++ ],++ "implementedBy": [++ "DELETE /api/v1/posts/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "id": "blog.post.publish",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Make a revision the published one (idempotent). AI-generated revisions are refused (409 post.review_required) until a person has recorded an approving review.",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-write",++ "events": [++ "blog.post.published",++ "blog.post.updated",++ "blog.index_document.upserted",++ "blog.index_document.deleted"++ ],++ "implementedBy": [++ "POST /api/v1/posts/:id/publish"++ ]++}nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "id": "blog.post.read",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Read posts that are not public (drafts, scheduled, members-only, private), their revisions and diffs, for the member in X-OV-Subject. Published public posts need no capability.",++ "permissions": [++ "blog:read"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/posts/:id",++ "GET /api/v1/posts/:id/revisions",++ "GET /api/v1/posts/:id/revisions/:n",++ "GET /api/v1/posts/:id/diff",++ "GET /api/v1/blogs/:handle/posts?all=1"++ ]++}nnew file mode 100644@@@ -0,0 +1,25 @@++{++ "id": "blog.post.schedule",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Schedule a publish of a revision (or an unpublish) at a future time, or cancel it. Idempotent per (post, action, time, revision); applied by Blog's worker with leases, so a restart never publishes twice; a job that cannot succeed emits blog.schedule.failed.",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-write",++ "events": [++ "blog.post.published",++ "blog.schedule.failed",++ "blog.index_document.upserted",++ "blog.index_document.deleted"++ ],++ "implementedBy": [++ "POST /api/v1/posts/:id/schedule",++ "DELETE /api/v1/posts/:id/schedule"++ ]++}nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "id": "blog.post.unpublish",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Take a published post down (readers get 404, Search a tombstone, feeds and sitemaps drop it). Editors, owners, the author, and staff (moderation).",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-write",++ "events": [++ "blog.post.unpublished",++ "blog.index_document.upserted",++ "blog.index_document.deleted"++ ],++ "implementedBy": [++ "POST /api/v1/posts/:id/unpublish"++ ]++}nnew file mode 100644@@@ -0,0 +1,26 @@++{++ "id": "blog.post.update",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Edit a post: content becomes a new immutable revision (expected_revision required; 412 revision.conflict when someone saved first), slug changes leave 301 redirects, visibility, taxonomy, series, media attachments (OpenVibe.Media object ids) and revert as a new revision. Authors edit their own posts; editors and owners every post of the blog.",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-write",++ "events": [++ "blog.post.updated",++ "blog.index_document.upserted",++ "blog.index_document.deleted"++ ],++ "implementedBy": [++ "PATCH /api/v1/posts/:id",++ "POST /api/v1/posts/:id/revert",++ "POST /api/v1/posts/:id/attachments",++ "DELETE /api/v1/posts/:id/attachments/:aid"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "blog.theme.set",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Choose a blog's theme from the small set of openvibe-shared token presets (vibe, paper, slate, sand, nord, high-contrast). There is no site-local theme engine and no custom CSS. Owners only.",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "blog-write",++ "events": [],++ "implementedBy": [++ "PUT /api/v1/blogs/:handle/theme"++ ]++}nnew file mode 100644@@@ -0,0 +1,45 @@++{++ "version": "0.1.0",++ "contractRanges": {++ "openvibe-contracts": ">=0.13.0 <1.0.0"++ },++ "id": "blog",++ "name": "OpenVibe.Blog",++ "status": "alpha",++ "repository": "OpenVibers/OpenVibe.Blog",++ "domains": [++ "openvibe.blog"++ ],++ "publicOrigin": "https://openvibe.blog",++ "health": "/api/health",++ "ready": "/api/ready",++ "capabilities": [++ "blog.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"++ ],++ "eventsProduced": [++ "blog.post.created",++ "blog.post.published",++ "blog.post.updated",++ "blog.post.unpublished",++ "blog.post.deleted",++ "blog.schedule.failed",++ "blog.index_document.upserted",++ "blog.index_document.deleted"++ ],++ "eventsConsumed": [],++ "namespacesOwned": [++ "blog.*"++ ],++ "notes": "Port 4810. Owns blogs, blog_memberships, blog_posts, blog_post_revisions, blog_series, blog_taxonomy, blog_post_terms, blog_schedules, blog_redirects, blog_feed_settings in its own SQLite (openvibe-publishing packages, prefix blog). Consumes OpenVibe.Network (SSO, identity.subject.resolve), OpenVibe.Community (community.comment.write, community.comment.moderate), OpenVibe.Media (media.object.read, namespace blog) and publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads blog.index_document.* from Events."++}
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+# ═══════════════════════════════════════════════════════════════
+# openvibe.blog — environment configuration (names only; never commit values)
+# Copy to .env for development. Production: /etc/openvibe/blog.env (0600).
+# ═══════════════════════════════════════════════════════════════
+
+# Server
+PORT=4810
+HOST=127.0.0.1
+NODE_ENV=development
+# Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it
+BASE_URL=http://localhost:4810
+# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
+TRUST_PROXY=2
+
+# SQLite database (production unit: /var/lib/openvibe-blog/blog.db)
+BLOG_DB_PATH=./data/blog.db
+
+# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# OAuth client `blog` (redirect https://openvibe.blog/auth/callback). The same client is Blog's
+# service principal svc:blog for client-credentials grants (see README, "Grants").
+OV_OAUTH_CLIENT_ID=blog
+OV_OAUTH_CLIENT_SECRET=
+# Production: https://openvibe.blog/auth/callback (default: BASE_URL + /auth/callback)
+#OV_OAUTH_REDIRECT_URI=
+# Cookies: Secure by default in production; set false for plain-http local development
+#COOKIE_SECURE=false
+# Signs the editor/comment form tokens (CSRF). Unset: random per process.
+#BLOG_FORM_SECRET=
+
+# The official blog (served at /): owners are Network subjects (usr_…), comma-separated
+BLOG_OFFICIAL_OWNERS=
+#BLOG_OFFICIAL_TITLE=The OpenVibe blog
+#BLOG_OFFICIAL_DESCRIPTION=News and release notes from the OpenVibe network.
+
+# OpenVibe.Media — attachments are Media object ids; bytes are served by Media
+OV_MEDIA_URL=https://openvibe.media
+OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
+# Media tenant/namespace for Blog's objects (media.object.read is granted for it)
+BLOG_MEDIA_APP=blog
+#BLOG_MEDIA_VERIFY_INTERVAL_MS=600000
+
+# OpenVibe.Community — comment threads (referenced, never copied)
+OV_COMMUNITY_URL=https://openvibe.community
+OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
+
+# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set
+#EVENTS_URL=http://127.0.0.1:4300
+#EVENTS_RELAY_INTERVAL_MS=2000
+
+# Scheduled publication worker (in-process); off: BLOG_WORKER=off
+#BLOG_WORKER=on
+#BLOG_SCHEDULE_INTERVAL_MS=15000
+#BLOG_WORKER_ID=
+
+# Members-only (VIP) posts: 'none' = no entitlement service yet → fails closed
+# (only the blog's members and staff can read them)
+#BLOG_ENTITLEMENTS_PROVIDER=none
+
+# Browser origins that may call /api/v1 with a Bearer Network JWT
+#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.media
+
+# release.json (openvibe-shared/release): the deployed commit when git is not available
+#RELEASE_COMMIT=
+#MIN_CLIENT_RELEASE=
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # production runs Node 22
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ # Blog's service manifest and capabilities are proposals (docs/) until the lead releases them
+ # in openvibe-contracts; until then the check reports "no service manifest blog".
+ - run: npx openvibe-contracts-check --service blog --src server
+ continue-on-error: true
@@ -2,77 +2,318 @@
@@ -2,77 +2,318 @@
> The official OpenVibe blog and a blog for every member: drafts, revisions, series, scheduling, feeds.
> The official OpenVibe blog and a blog for every member: drafts, revisions, series, scheduling, feeds.
-**Status:** placeholder — planning only, no runnable code yet.
-**Domain:** `openvibe.blog`
-**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.6.
+**Status:** alpha (roadmap Wave 16, Blog half). The service runs and its tests pass. It is **not
+deployed**, `openvibe.blog` still shows its placeholder from OpenVibe.Sites, and its capabilities and
+service manifest are proposals that the next openvibe-contracts release has to include.
+**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).
**License:** AGPL-3.0 (same as every OpenVibe service).
## Purpose
## Purpose
-Official and user blogs with canonical URLs and feeds, reusing Media, Community comments, AI drafting workflows, themes and optional VIP gating.
+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
+- an optional VIP gate: members-only posts with an entitlement check that fails closed
## Owns
## Owns
-- `blogs`, `blog_memberships`, `blog_posts`, `blog_post_revisions`, `blog_series`, `blog_taxonomy`, `blog_post_terms`, `blog_schedules`, `blog_redirects`, `blog_feed_settings`
+The ten charter tables live in Blog's own SQLite (`BLOG_DB_PATH`). Where a publishing package
+applies, it creates the table with the `blog` prefix:
+
+| Charter table | What it is |
+|---|---|
+| `blogs` | Owned by Blog. The official blog (`openvibe`, served at `/`) and the member blogs. One per member, owned by their Network subject (`usr_…`). |
+| `blog_memberships` | Owned by Blog. Roles are owner, editor and author. |
+| `blog_posts` | Owned by Blog. Publication state: slug, state, visibility, entitlement key, published revision, series, flags. |
+| `blog_post_revisions` | `openvibe-publishing/revisions` (prefix `blog_post`). Immutable: triggers abort UPDATE and DELETE. Drafts go in `blog_post_drafts`. |
+| `blog_series` | Owned by Blog. Ordered series for each blog. |
+| `blog_taxonomy` | A view over `blog_terms` (`openvibe-publishing/taxonomy`, prefix `blog`). Tags are network-wide. Categories are per blog (vocabulary `category_<blog>`) and can be nested. |
+| `blog_post_terms` | A view over `blog_term_links`. |
+| `blog_schedules` | A view over `blog_schedule_jobs` (`openvibe-publishing/schedule`, prefix `blog`). |
+| `blog_redirects` | `openvibe-publishing/seo` history-aware redirects (prefix `blog`). |
+| `blog_feed_settings` | Owned by Blog. RSS, Atom and JSON Feed switches, item count, and full text or summaries. |
+
+The three views keep the charter's names readable without keeping a second copy of the package state.
+
+Other tables in the same database:
+
+- Package companions: `blog_post_citations`, `blog_post_attachments`, `blog_post_reviews`,
+ `blog_post_discussion_refs` and `blog_index_revisions`.
+- `event_outbox`, the SDK transactional outbox.
+- `subject_projections`, a display cache of Network names. It isn't authority.
## Does not own
## Does not own
-- comments (Community)
-- media bytes (Media)
-- entitlements (Billing/VIP)
+- **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 (VIP) posts:**
+ - Visibility `members` with an `entitlement_key` in the ACL.
+ - The entitlement seam (`server/domain/access.js`) admits nobody while
+ `BLOG_ENTITLEMENTS_PROVIDER=none`, the default because no entitlement service exists yet. Only
+ the blog's members and staff can read these posts.
-## Planned surfaces
+### Routes (server-rendered, useful without JavaScript)
-- blog creation and configuration per eligible account, draft/preview/revision/publish/unpublish/schedule, tags/categories/series, RSS/Atom and sitemaps, theme inheritance, official release notes
+| 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 |
-## Data (authority tables / families)
+### Discoverability (roadmap §32)
-- see above
+- 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.
-## Capabilities and events
+### Caching (no leaks through shared caches)
-- `blog.create`, `blog.post.create|update|publish|schedule|unpublish`, `blog.feed.read`, `blog.theme.set`
+- 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: ``blog.post.created|published|updated|unpublished|deleted``, ``blog.schedule.failed``
+### 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.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. |
+
+### Capabilities (proposed: `docs/capabilities-proposal/`)
+
+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. Until the proposals are released, grants
+for these ids are decided locally with the contracts library's matching rule
+(`server/auth/capabilities.js`). Nothing changes when they are released. The service manifest
+proposal is `docs/service-manifest-proposal.json`.
## Depends on
## Depends on
-- shared publishing packages
-- OpenVibe.Network
-- OpenVibe.Media
-- OpenVibe.Community
-- themes
-- Search
-- optional OpenVibe.VIP
+- **Packages** (all pinned by release tarball): `openvibe-publishing` v0.2.0 (revisions, schedule,
+ taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr), `openvibe-contracts`
+ v0.13.0, `openvibe-shared` v1.3.0 (chrome, app icon, footer, legal, release, metrics, ready,
+ theme presets), `openvibe-sdk` v0.2.2 (events outbox, service tokens).
+- **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`.
+- **Optional:** an entitlement service (OpenVibe.VIP or Billing's `billing.entitlement.check`) for
+ members-only posts. Until one exists, the gate fails closed.
+
+### Grants the Network must hold for client `blog`
-## Acceptance (must be true before "done")
+Each grant is `[client, capability, audience]`:
-- scheduled publication is idempotent across worker restarts
-- feeds, canonical HTML, sitemap and search converge on publication state
-- VIP-only/private posts never leak through cache, feed or search
-- a deleted media object yields an explicit broken-asset state
+- `[blog, identity.subject.resolve, openvibe.network]`
+- `[blog, events.event.publish, openvibe.events]`
+- `[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.
+- 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.
-## Bootstrap / extraction source
+## Acceptance (automated: `npm test`)
-No current implementation; bootstraps from the shared publishing runtime (Wave 14).
+| 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` |
+| 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` |
## Launch rule
## Launch rule
-This repository does not make the product real, and the domain keeps its placeholder page on
-[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
-following exist here (plan §12.12):
-
-1. an owning runtime with health/readiness endpoints and observability;
-2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
-3. server-rendered or static public routes that are useful without JavaScript;
-4. real persistence and end-to-end workflows;
-5. capability and event registration against `OpenVibe.Contracts`;
-6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
-7. acceptance tests proving the advertised functionality.
-
-The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
-registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
-implemented service.
+This repository alone doesn't make the product live. `openvibe.blog` keeps its placeholder on
+[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of plan §12.12
+exists. 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.
+5. **Capability and event registration against OpenVibe.Contracts:** proposals are in `docs/`,
+ waiting on the release.
+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 does all of these in one release:**
+
+- 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, and this README doesn't call the service
+live.
+
+## 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
+
+- **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 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 and Media 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.
+- **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`.
+
+## 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)
+
+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:** release the capability and manifest proposals in openvibe-contracts. Then CI's
+ contracts check can drop `continue-on-error`.
+8. **Launch:** in the same release, remove `openvibe.blog` from OpenVibe.Sites and flip the Network
+ hub entry (see the launch rule above).
---
---
@@ -1,7 +1,19 @@
@@ -1,7 +1,19 @@
{
{
"repository": "OpenVibers/OpenVibe.Blog",
"repository": "OpenVibers/OpenVibe.Blog",
- "stage": "placeholder",
- "code": false,
+ "stage": "alpha",
+ "code": true,
+ "wave": "W16",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-21"
+ "runtime": "node 22, express 4, better-sqlite3 11",
+ "port": 4810,
+ "domain": "openvibe.blog",
+ "packages": ["openvibe-publishing v0.2.0", "openvibe-contracts v0.13.0", "openvibe-shared v1.3.0", "openvibe-sdk v0.2.2"],
+ "capabilities": ["blog.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"],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "entitlements": "none (members-only posts fail closed until an entitlement service exists)",
+ "seedPost": "draft (verbatim quotes; not reviewed by a person)",
+ "deployed": false,
+ "placeholderInSites": true,
+ "updated": "2026-09-22"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.blog — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.blog.conf — keep them in sync and reload nginx
+## after changing it. The Node app (port 4810, systemd unit openvibe-blog) renders every page;
+## nginx terminates TLS, redirects www → apex, rate-limits auth, editor and API, and compresses text.
+##
+## No proxy_cache here on purpose: the app decides cacheability per response (Cache-Control
+## public only for public posts and lists shown to anonymous visitors; private, no-store for
+## everything else, Vary: Cookie, Authorization). A cache added in front must honour those headers.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=ovblog_api:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=ovblog_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=ovblog_write:10m rate=5r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.blog www.openvibe.blog;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.blog$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.blog;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.blog/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.blog/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.blog$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.blog;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.blog/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.blog/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.blog.access.log;
+ error_log /var/log/nginx/openvibe.blog.error.log;
+
+ # Posts are at most 200 000 characters; forms are URL-encoded (no uploads: media lives in Media).
+ client_max_body_size 1m;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/feed+json image/svg+xml text/plain application/xml text/xml application/rss+xml application/atom+xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── Network SSO session endpoints (strict) ──────────────
+ location /auth/ {
+ limit_req zone=ovblog_auth burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4810;
+ }
+
+ # ── The editor (forms) ──────────────────────────────────
+ location /write {
+ limit_req zone=ovblog_write burst=20 nodelay;
+ proxy_pass http://127.0.0.1:4810;
+ }
+
+ # ── API ─────────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=ovblog_api burst=30 nodelay;
+ proxy_pass http://127.0.0.1:4810;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4810/metrics) ──
+ location = /metrics { return 404; }
+
+ # ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──
+ location / {
+ proxy_pass http://127.0.0.1:4810;
+ proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+[Unit]
+Description=OpenVibe.Blog — the official OpenVibe blog and a blog for every member
+# Network (sign-in, JWKS, service tokens) and Events (the outbox relay) should be up first, but Blog
+# degrades gracefully without them: pages and feeds serve, sign-in and events wait (see /api/ready).
+After=network.target
+Wants=openvibe-network.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.blog
+EnvironmentFile=/etc/openvibe/blog.env
+Environment=NODE_ENV=production
+Environment=PORT=4810
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.blog/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+TimeoutStopSec=10
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# Blog's own database lives in /var/lib/openvibe-blog; the code tree stays read-only.
+StateDirectory=openvibe-blog
+Environment=BLOG_DB_PATH=/var/lib/openvibe-blog/blog.db
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "blog.blog.configure",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Change a blog's title, description, language and feed settings (RSS, Atom, JSON Feed on/off, item count, full text or summaries). Only an owner of the blog (or staff on the official blog) acting through X-OV-Subject.",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-write",
+ "events": [],
+ "implementedBy": [
+ "PATCH /api/v1/blogs/:handle"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "blog.blog.create",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Get-or-create the blog of the acting member (X-OV-Subject, a usr_ subject): one member blog per subject, created on first use, owned by that subject, handle = the Network username unless another free handle is given. The charter's blog.create as a three-segment id. Browsers use their Network user JWT instead.",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-write",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/blogs"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "blog.feed.read",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read a blog's JSON Feed through the API (public, published, listable posts only; the same items as /@handle/feed.json). Browsers and crawlers read the public RSS, Atom and JSON Feed URLs without any capability.",
+ "permissions": [
+ "blog:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "blog-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/blogs/:handle/feed"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "blog.member.manage",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "List, add, change and remove a blog's members (owner, editor, author) by Network subject. A member blog keeps at least one owner. Owners only.",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-write",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/blogs/:handle/members",
+ "PUT /api/v1/blogs/:handle/members/:subject",
+ "DELETE /api/v1/blogs/:handle/members/:subject"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "blog.post.create",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create a draft post (revision 1) on a blog the acting member belongs to. With X-OV-Origin: ai the draft is AI-generated output (OpenVibe.AI blog.draft_post; authorship.workflow { id, runId } required): never attributed to the person, noindex, and not publishable until a person reviews it.",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-write",
+ "events": [
+ "blog.post.created"
+ ],
+ "implementedBy": [
+ "POST /api/v1/blogs/:handle/posts"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "id": "blog.post.delete",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Delete a post: readers get 410, Search a tombstone, the Community thread is hidden; immutable revisions are kept. Editors, owners, the author, and staff (moderation).",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-write",
+ "events": [
+ "blog.post.deleted",
+ "blog.index_document.upserted",
+ "blog.index_document.deleted"
+ ],
+ "implementedBy": [
+ "DELETE /api/v1/posts/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "id": "blog.post.publish",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Make a revision the published one (idempotent). AI-generated revisions are refused (409 post.review_required) until a person has recorded an approving review.",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-write",
+ "events": [
+ "blog.post.published",
+ "blog.post.updated",
+ "blog.index_document.upserted",
+ "blog.index_document.deleted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/posts/:id/publish"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "id": "blog.post.read",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read posts that are not public (drafts, scheduled, members-only, private), their revisions and diffs, for the member in X-OV-Subject. Published public posts need no capability.",
+ "permissions": [
+ "blog:read"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/posts/:id",
+ "GET /api/v1/posts/:id/revisions",
+ "GET /api/v1/posts/:id/revisions/:n",
+ "GET /api/v1/posts/:id/diff",
+ "GET /api/v1/blogs/:handle/posts?all=1"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+{
+ "id": "blog.post.schedule",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Schedule a publish of a revision (or an unpublish) at a future time, or cancel it. Idempotent per (post, action, time, revision); applied by Blog's worker with leases, so a restart never publishes twice; a job that cannot succeed emits blog.schedule.failed.",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-write",
+ "events": [
+ "blog.post.published",
+ "blog.schedule.failed",
+ "blog.index_document.upserted",
+ "blog.index_document.deleted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/posts/:id/schedule",
+ "DELETE /api/v1/posts/:id/schedule"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "id": "blog.post.unpublish",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Take a published post down (readers get 404, Search a tombstone, feeds and sitemaps drop it). Editors, owners, the author, and staff (moderation).",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-write",
+ "events": [
+ "blog.post.unpublished",
+ "blog.index_document.upserted",
+ "blog.index_document.deleted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/posts/:id/unpublish"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+{
+ "id": "blog.post.update",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Edit a post: content becomes a new immutable revision (expected_revision required; 412 revision.conflict when someone saved first), slug changes leave 301 redirects, visibility, taxonomy, series, media attachments (OpenVibe.Media object ids) and revert as a new revision. Authors edit their own posts; editors and owners every post of the blog.",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-write",
+ "events": [
+ "blog.post.updated",
+ "blog.index_document.upserted",
+ "blog.index_document.deleted"
+ ],
+ "implementedBy": [
+ "PATCH /api/v1/posts/:id",
+ "POST /api/v1/posts/:id/revert",
+ "POST /api/v1/posts/:id/attachments",
+ "DELETE /api/v1/posts/:id/attachments/:aid"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "blog.theme.set",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Choose a blog's theme from the small set of openvibe-shared token presets (vibe, paper, slate, sand, nord, high-contrast). There is no site-local theme engine and no custom CSS. Owners only.",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "blog-write",
+ "events": [],
+ "implementedBy": [
+ "PUT /api/v1/blogs/:handle/theme"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+{
+ "version": "0.1.0",
+ "contractRanges": {
+ "openvibe-contracts": ">=0.13.0 <1.0.0"
+ },
+ "id": "blog",
+ "name": "OpenVibe.Blog",
+ "status": "alpha",
+ "repository": "OpenVibers/OpenVibe.Blog",
+ "domains": [
+ "openvibe.blog"
+ ],
+ "publicOrigin": "https://openvibe.blog",
+ "health": "/api/health",
+ "ready": "/api/ready",
+ "capabilities": [
+ "blog.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"
+ ],
+ "eventsProduced": [
+ "blog.post.created",
+ "blog.post.published",
+ "blog.post.updated",
+ "blog.post.unpublished",
+ "blog.post.deleted",
+ "blog.schedule.failed",
+ "blog.index_document.upserted",
+ "blog.index_document.deleted"
+ ],
+ "eventsConsumed": [],
+ "namespacesOwned": [
+ "blog.*"
+ ],
+ "notes": "Port 4810. Owns blogs, blog_memberships, blog_posts, blog_post_revisions, blog_series, blog_taxonomy, blog_post_terms, blog_schedules, blog_redirects, blog_feed_settings in its own SQLite (openvibe-publishing packages, prefix blog). Consumes OpenVibe.Network (SSO, identity.subject.resolve), OpenVibe.Community (community.comment.write, community.comment.moderate), OpenVibe.Media (media.object.read, namespace blog) and publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads blog.index_document.* from Events."
+}