openvibers/openvibe.quest Commits
Guide Meme captions and Video uploads in daily quests (#278)
Changes
@@@ -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@@@ -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.@@@ -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@@@ -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.urconst 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.urif (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 };@@@ -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.');@@@ -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@@@ -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',@@@ -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, aclientSecret: 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, aif (!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, aconst 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_progressWHERE 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, astory = '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, avoid 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 };@@@ -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',});@@@ -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';@@@ -208,6 +208,11 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, cassert.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 () => {@@@ -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-09jsFiles: 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-09jsBrotliKB: 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
@@ -17,6 +17,7 @@ VALKEY_PREFIX=ov:quest:
@@ -17,6 +17,7 @@ VALKEY_PREFIX=ov:quest:
#QUEST_LIMITS_HOUR=3000
#QUEST_LIMITS_HOUR=3000
# Guided personalized daily quest reader: default off until the owner enables it.
# Guided personalized daily quest reader: default off until the owner enables it.
QUEST_GUIDED_TODAY=off
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.
# OpenVibe.Network: SSO (OAuth client `quest`, PKCE S256) and its JWKS; app tokens for audience openvibe.quest.
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
@@ -2,6 +2,9 @@
@@ -2,6 +2,9 @@
## Unreleased
## 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.
- 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.
- 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.
- Give guided picture, thread and calendar steps opaque per-person links and an authenticated step reader for the host page.
@@ -63,6 +63,7 @@ function load(env = process.env) {
@@ -63,6 +63,7 @@ function load(env = process.env) {
secrets: String(env.QUEST_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
secrets: String(env.QUEST_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
url: trim(env.QUEST_EVENTS_URL || env.EVENTS_URL || ''),
url: trim(env.QUEST_EVENTS_URL || env.EVENTS_URL || ''),
endpoint: env.QUEST_EVENTS_ENDPOINT || '',
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
// OpenCoins (server/quests/coins.js). Rewards are badges and OpenCoins — loyalty points, never money, and
@@ -120,6 +120,9 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
@@ -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 }) {
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;
if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.QUEST_EVENTS_SUBSCRIBE === '0') return null;
const base = String(eventsUrl).replace(/\/+$/, '');
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 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 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) => {
const call = async (method, path, body) => {
@@ -131,7 +134,7 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.ur
@@ -131,7 +134,7 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.ur
const listed = await call('GET', '/api/v1/subscriptions');
const listed = await call('GET', '/api/v1/subscriptions');
if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
const mine = (listed.body.subscriptions || []).filter((x) => x.endpoint === endpoint);
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;
if (mine.some((x) => x.topic_pattern === topic)) continue;
const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
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 || ''}`);
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
@@ -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})`);
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).
// 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 };
module.exports = { createEventsConsumer, startSubscriptions, CONSUMER, TOPICS };
@@ -46,7 +46,7 @@ function createApi(ctx) {
@@ -46,7 +46,7 @@ function createApi(ctx) {
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
- const questOrigins = new Set(Object.keys(catalog.SITES).map((host) => `https://${host}`));
+ const questOrigins = new Set([...Object.keys(catalog.SITES).map((host) => `https://${host}`), 'https://dns.openvibe.tools']);
r.use((req, res, next) => {
r.use((req, res, next) => {
const origin = req.get('Origin');
const origin = req.get('Origin');
if (origin && questOrigins.has(origin)) {
if (origin && questOrigins.has(origin)) {
@@ -56,6 +56,7 @@ function createApi(ctx) {
@@ -56,6 +56,7 @@ function createApi(ctx) {
next();
next();
});
});
r.options('/quests/today', (_req, res) => res.sendStatus(204));
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.use(principal.middleware);
r.get('/ping', limits.reads('quest.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
r.get('/ping', limits.reads('quest.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
@@ -96,6 +97,14 @@ function createApi(ctx) {
@@ -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.');
if (!answer) return problem(req, res, 404, 'quest.step_not_found', 'No guided step with that id today.');
return res.json(answer);
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) => {
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 (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 (!config.guidedTodayEnabled) return problem(req, res, 404, 'quest.guided_today_unavailable', 'Guided daily quests are not enabled.');
@@ -68,6 +68,7 @@ function personOf(event) {
@@ -68,6 +68,7 @@ function personOf(event) {
case 'games.world.published': // payload.owner (identity.subject-ref@1, contracts 0.194.0)
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.video.published': // payload.owner (contracts 0.196.0)
case 'mediahub.picture.uploaded': // 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);
return refUser(p.owner);
default:
default:
// An app's own event (app quests, server/quests/app-quests.js): the actor, when the app published it
// An app's own event (app quests, server/quests/app-quests.js): the actor, when the app published it
@@ -44,6 +44,7 @@ const SITES = Object.freeze({
@@ -44,6 +44,7 @@ const SITES = Object.freeze({
'openvibe.bot': { name: 'OpenVibe.Bot', what: 'robots you drive from the browser' },
'openvibe.bot': { name: 'OpenVibe.Bot', what: 'robots you drive from the browser' },
'openvibe.video': { name: 'OpenVibe.Video', what: 'videos you upload or record' },
'openvibe.video': { name: 'OpenVibe.Video', what: 'videos you upload or record' },
'openvibe.pics': { name: 'OpenVibe.Pics', what: 'pictures you share' },
'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([
@@ -243,6 +244,15 @@ const QUESTS = Object.freeze([
reward: { badge: { id: 'snapshot', name: 'Snapshot' }, coins: 5 },
reward: { badge: { id: 'snapshot', name: 'Snapshot' }, coins: 5 },
repeatable: false,
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',
id: 'settle-in',
site: 'openvibe.network',
site: 'openvibe.network',
@@ -470,6 +480,7 @@ const EVENTS = Object.freeze([
@@ -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: '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: '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.' },
{ 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({
@@ -526,7 +537,7 @@ const BADGE_ICONS = Object.freeze({
'first-reply': 'reply', commenter: 'quote', 'said-hello': 'hand', 'wiki-editor': 'book-open', 'player-one': 'game',
'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',
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',
'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-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-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',
'live-setup-sound-board': 'volume', 'live-setup-goal-setter': 'target', 'live-setup-tip-jar': 'coin',
@@ -21,19 +21,27 @@ const BASE_TYPES = Object.freeze([
@@ -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.'],
['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],
- ['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.'],
['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.'],
['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],
-].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',
status: 'approved', source: 'quest',
})).concat(SETUP_TYPES));
})).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);
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}`);
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}$/;
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -73,7 +81,8 @@ function rotation(day, types, yesterday) {
@@ -73,7 +81,8 @@ 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 } : {}),
...(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',
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 };
}
}
@@ -95,6 +104,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -95,6 +104,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
clientSecret: config.oauth.clientSecret, audience: 'openvibe.ai', fetchImpl,
clientSecret: config.oauth.clientSecret, audience: 'openvibe.ai', fetchImpl,
}) : null;
}) : null;
const allTypes = async () => [...BASE_TYPES, ...(await db.many("SELECT * FROM daily_step_types WHERE status = 'approved' ORDER BY id")).map(wireType)];
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 read = async (day) => {
const row = await db.maybe('SELECT * FROM daily_quests WHERE day = $1', [day]);
const row = await db.maybe('SELECT * FROM daily_quests WHERE day = $1', [day]);
return row && { date: row.day, title: row.title, story: row.story, steps: JSON.parse(row.steps_json), source: row.source };
return row && { date: row.day, title: row.title, story: row.story, steps: JSON.parse(row.steps_json), source: row.source };
@@ -133,7 +143,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -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;
if (!DAY_RE.test(day) || !Number.isFinite(Date.parse(day + 'T00:00:00Z'))) return null;
const existing = await read(day);
const existing = await read(day);
if (existing) return existing;
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 priorDay = utcDay(Date.parse(day + 'T00:00:00Z') - 86400000);
const yesterday = await read(priorDay);
const yesterday = await read(priorDay);
let chosen = null, title = '', story = '', source = 'rotation';
let chosen = null, title = '', story = '', source = 'rotation';
@@ -165,7 +175,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -165,7 +175,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
const cached = await readPersonal(subject, day);
const cached = await readPersonal(subject, day);
if (cached) return cached;
if (cached) return cached;
const preference = await aiPreference(subject);
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
const completedSetup = await db.many(`SELECT quest_id FROM quest_progress
WHERE subject=$1 AND quest_id LIKE 'live-setup-%' AND count > 0`, [subject]);
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-')));
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
@@ -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.';
story = 'Three small things, across the network, to make today yours.';
}
}
if (lines) chosen = chosen.map((step) => ({ ...step, sentence: lines[step.id].trim() }));
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)
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(guidedStep)), source, s.iso()]);
[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
@@ -199,20 +210,29 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
}
}
async function resolveStep(subject, id) {
async function resolveStep(subject, id) {
if (!/^[A-Za-z0-9_-]{16}$/.test(id || '')) return null;
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);
const index = quest.steps.findIndex((step) => step.guide_id === id);
if (index < 0) return null;
if (index < 0) return null;
const step = quest.steps[index];
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 };
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) {
async function channelQuest(subject) {
const row = await db.maybe('SELECT title,steps_json,updated_at FROM channel_daily_quests WHERE subject=$1', [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 };
return row && { channel_subject: subject, title: row.title, steps: JSON.parse(row.steps_json), updated_at: row.updated_at };
}
}
async function putChannelQuest(subject, input) {
async function putChannelQuest(subject, input) {
const title = typeof input?.title === 'string' ? input.title.trim() : '';
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);
const steps = validateSelection(input?.steps, types, null);
if (!title || title.length > 80 || !steps) return 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)
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
@@ -344,6 +364,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, 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 };
module.exports = { createDaily, BASE_TYPES, KNOWN_TOPICS, validateSelection, rotation, localDay, validZone, utcDay };
@@ -15,13 +15,14 @@ const TOPICS = Object.freeze({
@@ -15,13 +15,14 @@ const TOPICS = Object.freeze({
'community.comment.created': 'quote', 'chat.room.message.created': 'chat',
'community.comment.created': 'quote', 'chat.room.message.created': 'chat',
'wiki.revision.created': 'book-open', 'tools.job.created': 'wrench',
'wiki.revision.created': 'book-open', 'tools.job.created': 'wrench',
'mediahub.video.published': 'clapper', 'mediahub.picture.uploaded': 'camera',
'mediahub.video.published': 'clapper', 'mediahub.picture.uploaded': 'camera',
+ 'meme.caption.created': 'pencil',
'deals.vote.changed': 'thumbs-up', 'blog.post.published': 'pencil',
'deals.vote.changed': 'thumbs-up', 'blog.post.published': 'pencil',
'games.world.published': 'globe',
'games.world.published': 'globe',
});
});
const SITES = Object.freeze({
const SITES = Object.freeze({
'openvibe.live': 'live', 'openvibe.network': 'globe', 'openvibe.community': 'chat',
'openvibe.live': 'live', 'openvibe.network': 'globe', 'openvibe.community': 'chat',
'openvibe.chat': 'chat', 'openvibe.wiki': 'book-open', 'openvibe.tools': 'wrench',
'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',
'openvibe.blog': 'pencil', 'openvibe.games': 'game',
});
});
@@ -14,6 +14,15 @@ const { ids, serviceAuth } = require('openvibe-contracts');
@@ -14,6 +14,15 @@ const { ids, serviceAuth } = require('openvibe-contracts');
await check('catalogue carries the Live setup controls and at least thirty kinds', async () => {
await check('catalogue carries the Live setup controls and at least thirty kinds', async () => {
const kinds = await t.ctx.daily.allTypes();
const kinds = await t.ctx.daily.allTypes();
assert.ok(kinds.length >= 30);
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-'));
const setup = kinds.filter((step) => step.id.startsWith('setup-'));
assert.strictEqual(setup.length, 17);
assert.strictEqual(setup.length, 17);
for (const step of setup) {
for (const step of setup) {
@@ -27,6 +36,9 @@ const { ids, serviceAuth } = require('openvibe-contracts');
@@ -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' } });
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.status, 204);
assert.strictEqual(preflight.headers.get('access-control-allow-origin'), 'https://openvibe.pics');
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' } });
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);
assert.strictEqual(denied.headers.get('access-control-allow-origin'), null);
});
});
@@ -146,6 +158,7 @@ const { ids, serviceAuth } = require('openvibe-contracts');
@@ -146,6 +158,7 @@ const { ids, serviceAuth } = require('openvibe-contracts');
assert.strictEqual(today.status, 200);
assert.strictEqual(today.status, 200);
assert.strictEqual(today.json().source, 'ai');
assert.strictEqual(today.json().source, 'ai');
assert.ok(aiCalls.some((call) => call.language === 'es'));
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;
const beforeUnsignedChoice = aiCalls.length;
assert.strictEqual((await g.get('/api/v1/quests/today', { as: stranger })).json().source, 'rotation');
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');
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');
@@ -159,6 +172,20 @@ const { ids, serviceAuth } = require('openvibe-contracts');
const resolved = await g.get(path, { as: owner });
const resolved = await g.get(path, { as: owner });
assert.strictEqual(resolved.status, 200);
assert.strictEqual(resolved.status, 200);
assert.strictEqual(resolved.json().step.hook, 'pics.upload');
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.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);
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 channelPath = '/api/v1/me/channel-quest';
@@ -208,6 +208,11 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
@@ -208,6 +208,11 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
assert.ok((await done()).includes('publish-a-video'));
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' }));
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'));
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 () => {
await check('an event that names nobody creditable is ignored: no subject, a guest, a service, an unknown type', async () => {
@@ -22,8 +22,9 @@ const BUDGETS = {
@@ -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.
// 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.
// 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.
// 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)
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
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
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