openvibers/openvibe.quest Commits

Add private Quest today reader (#261)

OP OpenVibers committed 3 days ago · 0f94b4c848 · 9 files, +101 −6

* Add private Quest today reader

* Personalize cached daily quest steps

* Export private personalized Quest plans

Changes

ddiff --git a/.env.example b/.env.example
iindex e1bb832..f6f92a4 100644
---- a/.env.example
++++ b/.env.example
@@@ -15,6 +15,8 @@ VALKEY_PREFIX=ov:quest:
# Per-caller limits (server/http/caller-limits.js): requests one caller may make to the API per minute and per hour.
#QUEST_LIMITS_MINUTE=120
#QUEST_LIMITS_HOUR=3000
++# Guided personalized daily quest reader: default off until the owner enables it.
++QUEST_GUIDED_TODAY=off
# OpenVibe.Network: SSO (OAuth client `quest`, PKCE S256) and its JWKS; app tokens for audience openvibe.quest.
OV_NETWORK_URL=https://openvibe.network
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex c6e75a9..2e7ac32 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,9 @@
# Changelog
++## Unreleased
++
++- Add the authenticated, network-origin CORS reader `GET /api/v1/quests/today`; keep progress private to its person.
++
What changed in OpenVibe.Quest, newest first. Each site also publishes its patch notes at /updates.
## 0.3.0 — 2026-10-09
ddiff --git a/migrations/0011_personal_daily_quests.sql b/migrations/0011_personal_daily_quests.sql
nnew file mode 100644
iindex 0000000..fce7e21
---- /dev/null
++++ b/migrations/0011_personal_daily_quests.sql
@@@ -0,0 +1,12 @@
++-- phase: expand
++-- Cache the signed-in person's selected daily quest for that local day.
++CREATE TABLE daily_personal_quests (
++ subject text COLLATE "C" NOT NULL,
++ day text COLLATE "C" NOT NULL,
++ title text NOT NULL,
++ story text NOT NULL,
++ steps_json text NOT NULL,
++ source text NOT NULL CHECK (source IN ('ai', 'rotation')),
++ created_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (subject, day)
++);
ddiff --git a/server/config.js b/server/config.js
iindex 0b02dd0..75cd09b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -19,6 +19,7 @@ function load(env = process.env) {
return {
service: 'quest',
++ guidedTodayEnabled: String(env.QUEST_GUIDED_TODAY || '').toLowerCase() === 'on',
port,
host: env.HOST || '127.0.0.1',
nodeEnv,
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 95c990f..2d7a64f 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -46,6 +46,16 @@ function createApi(ctx) {
r.use(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ const questOrigins = new Set(Object.keys(catalog.SITES).map((host) => `https://${host}`));
++ r.use((req, res, next) => {
++ const origin = req.get('Origin');
++ if (origin && questOrigins.has(origin)) {
++ res.set('Access-Control-Allow-Origin', origin).set('Access-Control-Allow-Credentials', 'true').set('Vary', 'Origin');
++ if (req.method === 'OPTIONS') res.set('Access-Control-Allow-Methods', 'GET, OPTIONS').set('Access-Control-Allow-Headers', 'Authorization, Content-Type').set('Access-Control-Max-Age', '600');
++ }
++ next();
++ });
++ r.options('/quests/today', (_req, res) => res.sendStatus(204));
r.use(principal.middleware);
r.get('/ping', limits.reads('quest.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
@@@ -62,6 +72,11 @@ function createApi(ctx) {
if (req.principal.kind !== 'user') return problem(req, res, 401, 'token.required', 'Send a person\'s Network token.');
return res.json(await ctx.daily.progress(req.principal.requester.slice(5)));
});
++ r.get('/quests/today', limits.reads('quest.me.read'), async (req, res) => {
++ if (req.principal.kind !== 'user') return problem(req, res, 401, 'token.required', 'Send a person\'s Network token.');
++ if (!config.guidedTodayEnabled) return problem(req, res, 404, 'quest.guided_today_unavailable', 'Guided daily quests are not enabled.');
++ return res.json(await ctx.daily.today(req.principal.requester.slice(5)));
++ });
r.put('/me/daily/time-zone', limits.budget('quest.me.write'), async (req, res) => {
if (req.principal.kind !== 'user') return problem(req, res, 401, 'token.required', 'Send a person\'s Network token.');
if (req.principal.viaSession && !require('./principal').sameOrigin(req, config.baseUrl)) return problem(req, res, 403, 'request.cross_site', 'Use the same origin.');
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 5a66961..1ff5680 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -32,6 +32,7 @@ const TABLES = [
{ table: 'daily_preferences', subject: 'subject', file: 'daily-preferences.json' },
{ table: 'daily_events', subject: 'subject', file: 'daily-events.json' },
{ table: 'daily_completions', subject: 'subject', file: 'daily-completions.json' },
++ { table: 'daily_personal_quests', subject: 'subject', file: 'daily-personal-quests.json' },
];
/** The account-data handle for Quest's store (server/db.js createStore). */
ddiff --git a/server/quests/daily.js b/server/quests/daily.js
iindex ce2d150..9f3dade 100644
---- a/server/quests/daily.js
++++ b/server/quests/daily.js
@@@ -77,6 +77,10 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
const row = await db.maybe('SELECT * FROM daily_quests WHERE day = $1', [day]);
return row && { date: row.day, title: row.title, story: row.story, steps: JSON.parse(row.steps_json), source: row.source };
};
++ const readPersonal = async (subject, day) => {
++ const row = await db.maybe('SELECT * FROM daily_personal_quests WHERE subject=$1 AND day=$2', [subject, day]);
++ return row && { date: row.day, title: row.title, story: row.story, steps: JSON.parse(row.steps_json), source: row.source };
++ };
async function latestChanges() {
try {
const res = await fetchImpl(`${config.networkInternalUrl}/api/v1/changelog?limit=20`, { signal: AbortSignal.timeout(4000) });
@@@ -91,7 +95,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
if (!token) return null;
const prompt = JSON.stringify({ day, types: types.map(({ id, site, sentence, weight }) => ({ id, site, sentence, weight })),
yesterday: yesterday ? yesterday.map((s) => s.id) : [], changes: await latestChanges(),
-- task: 'Choose exactly three step ids across at least two sites, avoid yesterday, favor recently changed sites. Write a title and one-line story. Return JSON {steps:[ids],title,story}.' });
++ task: 'Choose exactly three supplied step ids across at least two sites. Never invent a step or name a person. Write an English title (max 80 characters), a one-line story (max 180 characters), and one short actionable line per chosen id (max 120 characters). Return JSON {steps:[ids],title,story,lines:{id:line}}.' });
const res = await fetchImpl(`${process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'}/api/v1/runs?wait=60000`, {
method: 'POST', headers: { ...(await token.authHeaders()), 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: prompt }], response_format: { type: 'json_object' }, max_tokens: 300 }, options: { latency: 'background' } }),
@@@ -133,6 +137,39 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
}).finally(() => pending.delete(day)));
return pending.get(day);
}
++ async function ensurePersonal(subject) {
++ const zone = await zoneOf(subject);
++ const day = localDay(s.iso(), zone);
++ const cached = await readPersonal(subject, day);
++ if (cached) return cached;
++ const types = await allTypes();
++ const since = new Date(s.now() - 30 * 86400000).toISOString();
++ const history = await db.many('SELECT DISTINCT event_type FROM daily_events WHERE subject=$1 AND occurred_at >= $2', [subject, since]);
++ const done = new Set(history.map((x) => x.event_type));
++ const fresh = types.filter((x) => !done.has(x.event_type));
++ const candidates = fresh.length >= 3 ? fresh : types;
++ let chosen = null, title = '', story = '', source = 'rotation', lines = null;
++ try {
++ const ai = await generateAi(day, candidates, null);
++ chosen = ai && validateSelection(ai.steps, candidates, null);
++ if (chosen && typeof ai.title === 'string' && ai.title.trim() && ai.title.length <= 80
++ && typeof ai.story === 'string' && ai.story.trim() && ai.story.length <= 180
++ && ai.lines && chosen.every((step) => typeof ai.lines[step.id] === 'string' && ai.lines[step.id].trim() && ai.lines[step.id].length <= 120)) {
++ title = ai.title.trim(); story = ai.story.trim(); source = 'ai'; lines = ai.lines;
++ } else chosen = null;
++ } catch (err) { log.warn('[Daily quest] Personal AI unavailable:', err.message); }
++ if (!chosen) {
++ try { chosen = rotation(day, candidates, null); }
++ catch { chosen = rotation(day, types, null); }
++ title = 'Make your way around OpenVibe';
++ story = 'Three small things, across the network, to make today yours.';
++ }
++ if (lines) chosen = chosen.map((step) => ({ ...step, sentence: lines[step.id].trim() }));
++ await db.exec(`INSERT INTO daily_personal_quests(subject,day,title,story,steps_json,source,created_at)
++ VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(subject,day) DO NOTHING`,
++ [subject, day, title, story, JSON.stringify(chosen.map(wireType)), source, s.iso()]);
++ return readPersonal(subject, day);
++ }
async function publicQuest(day = utcDay(s.now())) {
const q = day === utcDay(s.now()) ? await ensure(day) : await read(day);
if (!q) return null;
@@@ -156,11 +193,11 @@ 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()]);
return subject;
}
-- async function progress(subject, day = 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 zone = savedZone || 'UTC';
day = day || localDay(s.iso(), zone);
-- const q = await ensure(day);
++ const q = selectedQuest || await ensure(day);
if (!q) return null;
const start = new Date(day + 'T00:00:00Z').getTime();
const events = await db.many(`SELECT event_type,occurred_at FROM daily_events
@@@ -188,6 +225,10 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
return { ...q, steps, finished_today, progress: steps.filter((x) => x.done).length, completed: !!completion,
completed_at: completion && completion.completed_at, streak, time_zone: zone };
}
++ async function today(subject) {
++ const quest = await ensurePersonal(subject);
++ return progress(subject, quest.date, quest);
++ }
async function contribute(project, input) {
const { id, event_type, site, sentence, href, weight = 1 } = input || {};
if (!ID_RE.test(id || '') || !KNOWN_TOPICS.has(event_type)
@@@ -220,6 +261,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
try {
await ensure();
await db.exec('DELETE FROM daily_events WHERE occurred_at < $1', [new Date(s.now() - 3 * 86400000).toISOString()]);
++ await db.exec('DELETE FROM daily_personal_quests WHERE day < $1', [utcDay(s.now() - 2 * 86400000)]);
} catch (err) { log.warn('[Daily quest] generation failed:', err.message); }
const now = s.now();
const next = Date.parse(utcDay(now + 86400000) + 'T00:00:00Z') + 1000;
@@@ -228,6 +270,6 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
void due();
return () => { if (timer) clearTimeout(timer); };
}
-- return { ensure, read, publicQuest, progress, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };
++ return { ensure, read, publicQuest, progress, today, ensurePersonal, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };
}
module.exports = { createDaily, BASE_TYPES, KNOWN_TOPICS, validateSelection, rotation, localDay, validZone, utcDay };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 1ce6491..5859899 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -78,6 +78,10 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
try {
await makeRows(t, A, { questId: 'go-live', badgeId: 'first-broadcast' });
await makeRows(t, B, { questId: 'follow-someone', badgeId: 'first-follow' });
++ await t.ctx.s.db.exec('INSERT INTO daily_personal_quests(subject,day,title,story,steps_json,source,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)',
++ [A, '2026-11-08', 'Your quest', 'For you', '[]', 'rotation', '2026-11-08T00:00:00Z']);
++ await t.ctx.s.db.exec('INSERT INTO daily_personal_quests(subject,day,title,story,steps_json,source,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)',
++ [B, '2026-11-08', 'Other quest', 'For someone else', '[]', 'rotation', '2026-11-08T00:00:00Z']);
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
assert.strictEqual(res.status, 200);
@@@ -87,7 +91,7 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
assert.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_quest', 'with this service\'s own token');
assert.strictEqual(part.body.subject, A);
-- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['badges.json', 'completions.json', 'progress.json']);
++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['badges.json', 'completions.json', 'daily-personal-quests.json', 'progress.json']);
for (const file of part.body.files) {
assert.strictEqual(file.content.length, 1, `one row for ${file.name}`);
assert.strictEqual(file.content[0].subject, A, `only A's rows in ${file.name}`);
ddiff --git a/test/daily.test.js b/test/daily.test.js
iindex 6806d34..d8d9a89 100644
---- a/test/daily.test.js
++++ b/test/daily.test.js
@@@ -6,11 +6,19 @@ const { ids, serviceAuth } = require('openvibe-contracts');
(async () => {
let now = Date.parse('2026-11-08T12:00:00Z');
-- const t = await boot({ now: () => now, aiGenerate: async () => ({
++ const t = await boot({ now: () => now, env: { QUEST_GUIDED_TODAY: 'on' }, aiGenerate: async () => ({
steps: ['go-live', 'chat', 'wiki'], title: 'Go somewhere new', story: 'Make the network yours today.',
}) });
const user = t.network.addUser('daily');
try {
++ await check('today reader is private and permits network-site bearer requests', async () => {
++ assert.strictEqual((await t.get('/api/v1/quests/today')).status, 401);
++ const preflight = await t.get('/api/v1/quests/today', { method: 'OPTIONS', headers: { origin: 'https://openvibe.pics', 'access-control-request-method': 'GET' } });
++ assert.strictEqual(preflight.status, 204);
++ assert.strictEqual(preflight.headers.get('access-control-allow-origin'), 'https://openvibe.pics');
++ const denied = await t.get('/api/v1/quests/today', { method: 'OPTIONS', headers: { origin: 'https://example.com' } });
++ assert.strictEqual(denied.headers.get('access-control-allow-origin'), null);
++ });
await check('generator checks topics and sites, and falls back after a bad answer', async () => {
assert.strictEqual(validateSelection(['go-live', 'made-up', 'chat'], BASE_TYPES, null), null);
assert.strictEqual(validateSelection(['thread', 'reply', 'comment'], BASE_TYPES, null), null);
@@@ -46,6 +54,12 @@ const { ids, serviceAuth } = require('openvibe-contracts');
assert.strictEqual(mine.json().progress, 3);
assert.strictEqual(mine.json().streak, 1);
assert.strictEqual(mine.json().completed, true);
++ const today = await t.get('/api/v1/quests/today', { as: user, headers: { origin: 'https://openvibe.live' } });
++ assert.strictEqual(today.status, 200);
++ assert.strictEqual(today.json().steps.length, 3);
++ assert.ok(!today.json().steps.some((x) => ['live.stream.started', 'chat.room.message.created', 'wiki.revision.created'].includes(x.event_type)), 'the private quest avoids recent actions already in this person\'s history');
++ assert.deepStrictEqual((await t.get('/api/v1/quests/today', { as: user })).json(), today.json(), 'the personalized selection is cached for the day');
++ assert.strictEqual(today.headers.get('access-control-allow-origin'), 'https://openvibe.live');
const publicView = await t.get('/api/v1/daily');
assert.strictEqual(publicView.json().finished_today, 1);
assert.ok(!publicView.text.includes(user.subject));