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)
Changes
@@@ -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.@@@ -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 fromOpenVibe.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 thatleft 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`) areCoupons-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 theNetwork hub entry (see the launch rule above).@@@ -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",@@@ -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"@@@ -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": {@@@ -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 };@@@ -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 });});@@@ -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,@@@ -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>`}nnew file mode 100644@@@ -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();++})();@@@ -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`);@@@ -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\)/);@@@ -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};
@@ -2,4 +2,5 @@
@@ -2,4 +2,5 @@
## Unreleased
## 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.
- 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.
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
**deployed internally, not launched**: it runs on the production host on 127.0.0.1:4850 only
**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
(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
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`
**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.
**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).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -278,7 +278,7 @@ Coupons subscribes to `sources.item.*` and `sources.fetch.failed` with `scripts/
@@ -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
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 }`.
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:
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:
@@ -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
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
(`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
`docs/service-manifest-proposal.json`. Install scopes (`coupons.lookup`, `coupons.report`) are
Coupons-local and aren't capabilities.
Coupons-local and aren't capabilities.
@@ -303,9 +303,9 @@ What Coupons calls elsewhere, and with which grant, is under
@@ -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
- **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).
`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).
per-actor limits).
- **OpenVibe.Network:**
- **OpenVibe.Network:**
- SSO: an OAuth client `coupons` with redirect `https://openvibe.coupons/auth/callback`
- SSO: an OAuth client `coupons` with redirect `https://openvibe.coupons/auth/callback`
@@ -403,7 +403,7 @@ holds. Status against each point:
@@ -403,7 +403,7 @@ holds. Status against each point:
3. **SSR public routes useful without JS:** done.
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).
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
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:**
6. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:**
- There's nothing to migrate and nothing is seeded.
- There's nothing to migrate and nothing is seeded.
- Codes come from members, staff and the Sources `coupons` category.
- Codes come from members, staff and the Sources `coupons` category.
@@ -461,7 +461,7 @@ First install (done once; kept for a rebuild):
@@ -461,7 +461,7 @@ First install (done once; kept for a rebuild):
3. **systemd:** install `deploy/systemd/openvibe-coupons.service` (port 4850).
3. **systemd:** install `deploy/systemd/openvibe-coupons.service` (port 4850).
4. **nginx:** install `deploy/nginx/openvibe.coupons.conf`. `/metrics` is never proxied.
4. **nginx:** install `deploy/nginx/openvibe.coupons.conf`. `/metrics` is never proxied.
5. **Search:** add `coupons` to `SEARCH_EVENT_OWNERS`.
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.
and CI's contracts check runs against them.
7. **Launch:** in the same release, remove `openvibe.coupons` from OpenVibe.Sites and flip the
7. **Launch:** in the same release, remove `openvibe.coupons` from OpenVibe.Sites and flip the
Network hub entry (see the launch rule above).
Network hub entry (see the launch rule above).
@@ -7,12 +7,12 @@
@@ -7,12 +7,12 @@
"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": 4850,
"port": 4850,
"domain": "openvibe.coupons",
"domain": "openvibe.coupons",
- "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-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": [
"capabilities": [
"coupons.coupon.submit",
"coupons.coupon.submit",
@@ -34,7 +34,7 @@
@@ -34,7 +34,7 @@
"deployed": true,
"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",
"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,
"placeholderInSites": true,
- "updated": "2026-09-29",
+ "updated": "2026-10-04",
"features": [
"features": [
"merchants with normalized domain rules (public-suffix subset, path rules)",
"merchants with normalized domain rules (public-suffix subset, path rules)",
"codes with restrictions, known or unknown expiry and evidence",
"codes with restrictions, known or unknown expiry and evidence",
@@ -15,10 +15,10 @@
@@ -15,10 +15,10 @@
"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.6.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -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",
@@ -936,9 +936,9 @@
@@ -936,9 +936,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -26,10 +26,10 @@
@@ -26,10 +26,10 @@
"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.6.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -5,10 +5,9 @@
@@ -5,10 +5,9 @@
* install tokens.
* install tokens.
*
*
* Capabilities (roadmap §15.13, as 3-segment ids) are for OpenVibe services holding a Network
* 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
* 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
* things a browser-helper install token may do, issued and revoked on this site
@@ -24,19 +23,13 @@ const CAPABILITIES = Object.freeze({
@@ -24,19 +23,13 @@ const CAPABILITIES = Object.freeze({
STATUS_UPDATE: 'coupons.status.update',
STATUS_UPDATE: 'coupons.status.update',
MERCHANT_MANAGE: 'coupons.merchant.manage',
MERCHANT_MANAGE: 'coupons.merchant.manage',
});
});
-const PROPOSED = new Set(Object.values(CAPABILITIES));
const SCOPES = Object.freeze({ LOOKUP: 'coupons.lookup', REPORT: 'coupons.report' });
const SCOPES = Object.freeze({ LOOKUP: 'coupons.lookup', REPORT: 'coupons.report' });
const ALL_SCOPES = Object.freeze([SCOPES.LOOKUP, SCOPES.REPORT]);
const ALL_SCOPES = Object.freeze([SCOPES.LOOKUP, SCOPES.REPORT]);
/** → { 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, SCOPES, ALL_SCOPES, checkCapability };
+module.exports = { CAPABILITIES, SCOPES, ALL_SCOPES, checkCapability };
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
const express = require('express');
const express = require('express');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const { renderPage } = require('../render/layout');
const { renderPage } = require('../render/layout');
@@ -106,6 +107,24 @@ function createPublicRoutes(ctx) {
@@ -106,6 +107,24 @@ function createPublicRoutes(ctx) {
decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,
decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,
body: frame.updatesBody({ service: 'coupons', siteName: 'OpenVibe.Coupons' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'coupons', siteName: 'OpenVibe.Coupons' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}, { cacheable: true }));
}, { 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) => {
router.get('/', async (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 60) : '';
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) })));
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) {
@@ -118,8 +137,10 @@ function createPublicRoutes(ctx) {
// The service's own one-line summary: on "/" the shell turns it into the ai-summary meta
// 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.
// and the page's WebPage JSON-LD. A search result page is noindex and carries neither.
...(q ? {} : { summary: pages.SITE_SUMMARY }),
...(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)' }],
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 });
}, { cacheable: true });
});
});
@@ -77,6 +77,7 @@ function renderPage(o) {
@@ -77,6 +77,7 @@ function renderPage(o) {
// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta
// 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.
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
summary: o.summary,
summary: o.summary,
+ styles: o.styles, // openvibe-shared stylesheet names (the home's showcase.css)
facts: o.facts,
facts: o.facts,
updated: o.updated,
updated: o.updated,
url: o.url,
url: o.url,
@@ -115,10 +115,12 @@ function errors(list) {
@@ -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>`;
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>
<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>`}` : ''}
${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>
<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>`}
${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>`}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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();
+})();
@@ -1,17 +1,19 @@
@@ -1,17 +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 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 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, ALL_SCOPES } = require('../server/auth/capabilities');
+const { CAPABILITIES, ALL_SCOPES } = 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 ENFORCED = 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'));
@@ -30,9 +32,9 @@ const DIR = path.join(ROOT, 'docs', 'capabilities-proposal');
@@ -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 () => {
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 () => {
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');
@@ -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 () => {
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 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.upserted');
emitted.add('coupons.index_document.deleted');
emitted.add('coupons.index_document.deleted');
for (const e of emitted) assert.ok(manifest.eventsProduced.includes(e), `${e} emitted but not declared`);
for (const e of emitted) assert.ok(manifest.eventsProduced.includes(e), `${e} emitted but not declared`);
@@ -123,8 +123,16 @@ const { boot, check, done } = require('./helpers/boot');
@@ -123,8 +123,16 @@ const { boot, check, done } = require('./helpers/boot');
const home = await t.get('/');
const home = await t.get('/');
assert.match(home.text, /Green Grocer/);
assert.match(home.text, /Green Grocer/);
assert.match(home.text, /1 active code</);
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');
const s = await t.get('/?q=grocer');
assert.match(s.text, /Shops matching “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');
assert.match(s.text, /<meta name="robots" content="noindex, nofollow">/, 'search results are not indexed');
const about = await t.get('/about');
const about = await t.get('/about');
assert.match(about.text, /confidence = \(1 \+ e \+ W\) \/ \(2 \+ e \+ W \+ F\)/);
assert.match(about.text, /confidence = \(1 \+ e \+ W\) \/ \(2 \+ e \+ W \+ F\)/);
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
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
externalFiles: 1, // 0
};
};