openvibers/openvibe.coupons Commits
Coupons: per-source extension flag from the recorded terms (default no) (#223)
Changes
@@@ -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 };@@@ -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) => {@@@ -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;
@@ -2,18 +2,37 @@
@@ -2,18 +2,37 @@
/**
/**
* Per-source policy for imported codes, keyed by the OpenVibe.Sources `source_key` (as OpenVibe.Deals keeps its own):
* 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 DEALNEWS = Object.freeze({ text: 'DealNews', url: 'https://www.dealnews.com/' });
const POLICIES = Object.freeze({
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);
const policyFor = (key) => (key && Object.prototype.hasOwnProperty.call(POLICIES, key) ? POLICIES[key] : null);
@@ -27,9 +46,22 @@ function attributionOf(sources) {
@@ -27,9 +46,22 @@ function attributionOf(sources) {
return null;
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. */
/** True when any of the code's live sources forbids its content in a browser extension. */
function hidesFromExtension(sources) {
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). */
/** An extension page's origin (Chrome, Edge, Brave, Firefox, Safari). */
@@ -40,4 +72,4 @@ function fromExtension(req) {
@@ -40,4 +72,4 @@ function fromExtension(req) {
return EXTENSION_ORIGIN.test(String(req.get('origin') || '')) || Boolean(req.viewer && req.viewer.kind === 'install');
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 };
@@ -103,12 +103,18 @@ function createApi(ctx) {
@@ -103,12 +103,18 @@ function createApi(ctx) {
const publicCache = (res) => res.set('Cache-Control', 'public, max-age=60');
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 })));
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) {
async function merchantView(m, req = null) {
@@ -162,7 +168,8 @@ function createApi(ctx) {
@@ -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) => {
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);
const m = await activeMerchant(req.params.id);
publicCache(res);
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) => {
router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
@@ -119,16 +119,41 @@ const { boot, check, done } = require('./helpers/boot');
@@ -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');
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();
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.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' }]) {
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 });
const ext = await t.get(`/api/v1/merchants/${c.merchant_id}/coupons`, { headers });
assert.strictEqual(ext.status, 200);
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.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.strictEqual((await t.get(`/api/v1/coupons/${c.id}`, { headers })).status, 404);
assert.match(ext.headers.get('vary') || '', /Origin/);
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');
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 () => {
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 before = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get()).n;
const cursor = (await t.ctx.importer.state()).cursor;
const cursor = (await t.ctx.importer.state()).cursor;