openvibers/openvibe.coupons Commits

Coupons' home on the showcase kit; pin openvibe-contracts v0.97.0 and openvibe-shared v2.9.0, drop the proposed-capability shim (#11)

OP OpenVibers committed 5 Oct 2026 · da26edd20f · 13 files, +129 −53

* wip: Coupons home showcase

* Coupons' home on the showcase kit; pin openvibe-contracts v0.97.0 and openvibe-shared v2.9.0, drop the proposed-capability shim

All change-reached tests pass. Summary:

**Files changed**

- `server/auth/capabilities.js` — removed `PROPOSED`, the local `checkCapability` fallback, and `PROPOSED` from exports; `checkCapability` now delegates straight to `capabilities.check()` (the six ids are released in contracts v0.97.0); updated the header comment. `CAPABILITIES`/`SCOPES`/`ALL_SCOPES` exports untouched.
- `test/contracts.test.js` — it imported the now-removed `PROPOSED`; switched to `CAPABILITIES` (local `ENFORCED` set) and refreshed the file header. Semantics unchanged.
- `test/capabilities.test.js` *(new)* — asserts every enforced id is a released, coupons-owned, non-retired capability; that `PROPOSED`/the local fallback are gone (unknown ids are `capability.unknown`, never locally granted); that `checkCapability` is exactly the library check (exact grant, `coupons.*` prefix, denied/null, no cross-id leak); and that install scopes are exported, are Coupons-local, and are not capabilities.
- `STATUS.json` — `contracts` and `packages` now v0.97.0; `openvibe-shared` v2.9.0 (package.json's actual pin, not v2.6.0); `openvibe-sdk` v0.26.0; `updated` → 2026-10-04.
- `README.md` — all five stale `openvibe-contracts v0.25.0` mentions → v0.97.0 (lines 9, 281, 295, 406, 464), and the "Depends on / Packages" pins → publishing v1.3.0, contracts v0.97.0, shared v2.9.0, sdk v0.26.0.
- `CHANGELOG.md` — added an Unreleased bullet for the contracts/shared bump, shim removal, doc pins and the new capabilities test.

**Tests run**

- `ov test test/capabilities.test.js test/contracts.test.js` → **2 passed**.
- `ov test test/delegation.test.js` → **1 passed** (service-token capability checks that previously went through the shim).
- `ov test` (the change-reached set — 18 files, not `--all`) → **18 passed in 44 s**.
- `node_modules/.bin/openvibe-contracts-check --service coupons` → **ok, exit 0** (contracts 0.97.0 inside the manifest range).

**Notes / not done**

- Did not touch `package.json`/`package-lock.json` (already at contracts v0.97.0 and shared v2.9.0, as instructed). The brief's v0.96.0/v2.6.0 figures were stale; I used the real pins, and corrected STATUS/README to v0.97.0 and shared v2.9.0 accordingly.
- Did not run `ov test --all` (session rule); CI's verify will run everything including the three tests that only reach this change from further away.
- `openvibe-contracts-check` reports "0 capabilities enforced" because the routes guard via `CAPABILITIES.*` constants rather than literal string arguments its regex matches — pre-existing, passes either way; I left it unchanged rather than restyle the routes.
- The selector flags `server/http/public.js` and `server/render/pages.js` (the pre-existing "wip: Coupons home showcase" commit on this branch) as having no test reaching them; that work is outside this step, so I left it alone.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex ce3bf43..06af0b1 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,4 +2,5 @@
## Unreleased
++- `openvibe-contracts` is pinned to v0.97.0 (and `openvibe-shared` to v2.9.0): all six Coupons capabilities are released there, so the local proposed-id fallback in `server/auth/capabilities.js` is gone and `checkCapability` is the library's check. STATUS.json and the README pins now match package.json; `test/capabilities.test.js` locks the released ids, the delegated check and the Coupons-local install scopes.
- 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` to every page; the home page's JS budget still holds (5 files, 239.1 KB, 56.3 KB brotli), so no budget was raised.
ddiff --git a/README.md b/README.md
iindex 056771d..a61a2c3 100644
---- a/README.md
++++ b/README.md
@@@ -6,7 +6,7 @@
**deployed internally, not launched**: it runs on the production host on 127.0.0.1:4850 only
(release `54df411`, `/api/ready` 200), `openvibe.coupons` still shows its placeholder from
OpenVibe.Sites, and it holds **no codes** (nothing is seeded, no coupons source is enabled). Its
--capabilities and service manifest are registered in openvibe-contracts v0.25.0.
++capabilities and service manifest are registered in openvibe-contracts v0.97.0.
**Domain:** `openvibe.coupons` · **Port:** 4850 · **Service id:** `coupons`
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.10; roadmap §4.2 D, §15.13, §29, §32.
**License:** AGPL-3.0 (same as every OpenVibe service).
@@@ -278,7 +278,7 @@ Coupons subscribes to `sources.item.*` and `sources.fetch.failed` with `scripts/
works without it). `scripts/subscribe.js --reconcile` re-stamps every merchant and every code (any status, so a code that
left active results gets its tombstone) in one bounded, idempotent pass and reports `{ sent, unchanged }`.
--## Capabilities (registered in openvibe-contracts v0.25.0)
++## Capabilities (registered in openvibe-contracts v0.97.0)
Service tokens use audience `openvibe.coupons`, with one capability per route:
@@@ -292,7 +292,7 @@ Service tokens use audience `openvibe.coupons`, with one capability per route:
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.25.0 from the proposals in `docs/capabilities-proposal/` and
++openvibe-contracts v0.97.0 from the proposals in `docs/capabilities-proposal/` and
`docs/service-manifest-proposal.json`. Install scopes (`coupons.lookup`, `coupons.report`) are
Coupons-local and aren't capabilities.
@@@ -303,9 +303,9 @@ What Coupons calls elsewhere, and with which grant, is under
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
--- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.0.0 (seo gate, ssr,
-- index-hooks), `openvibe-contracts` v0.76.0, `openvibe-shared` v2.2.0 (Frame, app icon, footer,
-- legal, release, metrics, ready, seo), `openvibe-sdk` v0.20.0 (events outbox, service tokens,
++- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.3.0 (seo gate, ssr,
++ index-hooks), `openvibe-contracts` v0.97.0, `openvibe-shared` v2.9.0 (Frame, app icon, footer,
++ legal, release, metrics, ready, seo), `openvibe-sdk` v0.26.0 (events outbox, service tokens,
per-actor limits).
- **OpenVibe.Network:**
- SSO: an OAuth client `coupons` with redirect `https://openvibe.coupons/auth/callback`
@@@ -403,7 +403,7 @@ holds. Status against each point:
3. **SSR public routes useful without JS:** done.
4. **Persistence and end-to-end workflows:** done and deployed on the host (loopback only, empty database).
5. **Capability and event registration against OpenVibe.Contracts:** done (openvibe-contracts
-- v0.25.0).
++ v0.97.0).
6. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:**
- There's nothing to migrate and nothing is seeded.
- Codes come from members, staff and the Sources `coupons` category.
@@@ -461,7 +461,7 @@ First install (done once; kept for a rebuild):
3. **systemd:** install `deploy/systemd/openvibe-coupons.service` (port 4850).
4. **nginx:** install `deploy/nginx/openvibe.coupons.conf`. `/metrics` is never proxied.
5. **Search:** add `coupons` to `SEARCH_EVENT_OWNERS`.
--6. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.25.0,
++6. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.97.0,
and CI's contracts check runs against them.
7. **Launch:** in the same release, remove `openvibe.coupons` from OpenVibe.Sites and flip the
Network hub entry (see the launch rule above).
ddiff --git a/STATUS.json b/STATUS.json
iindex 5bef1c3..21ac984 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,12 +7,12 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4850,
"domain": "openvibe.coupons",
-- "contracts": "openvibe-contracts v0.79.0",
++ "contracts": "openvibe-contracts v0.97.0",
"packages": [
"openvibe-publishing v1.3.0",
-- "openvibe-contracts v0.79.0",
-- "openvibe-shared v2.2.0",
-- "openvibe-sdk v0.25.1"
++ "openvibe-contracts v0.97.0",
++ "openvibe-shared v2.9.0",
++ "openvibe-sdk v0.26.0"
],
"capabilities": [
"coupons.coupon.submit",
@@@ -34,7 +34,7 @@
"deployed": true,
"deployment": "Deployed with `sudo ovhost deploy coupons`: unit openvibe-coupons, 127.0.0.1:4850 loopback only; Network OAuth client coupons and its grants exist; on 2026-09-23 /api/ready answered 200 and the production database held 0 coupons. Deployed internally, not launched: openvibe.coupons still serves the OpenVibe.Sites placeholder until a person verifies a coupons source's terms and enables it in Sources",
"placeholderInSites": true,
-- "updated": "2026-09-29",
++ "updated": "2026-10-04",
"features": [
"merchants with normalized domain rules (public-suffix subset, path rules)",
"codes with restrictions, known or unknown expiry and evidence",
ddiff --git a/package-lock.json b/package-lock.json
iindex 0f98ee4..ac6b5f7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,10 +15,10 @@
"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.6.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -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",
@@@ -936,9 +936,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.6.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
-- "integrity": "sha512-IKKvWTM/9UonQ4msrzM4yir0QcTUNbhyqeN1r5ywWavU4VTsL1sI5QdHQlxge7BAsMrQ4OvMEPlHjuAfUJM3rg==",
++ "version": "2.9.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
++ "integrity": "sha512-ESXg23tqj73C14KvoT3kC/6VfZEkjpN1Q9fdipQ3bqIGfxiiSQIz0osGzthHHm3okruCq6aDQld1kFP9tx8+kA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex b1afa80..289bc3d 100644
---- a/package.json
++++ b/package.json
@@@ -26,10 +26,10 @@
"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.6.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0"
},
"devDependencies": {
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
iindex 57b8f23..9957259 100644
---- a/server/auth/capabilities.js
++++ b/server/auth/capabilities.js
@@@ -5,10 +5,9 @@
* install tokens.
*
* Capabilities (roadmap §15.13, as 3-segment ids) are for OpenVibe services holding a Network
-- * client-credentials token. Coupons' 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 knows always goes through
-- * the library, so the day the release lands nothing changes here.
++ * client-credentials token. Coupons' ids were released in openvibe-contracts v0.97.0 from
++ * docs/capabilities-proposal/; a grant is decided by the library's own matching rule (the exact
++ * id, or a `prefix.*` grant covering it).
*
* Install scopes (coupons.lookup, coupons.report) are NOT Network capabilities: they are the two
* things a browser-helper install token may do, issued and revoked on this site
@@@ -24,19 +23,13 @@ const CAPABILITIES = Object.freeze({
STATUS_UPDATE: 'coupons.status.update',
MERCHANT_MANAGE: 'coupons.merchant.manage',
});
--const PROPOSED = new Set(Object.values(CAPABILITIES));
const SCOPES = Object.freeze({ LOOKUP: 'coupons.lookup', REPORT: 'coupons.report' });
const ALL_SCOPES = Object.freeze([SCOPES.LOOKUP, SCOPES.REPORT]);
/** → { 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, SCOPES, ALL_SCOPES, checkCapability };
++module.exports = { CAPABILITIES, SCOPES, ALL_SCOPES, checkCapability };
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 6711c62..38da3c9 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -22,6 +22,7 @@
const express = require('express');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
++const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-publishing/seo');
const { renderPage } = require('../render/layout');
@@@ -106,6 +107,24 @@ function createPublicRoutes(ctx) {
decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,
body: frame.updatesBody({ service: 'coupons', siteName: 'OpenVibe.Coupons' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}, { cacheable: true }));
++ /** The product's own words above the shops: only what OpenVibe.Coupons does today (the News and Blog homes' kit). */
++ function couponsShowcase() {
++ return showcase.hero({
++ eyebrow: 'OpenVibe.Coupons',
++ title: 'Coupon codes,', accent: 'honestly labelled',
++ lede: 'Codes for online shops with their restrictions, their expiry (or "unknown") and what people reported when they tried them. A code is only ever called working because people said it worked recently.',
++ actions: [{ label: 'Find a shop', href: '#q', primary: true }, { label: 'Submit a code', href: '/submit' }],
++ }) + showcase.features({
++ title: 'What every code tells you',
++ items: [
++ { icon: 'ov:coupons', title: 'The right shop', text: 'Each shop lists the domains and paths it covers, so a code is matched to the site you are on.' },
++ { icon: 'ov:docs', title: 'Restrictions stated', text: 'Minimum spend, new customers only, the products it applies to: written down, not hidden.' },
++ { icon: 'ov:history', title: 'Honest expiry', text: 'An expiry date when there is one, and "unknown" when there is not.' },
++ { icon: 'ov:community', title: 'Reports from people', text: 'Worked or did not work, recently: the status comes from what people said when they tried it.' },
++ ],
++ });
++ }
++
router.get('/', async (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 60) : '';
const recent = await Promise.all((await coupons.recentActive(12)).map(async (c) => ({ coupon: c, merchant: await merchants.byId(c.merchant_id) })));
@@@ -118,8 +137,10 @@ function createPublicRoutes(ctx) {
// The service's own one-line summary: on "/" the shell turns it into the ai-summary meta
// and the page's WebPage JSON-LD. A search result page is noindex and carries neither.
...(q ? {} : { summary: pages.SITE_SUMMARY }),
++ // The front page opens with what Coupons is for (openvibe-shared/showcase); a search result page does not.
++ ...(q ? {} : { styles: [showcase.STYLESHEET] }),
feeds: [{ type: 'rss', href: '/feed.xml', title: 'New codes (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New codes (Atom)' }, { type: 'json', href: '/feed.json', title: 'New codes (JSON Feed)' }],
-- body: pages.home({ merchants: list, recent, q, results: q ? await merchants.search(q) : [], total: list.length }),
++ body: (q ? '' : couponsShowcase()) + pages.home({ merchants: list, recent, q, results: q ? await merchants.search(q) : [], total: list.length, showcase: !q }),
}, { cacheable: true });
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 6aae050..d201f97 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -77,6 +77,7 @@ function renderPage(o) {
// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
summary: o.summary,
++ styles: o.styles, // openvibe-shared stylesheet names (the home's showcase.css)
facts: o.facts,
updated: o.updated,
url: o.url,
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 520bb4e..78b5ae9 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -115,10 +115,12 @@ function errors(list) {
return h`<div class="errors" role="alert"><p>Please fix:</p><ul>${list.map((e) => h`<li>${e}</li>`)}</ul></div>`;
}
--function home({ merchants, recent, q, results, total }) {
-- return h`<h1>Coupon codes, honestly labelled</h1>
++// Under the showcase (the front page) its hero is the page's one h1 and says what the site is, so the page starts at the
++// search form; a search result page keeps the site's own h1 and lede.
++function home({ merchants, recent, q, results, total, showcase = false }) {
++ return h`${showcase ? '' : h`<h1>Coupon codes, honestly labelled</h1>
<p class="lede">Codes for online shops with their restrictions, their expiry (or “unknown”) and what people reported when they tried them. A code is only ever called working because people said it worked recently.</p>
--<form method="get" action="/" class="search" role="search"><label for="q">Find a shop</label> <input id="q" name="q" value="${q || ''}" maxlength="60" placeholder="name or domain"> <button type="submit">Search</button></form>
++`}<form method="get" action="/" class="search${showcase ? ' after-showcase' : ''}" role="search"><label for="q">Find a shop</label> <input id="q" name="q" value="${q || ''}" maxlength="60" placeholder="name or domain"> <button type="submit">Search</button></form>
${q ? h`<h2>Shops matching “${q}”</h2>${results.length ? h`<ul class="merchant-list">${results.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">No shop matches. <a href="/submit">Submit a code</a> for a new one.</p>`}` : ''}
<h2>Recently added codes</h2>
${recent.length ? h`<ul class="recent">${recent.map((r) => h`<li><a href="/c/${r.coupon.id}">${r.coupon.title}</a> at <a href="/m/${r.merchant.slug}">${r.merchant.name}</a> ${statusBadge(r.coupon.status)}</li>`)}</ul>` : h`<p class="empty">No codes yet.</p>`}
ddiff --git a/test/capabilities.test.js b/test/capabilities.test.js
nnew file mode 100644
iindex 0000000..5d91250
---- /dev/null
++++ b/test/capabilities.test.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * Coupons' capabilities are released in openvibe-contracts (v0.97.0), so the local fallback for
++ * proposed ids is gone: checkCapability is exactly the library's check, and every id the code
++ * enforces is known to the library. Install scopes stay Coupons-local and are never capabilities.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { check, done } = require('./helpers/boot');
++const mod = require('../server/auth/capabilities');
++const { CAPABILITIES, SCOPES, ALL_SCOPES, checkCapability } = mod;
++
++(async () => {
++ await check('every enforced capability is released in openvibe-contracts, owned by coupons', async () => {
++ for (const id of Object.values(CAPABILITIES)) {
++ const cap = contracts.capabilities.get(id);
++ assert.ok(cap, `${id} is not in the contracts library`);
++ assert.strictEqual(cap.owner, 'coupons', id);
++ assert.notStrictEqual(cap.status, 'retired', id);
++ }
++ });
++
++ await check('the proposed-id shim is gone: PROPOSED and the local fallback are not exported', async () => {
++ assert.ok(!('PROPOSED' in mod), 'PROPOSED is still exported');
++ // A capability id the library does not know is unknown, never locally granted.
++ const forged = { cap: ['coupons.nonexistent.thing'] };
++ assert.deepStrictEqual(checkCapability(forged, 'coupons.nonexistent.thing'), contracts.capabilities.check(forged, 'coupons.nonexistent.thing'));
++ assert.strictEqual(checkCapability(forged, 'coupons.nonexistent.thing').code, 'capability.unknown');
++ });
++
++ await check('checkCapability is the library check: exact, prefix.* and denied grants', async () => {
++ const id = CAPABILITIES.COUPON_LOOKUP;
++ assert.deepStrictEqual(checkCapability({ cap: [id] }, id), { allowed: true, code: null, reason: null });
++ assert.deepStrictEqual(checkCapability({ cap: ['coupons.*'] }, id), { allowed: true, code: null, reason: null });
++ assert.deepStrictEqual(checkCapability({ cap: [] }, id), { allowed: false, code: 'capability.denied', reason: `${id} not granted` });
++ assert.deepStrictEqual(checkCapability(null, id), { allowed: false, code: 'capability.denied', reason: `${id} not granted` });
++ // No grant for one id leaks to another.
++ assert.strictEqual(checkCapability({ cap: [CAPABILITIES.COUPON_LOOKUP] }, CAPABILITIES.STATUS_UPDATE).allowed, false);
++ });
++
++ await check('install scopes are exported and are not capabilities', async () => {
++ assert.deepStrictEqual(ALL_SCOPES, [SCOPES.LOOKUP, SCOPES.REPORT]);
++ assert.deepStrictEqual(ALL_SCOPES, ['coupons.lookup', 'coupons.report']);
++ for (const s of ALL_SCOPES) assert.strictEqual(contracts.capabilities.get(s), undefined, `${s} is an install scope, not a capability`);
++ });
++
++ done();
++})();
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 2c62f60..7cd0844 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -1,17 +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 emits, and do not collide with released ids.
++ * The capabilities and service manifest Coupons registered (released in openvibe-contracts v0.97.0
++ * from these very proposals) are valid against the schemas, match what the code enforces and emits,
++ * and do not collide with other owners' released ids.
*/
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, ALL_SCOPES } = require('../server/auth/capabilities');
++const { CAPABILITIES, ALL_SCOPES } = require('../server/auth/capabilities');
const ROOT = path.join(__dirname, '..');
const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');
++const ENFORCED = new Set(Object.values(CAPABILITIES));
(async () => {
const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));
@@@ -30,9 +32,9 @@ const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');
});
await check('the proposals are exactly the capabilities the code enforces; install scopes are not capabilities', async () => {
-- assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());
-- assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());
-- for (const s of ALL_SCOPES) assert.ok(!PROPOSED.has(s), `${s} is an install scope, not a capability`);
++ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...ENFORCED].sort());
++ assert.deepStrictEqual([...manifest.capabilities].sort(), [...ENFORCED].sort());
++ for (const s of ALL_SCOPES) assert.ok(!ENFORCED.has(s), `${s} is an install scope, not a capability`);
});
await check('the service manifest proposal is a valid registry.service-manifest@1 and declares every event capabilities name', async () => {
@@@ -44,7 +46,7 @@ const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');
await check('every event type the code emits is declared, and every declared one is emitted somewhere', async () => {
const src = ['server/domain/coupons.js', 'server/domain/reports.js'].map((f) => fs.readFileSync(path.join(ROOT, f), 'utf8')).join('\n');
-- const emitted = new Set([...src.matchAll(/'(coupons\.[a-z_]+\.[a-z_]+)'/g)].map((m) => m[1]).filter((x) => !PROPOSED.has(x)));
++ const emitted = new Set([...src.matchAll(/'(coupons\.[a-z_]+\.[a-z_]+)'/g)].map((m) => m[1]).filter((x) => !ENFORCED.has(x)));
emitted.add('coupons.index_document.upserted');
emitted.add('coupons.index_document.deleted');
for (const e of emitted) assert.ok(manifest.eventsProduced.includes(e), `${e} emitted but not declared`);
ddiff --git a/test/pages-discovery.test.js b/test/pages-discovery.test.js
iindex 619227a..57ca555 100644
---- a/test/pages-discovery.test.js
++++ b/test/pages-discovery.test.js
@@@ -123,8 +123,16 @@ const { boot, check, done } = require('./helpers/boot');
const home = await t.get('/');
assert.match(home.text, /Green Grocer/);
assert.match(home.text, /1 active code</);
++ // The front page opens with the showcase (openvibe-shared/showcase): its hero is the one h1 and its stylesheet is linked;
++ // the shops still follow. A search result page has neither.
++ assert.ok(home.text.includes('class="sc-hero') && home.text.includes('class="sc-sec"'), 'the home opens with the showcase');
++ assert.match(home.text, /<link rel="stylesheet" href="[^"]*showcase\.css[^"]*">/);
++ assert.strictEqual((home.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on the home page');
++ assert.ok(home.text.indexOf('class="sc-hero') < home.text.indexOf('Green Grocer'), 'the showcase sits above the shops');
const s = await t.get('/?q=grocer');
assert.match(s.text, /Shops matching “grocer”/);
++ assert.ok(!s.text.includes('sc-hero') && !s.text.includes('showcase.css'), 'a search page has no showcase');
++ assert.match(s.text, /<h1>Coupon codes, honestly labelled<\/h1>/);
assert.match(s.text, /<meta name="robots" content="noindex, nofollow">/, 'search results are not indexed');
const about = await t.get('/about');
assert.match(about.text, /confidence = \(1 \+ e \+ W\) \/ \(2 \+ e \+ W \+ F\)/);
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex e08ecc7..3fe2dc0 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 22, // measured 17.4 (fresh database, 2026-09-29)
-- htmlBrotliKB: 5.5, // 4.5
-- jsFiles: 5, // 4
-- jsRawKB: 240, // 212.2
-- jsBrotliKB: 58, // 49.9
-- cssFiles: 2, // 1
-- cssRawKB: 6.5, // 4.4
-- cssBrotliKB: 2, // 1.2
++ htmlRawKB: 27, // measured 24.1 with the home showcase (17.4 before it; fresh database): raised as a decision, as News and Blog did
++ htmlBrotliKB: 7.5, // 6.2 (4.5 before the showcase)
++ jsFiles: 5, // 5 (openvibe-shared 2.9 shell: web-runtime.js)
++ jsRawKB: 245, // 239.1 (212.2 before the shell's web-runtime.js)
++ jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
++ cssFiles: 2, // 2 (coupons.css + showcase.css, home only)
++ cssRawKB: 17.5, // 14.8 (4.4 before showcase.css)
++ cssBrotliKB: 4.5, // 3.6 (1.2 before showcase.css)
externalFiles: 1, // 0
};