openvibers/openvibe.news Commits
Pin openvibe-contracts v0.97.0 and drop the proposed-capability shim (#11)
Changes
@@@ -2,6 +2,7 @@## Unreleased++- openvibe-contracts moves from v0.79.0 to v0.97.0 (pin, lockfile and `node_modules`); nothing in the range breaks News, and the contracts' own service check is green. All eleven `news.*` ids are now defined by the release, so `server/auth/capabilities.js` drops its local fallback for proposed ids and sends every check through the library's grant rule; `test/capabilities.test.js` pins that every guarded id is defined by the installed contracts and the news manifest, and that exact, prefix, denied and unknown ids answer as `capabilities.check()` does. `test/contracts.test.js` checks the docs/ proposals against `CAPABILITIES` instead of the removed set. README and STATUS.json name v0.97.0.- The home page opens with what OpenVibe.News is for (plan T11, `openvibe-shared/showcase`, as Blog's home does): a hero (News you can check; Read the stories, Browse topics) and the four things a story really has today (cited paragraphs, revisions on the record, source checks, Community discussion). Page 1 only: later pages and topic pages are unchanged. The hero is the page's one h1, so the list header there is "Latest stories"; `showcase.css` is linked only on page 1. Home budgets raised as a decision (HTML 27 KB / 7.5 KB brotli, CSS 17.5 KB / 4.5 KB brotli).- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. The shell adds `web-runtime.js`, so the home page's JS budget is raised to 5 files, 245 KB, 59 KB brotli.- The `openvibe-publishing` v1.2.0 lock entry carries its sha512 integrity again (the bump had dropped it), and a test fails if any codeload pin loses its hash.@@@ -6,7 +6,7 @@**Status:** alpha (roadmap Wave 17, News half). The service runs and its tests pass. It is**deployed internally, not launched**: it runs on the production host on 127.0.0.1:4820 only(release `1802e7d`, `/api/ready` 200), while `openvibe.news` still shows its placeholder from--OpenVibe.Sites. Its capabilities and service manifest are registered in openvibe-contracts v0.21.0.++OpenVibe.Sites. Its capabilities and service manifest are registered in openvibe-contracts v0.97.0.The production database holds 12 topics and no stories: it has not ingested anything from therunning OpenVibe.Sources service, because every Sources seed is disabled.**Domain:** `openvibe.news` · **Port:** 4820 · **Service id:** `news`@@@ -203,7 +203,7 @@ five minutes and are never built for a viewer.| `news.story.retracted` | public for a listable story; the retraction note || `news.index_document.upserted` / `.deleted` | `search.index-document@1` documents (provenance: the Sources items and the AI run) and tombstones, with a monotonic index revision |--## Capabilities (registered in openvibe-contracts v0.21.0)++## Capabilities (registered in openvibe-contracts v0.97.0)Service tokens use audience `openvibe.news`, one capability per route; the editor the service actsfor goes in `X-OV-Subject` and must be an editor:@@@ -215,9 +215,10 @@ for goes in `X-OV-Subject` and must be an editor:Browser and app user JWTs are judged as editors (`NEWS_EDITORS` subjects and Network admins).Grants for these ids are decided with the contracts library's matching rule--(`server/auth/capabilities.js`). The ids and the service manifest are released in--openvibe-contracts v0.21.0; `docs/capabilities-proposal/` and `docs/service-manifest-proposal.json`--are the proposals they were released from.++(`server/auth/capabilities.js`, which delegates straight to `capabilities.check` now that the ids++are released). The ids and the service manifest are released in openvibe-contracts (pinned at++v0.97.0); `docs/capabilities-proposal/` and `docs/service-manifest-proposal.json` stay as the++proposals they were released from.Called elsewhere, as the service principal `news`: `sources.item.read` and optionally`sources.source.read` (Sources), `events.event.publish` and `events.subscription.manage` (Events),@@@ -232,7 +233,7 @@ under [Grants the Network must hold](#grants-the-network-must-hold).- **Packages** (pinned by release tarball): `openvibe-publishing` v1.3.0 (async PostgreSQL stores: revisions,citations, authorship, seo (ai-summary and llms-full), index-hooks, ssr, taxonomy slugify, and the shared ingest/publicationchassis: the Sources client, the change-cursor pull, the signed events consumer and the-- gate → document → outbox glue), `openvibe-contracts` v0.79.0,++ gate → document → outbox glue), `openvibe-contracts` v0.97.0,`openvibe-shared` v2.6.0 (Frame, app icon, footer, legal, release, metrics, ready, seo),`openvibe-sdk` v0.25.0 (db with after-commit hooks, PostgreSQL events outbox and inbox, webhook signatures v2,service tokens, Network sign-in (`openvibe-sdk/sso`) and the service outbox (`openvibe-sdk/events`), per-actor limits, testing).@@@ -302,7 +303,7 @@ exists:4. **Persistence and end-to-end workflows:** done in tests and deployed on the host (loopbackonly); not yet against live Sources items, since no news source is enabled.5. **Capability and event registration against OpenVibe.Contracts:** done (openvibe-contracts-- v0.21.0).++ v0.97.0).6. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:** done. Nothingto migrate; the seed is topics only; the threat review is below.7. **Acceptance tests:** done.@@@ -407,7 +408,7 @@ First install (done once; kept for a rebuild):`http://127.0.0.1:4820/internal/events`). The cursor pull starts on its own.7. **Sources:** News shows nothing until a news source is enabled in OpenVibe.Sources (termsverified by a person) and an editor publishes a story.--8. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.21.0,++8. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.97.0,and CI's contracts check runs against them.9. **Launch:** in the same release, remove `openvibe.news` from OpenVibe.Sites and flip the Networkhub entry (see the launch rule above).@@@ -7,10 +7,10 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4820,"domain": "openvibe.news",-- "contracts": "openvibe-contracts v0.79.0",++ "contracts": "openvibe-contracts v0.97.0","packages": ["openvibe-publishing v1.3.0",-- "openvibe-contracts v0.79.0",++ "openvibe-contracts v0.97.0","openvibe-shared v2.9.0","openvibe-sdk v0.25.0"],@@@ -35,7 +35,7 @@"deployed": true,"deployment": "Deployed with `sudo ovhost deploy news`: unit openvibe-news, 127.0.0.1:4820 loopback only; on 2026-09-23 /api/ready answered 200 and the production database held 12 topics and 0 stories. Deployed internally, not launched: openvibe.news still serves the OpenVibe.Sites placeholder until a person verifies a news source's terms, enables it in Sources, and an editor publishes a reviewed story","placeholderInSites": true,-- "updated": "2026-09-29",++ "updated": "2026-10-04","features": ["Sources ingestion by Events subscription and cursor pull, with dedupe","failed ingestion recorded, never fabricated",@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.97.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",@@@ -873,9 +873,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.79.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",-- "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",++ "version": "0.97.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.97.0",++ "integrity": "sha512-HPGBJYx6Dh2kaTg5B41WGhpw38zUdoU1VnWtkcsFkaU4E521uQOJxf6sIaLdHMsahqov7b9k2HKEFCnD9065MQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.97.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",@@@ -1,14 +1,10 @@'use strict';/**-- * Capability checks for service tokens (audience openvibe.news), including the ids News introduces-- * before the contracts library knows them.-- *-- * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its-- * manifests yet. News' ids are proposed in docs/capabilities-proposal/ for the next contracts-- * release; until then a grant is decided locally with the library's own matching rule (the exact-- * id, or a `prefix.*` grant covering it). An id the library does know always goes through the-- * library, so the day the release lands nothing changes here.++ * Capability checks for service tokens (audience openvibe.news). The news.* ids this service++ * introduces are defined by the installed openvibe-contracts, so a grant is decided by the library's++ * own matching rule (the exact id, or a `prefix.*` grant covering it). CAPABILITIES keeps the ids in++ * one place for the guards, the proposal documents and the tests.** Browsers (Network user JWTs) are never judged by capabilities: they are judged as editors* (domain/access.js). A service token is judged by its capability AND, except for AI draft@@@ -29,16 +25,10 @@ const CAPABILITIES = Object.freeze({PERSPECTIVE_UPDATE: 'news.perspective.update',TOPIC_MANAGE: 'news.topic.manage',});--const PROPOSED = new Set(Object.values(CAPABILITIES));/** → { allowed, code, reason } like capabilities.check(). */function checkCapability(claims, capabilityId) {-- if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {-- return capabilities.grants(claims && claims.cap, capabilityId)-- ? { allowed: true, code: null, reason: null }-- : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };-- }return capabilities.check(claims, capabilityId);}--module.exports = { CAPABILITIES, PROPOSED, checkCapability };++module.exports = { CAPABILITIES, checkCapability };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++/**++ * The news.* capabilities are released by openvibe-contracts, so the service guards delegate to the++ * library's grant rule rather than deciding a proposed id locally (the fallback that existed while++ * the ids were only proposals). These pin that: every guarded id is defined by the installed++ * contracts and by the news service manifest, and checkCapability agrees with capabilities.check for++ * an exact grant, a prefix grant, no grant, and an id the contracts do not define.++ */++const assert = require('assert');++const { capabilities, services } = require('openvibe-contracts');++const { checkCapability, CAPABILITIES } = require('../server/auth/capabilities');++const { check, done } = require('./helpers/boot');++++(async () => {++ await check('every guarded capability is defined by the installed contracts and the news manifest', () => {++ const manifest = services.get('news');++ assert.ok(manifest, 'openvibe-contracts defines the news service manifest');++ assert.deepStrictEqual([...manifest.capabilities].sort(), Object.values(CAPABILITIES).sort());++ for (const id of Object.values(CAPABILITIES)) {++ const cap = capabilities.get(id);++ assert.ok(cap, `${id} is defined by openvibe-contracts`);++ assert.strictEqual(cap.owner, 'news', `${id} is owned by news`);++ }++ });++++ await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', () => {++ // An exact grant and a `news.*` prefix grant the library's matching rule accepts.++ assert.deepStrictEqual(checkCapability({ cap: ['news.story.create'], sub: 'svc:sources' }, 'news.story.create'), { allowed: true, code: null, reason: null });++ assert.deepStrictEqual(checkCapability({ cap: ['news.*'] }, 'news.story.create'), { allowed: true, code: null, reason: null });++ // A grant of a sibling capability, or no cap at all, is denied with the library's code.++ assert.strictEqual(checkCapability({ cap: ['news.story.read'] }, 'news.story.create').code, 'capability.denied');++ assert.strictEqual(checkCapability(null, 'news.story.create').code, 'capability.denied');++ // An id the contracts do not define still answers capability.unknown — the guard delegates,++ // it never decides an unknown id on its own.++ assert.strictEqual(checkCapability({ cap: ['news.*'] }, 'news.not.a.capability').code, 'capability.unknown');++ });++++ done();++})();@@@ -1,18 +1,19 @@'use strict';/**-- * The proposals the lead releases in the next openvibe-contracts version are valid against the-- * released schemas, match what the code enforces, and do not collide with released ids; every-- * event type the code emits is declared.++ * The capability and service-manifest proposals in docs/ are valid against the installed++ * openvibe-contracts (which now release them), match what the code enforces, and do not collide with++ * ids owned by other services; every event type the code emits is declared.*/const assert = require('assert');const fs = require('fs');const path = require('path');const contracts = require('openvibe-contracts');const { check, done } = require('./helpers/boot');--const { PROPOSED } = require('../server/auth/capabilities');++const { CAPABILITIES } = require('../server/auth/capabilities');const ROOT = path.join(__dirname, '..');const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');++const IDS = new Set(Object.values(CAPABILITIES));(async () => {const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));@@@ -32,14 +33,14 @@ const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');});await check('the charter capabilities are all present as 3-segment ids', async () => {-- for (const id of ['news.story.create', 'news.story.revise', 'news.story.publish', 'news.cluster.read', 'news.source.attach', 'news.timeline.update']) assert.ok(PROPOSED.has(id), id);++ for (const id of ['news.story.create', 'news.story.revise', 'news.story.publish', 'news.cluster.read', 'news.source.attach', 'news.timeline.update']) assert.ok(IDS.has(id), id);});await check('the proposals are exactly the capabilities the code enforces, and every route guard names one', async () => {-- assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());-- assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());++ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...IDS].sort());++ assert.deepStrictEqual([...manifest.capabilities].sort(), [...IDS].sort());const api = fs.readFileSync(path.join(ROOT, 'server', 'http', 'api.js'), 'utf8');-- for (const m of api.matchAll(/(?:guard\(|write\('[a-z]+', '[^']+', )'(news\.[a-z_.]+)'/g)) assert.ok(PROPOSED.has(m[1]), m[1]);++ for (const m of api.matchAll(/(?:guard\(|write\('[a-z]+', '[^']+', )'(news\.[a-z_.]+)'/g)) assert.ok(IDS.has(m[1]), m[1]);});await check('the service manifest proposal is a valid registry.service-manifest@1', async () => {
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
## Unreleased
## Unreleased
+- openvibe-contracts moves from v0.79.0 to v0.97.0 (pin, lockfile and `node_modules`); nothing in the range breaks News, and the contracts' own service check is green. All eleven `news.*` ids are now defined by the release, so `server/auth/capabilities.js` drops its local fallback for proposed ids and sends every check through the library's grant rule; `test/capabilities.test.js` pins that every guarded id is defined by the installed contracts and the news manifest, and that exact, prefix, denied and unknown ids answer as `capabilities.check()` does. `test/contracts.test.js` checks the docs/ proposals against `CAPABILITIES` instead of the removed set. README and STATUS.json name v0.97.0.
- The home page opens with what OpenVibe.News is for (plan T11, `openvibe-shared/showcase`, as Blog's home does): a hero (News you can check; Read the stories, Browse topics) and the four things a story really has today (cited paragraphs, revisions on the record, source checks, Community discussion). Page 1 only: later pages and topic pages are unchanged. The hero is the page's one h1, so the list header there is "Latest stories"; `showcase.css` is linked only on page 1. Home budgets raised as a decision (HTML 27 KB / 7.5 KB brotli, CSS 17.5 KB / 4.5 KB brotli).
- The home page opens with what OpenVibe.News is for (plan T11, `openvibe-shared/showcase`, as Blog's home does): a hero (News you can check; Read the stories, Browse topics) and the four things a story really has today (cited paragraphs, revisions on the record, source checks, Community discussion). Page 1 only: later pages and topic pages are unchanged. The hero is the page's one h1, so the list header there is "Latest stories"; `showcase.css` is linked only on page 1. Home budgets raised as a decision (HTML 27 KB / 7.5 KB brotli, CSS 17.5 KB / 4.5 KB brotli).
- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. The shell adds `web-runtime.js`, so the home page's JS budget is raised to 5 files, 245 KB, 59 KB brotli.
- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. The shell adds `web-runtime.js`, so the home page's JS budget is raised to 5 files, 245 KB, 59 KB brotli.
- The `openvibe-publishing` v1.2.0 lock entry carries its sha512 integrity again (the bump had dropped it), and a test fails if any codeload pin loses its hash.
- The `openvibe-publishing` v1.2.0 lock entry carries its sha512 integrity again (the bump had dropped it), and a test fails if any codeload pin loses its hash.
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
**Status:** alpha (roadmap Wave 17, News half). The service runs and its tests pass. It is
**Status:** alpha (roadmap Wave 17, News half). The service runs and its tests pass. It is
**deployed internally, not launched**: it runs on the production host on 127.0.0.1:4820 only
**deployed internally, not launched**: it runs on the production host on 127.0.0.1:4820 only
(release `1802e7d`, `/api/ready` 200), while `openvibe.news` still shows its placeholder from
(release `1802e7d`, `/api/ready` 200), while `openvibe.news` still shows its placeholder from
-OpenVibe.Sites. Its capabilities and service manifest are registered in openvibe-contracts v0.21.0.
+OpenVibe.Sites. Its capabilities and service manifest are registered in openvibe-contracts v0.97.0.
The production database holds 12 topics and no stories: it has not ingested anything from the
The production database holds 12 topics and no stories: it has not ingested anything from the
running OpenVibe.Sources service, because every Sources seed is disabled.
running OpenVibe.Sources service, because every Sources seed is disabled.
**Domain:** `openvibe.news` · **Port:** 4820 · **Service id:** `news`
**Domain:** `openvibe.news` · **Port:** 4820 · **Service id:** `news`
@@ -203,7 +203,7 @@ five minutes and are never built for a viewer.
@@ -203,7 +203,7 @@ five minutes and are never built for a viewer.
| `news.story.retracted` | public for a listable story; the retraction note |
| `news.story.retracted` | public for a listable story; the retraction note |
| `news.index_document.upserted` / `.deleted` | `search.index-document@1` documents (provenance: the Sources items and the AI run) and tombstones, with a monotonic index revision |
| `news.index_document.upserted` / `.deleted` | `search.index-document@1` documents (provenance: the Sources items and the AI run) and tombstones, with a monotonic index revision |
-## Capabilities (registered in openvibe-contracts v0.21.0)
+## Capabilities (registered in openvibe-contracts v0.97.0)
Service tokens use audience `openvibe.news`, one capability per route; the editor the service acts
Service tokens use audience `openvibe.news`, one capability per route; the editor the service acts
for goes in `X-OV-Subject` and must be an editor:
for goes in `X-OV-Subject` and must be an editor:
@@ -215,9 +215,10 @@ for goes in `X-OV-Subject` and must be an editor:
@@ -215,9 +215,10 @@ for goes in `X-OV-Subject` and must be an editor:
Browser and app user JWTs are judged as editors (`NEWS_EDITORS` subjects and Network admins).
Browser and app user JWTs are judged as editors (`NEWS_EDITORS` subjects and Network admins).
Grants for these ids are decided with the contracts library's matching rule
Grants for these ids are decided with the contracts library's matching rule
-(`server/auth/capabilities.js`). The ids and the service manifest are released in
-openvibe-contracts v0.21.0; `docs/capabilities-proposal/` and `docs/service-manifest-proposal.json`
-are the proposals they were released from.
+(`server/auth/capabilities.js`, which delegates straight to `capabilities.check` now that the ids
+are released). The ids and the service manifest are released in openvibe-contracts (pinned at
+v0.97.0); `docs/capabilities-proposal/` and `docs/service-manifest-proposal.json` stay as the
+proposals they were released from.
Called elsewhere, as the service principal `news`: `sources.item.read` and optionally
Called elsewhere, as the service principal `news`: `sources.item.read` and optionally
`sources.source.read` (Sources), `events.event.publish` and `events.subscription.manage` (Events),
`sources.source.read` (Sources), `events.event.publish` and `events.subscription.manage` (Events),
@@ -232,7 +233,7 @@ under [Grants the Network must hold](#grants-the-network-must-hold).
@@ -232,7 +233,7 @@ under [Grants the Network must hold](#grants-the-network-must-hold).
- **Packages** (pinned by release tarball): `openvibe-publishing` v1.3.0 (async PostgreSQL stores: revisions,
- **Packages** (pinned by release tarball): `openvibe-publishing` v1.3.0 (async PostgreSQL stores: revisions,
citations, authorship, seo (ai-summary and llms-full), index-hooks, ssr, taxonomy slugify, and the shared ingest/publication
citations, authorship, seo (ai-summary and llms-full), index-hooks, ssr, taxonomy slugify, and the shared ingest/publication
chassis: the Sources client, the change-cursor pull, the signed events consumer and the
chassis: the Sources client, the change-cursor pull, the signed events consumer and the
- gate → document → outbox glue), `openvibe-contracts` v0.79.0,
+ gate → document → outbox glue), `openvibe-contracts` v0.97.0,
`openvibe-shared` v2.6.0 (Frame, app icon, footer, legal, release, metrics, ready, seo),
`openvibe-shared` v2.6.0 (Frame, app icon, footer, legal, release, metrics, ready, seo),
`openvibe-sdk` v0.25.0 (db with after-commit hooks, PostgreSQL events outbox and inbox, webhook signatures v2,
`openvibe-sdk` v0.25.0 (db with after-commit hooks, PostgreSQL events outbox and inbox, webhook signatures v2,
service tokens, Network sign-in (`openvibe-sdk/sso`) and the service outbox (`openvibe-sdk/events`), per-actor limits, testing).
service tokens, Network sign-in (`openvibe-sdk/sso`) and the service outbox (`openvibe-sdk/events`), per-actor limits, testing).
@@ -302,7 +303,7 @@ exists:
@@ -302,7 +303,7 @@ exists:
4. **Persistence and end-to-end workflows:** done in tests and deployed on the host (loopback
4. **Persistence and end-to-end workflows:** done in tests and deployed on the host (loopback
only); not yet against live Sources items, since no news source is enabled.
only); not yet against live Sources items, since no news source is enabled.
5. **Capability and event registration against OpenVibe.Contracts:** done (openvibe-contracts
5. **Capability and event registration against OpenVibe.Contracts:** done (openvibe-contracts
- v0.21.0).
+ v0.97.0).
6. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:** done. Nothing
6. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:** done. Nothing
to migrate; the seed is topics only; the threat review is below.
to migrate; the seed is topics only; the threat review is below.
7. **Acceptance tests:** done.
7. **Acceptance tests:** done.
@@ -407,7 +408,7 @@ First install (done once; kept for a rebuild):
@@ -407,7 +408,7 @@ First install (done once; kept for a rebuild):
`http://127.0.0.1:4820/internal/events`). The cursor pull starts on its own.
`http://127.0.0.1:4820/internal/events`). The cursor pull starts on its own.
7. **Sources:** News shows nothing until a news source is enabled in OpenVibe.Sources (terms
7. **Sources:** News shows nothing until a news source is enabled in OpenVibe.Sources (terms
verified by a person) and an editor publishes a story.
verified by a person) and an editor publishes a story.
-8. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.21.0,
+8. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.97.0,
and CI's contracts check runs against them.
and CI's contracts check runs against them.
9. **Launch:** in the same release, remove `openvibe.news` from OpenVibe.Sites and flip the Network
9. **Launch:** in the same release, remove `openvibe.news` from OpenVibe.Sites and flip the Network
hub entry (see the launch rule above).
hub entry (see the launch rule above).
@@ -7,10 +7,10 @@
@@ -7,10 +7,10 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4820,
"port": 4820,
"domain": "openvibe.news",
"domain": "openvibe.news",
- "contracts": "openvibe-contracts v0.79.0",
+ "contracts": "openvibe-contracts v0.97.0",
"packages": [
"packages": [
"openvibe-publishing v1.3.0",
"openvibe-publishing v1.3.0",
- "openvibe-contracts v0.79.0",
+ "openvibe-contracts v0.97.0",
"openvibe-shared v2.9.0",
"openvibe-shared v2.9.0",
"openvibe-sdk v0.25.0"
"openvibe-sdk v0.25.0"
],
],
@@ -35,7 +35,7 @@
@@ -35,7 +35,7 @@
"deployed": true,
"deployed": true,
"deployment": "Deployed with `sudo ovhost deploy news`: unit openvibe-news, 127.0.0.1:4820 loopback only; on 2026-09-23 /api/ready answered 200 and the production database held 12 topics and 0 stories. Deployed internally, not launched: openvibe.news still serves the OpenVibe.Sites placeholder until a person verifies a news source's terms, enables it in Sources, and an editor publishes a reviewed story",
"deployment": "Deployed with `sudo ovhost deploy news`: unit openvibe-news, 127.0.0.1:4820 loopback only; on 2026-09-23 /api/ready answered 200 and the production database held 12 topics and 0 stories. Deployed internally, not launched: openvibe.news still serves the OpenVibe.Sites placeholder until a person verifies a news source's terms, enables it in Sources, and an editor publishes a reviewed story",
"placeholderInSites": true,
"placeholderInSites": true,
- "updated": "2026-09-29",
+ "updated": "2026-10-04",
"features": [
"features": [
"Sources ingestion by Events subscription and cursor pull, with dedupe",
"Sources ingestion by Events subscription and cursor pull, with dedupe",
"failed ingestion recorded, never fabricated",
"failed ingestion recorded, never fabricated",
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.97.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
@@ -873,9 +873,9 @@
@@ -873,9 +873,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.79.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
- "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",
+ "version": "0.97.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.97.0",
+ "integrity": "sha512-HPGBJYx6Dh2kaTg5B41WGhpw38zUdoU1VnWtkcsFkaU4E521uQOJxf6sIaLdHMsahqov7b9k2HKEFCnD9065MQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.97.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
@@ -1,14 +1,10 @@
@@ -1,14 +1,10 @@
'use strict';
'use strict';
/**
/**
- * Capability checks for service tokens (audience openvibe.news), including the ids News introduces
- * before the contracts library knows them.
- *
- * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its
- * manifests yet. News' ids are proposed in docs/capabilities-proposal/ for the next contracts
- * release; until then a grant is decided locally with the library's own matching rule (the exact
- * id, or a `prefix.*` grant covering it). An id the library does know always goes through the
- * library, so the day the release lands nothing changes here.
+ * Capability checks for service tokens (audience openvibe.news). The news.* ids this service
+ * introduces are defined by the installed openvibe-contracts, so a grant is decided by the library's
+ * own matching rule (the exact id, or a `prefix.*` grant covering it). CAPABILITIES keeps the ids in
+ * one place for the guards, the proposal documents and the tests.
*
*
* Browsers (Network user JWTs) are never judged by capabilities: they are judged as editors
* Browsers (Network user JWTs) are never judged by capabilities: they are judged as editors
* (domain/access.js). A service token is judged by its capability AND, except for AI draft
* (domain/access.js). A service token is judged by its capability AND, except for AI draft
@@ -29,16 +25,10 @@ const CAPABILITIES = Object.freeze({
@@ -29,16 +25,10 @@ const CAPABILITIES = Object.freeze({
PERSPECTIVE_UPDATE: 'news.perspective.update',
PERSPECTIVE_UPDATE: 'news.perspective.update',
TOPIC_MANAGE: 'news.topic.manage',
TOPIC_MANAGE: 'news.topic.manage',
});
});
-const PROPOSED = new Set(Object.values(CAPABILITIES));
/** → { allowed, code, reason } like capabilities.check(). */
/** → { allowed, code, reason } like capabilities.check(). */
function checkCapability(claims, capabilityId) {
function checkCapability(claims, capabilityId) {
- if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
- return capabilities.grants(claims && claims.cap, capabilityId)
- ? { allowed: true, code: null, reason: null }
- : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
- }
return capabilities.check(claims, capabilityId);
return capabilities.check(claims, capabilityId);
}
}
-module.exports = { CAPABILITIES, PROPOSED, checkCapability };
+module.exports = { CAPABILITIES, checkCapability };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+/**
+ * The news.* capabilities are released by openvibe-contracts, so the service guards delegate to the
+ * library's grant rule rather than deciding a proposed id locally (the fallback that existed while
+ * the ids were only proposals). These pin that: every guarded id is defined by the installed
+ * contracts and by the news service manifest, and checkCapability agrees with capabilities.check for
+ * an exact grant, a prefix grant, no grant, and an id the contracts do not define.
+ */
+const assert = require('assert');
+const { capabilities, services } = require('openvibe-contracts');
+const { checkCapability, CAPABILITIES } = require('../server/auth/capabilities');
+const { check, done } = require('./helpers/boot');
+
+(async () => {
+ await check('every guarded capability is defined by the installed contracts and the news manifest', () => {
+ const manifest = services.get('news');
+ assert.ok(manifest, 'openvibe-contracts defines the news service manifest');
+ assert.deepStrictEqual([...manifest.capabilities].sort(), Object.values(CAPABILITIES).sort());
+ for (const id of Object.values(CAPABILITIES)) {
+ const cap = capabilities.get(id);
+ assert.ok(cap, `${id} is defined by openvibe-contracts`);
+ assert.strictEqual(cap.owner, 'news', `${id} is owned by news`);
+ }
+ });
+
+ await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', () => {
+ // An exact grant and a `news.*` prefix grant the library's matching rule accepts.
+ assert.deepStrictEqual(checkCapability({ cap: ['news.story.create'], sub: 'svc:sources' }, 'news.story.create'), { allowed: true, code: null, reason: null });
+ assert.deepStrictEqual(checkCapability({ cap: ['news.*'] }, 'news.story.create'), { allowed: true, code: null, reason: null });
+ // A grant of a sibling capability, or no cap at all, is denied with the library's code.
+ assert.strictEqual(checkCapability({ cap: ['news.story.read'] }, 'news.story.create').code, 'capability.denied');
+ assert.strictEqual(checkCapability(null, 'news.story.create').code, 'capability.denied');
+ // An id the contracts do not define still answers capability.unknown — the guard delegates,
+ // it never decides an unknown id on its own.
+ assert.strictEqual(checkCapability({ cap: ['news.*'] }, 'news.not.a.capability').code, 'capability.unknown');
+ });
+
+ done();
+})();
@@ -1,18 +1,19 @@
@@ -1,18 +1,19 @@
'use strict';
'use strict';
/**
/**
- * The proposals the lead releases in the next openvibe-contracts version are valid against the
- * released schemas, match what the code enforces, and do not collide with released ids; every
- * event type the code emits is declared.
+ * The capability and service-manifest proposals in docs/ are valid against the installed
+ * openvibe-contracts (which now release them), match what the code enforces, and do not collide with
+ * ids owned by other services; every event type the code emits is declared.
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
-const { PROPOSED } = require('../server/auth/capabilities');
+const { CAPABILITIES } = require('../server/auth/capabilities');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');
const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');
+const IDS = new Set(Object.values(CAPABILITIES));
(async () => {
(async () => {
const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));
const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));
@@ -32,14 +33,14 @@ const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');
@@ -32,14 +33,14 @@ const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');
});
});
await check('the charter capabilities are all present as 3-segment ids', async () => {
await check('the charter capabilities are all present as 3-segment ids', async () => {
- for (const id of ['news.story.create', 'news.story.revise', 'news.story.publish', 'news.cluster.read', 'news.source.attach', 'news.timeline.update']) assert.ok(PROPOSED.has(id), id);
+ for (const id of ['news.story.create', 'news.story.revise', 'news.story.publish', 'news.cluster.read', 'news.source.attach', 'news.timeline.update']) assert.ok(IDS.has(id), id);
});
});
await check('the proposals are exactly the capabilities the code enforces, and every route guard names one', async () => {
await check('the proposals are exactly the capabilities the code enforces, and every route guard names one', async () => {
- assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());
- assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());
+ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...IDS].sort());
+ assert.deepStrictEqual([...manifest.capabilities].sort(), [...IDS].sort());
const api = fs.readFileSync(path.join(ROOT, 'server', 'http', 'api.js'), 'utf8');
const api = fs.readFileSync(path.join(ROOT, 'server', 'http', 'api.js'), 'utf8');
- for (const m of api.matchAll(/(?:guard\(|write\('[a-z]+', '[^']+', )'(news\.[a-z_.]+)'/g)) assert.ok(PROPOSED.has(m[1]), m[1]);
+ for (const m of api.matchAll(/(?:guard\(|write\('[a-z]+', '[^']+', )'(news\.[a-z_.]+)'/g)) assert.ok(IDS.has(m[1]), m[1]);
});
});
await check('the service manifest proposal is a valid registry.service-manifest@1', async () => {
await check('the service manifest proposal is a valid registry.service-manifest@1', async () => {