openvibers/openvibe.coupons Commits
Reports: the submitter of a code (or of evidence for it) cannot report on it
Changes
@@@ -116,6 +116,8 @@ The sweep records the decay: status history, `coupons.confidence.changed`, and a- Staff and services with `coupons.status.update` can only disable a code, mark it expired, orreturn it to active. Returning it recomputes the status from reports and time.- Reports marked `X-OV-Origin: ai` are refused.++ - A person who submitted a code, or added evidence for it, can't report on it (403++ `report.own_submission`). Otherwise one account could submit a made-up code and mark it working.- AI-extracted codes (`coupons.extract_coupon` output) are held for staff review. They start`unknown` and carry an on-page disclosure.@@@ -5,8 +5,9 @@** Who may report: a signed-in person (site form or a Bearer Network token), an extension install* token with the coupons.report scope (it belongs to the person who connected it), or a service-- * with coupons.report.create acting for a person (X-OV-Subject). Never anonymously, and never as-- * AI output (X-OV-Origin: ai is refused): a model cannot report that a code works.++ * with coupons.report.create acting for a person (X-OV-Subject). Never anonymously, never as++ * AI output (X-OV-Origin: ai is refused): a model cannot report that a code works, and never by++ * a person who submitted the code or evidence for it (403 report.own_submission).** Deduplicated: one row per (reporter, coupon, UTC day). A second report the same day with the* same outcome changes nothing; with the other outcome it replaces that day's row (a correction,@@@ -37,6 +38,7 @@ function createReports({ store, config, coupons, merchants, publication }) {VALUES (@coupon_id, @reporter_key, @channel, @install_id, @day, @outcome, @reason, @now, @now)`),correct: db.prepare('UPDATE coupon_validation_reports SET outcome = @outcome, reason = @reason, channel = @channel, install_id = @install_id, updated_at = @now WHERE id = @id'),since: db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE reporter_key = ? AND created_at > ?'),++ submittedBy: db.prepare('SELECT 1 FROM coupon_sources WHERE coupon_id = ? AND submitted_by = ? LIMIT 1'),stamp: db.prepare(`UPDATE coupons SET last_report_at = @now,last_worked_at = CASE WHEN @outcome = 'worked' THEN @now ELSE last_worked_at END,last_failed_at = CASE WHEN @outcome = 'failed' THEN @now ELSE last_failed_at END@@@ -69,6 +71,11 @@ function createReports({ store, config, coupons, merchants, publication }) {const now = store.now();const isActive = m && m.status === 'active' && c.review_state === 'published' && c.status !== 'expired' && c.status !== 'disabled' && (c.expires_at == null || c.expires_at > now);if (!isActive) throw new ApiError(409, 'coupon.not_active', 'this code is not in active results (expired, disabled or not published)');++ // The submitter is not a reporter: otherwise one account could submit a made-up code and++ // make it "reported working" with its own report.++ if (c.created_by === who.subject || q.submittedBy.get(c.id, who.subject)) {++ throw new ApiError(403, 'report.own_submission', 'you submitted this code; its status comes from other people\'s reports');++ }const key = reporterKey(who.subject);const day = new Date(now).toISOString().slice(0, 10);@@@ -16,7 +16,8 @@ const DAY = 24 * 3600 * 1000;const alice = t.network.addUser('alice');const bob = t.network.addUser('bob');const shop = await t.merchant({ name: 'Paper Lantern', host: 'paperlantern.store' });-- const code = (await t.submit(alice, { host: 'paperlantern.store', code: 'GLOW', title: 'Ten off lamps', evidence_url: 'https://paperlantern.store/sale' })).json().coupon;++ const submitter = t.network.addUser('sam');++ const code = (await t.submit(submitter, { host: 'paperlantern.store', code: 'GLOW', title: 'Ten off lamps', evidence_url: 'https://paperlantern.store/sale' })).json().coupon;await t.get(`/api/v1/coupons/${code.id}/report`, { as: t.network.userToken(alice), json: { outcome: 'worked' } });let aliceToken;@@@ -35,14 +35,17 @@ const { boot, check, done } = require('./helpers/boot');});await check('report and watch with plain forms; the watch list is private', async () => {-- const r = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/m/${shop.slug}` } });++ const own = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/m/${shop.slug}` } });++ assert.strictEqual(own.status, 403, 'the submitter cannot report on her own code');++ const bob = t.network.addUser('bob');++ const r = await t.get(`/c/${codeId}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'worked', back: `/m/${shop.slug}` } });assert.strictEqual(r.status, 303);assert.strictEqual(r.headers.get('location'), `/m/${shop.slug}?done=reported`);-- const after = await t.get(`/m/${shop.slug}?done=reported`, { as: alice });++ const after = await t.get(`/m/${shop.slug}?done=reported`, { as: bob });assert.match(after.text, /your report was recorded/);assert.match(after.text, /Reported working/);assert.match(after.text, /Confidence 67%/);-- const offsite = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: 'https://evil.example-shop.com/' } });++ const offsite = await t.get(`/c/${codeId}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'worked', back: 'https://evil.example-shop.com/' } });assert.strictEqual(offsite.headers.get('location'), `/c/${codeId}?done=report_same`, 'no open redirect');const w = await t.get(`/m/${shop.slug}/watch`, { as: alice, form: { csrf: t.csrf(alice), action: 'watch' } });assert.strictEqual(w.status, 303);@@@ -58,6 +58,22 @@ const DAY = 24 * HOUR;assert.strictEqual(t.events('coupons.report.created').length, 1, 'one report, one event');});++ await check('the submitter (or anyone who added evidence) cannot report on the code: one account cannot make its own code "working"', async () => {++ const fake = await mk('MADEUP50');++ const api = await report(alice, fake.id, { outcome: 'worked' });++ assert.strictEqual(api.status, 403);++ assert.strictEqual(api.json().code, 'report.own_submission');++ const form = await t.get(`/c/${fake.id}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/c/${fake.id}` } });++ assert.strictEqual(form.status, 403);++ const token = await t.connect(alice, { label: 'alice laptop' });++ assert.strictEqual((await report(token, fake.id, { outcome: 'worked' })).status, 403);++ const hank = t.network.addUser('hank');++ assert.strictEqual((await t.submit(hank, { host: 'bluekettle.shop', code: 'MADEUP50', title: 'Code MADEUP50', evidence_url: 'https://bluekettle.shop/deals' })).status, 200, 'duplicate: evidence added');++ assert.strictEqual((await report(hank, fake.id, { outcome: 'worked' })).status, 403, 'evidence submitters are submitters too');++ assert.strictEqual(t.ctx.coupons.get(fake.id).status, 'unknown');++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE coupon_id = ?').get(fake.id).n, 0);++ });++await check('the next day is a new report, but only the latest per person counts', async () => {t.clock.advance(DAY);const r = await report(bob, code.id, { outcome: 'worked' });@@@ -73,7 +89,7 @@ const DAY = 24 * HOUR;});await check('the response and the events never name a reporter; report times are published to the hour', async () => {-- const r = await report(alice, code.id, { outcome: 'worked' });++ const r = await report(t.network.addUser('gina'), code.id, { outcome: 'worked' });const body = r.text;assert.doesNotMatch(body, /usr_[0-9A-Z]{26}/);assert.doesNotMatch(body, /reporter|install_id|cpi_/);
@@ -116,6 +116,8 @@ The sweep records the decay: status history, `coupons.confidence.changed`, and a
@@ -116,6 +116,8 @@ The sweep records the decay: status history, `coupons.confidence.changed`, and a
- Staff and services with `coupons.status.update` can only disable a code, mark it expired, or
- Staff and services with `coupons.status.update` can only disable a code, mark it expired, or
return it to active. Returning it recomputes the status from reports and time.
return it to active. Returning it recomputes the status from reports and time.
- Reports marked `X-OV-Origin: ai` are refused.
- Reports marked `X-OV-Origin: ai` are refused.
+ - A person who submitted a code, or added evidence for it, can't report on it (403
+ `report.own_submission`). Otherwise one account could submit a made-up code and mark it working.
- AI-extracted codes (`coupons.extract_coupon` output) are held for staff review. They start
- AI-extracted codes (`coupons.extract_coupon` output) are held for staff review. They start
`unknown` and carry an on-page disclosure.
`unknown` and carry an on-page disclosure.
@@ -5,8 +5,9 @@
@@ -5,8 +5,9 @@
*
*
* Who may report: a signed-in person (site form or a Bearer Network token), an extension install
* Who may report: a signed-in person (site form or a Bearer Network token), an extension install
* token with the coupons.report scope (it belongs to the person who connected it), or a service
* token with the coupons.report scope (it belongs to the person who connected it), or a service
- * with coupons.report.create acting for a person (X-OV-Subject). Never anonymously, and never as
- * AI output (X-OV-Origin: ai is refused): a model cannot report that a code works.
+ * with coupons.report.create acting for a person (X-OV-Subject). Never anonymously, never as
+ * AI output (X-OV-Origin: ai is refused): a model cannot report that a code works, and never by
+ * a person who submitted the code or evidence for it (403 report.own_submission).
*
*
* Deduplicated: one row per (reporter, coupon, UTC day). A second report the same day with the
* Deduplicated: one row per (reporter, coupon, UTC day). A second report the same day with the
* same outcome changes nothing; with the other outcome it replaces that day's row (a correction,
* same outcome changes nothing; with the other outcome it replaces that day's row (a correction,
@@ -37,6 +38,7 @@ function createReports({ store, config, coupons, merchants, publication }) {
@@ -37,6 +38,7 @@ function createReports({ store, config, coupons, merchants, publication }) {
VALUES (@coupon_id, @reporter_key, @channel, @install_id, @day, @outcome, @reason, @now, @now)`),
VALUES (@coupon_id, @reporter_key, @channel, @install_id, @day, @outcome, @reason, @now, @now)`),
correct: db.prepare('UPDATE coupon_validation_reports SET outcome = @outcome, reason = @reason, channel = @channel, install_id = @install_id, updated_at = @now WHERE id = @id'),
correct: db.prepare('UPDATE coupon_validation_reports SET outcome = @outcome, reason = @reason, channel = @channel, install_id = @install_id, updated_at = @now WHERE id = @id'),
since: db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE reporter_key = ? AND created_at > ?'),
since: db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE reporter_key = ? AND created_at > ?'),
+ submittedBy: db.prepare('SELECT 1 FROM coupon_sources WHERE coupon_id = ? AND submitted_by = ? LIMIT 1'),
stamp: db.prepare(`UPDATE coupons SET last_report_at = @now,
stamp: db.prepare(`UPDATE coupons SET last_report_at = @now,
last_worked_at = CASE WHEN @outcome = 'worked' THEN @now ELSE last_worked_at END,
last_worked_at = CASE WHEN @outcome = 'worked' THEN @now ELSE last_worked_at END,
last_failed_at = CASE WHEN @outcome = 'failed' THEN @now ELSE last_failed_at END
last_failed_at = CASE WHEN @outcome = 'failed' THEN @now ELSE last_failed_at END
@@ -69,6 +71,11 @@ function createReports({ store, config, coupons, merchants, publication }) {
@@ -69,6 +71,11 @@ function createReports({ store, config, coupons, merchants, publication }) {
const now = store.now();
const now = store.now();
const isActive = m && m.status === 'active' && c.review_state === 'published' && c.status !== 'expired' && c.status !== 'disabled' && (c.expires_at == null || c.expires_at > now);
const isActive = m && m.status === 'active' && c.review_state === 'published' && c.status !== 'expired' && c.status !== 'disabled' && (c.expires_at == null || c.expires_at > now);
if (!isActive) throw new ApiError(409, 'coupon.not_active', 'this code is not in active results (expired, disabled or not published)');
if (!isActive) throw new ApiError(409, 'coupon.not_active', 'this code is not in active results (expired, disabled or not published)');
+ // The submitter is not a reporter: otherwise one account could submit a made-up code and
+ // make it "reported working" with its own report.
+ if (c.created_by === who.subject || q.submittedBy.get(c.id, who.subject)) {
+ throw new ApiError(403, 'report.own_submission', 'you submitted this code; its status comes from other people\'s reports');
+ }
const key = reporterKey(who.subject);
const key = reporterKey(who.subject);
const day = new Date(now).toISOString().slice(0, 10);
const day = new Date(now).toISOString().slice(0, 10);
@@ -16,7 +16,8 @@ const DAY = 24 * 3600 * 1000;
@@ -16,7 +16,8 @@ const DAY = 24 * 3600 * 1000;
const alice = t.network.addUser('alice');
const alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
const bob = t.network.addUser('bob');
const shop = await t.merchant({ name: 'Paper Lantern', host: 'paperlantern.store' });
const shop = await t.merchant({ name: 'Paper Lantern', host: 'paperlantern.store' });
- const code = (await t.submit(alice, { host: 'paperlantern.store', code: 'GLOW', title: 'Ten off lamps', evidence_url: 'https://paperlantern.store/sale' })).json().coupon;
+ const submitter = t.network.addUser('sam');
+ const code = (await t.submit(submitter, { host: 'paperlantern.store', code: 'GLOW', title: 'Ten off lamps', evidence_url: 'https://paperlantern.store/sale' })).json().coupon;
await t.get(`/api/v1/coupons/${code.id}/report`, { as: t.network.userToken(alice), json: { outcome: 'worked' } });
await t.get(`/api/v1/coupons/${code.id}/report`, { as: t.network.userToken(alice), json: { outcome: 'worked' } });
let aliceToken;
let aliceToken;
@@ -35,14 +35,17 @@ const { boot, check, done } = require('./helpers/boot');
@@ -35,14 +35,17 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await check('report and watch with plain forms; the watch list is private', async () => {
await check('report and watch with plain forms; the watch list is private', async () => {
- const r = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/m/${shop.slug}` } });
+ const own = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/m/${shop.slug}` } });
+ assert.strictEqual(own.status, 403, 'the submitter cannot report on her own code');
+ const bob = t.network.addUser('bob');
+ const r = await t.get(`/c/${codeId}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'worked', back: `/m/${shop.slug}` } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
assert.strictEqual(r.headers.get('location'), `/m/${shop.slug}?done=reported`);
assert.strictEqual(r.headers.get('location'), `/m/${shop.slug}?done=reported`);
- const after = await t.get(`/m/${shop.slug}?done=reported`, { as: alice });
+ const after = await t.get(`/m/${shop.slug}?done=reported`, { as: bob });
assert.match(after.text, /your report was recorded/);
assert.match(after.text, /your report was recorded/);
assert.match(after.text, /Reported working/);
assert.match(after.text, /Reported working/);
assert.match(after.text, /Confidence 67%/);
assert.match(after.text, /Confidence 67%/);
- const offsite = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: 'https://evil.example-shop.com/' } });
+ const offsite = await t.get(`/c/${codeId}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'worked', back: 'https://evil.example-shop.com/' } });
assert.strictEqual(offsite.headers.get('location'), `/c/${codeId}?done=report_same`, 'no open redirect');
assert.strictEqual(offsite.headers.get('location'), `/c/${codeId}?done=report_same`, 'no open redirect');
const w = await t.get(`/m/${shop.slug}/watch`, { as: alice, form: { csrf: t.csrf(alice), action: 'watch' } });
const w = await t.get(`/m/${shop.slug}/watch`, { as: alice, form: { csrf: t.csrf(alice), action: 'watch' } });
assert.strictEqual(w.status, 303);
assert.strictEqual(w.status, 303);
@@ -58,6 +58,22 @@ const DAY = 24 * HOUR;
@@ -58,6 +58,22 @@ const DAY = 24 * HOUR;
assert.strictEqual(t.events('coupons.report.created').length, 1, 'one report, one event');
assert.strictEqual(t.events('coupons.report.created').length, 1, 'one report, one event');
});
});
+ await check('the submitter (or anyone who added evidence) cannot report on the code: one account cannot make its own code "working"', async () => {
+ const fake = await mk('MADEUP50');
+ const api = await report(alice, fake.id, { outcome: 'worked' });
+ assert.strictEqual(api.status, 403);
+ assert.strictEqual(api.json().code, 'report.own_submission');
+ const form = await t.get(`/c/${fake.id}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/c/${fake.id}` } });
+ assert.strictEqual(form.status, 403);
+ const token = await t.connect(alice, { label: 'alice laptop' });
+ assert.strictEqual((await report(token, fake.id, { outcome: 'worked' })).status, 403);
+ const hank = t.network.addUser('hank');
+ assert.strictEqual((await t.submit(hank, { host: 'bluekettle.shop', code: 'MADEUP50', title: 'Code MADEUP50', evidence_url: 'https://bluekettle.shop/deals' })).status, 200, 'duplicate: evidence added');
+ assert.strictEqual((await report(hank, fake.id, { outcome: 'worked' })).status, 403, 'evidence submitters are submitters too');
+ assert.strictEqual(t.ctx.coupons.get(fake.id).status, 'unknown');
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE coupon_id = ?').get(fake.id).n, 0);
+ });
+
await check('the next day is a new report, but only the latest per person counts', async () => {
await check('the next day is a new report, but only the latest per person counts', async () => {
t.clock.advance(DAY);
t.clock.advance(DAY);
const r = await report(bob, code.id, { outcome: 'worked' });
const r = await report(bob, code.id, { outcome: 'worked' });
@@ -73,7 +89,7 @@ const DAY = 24 * HOUR;
@@ -73,7 +89,7 @@ const DAY = 24 * HOUR;
});
});
await check('the response and the events never name a reporter; report times are published to the hour', async () => {
await check('the response and the events never name a reporter; report times are published to the hour', async () => {
- const r = await report(alice, code.id, { outcome: 'worked' });
+ const r = await report(t.network.addUser('gina'), code.id, { outcome: 'worked' });
const body = r.text;
const body = r.text;
assert.doesNotMatch(body, /usr_[0-9A-Z]{26}/);
assert.doesNotMatch(body, /usr_[0-9A-Z]{26}/);
assert.doesNotMatch(body, /reporter|install_id|cpi_/);
assert.doesNotMatch(body, /reporter|install_id|cpi_/);