openvibers/openvibe.quest Commits
Expose the daily streak aggregate to Network's user card (#270)
Changes
@@@ -178,6 +178,15 @@ function createApi(ctx) {});// ── Someone's badges (public: ids, names and dates only) ──++ // Network applies public-profile and per-field visibility before requesting this private aggregate.++ r.get('/profiles/:subject/streak', limits.reads('quest.badge.read'), async (req, res) => {++ if (req.principal.kind !== 'app' || req.principal.requester !== 'service:network' || req.principal.claims.env === 'sandbox' || !Array.isArray(req.principal.claims.cap) || !req.principal.claims.cap.includes('quest.badge.read')) {++ return problem(req, res, 403, 'quest.network_only', 'Only Network may read the card aggregate.');++ }++ if (!contracts.ids.isSubjectId('user', req.params.subject)) return problem(req, res, 400, 'quest.invalid_subject', 'Use a user subject.');++ res.json({ streak: await ctx.daily.streakOf(req.params.subject) });++ });++r.get('/profiles/:subject/badges', limits.reads('quest.badge.read'), async (req, res) => {const subject = String(req.params.subject || '');if (!SUBJECT_RE.test(subject)) return problem(req, res, 404, 'quest.profile.not_found', 'No such person.');@@@ -193,6 +193,18 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString()]);return subject;}++ // Read-only: unlike progress(), a card must never ensure quests or settle rewards.++ async function streakOf(subject, day = null) {++ day = day || localDay(s.iso(), await zoneOf(subject));++ const rows = await db.many('SELECT day FROM daily_completions WHERE subject=$1 AND day <= $2 ORDER BY day DESC LIMIT 366', [subject, day]);++ const days = new Set(rows.map(r => r.day));++ let streak = 0;++ for (let cursor = Date.parse(day + 'T00:00:00Z') - (days.has(day) ? 0 : 86400000); streak < 365; cursor -= 86400000) {++ if (!days.has(utcDay(cursor))) break;++ streak++;++ }++ return streak;++ }async function progress(subject, day = null, selectedQuest = null) {const savedZone = await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject]);const zone = savedZone || 'UTC';@@@ -215,12 +227,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aif (row && coins.enabled) await coins.settle(row);}const completion = await db.maybe('SELECT completed_at FROM daily_completions WHERE subject=$1 AND day=$2', [subject, day]);-- let streak = 0;-- for (let cursor = Date.parse(day + 'T00:00:00Z') - (completion ? 0 : 86400000); streak < 365; cursor -= 86400000) {-- const d = utcDay(cursor);-- if (!(await db.maybe('SELECT day FROM daily_completions WHERE subject=$1 AND day=$2', [subject, d]))) break;-- streak++;-- }++ const streak = await streakOf(subject, day);const finished_today = Number(await db.value('SELECT count(*) FROM daily_completions WHERE day=$1', [day]));return { ...q, steps, finished_today, progress: steps.filter((x) => x.done).length, completed: !!completion,completed_at: completion && completion.completed_at, streak, time_zone: zone };@@@ -270,6 +277,6 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, avoid due();return () => { if (timer) clearTimeout(timer); };}-- return { ensure, read, publicQuest, progress, today, ensurePersonal, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };++ return { ensure, read, publicQuest, progress, streakOf, today, ensurePersonal, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };}module.exports = { createDaily, BASE_TYPES, KNOWN_TOPICS, validateSelection, rotation, localDay, validZone, utcDay };nnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { serviceAuth } = require('openvibe-contracts');++(async () => {++ const t = await boot({ now: () => Date.parse('2026-11-10T02:00:00Z') });++ const u = t.network.addUser('card');++ const path = `/api/v1/profiles/${u.subject}/streak`;++ const token = (sub, cap = ['quest.badge.read']) => {++ const at = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'service', aud: ['openvibe.quest'], cap,++ iat: at, exp: at + 300, jti: 'card-test' }, t.network.privatePem);++ };++ try {++ await check('streak aggregate is restricted to Network with its read capability', async () => {++ assert.strictEqual((await t.get(path)).status, 403);++ assert.strictEqual((await t.get(path, { as: u })).status, 403);++ assert.strictEqual((await t.get(path, { bearer: token('svc:live') })).status, 403);++ assert.strictEqual((await t.get(path, { bearer: token('svc:network', []) })).status, 403);++ });++ await check('card streak respects local day, yesterday grace and a gap without creating quests or rewards', async () => {++ await t.ctx.daily.setZone(u.subject, 'America/Los_Angeles');++ for (const day of ['2026-11-07', '2026-11-08']) await t.ctx.s.db.exec('INSERT INTO daily_completions(subject,day,completed_at) VALUES($1,$2,$3)', [u.subject, day, day + 'T12:00:00Z']);++ const r = await t.get(path, { bearer: token('svc:network') });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json(), { streak: 2 });++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++ assert.strictEqual(await t.ctx.daily.streakOf(u.subject, '2026-11-11'), 0);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*) FROM quest_completions')), 0);++ const bad = await t.get('/api/v1/profiles/nope/streak', { bearer: token('svc:network') });++ assert.strictEqual(bad.status, 400);++ });++ } finally { await t.close(); }++ done('card-streak');++})().catch(e => { console.error(e); process.exitCode = 1; });
@@ -178,6 +178,15 @@ function createApi(ctx) {
@@ -178,6 +178,15 @@ function createApi(ctx) {
});
});
// ── Someone's badges (public: ids, names and dates only) ──
// ── Someone's badges (public: ids, names and dates only) ──
+ // Network applies public-profile and per-field visibility before requesting this private aggregate.
+ r.get('/profiles/:subject/streak', limits.reads('quest.badge.read'), async (req, res) => {
+ if (req.principal.kind !== 'app' || req.principal.requester !== 'service:network' || req.principal.claims.env === 'sandbox' || !Array.isArray(req.principal.claims.cap) || !req.principal.claims.cap.includes('quest.badge.read')) {
+ return problem(req, res, 403, 'quest.network_only', 'Only Network may read the card aggregate.');
+ }
+ if (!contracts.ids.isSubjectId('user', req.params.subject)) return problem(req, res, 400, 'quest.invalid_subject', 'Use a user subject.');
+ res.json({ streak: await ctx.daily.streakOf(req.params.subject) });
+ });
+
r.get('/profiles/:subject/badges', limits.reads('quest.badge.read'), async (req, res) => {
r.get('/profiles/:subject/badges', limits.reads('quest.badge.read'), async (req, res) => {
const subject = String(req.params.subject || '');
const subject = String(req.params.subject || '');
if (!SUBJECT_RE.test(subject)) return problem(req, res, 404, 'quest.profile.not_found', 'No such person.');
if (!SUBJECT_RE.test(subject)) return problem(req, res, 404, 'quest.profile.not_found', 'No such person.');
@@ -193,6 +193,18 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -193,6 +193,18 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString()]);
ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString()]);
return subject;
return subject;
}
}
+ // Read-only: unlike progress(), a card must never ensure quests or settle rewards.
+ async function streakOf(subject, day = null) {
+ day = day || localDay(s.iso(), await zoneOf(subject));
+ const rows = await db.many('SELECT day FROM daily_completions WHERE subject=$1 AND day <= $2 ORDER BY day DESC LIMIT 366', [subject, day]);
+ const days = new Set(rows.map(r => r.day));
+ let streak = 0;
+ for (let cursor = Date.parse(day + 'T00:00:00Z') - (days.has(day) ? 0 : 86400000); streak < 365; cursor -= 86400000) {
+ if (!days.has(utcDay(cursor))) break;
+ streak++;
+ }
+ return streak;
+ }
async function progress(subject, day = null, selectedQuest = null) {
async function progress(subject, day = null, selectedQuest = null) {
const savedZone = await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject]);
const savedZone = await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject]);
const zone = savedZone || 'UTC';
const zone = savedZone || 'UTC';
@@ -215,12 +227,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -215,12 +227,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
if (row && coins.enabled) await coins.settle(row);
if (row && coins.enabled) await coins.settle(row);
}
}
const completion = await db.maybe('SELECT completed_at FROM daily_completions WHERE subject=$1 AND day=$2', [subject, day]);
const completion = await db.maybe('SELECT completed_at FROM daily_completions WHERE subject=$1 AND day=$2', [subject, day]);
- let streak = 0;
- for (let cursor = Date.parse(day + 'T00:00:00Z') - (completion ? 0 : 86400000); streak < 365; cursor -= 86400000) {
- const d = utcDay(cursor);
- if (!(await db.maybe('SELECT day FROM daily_completions WHERE subject=$1 AND day=$2', [subject, d]))) break;
- streak++;
- }
+ const streak = await streakOf(subject, day);
const finished_today = Number(await db.value('SELECT count(*) FROM daily_completions WHERE day=$1', [day]));
const finished_today = Number(await db.value('SELECT count(*) FROM daily_completions WHERE day=$1', [day]));
return { ...q, steps, finished_today, progress: steps.filter((x) => x.done).length, completed: !!completion,
return { ...q, steps, finished_today, progress: steps.filter((x) => x.done).length, completed: !!completion,
completed_at: completion && completion.completed_at, streak, time_zone: zone };
completed_at: completion && completion.completed_at, streak, time_zone: zone };
@@ -270,6 +277,6 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -270,6 +277,6 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
void due();
void due();
return () => { if (timer) clearTimeout(timer); };
return () => { if (timer) clearTimeout(timer); };
}
}
- return { ensure, read, publicQuest, progress, today, ensurePersonal, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };
+ return { ensure, read, publicQuest, progress, streakOf, today, ensurePersonal, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };
}
}
module.exports = { createDaily, BASE_TYPES, KNOWN_TOPICS, validateSelection, rotation, localDay, validZone, utcDay };
module.exports = { createDaily, BASE_TYPES, KNOWN_TOPICS, validateSelection, rotation, localDay, validZone, utcDay };
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { serviceAuth } = require('openvibe-contracts');
+(async () => {
+ const t = await boot({ now: () => Date.parse('2026-11-10T02:00:00Z') });
+ const u = t.network.addUser('card');
+ const path = `/api/v1/profiles/${u.subject}/streak`;
+ const token = (sub, cap = ['quest.badge.read']) => {
+ const at = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'service', aud: ['openvibe.quest'], cap,
+ iat: at, exp: at + 300, jti: 'card-test' }, t.network.privatePem);
+ };
+ try {
+ await check('streak aggregate is restricted to Network with its read capability', async () => {
+ assert.strictEqual((await t.get(path)).status, 403);
+ assert.strictEqual((await t.get(path, { as: u })).status, 403);
+ assert.strictEqual((await t.get(path, { bearer: token('svc:live') })).status, 403);
+ assert.strictEqual((await t.get(path, { bearer: token('svc:network', []) })).status, 403);
+ });
+ await check('card streak respects local day, yesterday grace and a gap without creating quests or rewards', async () => {
+ await t.ctx.daily.setZone(u.subject, 'America/Los_Angeles');
+ for (const day of ['2026-11-07', '2026-11-08']) await t.ctx.s.db.exec('INSERT INTO daily_completions(subject,day,completed_at) VALUES($1,$2,$3)', [u.subject, day, day + 'T12:00:00Z']);
+ const r = await t.get(path, { bearer: token('svc:network') });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json(), { streak: 2 });
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+ assert.strictEqual(await t.ctx.daily.streakOf(u.subject, '2026-11-11'), 0);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*) FROM quest_completions')), 0);
+ const bad = await t.get('/api/v1/profiles/nope/streak', { bearer: token('svc:network') });
+ assert.strictEqual(bad.status, 400);
+ });
+ } finally { await t.close(); }
+ done('card-streak');
+})().catch(e => { console.error(e); process.exitCode = 1; });