openvibers/openvibe.coupons Commits

The Coupons front page shows the codes themselves: reported working, ending soon and new, with the site's numbers, the shops, the Companion and answers to common questions; DealNews codes say so and stay out of browser extensions, as its feed terms require (#79)

OP OpenVibers committed 22 days ago · a759a0a131 · 13 files, +356 −40

* The Coupons front page shows the codes themselves: reported working, ending soon and new, with the site's numbers, the shops, the Companion and answers to common questions; DealNews codes say so and stay out of browser extensions, as its feed terms require

* The Coupons front page's title fits the 60-character limit of search results

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 06af0b1..807d52d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,5 +2,6 @@
## Unreleased
++- The front page is curated (`server/render/home.js`): a shop search and the site's numbers beside the hero, then codes reported working, ending soon and new as tickets that show the code itself (the list showed only deal headlines), the shops, the Companion, what every code tells you and six questions (FAQPage, ItemList and WebSite JSON-LD); the page is 1080 px wide. DealNews' feed terms are kept (`server/domain/source-policy.js`): its codes say "via DealNews" on every page and never reach a browser extension (extension origins and install tokens). Budgets: html 33.7 KB, css 21.8 KB.
- `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 382e0a7..c7a9cc3 100644
---- a/README.md
++++ b/README.md
@@@ -162,7 +162,7 @@ The code is in `server/domain/hosts.js` and `server/domain/psl.js`. The tests ar
| Route | What |
|---|---|
--| `/` | Shops with their active-code counts, recently added codes, and search (`?q=`, noindex). |
++| `/` | The front page (`server/render/home.js`): a shop search and the site's numbers (active codes, shops with one, ending this week, added this week, with a stated end, people's reports), then codes reported working, ending soon and new (each a ticket with the code, the shop, what it is for, its end or "No end date stated" and its source's attribution; a code shows once), the shops, the Companion, what every code tells you, and six questions (FAQPage JSON-LD). A section with nothing to show is left out. A search (`?q=`) lists matching shops (noindex). |
| `/about` | Statuses, the confidence formula, expiry, privacy and the browser helper. |
| `/m/:slug`, `/m/:slug.json` | A shop, and the same as data. The page shows active codes with status, confidence, the last report time (to the hour), restrictions, expiry or "unknown", evidence and hints. Recently ended codes are listed apart. |
| `/c/:id`, `/c/:id.json` | One code, with its status history. Expired: served but noindex. Taken down: 410. |
@@@ -210,6 +210,13 @@ use a visitor's session against the API. Errors are RFC 9457 problems.
| `GET /merchants/resolve?host=` | Anyone. Anonymous callers are rate-limited per IP, installs per install. | `coupons.merchant.resolve` / `coupons.lookup` |
| `GET /merchants/:id/coupons` | Anyone (as above). | `coupons.coupon.lookup` / `coupons.lookup` |
| `GET /merchants/:id`, `GET /coupons/:id` | Anyone. | `coupons.coupon.lookup` / `coupons.lookup` |
++
++A source's terms can keep its codes out of browser extensions (`server/domain/source-policy.js`): DealNews' feed terms
++say its content may not be used in a browser extension made available for public use, so a code from
++`dealnews-coupon-codes` is never in an answer to an extension (an `Origin` of `chrome-extension://`, `moz-extension://`
++or another `*-extension://` scheme, or an install's `cpx_` token): left out of `/merchants/:id/coupons` and of
++`active_codes`, and `GET /coupons/:id` answers 404. These answers vary on `Origin`. Every code view carries
++`attribution` (`{ text: 'DealNews', url }`, shown as "via DealNews" wherever the code is) and `extension_excluded`.
| `POST /coupons/:id/report` `{outcome, reason?}` | A person: a Bearer Network token, an install token with `coupons.report`, or a service acting for `X-OV-Subject`. | `coupons.report.create` / `coupons.report` |
| `POST /coupons/submit` `{merchant_id \| host \| url, code, title, description?, evidence_url?, expires?, expiry_basis?, restrictions?, hint?, ai_run_id?}` | A person, or `X-OV-Origin: ai` (held for review). Install tokens can't submit. | `coupons.coupon.submit` |
| `POST /coupons/:id/status` `{status: disabled\|expired\|active, note?}` | Staff (Network admin, or `COUPONS_STAFF_SUBJECTS`) or a service. | `coupons.status.update` |
ddiff --git a/public/css/coupons.css b/public/css/coupons.css
iindex 47369c9..2a5f639 100644
---- a/public/css/coupons.css
++++ b/public/css/coupons.css
@@@ -64,3 +64,42 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
.ov-breadcrumbs ol { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: .9rem; }
.ov-breadcrumbs li + li::before { content: "›"; margin-right: 6px; color: var(--text-muted); }
@media (max-width: 600px) { .installs thead { display: none; } .installs td { display: block; border: 0; padding: 2px 0; } .installs tr { display: block; border-bottom: 1px solid var(--border, #1f2d47); padding: 6px 0; } }
++/* The front page (server/render/home.js): wider, with the numbers beside the hero, code tickets, and questions. */
++.home .page, .home .account-bar { max-width: 1080px; }
++.cp-hero { padding-top: 28px; }
++.cp-search { margin: 20px 0 0; flex-wrap: nowrap; max-width: 560px; }
++.cp-search input { flex: 1; min-width: 0; padding: 10px 12px; font-size: 1rem; border-radius: 10px; }
++.cp-search button { padding: 10px 18px; border-radius: 10px; }
++.cp-stats { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
++.cp-stats li { min-width: 0; }
++.cp-stats a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; text-decoration: none; transition: border-color .15s; }
++.cp-stats a:hover, .cp-stats a:focus-visible { border-color: var(--accent, #3b82f6); }
++.cp-stats b { font-size: 1.6rem; line-height: 1.1; font-variant-numeric: tabular-nums; }
++.cp-stats span { font-size: .85rem; line-height: 1.35; color: var(--text-secondary, #96a7c2); }
++.page section.cp-sec { margin-top: 44px; }
++.cp-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }
++.cp-head h2 { margin: 0; font-size: clamp(1.3rem, 2.4vw, 1.75rem); letter-spacing: -.01em; }
++.cp-more { font-weight: 600; font-size: .95rem; text-decoration: none; }
++.cp-more:hover { text-decoration: underline; text-underline-offset: 3px; }
++.cp-lede { margin: 4px 0 14px; color: var(--text-secondary, #96a7c2); }
++.cp-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); }
++.cp-card { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.cp-card:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); }
++.cp-card p { margin: 0; }
++.cp-top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 8px; }
++.cp-shop { font-weight: 700; text-decoration: none; overflow-wrap: anywhere; }
++.cp-shop:hover { text-decoration: underline; }
++.cp-code .code { display: inline-block; max-width: 100%; font-size: 1.2rem; letter-spacing: .04em; }
++.cp-card h3 { margin: 0; font-size: .98rem; line-height: 1.4; font-weight: 600; overflow-wrap: anywhere; }
++.cp-card h3 a { color: var(--text-primary, #e6edf7); text-decoration: none; }
++.cp-card h3 a:hover { color: var(--accent-light, #60a5fa); text-decoration: underline; text-underline-offset: 3px; }
++.cp-card .meta { font-size: .84rem; }
++.cp-via { margin-top: auto !important; }
++.cp-faq { display: grid; gap: 8px; }
++.cp-faq details { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); padding: 0 16px; }
++.cp-faq summary { cursor: pointer; font-weight: 600; padding: 12px 0; }
++.cp-faq p { margin: 0 0 14px; color: var(--text-secondary, #96a7c2); }
++.cp-empty { margin-top: 24px; }
++@media (max-width: 600px) { .cp-search { flex-wrap: wrap; } .cp-search button { width: 100%; } .cp-stats b { font-size: 1.3rem; } .cp-stats a { padding: 12px; } .cp-cards > li:nth-child(n+5) { display: none; } }
++@media (prefers-reduced-motion: reduce) { .cp-stats a { transition: none; } }
++.home .page .sc-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); }
ddiff --git a/server/domain/coupons.js b/server/domain/coupons.js
iindex 1641dea..0f5ff0a 100644
---- a/server/domain/coupons.js
++++ b/server/domain/coupons.js
@@@ -31,6 +31,8 @@ const { ApiError } = require('../http/errors');
const confidence = require('./confidence');
const { hosts } = require('openvibe-publishing/ingest');
++const { attributionOf, hidesFromExtension } = require('./source-policy');
++
const ACTIVE_SQL = `c.review_state = 'published' AND c.status NOT IN ('expired','disabled')
AND (c.expires_at IS NULL OR c.expires_at > @now) AND m.status = 'active'`;
@@@ -183,6 +185,19 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
ORDER BY COALESCE(c.expired_at, c.expires_at) DESC LIMIT 10`),
recentActive: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL}
ORDER BY c.created_at DESC LIMIT @limit`),
++ // The front page (http/public.js): what ends soon, what people said works, and the numbers.
++ endingSoon: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL}
++ AND c.expires_at IS NOT NULL AND c.expires_at <= @until ORDER BY c.expires_at, c.id LIMIT @limit`),
++ reportedWorking: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL}
++ AND c.status = 'reported_working' ORDER BY c.last_worked_at DESC NULLS LAST, c.id LIMIT @limit`),
++ homeStats: db.prepare(`SELECT COUNT(*) AS active,
++ COUNT(DISTINCT c.merchant_id) AS shops,
++ COUNT(*) FILTER (WHERE c.expires_at IS NOT NULL) AS dated,
++ COUNT(*) FILTER (WHERE c.expires_at IS NOT NULL AND c.expires_at <= @until) AS ending,
++ COUNT(*) FILTER (WHERE c.created_at > @since) AS added,
++ COUNT(*) FILTER (WHERE c.status = 'reported_working') AS working
++ FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL}`),
++ reportsSince: db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE updated_at > ?'),
allActive: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL} ORDER BY c.created_at DESC LIMIT 50000`),
activePage: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL} ORDER BY c.id LIMIT @limit OFFSET @offset`),
allPage: db.prepare('SELECT c.* FROM coupons c ORDER BY c.id LIMIT @limit OFFSET @offset'),
@@@ -548,6 +563,10 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
...(s.sources_item_id ? { sources_item: s.sources_item_id, retrieved_at: iso(s.retrieved_at) } : {}),
})),
origin: c.origin,
++ // A source's required attribution ("via DealNews") and whether its terms keep the code out of browser
++ // extensions (domain/source-policy.js).
++ attribution: attributionOf(sources),
++ extension_excluded: hidesFromExtension(sources),
created_at: iso(c.created_at),
updated_at: iso(c.updated_at),
expired_at: iso(c.expired_at),
@@@ -563,6 +582,15 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
active: async (merchantId) => await q.active.all({ merchant: merchantId, now: store.now() }),
recentlyEnded: async (merchantId) => await q.recentlyEnded.all({ merchant: merchantId, now: store.now() }),
recentActive: async (limit = 30) => await q.recentActive.all({ now: store.now(), limit }),
++ endingSoon: async (withinMs, limit = 8) => await q.endingSoon.all({ now: store.now(), until: store.now() + withinMs, limit }),
++ reportedWorking: async (limit = 8) => await q.reportedWorking.all({ now: store.now(), limit }),
++ /** The front page's numbers: active codes, shops with one, with a stated expiry, ending within, added since, reported working, reports. */
++ async homeStats({ endingWithinMs, addedSinceMs, reportsSinceMs }) {
++ const now = store.now();
++ const r = await q.homeStats.get({ now, until: now + endingWithinMs, since: now - addedSinceMs });
++ const n = (x) => Number(x) || 0;
++ return { active: n(r.active), shops: n(r.shops), dated: n(r.dated), ending: n(r.ending), added: n(r.added), working: n(r.working), reports: n((await q.reportsSince.get(now - reportsSinceMs)).n) };
++ },
allActive: async () => await q.allActive.all({ now: store.now() }),
/** One page of the active-results set (scripts/subscribe.js --reconcile). */
activePage: async ({ limit = 200, offset = 0 } = {}) => await q.activePage.all({ now: store.now(), limit, offset }),
ddiff --git a/server/domain/source-policy.js b/server/domain/source-policy.js
nnew file mode 100644
iindex 0000000..762ea6d
---- /dev/null
++++ b/server/domain/source-policy.js
@@@ -0,0 +1,43 @@
++'use strict';
++
++/**
++ * Per-source policy for imported codes, keyed by the OpenVibe.Sources `source_key` (as OpenVibe.Deals keeps its own):
++ * how a source must be attributed and whether its content may reach a browser extension. A source with no entry has
++ * no attribution and no extension rule. Sources' `terms_note` is prose and is never parsed.
++ *
++ * DealNews' feed terms (https://www.dealnews.com/pages/rss.html): the feeds may be used with attribution to DealNews
++ * as text, and "you [may] not use or include the content in any browser extension made available for public use".
++ * `dealnews-coupon-codes` reads that feed, so its codes say "via DealNews" wherever they are shown and are left out of
++ * every answer to the OpenVibe Companion (an extension origin, or a connected install's cpx_ token).
++ */
++const DEALNEWS = Object.freeze({ text: 'DealNews', url: 'https://www.dealnews.com/' });
++const POLICIES = Object.freeze({
++ 'dealnews-coupon-codes': Object.freeze({ attribution: DEALNEWS, noExtension: true }),
++ 'dealnews-daily': Object.freeze({ attribution: DEALNEWS, noExtension: true }),
++});
++
++const policyFor = (key) => (key && Object.prototype.hasOwnProperty.call(POLICIES, key) ? POLICIES[key] : null);
++
++/** The attribution for a code's live sources ([{ source_key }]): the first whose policy names one, else null. */
++function attributionOf(sources) {
++ for (const s of sources || []) {
++ const p = policyFor(s && s.source_key);
++ if (p && p.attribution) return { text: p.attribution.text, url: p.attribution.url };
++ }
++ return null;
++}
++
++/** True when any of the code's live sources forbids its content in a browser extension. */
++function hidesFromExtension(sources) {
++ return (sources || []).some((s) => { const p = policyFor(s && s.source_key); return Boolean(p && p.noExtension); });
++}
++
++/** An extension page's origin (Chrome, Edge, Brave, Firefox, Safari). */
++const EXTENSION_ORIGIN = /^[a-z][a-z-]*-extension:\/\//i;
++
++/** True when the request comes from a browser extension: its Origin, or a connected install's token. */
++function fromExtension(req) {
++ return EXTENSION_ORIGIN.test(String(req.get('origin') || '')) || Boolean(req.viewer && req.viewer.kind === 'install');
++}
++
++module.exports = { POLICIES, policyFor, attributionOf, hidesFromExtension, fromExtension };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex ee5045c..47900d5 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -21,7 +21,9 @@
* POST /merchants/:id/status coupons.merchant.manage active | disabled
*
* Every lookup response is the same bytes for every caller: it holds no data about the caller or
-- * any other person (no submitter, no reporter, no install).
++ * any other person (no submitter, no reporter, no install). One difference by kind of caller: a browser
++ * extension (an extension Origin or an install token) never gets a code whose source's terms keep it out of
++ * browser extensions (DealNews' feed, domain/source-policy.js), so these answers vary on Origin.
*/
const express = require('express');
const rateLimit = require('express-rate-limit');
@@@ -31,6 +33,7 @@ const { CAPABILITIES, SCOPES } = require('../auth/capabilities');
const { ApiError, run, jsonBody } = require('./errors');
const { hosts } = require('openvibe-publishing/ingest');
const cache = require('openvibe-shared/cache-policy');
++const { fromExtension } = require('../domain/source-policy');
const MOZ_EXTENSION_RE = /^moz-extension:\/\/[0-9a-f-]{36}$/;
@@@ -86,7 +89,16 @@ function createApi(ctx) {
const publicCache = (res) => res.set('Cache-Control', 'public, max-age=60');
-- async function merchantView(m) {
++ /**
++ * A merchant's active codes as views. A browser extension never gets a code its source keeps out of extensions
++ * (DealNews' feed terms, domain/source-policy.js); the count it sees is of the codes it gets.
++ */
++ async function activeViews(m, req) {
++ const all = await Promise.all((await coupons.active(m.id)).map(async (c) => await coupons.view(c, { merchant: m })));
++ return req && fromExtension(req) ? all.filter((v) => !v.extension_excluded) : all;
++ }
++
++ async function merchantView(m, req = null) {
return {
id: m.id,
slug: m.slug,
@@@ -94,7 +106,7 @@ function createApi(ctx) {
homepage_url: m.homepage_url,
url: publication.merchantUrl(m),
domains: (await merchants.domains(m)).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),
-- active_codes: await coupons.activeCount(m.id),
++ active_codes: req && fromExtension(req) ? (await activeViews(m, req)).length : await coupons.activeCount(m.id),
hints: (await coupons.merchantHints(m.id)).map((h) => h.text),
};
}
@@@ -130,28 +142,32 @@ function createApi(ctx) {
host: found.host,
registrable_domain: found.registrable,
matched_rule: { host: found.rule.host, include_subdomains: Boolean(found.rule.include_subdomains), path_prefix: found.rule.path_prefix || null },
-- merchant: await merchantView(found.merchant),
++ merchant: await merchantView(found.merchant, req),
};
}));
router.get('/merchants/:id/coupons', lookupCors, lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
const m = await activeMerchant(req.params.id);
publicCache(res);
-- return { merchant: await merchantView(m), coupons: await Promise.all((await coupons.active(m.id)).map(async (c) => await coupons.view(c, { merchant: m }))) };
++ return { merchant: await merchantView(m, req), coupons: await activeViews(m, req) };
}));
router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
const m = await activeMerchant(req.params.id);
++ res.vary('Origin');
publicCache(res);
-- return { merchant: await merchantView(m) };
++ return { merchant: await merchantView(m, req) };
}));
router.get('/coupons/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
const c = await coupons.get(req.params.id);
const m = c && await merchants.byId(c.merchant_id);
if (!c || !m || m.status !== 'active' || c.review_state !== 'published' || c.status === 'disabled') throw new ApiError(404, 'coupon.not_found', 'no such code');
++ const v = await coupons.view(c, { merchant: m });
++ res.vary('Origin');
++ if (v.extension_excluded && fromExtension(req)) throw new ApiError(404, 'coupon.not_found', 'no such code');
publicCache(res);
-- return { coupon: await coupons.view(c, { merchant: m }), merchant: await merchantView(m) };
++ return { coupon: v, merchant: await merchantView(m, req) };
}));
// ── Reports ─────────────────────────────────────────────
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 7d6bcac..cd174ee 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -27,6 +27,7 @@ const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-publishing/seo');
const { renderPage } = require('../render/layout');
const pages = require('../render/pages');
++const home = require('../render/home');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { ApiError } = require('./errors');
const confidence = require('../domain/confidence');
@@@ -127,41 +128,63 @@ 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: 'Get the browser extension', href: '/companion' }, { 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.' },
-- { icon: 'ov:plug', title: 'Tried for you, no referral games', text: 'The OpenVibe Companion extension tries the codes in the shop\'s coupon box when you ask, and never rewrites referral links.', href: '/companion' },
++ // The front page (render/home.js). Its cards: codes people reported working, then what ends soon, then the newest;
++ // a code shows once.
++ const DAY = 24 * 3600 * 1000;
++ const HOME_CARDS = 8;
++ async function homePage(req, res) {
++ const now = store.now();
++ const [stats, workingRows, endingRows, recentRows, shops] = await Promise.all([
++ coupons.homeStats({ endingWithinMs: 7 * DAY, addedSinceMs: 7 * DAY, reportsSinceMs: confidence.WINDOW_DAYS * DAY }),
++ coupons.reportedWorking(HOME_CARDS), coupons.endingSoon(7 * DAY, HOME_CARDS), coupons.recentActive(HOME_CARDS * 3),
++ merchants.listActive({ limit: 500 }),
++ ]);
++ const byId = new Map(shops.map((m) => [m.id, m]));
++ const shown = new Set();
++ const take = async (rows) => {
++ const keep = [];
++ for (const c of rows) {
++ if (keep.length >= HOME_CARDS || shown.has(c.id) || !byId.has(c.merchant_id)) continue;
++ shown.add(c.id);
++ keep.push(c);
++ }
++ return await Promise.all(keep.map(async (c) => ({ view: await coupons.view(c, { merchant: byId.get(c.merchant_id) }), merchant: byId.get(c.merchant_id) })));
++ };
++ const working = await take(workingRows);
++ const ending = await take(endingRows);
++ const recent = await take(recentRows);
++ const counts = await coupons.activeCounts(shops.map((m) => m.id));
++ const list = shops.map((m) => ({ merchant: m, active: counts.get(m.id) || 0 }));
++ const faq = home.faqItems({ windowDays: confidence.WINDOW_DAYS });
++ const listed = [...working, ...ending, ...recent];
++ send(req, res, 200, {
++ title: 'Coupon codes with end dates and reports',
++ description: 'Promo codes for online shops, each with the deal it is for, its stated end date and what people reported when they tried it. Never called working without reports.',
++ decision: pageDecision('/'), canonical: `${config.baseUrl}/`,
++ // The service's own one-line summary: the shell turns it into the ai-summary meta and the WebPage JSON-LD.
++ summary: pages.SITE_SUMMARY, styles: [showcase.STYLESHEET], bodyClass: 'home',
++ 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)' }],
++ jsonLd: [
++ { '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Coupons', url: `${config.baseUrl}/`, potentialAction: { '@type': 'SearchAction', target: `${config.baseUrl}/?q={q}`, 'query-input': 'required name=q' } },
++ ...(listed.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Coupon codes on OpenVibe.Coupons', itemListElement: listed.map((x, i) => ({ '@type': 'ListItem', position: i + 1, url: x.view.url, name: `${x.view.code}: ${x.view.title} at ${x.merchant.name}` })) }] : []),
++ home.faqJsonLd(faq),
],
-- });
++ body: home.homePage({ now, stats, working, ending, recent, merchants: list, total: list.length, faq, windowDays: confidence.WINDOW_DAYS }),
++ }, { cacheable: true });
}
router.get('/', async (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 60) : '';
++ if (!q) return await homePage(req, res);
++ // A shop search: its matches, then the newest codes and every shop (noindex).
const recent = await Promise.all((await coupons.recentActive(12)).map(async (c) => ({ coupon: c, merchant: await merchants.byId(c.merchant_id) })));
const shops = await merchants.listActive({ limit: 500 });
const counts = await coupons.activeCounts(shops.map((m) => m.id));
const list = shops.map((m) => ({ merchant: m, active: counts.get(m.id) || 0 }));
-- const decision = q ? pageDecision('/', { indexable: false }) : pageDecision('/');
send(req, res, 200, {
-- title: q ? `Search: ${q}` : null, decision, canonical: `${config.baseUrl}/`,
-- // 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] }),
++ title: `Search: ${q}`, decision: pageDecision('/', { indexable: false }), canonical: `${config.baseUrl}/`,
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: (q ? '' : couponsShowcase()) + pages.home({ merchants: list, recent, q, results: q ? await merchants.search(q) : [], total: list.length, showcase: !q }),
++ body: pages.home({ merchants: list, recent, q, results: await merchants.search(q), total: list.length, showcase: false }),
}, { cacheable: true });
});
ddiff --git a/server/render/home.js b/server/render/home.js
nnew file mode 100644
iindex 0000000..ab9b168
---- /dev/null
++++ b/server/render/home.js
@@@ -0,0 +1,126 @@
++'use strict';
++
++/**
++ * The front page (/): what OpenVibe.Coupons is for and a shop search, the site's numbers, then codes that end soon,
++ * new codes, codes people reported working, the shops, the Companion, how codes are labelled and the questions people
++ * ask. Every section is built from rows the page passes; one with nothing to show renders nothing.
++ *
++ * Wording rules (as in pages.js): a code is called working only from people's recent reports (its status label); an
++ * expiry is shown only when someone stated it; a source's required attribution ("via DealNews") is on its codes.
++ */
++const ssr = require('openvibe-publishing/ssr');
++const showcase = require('openvibe-shared/showcase');
++const { STATUS_LABEL, shopsSection } = require('./pages');
++
++const { html: h, raw } = ssr;
++const count = (n) => Number(n).toLocaleString('en-US');
++
++/** "in 5 hours" / "in 3 days" until a stated end. */
++function until(iso, now) {
++ const hrs = Math.max(0, (Date.parse(iso) - now) / 3600000);
++ if (hrs < 1) return 'within the hour';
++ if (hrs < 36) return `in ${Math.round(hrs)} hour${Math.round(hrs) === 1 ? '' : 's'}`;
++ const d = Math.round(hrs / 24);
++ return `in ${d} day${d === 1 ? '' : 's'}`;
++}
++
++/** One code as a ticket: the shop, the code to copy, what it is for, its end and its first restriction. */
++function codeCard(v, m, now) {
++ const min = (v.restrictions || []).find((r) => r.kind === 'min_spend');
++ const rules = [min ? `Minimum spend ${min.amount}` : null, (v.restrictions || []).some((r) => r.kind === 'new_customers_only') ? 'New customers only' : null].filter(Boolean);
++ return h`<li class="cp-card">
++<p class="cp-top"><a class="cp-shop" href="/m/${m.slug}">${m.name}</a> <span class="status status-${v.status}">${STATUS_LABEL[v.status] || v.status}</span></p>
++<p class="cp-code"><span class="sr-only">Code: </span><code class="code">${v.code}</code></p>
++<h3><a href="/c/${v.id}">${v.title}</a></h3>
++<p class="meta">${v.expiry.known ? h`Ends <time datetime="${v.expiry.expires_at}" title="${v.expiry.expires_at.slice(0, 16).replace('T', ' ')} UTC">${until(v.expiry.expires_at, now)}</time>` : h`<span class="unknown">No end date stated</span>`}${rules.length ? h` · ${rules.join(' · ')}` : ''}</p>
++${v.attribution ? h`<p class="meta cp-via">via <a href="${v.attribution.url}" rel="nofollow noopener">${v.attribution.text}</a></p>` : ''}
++</li>`;
++}
++
++function section({ id, title, lede, more, body }) {
++ if (!body) return '';
++ return h`<section class="cp-sec" aria-labelledby="${id}">
++<div class="cp-head"><h2 id="${id}">${title}</h2>${more ? h`<a class="cp-more" href="${more.href}">${more.label}<span aria-hidden="true"> →</span></a>` : ''}</div>
++${lede ? h`<p class="cp-lede">${lede}</p>` : ''}
++${raw(String(body))}
++</section>`;
++}
++
++const cards = (list, now) => (list.length ? h`<ol class="cp-cards">${list.map((x) => codeCard(x.view, x.merchant, now))}</ol>` : '');
++
++/** The numbers beside the hero, each a link; zero counts are left out. */
++function stats(items) {
++ const shown = items.filter((x) => x.n > 0);
++ if (!shown.length) return '';
++ return h`<ul class="cp-stats" aria-label="OpenVibe.Coupons in numbers">${shown.map((x) => h`<li><a href="${x.href}"><b>${count(x.n)}</b><span>${x.label}</span></a></li>`)}</ul>`;
++}
++
++/** Questions people ask, answered from how the site works (also the page's FAQPage JSON-LD). */
++function faqItems({ windowDays }) {
++ return [
++ { q: 'Do these codes work?', a: `Nobody can promise that, so OpenVibe.Coupons says what is known. A code is marked "Reported working" only when people who tried it in the last ${windowDays} days said it worked, and "Reported not working" when they said it did not. Every other code says "Validity unknown".` },
++ { q: 'Where do the codes come from?', a: 'From people who submit a code with a link to where it was published, and from DealNews, whose deal feed names a code, the shop and often an end date. OpenVibe.Sources reads that feed every hour. Each code page links to its evidence.' },
++ { q: 'How do I use a code?', a: 'Copy the code, open the shop, and paste it into the coupon or promo box at checkout. The code page lists any minimum spend or other rule that was stated, and how to apply it when someone wrote that down.' },
++ { q: 'What does "No end date stated" mean?', a: 'Nobody stated when the code ends, so no date is shown. OpenVibe.Coupons never guesses one. A code with a stated end leaves the active list the moment it passes.' },
++ { q: 'Does the browser extension change referral links?', a: 'No. The OpenVibe Companion never rewrites links, never adds referral tags and never earns from your shopping, so the creator or site that sent you keeps its credit. Codes from DealNews stay on this site, because DealNews\' terms keep them out of browser extensions.' },
++ { q: 'Can I add a code?', a: 'Yes. Sign in with your free OpenVibe account and submit it with a link to where it was published. It starts as "Validity unknown" until people report on it.' },
++ ];
++}
++
++function faqJsonLd(items) {
++ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: items.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) };
++}
++
++/**
++ * d: { now, stats: { active, shops, dated, ending, added, working, reports }, ending, recent, working: [{ view, merchant }],
++ * merchants: [{ merchant, active }], total, faq, windowDays }
++ */
++function homePage(d) {
++ const s = d.stats;
++ const hero = h`<section class="sc-hero sc-split cp-hero"><div class="sc-hero-main">
++<p class="sc-eyebrow">OpenVibe.Coupons</p>
++<h1>Coupon codes,<span class="sc-accent"> honestly labelled</span></h1>
++<p class="sc-hero-lede">Codes for online shops, each with the deal it is for, its end date when one is stated and what people reported when they tried it. A code is called working only when people said so recently.</p>
++<form method="get" action="/" class="search cp-search" role="search"><label class="sr-only" for="q">Find a shop by name or domain</label><input id="q" name="q" type="search" maxlength="60" placeholder="Find a shop, like amazon.com"><button type="submit" class="primary">Search</button></form>
++<div class="sc-actions"><a class="sc-btn sc-primary" href="#cp-new">See the newest codes</a><a class="sc-btn" href="/companion">Get the extension</a><a class="sc-btn" href="/submit">Submit a code</a></div>
++</div>
++<div class="sc-hero-aside">${stats([
++ { n: s.active, label: 'active codes', href: '#cp-new' },
++ { n: s.shops, label: 'shops with a code today', href: '#shops-h' },
++ { n: s.ending, label: 'ending in the next 7 days', href: '#cp-ending' },
++ { n: s.added, label: 'added in the last 7 days', href: '#cp-new' },
++ { n: s.dated, label: 'with a stated end date', href: '/about#expiry' },
++ { n: s.reports, label: `reports from people in ${d.windowDays} days`, href: '/about#confidence' },
++ ])}</div></section>`;
++
++ return h`${hero}
++${s.active === 0 ? h`<p class="empty cp-empty">No codes are listed yet. <a href="/submit">Submit the first one</a>.</p>` : ''}
++${section({ id: 'cp-working', title: 'Reported working', lede: `Codes people said worked when they tried them in the last ${d.windowDays} days, the most recent first.`, body: cards(d.working, d.now) })}
++${section({ id: 'cp-ending', title: 'Ending soon', lede: 'Codes whose stated end is in the next seven days, soonest first.', body: cards(d.ending, d.now) })}
++${section({ id: 'cp-new', title: 'New codes', lede: 'The latest codes added, newest first. Copy one and paste it at the shop\'s checkout.', more: { href: '/feed.xml', label: 'Follow new codes (RSS)' }, body: cards(d.recent, d.now) })}
++${raw(String(shopsSection(d.merchants, d.total)))}
++${raw(showcase.features({
++ title: 'The OpenVibe Companion',
++ lede: 'A free, open-source browser extension for Chrome, Edge, Brave and Firefox.',
++ items: [
++ { icon: 'ov:coupons', title: 'Codes where you shop', text: 'On a shop\'s site it lists the codes people submitted for that shop, with their rules and what people reported.', href: '/companion' },
++ { icon: 'ov:check', title: 'Tried when you ask', text: 'At checkout one button types the codes into the shop\'s own coupon box and reads the total before and after.', href: '/companion' },
++ { icon: 'ov:shield', title: 'No referral games', text: 'It never rewrites links or adds referral tags, so whoever sent you keeps the credit.', href: '/companion' },
++ ],
++ }))}
++${raw(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 end date when someone stated one, and "No end date stated" when nobody did.', href: '/about#expiry' },
++ { 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.', href: '/about#statuses' },
++ ],
++ }))}
++<section class="cp-sec" aria-labelledby="cp-faq"><div class="cp-head"><h2 id="cp-faq">Questions and answers</h2></div>
++<div class="cp-faq">${d.faq.map((x) => h`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div></section>
++${raw(showcase.cta({ title: 'Have a code?', text: 'Share it with a link to where it was published. People report whether it works, and everyone sees what they said.', actions: [{ label: 'Submit a code', href: '/submit', primary: true }, { label: 'Find deals on OpenVibe.Deals', href: 'https://openvibe.deals/' }] }))}
++<p class="meta"><a href="/about">How statuses and confidence work</a> · <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a></p>`;
++}
++
++module.exports = { homePage, faqItems, faqJsonLd, codeCard };
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 53fbe6f..2ca6d99 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -21,7 +21,7 @@ const STATUS_LABEL = {
};
// The one-line summary of the service: /llms.txt and /llms-full.txt open with it, and the home page
// hands it to the shell as its AI-readable summary (ai-summary meta and WebPage JSON-LD).
--const SITE_SUMMARY = 'Coupon codes for online shops with merchant/domain matching, restrictions, honest expiry and validity reports by people.';
++const SITE_SUMMARY = 'Coupon codes for online shops with merchant/domain matching, restrictions, honest expiry and validity reports by people; new codes from members and DealNews every hour.';
const BASIS_LABEL = {
evidence: 'as stated on the evidence page',
submitter: 'according to the person who submitted it',
@@@ -101,6 +101,7 @@ ${v.description ? h`<p>${v.description}</p>` : ''}
${restrictionsList(v.restrictions)}
${v.hints.length ? h`<ul class="hints">${v.hints.map((x) => h`<li>How to apply: ${x.text}</li>`)}</ul>` : ''}
${evidenceList(v.evidence)}
++${v.attribution ? h`<p class="meta">Code via <a href="${v.attribution.url}" rel="nofollow noopener">${v.attribution.text}</a></p>` : ''}
${v.origin === 'ai' ? h`<p class="disclosure">Extracted by an AI workflow from the evidence page and reviewed by staff before listing. Its validity comes only from people's reports.</p>` : ''}
${reportForms(v, opts)}
</article>`;
@@@ -324,4 +325,4 @@ function message({ heading, text, action }) {
return h`<h1>${heading}</h1><p>${text}</p>${action ? h`<p><a href="${action.href}">${action.label}</a></p>` : ''}`;
}
--module.exports = { home, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };
++module.exports = { home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex d7f7943..54080bf 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -52,7 +52,9 @@ const count = (html, re) => (html.match(re) || []).length;
const head = home.slice(0, home.indexOf('</head>'));
assert.strictEqual(count(head, /<title>/g), 1);
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.coupons/">'));
-- assert.strictEqual(count(head, /<script type="application\/ld\+json">/g), 1, 'one JSON-LD script on the home page');
++ // WebPage (the summary), WebSite with its search, FAQPage (the questions shown), and ItemList when codes are listed.
++ assert.ok(count(head, /<script type="application\/ld\+json">/g) >= 3, 'the home page\'s JSON-LD');
++ for (const type of ['"@type":"WebPage"', '"@type":"WebSite"', '"@type":"FAQPage"']) assert.ok(head.includes(type), type);
for (const [type, href, title] of [['application/rss+xml', '/feed.xml', 'New codes (RSS)'], ['application/atom+xml', '/atom.xml', 'New codes (Atom)'], ['application/feed+json', '/feed.json', 'New codes (JSON Feed)']]) {
assert.ok(head.includes(`<link rel="alternate" type="${type}" href="${href}" title="${title}">`), `${href} feed link`);
}
ddiff --git a/test/pages-discovery.test.js b/test/pages-discovery.test.js
iindex 4c3ecad..6c03597 100644
---- a/test/pages-discovery.test.js
++++ b/test/pages-discovery.test.js
@@@ -153,7 +153,18 @@ const { boot, check, done } = require('./helpers/boot');
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 body = home.text.slice(home.text.indexOf('<main'));
++ assert.ok(body.indexOf('class="sc-hero') < body.indexOf('Green Grocer'), 'the showcase sits above the shops');
++ // The curated front page: the code itself on a ticket, the numbers, questions with their FAQPage JSON-LD, no filler.
++ assert.ok(body.includes('<code class="code">VEG5</code>'), 'codes on tickets, each showing its code');
++ assert.ok(body.includes('class="cp-stats"') && /<b>1<\/b><span>active codes<\/span>/.test(body), 'the numbers');
++ assert.ok(body.includes('id="cp-working"') && body.indexOf('VEG5') > body.indexOf('id="cp-working"'), 'people reported it worked: it leads, under "Reported working"');
++ assert.strictEqual(body.split('<code class="code">VEG5</code>').length, 2, 'a code shows once');
++ const ld = [...home.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const faq = ld.find((x) => x['@type'] === 'FAQPage');
++ assert.ok(faq && faq.mainEntity.length >= 5 && ld.some((x) => x['@type'] === 'WebSite') && ld.some((x) => x['@type'] === 'ItemList'));
++ for (const q of faq.mainEntity) assert.ok(body.includes(`<summary>${q.name.replace(/"/g, '&quot;')}</summary>`), `the question is on the page: ${q.name}`);
++ assert.doesNotMatch(body, /coming soon/i);
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');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex f9d2227..3d32fb7 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: 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)
++ htmlRawKB: 37, // 33.7 with the curated front page (numbers, code tickets, the Companion, six questions and their JSON-LD; 2026-10-22); 24.1 with the showcase (17.4 before it; fresh database)
++ htmlBrotliKB: 9, // 7.9 with the curated front page; 6.2 (4.5 before the showcase)
jsFiles: 5, // 5 (openvibe-shared 2.9 shell: web-runtime.js)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before the shell's web-runtime.js)
jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (coupons.css + showcase.css, home only)
-- cssRawKB: 19, // 18.1 (14.8 before 2026-10-15's search bar, offers list and shop chips; 4.4 before showcase.css)
-- cssBrotliKB: 4.5, // 3.6 (1.2 before showcase.css)
++ cssRawKB: 24, // 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css)
++ cssBrotliKB: 5.5, // 5.0 with the front page (2026-10-22); 3.6 before (1.2 before showcase.css)
externalFiles: 1, // 0
};
ddiff --git a/test/sources.test.js b/test/sources.test.js
iindex 520a09d..39f1bb0 100644
---- a/test/sources.test.js
++++ b/test/sources.test.js
@@@ -109,6 +109,25 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(hold.detail, /the source names "Nowhere Shop"/);
});
++ await check('DealNews\' feed terms: its codes say "via DealNews" on the site and never reach a browser extension', async () => {
++ const c = await t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'UXBMXG9V'").get();
++ await t.ctx.store.db.prepare("UPDATE coupons SET review_state = 'published' WHERE id = ?").run(c.id);
++ const v = await t.ctx.coupons.view(c);
++ assert.deepStrictEqual([v.attribution, v.extension_excluded], [{ text: 'DealNews', url: 'https://www.dealnews.com/' }, true]);
++ const page = await t.get(`/c/${c.id}`);
++ assert.ok(page.text.includes('Code via <a href="https://www.dealnews.com/" rel="nofollow noopener">DealNews</a>'), 'attributed on its page');
++ assert.ok((await t.get('/')).text.includes('via <a href="https://www.dealnews.com/" rel="nofollow noopener">DealNews</a>'), 'and on the front page');
++ const site = (await t.get(`/api/v1/merchants/${c.merchant_id}/coupons`)).json();
++ assert.ok(site.coupons.some((x) => x.code === 'UXBMXG9V') && site.merchant.active_codes === 1, 'a server or page caller gets it');
++ for (const headers of [{ origin: 'chrome-extension://abcdefghijklmnopabcdefghijklmnop' }, { origin: 'moz-extension://0d3c5e2a-1111-2222-3333-444455556666' }]) {
++ const ext = await t.get(`/api/v1/merchants/${c.merchant_id}/coupons`, { headers });
++ assert.strictEqual(ext.status, 200);
++ assert.ok(!ext.json().coupons.some((x) => x.code === 'UXBMXG9V') && ext.json().merchant.active_codes === 0, `an extension never does (${headers.origin})`);
++ assert.strictEqual((await t.get(`/api/v1/coupons/${c.id}`, { headers })).status, 404);
++ assert.match(ext.headers.get('vary') || '', /Origin/);
++ }
++ });
++
await check('a failed fetch changes nothing and is recorded', async () => {
const before = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get()).n;
const cursor = (await t.ctx.importer.state()).cursor;