openvibers/openvibe.quest Commits

Add shared event-backed daily quests (#232)

OP OpenVibers committed 4 days ago · 9a56c16e08 · 21 files, +498 −24

* Add shared daily quests from verified network events

* Merge remote-tracking branch 'origin/main' into feat/daily-quest-1108

* Cover daily quest caller budgets and measured home size

* Merge remote-tracking branch 'origin/main' into HEAD

* openvibe-contracts v0.355.0: the daily quest's steps (quest.steps.contribute, quest.steps.review) are in it

Changes

ddiff --git a/.env.example b/.env.example
iindex 0f66898..c0fa03f 100644
---- a/.env.example
++++ b/.env.example
@@@ -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.network
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_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.quest/auth/callback
ddiff --git a/README.md b/README.md
iindex 355d5f9..1e47057 100644
---- a/README.md
++++ b/README.md
@@@ -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, each
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.
ddiff --git a/migrations/0010_daily_quests.sql b/migrations/0010_daily_quests.sql
nnew file mode 100644
iindex 0000000..0c7cc62
---- /dev/null
++++ b/migrations/0010_daily_quests.sql
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
iindex 5ff9d93..5cb19a9 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex d24843c..c7d8fef 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/css/app.css b/public/css/app.css
iindex af7008a..b852840 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex 774db68..09a4529 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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
ddiff --git a/server/events-consumer.js b/server/events-consumer.js
iindex c3e2bdc..247933a 100644
---- a/server/events-consumer.js
++++ b/server/events-consumer.js
@@@ -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),
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 97089a4..95c990f 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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))) });
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 57ea728..f85b3ce 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -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 }) {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 11035dc..1b973ef 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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 });
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 735e4f8..63eb69f 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -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 });
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 98e5394..5a66961 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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). */
ddiff --git a/server/identity/subject-merge.js b/server/identity/subject-merge.js
iindex 3329467..27c2293 100644
---- a/server/identity/subject-merge.js
++++ b/server/identity/subject-merge.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex b8e5cd2..364edea 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 };
}
ddiff --git a/server/quests/coins.js b/server/quests/coins.js
iindex 197650b..8199bcb 100644
---- a/server/quests/coins.js
++++ b/server/quests/coins.js
@@@ -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, n
user_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,
},
});
ddiff --git a/server/quests/daily.js b/server/quests/daily.js
nnew file mode 100644
iindex 0000000..f8a7f6d
---- /dev/null
++++ b/server/quests/daily.js
@@@ -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 };
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex 12ff9f7..02ebda2 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -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 () => {
ddiff --git a/test/daily.test.js b/test/daily.test.js
nnew file mode 100644
iindex 0000000..6806d34
---- /dev/null
++++ b/test/daily.test.js
@@@ -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); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 14fb247..fdcaf4b 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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}`;
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex cc18a56..c55eaef 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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.6
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
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)
-- 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
};