openvibers/openvibe.quest Commits

Resolve guided Quest steps through private opaque links (#275)

OP OpenVibers committed 2 days ago · c9a3f5592d · 10 files, +186 −14

* Resolve guided Quest steps through private opaque links

* Count calendar schedules as guided Quest steps

* Let channel owners choose guided catalogue quests

* Require opt-in before AI writes a personal quest

* Include guided Quest records in account export and deletion

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2e7ac32..22004ac 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/migrations/0013_channel_daily_quests.sql b/migrations/0013_channel_daily_quests.sql
nnew file mode 100644
iindex 0000000..aed8350
---- /dev/null
++++ b/migrations/0013_channel_daily_quests.sql
@@@ -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
++);
ddiff --git a/migrations/0014_daily_ai_choice.sql b/migrations/0014_daily_ai_choice.sql
nnew file mode 100644
iindex 0000000..c581286
---- /dev/null
++++ b/migrations/0014_daily_ai_choice.sql
@@@ -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
++);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex e14a59d..24a67f2 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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.');
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 1ff5680..fdc312e 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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). */
ddiff --git a/server/quests/apply.js b/server/quests/apply.js
iindex 6eb6c02..c337c1d 100644
---- a/server/quests/apply.js
++++ b/server/quests/apply.js
@@@ -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 owner
return refUser(event.actor);
case 'run.job.succeeded': // payload.requester (identity.subject-ref@1: a user, service, app or mod)
return refUser(p.requester);
ddiff --git a/server/quests/catalog.js b/server/quests/catalog.js
iindex b218a23..dce2c7f 100644
---- a/server/quests/catalog.js
++++ b/server/quests/catalog.js
@@@ -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
ddiff --git a/server/quests/daily.js b/server/quests/daily.js
iindex 246f08b..4fd5543 100644
---- a/server/quests/daily.js
++++ b/server/quests/daily.js
@@@ -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, a
const 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, a
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()]);
++ [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, a
async 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, a
void 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 };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 5859899..275d7f3 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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(`SELE
assert.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_quest', 'with this service\'s own token');
assert.strictEqual(part.body.subject, A);
-- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['badges.json', 'completions.json', '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(`SELE
try {
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(`SELE
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}`);
}
++ 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)));
ddiff --git a/test/daily.test.js b/test/daily.test.js
iindex d8d9a89..61890c2 100644
---- a/test/daily.test.js
++++ b/test/daily.test.js
@@@ -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); });