openvibers/openvibe.design Commits

Accept reviewable glyph draft requests from services (#200)

OP OpenVibers committed 4 days ago · 1e5d5cab62 · 4 files, +110 −3

* Accept reviewed glyph draft requests from services

* Merge remote-tracking branch 'origin/main' into feat/glyph-draft-api-1109

# Conflicts:
#	server/glyphs/drafts.js

Changes

ddiff --git a/README.md b/README.md
iindex e093307..20df6fc 100644
---- a/README.md
++++ b/README.md
@@@ -40,6 +40,8 @@ Network's theme API.
`/create` takes one prompt and shows three model variants on `/d/:id`. The variants use the Arena's OpenVibe.AI route pins and HTML sanitizer. The canvas supports phone, tablet and desktop widths, and changing the token theme repaints all frames. A signed-in design belongs to its Network subject. A guest receives a secret link, kept among the last three drafts in a private cookie. Guests can create two designs per day per IP; signed-in people can create ten. Generation progress is polled, and completed frames appear individually.
++First-party services can request a missing glyph with `POST /api/v1/glyphs/drafts` and a service token for audience `openvibe.design` carrying `design.glyph.draft`. Send `{ "name": "daily-2026-11-09", "sentence": "What this icon should mean" }`. The response names pending draft IDs; repeated requests return the same IDs without drawing again. Candidates use the cheapest AI preset and wait in the existing `/icons` staff review queue. Only approved drafts appear in `GET /api/v1/glyphs/drafts?status=approved`; a requesting site keeps a bundled glyph until then.
++
Pick one frame with **Use this design**: it becomes the working frame, its HTML becomes version 1 and the pick is recorded as one Arena vote through OpenVibe.Bench (a signed-in person votes once; a guest's pick is saved without a vote). Edit it by chat (`POST /api/v1/designs/:id/edit`) or by clicking an element: the iframe reports `body > …:nth-of-type(n)`, and the floating panel changes its text, theme-token colour, padding or alignment. The server validates the path, tag and values, escapes the text, rebuilds only the inline style and saves a new version; the overlay script is injected at render time and is never stored with the model HTML. Versions are append-only in `design_versions`; undo/redo move the pointer (`design_picks.current_seq`), and an edit after undo appends a new version instead of deleting the old ones. Chat edits cost AI money, so each design has a small AI-edit budget (`DESIGN_CHAT_EDITS_PER_DESIGN`, default 5); a failed attempt spends budget but never replaces the last good version.
The API is under `/api/v1/designs`; `/developers` lists the routes. `migrations/0002_design_studio.sql` stores each design and its JSON variants in one row, and `0003_design_working_versions.sql` adds the pick, its Bench vote fields, the AI-edit counter and the version table, using the service's existing PostgreSQL or development PGlite setup. Production needs the existing `DATABASE_URL` and `DATABASE_DIRECT_URL` roles and working OpenVibe.AI and OpenVibe.Bench service credentials (the Bench scope now includes `bench.entry.create` so Design can submit the generated variants to the pick-vote round). Checks, HTML/zip export, preview publishing and MCP remain part C.
ddiff --git a/server/glyphs/drafts.js b/server/glyphs/drafts.js
iindex 22e3b2c..ac548a0 100644
---- a/server/glyphs/drafts.js
++++ b/server/glyphs/drafts.js
@@@ -64,7 +64,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
db.exec("DELETE FROM glyph_guest_draws WHERE created_at < now() - interval '2 days'").catch(() => {});
}
-- async function draft({ subject, name, sentence = '', guest = null, styles = null, sourceSvg = '' }) {
++ async function draft({ subject, name, sentence = '', guest = null, service = false, styles = null, sourceSvg = '' }) {
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}`;
@@@ -96,7 +96,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
// aren't logged in… log in for the maximum tier, Anthropic"). Signed in: Claude Opus 5.5 first, on the
// person's own key when they have one, else OpenVibe's Anthropic offer. A guest: the cheapest offer that
// can do the job, on OpenVibe's, inside the guest caps above.
-- ...(subject ? { person: subject, prefer: CLAUDE } : { preset: { name: 'cheapest' } }),
++ ...(subject && !service ? { person: subject, prefer: CLAUDE } : { preset: { name: 'cheapest' } }),
});
let got = [];
try {
ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex a8b3d01..40698fb 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -25,7 +25,7 @@
* GET /api/v1/glyphs/drafts approved drafts, for openvibe-shared to take in (an admin may ask for others)
*/
const express = require('express');
--const { http } = require('openvibe-contracts');
++const { http, serviceAuth, capabilities } = require('openvibe-contracts');
const glyphs = require('openvibe-shared/glyphs');
const { asyncRouter } = require('./router');
const { send, baseUrl } = require('../render/layout');
@@@ -87,6 +87,51 @@ function createIconRoutes(ctx) {
const isAdmin = (req) => !!(req.viewer && req.viewer.kind === 'user' && req.viewer.role === 'admin');
const problem = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const back = (res, q) => res.redirect(303, `/icons?${new URLSearchParams(q)}#draw`);
++ const serviceDrafts = new Map();
++
++ // First-party requests use the same staff review queue as the icon form.
++ r.post('/api/v1/glyphs/drafts', express.json({ limit: '2kb' }), async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ const header = String(req.headers.authorization || '');
++ if (!header.startsWith('Bearer ')) return problem(res, req, 401, 'token.required', 'A service token is required.');
++ const publicKey = ctx.keys.get();
++ if (!publicKey) return problem(res, req, 503, 'identity.unavailable', 'The Network signing key is unavailable.');
++ const verified = serviceAuth.verifyServiceToken(header.slice(7).trim(), {
++ publicKey, issuer: config.networkIssuer, audience: 'openvibe.design',
++ });
++ if (!verified.ok) return problem(res, req, 401, verified.code || 'token.invalid', 'The service token did not verify.');
++ const claims = verified.claims;
++ if (claims.actor_type !== 'service' || !/^svc:[a-z0-9_-]+$/.test(String(claims.sub || '')) ||
++ !capabilities.grants(claims.cap, 'design.glyph.draft')) {
++ return problem(res, req, 403, 'capability.denied', 'This service cannot request glyph drafts.');
++ }
++ const name = String(req.body?.name || '').trim().toLowerCase();
++ const sentence = String(req.body?.sentence || '').trim();
++ if (!req.body || typeof req.body !== 'object' || Object.keys(req.body).some((k) => !['name', 'sentence'].includes(k)) ||
++ !/^[a-z][a-z0-9-]{1,31}$/.test(name) || sentence.length < 3 || sentence.length > 200) {
++ return problem(res, req, 422, 'design.glyph_draft_invalid', 'Use a short glyph name and a sentence up to 200 characters.');
++ }
++ const subject = `service:${claims.sub.slice(4)}`;
++ const key = `${subject}:${name}`;
++ const run = async () => {
++ const prior = await s.db.many("SELECT id,status FROM glyph_drafts WHERE subject=$1 AND name=$2 AND status IN ('draft','approved') ORDER BY created_at DESC LIMIT 8", [subject, name]);
++ if (prior.length) return { name, status: prior.some((d) => d.status === 'approved') ? 'approved' : 'pending',
++ draft_ids: prior.map((d) => d.id).sort(), created: false };
++ const out = await drafts.draft({ subject, name, sentence, service: true });
++ if (!out.kept.length) throw Object.assign(new Error('No candidate passed the glyph checks.'), { status: 503 });
++ return { name, status: 'pending', draft_ids: out.kept.map((d) => d.id).sort(), created: true };
++ };
++ if (!serviceDrafts.has(key)) serviceDrafts.set(key, run().finally(() => serviceDrafts.delete(key)));
++ try {
++ const result = await serviceDrafts.get(key);
++ return res.status(result.created ? 201 : 200).json(result);
++ } catch (err) {
++ if (!err.status || err.status >= 500) log.warn('[OpenVibe.Design] service glyph draft failed:', err && err.message);
++ return problem(res, req, err.status && err.status < 500 ? err.status : 503,
++ err.status === 429 ? 'design.glyph_draft_limit' : 'design.glyph_draft_unavailable',
++ err.status && err.status < 500 ? err.message : 'The glyph draft is unavailable.');
++ }
++ });
r.get('/icons', async (req, res) => {
const subject = subjectOf(req);
ddiff --git a/test/glyph-service-api.test.js b/test/glyph-service-api.test.js
nnew file mode 100644
iindex 0000000..e150688
---- /dev/null
++++ b/test/glyph-service-api.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const { boot, check, done } = require('./helpers/boot');
++
++const calls = [];
++const ai = { enabled: true, async text(args) {
++ calls.push(args);
++ return { text: JSON.stringify({ candidates: [
++ { line: '<path d="M5 12h14"/>', solid: '<path d="M5 11h14v2H5z"/>' },
++ { line: '<path d="M12 5v14"/>', solid: '<path d="M11 5h2v14h-2z"/>' },
++ ] }), provider: 'test', model: 'test' };
++} };
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++function token(t, cap = ['design.glyph.draft'], sub = 'svc:quest') {
++ const now = Math.floor(Date.now() / 1000);
++ const data = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64({ iss: t.network.url, sub, actor_type: 'service',
++ aud: ['openvibe.design'], cap, iat: now, exp: now + 300, jti: 'tok_glyph_test' })}`;
++ return `${data}.${crypto.sign('RSA-SHA256', Buffer.from(data), t.network.privatePem).toString('base64url')}`;
++}
++
++(async () => {
++ const t = await boot({ ai });
++ const boss = t.network.addUser('reviewer', { role: 'admin' });
++ const body = { name: 'daily-2026-11-09', sentence: 'A shared quest across several sites' };
++ const post = (bearer, json = body) => t.get('/api/v1/glyphs/drafts', { method: 'POST', json,
++ headers: bearer ? { authorization: `Bearer ${bearer}` } : {} });
++ try {
++ await check('service-only capability and validated body protect the draft API', async () => {
++ assert.equal((await post(null)).status, 401);
++ assert.equal((await post(t.network.userToken(boss))).status, 401);
++ assert.equal((await post(token(t, []))).status, 403);
++ assert.equal((await post(token(t, ['design.glyph.draft']), { ...body, svg: '<svg/>' })).status, 422);
++ assert.equal(calls.length, 0);
++ });
++ await check('a service request creates review drafts on the cheapest tier and deduplicates', async () => {
++ const first = await post(token(t));
++ assert.equal(first.status, 201, first.text.slice(0, 180));
++ assert.equal(first.json().status, 'pending');
++ assert.ok(first.json().draft_ids.length > 0);
++ assert.equal(calls.length, 1);
++ assert.deepEqual(calls[0].preset, { name: 'cheapest' });
++ assert.equal(calls[0].person, undefined, 'a service request never spends a person’s key');
++ const again = await post(token(t));
++ assert.equal(again.status, 200);
++ assert.equal(again.json().created, false);
++ assert.deepEqual(again.json().draft_ids, first.json().draft_ids);
++ assert.equal(calls.length, 1);
++ const review = await t.get(`/icons/drafts/${first.json().draft_ids[0]}/review`, { method: 'POST', as: boss,
++ body: new URLSearchParams({ action: 'approve' }).toString(),
++ headers: { 'content-type': 'application/x-www-form-urlencoded' }, origin: 'https://openvibe.design' });
++ assert.equal(review.status, 303);
++ const after = await post(token(t));
++ assert.equal(after.json().status, 'approved');
++ assert.equal(calls.length, 1);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exit(1); });