openvibers/openvibe.quest Commits
Add shared event-backed daily quests (#232)
Changes
@@@ -19,6 +19,8 @@ VALKEY_PREFIX=ov:quest:# OpenVibe.Network: SSO (OAuth client `quest`, PKCE S256) and its JWKS; app tokens for audience openvibe.quest.OV_NETWORK_URL=https://openvibe.networkOV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# Daily quest generation through OpenVibe.AI (ai.complete, with a hand-written fallback).++OV_AI_INTERNAL_URL=http://127.0.0.1:4700OV_OAUTH_CLIENT_ID=questOV_OAUTH_CLIENT_SECRET=# OV_OAUTH_REDIRECT_URI=https://openvibe.quest/auth/callback@@@ -25,6 +25,8 @@ subscribes to.OpenVibe.Quest owns the quest log and the catalog that describes it.++The home page leads with one shared daily quest. A due-based job chooses three verified Events step types across at least two sites, asks OpenVibe.AI to favor recent network changes, and rotates a hand-written set when AI is unavailable. GET `/api/v1/daily` is public; GET `/api/v1/me/daily` returns the caller's private checklist and streak. PUT `/api/v1/me/daily/time-zone` stores an IANA time zone for local-day counting. Apps with `quest.steps.contribute` can POST `/api/v1/step-types`; staff must approve a proposal before the generator can use it.++- The **quest catalog** in code ([server/quests/catalog.js](server/quests/catalog.js)): 12 first-party quests, eachwith its steps, the events that count them, its badge, its OpenCoins and any item reward, plus the list of eventsQuest listens to. A quest may not count an event the catalog does not document; that fails at load.nnew file mode 100644@@@ -0,0 +1,41 @@++-- phase: expand++-- One network-wide quest per UTC date; each person's progress uses their chosen IANA time zone.++CREATE TABLE daily_quests (++ day text COLLATE "C" PRIMARY KEY,++ title text NOT NULL,++ story text NOT NULL,++ steps_json text NOT NULL,++ source text NOT NULL,++ created_at text COLLATE "C" NOT NULL++);++CREATE TABLE daily_step_types (++ id text COLLATE "C" PRIMARY KEY,++ project_id text COLLATE "C" NOT NULL,++ event_type text COLLATE "C" NOT NULL,++ site text NOT NULL,++ sentence text NOT NULL,++ href text NOT NULL,++ weight integer NOT NULL,++ status text NOT NULL DEFAULT 'pending',++ created_at text COLLATE "C" NOT NULL,++ reviewed_at text COLLATE "C",++ CHECK (status IN ('pending', 'approved', 'rejected'))++);++CREATE TABLE daily_preferences (++ subject text COLLATE "C" PRIMARY KEY,++ time_zone text NOT NULL DEFAULT 'UTC'++);++CREATE TABLE daily_events (++ event_id text COLLATE "C" PRIMARY KEY,++ subject text COLLATE "C" NOT NULL,++ event_type text COLLATE "C" NOT NULL,++ occurred_at text COLLATE "C" NOT NULL++);++CREATE INDEX daily_events_person_time ON daily_events (subject, occurred_at);++CREATE TABLE daily_completions (++ subject text COLLATE "C" NOT NULL,++ day text COLLATE "C" NOT NULL,++ completed_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (subject, day)++);++CREATE INDEX daily_completions_day ON daily_completions (day);@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.355.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.159.0",@@@ -1147,9 +1147,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.338.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",-- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",++ "version": "0.355.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.355.0",++ "integrity": "sha512-j3Pl93MLl/9hsIVqz4xwnlOVEUDRZlYDnmyaLTahMk46uxLJyLfLsOrJwR1N9jWublhJBhoBEc6q5fRj+bAwMw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -22,7 +22,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.355.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.159.0",@@@ -195,3 +195,14 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }}++++/* One shared daily quest, before the catalog. */++.q-daily { border: 1px solid var(--ov-border, #8886); border-radius: 18px; padding: clamp(20px, 4vw, 36px); margin-block: 20px; background: var(--ov-surface, transparent); }++.q-daily-kicker { font-size: .75rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }++.q-daily h1 { margin: .2em 0; font-size: clamp(1.5rem, 4vw, 2.4rem); }++.q-daily-steps { list-style: none; margin: 20px 0; padding: 0; display: grid; gap: 8px; }++.q-daily-steps li { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px 12px; border: 1px solid var(--ov-border, #8886); border-radius: 12px; }++.q-daily-steps small { display: block; opacity: .7; }++.q-daily-past { margin: 12px auto 28px; max-width: 1200px; padding: 12px 20px; }++.q-daily-past summary { cursor: pointer; min-height: 44px; }++.q-doc { max-width: 780px; margin-inline: auto; }@@@ -31,6 +31,7 @@ const { createApi } = require('./http/api');const { createPageRoutes } = require('./http/pages');const { createCoins } = require('./quests/coins');const { createItems } = require('./quests/items');++const { createDaily } = require('./quests/daily');const { createEventsConsumer } = require('./events-consumer');const accountDataLib = require('./identity/account-data');const { createNetworkSender } = require('openvibe-sdk/account-data');@@@ -70,10 +71,11 @@ async function createApp(opts = {}) {: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });// App quests (server/quests/app-quests.js): quests developer apps define on their own events.const appQuests = require('./quests/app-quests').createAppQuests({ s, log });-- const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, appQuests, now: s.now, log });++ const daily = createDaily({ s, config, coins, fetchImpl, aiGenerate: opts.aiGenerate, log });++ const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, appQuests, daily, now: s.now, log });// The quests in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.const searchIndex = opts.searchIndex || createSearchIndex({ config, s, log });-- const ctx = { config, s, keys, sso, principal, coins, items, eventsConsumer, accountData, accountSend, searchIndex, appQuests, log };++ const ctx = { config, s, keys, sso, principal, coins, items, daily, eventsConsumer, accountData, accountSend, searchIndex, appQuests, log };const app = express();// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts@@@ -44,7 +44,7 @@ const CONSUMER = 'quest';const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS, subjectMerge.TOPIC]);const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;--function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, now = () => Date.now(), log = console }) {++function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, daily = null, now = () => Date.now(), log = console }) {const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);// Receipts (quest_event_inbox) are in migrations/0002_quest.sql.const inbox = createPgInbox(db, { table: 'quest_event_inbox', now });@@@ -99,7 +99,11 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,let outcome;try {-- const r = await inbox.once(CONSUMER, event.event_id, (t) => apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId, appQuests }));++ const r = await inbox.once(CONSUMER, event.event_id, async (t) => {++ const applied = await apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId, appQuests });++ if (daily) applied.dailySubject = await daily.recordEvent(t, event);++ return applied;++ });if (r.duplicate) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }outcome = r.result;} catch (err) {@@@ -116,6 +120,9 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,if (coins) for (const completion of outcome.completed) await coins.settle(completion);// An item reward the same way (server/quests/items.js): granted in OpenVibe.Inventory after the commit.if (items) for (const completion of outcome.completed) await items.settle(completion);++ if (daily && outcome.dailySubject) {++ try { await daily.refreshAfterEvent(outcome.dailySubject); } catch (err) { log.warn('[Daily quest] progress refresh failed:', err.message); }++ }return res.json({event_id: event.event_id, duplicate: false, outcome: outcome.outcome,completed: outcome.completed.map((c) => c.id),@@@ -50,6 +50,28 @@ function createApi(ctx) {r.get('/ping', limits.reads('quest.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));++ // One network-wide quest each UTC date; progress is counted in the person's chosen IANA time zone.++ r.get('/daily', limits.reads('quest.daily.read'), async (_req, res) => res.json(await ctx.daily.publicQuest()));++ r.get('/daily/:date', limits.reads('quest.daily.read'), async (req, res) => {++ if (!/^\d{4}-\d{2}-\d{2}$/.test(req.params.date)) return problem(req, res, 400, 'quest.invalid_date', 'Use YYYY-MM-DD.');++ const q = await ctx.daily.publicQuest(req.params.date);++ if (!q) return problem(req, res, 404, 'quest.daily_not_found', 'No quest for that day.');++ return res.json(q);++ });++ r.get('/me/daily', limits.reads('quest.me.read'), async (req, res) => {++ if (req.principal.kind !== 'user') return problem(req, res, 401, 'token.required', 'Send a person\'s Network token.');++ return res.json(await ctx.daily.progress(req.principal.requester.slice(5)));++ });++ r.put('/me/daily/time-zone', limits.budget('quest.me.write'), async (req, res) => {++ if (req.principal.kind !== 'user') return problem(req, res, 401, 'token.required', 'Send a person\'s Network token.');++ if (req.principal.viaSession && !require('./principal').sameOrigin(req, config.baseUrl)) return problem(req, res, 403, 'request.cross_site', 'Use the same origin.');++ if (!require('../quests/daily').validZone(req.body && req.body.time_zone)) return problem(req, res, 422, 'quest.invalid_time_zone', 'Use an IANA time zone.');++ const subject = req.principal.requester.slice(5);++ await ctx.daily.setZone(subject, req.body.time_zone);++ return res.json({ time_zone: req.body.time_zone });++ });++ r.get('/step-types', limits.reads('quest.step_type.read'), async (_req, res) => res.json({ step_types: await ctx.daily.allTypes() }));++// ── The catalog (public) ─────────────────────────────────r.get('/quests', limits.reads('quest.quests.read'), (_req, res) => res.json({sites: catalog.grouped().map((g) => ({@@@ -76,6 +98,18 @@ function createApi(ctx) {return next();};const manage = principal.requireCapability('quest.app_quest.manage');++ r.post('/step-types', limits.budget('quest.step_type.write'), appOnly, principal.requireCapability('quest.steps.contribute'), async (req, res) => {++ const row = await ctx.daily.contribute(req.principal.project, req.body);++ if (row.error) return problem(req, res, 422, 'quest.invalid_step_type', 'Use a known event topic, its site and a secure link on that site.');++ if (row.project_id !== req.principal.project) return problem(req, res, 409, 'quest.step_type_exists', 'That id belongs to another app.');++ return res.status(202).json(row);++ });++ r.post('/step-types/:id/review', limits.budget('quest.step_type.review'), principal.requireCapability('quest.steps.review'), async (req, res) => {++ if (req.principal.kind !== 'app' || !String(req.principal.requester).startsWith('service:')) return problem(req, res, 403, 'quest.staff_only', 'Staff review uses a service token.');++ const row = await ctx.daily.review(req.params.id, req.body && req.body.status);++ if (!row) return problem(req, res, 404, 'quest.step_type_not_found', 'No step type with that id or review state.');++ return res.json(row);++ });r.get('/app-quests', limits.reads('quest.app_quest.read'), appOnly, manage, async (req, res) => {const rows = await ctx.appQuests.listForProject(req.principal.project);res.json({ project_id: req.principal.project, max: ctx.appQuests.MAX_PER_PROJECT, quests: await Promise.all(rows.map((row) => ctx.appQuests.wire(row))) });@@@ -35,6 +35,9 @@ function caller(req) {const BUDGETS = {// An app defining or retiring its quests (server/quests/app-quests.js).'quest.app_quest.write': { minute: 20, hour: 200 },++ 'quest.me.write': { minute: 6, hour: 30 },++ 'quest.step_type.write': { minute: 5, hour: 30 },++ 'quest.step_type.review': { minute: 20, hour: 200 },};function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {@@@ -51,7 +51,7 @@ function createPageRoutes(ctx) {const r = asyncRouter();const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });// The pages with a Markdown twin (openvibe-shared/agent-layer): the public ones only.-- const TWIN_PATH_RE = /^\/(?:how-it-works|updates|quests|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;++ const TWIN_PATH_RE = /^\/(?:how-it-works|build-a-step-type|updates|quests|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, agentLayer: TWIN_PATH_RE.test(String(req.path || '/').split('?')[0]), llms: '/llms.txt', ...o });const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;const coinsUi = { coinsEnabled: coins.enabled, dailyCap: config.coins.dailyCap, globalDailyCap: config.coins.globalDailyCap };@@@ -142,6 +142,14 @@ function createPageRoutes(ctx) {r.get('/', async (req, res) => {const [appList, p] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(12) : [], pulse()]);++ const daily = signedIn(req) ? await ctx.daily.progress(req.viewer.subject) : await ctx.daily.publicQuest();++ const past = await s.db.many('SELECT day,title FROM daily_quests WHERE day < $1 ORDER BY day DESC LIMIT 14', [daily.date]);++ const dailyCard = html`<section class="sc-sec q-daily" aria-labelledby="daily-title"><p class="q-daily-kicker">TODAY'S QUEST · ${daily.date}</p>++<h1 id="daily-title">${daily.title}</h1><p>${daily.story}</p>++<ol class="q-daily-steps">${daily.steps.map((step) => html`<li><span class="ov-icon" data-icon="${step.site.split('.')[1] || 'quest'}" data-size="32" aria-hidden="true"></span><span>${step.done ? '✓ ' : ''}<a href="${step.href}">${step.sentence}</a><small>${step.site}</small></span></li>`)}</ol>++<p>${signedIn(req) ? html`${daily.progress} of 3 · ${daily.streak}-day streak` : html`Sign in to track your progress.`} · ${daily.finished_today || 0} finished today</p>++<a href="/build-a-step-type">Build a step type</a></section>`;++ const pastFold = html`<details class="q-daily-past"><summary>Past daily quests (${past.length})</summary><ul>${past.map((x) => html`<li><a href="/api/v1/daily/${x.day}">${x.day}: ${x.title}</a></li>`)}</ul></details>`;const site = String(config.baseUrl).replace(/\/+$/, '');const total = catalog.all().length;// The navbar already says OpenVibe.Quest: the hero says what you get, in plain words, and where to start.@@@ -185,9 +193,10 @@ function createPageRoutes(ctx) {// The kit's controls (numbers, filters, folds) and its cards and rails; no kit-web.js: the cards carry no// menu, and from 760 px the rails wrap into a grid instead of scrolling (public/css/app.css).styles: KIT_STYLES,-- body: raw(foldedHome(html`${raw(hero)}++ body: raw(foldedHome(html`${dailyCard}${signedIn(req) ? raw(`<script>try{const z=Intl.DateTimeFormat().resolvedOptions().timeZone;if(z&&sessionStorage.getItem('quest-zone')!==z){fetch('/api/v1/me/daily/time-zone',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({time_zone:z})}).then(r=>{if(r.ok){sessionStorage.setItem('quest-zone',z);location.reload()}}).catch(()=>{})}}catch{}</script>`) : ''}${raw(hero)}${statsStrip(p)}${yourStuff(req)}++${pastFold}<section class="sc-sec q-rails">${raw(kit.rail({ id: 'the-quests', wrap: true, title: first.some((e) => e.n > 0) ? 'Badges people earn most' : 'Quests to start with', more: { href: '/quests', label: `See all ${total}` }, items: first.map(entryCard) }))}<p class="q-kinds-h" aria-hidden="true">Quests by site</p>@@@ -221,6 +230,13 @@ ${raw(showcase.cta({});});++ r.get('/build-a-step-type', async (req, res) => page(req, res, {++ title: 'Build a step type', description: 'Add a reviewable daily quest step using an OpenVibe.Events topic.', styles: KIT_STYLES,++ body: html`<main class="sc-sec q-doc"><h1>Build a step type</h1><p>Apps can suggest a daily step on an event Quest already verifies. A staff review is required before it can appear in a daily quest.</p>++<ol><li>Get an app token for openvibe.quest with <code>quest.steps.contribute</code>.</li><li>Choose a topic from <a href="/api/v1/step-types">the registry</a>. The event must identify the person who did it.</li><li>POST JSON to <code>/api/v1/step-types</code> with <code>id</code>, <code>event_type</code>, <code>site</code>, <code>sentence</code>, <code>href</code> and <code>weight</code> (1–5).</li><li>The API returns a pending type. Staff reviews it before the generator can select it.</li></ol>++<p>Each selected step needs one real event in the person's local day. The API gives the same quest to everyone; each person's progress remains private.</p></main>`,++ }));++// ── Every quest: the index the home's See all opens, one site at a time (?site=), as the kit's tabs ──r.get('/quests', async (req, res) => {const groups = catalog.grouped({ fold: true });@@@ -22,7 +22,7 @@ const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;// Every other route is public or a person reading their own log (no capability). App quests are the one exception:// a developer app (an app token with a project) defines its own quests with quest.app_quest.manage (contracts 0.193.0).--const CAPABILITIES = ['quest.app_quest.manage'];++const CAPABILITIES = ['quest.app_quest.manage', 'quest.steps.contribute', 'quest.steps.review'];function decodePayload(token) {const parts = String(token || '').split('.');@@@ -90,6 +90,9 @@ function createPrincipal({ config, keys }) {if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'request.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.quest itself.', ctx: req.ov });return next();}++ // Contracts is released separately. A verified token can carry Quest's new capability before this++ // service's pinned catalog knows its metadata; the exact signed cap remains the authorization check.++ if (cap.startsWith('quest.steps.') && Array.isArray(p.claims.cap) && p.claims.cap.includes(cap)) return next();const decision = capabilities.check(p.claims, cap);if (decision.allowed) return next();return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });@@@ -29,6 +29,9 @@ const TABLES = [{ table: 'quest_progress', subject: 'subject', file: 'progress.json' },{ table: 'quest_completions', subject: 'subject', file: 'completions.json' },{ table: 'quest_badges', subject: 'subject', file: 'badges.json' },++ { table: 'daily_preferences', subject: 'subject', file: 'daily-preferences.json' },++ { table: 'daily_events', subject: 'subject', file: 'daily-events.json' },++ { table: 'daily_completions', subject: 'subject', file: 'daily-completions.json' },];/** The account-data handle for Quest's store (server/db.js createStore). */@@@ -45,6 +45,11 @@ async function apply(ev, { s, log = console }) {ON CONFLICT (subject, badge_id) DO UPDATE SET awarded_at = LEAST(quest_badges.awarded_at, excluded.awarded_at)RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));await db.exec('DELETE FROM quest_badges WHERE subject = $1', [from]);++ await db.exec('INSERT INTO daily_preferences (subject,time_zone) SELECT $2,time_zone FROM daily_preferences WHERE subject=$1 ON CONFLICT (subject) DO NOTHING', [from, into]);++ await db.exec('DELETE FROM daily_preferences WHERE subject=$1', [from]);++ await db.exec('UPDATE daily_events SET subject=$2 WHERE subject=$1', [from, into]);++ await db.exec('INSERT INTO daily_completions (subject,day,completed_at) SELECT $2,day,completed_at FROM daily_completions WHERE subject=$1 ON CONFLICT (subject,day) DO UPDATE SET completed_at=LEAST(daily_completions.completed_at,excluded.completed_at)', [from, into]);++ await db.exec('DELETE FROM daily_completions WHERE subject=$1', [from]);await db.exec('INSERT INTO quest_subject_merges (merge_id, from_subject, into_subject, progress, completions, badges, merged_at) VALUES ($1, $2, $3, $4, $5, $6, $7)',[mergeId, from, into, progress, completions, badges, at]);return { progress, completions, badges };@@@ -17,7 +17,7 @@ const { startTelemetryRetention } = require('openvibe-sdk/telemetry');function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {return gracefulStop({name: 'OpenVibe.Quest', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,-- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.searchIndex.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close(), ...extra],++ close: [() => { for (const t of timers) clearInterval(t); }, ...extra, () => ctx.keys.client.stop(), () => ctx.searchIndex.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close()],});}@@@ -40,6 +40,7 @@ async function start() {ctx.telemetryTick.unref();ctx.keys.client.start();ctx.searchIndex.start();++ const stopDaily = ctx.daily.start();// Subscribe to the events the catalog counts at OpenVibe.Events (idempotent; off without QUEST_EVENTS_URL// and QUEST_EVENTS_SECRET) and come back to OpenCoins a failed credit owes (off unless QUEST_COINS=on).@@@ -55,7 +56,7 @@ async function start() {// App quests: each project with an active production quest gets its app.<project_key>.* subscription.ctx.appQuests.setSubscriber(subscriptions ? subscriptions.subscribe : null);ctx.appQuests.load().catch((err) => console.warn('[Quest] app quests not loaded:', err.message));-- const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop(), () => ctx.items.stop()];++ const extra = [stopDaily, () => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop(), () => ctx.items.stop()];createLifecycle({ server, ctx, timers, extra });return { server, ctx };}@@@ -37,7 +37,8 @@ const SCOPES = 'identity.subject.resolve network.coins.credit';const TIMEOUT_MS = 5000;/** How a completion is told apart in the ledger row (never anything about the person). */--const coinsRef = (questId) => `https://openvibe.quest/quests/${questId}`;++const coinsRef = (questId) => String(questId).startsWith('daily-')++ ? 'https://openvibe.quest/' : `https://openvibe.quest/quests/${questId}`;function createCoins({ config, s, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {const enabled = config.coins.mode === 'on';@@@ -88,7 +89,7 @@ function createCoins({ config, s, fetchImpl = globalThis.fetch, log = console, n/** Credit one completion. Throws with a short, safe reason; never logs a token or a body. */async function credit(completion) {const quest = catalog.byId(completion.quest_id);-- if (!quest) throw new Error(`no quest ${completion.quest_id} in the catalog`);++ if (!quest && !/^daily-\d{4}-\d{2}-\d{2}$/.test(completion.quest_id)) throw new Error(`no quest ${completion.quest_id} in the catalog`);const userId = await networkUserId(completion.subject);if (!userId) throw new Error('the Network has no account for this subject yet');const out = await call('POST', '/internal/coins/credit', {@@@ -96,8 +97,8 @@ function createCoins({ config, s, fetchImpl = globalThis.fetch, log = console, nuser_id: userId,app_id: APP_ID,amount: completion.coins,-- reason: `quest:${quest.id}`,-- ref: coinsRef(quest.id),++ reason: `quest:${completion.quest_id}`,++ ref: coinsRef(completion.quest_id),idempotency_key: completion.id,},});nnew file mode 100644@@@ -0,0 +1,230 @@++'use strict';++++/** One shared daily quest. Events are kept without payloads so local-day progress can be recalculated safely. */++const { serviceAuth } = require('openvibe-contracts');++const catalog = require('./catalog');++const { personOf } = require('./apply');++const store = require('./store');++++const BASE_TYPES = Object.freeze([++ ['go-live', 'live.stream.started', 'openvibe.live', 'Go live for a stream', 'https://openvibe.live/broadcast', 3],++ ['follow', 'network.follow.created', 'openvibe.network', 'Follow someone new', 'https://openvibe.network/', 1],++ ['thread', 'community.thread.created', 'openvibe.community', 'Start a community thread', 'https://openvibe.community/', 2],++ ['reply', 'community.post.created', 'openvibe.community', 'Reply to a community thread', 'https://openvibe.community/', 1],++ ['comment', 'community.comment.created', 'openvibe.community', 'Comment on a post', 'https://openvibe.community/', 1],++ ['chat', 'chat.room.message.created', 'openvibe.chat', 'Send a public chat message', 'https://openvibe.chat/', 1],++ ['wiki', 'wiki.revision.created', 'openvibe.wiki', 'Improve a wiki page', 'https://openvibe.wiki/', 3],++ ['tool', 'tools.job.created', 'openvibe.tools', 'Use a tool', 'https://openvibe.tools/', 2],++ ['video', 'mediahub.video.published', 'openvibe.video', 'Publish a video', 'https://openvibe.video/', 3],++ ['picture', 'mediahub.picture.uploaded', 'openvibe.pics', 'Upload a picture', 'https://openvibe.pics/', 2],++ ['vote', 'deals.vote.changed', 'openvibe.deals', 'Vote on a deal', 'https://openvibe.deals/', 1],++ ['blog', 'blog.post.published', 'openvibe.blog', 'Publish a blog post', 'https://openvibe.blog/', 3],++ ['world', 'games.world.published', 'openvibe.games', 'Publish a world', 'https://openvibe.games/', 3],++].map(([id, event_type, site, sentence, href, weight]) => Object.freeze({++ id, event_type, site, sentence, href, weight, count: 1, verification: 'event subject, count within the person\'s local day',++ status: 'approved', source: 'quest',++})));++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}$/;++const ID_RE = /^[a-z][a-z0-9-]{1,47}$/;++function utcDay(ms) { return new Date(ms).toISOString().slice(0, 10); }++function localDay(iso, zone) {++ const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(iso));++ const v = Object.fromEntries(parts.map((p) => [p.type, p.value]));++ return `${v.year}-${v.month}-${v.day}`;++}++function validZone(zone) {++ if (typeof zone !== 'string' || zone.length > 64) return false;++ try { new Intl.DateTimeFormat('en-US', { timeZone: zone }); return true; } catch { return false; }++}++function validateSelection(ids, types, yesterday) {++ if (!Array.isArray(ids) || ids.length !== 3 || new Set(ids).size !== 3) return null;++ const byId = new Map(types.map((t) => [t.id, t]));++ const steps = ids.map((id) => byId.get(id));++ if (steps.some((s) => !s || !KNOWN_TOPICS.has(s.event_type))) return null;++ if (new Set(steps.map((s) => s.site)).size < 2) return null;++ if (new Set(steps.map((s) => s.event_type)).size !== 3) return null;++ if (yesterday && ids.slice().sort().join(',') === yesterday.map((s) => s.id).sort().join(',')) return null;++ return steps;++}++function rotation(day, types, yesterday) {++ const ranked = types.slice().sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id));++ const start = Math.floor(Date.parse(day + 'T00:00:00Z') / 86400000) % ranked.length;++ for (let shift = 0; shift < ranked.length; shift++) {++ const ordered = [...ranked.slice((start + shift) % ranked.length), ...ranked.slice(0, (start + shift) % ranked.length)];++ for (let i = 1; i < ordered.length; i++) for (let j = i + 1; j < ordered.length; j++) {++ const selected = validateSelection([ordered[0].id, ordered[i].id, ordered[j].id], types, yesterday);++ if (selected) return selected;++ }++ }++ throw new Error('Daily registry needs at least three usable step types on two sites');++}++function wireType(t) {++ return { id: t.id, event_type: t.event_type, site: t.site, sentence: t.sentence, href: t.href,++ weight: Number(t.weight), count: 1, verification: 'event subject, count within the person\'s local day',++ status: t.status, source: t.source || t.project_id };++}++++function createDaily({ s, config, coins, fetchImpl = globalThis.fetch, aiGenerate, log = console }) {++ const db = s.db;++ const token = config.oauth.clientSecret ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,++ 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 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 };++ };++ async function latestChanges() {++ try {++ const res = await fetchImpl(`${config.networkInternalUrl}/api/v1/changelog?limit=20`, { signal: AbortSignal.timeout(4000) });++ if (!res.ok) return [];++ const body = await res.json();++ return (Array.isArray(body) ? body : body.items || body.entries || []).slice(0, 20)++ .map((x) => ({ service: String(x.service || ''), title: String(x.title || x.message || '').slice(0, 140) }));++ } catch { return []; }++ }++ async function generateAi(day, types, yesterday) {++ if (aiGenerate) return aiGenerate({ day, types, yesterday, changes: await latestChanges(), preset: 'cheapest' });++ if (!token) return null;++ const prompt = JSON.stringify({ day, types: types.map(({ id, site, sentence, weight }) => ({ id, site, sentence, weight })),++ yesterday: yesterday ? yesterday.map((s) => s.id) : [], changes: await latestChanges(),++ task: 'Choose exactly three step ids across at least two sites, avoid yesterday, favor recently changed sites. Write a title and one-line story. Return JSON {steps:[ids],title,story}.' });++ const res = await fetchImpl(`${process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'}/api/v1/runs?wait=60000`, {++ method: 'POST', headers: { ...(await token.authHeaders()), 'Content-Type': 'application/json' },++ body: JSON.stringify({ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: prompt }], response_format: { type: 'json_object' }, max_tokens: 300 }, options: { latency: 'background' } }),++ signal: AbortSignal.timeout(70000),++ });++ if (!res.ok) return null;++ const run = (await res.json()).run;++ if (!run || !['succeeded', 'cached'].includes(run.status) || run.synthetic) return null;++ const content = run.output && run.output.choices && run.output.choices[0] && run.output.choices[0].message && run.output.choices[0].message.content;++ return typeof content === 'string' ? JSON.parse(content) : null;++ }++ async function create(day) {++ 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();++ const priorDay = utcDay(Date.parse(day + 'T00:00:00Z') - 86400000);++ const yesterday = await read(priorDay);++ let chosen = null, title = '', story = '', source = 'rotation';++ try {++ const ai = await generateAi(day, types, yesterday && yesterday.steps);++ chosen = ai && validateSelection(ai.steps, types, yesterday && yesterday.steps);++ if (chosen && typeof ai.title === 'string' && ai.title.trim() && ai.title.length <= 80++ && typeof ai.story === 'string' && ai.story.trim() && ai.story.length <= 180) {++ title = ai.title.trim(); story = ai.story.trim(); source = 'ai';++ } else chosen = null;++ } catch (err) { log.warn('[Daily quest] AI unavailable:', err.message); }++ if (!chosen) { chosen = rotation(day, types, yesterday && yesterday.steps); title = 'Make your way around OpenVibe'; story = 'Three small things, across the network, to make today yours.'; }++ await db.exec(`INSERT INTO daily_quests (day,title,story,steps_json,source,created_at)++ VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (day) DO NOTHING`,++ [day, title, story, JSON.stringify(chosen.map(wireType)), source, s.iso()]);++ return read(day);++ }++ const pending = new Map();++ async function ensure(day = utcDay(s.now())) {++ if (!pending.has(day)) pending.set(day, create(day).finally(() => pending.delete(day)));++ return pending.get(day);++ }++ async function publicQuest(day = utcDay(s.now())) {++ const q = day === utcDay(s.now()) ? await ensure(day) : await read(day);++ if (!q) return null;++ const finished_today = Number(await db.value('SELECT count(*) FROM daily_completions WHERE day = $1', [day]));++ return { ...q, finished_today };++ }++ async function setZone(subject, zone) {++ if (!validZone(zone)) throw new TypeError('Use an IANA time zone');++ await db.exec(`INSERT INTO daily_preferences (subject,time_zone) VALUES ($1,$2)++ ON CONFLICT (subject) DO UPDATE SET time_zone = EXCLUDED.time_zone`, [subject, zone]);++ }++ async function zoneOf(subject) {++ return (await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject])) || 'UTC';++ }++ async function recordEvent(tx, event) {++ const subject = personOf(event);++ if (!subject || !KNOWN_TOPICS.has(event.event_type)) return null;++ const occurred = new Date(event.timestamp);++ if (!Number.isFinite(occurred.getTime()) || Math.abs(s.now() - occurred.getTime()) > 3 * 86400000) return null;++ await tx.exec(`INSERT INTO daily_events (event_id,subject,event_type,occurred_at) VALUES ($1,$2,$3,$4)++ ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString()]);++ return subject;++ }++ async function progress(subject, day = null) {++ const savedZone = await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject]);++ const zone = savedZone || 'UTC';++ day = day || localDay(s.iso(), zone);++ const q = await ensure(day);++ if (!q) return null;++ const start = new Date(day + 'T00:00:00Z').getTime();++ const events = await db.many(`SELECT event_type,occurred_at FROM daily_events++ WHERE subject=$1 AND occurred_at >= $2 AND occurred_at < $3`,++ [subject, new Date(start - 86400000).toISOString(), new Date(start + 2 * 86400000).toISOString()]);++ const local = events.filter((e) => localDay(e.occurred_at, zone) === day);++ const steps = q.steps.map((step) => ({ ...step, progress: Math.min(step.count, local.filter((e) => e.event_type === step.event_type).length),++ done: local.some((e) => e.event_type === step.event_type) }));++ const done = steps.every((step) => step.done);++ if (done && savedZone) {++ const at = s.iso();++ await db.exec('INSERT INTO daily_completions (subject,day,completed_at) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING', [subject, day, at]);++ const row = await store.complete(db, { id: s.newId('qcp'), subject, questId: `daily-${day}`, at,++ coins: 5, coinsState: coins.enabled ? 'pending' : 'skipped' });++ if (row && coins.enabled) await coins.settle(row);++ }++ const completion = await db.maybe('SELECT completed_at FROM daily_completions WHERE subject=$1 AND day=$2', [subject, day]);++ let streak = 0;++ for (let cursor = Date.parse(day + 'T00:00:00Z') - (completion ? 0 : 86400000); streak < 365; cursor -= 86400000) {++ const d = utcDay(cursor);++ if (!(await db.maybe('SELECT day FROM daily_completions WHERE subject=$1 AND day=$2', [subject, d]))) break;++ streak++;++ }++ const finished_today = Number(await db.value('SELECT count(*) FROM daily_completions WHERE day=$1', [day]));++ return { ...q, steps, finished_today, progress: steps.filter((x) => x.done).length, completed: !!completion,++ completed_at: completion && completion.completed_at, streak, time_zone: zone };++ }++ async function contribute(project, input) {++ const { id, event_type, site, sentence, href, weight = 1 } = input || {};++ if (!ID_RE.test(id || '') || !KNOWN_TOPICS.has(event_type)++ || !catalog.EVENTS.some((e) => e.event_type === event_type && e.site === site)++ || typeof sentence !== 'string' || !sentence.trim() || sentence.length > 120++ || !Number.isInteger(weight) || weight < 1 || weight > 5) return { error: 'invalid' };++ let url;++ try { url = new URL(href); } catch { return { error: 'invalid' }; }++ if (url.protocol !== 'https:' || url.hostname !== site) return { error: 'invalid' };++ const key = `${project}:${id}`;++ await db.exec(`INSERT INTO daily_step_types (id,project_id,event_type,site,sentence,href,weight,status,created_at)++ VALUES ($1,$2,$3,$4,$5,$6,$7,'pending',$8)++ ON CONFLICT (id) DO UPDATE SET event_type=$3,site=$4,sentence=$5,href=$6,weight=$7,status='pending',reviewed_at=NULL++ WHERE daily_step_types.project_id=$2`, [key, project, event_type, site, sentence.trim(), url.toString(), weight, s.iso()]);++ return { ...(await db.maybe('SELECT * FROM daily_step_types WHERE id=$1', [key])), verification: 'event subject, count within the person\'s local day' };++ }++ async function review(id, status) {++ if (!['approved', 'rejected'].includes(status)) return null;++ await db.exec('UPDATE daily_step_types SET status=$2,reviewed_at=$3 WHERE id=$1', [id, status, s.iso()]);++ return db.maybe('SELECT * FROM daily_step_types WHERE id=$1', [id]);++ }++ async function refreshAfterEvent(subject) {++ const day = localDay(s.iso(), await zoneOf(subject));++ if (!(await read(day))) return null;++ return progress(subject, day);++ }++ let timer;++ function start() {++ const due = async () => {++ try {++ await ensure();++ await db.exec('DELETE FROM daily_events WHERE occurred_at < $1', [new Date(s.now() - 3 * 86400000).toISOString()]);++ } catch (err) { log.warn('[Daily quest] generation failed:', err.message); }++ const now = s.now();++ const next = Date.parse(utcDay(now + 86400000) + 'T00:00:00Z') + 1000;++ timer = setTimeout(due, Math.max(1000, next - now)); timer.unref?.();++ };++ void due();++ return () => { if (timer) clearTimeout(timer); };++ }++ return { ensure, read, publicQuest, progress, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };++}++module.exports = { createDaily, BASE_TYPES, KNOWN_TOPICS, validateSelection, rotation, localDay, validZone, utcDay };@@@ -21,7 +21,12 @@ const READ = 'quest.api.read';const sam = t.network.addUser('sam');await check('BUDGETS: the product declares its own expensive routes (an app defining its quests)', () => {-- assert.deepStrictEqual(BUDGETS, { 'quest.app_quest.write': { minute: 20, hour: 200 } });++ assert.deepStrictEqual(BUDGETS, {++ 'quest.app_quest.write': { minute: 20, hour: 200 },++ 'quest.me.write': { minute: 6, hour: 30 },++ 'quest.step_type.write': { minute: 5, hour: 30 },++ 'quest.step_type.review': { minute: 20, hour: 200 },++ });});await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { BASE_TYPES, validateSelection, localDay } = require('../server/quests/daily');++const { ids, serviceAuth } = require('openvibe-contracts');++++(async () => {++ let now = Date.parse('2026-11-08T12:00:00Z');++ const t = await boot({ now: () => now, aiGenerate: async () => ({++ steps: ['go-live', 'chat', 'wiki'], title: 'Go somewhere new', story: 'Make the network yours today.',++ }) });++ const user = t.network.addUser('daily');++ try {++ await check('generator checks topics and sites, and falls back after a bad answer', async () => {++ assert.strictEqual(validateSelection(['go-live', 'made-up', 'chat'], BASE_TYPES, null), null);++ assert.strictEqual(validateSelection(['thread', 'reply', 'comment'], BASE_TYPES, null), null);++ const today = await t.ctx.daily.ensure();++ assert.deepStrictEqual(today.steps.map((x) => x.id), ['go-live', 'chat', 'wiki']);++ assert.strictEqual(today.source, 'ai');++ const same = await t.ctx.daily.ensure();++ assert.deepStrictEqual(same, today);++ const bad = require('../server/quests/daily').createDaily({++ s: t.ctx.s, config: t.config, coins: t.ctx.coins,++ aiGenerate: async () => ({ steps: ['unknown', 'chat', 'wiki'], title: 'Bad', story: 'Bad' }),++ });++ now += 86400000;++ const next = await bad.ensure();++ assert.strictEqual(next.source, 'rotation');++ assert.notDeepStrictEqual(next.steps.map((x) => x.id).sort(), today.steps.map((x) => x.id).sort());++ });++ await check('events identify their person and count only in the chosen local day', async () => {++ now -= 86400000;++ await t.ctx.daily.setZone(user.subject, 'America/Los_Angeles');++ assert.strictEqual(localDay('2026-11-08T02:00:00Z', 'America/Los_Angeles'), '2026-11-07');++ const types = ['live.stream.started', 'chat.room.message.created', 'wiki.revision.created'];++ for (const [i, event_type] of types.entries()) {++ const payload = event_type === 'live.stream.started' ? { channel: { subject: { type: 'user', id: user.subject } } }++ : event_type === 'chat.room.message.created' ? { user_subject: user.subject } : { author: user.subject };++ await t.ctx.daily.recordEvent(t.ctx.s.db, {++ event_id: `evt_daily_${i}`, event_type, payload,++ timestamp: '2026-11-08T18:00:00Z',++ });++ }++ const mine = await t.get('/api/v1/me/daily', { as: user });++ assert.strictEqual(mine.status, 200);++ assert.strictEqual(mine.json().progress, 3);++ assert.strictEqual(mine.json().streak, 1);++ assert.strictEqual(mine.json().completed, true);++ const publicView = await t.get('/api/v1/daily');++ assert.strictEqual(publicView.json().finished_today, 1);++ assert.ok(!publicView.text.includes(user.subject));++ });++ await check('review gates contributed types and streak reads consecutive completions', async () => {++ const submitted = await t.ctx.daily.contribute('prj_test', {++ id: 'new-wiki', event_type: 'wiki.revision.created', site: 'openvibe.wiki',++ sentence: 'Edit a page', href: 'https://openvibe.wiki/', weight: 2,++ });++ assert.strictEqual(submitted.status, 'pending');++ assert.ok(!(await t.ctx.daily.allTypes()).some((x) => x.id === submitted.id));++ await t.ctx.daily.review(submitted.id, 'approved');++ assert.ok((await t.ctx.daily.allTypes()).some((x) => x.id === submitted.id));++ await t.ctx.s.db.exec('INSERT INTO daily_completions (subject,day,completed_at) VALUES ($1,$2,$3)',++ [user.subject, '2026-11-07', '2026-11-07T20:00:00Z']);++ assert.strictEqual((await t.ctx.daily.progress(user.subject)).streak, 2);++ });++ await check('step type API requires an app capability and a separate staff review', async () => {++ const project = ids.newId('project');++ const app = ids.newId('app');++ const sign = (sub, cap, actor_type, project_id = null) => {++ const at = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type, aud: ['openvibe.quest'],++ cap: [cap], ns: project_id ? [project_id] : [], ...(project_id ? { project_id } : {}),++ env: 'production', iat: at, exp: at + 300, jti: `tok_${Date.now()}` }, t.network.privatePem);++ };++ const proposal = { id: 'community-reply', event_type: 'community.post.created', site: 'openvibe.community',++ sentence: 'Reply to a thread', href: 'https://openvibe.community/', weight: 2 };++ const denied = await t.get('/api/v1/step-types', { method: 'POST', bearer: sign(`app:${app}`, 'quest.app_quest.manage', 'app', project), json: proposal });++ assert.strictEqual(denied.status, 403);++ const created = await t.get('/api/v1/step-types', { method: 'POST', bearer: sign(`app:${app}`, 'quest.steps.contribute', 'app', project), json: proposal });++ assert.strictEqual(created.status, 202, created.text);++ assert.strictEqual(created.json().status, 'pending');++ assert.ok(!(await t.get('/api/v1/step-types')).json().step_types.some(x => x.id === created.json().id));++ const reviewed = await t.get(`/api/v1/step-types/${encodeURIComponent(created.json().id)}/review`, {++ method: 'POST', bearer: sign('svc:quest', 'quest.steps.review', 'service'), json: { status: 'approved' },++ });++ assert.strictEqual(reviewed.status, 200);++ assert.ok((await t.get('/api/v1/step-types')).json().step_types.some(x => x.id === created.json().id));++ });++ await check('enabled OpenCoins credit a daily completion once', async () => {++ const c = await boot({ now: () => Date.parse('2026-11-08T20:00:00Z'), env: { QUEST_COINS: 'on' },++ aiGenerate: async () => ({ steps: ['go-live', 'chat', 'wiki'], title: 'Around the network', story: 'Three places today.' }) });++ try {++ const coinUser = c.network.addUser('coin-daily');++ await c.ctx.daily.setZone(coinUser.subject, 'UTC');++ for (const [i, event_type] of ['live.stream.started', 'chat.room.message.created', 'wiki.revision.created'].entries()) {++ const payload = event_type === 'live.stream.started' ? { channel: { subject: { type: 'user', id: coinUser.subject } } }++ : event_type === 'chat.room.message.created' ? { user_subject: coinUser.subject } : { author: coinUser.subject };++ await c.ctx.daily.recordEvent(c.ctx.s.db, { event_id: `evt_coins_${i}`, event_type, payload, timestamp: '2026-11-08T19:00:00Z' });++ }++ await c.ctx.daily.progress(coinUser.subject);++ await c.ctx.daily.progress(coinUser.subject);++ const rows = await c.ctx.s.db.many('SELECT coins,coins_state FROM quest_completions WHERE subject=$1 AND quest_id=$2', [coinUser.subject, 'daily-2026-11-08']);++ assert.deepStrictEqual(rows.map(r => [Number(r.coins), r.coins_state]), [[5, 'credited']]);++ } finally { await c.close(); }++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { process.stderr.write(String(err.stack || err) + '\n'); process.exit(1); });@@@ -38,7 +38,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, aiGenerate: opts.aiGenerate });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -21,14 +21,14 @@ const BUDGETS = {// Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",// 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.-- htmlRawKB: 82, // 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: 16, // 15.7 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 11.8 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 10.6++ htmlRawKB: 83, // 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: 16.1, // 16.1 with the daily quest hero (2026-11-09); 15.7 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 11.8 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 10.6jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 366.1, // 366.1: measured 360.1 KB raw / 85.7 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 357.3 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 356.6 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 353.9 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 352.9 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 350.2 with openvibe-shared 4.142.0 (2026-11-07: the menu has a close button on a phone); 349.2 with openvibe-shared 4.139.0 (2026-11-07: the menu's feed rows lead with their picture, the footer and glyphs defer 38 KB); 348.2 with openvibe-shared 4.137.0 (2026-11-07: every script by its content hash, one menu button on a phone, the menu warms at idle); 346.1 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 345.4 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 345.4 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 343 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 342 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 340.4 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 338.4 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 337.1 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 335.8 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 333.1 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 332.5 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 331.3 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 328.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 324.8 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 323.2 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8jsBrotliKB: 87.2, // 87.2: measured 360.1 KB raw / 85.7 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 85.2 with openvibe-shared 4.155.0 (2026-11-08: the menu names each site by a short product name, so a name is never cut off); 85.1 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 85.0 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 84.4 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 84.2 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 83.7 with openvibe-shared 4.142.0 (2026-11-07: the menu has a close button on a phone); 83.5 with openvibe-shared 4.139.0 (2026-11-07: the menu's feed rows lead with their picture, the footer and glyphs defer 38 KB); 83.1 with openvibe-shared 4.137.0 (2026-11-07: every script by its content hash, one menu button on a phone, the menu warms at idle); 82.6 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 82.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 82.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 81.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 81.7 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 81.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 81.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 80.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 80.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 79.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5cssFiles: 4, // 4 (2026-10-29: + /shared/kit.css and /shared/room.css, the kit's numbers, filters, folds, cards and rails); 2 (app.css + the cached /shared/showcase.css)-- cssRawKB: 111.9, // 111.9 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 111.8 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 111.4 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 111.3 with openvibe-shared 4.113.0 (2026-11-06: the kit's badge at 12 px and the room link's touch hit area); 111.1 with 2026-11-06: the reward badge note at 12 px on phones; 110.6 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 109.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 108.5 with 2026-11-01: openvibe-shared 4.91.0 (card faces, audio-only player, busy buttons) (2026-11-01); 107.5 with 2026-11-01: market outcomes (2026-11-01); 106.1 with 2026-11-01: openvibe-shared 4.86.0 (2026-11-01); 105.3 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 103.3 with openvibe-shared 4.83.0: the -text status tokens add about 0.4 KB raw (102.9 KB measured on 4.82.0) (2026-11-01); 102.9 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 100.8 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 96.4 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 92.9 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 87.6 the phone home (2026-10-30: the fold rules for how it works and the questions); 83.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)-- cssBrotliKB: 24.6, // 24.6 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 24.1 with 2026-11-02: openvibe-shared 4.100.1 (the conversation kit's parts, date covers, room CSS) (2026-11-02); 23.8 with 2026-11-01: openvibe-shared 4.91.0 (card faces, audio-only player, busy buttons) (2026-11-01); 23.1 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 22.8 with openvibe-shared 4.82.0: room over the rounded measure (2026-11-01); 22.5 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 22.0 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 20.1 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 19.3 the phone home (2026-10-30: the fold rules for how it works and the questions); 18.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 16.5 kit.css and room.css on the home (2026-10-29, see cssRawKB); 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6++ cssRawKB: 112.8, // 112.8 with daily quest styles (2026-11-09); 111.9 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 111.8 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 111.4 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 111.3 with openvibe-shared 4.113.0 (2026-11-06: the kit's badge at 12 px and the room link's touch hit area); 111.1 with 2026-11-06: the reward badge note at 12 px on phones; 110.6 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 109.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 108.5 with 2026-11-01: openvibe-shared 4.91.0 (card faces, audio-only player, busy buttons) (2026-11-01); 107.5 with 2026-11-01: market outcomes (2026-11-01); 106.1 with 2026-11-01: openvibe-shared 4.86.0 (2026-11-01); 105.3 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 103.3 with openvibe-shared 4.83.0: the -text status tokens add about 0.4 KB raw (102.9 KB measured on 4.82.0) (2026-11-01); 102.9 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 100.8 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 96.4 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 92.9 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 87.6 the phone home (2026-10-30: the fold rules for how it works and the questions); 83.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)++ cssBrotliKB: 24.7, // 24.7 with daily quest styles (2026-11-09); 24.6 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 24.1 with 2026-11-02: openvibe-shared 4.100.1 (the conversation kit's parts, date covers, room CSS) (2026-11-02); 23.8 with 2026-11-01: openvibe-shared 4.91.0 (card faces, audio-only player, busy buttons) (2026-11-01); 23.1 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 22.8 with openvibe-shared 4.82.0: room over the rounded measure (2026-11-01); 22.5 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 22.0 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 20.1 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 19.3 the phone home (2026-10-30: the fold rules for how it works and the questions); 18.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 16.5 kit.css and room.css on the home (2026-10-29, see cssRawKB); 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6externalFiles: 0, // 0};
@@ -19,6 +19,8 @@ VALKEY_PREFIX=ov:quest:
@@ -19,6 +19,8 @@ VALKEY_PREFIX=ov:quest:
# 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
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# Daily quest generation through OpenVibe.AI (ai.complete, with a hand-written fallback).
+OV_AI_INTERNAL_URL=http://127.0.0.1:4700
OV_OAUTH_CLIENT_ID=quest
OV_OAUTH_CLIENT_ID=quest
OV_OAUTH_CLIENT_SECRET=
OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.quest/auth/callback
# OV_OAUTH_REDIRECT_URI=https://openvibe.quest/auth/callback
@@ -25,6 +25,8 @@ subscribes to.
@@ -25,6 +25,8 @@ subscribes to.
OpenVibe.Quest owns the quest log and the catalog that describes it.
OpenVibe.Quest owns the quest log and the catalog that describes it.
+The home page leads with one shared daily quest. A due-based job chooses three verified Events step types across at least two sites, asks OpenVibe.AI to favor recent network changes, and rotates a hand-written set when AI is unavailable. GET `/api/v1/daily` is public; GET `/api/v1/me/daily` returns the caller's private checklist and streak. PUT `/api/v1/me/daily/time-zone` stores an IANA time zone for local-day counting. Apps with `quest.steps.contribute` can POST `/api/v1/step-types`; staff must approve a proposal before the generator can use it.
+
- The **quest catalog** in code ([server/quests/catalog.js](server/quests/catalog.js)): 12 first-party quests, each
- The **quest catalog** in code ([server/quests/catalog.js](server/quests/catalog.js)): 12 first-party quests, each
with its steps, the events that count them, its badge, its OpenCoins and any item reward, plus the list of events
with its steps, the events that count them, its badge, its OpenCoins and any item reward, plus the list of events
Quest listens to. A quest may not count an event the catalog does not document; that fails at load.
Quest listens to. A quest may not count an event the catalog does not document; that fails at load.
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+-- phase: expand
+-- One network-wide quest per UTC date; each person's progress uses their chosen IANA time zone.
+CREATE TABLE daily_quests (
+ day text COLLATE "C" PRIMARY KEY,
+ title text NOT NULL,
+ story text NOT NULL,
+ steps_json text NOT NULL,
+ source text NOT NULL,
+ created_at text COLLATE "C" NOT NULL
+);
+CREATE TABLE daily_step_types (
+ id text COLLATE "C" PRIMARY KEY,
+ project_id text COLLATE "C" NOT NULL,
+ event_type text COLLATE "C" NOT NULL,
+ site text NOT NULL,
+ sentence text NOT NULL,
+ href text NOT NULL,
+ weight integer NOT NULL,
+ status text NOT NULL DEFAULT 'pending',
+ created_at text COLLATE "C" NOT NULL,
+ reviewed_at text COLLATE "C",
+ CHECK (status IN ('pending', 'approved', 'rejected'))
+);
+CREATE TABLE daily_preferences (
+ subject text COLLATE "C" PRIMARY KEY,
+ time_zone text NOT NULL DEFAULT 'UTC'
+);
+CREATE TABLE daily_events (
+ event_id text COLLATE "C" PRIMARY KEY,
+ subject text COLLATE "C" NOT NULL,
+ event_type text COLLATE "C" NOT NULL,
+ occurred_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX daily_events_person_time ON daily_events (subject, occurred_at);
+CREATE TABLE daily_completions (
+ subject text COLLATE "C" NOT NULL,
+ day text COLLATE "C" NOT NULL,
+ completed_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (subject, day)
+);
+CREATE INDEX daily_completions_day ON daily_completions (day);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.355.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.159.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.159.0",
@@ -1147,9 +1147,9 @@
@@ -1147,9 +1147,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.338.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",
+ "version": "0.355.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.355.0",
+ "integrity": "sha512-j3Pl93MLl/9hsIVqz4xwnlOVEUDRZlYDnmyaLTahMk46uxLJyLfLsOrJwR1N9jWublhJBhoBEc6q5fRj+bAwMw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.355.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.159.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.159.0",
@@ -195,3 +195,14 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
@@ -195,3 +195,14 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
}
}
+
+/* One shared daily quest, before the catalog. */
+.q-daily { border: 1px solid var(--ov-border, #8886); border-radius: 18px; padding: clamp(20px, 4vw, 36px); margin-block: 20px; background: var(--ov-surface, transparent); }
+.q-daily-kicker { font-size: .75rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
+.q-daily h1 { margin: .2em 0; font-size: clamp(1.5rem, 4vw, 2.4rem); }
+.q-daily-steps { list-style: none; margin: 20px 0; padding: 0; display: grid; gap: 8px; }
+.q-daily-steps li { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px 12px; border: 1px solid var(--ov-border, #8886); border-radius: 12px; }
+.q-daily-steps small { display: block; opacity: .7; }
+.q-daily-past { margin: 12px auto 28px; max-width: 1200px; padding: 12px 20px; }
+.q-daily-past summary { cursor: pointer; min-height: 44px; }
+.q-doc { max-width: 780px; margin-inline: auto; }
@@ -31,6 +31,7 @@ const { createApi } = require('./http/api');
@@ -31,6 +31,7 @@ const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createCoins } = require('./quests/coins');
const { createCoins } = require('./quests/coins');
const { createItems } = require('./quests/items');
const { createItems } = require('./quests/items');
+const { createDaily } = require('./quests/daily');
const { createEventsConsumer } = require('./events-consumer');
const { createEventsConsumer } = require('./events-consumer');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
@@ -70,10 +71,11 @@ async function createApp(opts = {}) {
@@ -70,10 +71,11 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });
// App quests (server/quests/app-quests.js): quests developer apps define on their own events.
// App quests (server/quests/app-quests.js): quests developer apps define on their own events.
const appQuests = require('./quests/app-quests').createAppQuests({ s, log });
const appQuests = require('./quests/app-quests').createAppQuests({ s, log });
- const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, appQuests, now: s.now, log });
+ const daily = createDaily({ s, config, coins, fetchImpl, aiGenerate: opts.aiGenerate, log });
+ const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, appQuests, daily, now: s.now, log });
// The quests in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.
// The quests in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.
const searchIndex = opts.searchIndex || createSearchIndex({ config, s, log });
const searchIndex = opts.searchIndex || createSearchIndex({ config, s, log });
- const ctx = { config, s, keys, sso, principal, coins, items, eventsConsumer, accountData, accountSend, searchIndex, appQuests, log };
+ const ctx = { config, s, keys, sso, principal, coins, items, daily, eventsConsumer, accountData, accountSend, searchIndex, appQuests, log };
const app = express();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@ -44,7 +44,7 @@ const CONSUMER = 'quest';
@@ -44,7 +44,7 @@ const CONSUMER = 'quest';
const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS, subjectMerge.TOPIC]);
const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS, subjectMerge.TOPIC]);
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
-function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, now = () => Date.now(), log = console }) {
+function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, daily = null, now = () => Date.now(), log = console }) {
const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
// Receipts (quest_event_inbox) are in migrations/0002_quest.sql.
// Receipts (quest_event_inbox) are in migrations/0002_quest.sql.
const inbox = createPgInbox(db, { table: 'quest_event_inbox', now });
const inbox = createPgInbox(db, { table: 'quest_event_inbox', now });
@@ -99,7 +99,11 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
@@ -99,7 +99,11 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
let outcome;
let outcome;
try {
try {
- const r = await inbox.once(CONSUMER, event.event_id, (t) => apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId, appQuests }));
+ const r = await inbox.once(CONSUMER, event.event_id, async (t) => {
+ const applied = await apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId, appQuests });
+ if (daily) applied.dailySubject = await daily.recordEvent(t, event);
+ return applied;
+ });
if (r.duplicate) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }
if (r.duplicate) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }
outcome = r.result;
outcome = r.result;
} catch (err) {
} catch (err) {
@@ -116,6 +120,9 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
@@ -116,6 +120,9 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
if (coins) for (const completion of outcome.completed) await coins.settle(completion);
if (coins) for (const completion of outcome.completed) await coins.settle(completion);
// An item reward the same way (server/quests/items.js): granted in OpenVibe.Inventory after the commit.
// An item reward the same way (server/quests/items.js): granted in OpenVibe.Inventory after the commit.
if (items) for (const completion of outcome.completed) await items.settle(completion);
if (items) for (const completion of outcome.completed) await items.settle(completion);
+ if (daily && outcome.dailySubject) {
+ try { await daily.refreshAfterEvent(outcome.dailySubject); } catch (err) { log.warn('[Daily quest] progress refresh failed:', err.message); }
+ }
return res.json({
return res.json({
event_id: event.event_id, duplicate: false, outcome: outcome.outcome,
event_id: event.event_id, duplicate: false, outcome: outcome.outcome,
completed: outcome.completed.map((c) => c.id),
completed: outcome.completed.map((c) => c.id),
@@ -50,6 +50,28 @@ function createApi(ctx) {
@@ -50,6 +50,28 @@ function createApi(ctx) {
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 }));
+ // One network-wide quest each UTC date; progress is counted in the person's chosen IANA time zone.
+ r.get('/daily', limits.reads('quest.daily.read'), async (_req, res) => res.json(await ctx.daily.publicQuest()));
+ r.get('/daily/:date', limits.reads('quest.daily.read'), async (req, res) => {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(req.params.date)) return problem(req, res, 400, 'quest.invalid_date', 'Use YYYY-MM-DD.');
+ const q = await ctx.daily.publicQuest(req.params.date);
+ if (!q) return problem(req, res, 404, 'quest.daily_not_found', 'No quest for that day.');
+ return res.json(q);
+ });
+ r.get('/me/daily', limits.reads('quest.me.read'), async (req, res) => {
+ if (req.principal.kind !== 'user') return problem(req, res, 401, 'token.required', 'Send a person\'s Network token.');
+ return res.json(await ctx.daily.progress(req.principal.requester.slice(5)));
+ });
+ r.put('/me/daily/time-zone', limits.budget('quest.me.write'), async (req, res) => {
+ if (req.principal.kind !== 'user') return problem(req, res, 401, 'token.required', 'Send a person\'s Network token.');
+ if (req.principal.viaSession && !require('./principal').sameOrigin(req, config.baseUrl)) return problem(req, res, 403, 'request.cross_site', 'Use the same origin.');
+ if (!require('../quests/daily').validZone(req.body && req.body.time_zone)) return problem(req, res, 422, 'quest.invalid_time_zone', 'Use an IANA time zone.');
+ const subject = req.principal.requester.slice(5);
+ await ctx.daily.setZone(subject, req.body.time_zone);
+ return res.json({ time_zone: req.body.time_zone });
+ });
+ r.get('/step-types', limits.reads('quest.step_type.read'), async (_req, res) => res.json({ step_types: await ctx.daily.allTypes() }));
+
// ── The catalog (public) ─────────────────────────────────
// ── The catalog (public) ─────────────────────────────────
r.get('/quests', limits.reads('quest.quests.read'), (_req, res) => res.json({
r.get('/quests', limits.reads('quest.quests.read'), (_req, res) => res.json({
sites: catalog.grouped().map((g) => ({
sites: catalog.grouped().map((g) => ({
@@ -76,6 +98,18 @@ function createApi(ctx) {
@@ -76,6 +98,18 @@ function createApi(ctx) {
return next();
return next();
};
};
const manage = principal.requireCapability('quest.app_quest.manage');
const manage = principal.requireCapability('quest.app_quest.manage');
+ r.post('/step-types', limits.budget('quest.step_type.write'), appOnly, principal.requireCapability('quest.steps.contribute'), async (req, res) => {
+ const row = await ctx.daily.contribute(req.principal.project, req.body);
+ if (row.error) return problem(req, res, 422, 'quest.invalid_step_type', 'Use a known event topic, its site and a secure link on that site.');
+ if (row.project_id !== req.principal.project) return problem(req, res, 409, 'quest.step_type_exists', 'That id belongs to another app.');
+ return res.status(202).json(row);
+ });
+ r.post('/step-types/:id/review', limits.budget('quest.step_type.review'), principal.requireCapability('quest.steps.review'), async (req, res) => {
+ if (req.principal.kind !== 'app' || !String(req.principal.requester).startsWith('service:')) return problem(req, res, 403, 'quest.staff_only', 'Staff review uses a service token.');
+ const row = await ctx.daily.review(req.params.id, req.body && req.body.status);
+ if (!row) return problem(req, res, 404, 'quest.step_type_not_found', 'No step type with that id or review state.');
+ return res.json(row);
+ });
r.get('/app-quests', limits.reads('quest.app_quest.read'), appOnly, manage, async (req, res) => {
r.get('/app-quests', limits.reads('quest.app_quest.read'), appOnly, manage, async (req, res) => {
const rows = await ctx.appQuests.listForProject(req.principal.project);
const rows = await ctx.appQuests.listForProject(req.principal.project);
res.json({ project_id: req.principal.project, max: ctx.appQuests.MAX_PER_PROJECT, quests: await Promise.all(rows.map((row) => ctx.appQuests.wire(row))) });
res.json({ project_id: req.principal.project, max: ctx.appQuests.MAX_PER_PROJECT, quests: await Promise.all(rows.map((row) => ctx.appQuests.wire(row))) });
@@ -35,6 +35,9 @@ function caller(req) {
@@ -35,6 +35,9 @@ function caller(req) {
const BUDGETS = {
const BUDGETS = {
// An app defining or retiring its quests (server/quests/app-quests.js).
// An app defining or retiring its quests (server/quests/app-quests.js).
'quest.app_quest.write': { minute: 20, hour: 200 },
'quest.app_quest.write': { minute: 20, hour: 200 },
+ 'quest.me.write': { minute: 6, hour: 30 },
+ 'quest.step_type.write': { minute: 5, hour: 30 },
+ 'quest.step_type.review': { minute: 20, hour: 200 },
};
};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
@@ -51,7 +51,7 @@ function createPageRoutes(ctx) {
@@ -51,7 +51,7 @@ function createPageRoutes(ctx) {
const r = asyncRouter();
const r = asyncRouter();
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
// The pages with a Markdown twin (openvibe-shared/agent-layer): the public ones only.
// The pages with a Markdown twin (openvibe-shared/agent-layer): the public ones only.
- const TWIN_PATH_RE = /^\/(?:how-it-works|updates|quests|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;
+ const TWIN_PATH_RE = /^\/(?:how-it-works|build-a-step-type|updates|quests|quests\/[^/]+|series\/[^/]+|apps\/quests\/[^/]+)?$/;
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, agentLayer: TWIN_PATH_RE.test(String(req.path || '/').split('?')[0]), llms: '/llms.txt', ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, agentLayer: TWIN_PATH_RE.test(String(req.path || '/').split('?')[0]), llms: '/llms.txt', ...o });
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const coinsUi = { coinsEnabled: coins.enabled, dailyCap: config.coins.dailyCap, globalDailyCap: config.coins.globalDailyCap };
const coinsUi = { coinsEnabled: coins.enabled, dailyCap: config.coins.dailyCap, globalDailyCap: config.coins.globalDailyCap };
@@ -142,6 +142,14 @@ function createPageRoutes(ctx) {
@@ -142,6 +142,14 @@ function createPageRoutes(ctx) {
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const [appList, p] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(12) : [], pulse()]);
const [appList, p] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(12) : [], pulse()]);
+ const daily = signedIn(req) ? await ctx.daily.progress(req.viewer.subject) : await ctx.daily.publicQuest();
+ const past = await s.db.many('SELECT day,title FROM daily_quests WHERE day < $1 ORDER BY day DESC LIMIT 14', [daily.date]);
+ const dailyCard = html`<section class="sc-sec q-daily" aria-labelledby="daily-title"><p class="q-daily-kicker">TODAY'S QUEST · ${daily.date}</p>
+<h1 id="daily-title">${daily.title}</h1><p>${daily.story}</p>
+<ol class="q-daily-steps">${daily.steps.map((step) => html`<li><span class="ov-icon" data-icon="${step.site.split('.')[1] || 'quest'}" data-size="32" aria-hidden="true"></span><span>${step.done ? '✓ ' : ''}<a href="${step.href}">${step.sentence}</a><small>${step.site}</small></span></li>`)}</ol>
+<p>${signedIn(req) ? html`${daily.progress} of 3 · ${daily.streak}-day streak` : html`Sign in to track your progress.`} · ${daily.finished_today || 0} finished today</p>
+<a href="/build-a-step-type">Build a step type</a></section>`;
+ const pastFold = html`<details class="q-daily-past"><summary>Past daily quests (${past.length})</summary><ul>${past.map((x) => html`<li><a href="/api/v1/daily/${x.day}">${x.day}: ${x.title}</a></li>`)}</ul></details>`;
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
const total = catalog.all().length;
const total = catalog.all().length;
// The navbar already says OpenVibe.Quest: the hero says what you get, in plain words, and where to start.
// The navbar already says OpenVibe.Quest: the hero says what you get, in plain words, and where to start.
@@ -185,9 +193,10 @@ function createPageRoutes(ctx) {
@@ -185,9 +193,10 @@ function createPageRoutes(ctx) {
// The kit's controls (numbers, filters, folds) and its cards and rails; no kit-web.js: the cards carry no
// The kit's controls (numbers, filters, folds) and its cards and rails; no kit-web.js: the cards carry no
// menu, and from 760 px the rails wrap into a grid instead of scrolling (public/css/app.css).
// menu, and from 760 px the rails wrap into a grid instead of scrolling (public/css/app.css).
styles: KIT_STYLES,
styles: KIT_STYLES,
- body: raw(foldedHome(html`${raw(hero)}
+ body: raw(foldedHome(html`${dailyCard}${signedIn(req) ? raw(`<script>try{const z=Intl.DateTimeFormat().resolvedOptions().timeZone;if(z&&sessionStorage.getItem('quest-zone')!==z){fetch('/api/v1/me/daily/time-zone',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({time_zone:z})}).then(r=>{if(r.ok){sessionStorage.setItem('quest-zone',z);location.reload()}}).catch(()=>{})}}catch{}</script>`) : ''}${raw(hero)}
${statsStrip(p)}
${statsStrip(p)}
${yourStuff(req)}
${yourStuff(req)}
+${pastFold}
<section class="sc-sec q-rails">
<section class="sc-sec q-rails">
${raw(kit.rail({ id: 'the-quests', wrap: true, title: first.some((e) => e.n > 0) ? 'Badges people earn most' : 'Quests to start with', more: { href: '/quests', label: `See all ${total}` }, items: first.map(entryCard) }))}
${raw(kit.rail({ id: 'the-quests', wrap: true, title: first.some((e) => e.n > 0) ? 'Badges people earn most' : 'Quests to start with', more: { href: '/quests', label: `See all ${total}` }, items: first.map(entryCard) }))}
<p class="q-kinds-h" aria-hidden="true">Quests by site</p>
<p class="q-kinds-h" aria-hidden="true">Quests by site</p>
@@ -221,6 +230,13 @@ ${raw(showcase.cta({
@@ -221,6 +230,13 @@ ${raw(showcase.cta({
});
});
});
});
+ r.get('/build-a-step-type', async (req, res) => page(req, res, {
+ title: 'Build a step type', description: 'Add a reviewable daily quest step using an OpenVibe.Events topic.', styles: KIT_STYLES,
+ body: html`<main class="sc-sec q-doc"><h1>Build a step type</h1><p>Apps can suggest a daily step on an event Quest already verifies. A staff review is required before it can appear in a daily quest.</p>
+<ol><li>Get an app token for openvibe.quest with <code>quest.steps.contribute</code>.</li><li>Choose a topic from <a href="/api/v1/step-types">the registry</a>. The event must identify the person who did it.</li><li>POST JSON to <code>/api/v1/step-types</code> with <code>id</code>, <code>event_type</code>, <code>site</code>, <code>sentence</code>, <code>href</code> and <code>weight</code> (1–5).</li><li>The API returns a pending type. Staff reviews it before the generator can select it.</li></ol>
+<p>Each selected step needs one real event in the person's local day. The API gives the same quest to everyone; each person's progress remains private.</p></main>`,
+ }));
+
// ── Every quest: the index the home's See all opens, one site at a time (?site=), as the kit's tabs ──
// ── Every quest: the index the home's See all opens, one site at a time (?site=), as the kit's tabs ──
r.get('/quests', async (req, res) => {
r.get('/quests', async (req, res) => {
const groups = catalog.grouped({ fold: true });
const groups = catalog.grouped({ fold: true });
@@ -22,7 +22,7 @@ const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
@@ -22,7 +22,7 @@ const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
// Every other route is public or a person reading their own log (no capability). App quests are the one exception:
// Every other route is public or a person reading their own log (no capability). App quests are the one exception:
// a developer app (an app token with a project) defines its own quests with quest.app_quest.manage (contracts 0.193.0).
// a developer app (an app token with a project) defines its own quests with quest.app_quest.manage (contracts 0.193.0).
-const CAPABILITIES = ['quest.app_quest.manage'];
+const CAPABILITIES = ['quest.app_quest.manage', 'quest.steps.contribute', 'quest.steps.review'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -90,6 +90,9 @@ function createPrincipal({ config, keys }) {
@@ -90,6 +90,9 @@ function createPrincipal({ config, keys }) {
if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'request.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.quest itself.', ctx: req.ov });
if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'request.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.quest itself.', ctx: req.ov });
return next();
return next();
}
}
+ // Contracts is released separately. A verified token can carry Quest's new capability before this
+ // service's pinned catalog knows its metadata; the exact signed cap remains the authorization check.
+ if (cap.startsWith('quest.steps.') && Array.isArray(p.claims.cap) && p.claims.cap.includes(cap)) return next();
const decision = capabilities.check(p.claims, cap);
const decision = capabilities.check(p.claims, cap);
if (decision.allowed) return next();
if (decision.allowed) return next();
return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });
return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });
@@ -29,6 +29,9 @@ const TABLES = [
@@ -29,6 +29,9 @@ const TABLES = [
{ table: 'quest_progress', subject: 'subject', file: 'progress.json' },
{ table: 'quest_progress', subject: 'subject', file: 'progress.json' },
{ table: 'quest_completions', subject: 'subject', file: 'completions.json' },
{ table: 'quest_completions', subject: 'subject', file: 'completions.json' },
{ table: 'quest_badges', subject: 'subject', file: 'badges.json' },
{ table: 'quest_badges', subject: 'subject', file: 'badges.json' },
+ { table: 'daily_preferences', subject: 'subject', file: 'daily-preferences.json' },
+ { table: 'daily_events', subject: 'subject', file: 'daily-events.json' },
+ { table: 'daily_completions', subject: 'subject', file: 'daily-completions.json' },
];
];
/** The account-data handle for Quest's store (server/db.js createStore). */
/** The account-data handle for Quest's store (server/db.js createStore). */
@@ -45,6 +45,11 @@ async function apply(ev, { s, log = console }) {
@@ -45,6 +45,11 @@ async function apply(ev, { s, log = console }) {
ON CONFLICT (subject, badge_id) DO UPDATE SET awarded_at = LEAST(quest_badges.awarded_at, excluded.awarded_at)
ON CONFLICT (subject, badge_id) DO UPDATE SET awarded_at = LEAST(quest_badges.awarded_at, excluded.awarded_at)
RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));
RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));
await db.exec('DELETE FROM quest_badges WHERE subject = $1', [from]);
await db.exec('DELETE FROM quest_badges WHERE subject = $1', [from]);
+ await db.exec('INSERT INTO daily_preferences (subject,time_zone) SELECT $2,time_zone FROM daily_preferences WHERE subject=$1 ON CONFLICT (subject) DO NOTHING', [from, into]);
+ await db.exec('DELETE FROM daily_preferences WHERE subject=$1', [from]);
+ await db.exec('UPDATE daily_events SET subject=$2 WHERE subject=$1', [from, into]);
+ await db.exec('INSERT INTO daily_completions (subject,day,completed_at) SELECT $2,day,completed_at FROM daily_completions WHERE subject=$1 ON CONFLICT (subject,day) DO UPDATE SET completed_at=LEAST(daily_completions.completed_at,excluded.completed_at)', [from, into]);
+ await db.exec('DELETE FROM daily_completions WHERE subject=$1', [from]);
await db.exec('INSERT INTO quest_subject_merges (merge_id, from_subject, into_subject, progress, completions, badges, merged_at) VALUES ($1, $2, $3, $4, $5, $6, $7)',
await db.exec('INSERT INTO quest_subject_merges (merge_id, from_subject, into_subject, progress, completions, badges, merged_at) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[mergeId, from, into, progress, completions, badges, at]);
[mergeId, from, into, progress, completions, badges, at]);
return { progress, completions, badges };
return { progress, completions, badges };
@@ -17,7 +17,7 @@ const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
@@ -17,7 +17,7 @@ const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'OpenVibe.Quest', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
name: 'OpenVibe.Quest', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.searchIndex.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close(), ...extra],
+ close: [() => { for (const t of timers) clearInterval(t); }, ...extra, () => ctx.keys.client.stop(), () => ctx.searchIndex.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close()],
});
});
}
}
@@ -40,6 +40,7 @@ async function start() {
@@ -40,6 +40,7 @@ async function start() {
ctx.telemetryTick.unref();
ctx.telemetryTick.unref();
ctx.keys.client.start();
ctx.keys.client.start();
ctx.searchIndex.start();
ctx.searchIndex.start();
+ const stopDaily = ctx.daily.start();
// Subscribe to the events the catalog counts at OpenVibe.Events (idempotent; off without QUEST_EVENTS_URL
// Subscribe to the events the catalog counts at OpenVibe.Events (idempotent; off without QUEST_EVENTS_URL
// and QUEST_EVENTS_SECRET) and come back to OpenCoins a failed credit owes (off unless QUEST_COINS=on).
// and QUEST_EVENTS_SECRET) and come back to OpenCoins a failed credit owes (off unless QUEST_COINS=on).
@@ -55,7 +56,7 @@ async function start() {
@@ -55,7 +56,7 @@ async function start() {
// App quests: each project with an active production quest gets its app.<project_key>.* subscription.
// App quests: each project with an active production quest gets its app.<project_key>.* subscription.
ctx.appQuests.setSubscriber(subscriptions ? subscriptions.subscribe : null);
ctx.appQuests.setSubscriber(subscriptions ? subscriptions.subscribe : null);
ctx.appQuests.load().catch((err) => console.warn('[Quest] app quests not loaded:', err.message));
ctx.appQuests.load().catch((err) => console.warn('[Quest] app quests not loaded:', err.message));
- const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop(), () => ctx.items.stop()];
+ const extra = [stopDaily, () => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop(), () => ctx.items.stop()];
createLifecycle({ server, ctx, timers, extra });
createLifecycle({ server, ctx, timers, extra });
return { server, ctx };
return { server, ctx };
}
}
@@ -37,7 +37,8 @@ const SCOPES = 'identity.subject.resolve network.coins.credit';
@@ -37,7 +37,8 @@ const SCOPES = 'identity.subject.resolve network.coins.credit';
const TIMEOUT_MS = 5000;
const TIMEOUT_MS = 5000;
/** How a completion is told apart in the ledger row (never anything about the person). */
/** How a completion is told apart in the ledger row (never anything about the person). */
-const coinsRef = (questId) => `https://openvibe.quest/quests/${questId}`;
+const coinsRef = (questId) => String(questId).startsWith('daily-')
+ ? 'https://openvibe.quest/' : `https://openvibe.quest/quests/${questId}`;
function createCoins({ config, s, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
function createCoins({ config, s, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
const enabled = config.coins.mode === 'on';
const enabled = config.coins.mode === 'on';
@@ -88,7 +89,7 @@ function createCoins({ config, s, fetchImpl = globalThis.fetch, log = console, n
@@ -88,7 +89,7 @@ function createCoins({ config, s, fetchImpl = globalThis.fetch, log = console, n
/** Credit one completion. Throws with a short, safe reason; never logs a token or a body. */
/** Credit one completion. Throws with a short, safe reason; never logs a token or a body. */
async function credit(completion) {
async function credit(completion) {
const quest = catalog.byId(completion.quest_id);
const quest = catalog.byId(completion.quest_id);
- if (!quest) throw new Error(`no quest ${completion.quest_id} in the catalog`);
+ if (!quest && !/^daily-\d{4}-\d{2}-\d{2}$/.test(completion.quest_id)) throw new Error(`no quest ${completion.quest_id} in the catalog`);
const userId = await networkUserId(completion.subject);
const userId = await networkUserId(completion.subject);
if (!userId) throw new Error('the Network has no account for this subject yet');
if (!userId) throw new Error('the Network has no account for this subject yet');
const out = await call('POST', '/internal/coins/credit', {
const out = await call('POST', '/internal/coins/credit', {
@@ -96,8 +97,8 @@ function createCoins({ config, s, fetchImpl = globalThis.fetch, log = console, n
@@ -96,8 +97,8 @@ function createCoins({ config, s, fetchImpl = globalThis.fetch, log = console, n
user_id: userId,
user_id: userId,
app_id: APP_ID,
app_id: APP_ID,
amount: completion.coins,
amount: completion.coins,
- reason: `quest:${quest.id}`,
- ref: coinsRef(quest.id),
+ reason: `quest:${completion.quest_id}`,
+ ref: coinsRef(completion.quest_id),
idempotency_key: completion.id,
idempotency_key: completion.id,
},
},
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,230 @@
@@ -0,0 +1,230 @@
+'use strict';
+
+/** One shared daily quest. Events are kept without payloads so local-day progress can be recalculated safely. */
+const { serviceAuth } = require('openvibe-contracts');
+const catalog = require('./catalog');
+const { personOf } = require('./apply');
+const store = require('./store');
+
+const BASE_TYPES = Object.freeze([
+ ['go-live', 'live.stream.started', 'openvibe.live', 'Go live for a stream', 'https://openvibe.live/broadcast', 3],
+ ['follow', 'network.follow.created', 'openvibe.network', 'Follow someone new', 'https://openvibe.network/', 1],
+ ['thread', 'community.thread.created', 'openvibe.community', 'Start a community thread', 'https://openvibe.community/', 2],
+ ['reply', 'community.post.created', 'openvibe.community', 'Reply to a community thread', 'https://openvibe.community/', 1],
+ ['comment', 'community.comment.created', 'openvibe.community', 'Comment on a post', 'https://openvibe.community/', 1],
+ ['chat', 'chat.room.message.created', 'openvibe.chat', 'Send a public chat message', 'https://openvibe.chat/', 1],
+ ['wiki', 'wiki.revision.created', 'openvibe.wiki', 'Improve a wiki page', 'https://openvibe.wiki/', 3],
+ ['tool', 'tools.job.created', 'openvibe.tools', 'Use a tool', 'https://openvibe.tools/', 2],
+ ['video', 'mediahub.video.published', 'openvibe.video', 'Publish a video', 'https://openvibe.video/', 3],
+ ['picture', 'mediahub.picture.uploaded', 'openvibe.pics', 'Upload a picture', 'https://openvibe.pics/', 2],
+ ['vote', 'deals.vote.changed', 'openvibe.deals', 'Vote on a deal', 'https://openvibe.deals/', 1],
+ ['blog', 'blog.post.published', 'openvibe.blog', 'Publish a blog post', 'https://openvibe.blog/', 3],
+ ['world', 'games.world.published', 'openvibe.games', 'Publish a world', 'https://openvibe.games/', 3],
+].map(([id, event_type, site, sentence, href, weight]) => Object.freeze({
+ id, event_type, site, sentence, href, weight, count: 1, verification: 'event subject, count within the person\'s local day',
+ status: 'approved', source: 'quest',
+})));
+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}$/;
+const ID_RE = /^[a-z][a-z0-9-]{1,47}$/;
+function utcDay(ms) { return new Date(ms).toISOString().slice(0, 10); }
+function localDay(iso, zone) {
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(iso));
+ const v = Object.fromEntries(parts.map((p) => [p.type, p.value]));
+ return `${v.year}-${v.month}-${v.day}`;
+}
+function validZone(zone) {
+ if (typeof zone !== 'string' || zone.length > 64) return false;
+ try { new Intl.DateTimeFormat('en-US', { timeZone: zone }); return true; } catch { return false; }
+}
+function validateSelection(ids, types, yesterday) {
+ if (!Array.isArray(ids) || ids.length !== 3 || new Set(ids).size !== 3) return null;
+ const byId = new Map(types.map((t) => [t.id, t]));
+ const steps = ids.map((id) => byId.get(id));
+ if (steps.some((s) => !s || !KNOWN_TOPICS.has(s.event_type))) return null;
+ if (new Set(steps.map((s) => s.site)).size < 2) return null;
+ if (new Set(steps.map((s) => s.event_type)).size !== 3) return null;
+ if (yesterday && ids.slice().sort().join(',') === yesterday.map((s) => s.id).sort().join(',')) return null;
+ return steps;
+}
+function rotation(day, types, yesterday) {
+ const ranked = types.slice().sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id));
+ const start = Math.floor(Date.parse(day + 'T00:00:00Z') / 86400000) % ranked.length;
+ for (let shift = 0; shift < ranked.length; shift++) {
+ const ordered = [...ranked.slice((start + shift) % ranked.length), ...ranked.slice(0, (start + shift) % ranked.length)];
+ for (let i = 1; i < ordered.length; i++) for (let j = i + 1; j < ordered.length; j++) {
+ const selected = validateSelection([ordered[0].id, ordered[i].id, ordered[j].id], types, yesterday);
+ if (selected) return selected;
+ }
+ }
+ throw new Error('Daily registry needs at least three usable step types on two sites');
+}
+function wireType(t) {
+ return { id: t.id, event_type: t.event_type, site: t.site, sentence: t.sentence, href: t.href,
+ weight: Number(t.weight), count: 1, verification: 'event subject, count within the person\'s local day',
+ status: t.status, source: t.source || t.project_id };
+}
+
+function createDaily({ s, config, coins, fetchImpl = globalThis.fetch, aiGenerate, log = console }) {
+ const db = s.db;
+ const token = config.oauth.clientSecret ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
+ 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 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 };
+ };
+ async function latestChanges() {
+ try {
+ const res = await fetchImpl(`${config.networkInternalUrl}/api/v1/changelog?limit=20`, { signal: AbortSignal.timeout(4000) });
+ if (!res.ok) return [];
+ const body = await res.json();
+ return (Array.isArray(body) ? body : body.items || body.entries || []).slice(0, 20)
+ .map((x) => ({ service: String(x.service || ''), title: String(x.title || x.message || '').slice(0, 140) }));
+ } catch { return []; }
+ }
+ async function generateAi(day, types, yesterday) {
+ if (aiGenerate) return aiGenerate({ day, types, yesterday, changes: await latestChanges(), preset: 'cheapest' });
+ if (!token) return null;
+ const prompt = JSON.stringify({ day, types: types.map(({ id, site, sentence, weight }) => ({ id, site, sentence, weight })),
+ yesterday: yesterday ? yesterday.map((s) => s.id) : [], changes: await latestChanges(),
+ task: 'Choose exactly three step ids across at least two sites, avoid yesterday, favor recently changed sites. Write a title and one-line story. Return JSON {steps:[ids],title,story}.' });
+ const res = await fetchImpl(`${process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'}/api/v1/runs?wait=60000`, {
+ method: 'POST', headers: { ...(await token.authHeaders()), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: prompt }], response_format: { type: 'json_object' }, max_tokens: 300 }, options: { latency: 'background' } }),
+ signal: AbortSignal.timeout(70000),
+ });
+ if (!res.ok) return null;
+ const run = (await res.json()).run;
+ if (!run || !['succeeded', 'cached'].includes(run.status) || run.synthetic) return null;
+ const content = run.output && run.output.choices && run.output.choices[0] && run.output.choices[0].message && run.output.choices[0].message.content;
+ return typeof content === 'string' ? JSON.parse(content) : null;
+ }
+ async function create(day) {
+ 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();
+ const priorDay = utcDay(Date.parse(day + 'T00:00:00Z') - 86400000);
+ const yesterday = await read(priorDay);
+ let chosen = null, title = '', story = '', source = 'rotation';
+ try {
+ const ai = await generateAi(day, types, yesterday && yesterday.steps);
+ chosen = ai && validateSelection(ai.steps, types, yesterday && yesterday.steps);
+ if (chosen && typeof ai.title === 'string' && ai.title.trim() && ai.title.length <= 80
+ && typeof ai.story === 'string' && ai.story.trim() && ai.story.length <= 180) {
+ title = ai.title.trim(); story = ai.story.trim(); source = 'ai';
+ } else chosen = null;
+ } catch (err) { log.warn('[Daily quest] AI unavailable:', err.message); }
+ if (!chosen) { chosen = rotation(day, types, yesterday && yesterday.steps); title = 'Make your way around OpenVibe'; story = 'Three small things, across the network, to make today yours.'; }
+ await db.exec(`INSERT INTO daily_quests (day,title,story,steps_json,source,created_at)
+ VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (day) DO NOTHING`,
+ [day, title, story, JSON.stringify(chosen.map(wireType)), source, s.iso()]);
+ return read(day);
+ }
+ const pending = new Map();
+ async function ensure(day = utcDay(s.now())) {
+ if (!pending.has(day)) pending.set(day, create(day).finally(() => pending.delete(day)));
+ return pending.get(day);
+ }
+ async function publicQuest(day = utcDay(s.now())) {
+ const q = day === utcDay(s.now()) ? await ensure(day) : await read(day);
+ if (!q) return null;
+ const finished_today = Number(await db.value('SELECT count(*) FROM daily_completions WHERE day = $1', [day]));
+ return { ...q, finished_today };
+ }
+ async function setZone(subject, zone) {
+ if (!validZone(zone)) throw new TypeError('Use an IANA time zone');
+ await db.exec(`INSERT INTO daily_preferences (subject,time_zone) VALUES ($1,$2)
+ ON CONFLICT (subject) DO UPDATE SET time_zone = EXCLUDED.time_zone`, [subject, zone]);
+ }
+ async function zoneOf(subject) {
+ return (await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject])) || 'UTC';
+ }
+ async function recordEvent(tx, event) {
+ const subject = personOf(event);
+ if (!subject || !KNOWN_TOPICS.has(event.event_type)) return null;
+ const occurred = new Date(event.timestamp);
+ if (!Number.isFinite(occurred.getTime()) || Math.abs(s.now() - occurred.getTime()) > 3 * 86400000) return null;
+ await tx.exec(`INSERT INTO daily_events (event_id,subject,event_type,occurred_at) VALUES ($1,$2,$3,$4)
+ ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString()]);
+ return subject;
+ }
+ async function progress(subject, day = null) {
+ const savedZone = await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject]);
+ const zone = savedZone || 'UTC';
+ day = day || localDay(s.iso(), zone);
+ const q = await ensure(day);
+ if (!q) return null;
+ const start = new Date(day + 'T00:00:00Z').getTime();
+ const events = await db.many(`SELECT event_type,occurred_at FROM daily_events
+ WHERE subject=$1 AND occurred_at >= $2 AND occurred_at < $3`,
+ [subject, new Date(start - 86400000).toISOString(), new Date(start + 2 * 86400000).toISOString()]);
+ const local = events.filter((e) => localDay(e.occurred_at, zone) === day);
+ const steps = q.steps.map((step) => ({ ...step, progress: Math.min(step.count, local.filter((e) => e.event_type === step.event_type).length),
+ done: local.some((e) => e.event_type === step.event_type) }));
+ const done = steps.every((step) => step.done);
+ if (done && savedZone) {
+ const at = s.iso();
+ await db.exec('INSERT INTO daily_completions (subject,day,completed_at) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING', [subject, day, at]);
+ const row = await store.complete(db, { id: s.newId('qcp'), subject, questId: `daily-${day}`, at,
+ coins: 5, coinsState: coins.enabled ? 'pending' : 'skipped' });
+ if (row && coins.enabled) await coins.settle(row);
+ }
+ const completion = await db.maybe('SELECT completed_at FROM daily_completions WHERE subject=$1 AND day=$2', [subject, day]);
+ let streak = 0;
+ for (let cursor = Date.parse(day + 'T00:00:00Z') - (completion ? 0 : 86400000); streak < 365; cursor -= 86400000) {
+ const d = utcDay(cursor);
+ if (!(await db.maybe('SELECT day FROM daily_completions WHERE subject=$1 AND day=$2', [subject, d]))) break;
+ streak++;
+ }
+ const finished_today = Number(await db.value('SELECT count(*) FROM daily_completions WHERE day=$1', [day]));
+ return { ...q, steps, finished_today, progress: steps.filter((x) => x.done).length, completed: !!completion,
+ completed_at: completion && completion.completed_at, streak, time_zone: zone };
+ }
+ async function contribute(project, input) {
+ const { id, event_type, site, sentence, href, weight = 1 } = input || {};
+ if (!ID_RE.test(id || '') || !KNOWN_TOPICS.has(event_type)
+ || !catalog.EVENTS.some((e) => e.event_type === event_type && e.site === site)
+ || typeof sentence !== 'string' || !sentence.trim() || sentence.length > 120
+ || !Number.isInteger(weight) || weight < 1 || weight > 5) return { error: 'invalid' };
+ let url;
+ try { url = new URL(href); } catch { return { error: 'invalid' }; }
+ if (url.protocol !== 'https:' || url.hostname !== site) return { error: 'invalid' };
+ const key = `${project}:${id}`;
+ await db.exec(`INSERT INTO daily_step_types (id,project_id,event_type,site,sentence,href,weight,status,created_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,'pending',$8)
+ ON CONFLICT (id) DO UPDATE SET event_type=$3,site=$4,sentence=$5,href=$6,weight=$7,status='pending',reviewed_at=NULL
+ WHERE daily_step_types.project_id=$2`, [key, project, event_type, site, sentence.trim(), url.toString(), weight, s.iso()]);
+ return { ...(await db.maybe('SELECT * FROM daily_step_types WHERE id=$1', [key])), verification: 'event subject, count within the person\'s local day' };
+ }
+ async function review(id, status) {
+ if (!['approved', 'rejected'].includes(status)) return null;
+ await db.exec('UPDATE daily_step_types SET status=$2,reviewed_at=$3 WHERE id=$1', [id, status, s.iso()]);
+ return db.maybe('SELECT * FROM daily_step_types WHERE id=$1', [id]);
+ }
+ async function refreshAfterEvent(subject) {
+ const day = localDay(s.iso(), await zoneOf(subject));
+ if (!(await read(day))) return null;
+ return progress(subject, day);
+ }
+ let timer;
+ function start() {
+ const due = async () => {
+ try {
+ await ensure();
+ await db.exec('DELETE FROM daily_events WHERE occurred_at < $1', [new Date(s.now() - 3 * 86400000).toISOString()]);
+ } catch (err) { log.warn('[Daily quest] generation failed:', err.message); }
+ const now = s.now();
+ const next = Date.parse(utcDay(now + 86400000) + 'T00:00:00Z') + 1000;
+ timer = setTimeout(due, Math.max(1000, next - now)); timer.unref?.();
+ };
+ void due();
+ return () => { if (timer) clearTimeout(timer); };
+ }
+ return { ensure, read, publicQuest, progress, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };
+}
+module.exports = { createDaily, BASE_TYPES, KNOWN_TOPICS, validateSelection, rotation, localDay, validZone, utcDay };
@@ -21,7 +21,12 @@ const READ = 'quest.api.read';
@@ -21,7 +21,12 @@ const READ = 'quest.api.read';
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
await check('BUDGETS: the product declares its own expensive routes (an app defining its quests)', () => {
await check('BUDGETS: the product declares its own expensive routes (an app defining its quests)', () => {
- assert.deepStrictEqual(BUDGETS, { 'quest.app_quest.write': { minute: 20, hour: 200 } });
+ assert.deepStrictEqual(BUDGETS, {
+ 'quest.app_quest.write': { minute: 20, hour: 200 },
+ 'quest.me.write': { minute: 6, hour: 30 },
+ 'quest.step_type.write': { minute: 5, hour: 30 },
+ 'quest.step_type.review': { minute: 20, hour: 200 },
+ });
});
});
await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { BASE_TYPES, validateSelection, localDay } = require('../server/quests/daily');
+const { ids, serviceAuth } = require('openvibe-contracts');
+
+(async () => {
+ let now = Date.parse('2026-11-08T12:00:00Z');
+ const t = await boot({ now: () => now, aiGenerate: async () => ({
+ steps: ['go-live', 'chat', 'wiki'], title: 'Go somewhere new', story: 'Make the network yours today.',
+ }) });
+ const user = t.network.addUser('daily');
+ try {
+ await check('generator checks topics and sites, and falls back after a bad answer', async () => {
+ assert.strictEqual(validateSelection(['go-live', 'made-up', 'chat'], BASE_TYPES, null), null);
+ assert.strictEqual(validateSelection(['thread', 'reply', 'comment'], BASE_TYPES, null), null);
+ const today = await t.ctx.daily.ensure();
+ assert.deepStrictEqual(today.steps.map((x) => x.id), ['go-live', 'chat', 'wiki']);
+ assert.strictEqual(today.source, 'ai');
+ const same = await t.ctx.daily.ensure();
+ assert.deepStrictEqual(same, today);
+ const bad = require('../server/quests/daily').createDaily({
+ s: t.ctx.s, config: t.config, coins: t.ctx.coins,
+ aiGenerate: async () => ({ steps: ['unknown', 'chat', 'wiki'], title: 'Bad', story: 'Bad' }),
+ });
+ now += 86400000;
+ const next = await bad.ensure();
+ assert.strictEqual(next.source, 'rotation');
+ assert.notDeepStrictEqual(next.steps.map((x) => x.id).sort(), today.steps.map((x) => x.id).sort());
+ });
+ await check('events identify their person and count only in the chosen local day', async () => {
+ now -= 86400000;
+ await t.ctx.daily.setZone(user.subject, 'America/Los_Angeles');
+ assert.strictEqual(localDay('2026-11-08T02:00:00Z', 'America/Los_Angeles'), '2026-11-07');
+ const types = ['live.stream.started', 'chat.room.message.created', 'wiki.revision.created'];
+ for (const [i, event_type] of types.entries()) {
+ const payload = event_type === 'live.stream.started' ? { channel: { subject: { type: 'user', id: user.subject } } }
+ : event_type === 'chat.room.message.created' ? { user_subject: user.subject } : { author: user.subject };
+ await t.ctx.daily.recordEvent(t.ctx.s.db, {
+ event_id: `evt_daily_${i}`, event_type, payload,
+ timestamp: '2026-11-08T18:00:00Z',
+ });
+ }
+ const mine = await t.get('/api/v1/me/daily', { as: user });
+ assert.strictEqual(mine.status, 200);
+ assert.strictEqual(mine.json().progress, 3);
+ assert.strictEqual(mine.json().streak, 1);
+ assert.strictEqual(mine.json().completed, true);
+ const publicView = await t.get('/api/v1/daily');
+ assert.strictEqual(publicView.json().finished_today, 1);
+ assert.ok(!publicView.text.includes(user.subject));
+ });
+ await check('review gates contributed types and streak reads consecutive completions', async () => {
+ const submitted = await t.ctx.daily.contribute('prj_test', {
+ id: 'new-wiki', event_type: 'wiki.revision.created', site: 'openvibe.wiki',
+ sentence: 'Edit a page', href: 'https://openvibe.wiki/', weight: 2,
+ });
+ assert.strictEqual(submitted.status, 'pending');
+ assert.ok(!(await t.ctx.daily.allTypes()).some((x) => x.id === submitted.id));
+ await t.ctx.daily.review(submitted.id, 'approved');
+ assert.ok((await t.ctx.daily.allTypes()).some((x) => x.id === submitted.id));
+ await t.ctx.s.db.exec('INSERT INTO daily_completions (subject,day,completed_at) VALUES ($1,$2,$3)',
+ [user.subject, '2026-11-07', '2026-11-07T20:00:00Z']);
+ assert.strictEqual((await t.ctx.daily.progress(user.subject)).streak, 2);
+ });
+ await check('step type API requires an app capability and a separate staff review', async () => {
+ const project = ids.newId('project');
+ const app = ids.newId('app');
+ const sign = (sub, cap, actor_type, project_id = null) => {
+ const at = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type, aud: ['openvibe.quest'],
+ cap: [cap], ns: project_id ? [project_id] : [], ...(project_id ? { project_id } : {}),
+ env: 'production', iat: at, exp: at + 300, jti: `tok_${Date.now()}` }, t.network.privatePem);
+ };
+ const proposal = { id: 'community-reply', event_type: 'community.post.created', site: 'openvibe.community',
+ sentence: 'Reply to a thread', href: 'https://openvibe.community/', weight: 2 };
+ const denied = await t.get('/api/v1/step-types', { method: 'POST', bearer: sign(`app:${app}`, 'quest.app_quest.manage', 'app', project), json: proposal });
+ assert.strictEqual(denied.status, 403);
+ const created = await t.get('/api/v1/step-types', { method: 'POST', bearer: sign(`app:${app}`, 'quest.steps.contribute', 'app', project), json: proposal });
+ assert.strictEqual(created.status, 202, created.text);
+ assert.strictEqual(created.json().status, 'pending');
+ assert.ok(!(await t.get('/api/v1/step-types')).json().step_types.some(x => x.id === created.json().id));
+ const reviewed = await t.get(`/api/v1/step-types/${encodeURIComponent(created.json().id)}/review`, {
+ method: 'POST', bearer: sign('svc:quest', 'quest.steps.review', 'service'), json: { status: 'approved' },
+ });
+ assert.strictEqual(reviewed.status, 200);
+ assert.ok((await t.get('/api/v1/step-types')).json().step_types.some(x => x.id === created.json().id));
+ });
+ await check('enabled OpenCoins credit a daily completion once', async () => {
+ const c = await boot({ now: () => Date.parse('2026-11-08T20:00:00Z'), env: { QUEST_COINS: 'on' },
+ aiGenerate: async () => ({ steps: ['go-live', 'chat', 'wiki'], title: 'Around the network', story: 'Three places today.' }) });
+ try {
+ const coinUser = c.network.addUser('coin-daily');
+ await c.ctx.daily.setZone(coinUser.subject, 'UTC');
+ for (const [i, event_type] of ['live.stream.started', 'chat.room.message.created', 'wiki.revision.created'].entries()) {
+ const payload = event_type === 'live.stream.started' ? { channel: { subject: { type: 'user', id: coinUser.subject } } }
+ : event_type === 'chat.room.message.created' ? { user_subject: coinUser.subject } : { author: coinUser.subject };
+ await c.ctx.daily.recordEvent(c.ctx.s.db, { event_id: `evt_coins_${i}`, event_type, payload, timestamp: '2026-11-08T19:00:00Z' });
+ }
+ await c.ctx.daily.progress(coinUser.subject);
+ await c.ctx.daily.progress(coinUser.subject);
+ const rows = await c.ctx.s.db.many('SELECT coins,coins_state FROM quest_completions WHERE subject=$1 AND quest_id=$2', [coinUser.subject, 'daily-2026-11-08']);
+ assert.deepStrictEqual(rows.map(r => [Number(r.coins), r.coins_state]), [[5, 'credited']]);
+ } finally { await c.close(); }
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { process.stderr.write(String(err.stack || err) + '\n'); process.exit(1); });
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });
+ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, aiGenerate: opts.aiGenerate });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -21,14 +21,14 @@ const BUDGETS = {
@@ -21,14 +21,14 @@ const BUDGETS = {
// Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",
// Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",
// 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.
- htmlRawKB: 82, // 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: 16, // 15.7 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 11.8 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 10.6
+ htmlRawKB: 83, // 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: 16.1, // 16.1 with the daily quest hero (2026-11-09); 15.7 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 11.8 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 10.6
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: 366.1, // 366.1: measured 360.1 KB raw / 85.7 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 357.3 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 356.6 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 353.9 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 352.9 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 350.2 with openvibe-shared 4.142.0 (2026-11-07: the menu has a close button on a phone); 349.2 with openvibe-shared 4.139.0 (2026-11-07: the menu's feed rows lead with their picture, the footer and glyphs defer 38 KB); 348.2 with openvibe-shared 4.137.0 (2026-11-07: every script by its content hash, one menu button on a phone, the menu warms at idle); 346.1 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 345.4 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 345.4 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 343 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 342 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 340.4 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 338.4 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 337.1 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 335.8 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 333.1 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 332.5 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 331.3 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 328.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 324.8 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 323.2 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsRawKB: 366.1, // 366.1: measured 360.1 KB raw / 85.7 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 357.3 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 356.6 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 353.9 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 352.9 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 350.2 with openvibe-shared 4.142.0 (2026-11-07: the menu has a close button on a phone); 349.2 with openvibe-shared 4.139.0 (2026-11-07: the menu's feed rows lead with their picture, the footer and glyphs defer 38 KB); 348.2 with openvibe-shared 4.137.0 (2026-11-07: every script by its content hash, one menu button on a phone, the menu warms at idle); 346.1 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 345.4 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 345.4 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 343 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 342 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 340.4 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 338.4 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 337.1 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 335.8 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 333.1 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 332.5 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 331.3 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 328.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 324.8 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 323.2 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 87.2, // 87.2: measured 360.1 KB raw / 85.7 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 85.2 with openvibe-shared 4.155.0 (2026-11-08: the menu names each site by a short product name, so a name is never cut off); 85.1 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 85.0 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 84.4 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 84.2 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 83.7 with openvibe-shared 4.142.0 (2026-11-07: the menu has a close button on a phone); 83.5 with openvibe-shared 4.139.0 (2026-11-07: the menu's feed rows lead with their picture, the footer and glyphs defer 38 KB); 83.1 with openvibe-shared 4.137.0 (2026-11-07: every script by its content hash, one menu button on a phone, the menu warms at idle); 82.6 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 82.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 82.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 81.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 81.7 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 81.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 81.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 80.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 80.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 79.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
jsBrotliKB: 87.2, // 87.2: measured 360.1 KB raw / 85.7 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 85.2 with openvibe-shared 4.155.0 (2026-11-08: the menu names each site by a short product name, so a name is never cut off); 85.1 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 85.0 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 84.4 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 84.2 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 83.7 with openvibe-shared 4.142.0 (2026-11-07: the menu has a close button on a phone); 83.5 with openvibe-shared 4.139.0 (2026-11-07: the menu's feed rows lead with their picture, the footer and glyphs defer 38 KB); 83.1 with openvibe-shared 4.137.0 (2026-11-07: every script by its content hash, one menu button on a phone, the menu warms at idle); 82.6 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 82.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 82.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 81.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 81.7 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 81.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 81.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 80.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 80.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 79.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
cssFiles: 4, // 4 (2026-10-29: + /shared/kit.css and /shared/room.css, the kit's numbers, filters, folds, cards and rails); 2 (app.css + the cached /shared/showcase.css)
cssFiles: 4, // 4 (2026-10-29: + /shared/kit.css and /shared/room.css, the kit's numbers, filters, folds, cards and rails); 2 (app.css + the cached /shared/showcase.css)
- cssRawKB: 111.9, // 111.9 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 111.8 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 111.4 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 111.3 with openvibe-shared 4.113.0 (2026-11-06: the kit's badge at 12 px and the room link's touch hit area); 111.1 with 2026-11-06: the reward badge note at 12 px on phones; 110.6 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 109.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 108.5 with 2026-11-01: openvibe-shared 4.91.0 (card faces, audio-only player, busy buttons) (2026-11-01); 107.5 with 2026-11-01: market outcomes (2026-11-01); 106.1 with 2026-11-01: openvibe-shared 4.86.0 (2026-11-01); 105.3 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 103.3 with openvibe-shared 4.83.0: the -text status tokens add about 0.4 KB raw (102.9 KB measured on 4.82.0) (2026-11-01); 102.9 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 100.8 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 96.4 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 92.9 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 87.6 the phone home (2026-10-30: the fold rules for how it works and the questions); 83.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)
- cssBrotliKB: 24.6, // 24.6 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 24.1 with 2026-11-02: openvibe-shared 4.100.1 (the conversation kit's parts, date covers, room CSS) (2026-11-02); 23.8 with 2026-11-01: openvibe-shared 4.91.0 (card faces, audio-only player, busy buttons) (2026-11-01); 23.1 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 22.8 with openvibe-shared 4.82.0: room over the rounded measure (2026-11-01); 22.5 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 22.0 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 20.1 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 19.3 the phone home (2026-10-30: the fold rules for how it works and the questions); 18.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 16.5 kit.css and room.css on the home (2026-10-29, see cssRawKB); 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6
+ cssRawKB: 112.8, // 112.8 with daily quest styles (2026-11-09); 111.9 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 111.8 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 111.4 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 111.3 with openvibe-shared 4.113.0 (2026-11-06: the kit's badge at 12 px and the room link's touch hit area); 111.1 with 2026-11-06: the reward badge note at 12 px on phones; 110.6 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 109.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 108.5 with 2026-11-01: openvibe-shared 4.91.0 (card faces, audio-only player, busy buttons) (2026-11-01); 107.5 with 2026-11-01: market outcomes (2026-11-01); 106.1 with 2026-11-01: openvibe-shared 4.86.0 (2026-11-01); 105.3 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 103.3 with openvibe-shared 4.83.0: the -text status tokens add about 0.4 KB raw (102.9 KB measured on 4.82.0) (2026-11-01); 102.9 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 100.8 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 96.4 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 92.9 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 87.6 the phone home (2026-10-30: the fold rules for how it works and the questions); 83.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)
+ cssBrotliKB: 24.7, // 24.7 with daily quest styles (2026-11-09); 24.6 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 24.1 with 2026-11-02: openvibe-shared 4.100.1 (the conversation kit's parts, date covers, room CSS) (2026-11-02); 23.8 with 2026-11-01: openvibe-shared 4.91.0 (card faces, audio-only player, busy buttons) (2026-11-01); 23.1 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 22.8 with openvibe-shared 4.82.0: room over the rounded measure (2026-11-01); 22.5 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 22.0 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 20.1 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 19.3 the phone home (2026-10-30: the fold rules for how it works and the questions); 18.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 16.5 kit.css and room.css on the home (2026-10-29, see cssRawKB); 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6
externalFiles: 0, // 0
externalFiles: 0, // 0
};
};