openvibers/openvibe.design Commits
Accept reviewable glyph draft requests from services (#200)
Changes
@@@ -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.@@@ -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 {@@@ -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);nnew file mode 100644@@@ -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); });
@@ -40,6 +40,8 @@ Network's theme API.
@@ -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.
`/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.
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.
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.
@@ -64,7 +64,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
@@ -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(() => {});
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 (!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 });
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 who = subject || `guest:${guest.id}`;
@@ -96,7 +96,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
@@ -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
// 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
// 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.
// 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 = [];
let got = [];
try {
try {
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
* GET /api/v1/glyphs/drafts approved drafts, for openvibe-shared to take in (an admin may ask for others)
* GET /api/v1/glyphs/drafts approved drafts, for openvibe-shared to take in (an admin may ask for others)
*/
*/
const express = require('express');
const express = require('express');
-const { http } = require('openvibe-contracts');
+const { http, serviceAuth, capabilities } = require('openvibe-contracts');
const glyphs = require('openvibe-shared/glyphs');
const glyphs = require('openvibe-shared/glyphs');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { send, baseUrl } = require('../render/layout');
const { send, baseUrl } = require('../render/layout');
@@ -87,6 +87,51 @@ function createIconRoutes(ctx) {
@@ -87,6 +87,51 @@ function createIconRoutes(ctx) {
const isAdmin = (req) => !!(req.viewer && req.viewer.kind === 'user' && req.viewer.role === 'admin');
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 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 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) => {
r.get('/icons', async (req, res) => {
const subject = subjectOf(req);
const subject = subjectOf(req);
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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); });