openvibers/openvibe.quest Commits
Resolve guided Quest steps through private opaque links (#275)
Changes
@@@ -2,6 +2,9 @@## Unreleased++- Keep personalized AI wording off until the person records an explicit choice and language; choose only guided step kinds for that person.++- Give guided picture, thread and calendar steps opaque per-person links and an authenticated step reader for the host page.++- Let a channel owner save a quest made from guided catalogue steps through a default-off API.- 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.nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- A channel owner can choose guided catalogue steps for their channel.++CREATE TABLE channel_daily_quests (++ subject text COLLATE "C" PRIMARY KEY,++ title text NOT NULL,++ steps_json text NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- AI wording is off until the person explicitly chooses it; keep each change for audit.++ALTER TABLE daily_preferences ADD COLUMN ai_text_enabled boolean NOT NULL DEFAULT false;++ALTER TABLE daily_preferences ADD COLUMN language text NOT NULL DEFAULT 'en';++CREATE TABLE daily_ai_choices (++ id text COLLATE "C" PRIMARY KEY,++ subject text COLLATE "C" NOT NULL,++ enabled boolean NOT NULL,++ language text NOT NULL,++ text_version text NOT NULL,++ chosen_at text COLLATE "C" NOT NULL++);@@@ -77,6 +77,40 @@ function createApi(ctx) {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.get('/me/daily/ai-text', 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.');++ return res.json(await ctx.daily.aiPreference(req.principal.requester.slice(5)));++ });++ r.put('/me/daily/ai-text', 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.');++ if (req.body?.text_version !== 'quest-ai-text-1') return problem(req, res, 422, 'quest.stale_text', 'Review the current AI wording choice.');++ const pref = await ctx.daily.setAiPreference(req.principal.requester.slice(5), req.body?.enabled, req.body?.language);++ if (!pref) return problem(req, res, 422, 'quest.invalid', 'Choose on or off and a language tag.');++ return res.json(pref);++ });++ r.get('/quests/today/steps/:id', 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.');++ const answer = await ctx.daily.resolveStep(req.principal.requester.slice(5), req.params.id);++ if (!answer) return problem(req, res, 404, 'quest.step_not_found', 'No guided step with that id today.');++ return res.json(answer);++ });++ r.put('/me/channel-quest', 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 (!config.guidedTodayEnabled) return problem(req, res, 404, 'quest.guided_today_unavailable', 'Guided daily quests are not enabled.');++ if (req.principal.viaSession && !require('./principal').sameOrigin(req, config.baseUrl)) return problem(req, res, 403, 'request.cross_site', 'Use the same origin.');++ const quest = await ctx.daily.putChannelQuest(req.principal.requester.slice(5), req.body);++ if (!quest) return problem(req, res, 422, 'quest.invalid', 'Choose three guided catalogue steps on different sites and a title under 80 characters.');++ return res.json(quest);++ });++ r.get('/channels/:subject/quest', limits.reads('quest.quests.read'), async (req, res) => {++ if (!config.guidedTodayEnabled) return problem(req, res, 404, 'quest.guided_today_unavailable', 'Guided daily quests are not enabled.');++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.params.subject)) return problem(req, res, 404, 'quest.not_found', 'No channel quest.');++ const quest = await ctx.daily.channelQuest(req.params.subject);++ if (!quest) return problem(req, res, 404, 'quest.not_found', 'No channel quest.');++ return res.json(quest);++ });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.');@@@ -33,6 +33,8 @@ const TABLES = [{ 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' },++ { table: 'channel_daily_quests', subject: 'subject', file: 'channel-daily-quests.json' },++ { table: 'daily_ai_choices', subject: 'subject', file: 'daily-ai-choices.json' },];/** The account-data handle for Quest's store (server/db.js createStore). */@@@ -58,6 +58,7 @@ function personOf(event) {case 'blog.post.published': // the envelope actor: the person who published (a schedule or a service is not one)case 'deals.offer.created': // the envelope actor: the submitter (svc:deals for an imported deal)case 'deals.vote.changed': // the envelope actor: the voter++ case 'today.event.scheduled': // the envelope actor: the calendar ownerreturn refUser(event.actor);case 'run.job.succeeded': // payload.requester (identity.subject-ref@1: a user, service, app or mod)return refUser(p.requester);@@@ -470,6 +470,7 @@ const EVENTS = Object.freeze([{ event_type: 'games.world.published', site: 'openvibe.games', what: 'A player published one of their creator worlds for the first time; the payload names the owner.' },{ event_type: 'mediahub.video.published', site: 'openvibe.video', what: 'A video a person uploaded or recorded is public and ready for the first time; the payload names the owner.' },{ event_type: 'mediahub.picture.uploaded', site: 'openvibe.pics', what: 'A signed-in person uploaded a picture; the payload names the owner (and carries no link).' },++ { event_type: 'today.event.scheduled', site: 'openvibe.today', what: 'A person scheduled a stream or event; the envelope actor names the owner.' },]);// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event@@@ -2,6 +2,7 @@/** One shared daily quest. Events are kept without payloads so local-day progress can be recalculated safely. */const { serviceAuth } = require('openvibe-contracts');++const { randomBytes } = require('node:crypto');const catalog = require('./catalog');const { personOf } = require('./apply');const store = require('./store');@@@ -9,19 +10,20 @@ const store = require('./store');const BASE_TYPES = Object.freeze([['go-live', 'live.stream.started', 'openvibe.live', 'Go live for a stream', 'https://openvibe.live/broadcast', 3],['follow', 'network.follow.created', 'openvibe.network', 'Follow someone new', 'https://openvibe.network/', 1],-- ['thread', 'community.thread.created', 'openvibe.community', 'Start a community thread', 'https://openvibe.community/', 2],++ ['thread', 'community.thread.created', 'openvibe.community', 'Start a community thread', 'https://openvibe.community/forum/general/new', 2, 'community.thread', 'Write a title and message, then press Post thread.'],['reply', 'community.post.created', 'openvibe.community', 'Reply to a community thread', 'https://openvibe.community/', 1],['comment', 'community.comment.created', 'openvibe.community', 'Comment on a post', 'https://openvibe.community/', 1],['chat', 'chat.room.message.created', 'openvibe.chat', 'Send a public chat message', 'https://openvibe.chat/', 1],['wiki', 'wiki.revision.created', 'openvibe.wiki', 'Improve a wiki page', 'https://openvibe.wiki/', 3],['tool', 'tools.job.created', 'openvibe.tools', 'Use a tool', 'https://openvibe.tools/', 2],['video', 'mediahub.video.published', 'openvibe.video', 'Publish a video', 'https://openvibe.video/', 3],-- ['picture', 'mediahub.picture.uploaded', 'openvibe.pics', 'Upload a picture', 'https://openvibe.pics/', 2],++ ['picture', 'mediahub.picture.uploaded', 'openvibe.pics', 'Upload a picture', 'https://openvibe.pics/', 2, 'pics.upload', 'Choose a picture to upload, then finish the upload form.'],++ ['schedule', 'today.event.scheduled', 'openvibe.today', 'Schedule a stream or event', 'https://openvibe.today/schedule', 2, 'today.schedule', 'Choose a time and press Schedule it.'],['vote', 'deals.vote.changed', 'openvibe.deals', 'Vote on a deal', 'https://openvibe.deals/', 1],['blog', 'blog.post.published', 'openvibe.blog', 'Publish a blog post', 'https://openvibe.blog/', 3],['world', 'games.world.published', 'openvibe.games', 'Publish a world', 'https://openvibe.games/', 3],--].map(([id, event_type, site, sentence, href, weight]) => Object.freeze({-- id, event_type, site, sentence, href, weight, count: 1, verification: 'event subject, count within the person\'s local day',++].map(([id, event_type, site, sentence, href, weight, hook, help]) => Object.freeze({++ id, event_type, site, sentence, href, weight, hook, help, count: 1, verification: 'event subject, count within the person\'s local day',status: 'approved', source: 'quest',})));const KNOWN_TOPICS = new Set(catalog.EVENT_TYPES);@@@ -62,9 +64,20 @@ function rotation(day, types, yesterday) {}function wireType(t) {return { id: t.id, event_type: t.event_type, site: t.site, sentence: t.sentence, href: t.href,++ ...(t.hook ? { hook: t.hook, help: t.help } : {}),weight: Number(t.weight), count: 1, verification: 'event subject, count within the person\'s local day',status: t.status, source: t.source || t.project_id };}++function guidedStep(step) {++ const value = wireType(step);++ if (value.hook) {++ value.guide_id = randomBytes(12).toString('base64url');++ const url = new URL(value.href);++ url.searchParams.set('qstep', value.guide_id);++ value.href = url.toString();++ }++ return value;++}function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aiGenerate, log = console }) {const db = s.db;@@@ -90,12 +103,12 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a.map((x) => ({ service: String(x.service || ''), title: String(x.title || x.message || '').slice(0, 140) }));} catch { return []; }}-- async function generateAi(day, types, yesterday) {-- if (aiGenerate) return aiGenerate({ day, types, yesterday, changes: await latestChanges(), preset: 'cheapest' });++ async function generateAi(day, types, yesterday, language = 'en') {++ if (aiGenerate) return aiGenerate({ day, types, yesterday, language, changes: await latestChanges(), preset: 'cheapest' });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 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}}.' });++ task: `Choose exactly three supplied step ids across at least two sites. Never invent a step or name a person. Write in ${language}. Title max 80 characters, story max 180 characters, and one 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' } }),@@@ -142,15 +155,16 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aconst day = localDay(s.iso(), zone);const cached = await readPersonal(subject, day);if (cached) return cached;-- const types = await allTypes();++ const preference = await aiPreference(subject);++ const types = (await allTypes()).filter((step) => step.hook && step.help);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);++ if (preference.ai_text_enabled) try {++ const ai = await generateAi(day, candidates, null, preference.language);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@@@ -167,9 +181,32 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aif (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()]);++ [subject, day, title, story, JSON.stringify(chosen.map(guidedStep)), source, s.iso()]);return readPersonal(subject, day);}++ async function resolveStep(subject, id) {++ if (!/^[A-Za-z0-9_-]{16}$/.test(id || '')) return null;++ const quest = await ensurePersonal(subject);++ const index = quest.steps.findIndex((step) => step.guide_id === id);++ if (index < 0) return null;++ const step = quest.steps[index];++ return { step: { id: step.id, hook: step.hook, help: step.help, event_type: step.event_type },++ next_href: quest.steps[index + 1]?.href || null };++ }++ async function channelQuest(subject) {++ const row = await db.maybe('SELECT title,steps_json,updated_at FROM channel_daily_quests WHERE subject=$1', [subject]);++ return row && { channel_subject: subject, title: row.title, steps: JSON.parse(row.steps_json), updated_at: row.updated_at };++ }++ async function putChannelQuest(subject, input) {++ const title = typeof input?.title === 'string' ? input.title.trim() : '';++ const types = (await allTypes()).filter((step) => step.hook && step.help);++ const steps = validateSelection(input?.steps, types, null);++ if (!title || title.length > 80 || !steps) return null;++ await db.exec(`INSERT INTO channel_daily_quests(subject,title,steps_json,updated_at) VALUES($1,$2,$3,$4)++ ON CONFLICT(subject) DO UPDATE SET title=EXCLUDED.title,steps_json=EXCLUDED.steps_json,updated_at=EXCLUDED.updated_at`,++ [subject, title, JSON.stringify(steps.map(wireType)), s.iso()]);++ return channelQuest(subject);++ }async function publicQuest(day = utcDay(s.now())) {const q = day === utcDay(s.now()) ? await ensure(day) : await read(day);if (!q) return null;@@@ -184,6 +221,20 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aasync function zoneOf(subject) {return (await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject])) || 'UTC';}++ async function aiPreference(subject) {++ const row = await db.maybe('SELECT ai_text_enabled,language FROM daily_preferences WHERE subject=$1', [subject]);++ return { ai_text_enabled: !!row?.ai_text_enabled, language: row?.language || 'en', text_version: 'quest-ai-text-1' };++ }++ async function setAiPreference(subject, enabled, language) {++ if (typeof enabled !== 'boolean' || !/^[a-z]{2}(?:-[A-Z]{2})?$/.test(language || '')) return null;++ const at = s.iso();++ await db.exec(`INSERT INTO daily_preferences(subject,ai_text_enabled,language) VALUES($1,$2,$3)++ ON CONFLICT(subject) DO UPDATE SET ai_text_enabled=EXCLUDED.ai_text_enabled,language=EXCLUDED.language`,++ [subject, enabled, language]);++ await db.exec('INSERT INTO daily_ai_choices(id,subject,enabled,language,text_version,chosen_at) VALUES($1,$2,$3,$4,$5,$6)',++ [s.newId('qac'), subject, enabled, language, 'quest-ai-text-1', at]);++ return aiPreference(subject);++ }async function recordEvent(tx, event) {const subject = personOf(event);if (!subject || !KNOWN_TOPICS.has(event.event_type)) return null;@@@ -277,6 +328,6 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, avoid due();return () => { if (timer) clearTimeout(timer); };}-- return { ensure, read, publicQuest, progress, streakOf, today, ensurePersonal, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };++ return { ensure, read, publicQuest, progress, streakOf, today, ensurePersonal, resolveStep, channelQuest, putChannelQuest, aiPreference, setAiPreference, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };}module.exports = { createDaily, BASE_TYPES, KNOWN_TOPICS, validateSelection, rotation, localDay, validZone, utcDay };@@@ -82,6 +82,12 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE[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']);++ for (const subject of [A, B]) {++ await t.ctx.s.db.exec('INSERT INTO channel_daily_quests(subject,title,steps_json,updated_at) VALUES($1,$2,$3,$4)',++ [subject, 'Channel quest', '[]', '2026-11-08T00:00:00Z']);++ await t.ctx.s.db.exec('INSERT INTO daily_ai_choices(id,subject,enabled,language,text_version,chosen_at) VALUES($1,$2,$3,$4,$5,$6)',++ [t.ctx.s.newId('qac'), subject, true, 'en', 'quest-ai-text-1', '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);@@@ -91,7 +97,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', 'daily-personal-quests.json', 'progress.json']);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['badges.json', 'channel-daily-quests.json', 'completions.json', 'daily-ai-choices.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}`);@@@ -115,6 +121,12 @@ 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' });++ for (const subject of [A, B]) {++ await t.ctx.s.db.exec('INSERT INTO channel_daily_quests(subject,title,steps_json,updated_at) VALUES($1,$2,$3,$4)',++ [subject, 'Channel quest', '[]', '2026-11-08T00:00:00Z']);++ await t.ctx.s.db.exec('INSERT INTO daily_ai_choices(id,subject,enabled,language,text_version,chosen_at) VALUES($1,$2,$3,$4,$5,$6)',++ [t.ctx.s.newId('qac'), subject, true, 'en', 'quest-ai-text-1', '2026-11-08T00:00:00Z']);++ }const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });assert.strictEqual(res.status, 200);@@@ -123,12 +135,16 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELEassert.strictEqual(await rowsFor(t, table, A), 0, `the person's rows are gone from ${table}`);assert.strictEqual(await rowsFor(t, table, B), 1, `someone else's row stays in ${table}`);}++ for (const table of ['channel_daily_quests', 'daily_ai_choices']) {++ assert.strictEqual(await rowsFor(t, table, A), 0, `the person's rows are gone from ${table}`);++ assert.strictEqual(await rowsFor(t, table, B), 1, `someone else's row stays in ${table}`);++ }// A bare network.account.deleted is never claimed by the quest inbox.assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*)::int FROM quest_event_inbox WHERE event_id = $1', [deleteEvent.event_id])), 0, 'the deletion is not claimed by quest_event_inbox');const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);assert.ok(confirmation, 'the confirmation was sent');-- assert.deepStrictEqual(confirmation.body.erased, { quest_progress: 1, quest_completions: 1, quest_badges: 1 });++ assert.deepStrictEqual(confirmation.body.erased, { quest_progress: 1, quest_completions: 1, quest_badges: 1, channel_daily_quests: 1, daily_ai_choices: 1 });assert.deepStrictEqual(confirmation.body.retained, {});assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));@@@ -117,6 +117,50 @@ const { ids, serviceAuth } = require('openvibe-contracts');assert.deepStrictEqual(rows.map(r => [Number(r.coins), r.coins_state]), [[5, 'credited']]);} finally { await c.close(); }});++ await check('opaque guide links resolve only for their signed-in owner', async () => {++ const aiCalls = [];++ const g = await boot({ now: () => Date.parse('2026-11-08T12:00:00Z'), env: { QUEST_GUIDED_TODAY: 'on' },++ aiGenerate: async (input) => { aiCalls.push(input); return ({ steps: ['picture', 'thread', 'schedule'], title: 'Make something', story: 'Three places.',++ lines: { picture: 'Upload a picture', thread: 'Start a thread', schedule: 'Schedule something' } }); } });++ try {++ const owner = g.network.addUser('guide-owner');++ const stranger = g.network.addUser('guide-stranger');++ const aiPath = '/api/v1/me/daily/ai-text';++ assert.strictEqual((await g.get(aiPath, { as: owner })).json().ai_text_enabled, false);++ const choice = await g.get(aiPath, { as: owner, method: 'PUT', headers: { 'sec-fetch-site': 'same-origin' },++ json: { enabled: true, language: 'es', text_version: 'quest-ai-text-1' } });++ assert.strictEqual(choice.status, 200, choice.text);++ const today = await g.get('/api/v1/quests/today', { as: owner });++ assert.strictEqual(today.status, 200);++ assert.strictEqual(today.json().source, 'ai');++ assert.ok(aiCalls.some((call) => call.language === 'es'));++ const beforeUnsignedChoice = aiCalls.length;++ assert.strictEqual((await g.get('/api/v1/quests/today', { as: stranger })).json().source, 'rotation');++ assert.strictEqual(aiCalls.length, beforeUnsignedChoice, 'no person data reaches AI before that person opts in');++ const picture = today.json().steps.find((step) => step.id === 'picture');++ assert.match(picture.href, /^https:\/\/openvibe\.pics\/\?qstep=[A-Za-z0-9_-]{16}$/);++ assert.strictEqual(picture.hook, 'pics.upload');++ const id = new URL(picture.href).searchParams.get('qstep');++ const path = `/api/v1/quests/today/steps/${id}`;++ assert.strictEqual((await g.get(path)).status, 401);++ assert.strictEqual((await g.get(path, { as: stranger })).status, 404);++ const resolved = await g.get(path, { as: owner });++ assert.strictEqual(resolved.status, 200);++ assert.strictEqual(resolved.json().step.hook, 'pics.upload');++ assert.ok(resolved.json().next_href === null || resolved.json().next_href.includes('qstep='));++ assert.strictEqual(require('../server/quests/apply').personOf({ event_type: 'today.event.scheduled', actor: { type: 'user', id: owner.subject } }), owner.subject);++ const channelPath = '/api/v1/me/channel-quest';++ const request = { title: 'Make something together', steps: ['picture', 'thread', 'schedule'] };++ assert.strictEqual((await g.get(channelPath, { method: 'PUT', json: request })).status, 401);++ assert.strictEqual((await g.get(channelPath, { as: owner, method: 'PUT', headers: { 'sec-fetch-site': 'same-origin' }, json: { ...request, steps: ['picture', 'thread', 'wiki'] } })).status, 422);++ const made = await g.get(channelPath, { as: owner, method: 'PUT', headers: { 'sec-fetch-site': 'same-origin' }, json: request });++ assert.strictEqual(made.status, 200, made.text);++ assert.deepStrictEqual(made.json().steps.map((step) => step.id), request.steps);++ const publicQuest = await g.get(`/api/v1/channels/${owner.subject}/quest`);++ assert.strictEqual(publicQuest.status, 200);++ assert.strictEqual(publicQuest.json().title, request.title);++ } finally { await g.close(); }++ });} finally { await t.close(); }done();})().catch((err) => { process.stderr.write(String(err.stack || err) + '\n'); process.exit(1); });
@@ -2,6 +2,9 @@
@@ -2,6 +2,9 @@
## Unreleased
## Unreleased
+- Keep personalized AI wording off until the person records an explicit choice and language; choose only guided step kinds for that person.
+- Give guided picture, thread and calendar steps opaque per-person links and an authenticated step reader for the host page.
+- Let a channel owner save a quest made from guided catalogue steps through a default-off API.
- Add the authenticated, network-origin CORS reader `GET /api/v1/quests/today`; keep progress private to its person.
- 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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- A channel owner can choose guided catalogue steps for their channel.
+CREATE TABLE channel_daily_quests (
+ subject text COLLATE "C" PRIMARY KEY,
+ title text NOT NULL,
+ steps_json text NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- AI wording is off until the person explicitly chooses it; keep each change for audit.
+ALTER TABLE daily_preferences ADD COLUMN ai_text_enabled boolean NOT NULL DEFAULT false;
+ALTER TABLE daily_preferences ADD COLUMN language text NOT NULL DEFAULT 'en';
+CREATE TABLE daily_ai_choices (
+ id text COLLATE "C" PRIMARY KEY,
+ subject text COLLATE "C" NOT NULL,
+ enabled boolean NOT NULL,
+ language text NOT NULL,
+ text_version text NOT NULL,
+ chosen_at text COLLATE "C" NOT NULL
+);
@@ -77,6 +77,40 @@ function createApi(ctx) {
@@ -77,6 +77,40 @@ function createApi(ctx) {
if (!config.guidedTodayEnabled) return problem(req, res, 404, 'quest.guided_today_unavailable', 'Guided daily quests are not enabled.');
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)));
return res.json(await ctx.daily.today(req.principal.requester.slice(5)));
});
});
+ r.get('/me/daily/ai-text', 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.');
+ return res.json(await ctx.daily.aiPreference(req.principal.requester.slice(5)));
+ });
+ r.put('/me/daily/ai-text', 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.');
+ if (req.body?.text_version !== 'quest-ai-text-1') return problem(req, res, 422, 'quest.stale_text', 'Review the current AI wording choice.');
+ const pref = await ctx.daily.setAiPreference(req.principal.requester.slice(5), req.body?.enabled, req.body?.language);
+ if (!pref) return problem(req, res, 422, 'quest.invalid', 'Choose on or off and a language tag.');
+ return res.json(pref);
+ });
+ r.get('/quests/today/steps/:id', 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.');
+ const answer = await ctx.daily.resolveStep(req.principal.requester.slice(5), req.params.id);
+ if (!answer) return problem(req, res, 404, 'quest.step_not_found', 'No guided step with that id today.');
+ return res.json(answer);
+ });
+ r.put('/me/channel-quest', 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 (!config.guidedTodayEnabled) return problem(req, res, 404, 'quest.guided_today_unavailable', 'Guided daily quests are not enabled.');
+ if (req.principal.viaSession && !require('./principal').sameOrigin(req, config.baseUrl)) return problem(req, res, 403, 'request.cross_site', 'Use the same origin.');
+ const quest = await ctx.daily.putChannelQuest(req.principal.requester.slice(5), req.body);
+ if (!quest) return problem(req, res, 422, 'quest.invalid', 'Choose three guided catalogue steps on different sites and a title under 80 characters.');
+ return res.json(quest);
+ });
+ r.get('/channels/:subject/quest', limits.reads('quest.quests.read'), async (req, res) => {
+ if (!config.guidedTodayEnabled) return problem(req, res, 404, 'quest.guided_today_unavailable', 'Guided daily quests are not enabled.');
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.params.subject)) return problem(req, res, 404, 'quest.not_found', 'No channel quest.');
+ const quest = await ctx.daily.channelQuest(req.params.subject);
+ if (!quest) return problem(req, res, 404, 'quest.not_found', 'No channel quest.');
+ return res.json(quest);
+ });
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.');
@@ -33,6 +33,8 @@ const TABLES = [
@@ -33,6 +33,8 @@ const TABLES = [
{ 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' },
{ table: 'daily_personal_quests', subject: 'subject', file: 'daily-personal-quests.json' },
+ { table: 'channel_daily_quests', subject: 'subject', file: 'channel-daily-quests.json' },
+ { table: 'daily_ai_choices', subject: 'subject', file: 'daily-ai-choices.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). */
@@ -58,6 +58,7 @@ function personOf(event) {
@@ -58,6 +58,7 @@ function personOf(event) {
case 'blog.post.published': // the envelope actor: the person who published (a schedule or a service is not one)
case 'blog.post.published': // the envelope actor: the person who published (a schedule or a service is not one)
case 'deals.offer.created': // the envelope actor: the submitter (svc:deals for an imported deal)
case 'deals.offer.created': // the envelope actor: the submitter (svc:deals for an imported deal)
case 'deals.vote.changed': // the envelope actor: the voter
case 'deals.vote.changed': // the envelope actor: the voter
+ case 'today.event.scheduled': // the envelope actor: the calendar owner
return refUser(event.actor);
return refUser(event.actor);
case 'run.job.succeeded': // payload.requester (identity.subject-ref@1: a user, service, app or mod)
case 'run.job.succeeded': // payload.requester (identity.subject-ref@1: a user, service, app or mod)
return refUser(p.requester);
return refUser(p.requester);
@@ -470,6 +470,7 @@ const EVENTS = Object.freeze([
@@ -470,6 +470,7 @@ const EVENTS = Object.freeze([
{ event_type: 'games.world.published', site: 'openvibe.games', what: 'A player published one of their creator worlds for the first time; the payload names the owner.' },
{ event_type: 'games.world.published', site: 'openvibe.games', what: 'A player published one of their creator worlds for the first time; the payload names the owner.' },
{ event_type: 'mediahub.video.published', site: 'openvibe.video', what: 'A video a person uploaded or recorded is public and ready for the first time; the payload names the owner.' },
{ event_type: 'mediahub.video.published', site: 'openvibe.video', what: 'A video a person uploaded or recorded is public and ready for the first time; the payload names the owner.' },
{ event_type: 'mediahub.picture.uploaded', site: 'openvibe.pics', what: 'A signed-in person uploaded a picture; the payload names the owner (and carries no link).' },
{ event_type: 'mediahub.picture.uploaded', site: 'openvibe.pics', what: 'A signed-in person uploaded a picture; the payload names the owner (and carries no link).' },
+ { event_type: 'today.event.scheduled', site: 'openvibe.today', what: 'A person scheduled a stream or event; the envelope actor names the owner.' },
]);
]);
// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event
// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
/** One shared daily quest. Events are kept without payloads so local-day progress can be recalculated safely. */
/** One shared daily quest. Events are kept without payloads so local-day progress can be recalculated safely. */
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
+const { randomBytes } = require('node:crypto');
const catalog = require('./catalog');
const catalog = require('./catalog');
const { personOf } = require('./apply');
const { personOf } = require('./apply');
const store = require('./store');
const store = require('./store');
@@ -9,19 +10,20 @@ const store = require('./store');
@@ -9,19 +10,20 @@ const store = require('./store');
const BASE_TYPES = Object.freeze([
const BASE_TYPES = Object.freeze([
['go-live', 'live.stream.started', 'openvibe.live', 'Go live for a stream', 'https://openvibe.live/broadcast', 3],
['go-live', 'live.stream.started', 'openvibe.live', 'Go live for a stream', 'https://openvibe.live/broadcast', 3],
['follow', 'network.follow.created', 'openvibe.network', 'Follow someone new', 'https://openvibe.network/', 1],
['follow', 'network.follow.created', 'openvibe.network', 'Follow someone new', 'https://openvibe.network/', 1],
- ['thread', 'community.thread.created', 'openvibe.community', 'Start a community thread', 'https://openvibe.community/', 2],
+ ['thread', 'community.thread.created', 'openvibe.community', 'Start a community thread', 'https://openvibe.community/forum/general/new', 2, 'community.thread', 'Write a title and message, then press Post thread.'],
['reply', 'community.post.created', 'openvibe.community', 'Reply to a community thread', 'https://openvibe.community/', 1],
['reply', 'community.post.created', 'openvibe.community', 'Reply to a community thread', 'https://openvibe.community/', 1],
['comment', 'community.comment.created', 'openvibe.community', 'Comment on a post', 'https://openvibe.community/', 1],
['comment', 'community.comment.created', 'openvibe.community', 'Comment on a post', 'https://openvibe.community/', 1],
['chat', 'chat.room.message.created', 'openvibe.chat', 'Send a public chat message', 'https://openvibe.chat/', 1],
['chat', 'chat.room.message.created', 'openvibe.chat', 'Send a public chat message', 'https://openvibe.chat/', 1],
['wiki', 'wiki.revision.created', 'openvibe.wiki', 'Improve a wiki page', 'https://openvibe.wiki/', 3],
['wiki', 'wiki.revision.created', 'openvibe.wiki', 'Improve a wiki page', 'https://openvibe.wiki/', 3],
['tool', 'tools.job.created', 'openvibe.tools', 'Use a tool', 'https://openvibe.tools/', 2],
['tool', 'tools.job.created', 'openvibe.tools', 'Use a tool', 'https://openvibe.tools/', 2],
['video', 'mediahub.video.published', 'openvibe.video', 'Publish a video', 'https://openvibe.video/', 3],
['video', 'mediahub.video.published', 'openvibe.video', 'Publish a video', 'https://openvibe.video/', 3],
- ['picture', 'mediahub.picture.uploaded', 'openvibe.pics', 'Upload a picture', 'https://openvibe.pics/', 2],
+ ['picture', 'mediahub.picture.uploaded', 'openvibe.pics', 'Upload a picture', 'https://openvibe.pics/', 2, 'pics.upload', 'Choose a picture to upload, then finish the upload form.'],
+ ['schedule', 'today.event.scheduled', 'openvibe.today', 'Schedule a stream or event', 'https://openvibe.today/schedule', 2, 'today.schedule', 'Choose a time and press Schedule it.'],
['vote', 'deals.vote.changed', 'openvibe.deals', 'Vote on a deal', 'https://openvibe.deals/', 1],
['vote', 'deals.vote.changed', 'openvibe.deals', 'Vote on a deal', 'https://openvibe.deals/', 1],
['blog', 'blog.post.published', 'openvibe.blog', 'Publish a blog post', 'https://openvibe.blog/', 3],
['blog', 'blog.post.published', 'openvibe.blog', 'Publish a blog post', 'https://openvibe.blog/', 3],
['world', 'games.world.published', 'openvibe.games', 'Publish a world', 'https://openvibe.games/', 3],
['world', 'games.world.published', 'openvibe.games', 'Publish a world', 'https://openvibe.games/', 3],
-].map(([id, event_type, site, sentence, href, weight]) => Object.freeze({
- id, event_type, site, sentence, href, weight, count: 1, verification: 'event subject, count within the person\'s local day',
+].map(([id, event_type, site, sentence, href, weight, hook, help]) => Object.freeze({
+ id, event_type, site, sentence, href, weight, hook, help, count: 1, verification: 'event subject, count within the person\'s local day',
status: 'approved', source: 'quest',
status: 'approved', source: 'quest',
})));
})));
const KNOWN_TOPICS = new Set(catalog.EVENT_TYPES);
const KNOWN_TOPICS = new Set(catalog.EVENT_TYPES);
@@ -62,9 +64,20 @@ function rotation(day, types, yesterday) {
@@ -62,9 +64,20 @@ function rotation(day, types, yesterday) {
}
}
function wireType(t) {
function wireType(t) {
return { id: t.id, event_type: t.event_type, site: t.site, sentence: t.sentence, href: t.href,
return { id: t.id, event_type: t.event_type, site: t.site, sentence: t.sentence, href: t.href,
+ ...(t.hook ? { hook: t.hook, help: t.help } : {}),
weight: Number(t.weight), count: 1, verification: 'event subject, count within the person\'s local day',
weight: Number(t.weight), count: 1, verification: 'event subject, count within the person\'s local day',
status: t.status, source: t.source || t.project_id };
status: t.status, source: t.source || t.project_id };
}
}
+function guidedStep(step) {
+ const value = wireType(step);
+ if (value.hook) {
+ value.guide_id = randomBytes(12).toString('base64url');
+ const url = new URL(value.href);
+ url.searchParams.set('qstep', value.guide_id);
+ value.href = url.toString();
+ }
+ return value;
+}
function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aiGenerate, log = console }) {
function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aiGenerate, log = console }) {
const db = s.db;
const db = s.db;
@@ -90,12 +103,12 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -90,12 +103,12 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
.map((x) => ({ service: String(x.service || ''), title: String(x.title || x.message || '').slice(0, 140) }));
.map((x) => ({ service: String(x.service || ''), title: String(x.title || x.message || '').slice(0, 140) }));
} catch { return []; }
} catch { return []; }
}
}
- async function generateAi(day, types, yesterday) {
- if (aiGenerate) return aiGenerate({ day, types, yesterday, changes: await latestChanges(), preset: 'cheapest' });
+ async function generateAi(day, types, yesterday, language = 'en') {
+ if (aiGenerate) return aiGenerate({ day, types, yesterday, language, changes: await latestChanges(), preset: 'cheapest' });
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 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}}.' });
+ task: `Choose exactly three supplied step ids across at least two sites. Never invent a step or name a person. Write in ${language}. Title max 80 characters, story max 180 characters, and one 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' } }),
@@ -142,15 +155,16 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -142,15 +155,16 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
const day = localDay(s.iso(), zone);
const day = localDay(s.iso(), zone);
const cached = await readPersonal(subject, day);
const cached = await readPersonal(subject, day);
if (cached) return cached;
if (cached) return cached;
- const types = await allTypes();
+ const preference = await aiPreference(subject);
+ const types = (await allTypes()).filter((step) => step.hook && step.help);
const since = new Date(s.now() - 30 * 86400000).toISOString();
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 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 done = new Set(history.map((x) => x.event_type));
const fresh = types.filter((x) => !done.has(x.event_type));
const fresh = types.filter((x) => !done.has(x.event_type));
const candidates = fresh.length >= 3 ? fresh : types;
const candidates = fresh.length >= 3 ? fresh : types;
let chosen = null, title = '', story = '', source = 'rotation', lines = null;
let chosen = null, title = '', story = '', source = 'rotation', lines = null;
- try {
- const ai = await generateAi(day, candidates, null);
+ if (preference.ai_text_enabled) try {
+ const ai = await generateAi(day, candidates, null, preference.language);
chosen = ai && validateSelection(ai.steps, candidates, null);
chosen = ai && validateSelection(ai.steps, candidates, null);
if (chosen && typeof ai.title === 'string' && ai.title.trim() && ai.title.length <= 80
if (chosen && typeof ai.title === 'string' && ai.title.trim() && ai.title.length <= 80
&& typeof ai.story === 'string' && ai.story.trim() && ai.story.length <= 180
&& typeof ai.story === 'string' && ai.story.trim() && ai.story.length <= 180
@@ -167,9 +181,32 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -167,9 +181,32 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
if (lines) chosen = chosen.map((step) => ({ ...step, sentence: lines[step.id].trim() }));
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)
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`,
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()]);
+ [subject, day, title, story, JSON.stringify(chosen.map(guidedStep)), source, s.iso()]);
return readPersonal(subject, day);
return readPersonal(subject, day);
}
}
+ async function resolveStep(subject, id) {
+ if (!/^[A-Za-z0-9_-]{16}$/.test(id || '')) return null;
+ const quest = await ensurePersonal(subject);
+ const index = quest.steps.findIndex((step) => step.guide_id === id);
+ if (index < 0) return null;
+ const step = quest.steps[index];
+ return { step: { id: step.id, hook: step.hook, help: step.help, event_type: step.event_type },
+ next_href: quest.steps[index + 1]?.href || null };
+ }
+ async function channelQuest(subject) {
+ const row = await db.maybe('SELECT title,steps_json,updated_at FROM channel_daily_quests WHERE subject=$1', [subject]);
+ return row && { channel_subject: subject, title: row.title, steps: JSON.parse(row.steps_json), updated_at: row.updated_at };
+ }
+ async function putChannelQuest(subject, input) {
+ const title = typeof input?.title === 'string' ? input.title.trim() : '';
+ const types = (await allTypes()).filter((step) => step.hook && step.help);
+ const steps = validateSelection(input?.steps, types, null);
+ if (!title || title.length > 80 || !steps) return null;
+ await db.exec(`INSERT INTO channel_daily_quests(subject,title,steps_json,updated_at) VALUES($1,$2,$3,$4)
+ ON CONFLICT(subject) DO UPDATE SET title=EXCLUDED.title,steps_json=EXCLUDED.steps_json,updated_at=EXCLUDED.updated_at`,
+ [subject, title, JSON.stringify(steps.map(wireType)), s.iso()]);
+ return channelQuest(subject);
+ }
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;
@@ -184,6 +221,20 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -184,6 +221,20 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
async function zoneOf(subject) {
async function zoneOf(subject) {
return (await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject])) || 'UTC';
return (await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject])) || 'UTC';
}
}
+ async function aiPreference(subject) {
+ const row = await db.maybe('SELECT ai_text_enabled,language FROM daily_preferences WHERE subject=$1', [subject]);
+ return { ai_text_enabled: !!row?.ai_text_enabled, language: row?.language || 'en', text_version: 'quest-ai-text-1' };
+ }
+ async function setAiPreference(subject, enabled, language) {
+ if (typeof enabled !== 'boolean' || !/^[a-z]{2}(?:-[A-Z]{2})?$/.test(language || '')) return null;
+ const at = s.iso();
+ await db.exec(`INSERT INTO daily_preferences(subject,ai_text_enabled,language) VALUES($1,$2,$3)
+ ON CONFLICT(subject) DO UPDATE SET ai_text_enabled=EXCLUDED.ai_text_enabled,language=EXCLUDED.language`,
+ [subject, enabled, language]);
+ await db.exec('INSERT INTO daily_ai_choices(id,subject,enabled,language,text_version,chosen_at) VALUES($1,$2,$3,$4,$5,$6)',
+ [s.newId('qac'), subject, enabled, language, 'quest-ai-text-1', at]);
+ return aiPreference(subject);
+ }
async function recordEvent(tx, event) {
async function recordEvent(tx, event) {
const subject = personOf(event);
const subject = personOf(event);
if (!subject || !KNOWN_TOPICS.has(event.event_type)) return null;
if (!subject || !KNOWN_TOPICS.has(event.event_type)) return null;
@@ -277,6 +328,6 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -277,6 +328,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, streakOf, today, ensurePersonal, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };
+ return { ensure, read, publicQuest, progress, streakOf, today, ensurePersonal, resolveStep, channelQuest, putChannelQuest, aiPreference, setAiPreference, 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 };
@@ -82,6 +82,12 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
@@ -82,6 +82,12 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
[A, '2026-11-08', 'Your quest', 'For you', '[]', 'rotation', '2026-11-08T00:00:00Z']);
[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)',
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']);
[B, '2026-11-08', 'Other quest', 'For someone else', '[]', 'rotation', '2026-11-08T00:00:00Z']);
+ for (const subject of [A, B]) {
+ await t.ctx.s.db.exec('INSERT INTO channel_daily_quests(subject,title,steps_json,updated_at) VALUES($1,$2,$3,$4)',
+ [subject, 'Channel quest', '[]', '2026-11-08T00:00:00Z']);
+ await t.ctx.s.db.exec('INSERT INTO daily_ai_choices(id,subject,enabled,language,text_version,chosen_at) VALUES($1,$2,$3,$4,$5,$6)',
+ [t.ctx.s.newId('qac'), subject, true, 'en', 'quest-ai-text-1', '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);
@@ -91,7 +97,7 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
@@ -91,7 +97,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', 'daily-personal-quests.json', 'progress.json']);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['badges.json', 'channel-daily-quests.json', 'completions.json', 'daily-ai-choices.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}`);
@@ -115,6 +121,12 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
@@ -115,6 +121,12 @@ 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' });
+ for (const subject of [A, B]) {
+ await t.ctx.s.db.exec('INSERT INTO channel_daily_quests(subject,title,steps_json,updated_at) VALUES($1,$2,$3,$4)',
+ [subject, 'Channel quest', '[]', '2026-11-08T00:00:00Z']);
+ await t.ctx.s.db.exec('INSERT INTO daily_ai_choices(id,subject,enabled,language,text_version,chosen_at) VALUES($1,$2,$3,$4,$5,$6)',
+ [t.ctx.s.newId('qac'), subject, true, 'en', 'quest-ai-text-1', '2026-11-08T00:00:00Z']);
+ }
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.status, 200);
@@ -123,12 +135,16 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
@@ -123,12 +135,16 @@ const rowsFor = async (t, table, subject) => Number(await t.ctx.s.db.value(`SELE
assert.strictEqual(await rowsFor(t, table, A), 0, `the person's rows are gone from ${table}`);
assert.strictEqual(await rowsFor(t, table, A), 0, `the person's rows are gone from ${table}`);
assert.strictEqual(await rowsFor(t, table, B), 1, `someone else's row stays in ${table}`);
assert.strictEqual(await rowsFor(t, table, B), 1, `someone else's row stays in ${table}`);
}
}
+ for (const table of ['channel_daily_quests', 'daily_ai_choices']) {
+ assert.strictEqual(await rowsFor(t, table, A), 0, `the person's rows are gone from ${table}`);
+ assert.strictEqual(await rowsFor(t, table, B), 1, `someone else's row stays in ${table}`);
+ }
// A bare network.account.deleted is never claimed by the quest inbox.
// A bare network.account.deleted is never claimed by the quest inbox.
assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*)::int FROM quest_event_inbox WHERE event_id = $1', [deleteEvent.event_id])), 0, 'the deletion is not claimed by quest_event_inbox');
assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*)::int FROM quest_event_inbox WHERE event_id = $1', [deleteEvent.event_id])), 0, 'the deletion is not claimed by quest_event_inbox');
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
assert.ok(confirmation, 'the confirmation was sent');
assert.ok(confirmation, 'the confirmation was sent');
- assert.deepStrictEqual(confirmation.body.erased, { quest_progress: 1, quest_completions: 1, quest_badges: 1 });
+ assert.deepStrictEqual(confirmation.body.erased, { quest_progress: 1, quest_completions: 1, quest_badges: 1, channel_daily_quests: 1, daily_ai_choices: 1 });
assert.deepStrictEqual(confirmation.body.retained, {});
assert.deepStrictEqual(confirmation.body.retained, {});
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
@@ -117,6 +117,50 @@ const { ids, serviceAuth } = require('openvibe-contracts');
@@ -117,6 +117,50 @@ const { ids, serviceAuth } = require('openvibe-contracts');
assert.deepStrictEqual(rows.map(r => [Number(r.coins), r.coins_state]), [[5, 'credited']]);
assert.deepStrictEqual(rows.map(r => [Number(r.coins), r.coins_state]), [[5, 'credited']]);
} finally { await c.close(); }
} finally { await c.close(); }
});
});
+ await check('opaque guide links resolve only for their signed-in owner', async () => {
+ const aiCalls = [];
+ const g = await boot({ now: () => Date.parse('2026-11-08T12:00:00Z'), env: { QUEST_GUIDED_TODAY: 'on' },
+ aiGenerate: async (input) => { aiCalls.push(input); return ({ steps: ['picture', 'thread', 'schedule'], title: 'Make something', story: 'Three places.',
+ lines: { picture: 'Upload a picture', thread: 'Start a thread', schedule: 'Schedule something' } }); } });
+ try {
+ const owner = g.network.addUser('guide-owner');
+ const stranger = g.network.addUser('guide-stranger');
+ const aiPath = '/api/v1/me/daily/ai-text';
+ assert.strictEqual((await g.get(aiPath, { as: owner })).json().ai_text_enabled, false);
+ const choice = await g.get(aiPath, { as: owner, method: 'PUT', headers: { 'sec-fetch-site': 'same-origin' },
+ json: { enabled: true, language: 'es', text_version: 'quest-ai-text-1' } });
+ assert.strictEqual(choice.status, 200, choice.text);
+ const today = await g.get('/api/v1/quests/today', { as: owner });
+ assert.strictEqual(today.status, 200);
+ assert.strictEqual(today.json().source, 'ai');
+ assert.ok(aiCalls.some((call) => call.language === 'es'));
+ const beforeUnsignedChoice = aiCalls.length;
+ assert.strictEqual((await g.get('/api/v1/quests/today', { as: stranger })).json().source, 'rotation');
+ assert.strictEqual(aiCalls.length, beforeUnsignedChoice, 'no person data reaches AI before that person opts in');
+ const picture = today.json().steps.find((step) => step.id === 'picture');
+ assert.match(picture.href, /^https:\/\/openvibe\.pics\/\?qstep=[A-Za-z0-9_-]{16}$/);
+ assert.strictEqual(picture.hook, 'pics.upload');
+ const id = new URL(picture.href).searchParams.get('qstep');
+ const path = `/api/v1/quests/today/steps/${id}`;
+ assert.strictEqual((await g.get(path)).status, 401);
+ assert.strictEqual((await g.get(path, { as: stranger })).status, 404);
+ const resolved = await g.get(path, { as: owner });
+ assert.strictEqual(resolved.status, 200);
+ assert.strictEqual(resolved.json().step.hook, 'pics.upload');
+ assert.ok(resolved.json().next_href === null || resolved.json().next_href.includes('qstep='));
+ assert.strictEqual(require('../server/quests/apply').personOf({ event_type: 'today.event.scheduled', actor: { type: 'user', id: owner.subject } }), owner.subject);
+ const channelPath = '/api/v1/me/channel-quest';
+ const request = { title: 'Make something together', steps: ['picture', 'thread', 'schedule'] };
+ assert.strictEqual((await g.get(channelPath, { method: 'PUT', json: request })).status, 401);
+ assert.strictEqual((await g.get(channelPath, { as: owner, method: 'PUT', headers: { 'sec-fetch-site': 'same-origin' }, json: { ...request, steps: ['picture', 'thread', 'wiki'] } })).status, 422);
+ const made = await g.get(channelPath, { as: owner, method: 'PUT', headers: { 'sec-fetch-site': 'same-origin' }, json: request });
+ assert.strictEqual(made.status, 200, made.text);
+ assert.deepStrictEqual(made.json().steps.map((step) => step.id), request.steps);
+ const publicQuest = await g.get(`/api/v1/channels/${owner.subject}/quest`);
+ assert.strictEqual(publicQuest.status, 200);
+ assert.strictEqual(publicQuest.json().title, request.title);
+ } finally { await g.close(); }
+ });
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
})().catch((err) => { process.stderr.write(String(err.stack || err) + '\n'); process.exit(1); });
})().catch((err) => { process.stderr.write(String(err.stack || err) + '\n'); process.exit(1); });