openvibers/openvibe.coupons Commits

Coupons: per-source extension flag from the recorded terms (default no) (#223)

OP OpenVibers committed 7 days ago · da600a5424 · 3 files, +79 −15

What may reach the Companion is now an allowlist. A source with no recorded
permission stays out: only staff-coupon-codes (a merchant's own promo page),
member/staff submissions and evidence on the merchant's own domain are served
to a browser extension. DealNews' two sources keep their attribution and stay
out, as its feed terms require.

GET /api/v1/merchants/:id/coupons now also says how many codes were left out
(left_out), so the Companion can tell the person rather than quietly showing
fewer.

Changes

ddiff --git a/server/domain/source-policy.js b/server/domain/source-policy.js
iindex 762ea6d..078cce0 100644
---- a/server/domain/source-policy.js
++++ b/server/domain/source-policy.js
@@@ -2,18 +2,37 @@
/**
* 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.
++ * how a source must be attributed and whether its content may reach a browser extension. A source with no entry may
++ * not reach an extension: the answer is "no" unless the source's recorded terms say yes. Sources' `terms_note` is
++ * prose and is never parsed; the decision and the line it rests on are written here.
*
-- * 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).
++ * Allowed to reach an extension (the Companion):
++ * - `staff-coupon-codes` — "Only codes a merchant published itself, entered with the URL of that page as evidence."
++ * A merchant's own promo page is the merchant's to publish, so its code may be shown anywhere it is shown.
++ * - a submission by a person or staff (`kind` member / staff) — member-submitted codes, the same basis.
++ * - a source row whose evidence is on the merchant's own domain (`merchant_evidence`).
++ *
++ * Not allowed:
++ * - `dealnews-coupon-codes`, `dealnews-daily` — DealNews' feed terms (https://www.dealnews.com/pages/rss.html): the
++ * feeds may be used with attribution to DealNews as text, but "you [may] not use or include the content in any
++ * browser extension made available for public use". Their codes say "via DealNews" wherever they are shown (the
++ * site keeps them) and are left out of every answer to the OpenVibe Companion.
++ * - any source not named above — no recorded permission, so no.
*/
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 }),
++ 'dealnews-coupon-codes': Object.freeze({
++ attribution: DEALNEWS, extension: false,
++ terms: 'DealNews feed terms: never in a browser extension made available for public use',
++ }),
++ 'dealnews-daily': Object.freeze({
++ attribution: DEALNEWS, extension: false,
++ terms: 'DealNews feed terms: never in a browser extension made available for public use',
++ }),
++ 'staff-coupon-codes': Object.freeze({
++ extension: true,
++ terms: 'only codes a merchant published itself, with that page as evidence',
++ }),
});
const policyFor = (key) => (key && Object.prototype.hasOwnProperty.call(POLICIES, key) ? POLICIES[key] : null);
@@@ -27,9 +46,22 @@ function attributionOf(sources) {
return null;
}
++/**
++ * True when this source row's terms allow the code to reach a browser extension. A source with no policy is not
++ * allowed (the default is no).
++ */
++function mayReachExtension(s) {
++ if (!s) return false;
++ if (s.kind === 'member' || s.kind === 'staff') return true;
++ if (Number(s.merchant_evidence) === 1) return true;
++ const p = policyFor(s.source_key);
++ return Boolean(p && p.extension === true);
++}
++
/** 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); });
++ const live = (sources || []).filter(Boolean);
++ return !live.length || !live.every(mayReachExtension);
}
/** An extension page's origin (Chrome, Edge, Brave, Firefox, Safari). */
@@@ -40,4 +72,4 @@ 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 };
++module.exports = { POLICIES, policyFor, attributionOf, mayReachExtension, hidesFromExtension, fromExtension };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 113d576..bddb0d1 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -103,12 +103,18 @@ function createApi(ctx) {
const publicCache = (res) => res.set('Cache-Control', 'public, max-age=60');
/**
-- * 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.
++ * A merchant's active codes as views, and how many were withheld from this caller. A browser extension never gets
++ * a code its source keeps out of extensions (domain/source-policy.js); the count it sees is of the codes it gets,
++ * and `left_out` says how many its source's terms withheld.
*/
-- async function activeViews(m, req) {
++ async function activeViewSet(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;
++ const served = req && fromExtension(req) ? all.filter((v) => !v.extension_excluded) : all;
++ return { served, left_out: all.length - served.length };
++ }
++
++ async function activeViews(m, req) {
++ return (await activeViewSet(m, req)).served;
}
async function merchantView(m, req = null) {
@@@ -162,7 +168,8 @@ function createApi(ctx) {
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, req), coupons: await activeViews(m, req) };
++ const { served, left_out } = await activeViewSet(m, req);
++ return { merchant: await merchantView(m, req), coupons: served, left_out };
}));
router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
ddiff --git a/test/sources.test.js b/test/sources.test.js
iindex a8f1fb0..39276b5 100644
---- a/test/sources.test.js
++++ b/test/sources.test.js
@@@ -119,16 +119,41 @@ const { boot, check, done } = require('./helpers/boot');
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');
++ assert.strictEqual(site.left_out, 0, 'nothing is left out for a page caller');
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(ext.json().left_out, 1, 'and the answer says how many were withheld');
assert.strictEqual((await t.get(`/api/v1/coupons/${c.id}`, { headers })).status, 404);
assert.match(ext.headers.get('vary') || '', /Origin/);
assert.strictEqual(ext.headers.get('cloudflare-cdn-cache-control'), 'no-store', 'the edge never keeps an answer that differs by caller');
}
});
++ await check('the extension flag is per source and defaults to no: a merchant\'s own page is allowed, a source with no recorded permission is withheld', async () => {
++ const lantern = await t.merchant({ name: 'Lantern Supply', host: 'lanternsupply.com' });
++ t.sources.put(item('itm_01K0000000000000000000000R', { canonical_url: 'https://lanternsupply.com/promo', fields: { code: 'LANTERN10' } })); // staff-coupon-codes: the shop's own page
++ t.sources.put(item('itm_01K0000000000000000000000S', { source_key: 'a-coupon-aggregator', canonical_url: 'https://aggregator.example/c/1', fields: { code: 'AGG20', merchant: 'Lantern Supply' } }));
++ const r = await t.ctx.importer.run();
++ assert.strictEqual(r.outcomes.imported, 2, JSON.stringify(r.outcomes));
++ await t.ctx.store.db.prepare("UPDATE coupons SET review_state = 'published' WHERE merchant_id = ?").run(lantern.id);
++ const views = {};
++ for (const code of ['LANTERN10', 'AGG20']) {
++ const c = await t.ctx.store.db.prepare('SELECT * FROM coupons WHERE code = ?').get(code);
++ views[code] = await t.ctx.coupons.view(c);
++ }
++ assert.strictEqual(views.LANTERN10.extension_excluded, false, 'a merchant\'s own promo page may reach the extension');
++ assert.strictEqual(views.AGG20.extension_excluded, true, 'a source with no recorded permission may not (default no)');
++ const site = (await t.get(`/api/v1/merchants/${lantern.id}/coupons`)).json();
++ assert.deepStrictEqual(site.coupons.map((c) => c.code).sort(), ['AGG20', 'LANTERN10'], 'a page caller gets both');
++ assert.strictEqual(site.left_out, 0);
++ const ext = (await t.get(`/api/v1/merchants/${lantern.id}/coupons`, { headers: { origin: 'chrome-extension://abcdefghijklmnopabcdefghijklmnop' } })).json();
++ assert.deepStrictEqual(ext.coupons.map((c) => c.code), ['LANTERN10'], 'an extension gets only the allowed one');
++ assert.strictEqual(ext.left_out, 1, 'and is told how many were left out');
++ assert.strictEqual(ext.merchant.active_codes, 1, 'the count is of what it gets');
++ });
++
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;