openvibers/openvibe.design Commits

Icons: draw one without signing in, inside caps (#83)

OP OpenVibers committed 12 days ago · 6c8feaf539 · 8 files, +161 −24

Plan L50 step 5 (the owner: "without login, but prevent abuse"). A guest gets a random id in an httpOnly cookie,
stored only as a keyed hash; their drafts are kept under guest:<hash>, shown on that browser, reviewed like any.
Each attempt is a row (migration 0008, expand) with the guest hash and the address salted with the day (never the
address): per guest 2 a day, per address 4, all guests 25 (config), checked before any model is asked; a guest runs
on OpenVibe's key, not a person's. A guest's post must come from openvibe.design. Cloudflare Turnstile checks the
form when both of its keys are set (DESIGN_TURNSTILE_*), with the CSP opened only then.

Changes

ddiff --git a/.env.example b/.env.example
iindex c8ab1f0..ab9e1a5 100644
---- a/.env.example
++++ b/.env.example
@@@ -61,3 +61,11 @@ OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230
# DESIGN_EVENTS_SECRET=
# DESIGN_EVENTS_URL=
# DESIGN_EVENTS_ENDPOINT=
++
++# Drawing an icon without signing in (plan L50 step 5): caps a day per guest, per address and for all guests.
++DESIGN_GLYPH_GUEST_PER_DAY=2
++DESIGN_GLYPH_GUEST_PER_ADDRESS=4
++DESIGN_GLYPH_GUEST_DAILY=25
++# Cloudflare Turnstile on the guest form: on only when both are set (the site key is public, the secret is not).
++DESIGN_TURNSTILE_SITE_KEY=
++DESIGN_TURNSTILE_SECRET=
ddiff --git a/migrations/0008_glyph_guest_draws.sql b/migrations/0008_glyph_guest_draws.sql
nnew file mode 100644
iindex 0000000..54a4583
---- /dev/null
++++ b/migrations/0008_glyph_guest_draws.sql
@@@ -0,0 +1,14 @@
++-- phase: expand
++-- Drawing an icon without signing in (plan L50 step 5; the owner, 11-01: "without login, but prevent abuse"). Each
++-- guest attempt is one row: the guest (a hash of their browser's random id, never the id itself) and a salted hash of
++-- the address that day (never the address). The caps count these rows: per guest a day, per address a day, and all
++-- guests together a day, so a guest's cost is bounded whatever they try. Rows older than two days are pruned.
++CREATE TABLE IF NOT EXISTS glyph_guest_draws (
++ id bigserial PRIMARY KEY,
++ guest text COLLATE "C" NOT NULL,
++ ip_hash text COLLATE "C" NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX IF NOT EXISTS glyph_guest_draws_guest ON glyph_guest_draws (guest, created_at DESC);
++CREATE INDEX IF NOT EXISTS glyph_guest_draws_ip ON glyph_guest_draws (ip_hash, created_at DESC);
++CREATE INDEX IF NOT EXISTS glyph_guest_draws_at ON glyph_guest_draws (created_at DESC);
ddiff --git a/server/app.js b/server/app.js
iindex 2bf578e..fabcbf8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -88,6 +88,8 @@ async function createApp(opts = {}) {
const app = express();
app.disable('x-powered-by');
++ // Cloudflare Turnstile on the guest icon form (L50 step 5), only when both of its keys are set.
++ const turnstileSrc = config.glyphGuests && config.glyphGuests.turnstileSiteKey && config.glyphGuests.turnstileSecret ? ['https://challenges.cloudflare.com'] : [];
app.set('trust proxy', config.trustProxy);
app.locals.ctx = ctx;
@@@ -106,7 +108,7 @@ async function createApp(opts = {}) {
directives: {
defaultSrc: ["'self'"],
// The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
-- scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', ...turnstileSrc],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
// Theme previews may carry inline data: images and any https image the catalogue references.
@@@ -114,7 +116,7 @@ async function createApp(opts = {}) {
// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
// 'self' covers the Arena's about:srcdoc iframes; generated pages cannot navigate the top window.
-- frameSrc: ["'self'", 'https://openvibe.network'],
++ frameSrc: ["'self'", 'https://openvibe.network', ...turnstileSrc],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
ddiff --git a/server/config.js b/server/config.js
iindex 814d63e..43688bf 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -73,6 +73,16 @@ function load(env = process.env) {
scope: env.DESIGN_BENCH_SCOPE || 'bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.entry.create bench.board.read',
},
++ // Drawing an icon without signing in (plan L50 step 5): caps per guest, per address and for all guests a day,
++ // and Cloudflare Turnstile when both of its keys are set (the guest form then carries its widget).
++ glyphGuests: {
++ perGuest: Math.max(0, int(env.DESIGN_GLYPH_GUEST_PER_DAY, 2)),
++ perAddress: Math.max(0, int(env.DESIGN_GLYPH_GUEST_PER_ADDRESS, 4)),
++ daily: Math.max(0, int(env.DESIGN_GLYPH_GUEST_DAILY, 25)),
++ turnstileSiteKey: env.DESIGN_TURNSTILE_SITE_KEY || '',
++ turnstileSecret: env.DESIGN_TURNSTILE_SECRET || '',
++ },
++
// Design Studio part B: a small per-design AI edit budget (click edits and undo do not spend AI).
studio: {
chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),
ddiff --git a/server/glyphs/drafts.js b/server/glyphs/drafts.js
iindex 45067e6..7afd557 100644
---- a/server/glyphs/drafts.js
++++ b/server/glyphs/drafts.js
@@@ -5,7 +5,9 @@
* each one. Approved drafts are what openvibe-shared's scripts/pull-glyph-drafts.js takes into the registry; nothing
* reaches a page without that review and a Shared release.
*
-- * draft({ subject, name, sentence }) → { kept: [draft…], refused: [why…] } (at most PER_DAY a person per day)
++ * draft({ subject, name, sentence, guest }) → { kept: [draft…], refused: [why…] } (at most PER_DAY a person per day)
++ * guest: { id, ipHash } for someone not signed in (L50 step 5): subject is then guest:<id>, and the guest caps
++ * (per guest, per address, all guests, a day; config.glyphGuests) are checked and the attempt recorded first.
* list({ status, subject, limit }) → drafts, newest first
* review({ id, action: 'approve' | 'reject', reviewer })
*/
@@@ -34,18 +36,36 @@ reads at 16 px, in the hand of a clean line icon set. A dot is a zero-length lin
different idea, not the same drawing moved. Answer with JSON only:
{"candidates": ["<path d=\\"…\\"/>", …]} with ${CANDIDATES} different candidates.${examples()}`;
--function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
++function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests = { perGuest: 2, perAddress: 4, daily: 25 } }) {
const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;
const iso = () => new Date(clock.now()).toISOString();
-- async function draft({ subject, name, sentence = '' }) {
-- if (!subject) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });
++ /** A guest's three caps, then their attempt recorded (before any model is asked, so a failure counts too). */
++ async function admitGuest(guest) {
++ const count = async (where, args) => Number(((await db.maybe(`SELECT count(*)::int AS n FROM glyph_guest_draws WHERE ${where} AND created_at > now() - interval '1 day'`, args)) || {}).n || 0);
++ const busy = (msg) => Object.assign(new Error(msg), { status: 429 });
++ if (!guests.daily || await count('true', []) >= guests.daily) throw busy('Guests have drawn all the icons they can today. Sign in to draw more, or come back tomorrow.');
++ if (!guests.perAddress || await count('ip_hash = $1', [guest.ipHash]) >= guests.perAddress) throw busy('That is all the icons this connection can draw today without signing in. Sign in to draw more.');
++ if (!guests.perGuest || await count('guest = $1', [guest.id]) >= guests.perGuest) throw busy(`Without signing in you can draw ${guests.perGuest} icon${guests.perGuest === 1 ? '' : 's'} a day. Sign in to draw more.`);
++ await db.exec('INSERT INTO glyph_guest_draws (guest, ip_hash) VALUES ($1, $2)', [guest.id, guest.ipHash]);
++ // Two days is enough to count a day; older rows go.
++ db.exec("DELETE FROM glyph_guest_draws WHERE created_at < now() - interval '2 days'").catch(() => {});
++ }
++
++ async function draft({ subject, name, sentence = '', guest = null }) {
++ if (!subject && !guest) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });
++ if (guest && !/^[0-9a-f]{16,64}$/.test(String(guest.id || ''))) throw Object.assign(new Error('this browser cannot draw without signing in'), { status: 400 });
++ const who = subject || `guest:${guest.id}`;
const n = String(name || '').trim().toLowerCase();
if (!NAME_RE.test(n)) throw Object.assign(new Error('a name is 2 to 32 lowercase letters, digits and dashes, like "hot-tub"'), { status: 422 });
const what = String(sentence || '').trim().slice(0, 200);
-- const today = await db.maybe("SELECT count(*)::int AS n FROM glyph_drafts WHERE subject = $1 AND created_at > now() - interval '1 day'", [subject]);
-- if ((today && today.n) >= PER_DAY) throw Object.assign(new Error(`${PER_DAY} drafts a day: more tomorrow`), { status: 429 });
if (!ai || !ai.enabled) throw Object.assign(new Error('drawing needs OpenVibe.AI, which is not configured here'), { status: 503 });
++ if (subject) {
++ const today = await db.maybe("SELECT count(*)::int AS n FROM glyph_drafts WHERE subject = $1 AND created_at > now() - interval '1 day'", [subject]);
++ if ((today && today.n) >= PER_DAY) throw Object.assign(new Error(`${PER_DAY} drafts a day: more tomorrow`), { status: 429 });
++ } else {
++ await admitGuest(guest);
++ }
const ask = async (extra = '') => {
const answer = await ai.text({
system: SYSTEM,
@@@ -53,7 +73,8 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
maxTokens: 1800,
target: { service: 'design', type: 'glyph_draft', id: n },
prefer: { provider: 'anthropic', model: 'claude-opus-5-5' }, // Claude first, soft, on the person's key (L37)
-- person: subject,
++ // A person's own key when they have one (L37); a guest runs on OpenVibe's, inside the caps above.
++ ...(subject ? { person: subject } : {}),
});
let got = [];
try {
@@@ -78,7 +99,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
if (!r.ok) { refused.push(r.why); continue; }
if (seen.has(r.svg)) continue;
seen.add(r.svg);
-- const row = { id: id(), subject, name: n, sentence: what, svg: r.svg, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };
++ const row = { id: id(), subject: who, name: n, sentence: what, svg: r.svg, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };
await db.exec('INSERT INTO glyph_drafts (id, subject, name, sentence, svg, provider, model, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
[row.id, row.subject, row.name, row.sentence, row.svg, row.provider, row.model, row.status, row.created_at]);
kept.push(row);
ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex 83e9fc7..a612f73 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -17,7 +17,8 @@
* it with its OpenVibe twin (L50 step 4); POST /api/v1/glyphs/migrate as data
* GET /icons/kit, POST /icons/kit pick glyphs, get a font with only those (WOFF2, TTF, CSS, code points) as a ZIP
* GET /icons/kit.zip?names=&name=&fa= the same kit from a link
-- * POST /icons/draft signed in: OpenVibe.AI drafts candidates (same-origin form)
++ * POST /icons/draft OpenVibe.AI drafts candidates (same-origin form); signed out too, inside the
++ * guest caps (L50 step 5), behind Cloudflare Turnstile when it is configured
* POST /icons/drafts/:id/review an admin approves or rejects one (same-origin form)
* GET /api/v1/glyphs the registry as data; ?names=a,b,fa-c (up to 120) for just those, with solids
* GET /api/v1/glyphs/drafts approved drafts, for openvibe-shared to take in (an admin may ask for others)
@@@ -27,7 +28,8 @@ const { http } = require('openvibe-contracts');
const glyphs = require('openvibe-shared/glyphs');
const { asyncRouter } = require('./router');
const { send, baseUrl } = require('../render/layout');
--const { writeAllowed } = require('./same-origin');
++const { writeAllowed, sameOrigin } = require('./same-origin');
++const nodeCrypto = require('crypto');
const { iconsPage, glyphsJson } = require('../views/icons');
const { glyphPage, faAlternativePage, kitPage, migratePage } = require('../views/icon-page');
const aliases = require('../glyphs/aliases');
@@@ -45,7 +47,39 @@ const OPEN = { 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy
function createIconRoutes(ctx) {
const { config, s, ai, log = console } = ctx;
-- const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai });
++ const guestCaps = config.glyphGuests || { perGuest: 2, perAddress: 4, daily: 25 };
++ const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai, guests: guestCaps });
++ // A guest (L50 step 5): a random id in an httpOnly cookie, stored only as a keyed hash; the address only as a hash
++ // salted with the day. The key is the service's own secret (a new one each start where none is set: development).
++ const GUEST_COOKIE = 'ovd_guest';
++ const guestKey = (config.oauth && config.oauth.clientSecret) ? `glyph-guest:${config.oauth.clientSecret}` : nodeCrypto.randomBytes(32).toString('hex');
++ const keyed = (v) => nodeCrypto.createHmac('sha256', guestKey).update(String(v)).digest('hex').slice(0, 40);
++ const guestOf = (req, res, { make = false } = {}) => {
++ let raw = String((req.cookies && req.cookies[GUEST_COOKIE]) || '');
++ if (!/^[0-9a-f]{32}$/.test(raw)) {
++ if (!make) return null;
++ raw = nodeCrypto.randomBytes(16).toString('hex');
++ res.cookie(GUEST_COOKIE, raw, { httpOnly: true, sameSite: 'lax', secure: /^https:/.test(String(config.baseUrl || '')), maxAge: 365 * 86400 * 1000, path: '/icons' });
++ }
++ const day = new Date().toISOString().slice(0, 10);
++ return { id: keyed(raw), ipHash: keyed(`${day}|${req.ip || ''}`) };
++ };
++ // Cloudflare Turnstile, when both keys are set: the guest form carries its widget and the token is checked here.
++ const turnstileOn = !!(guestCaps.turnstileSiteKey && guestCaps.turnstileSecret);
++ async function turnstileOk(req) {
++ if (!turnstileOn) return true;
++ const token = String((req.body || {})['cf-turnstile-response'] || '');
++ if (!token || token.length > 2048) return false;
++ try {
++ const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
++ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ secret: guestCaps.turnstileSecret, response: token, ...(req.ip ? { remoteip: req.ip } : {}) }).toString(),
++ signal: AbortSignal.timeout(5000),
++ });
++ const j = await r.json().catch(() => ({}));
++ return j.success === true;
++ } catch { return false; }
++ }
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '8kb' });
const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
@@@ -56,13 +90,14 @@ function createIconRoutes(ctx) {
r.get('/icons', async (req, res) => {
const subject = subjectOf(req);
const reviewer = isAdmin(req);
++ const guest = subject ? null : guestOf(req, res);
const [mine, waiting] = await Promise.all([
-- subject ? drafts.list({ subject, limit: 24 }).catch(() => []) : [],
++ subject ? drafts.list({ subject, limit: 24 }).catch(() => []) : guest ? drafts.list({ subject: `guest:${guest.id}`, limit: 24 }).catch(() => []) : [],
reviewer ? drafts.list({ status: 'draft', limit: 60 }).catch(() => []) : [],
]);
const notice = req.query.drafted != null ? (Number(req.query.drafted) ? `${Number(req.query.drafted)} candidate${Number(req.query.drafted) === 1 ? '' : 's'} drawn: they wait for review below.` : 'No candidate passed the checks this time: try saying more about it.') : req.query.reviewed ? `Draft ${String(req.query.reviewed).slice(0, 20)}.` : '';
const draw = /^[a-z][a-z0-9-]{1,31}$/.test(String(req.query.draw || '')) ? String(req.query.draw) : '';
-- send(res, 200, iconsPage({ base: baseUrl(), mine, drafts: waiting, reviewer, notice, error: String(req.query.error || '').slice(0, 200), draw }), { viewer: req.viewer });
++ send(res, 200, iconsPage({ base: baseUrl(), mine, drafts: waiting, reviewer, notice, error: String(req.query.error || '').slice(0, 200), draw, guest: !subject ? { perDay: guestCaps.perGuest, turnstile: turnstileOn ? guestCaps.turnstileSiteKey : '' } : null }), { viewer: req.viewer, ...(guest ? { cacheControl: 'private, no-store' } : {}) });
});
r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));
@@@ -144,9 +179,16 @@ function createIconRoutes(ctx) {
r.post('/icons/draft', form, async (req, res) => {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
const subject = subjectOf(req);
-- if (!subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/icons#draw')}`);
++ let guest = null;
++ if (!subject) {
++ // A guest's post must come from this site (a cross-site form could spend the guests' day), and pass
++ // Turnstile when it is on.
++ if (!sameOrigin(req, config)) return res.status(403).send('Open this form on openvibe.design.');
++ if (!await turnstileOk(req)) return back(res, { error: 'The check that you are a person did not pass: try again.', draw: String((req.body || {}).name || '') });
++ guest = guestOf(req, res, { make: true });
++ }
try {
-- const out = await drafts.draft({ subject, name: (req.body || {}).name, sentence: (req.body || {}).sentence });
++ const out = await drafts.draft({ subject, guest, name: (req.body || {}).name, sentence: (req.body || {}).sentence });
back(res, { drafted: String(out.kept.length), draw: String((req.body || {}).name || '') });
} catch (err) {
if (!err.status || err.status >= 500) log.warn('[OpenVibe.Design] glyph draft failed:', err && err.message);
ddiff --git a/server/views/icons.js b/server/views/icons.js
iindex e45be35..497b3d4 100644
---- a/server/views/icons.js
++++ b/server/views/icons.js
@@@ -38,7 +38,7 @@ function stillFontAwesome() {
const inner = (name) => glyphs.path(name);
const svg = (d, size = 24) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${d}</svg>`;
--function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], reviewer = false, notice = '', error = '', draw = '' } = {}) {
++function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], reviewer = false, notice = '', error = '', draw = '', guest = null } = {}) {
const fa = faByGlyph();
const left = stillFontAwesome();
const covered = usage.names.filter((n) => glyphs.fromFa(n.name)).reduce((a, n) => a + n.uses, 0);
@@@ -97,9 +97,11 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
<form class="ic-form" method="post" action="/icons/draft">
<label><span>Name</span><input name="name" required pattern="[a-z][a-z0-9-]{1,31}" maxlength="32" placeholder="hot-tub" value="${esc(draw)}" autocomplete="off" spellcheck="false"></label>
<label class="ic-grow"><span>What it stands for</span><input name="sentence" maxlength="200" placeholder="A tub with steam over it: a hot tub at a rental"></label>
++ ${guest && guest.turnstile ? `<div class="cf-turnstile ic-human" data-sitekey="${esc(guest.turnstile)}" data-theme="auto" data-size="flexible"></div><script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>` : ''}
<button type="submit" class="ic-btn is-go">${kit.icon('spark')} Draw it</button>
</form>
-- ${mine.length ? `<h3>Your drafts</h3><ul class="ic-drafts">${mine.map((d) => draftCard(d)).join('')}</ul>` : ''}
++ ${guest ? `<p class="ic-more">No account needed: you can draw ${guest.perDay} icon${guest.perDay === 1 ? '' : 's'} a day as a guest. <a href="/auth/login?next=${encodeURIComponent('/icons#draw')}">Sign in</a> to draw more and see your drafts on any device.</p>` : ''}
++ ${mine.length ? `<h3>${guest ? 'Your drafts, on this browser' : 'Your drafts'}</h3><ul class="ic-drafts">${mine.map((d) => draftCard(d)).join('')}</ul>` : ''}
${reviewer ? `<h3>Waiting for review</h3>${drafts.length ? `<ul class="ic-drafts">${drafts.map((d) => draftCard(d, reviewForm(d))).join('')}</ul>` : '<p class="ic-more">Nothing waiting.</p>'}` : ''}
</section>`;
ddiff --git a/test/icons.test.js b/test/icons.test.js
iindex ff1718b..871b7c0 100644
---- a/test/icons.test.js
++++ b/test/icons.test.js
@@@ -88,13 +88,51 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
]) assert.equal(clean(bad).ok, false, what);
});
-- await check('drawing needs a signed-in person and this site\'s own form', async () => {
-- const anon = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'hot-tub' }), origin: ORIGIN });
-- assert.equal(anon.status, 303);
-- assert.match(anon.headers.get('location'), /^\/auth\/login\?next=/);
++ await check('drawing takes this site\'s own form, signed in or not', async () => {
const cross = await t.get('/icons/draft', { method: 'POST', as: ana, ...form({ name: 'hot-tub' }), origin: 'https://evil.test' });
assert.equal(cross.status, 403);
-- assert.equal(asked.length, 0, 'no AI call for either');
++ const anonCross = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'hot-tub' }), origin: 'https://evil.test' });
++ assert.equal(anonCross.status, 403, 'a guest\'s post must come from openvibe.design too');
++ const noOrigin = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'hot-tub' }) });
++ assert.equal(noOrigin.status, 403, 'and say so');
++ assert.equal(asked.length, 0, 'no AI call for any of them');
++ });
++
++ await check('a guest draws without signing in, inside the caps: per guest, per address, all guests', async () => {
++ answer = JSON.stringify({ candidates: ['<rect x="4" y="10" width="16" height="8" rx="2"/><line x1="8" y1="4" x2="8" y2="7"/>', '<path d="M5 10h14v8H5z"/><path d="M9 6c0-1 1-1 1-2"/>'] });
++ const before = asked.length;
++ const page0 = await t.get('/icons');
++ assert.ok(page0.text.includes('you can draw 2 icons a day as a guest'), 'the form says what a guest may do');
++ const first = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'kettle', sentence: 'A kettle' }), origin: ORIGIN });
++ assert.equal(first.status, 303);
++ assert.match(first.headers.get('location'), /^\/icons\?drafted=2&draw=kettle#draw$/);
++ const cookie = (first.headers.get('set-cookie') || '').split(';')[0];
++ assert.match(cookie, /^ovd_guest=[0-9a-f]{32}$/, 'a random guest id, in a cookie');
++ assert.match(first.headers.get('set-cookie'), /HttpOnly/i);
++ assert.equal(asked.length, before + 1);
++ assert.equal(asked[asked.length - 1].person, undefined, 'a guest runs on OpenVibe\'s key, not a person\'s');
++ const row = await t.store.db.maybe("SELECT subject FROM glyph_drafts WHERE name = 'kettle'");
++ assert.match(row.subject, /^guest:[0-9a-f]{40}$/, 'stored under a keyed hash, never the id itself');
++ const mine = await t.get('/icons', { headers: { cookie } });
++ assert.ok(mine.text.includes('<h3>Your drafts, on this browser</h3>'), 'the guest sees their drafts');
++ assert.match(mine.headers.get('cache-control'), /no-store/);
++ const said = (r) => decodeURIComponent(String(r.headers.get('location') || '').replace(/\+/g, ' '));
++ const asGuest = (f) => { const x = form(f); return { ...x, headers: { ...x.headers, cookie } }; };
++ const second = await t.get('/icons/draft', { method: 'POST', ...asGuest({ name: 'teapot' }), origin: ORIGIN });
++ assert.match(second.headers.get('location'), /drafted=2/);
++ const third = await t.get('/icons/draft', { method: 'POST', ...asGuest({ name: 'mugs' }), origin: ORIGIN });
++ assert.match(said(third), /you can draw 2 icons a day/, 'the guest\'s day is spent');
++ assert.equal(asked.length, before + 2, 'and no model was asked for the third');
++ // Two drawn so far from this address; two more from fresh browsers reach its four, the fifth is refused.
++ const fresh = () => t.get('/icons/draft', { method: 'POST', ...form({ name: 'cups' }), origin: ORIGIN });
++ assert.match(said(await fresh()), /drafted=2/);
++ assert.match(said(await fresh()), /drafted=2/);
++ assert.match(said(await fresh()), /this connection can draw today/, 'a new cookie does not reset the address');
++ const rows = await t.store.db.many('SELECT guest, ip_hash FROM glyph_guest_draws');
++ assert.ok(rows.length >= 4 && rows.every((r) => /^[0-9a-f]{40}$/.test(r.guest) && /^[0-9a-f]{40}$/.test(r.ip_hash)), 'only hashes are kept');
++ await t.store.db.exec('DELETE FROM glyph_guest_draws');
++ await t.store.db.exec("DELETE FROM glyph_drafts WHERE subject LIKE 'guest:%'");
++ asked.length = 0;
});
await check('OpenVibe.AI drafts candidates; the checked ones are kept, the rest refused', async () => {