openvibers/openvibe.quest Commits
Add private Quest today reader (#261)
Changes
@@@ -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@@@ -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-09nnew file mode 100644@@@ -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)++);@@@ -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,@@@ -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.');@@@ -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). */@@@ -77,6 +77,10 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aconst 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, aif (!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, aON 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, areturn { ...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, atry {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, avoid 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 };@@@ -78,6 +78,10 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELEtry {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(`SELEassert.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}`);@@@ -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));
@@ -15,6 +15,8 @@ VALKEY_PREFIX=ov:quest:
@@ -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.
# 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_MINUTE=120
#QUEST_LIMITS_HOUR=3000
#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.
# OpenVibe.Network: SSO (OAuth client `quest`, PKCE S256) and its JWKS; app tokens for audience openvibe.quest.
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
@@ -1,5 +1,9 @@
@@ -1,5 +1,9 @@
# Changelog
# 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.
What changed in OpenVibe.Quest, newest first. Each site also publishes its patch notes at /updates.
## 0.3.0 — 2026-10-09
## 0.3.0 — 2026-10-09
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -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)
+);
@@ -19,6 +19,7 @@ function load(env = process.env) {
@@ -19,6 +19,7 @@ function load(env = process.env) {
return {
return {
service: 'quest',
service: 'quest',
+ guidedTodayEnabled: String(env.QUEST_GUIDED_TODAY || '').toLowerCase() === 'on',
port,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
nodeEnv,
nodeEnv,
@@ -46,6 +46,16 @@ function createApi(ctx) {
@@ -46,6 +46,16 @@ function createApi(ctx) {
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
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.use(principal.middleware);
r.get('/ping', limits.reads('quest.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
r.get('/ping', limits.reads('quest.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
@@ -62,6 +72,11 @@ function createApi(ctx) {
@@ -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.');
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)));
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) => {
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.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.');
if (req.principal.viaSession && !require('./principal').sameOrigin(req, config.baseUrl)) return problem(req, res, 403, 'request.cross_site', 'Use the same origin.');
@@ -32,6 +32,7 @@ const TABLES = [
@@ -32,6 +32,7 @@ const TABLES = [
{ table: 'daily_preferences', subject: 'subject', file: 'daily-preferences.json' },
{ table: 'daily_preferences', subject: 'subject', file: 'daily-preferences.json' },
{ table: 'daily_events', subject: 'subject', file: 'daily-events.json' },
{ table: 'daily_events', subject: 'subject', file: 'daily-events.json' },
{ table: 'daily_completions', subject: 'subject', file: 'daily-completions.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). */
/** The account-data handle for Quest's store (server/db.js createStore). */
@@ -77,6 +77,10 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -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]);
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 };
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() {
async function latestChanges() {
try {
try {
const res = await fetchImpl(`${config.networkInternalUrl}/api/v1/changelog?limit=20`, { signal: AbortSignal.timeout(4000) });
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
@@ -91,7 +95,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
if (!token) return null;
if (!token) return null;
const prompt = JSON.stringify({ day, types: types.map(({ id, site, sentence, weight }) => ({ id, site, sentence, weight })),
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(),
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`, {
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' },
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' } }),
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
@@ -133,6 +137,39 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
}).finally(() => pending.delete(day)));
}).finally(() => pending.delete(day)));
return pending.get(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())) {
async function publicQuest(day = utcDay(s.now())) {
const q = day === utcDay(s.now()) ? await ensure(day) : await read(day);
const q = day === utcDay(s.now()) ? await ensure(day) : await read(day);
if (!q) return null;
if (!q) return null;
@@ -156,11 +193,11 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -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()]);
ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString()]);
return subject;
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 savedZone = await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject]);
const zone = savedZone || 'UTC';
const zone = savedZone || 'UTC';
day = day || localDay(s.iso(), zone);
day = day || localDay(s.iso(), zone);
- const q = await ensure(day);
+ const q = selectedQuest || await ensure(day);
if (!q) return null;
if (!q) return null;
const start = new Date(day + 'T00:00:00Z').getTime();
const start = new Date(day + 'T00:00:00Z').getTime();
const events = await db.many(`SELECT event_type,occurred_at FROM daily_events
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
@@ -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,
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 };
}
}
+ async function today(subject) {
+ const quest = await ensurePersonal(subject);
+ return progress(subject, quest.date, quest);
+ }
async function contribute(project, input) {
async function contribute(project, input) {
const { id, event_type, site, sentence, href, weight = 1 } = input || {};
const { id, event_type, site, sentence, href, weight = 1 } = input || {};
if (!ID_RE.test(id || '') || !KNOWN_TOPICS.has(event_type)
if (!ID_RE.test(id || '') || !KNOWN_TOPICS.has(event_type)
@@ -220,6 +261,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -220,6 +261,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
try {
try {
await ensure();
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_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); }
} catch (err) { log.warn('[Daily quest] generation failed:', err.message); }
const now = s.now();
const now = s.now();
const next = Date.parse(utcDay(now + 86400000) + 'T00:00:00Z') + 1000;
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
@@ -228,6 +270,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, 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 };
module.exports = { createDaily, BASE_TYPES, KNOWN_TOPICS, validateSelection, rotation, localDay, validZone, utcDay };
@@ -78,6 +78,10 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
@@ -78,6 +78,10 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
try {
try {
await makeRows(t, A, { questId: 'go-live', badgeId: 'first-broadcast' });
await makeRows(t, A, { questId: 'go-live', badgeId: 'first-broadcast' });
await makeRows(t, B, { questId: 'follow-someone', badgeId: 'first-follow' });
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) } });
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);
assert.strictEqual(res.status, 200);
@@ -87,7 +91,7 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
@@ -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.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_quest', 'with this service\'s own token');
assert.strictEqual(part.auth, 'Bearer tok_quest', 'with this service\'s own token');
assert.strictEqual(part.body.subject, A);
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) {
for (const file of part.body.files) {
assert.strictEqual(file.content.length, 1, `one row for ${file.name}`);
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}`);
assert.strictEqual(file.content[0].subject, A, `only A's rows in ${file.name}`);
@@ -6,11 +6,19 @@ const { ids, serviceAuth } = require('openvibe-contracts');
@@ -6,11 +6,19 @@ const { ids, serviceAuth } = require('openvibe-contracts');
(async () => {
(async () => {
let now = Date.parse('2026-11-08T12:00:00Z');
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.',
steps: ['go-live', 'chat', 'wiki'], title: 'Go somewhere new', story: 'Make the network yours today.',
}) });
}) });
const user = t.network.addUser('daily');
const user = t.network.addUser('daily');
try {
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 () => {
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(['go-live', 'made-up', 'chat'], BASE_TYPES, null), null);
assert.strictEqual(validateSelection(['thread', 'reply', 'comment'], BASE_TYPES, null), null);
assert.strictEqual(validateSelection(['thread', 'reply', 'comment'], BASE_TYPES, null), null);
@@ -46,6 +54,12 @@ const { ids, serviceAuth } = require('openvibe-contracts');
@@ -46,6 +54,12 @@ const { ids, serviceAuth } = require('openvibe-contracts');
assert.strictEqual(mine.json().progress, 3);
assert.strictEqual(mine.json().progress, 3);
assert.strictEqual(mine.json().streak, 1);
assert.strictEqual(mine.json().streak, 1);
assert.strictEqual(mine.json().completed, true);
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');
const publicView = await t.get('/api/v1/daily');
assert.strictEqual(publicView.json().finished_today, 1);
assert.strictEqual(publicView.json().finished_today, 1);
assert.ok(!publicView.text.includes(user.subject));
assert.ok(!publicView.text.includes(user.subject));