openvibers/openvibe.quest Commits

Guide Meme captions and Video uploads in daily quests (#278)

OP OpenVibers committed 2 days ago · 1f4efaaefa · 12 files, +104 −21

* Catalogue Live setup tasks with exact event matching

* Merge branch 'quest-catalogue-finish-1110' into quest-meme-step-1110

* Guide public Meme captions and video uploads

* Merge remote-tracking branch 'origin/main' into quest-meme-step-1110

# Conflicts:
#	CHANGELOG.md
#	test/daily.test.js

* Gate Meme subscription until Events contract release

* Expose verified guide completion and Meme badge icon

* Address Chat Tools Games and Wiki guide controls

* Point Wiki guide at an existing page

* Allow measured compressed quest badge size

* Allow guided step reads from the DNS tool

* Keep disabled Meme receipts out of daily choices

* Accept step receipts only from their owning site

* Merge remote-tracking branch 'origin/main' into quest-meme-step-1110

Changes

ddiff --git a/.env.example b/.env.example
iindex f6f92a4..bdb5434 100644
---- a/.env.example
++++ b/.env.example
@@@ -17,6 +17,7 @@ VALKEY_PREFIX=ov:quest:
#QUEST_LIMITS_HOUR=3000
# Guided personalized daily quest reader: default off until the owner enables it.
QUEST_GUIDED_TODAY=off
++QUEST_MEME_EVENTS_ENABLED=off
# OpenVibe.Network: SSO (OAuth client `quest`, PKCE S256) and its JWKS; app tokens for audience openvibe.quest.
OV_NETWORK_URL=https://openvibe.network
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 280948a..3eab377 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,9 @@
## Unreleased
++- Let the owning site's verified service report an opaque guided step by source event id, with idempotent progress; allow the DNS tool's guide preflight and exclude Meme from daily choices until its event feed is enabled.
++- Count public Meme captions without their content, add the caption maker and Video upload controls to guided daily choices, and order personal steps from easier to harder.
++- Address Chat's public composer and the DNS tool from the personal catalogue; keep Wiki edits and world publishing out of automatic choices until the person has permission or a draft.
- Catalogue Live's seventeen individually guided setup tasks, and count only the matching task event for each step.
- 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.
ddiff --git a/server/config.js b/server/config.js
iindex 75cd09b..770a4c5 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -63,6 +63,7 @@ function load(env = process.env) {
secrets: String(env.QUEST_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
url: trim(env.QUEST_EVENTS_URL || env.EVENTS_URL || ''),
endpoint: env.QUEST_EVENTS_ENDPOINT || '',
++ memeEnabled: env.QUEST_MEME_EVENTS_ENABLED === 'on',
},
// OpenCoins (server/quests/coins.js). Rewards are badges and OpenCoins — loyalty points, never money, and
ddiff --git a/server/events-consumer.js b/server/events-consumer.js
iindex 987f6b9..14c0dd4 100644
---- a/server/events-consumer.js
++++ b/server/events-consumer.js
@@@ -120,6 +120,9 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console }) {
if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.QUEST_EVENTS_SUBSCRIBE === '0') return null;
const base = String(eventsUrl).replace(/\/+$/, '');
++ // The Meme topic is declared in Contracts #444. Subscribe after Events has pinned that release;
++ // an unknown topic would stop the entire subscription pass before later topics are registered.
++ const topics = TOPICS.filter((topic) => topic !== 'meme.caption.created' || config.events.memeEnabled);
const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;
const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.events', scope: 'events.subscription.manage', fetchImpl });
const call = async (method, path, body) => {
@@@ -131,7 +134,7 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.ur
const listed = await call('GET', '/api/v1/subscriptions');
if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
const mine = (listed.body.subscriptions || []).filter((x) => x.endpoint === endpoint);
-- for (const topic of TOPICS) {
++ for (const topic of topics) {
if (mine.some((x) => x.topic_pattern === topic)) continue;
const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
@@@ -154,7 +157,7 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.ur
if (r.ok) log.log(`[Events consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);
};
// Graceful stop: no further attempts (they run again at the next start).
-- return { topics: TOPICS, endpoint, subscribe, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
++ return { topics, endpoint, subscribe, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
}
module.exports = { createEventsConsumer, startSubscriptions, CONSUMER, TOPICS };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 24a67f2..cf93875 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -46,7 +46,7 @@ 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}`));
++ const questOrigins = new Set([...Object.keys(catalog.SITES).map((host) => `https://${host}`), 'https://dns.openvibe.tools']);
r.use((req, res, next) => {
const origin = req.get('Origin');
if (origin && questOrigins.has(origin)) {
@@@ -56,6 +56,7 @@ function createApi(ctx) {
next();
});
r.options('/quests/today', (_req, res) => res.sendStatus(204));
++ r.options('/quests/today/steps/:id', (_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 }));
@@@ -96,6 +97,14 @@ function createApi(ctx) {
if (!answer) return problem(req, res, 404, 'quest.step_not_found', 'No guided step with that id today.');
return res.json(answer);
});
++ r.post('/quests/today/steps/:id/done', limits.budget('quest.me.write'), async (req, res) => {
++ if (req.principal.kind !== 'app' || !req.principal.requester.startsWith('service:'))
++ return problem(req, res, 403, 'quest.site_required', 'Only the service that owns this step can report it.');
++ if (!config.guidedTodayEnabled) return problem(req, res, 404, 'quest.guided_today_unavailable', 'Guided daily quests are not enabled.');
++ const answer = await ctx.daily.reportStep(req.body?.subject, req.params.id, req.body?.event_id, req.principal.requester);
++ if (!answer) return problem(req, res, 403, 'quest.step_not_owned', 'This service cannot report that step or the report is invalid.');
++ 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.');
ddiff --git a/server/quests/apply.js b/server/quests/apply.js
iindex c337c1d..336ac3c 100644
---- a/server/quests/apply.js
++++ b/server/quests/apply.js
@@@ -68,6 +68,7 @@ function personOf(event) {
case 'games.world.published': // payload.owner (identity.subject-ref@1, contracts 0.194.0)
case 'mediahub.video.published': // payload.owner (contracts 0.196.0)
case 'mediahub.picture.uploaded': // payload.owner (contracts 0.196.0)
++ case 'meme.caption.created': // payload.owner (no caption text)
return refUser(p.owner);
default:
// An app's own event (app quests, server/quests/app-quests.js): the actor, when the app published it
ddiff --git a/server/quests/catalog.js b/server/quests/catalog.js
iindex dce2c7f..95d2703 100644
---- a/server/quests/catalog.js
++++ b/server/quests/catalog.js
@@@ -44,6 +44,7 @@ const SITES = Object.freeze({
'openvibe.bot': { name: 'OpenVibe.Bot', what: 'robots you drive from the browser' },
'openvibe.video': { name: 'OpenVibe.Video', what: 'videos you upload or record' },
'openvibe.pics': { name: 'OpenVibe.Pics', what: 'pictures you share' },
++ 'openvibe.meme': { name: 'OpenVibe.Meme', what: 'memes and captions' },
});
/**
@@@ -243,6 +244,15 @@ const QUESTS = Object.freeze([
reward: { badge: { id: 'snapshot', name: 'Snapshot' }, coins: 5 },
repeatable: false,
},
++ {
++ id: 'caption-a-meme',
++ site: 'openvibe.meme',
++ title: 'Caption a meme',
++ why: 'Put your own words on a template and share the result.',
++ steps: [{ event_type: 'meme.caption.created', count: 1, label: 'Save a public caption' }],
++ reward: { badge: { id: 'meme-captioner', name: 'Meme Captioner' }, coins: 0 },
++ repeatable: false,
++ },
{
id: 'settle-in',
site: 'openvibe.network',
@@@ -470,6 +480,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: 'meme.caption.created', site: 'openvibe.meme', what: 'A signed-in person saved a public caption; the payload names the owner and caption id, without its content.' },
{ event_type: 'today.event.scheduled', site: 'openvibe.today', what: 'A person scheduled a stream or event; the envelope actor names the owner.' },
]);
@@@ -526,7 +537,7 @@ const BADGE_ICONS = Object.freeze({
'first-reply': 'reply', commenter: 'quote', 'said-hello': 'hand', 'wiki-editor': 'book-open', 'player-one': 'game',
toolsmith: 'wrench', 'space-thread-starter': 'rocket', 'first-post': 'pencil', 'deal-spotter': 'tag', 'deal-judge': 'thumbs-up',
'job-runner': 'terminal', 'dressed-up': 'shirt', 'robot-pilot': 'robot', 'world-builder': 'globe', premiere: 'clapper',
-- snapshot: 'camera', 'settled-in': 'home',
++ snapshot: 'camera', 'meme-captioner': 'pencil', 'settled-in': 'home',
'live-setup-slot-holder': 'layers', 'live-setup-method-picked': 'sliders', 'live-setup-restreamer': 'share',
'live-setup-profile-ready': 'user-check', 'live-setup-offline-screen': 'image', 'live-setup-emote-maker': 'smile',
'live-setup-sound-board': 'volume', 'live-setup-goal-setter': 'target', 'live-setup-tip-jar': 'coin',
ddiff --git a/server/quests/daily.js b/server/quests/daily.js
iindex 4a3a3ab..9ae4348 100644
---- a/server/quests/daily.js
++++ b/server/quests/daily.js
@@@ -21,19 +21,27 @@ const BASE_TYPES = Object.freeze([
['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],
++ ['chat', 'chat.room.message.created', 'openvibe.chat', 'Send a public chat message', 'https://openvibe.chat/', 1, 'chat.message', 'Write a message to everyone, then press Send.'],
++ ['wiki', 'wiki.revision.created', 'openvibe.wiki', 'Improve a wiki page', 'https://openvibe.wiki/w/openvibe/openvibe-wiki/edit', 3, 'wiki.revision', 'Edit a page where you are an editor, then press Save revision.', false],
++ ['tool', 'tools.job.created', 'openvibe.tools', 'Use a tool', 'https://dns.openvibe.tools/', 2, 'tools.job', 'Enter a domain and press Lookup to run the DNS tool.'],
++ ['video', 'mediahub.video.published', 'openvibe.video', 'Publish a video', 'https://openvibe.video/upload', 3, 'video.upload', 'Choose a video, upload it, then make it public.'],
['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.'],
++ ['meme', 'meme.caption.created', 'openvibe.meme', 'Caption a meme', 'https://openvibe.meme/make', 2, 'meme.caption', 'Choose a template, add a caption, then save it as public.'],
['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, 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',
++ ['world', 'games.world.published', 'openvibe.games', 'Publish a world', 'https://openvibe.games/worlds', 3, 'games.world.publish', 'Open your draft world and press Publish.', false],
++].map(([id, event_type, site, sentence, href, weight, hook, help, daily_eligible]) => Object.freeze({
++ id, event_type, site, sentence, href, weight, hook, help, daily_eligible, count: 1, verification: 'event subject, count within the person\'s local day',
status: 'approved', source: 'quest',
})).concat(SETUP_TYPES));
++const STEP_SERVICE = Object.freeze({
++ 'openvibe.live': 'service:live', 'openvibe.community': 'service:community',
++ 'openvibe.chat': 'service:chat', 'openvibe.tools': 'service:tools',
++ 'openvibe.pics': 'service:media-hub', 'openvibe.video': 'service:media-hub',
++ 'openvibe.meme': 'service:meme', 'openvibe.today': 'service:today',
++ 'openvibe.games': 'service:games', 'openvibe.wiki': 'service:wiki',
++});
const KNOWN_TOPICS = new Set(catalog.EVENT_TYPES);
for (const t of BASE_TYPES) if (!KNOWN_TOPICS.has(t.event_type)) throw new Error(`Unknown daily event topic: ${t.event_type}`);
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
@@@ -73,7 +81,8 @@ 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 } : {}),
-- ...(t.match_task ? { match_task: t.match_task, daily_eligible: t.daily_eligible } : {}),
++ ...(t.match_task ? { match_task: t.match_task } : {}),
++ ...(t.daily_eligible === false ? { daily_eligible: false } : {}),
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 };
}
@@@ -95,6 +104,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
clientSecret: config.oauth.clientSecret, audience: 'openvibe.ai', fetchImpl,
}) : null;
const allTypes = async () => [...BASE_TYPES, ...(await db.many("SELECT * FROM daily_step_types WHERE status = 'approved' ORDER BY id")).map(wireType)];
++ const eventAvailable = (step) => step.event_type !== 'meme.caption.created' || config.events.memeEnabled;
const read = async (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 };
@@@ -133,7 +143,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
if (!DAY_RE.test(day) || !Number.isFinite(Date.parse(day + 'T00:00:00Z'))) return null;
const existing = await read(day);
if (existing) return existing;
-- const types = (await allTypes()).filter((step) => !step.match_task);
++ const types = (await allTypes()).filter((step) => !step.match_task && eventAvailable(step));
const priorDay = utcDay(Date.parse(day + 'T00:00:00Z') - 86400000);
const yesterday = await read(priorDay);
let chosen = null, title = '', story = '', source = 'rotation';
@@@ -165,7 +175,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
const cached = await readPersonal(subject, day);
if (cached) return cached;
const preference = await aiPreference(subject);
-- const types = (await allTypes()).filter((step) => step.hook && step.help && step.daily_eligible !== false);
++ const types = (await allTypes()).filter((step) => step.hook && step.help && step.daily_eligible !== false && eventAvailable(step));
const completedSetup = await db.many(`SELECT quest_id FROM quest_progress
WHERE subject=$1 AND quest_id LIKE 'live-setup-%' AND count > 0`, [subject]);
const completedIds = new Set(completedSetup.map((row) => row.quest_id.replace(/^live-setup-/, 'setup-')));
@@@ -192,6 +202,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
story = 'Three small things, across the network, to make today yours.';
}
if (lines) chosen = chosen.map((step) => ({ ...step, sentence: lines[step.id].trim() }));
++ chosen.sort((a, b) => a.weight - b.weight || a.id.localeCompare(b.id));
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(guidedStep)), source, s.iso()]);
@@@ -199,20 +210,29 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
}
async function resolveStep(subject, id) {
if (!/^[A-Za-z0-9_-]{16}$/.test(id || '')) return null;
-- const quest = await ensurePersonal(subject);
++ const quest = await today(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 },
++ return { step: { id: step.id, hook: step.hook, help: step.help, event_type: step.event_type, done: step.done },
next_href: quest.steps[index + 1]?.href || null };
}
++ async function reportStep(subject, id, eventId, service) {
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject || '') || !/^[A-Za-z0-9_:-]{8,96}$/.test(eventId || '')) return null;
++ const quest = await today(subject);
++ const step = quest.steps.find((item) => item.guide_id === id);
++ if (!step || STEP_SERVICE[step.site] !== service || !eventAvailable(step) || step.daily_eligible === false) return null;
++ await db.exec(`INSERT INTO daily_events(event_id,subject,event_type,occurred_at,event_detail) VALUES($1,$2,$3,$4,$5)
++ ON CONFLICT(event_id) DO NOTHING`, [eventId, subject, step.event_type, s.iso(), step.match_task || null]);
++ return resolveStep(subject, id);
++ }
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 && !step.match_task);
++ const types = (await allTypes()).filter((step) => step.hook && step.help && !step.match_task && step.daily_eligible !== false && eventAvailable(step));
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)
@@@ -344,6 +364,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, resolveStep, channelQuest, putChannelQuest, aiPreference, setAiPreference, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };
++ return { ensure, read, publicQuest, progress, streakOf, today, ensurePersonal, resolveStep, reportStep, 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/server/render/quest-icons.js b/server/render/quest-icons.js
iindex 6945f96..42d8839 100644
---- a/server/render/quest-icons.js
++++ b/server/render/quest-icons.js
@@@ -15,13 +15,14 @@ const TOPICS = Object.freeze({
'community.comment.created': 'quote', 'chat.room.message.created': 'chat',
'wiki.revision.created': 'book-open', 'tools.job.created': 'wrench',
'mediahub.video.published': 'clapper', 'mediahub.picture.uploaded': 'camera',
++ 'meme.caption.created': 'pencil',
'deals.vote.changed': 'thumbs-up', 'blog.post.published': 'pencil',
'games.world.published': 'globe',
});
const SITES = Object.freeze({
'openvibe.live': 'live', 'openvibe.network': 'globe', 'openvibe.community': 'chat',
'openvibe.chat': 'chat', 'openvibe.wiki': 'book-open', 'openvibe.tools': 'wrench',
-- 'openvibe.video': 'clapper', 'openvibe.pics': 'camera', 'openvibe.deals': 'tag',
++ 'openvibe.video': 'clapper', 'openvibe.pics': 'camera', 'openvibe.meme': 'pencil', 'openvibe.deals': 'tag',
'openvibe.blog': 'pencil', 'openvibe.games': 'game',
});
ddiff --git a/test/daily.test.js b/test/daily.test.js
iindex 67ba3ce..df7a8f9 100644
---- a/test/daily.test.js
++++ b/test/daily.test.js
@@@ -14,6 +14,15 @@ const { ids, serviceAuth } = require('openvibe-contracts');
await check('catalogue carries the Live setup controls and at least thirty kinds', async () => {
const kinds = await t.ctx.daily.allTypes();
assert.ok(kinds.length >= 30);
++ for (const [id, hook, site] of [['meme', 'meme.caption', 'openvibe.meme'], ['video', 'video.upload', 'openvibe.video'],
++ ['chat', 'chat.message', 'openvibe.chat'], ['tool', 'tools.job', 'dns.openvibe.tools'],
++ ['world', 'games.world.publish', 'openvibe.games'], ['wiki', 'wiki.revision', 'openvibe.wiki']]) {
++ const step = kinds.find((item) => item.id === id);
++ assert.strictEqual(step.hook, hook);
++ assert.strictEqual(new URL(step.href).hostname, site);
++ }
++ assert.strictEqual(kinds.find((step) => step.id === 'world').daily_eligible, false);
++ assert.strictEqual(kinds.find((step) => step.id === 'wiki').daily_eligible, false);
const setup = kinds.filter((step) => step.id.startsWith('setup-'));
assert.strictEqual(setup.length, 17);
for (const step of setup) {
@@@ -27,6 +36,9 @@ const { ids, serviceAuth } = require('openvibe-contracts');
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 guidePreflight = await t.get('/api/v1/quests/today/steps/abc123def456', { method: 'OPTIONS', headers: { origin: 'https://dns.openvibe.tools', 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });
++ assert.strictEqual(guidePreflight.status, 204);
++ assert.strictEqual(guidePreflight.headers.get('access-control-allow-origin'), 'https://dns.openvibe.tools');
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);
});
@@@ -146,6 +158,7 @@ const { ids, serviceAuth } = require('openvibe-contracts');
assert.strictEqual(today.status, 200);
assert.strictEqual(today.json().source, 'ai');
assert.ok(aiCalls.some((call) => call.language === 'es'));
++ assert.ok(aiCalls.every((call) => !call.types.some((step) => step.id === 'meme')), 'disabled Meme events cannot become a daily step');
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');
@@@ -159,6 +172,20 @@ const { ids, serviceAuth } = require('openvibe-contracts');
const resolved = await g.get(path, { as: owner });
assert.strictEqual(resolved.status, 200);
assert.strictEqual(resolved.json().step.hook, 'pics.upload');
++ assert.strictEqual(resolved.json().step.done, false);
++ const siteToken = (service) => {
++ const at = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: g.network.url, sub: `svc:${service}`, actor_type: 'service',
++ aud: ['openvibe.quest'], cap: [], ns: [], env: 'production', iat: at, exp: at + 300,
++ jti: `tok_${service}_${at}` }, g.network.privatePem);
++ };
++ const report = { subject: owner.subject, event_id: ids.newId('event') };
++ assert.strictEqual((await g.get(`${path}/done`, { method: 'POST', bearer: siteToken('chat'), json: report })).status, 403);
++ const reported = await g.get(`${path}/done`, { method: 'POST', bearer: siteToken('media-hub'), json: report });
++ assert.strictEqual(reported.status, 200, reported.text);
++ assert.strictEqual(reported.json().step.done, true);
++ assert.strictEqual((await g.get(`${path}/done`, { method: 'POST', bearer: siteToken('media-hub'), json: report })).status, 200, 'source event replay is idempotent');
++ assert.strictEqual((await g.get(path, { as: owner })).json().step.done, true);
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';
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex 1e42f5a..d83d191 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -208,6 +208,11 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
assert.ok((await done()).includes('publish-a-video'));
await deliver(t, envelope('mediahub.picture.uploaded', { picture: 'pic_01M516F1WX5NN6C0EMW2V0G8Y9', owner: { type: 'user', id: sam.subject }, uploaded_at: iso() }, { source: 'media-hub' }));
assert.ok((await done()).includes('share-a-picture'));
++ const caption = { caption_id: 'mcp_01M516F1WX5NN6C0EMW2V0G8Y9', created_at: iso() };
++ await deliver(t, envelope('meme.caption.created', { ...caption, owner: { type: 'service', id: 'meme' } }, { source: 'meme' }));
++ assert.ok(!(await done()).includes('caption-a-meme'));
++ await deliver(t, envelope('meme.caption.created', { ...caption, owner: { type: 'user', id: sam.subject } }, { source: 'meme' }));
++ assert.ok((await done()).includes('caption-a-meme'));
});
await check('an event that names nobody creditable is ignored: no subject, a guest, a service, an unknown type', async () => {
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex cca0c26..ecb2678 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -22,8 +22,9 @@ const BUDGETS = {
// the badges nobody holds yet, the sites Quest follows, a FAQ and the quests as ItemList + FAQPage JSON-LD.
// Measured 54.3 KB raw / 10.6 KB brotli on a fresh database.
// Shared 4.205.0 on main measures 89.9 KB raw; 89.5 KB came from Shared 4.202.0.
-- htmlRawKB: 90, // 89.5 openvibe-shared v4.202.0 (2026-11-11: measured 89.5, raised by pin-bump from 87); 87: measured 86.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
-- htmlBrotliKB: 17.5, // 17.5 openvibe-shared v4.202.0 (2026-11-11: measured 17.5, raised by pin-bump from 17); 17: measured 16.9 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 16.8: measured 16.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
++ // The Meme quest and its site add 0.3 KB to the home page; measured 90.2 KB raw.
++ htmlRawKB: 90.5, // 89.5 openvibe-shared v4.202.0 (2026-11-11: measured 89.5, raised by pin-bump from 87); 87: measured 86.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
++ htmlBrotliKB: 17.7, // 17.7: measured 17.6 with the zero-coin quest badge (2026-11-11); 17.5 openvibe-shared v4.202.0 (2026-11-11: measured 17.5, raised by pin-bump from 17); 17: measured 16.9 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 16.8: measured 16.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 394.5, // 394.5: measured 394.1 with openvibe-shared 4.197.0 (2026-11-10: the menu's row of places, the You tab's row, the Sign in mark, the tooltip); 393: measured 392.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 376.7: measured 368.7 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
jsBrotliKB: 94, // 94: measured 93.7 KB brotli with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 89.8: measured 89.7 KB brotli with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 89.5: measured 87.5 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09