openvibers/openvibe.news Commits

Pin openvibe-contracts v0.97.0 and drop the proposed-capability shim (#11)

OP OpenVibers committed 5 Oct 2026 · 58546a1117 · 8 files, +71 −39

All steps done. Summary:

**Files changed**
- `server/auth/capabilities.js`: dropped `PROPOSED` and the `capabilities.grants` fallback branch; `checkCapability` now delegates straight to `capabilities.check`; rewrote the doc comment (ids are released, not proposed) and removed `PROPOSED` from `module.exports`.
- `test/capabilities.test.js` (new): Reviews-pattern pin — every `news.*` id is defined by the installed contracts and the `news` service manifest, and `checkCapability` agrees with `capabilities.check` for exact / `news.*` prefix / sibling-denied / no-grant / unknown ids.
- `test/contracts.test.js`: the only consumer of the removed `PROPOSED`; now derives its id set from `CAPABILITIES` (`IDS`) and the doc comment says the docs/ proposals are released rather than pending.
- `STATUS.json`: `contracts` and `packages` entries v0.79.0 → v0.97.0; `updated` 2026-09-29 → 2026-10-04.
- `README.md`: every contracts-version mention brought to the pinned v0.97.0 (status line, Capabilities heading, registration sentence, Depends-on packages line, acceptance item 5, launch checklist item 8); the registration sentence now says `capabilities.js` delegates straight to `capabilities.check` now that the ids are released.
- `CHANGELOG.md`: Unreleased entry for the v0.79.0 → v0.97.0 move, the guard simplification and the two tests.
- `package.json` / `package-lock.json`: **not touched** — already at v0.97.0 by the harness, per instruction.

**Tests run**
- `ov test test/capabilities.test.js test/contracts.test.js test/stories.test.js test/retraction.test.js test/discussion.test.js test/lockfile.test.js` → **6 passed in 10 s** (the last four are the suite's only other capability-reachable tests; `lockfile.test.js` is the repo's pin-integrity test — News has no `pins*.test.js` or docs/status test).
- `node node_modules/openvibe-contracts/bin/check-service.js --service news --src server` → **ok**: contracts 0.97.0, 30 files, 11 capabilities enforced, 0 contract ids validated.
- `node --check` on the three JS files → ok.

**Could not do / deviations**
- Did not run `ov test --all` as the environment instructions forbid whole-suite runs (CI runs it after push); ran only the change-reachable files. The brief's `ov test --all` could not be honored.
- The brief expected v0.96.0 and line-numbered README pins that read v0.21.0 in this checkout; I used the actual pinned v0.97.0 and updated all contracts-version mentions found by grep (v0.21.0 and v0.79.0). No commit/push/install performed.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 798fcb2..1b7399f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 7a03df9..2fc2280 100644
---- a/README.md
++++ b/README.md
@@@ -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 the
running 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 acts
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).
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/publication
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-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 (loopback
only); 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. Nothing
to 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 (terms
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.
9. **Launch:** in the same release, remove `openvibe.news` from OpenVibe.Sites and flip the Network
hub entry (see the launch rule above).
ddiff --git a/STATUS.json b/STATUS.json
iindex de4112e..856a9c0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package-lock.json b/package-lock.json
iindex 6eeffaa..83eaa90 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 042490f..f8c68cd 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
iindex 22da37a..2da1559 100644
---- a/server/auth/capabilities.js
++++ b/server/auth/capabilities.js
@@@ -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 };
ddiff --git a/test/capabilities.test.js b/test/capabilities.test.js
nnew file mode 100644
iindex 0000000..755577e
---- /dev/null
++++ b/test/capabilities.test.js
@@@ -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();
++})();
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex ff2a0df..108bcb1 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -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 () => {