openvibers/openvibe.design Commits
Icon references: ask Exchange with Design's service token (#279)
Changes
@@@ -38,6 +38,15 @@ OV_AI_INTERNAL_URL=http://127.0.0.1:4700# DESIGN_AI_WAIT_MS=55000# DESIGN_AI_TIMEOUT_MS=120000# DESIGN_AI_POLL_MS=1500++++# OpenVibe.Exchange's icon reference search (the Exchange platform API, served by OpenVibe.AI): Design mints its own++# client-credentials token (audience openvibe.ai, scope exchange.search.icons), so no Exchange key is needed.++# EXCHANGE_SEARCH_KEY is the owner's override; Iconify is the fallback when neither answers.++# EXCHANGE_API_URL=https://api.openvibe.exchange++# DESIGN_EXCHANGE_AUDIENCE=openvibe.ai++# DESIGN_EXCHANGE_SCOPE=exchange.search.icons++# DESIGN_EXCHANGE_TIMEOUT_MS=5000++# EXCHANGE_SEARCH_KEY=# Three explicit provider:model slots; unset pins AI's generate-route offers (fewer than three falls back to its pool).# DESIGN_ARENA_MODELS=shared:gpt-5-mini,local:qwen2.5@@@ -43,7 +43,7 @@ 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. Reference icons for a draft come from OpenVibe.Exchange (`POST /v1/search/icons`) when `EXCHANGE_SEARCH_KEY` is set in the service environment; without it Design reads Iconify, applies the same licence filter and sends only names and concepts to the model. 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.++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. Reference icons for a draft come from OpenVibe.Exchange (`POST /v1/search/icons`, the Exchange platform API served by OpenVibe.AI) with Design's own Network client-credentials token (`exchange.search.icons`), so no key is needed; `EXCHANGE_SEARCH_KEY` is the owner's override. Without either, or when Exchange fails, Design reads Iconify, applies the same licence filter and sends only names and concepts to the model. 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.@@@ -64,6 +64,17 @@ function load(env = process.env) {maxHtmlBytes: 200 * 1024,},++ // OpenVibe.Exchange's icon reference search (the Exchange platform API at api.openvibe.exchange, served by++ // OpenVibe.AI): Design mints its own client-credentials token (audience openvibe.ai, scope++ // exchange.search.icons) and needs no Exchange key. EXCHANGE_SEARCH_KEY is the owner's override.++ exchange: {++ url: trim(env.EXCHANGE_API_URL || 'https://api.openvibe.exchange'),++ audience: env.DESIGN_EXCHANGE_AUDIENCE || 'openvibe.ai',++ scope: env.DESIGN_EXCHANGE_SCOPE || 'exchange.search.icons',++ key: String(env.EXCHANGE_SEARCH_KEY || ''),++ timeoutMs: Math.max(1000, int(env.DESIGN_EXCHANGE_TIMEOUT_MS, 5000)),++ },++// OpenVibe.Media: a starting picture is uploaded there by the page and the create request carries its id;// Design names its public file address in the prompt and never takes raw image bytes.media: {@@@ -1,8 +1,12 @@'use strict';--// Icon references for the glyph drafter. OpenVibe.Exchange is the network's own search (Seller and buyer API,--// POST /v1/search/icons with an Exchange key); Iconify is the fallback while this service has no key. The model--// receives names and concepts only; SVG paths are never sent to it.++// Icon references for the glyph drafter. OpenVibe.Exchange is the network's own search (the Exchange platform API,++// POST /v1/search/icons at api.openvibe.exchange, served by OpenVibe.AI). Design asks with its own Network++// client-credentials token (audience openvibe.ai, scope exchange.search.icons) and no Exchange key; an++// EXCHANGE_SEARCH_KEY override still wins when the owner sets one. Iconify is the fallback when neither answers. The++// model receives names and concepts only; SVG paths are never sent to it.++const { serviceAuth } = require('openvibe-contracts');++const EXCHANGE_API = 'https://api.openvibe.exchange';const ICONIFY_API = 'https://api.iconify.design';@@@ -18,27 +22,27 @@ function fromExchange(body, limit) {const license = clean(item && item.license, 80);if (!name || !set || !license) return [];return [{ name, set, setName: clean(item.title || item.name || set, 100).replace(/^[^:]+:\s*/, ''),-- author: clean(item.author, 100), license,++ author: clean(item.author, 100), license, source: 'exchange',licenseUrl: clean(item.license_url, 300), preview: clean(item.url || item.thumbnail, 500) }];});}--async function searchExchange(query, { fetchImpl, limit, key, baseUrl }) {++async function searchExchange(query, { fetchImpl, limit, authorization, baseUrl, timeoutMs }) {const response = await fetchImpl(`${String(baseUrl || EXCHANGE_API).replace(/\/+$/, '')}/v1/search/icons`, {method: 'POST',-- headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${key}` },++ headers: { accept: 'application/json', 'content-type': 'application/json', authorization },body: JSON.stringify({ q: query, limit }),-- signal: AbortSignal.timeout(5000),++ signal: AbortSignal.timeout(timeoutMs || 5000),});if (!response.ok) throw new Error(`Exchange icon references returned ${response.status}`);return fromExchange(await response.json(), limit);}--async function searchIconify(query, { fetchImpl, limit }) {++async function searchIconify(query, { fetchImpl, limit, timeoutMs }) {const url = new URL('/search', ICONIFY_API);url.searchParams.set('query', query);url.searchParams.set('limit', String(limit));-- const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });++ const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs || 5000) });if (!response.ok) throw new Error(`Icon references returned ${response.status}`);const body = await response.json();return (Array.isArray(body.icons) ? body.icons : []).slice(0, 8).flatMap((fullName) => {@@@ -49,28 +53,61 @@ async function searchIconify(query, { fetchImpl, limit }) {return [{ name, set: prefix, setName: String(collection.name || prefix).slice(0, 100),author: String((collection.author && collection.author.name) || '').slice(0, 100),license: String(license.spdx).slice(0, 80), licenseUrl: String(license.url || '').slice(0, 300),-- preview: `${ICONIFY_API}/${prefix}/${name}.svg` }];++ source: 'iconify', preview: `${ICONIFY_API}/${prefix}/${name}.svg` }];});}/**-- * Reference icons for a glyph name and sentence, from the Exchange when this service holds an Exchange key-- * (EXCHANGE_SEARCH_KEY; the owner creates it on openvibe.exchange) and from Iconify otherwise.++ * Reference icons for a glyph name and sentence. Exchange answers when this service holds an Exchange key++ * (EXCHANGE_SEARCH_KEY, the owner's override) or a Network service token can be minted for exchange.search.icons;++ * Iconify answers otherwise, and after any Exchange failure.*/async function searchReferences(query, {fetchImpl = globalThis.fetch,limit = 5,key = process.env.EXCHANGE_SEARCH_KEY,++ getToken,baseUrl = process.env.EXCHANGE_API_URL,++ timeoutMs = 5000,} = {}) {const q = String(query || '').trim().slice(0, 120);const n = Math.min(Math.max(Number(limit) || 5, 1), 8);if (!q) return [];-- if (key) {-- try { return await searchExchange(q, { fetchImpl, limit: n, key, baseUrl }); }-- catch { /* the fallback keeps a draft working while Exchange is unreachable */ }++ if (key || getToken) {++ try {++ const authorization = key ? `Bearer ${key}` : `Bearer ${await getToken()}`;++ return await searchExchange(q, { fetchImpl, limit: n, authorization, baseUrl, timeoutMs });++ } catch { /* the fallback keeps a draft working while Exchange is unreachable */ }}-- return await searchIconify(q, { fetchImpl, limit: n });++ return await searchIconify(q, { fetchImpl, limit: n, timeoutMs });++}++++/**++ * The service's reference search, bound to its own Network token. EXCHANGE_SEARCH_KEY, when the owner sets it,++ * replaces the token; with no secret the service cannot mint one and Iconify answers. searchReferences keeps its++ * direct-test form above.++ */++function createReferenceSearch({ config = {}, fetchImpl = globalThis.fetch, tokenClient } = {}) {++ const exchange = config.exchange || {};++ const key = String(exchange.key || '');++ const tokens = key ? null : (tokenClient || (config.oauth && config.oauth.clientSecret++ ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl || config.networkUrl || 'http://127.0.0.1:4000'}/oauth/token`,++ clientId: config.oauth.clientId || 'design',++ clientSecret: config.oauth.clientSecret,++ audience: exchange.audience || 'openvibe.ai',++ scope: exchange.scope || 'exchange.search.icons',++ fetchImpl,++ })++ : null));++ return (query, opts = {}) => searchReferences(query, {++ fetchImpl,++ ...opts,++ key: opts.key !== undefined ? opts.key : key,++ baseUrl: opts.baseUrl || exchange.url,++ getToken: opts.getToken || (tokens ? () => tokens.getToken() : undefined),++ timeoutMs: opts.timeoutMs || exchange.timeoutMs || 5000,++ });}--module.exports = { searchReferences, fromExchange };++module.exports = { searchReferences, createReferenceSearch, fromExchange };@@@ -37,6 +37,7 @@ const aliases = require('../glyphs/aliases');const kits = require('../glyphs/kits');const ex = require('../glyphs/export');const { createGlyphDrafts } = require('../glyphs/drafts');++const { createReferenceSearch } = require('../glyphs/references');const formats = require('../glyphs/formats');const fs = require('fs');const path = require('path');@@@ -49,7 +50,8 @@ const OPEN = { 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policyfunction createIconRoutes(ctx) {const { config, s, ai, log = console } = ctx;const guestCaps = config.glyphGuests || { perGuest: 2, perAddress: 4, daily: 25 };-- const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai, guests: guestCaps });++ const referenceSearch = ctx.referenceSearch || createReferenceSearch({ config });++ const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai, guests: guestCaps, referenceSearch });// 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';@@@ -103,7 +103,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], rconst path = candidate ? (style === 'solid' && d.solid_svg ? d.solid_svg : d.svg) : (glyphs.path(name) || '');return sample(path, size, width, style);};-- return `<details class="ic-compare"><summary>Compare styles with four neighbours</summary>${styles.map(([style, width]) => `<div class="ic-compare-row"><b>${style}</b>${[16, 20, 32].map((size) => `<div class="ic-compare-size"><small>${size}px</small><div>${samples(d.name, size, width, style, true)}${neighbours.map((n) => samples(n, size, width, style, false)).join('')}</div></div>`).join('')}</div>`).join('')}${(d.references_json || d.references || []).length ? `<p class="ic-compare-refs">References: ${(d.references_json || d.references).map((r) => `<a href="${esc(r.preview)}" target="_blank" rel="noopener">${esc(r.setName)} · ${esc(r.name)} · ${esc(r.license)}</a>`).join(' · ')}</p>` : ''}</details>`;++ return `<details class="ic-compare"><summary>Compare styles with four neighbours</summary>${styles.map(([style, width]) => `<div class="ic-compare-row"><b>${style}</b>${[16, 20, 32].map((size) => `<div class="ic-compare-size"><small>${size}px</small><div>${samples(d.name, size, width, style, true)}${neighbours.map((n) => samples(n, size, width, style, false)).join('')}</div></div>`).join('')}</div>`).join('')}${(d.references_json || d.references || []).length ? `<p class="ic-compare-refs">References: ${(d.references_json || d.references).map((r) => `<a href="${esc(r.preview)}" target="_blank" rel="noopener">${esc(r.setName)} · ${esc(r.name)} · ${esc(r.license)}${r.source ? ` · via ${esc(r.source)}` : ''}</a>`).join(' · ')}</p>` : ''}</details>`;};const draftCard = (d, actions = '') => `<li class="ic-draft is-${esc(d.status)}"><div class="ic-draft-art"${d.solid_svg ? ' title="The line drawing, and its solid twin as the sites show it at 18 px and under"' : ''}>${svg(d.svg, 40)}${d.solid_svg ? solidSvg(d.solid_svg, 20) : svg(d.svg, 20)}</div><div class="ic-draft-t"><b>${esc(d.name)}</b>${actions && d.sentence ? `<span>${esc(d.sentence)}</span>` : ''}<small>${esc(d.status === 'draft' ? 'waiting for review' : d.status)}${d.model ? ` · ${esc(d.model)}` : ''}</small></div>${actions}${actions ? compare(d) : ''}</li>`;const reviewForm = (d) => `<div class="ic-draft-acts">${['approve', 'reject'].map((a) => `<form method="post" action="/icons/drafts/${esc(d.id)}/review"><input type="hidden" name="action" value="${a}"><button type="submit" class="ic-btn${a === 'approve' ? ' is-go' : ''}">${a === 'approve' ? 'Approve' : 'Reject'}</button></form>`).join('')}</div>`;@@@ -1,6 +1,11 @@'use strict';const assert = require('node:assert/strict');--const { searchReferences } = require('../server/glyphs/references');++const { searchReferences, createReferenceSearch } = require('../server/glyphs/references');++++const iconifyAnswer = {++ icons: ['lucide:hot-tub', 'unknown:no-license'],++ collections: { lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } },++};(async () => {// With an Exchange key the drafter reads the network's own icon search, not Iconify.@@@ -19,33 +24,112 @@ const { searchReferences } = require('../server/glyphs/references');assert.equal(sent.headers.authorization, 'Bearer ovx_test');assert.deepEqual(JSON.parse(sent.body), { q: 'hot tub', limit: 5 });assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'hot tub', author: 'Lucide', license: 'ISC',-- licenseUrl: 'https://example.test/license', preview: 'https://cdn.test/lucide/hot-tub.svg' }]);++ source: 'exchange', licenseUrl: 'https://example.test/license', preview: 'https://cdn.test/lucide/hot-tub.svg' }]);++ }++ // Without a key but with a Network service token, Exchange answers with that token and names the source.++ {++ let requested, sent, minted = 0;++ const refs = await searchReferences('hot tub', { key: '', baseUrl: 'https://exchange.test', getToken: async () => {++ minted += 1; return 'svc_design';++ }, fetchImpl: async (url, opts) => {++ requested = new URL(url); sent = opts;++ return { ok: true, async json() { return { results: [++ { name: 'lucide:hot-tub', set: 'lucide', title: 'Lucide: hot tub', author: 'Lucide', license: 'ISC', url: 'https://cdn.test/lucide/hot-tub.svg' },++ ] }; } };++ } });++ assert.equal(minted, 1);++ assert.equal(requested.pathname, '/v1/search/icons');++ assert.equal(sent.headers.authorization, 'Bearer svc_design');++ assert.equal(refs[0].source, 'exchange');}-- // Without one it keeps Iconify as the fallback, with the same attribution and the same licence filter.++ // Without either it keeps Iconify as the fallback, with the same attribution and the same licence filter.{let requested;const refs = await searchReferences('hot tub', { key: '', fetchImpl: async (url) => {requested = new URL(url);-- return { ok: true, async json() { return {-- icons: ['lucide:hot-tub', 'unknown:no-license'],-- collections: { lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } },-- }; } };++ return { ok: true, async json() { return iconifyAnswer; } };} });assert.equal(requested.pathname, '/search');assert.equal(requested.searchParams.get('query'), 'hot tub');assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'Lucide', author: 'Lucide', license: 'ISC',-- licenseUrl: 'https://example.test/license', preview: 'https://api.iconify.design/lucide/hot-tub.svg' }]);++ source: 'iconify', licenseUrl: 'https://example.test/license', preview: 'https://api.iconify.design/lucide/hot-tub.svg' }]);++ }++ // A token that cannot be minted (Network down) does not stop a draft: the fallback answers.++ {++ let calls = 0;++ const refs = await searchReferences('hot tub', { key: '', getToken: async () => { throw new Error('no token'); }, fetchImpl: async (url) => {++ calls += 1;++ assert.equal(new URL(url).hostname, 'api.iconify.design');++ return { ok: true, async json() { return iconifyAnswer; } };++ } });++ assert.equal(calls, 1, 'the Iconify search carries its collections');++ assert.equal(refs[0].source, 'iconify');}// A configured Exchange that fails does not stop a draft: the fallback answers.{let calls = 0;const refs = await searchReferences('hot tub', { key: 'ovx_test', fetchImpl: async (url) => {calls += 1;-- if (calls === 1) return { ok: false, status: 503 };-- return { ok: true, async json() { return { icons: ['lucide:hot-tub'], collections: { lucide: { name: 'Lucide', license: { spdx: 'ISC' } } } }; } };++ if (new URL(url).hostname !== 'api.iconify.design') return { ok: false, status: 503 };++ return { ok: true, async json() { return iconifyAnswer; } };} });-- assert.equal(calls, 2);-- assert.equal(refs[0].name, 'hot-tub');++ assert.equal(calls, 2, 'the failed Exchange call and the Iconify fallback');++ assert.equal(refs[0].source, 'iconify');++ }++ // The service's own search: a Network client-credentials token for exchange.search.icons, minted from its++ // OAuth client, and Exchange called with it. No Exchange key anywhere.++ {++ const calls = [];++ const fetchImpl = async (url, opts) => {++ calls.push({ url: String(url), opts });++ if (new URL(url).pathname === '/oauth/token') {++ return { ok: true, async json() { return { access_token: 'svc_design', expires_in: 300 }; } };++ }++ return { ok: true, async json() { return { results: [++ { name: 'lucide:hot-tub', set: 'lucide', title: 'Lucide: hot tub', author: 'Lucide', license: 'ISC', url: 'https://cdn.test/lucide/hot-tub.svg' },++ ] }; } };++ };++ const search = createReferenceSearch({++ config: {++ networkInternalUrl: 'https://network.test',++ oauth: { clientId: 'design', clientSecret: 's3cret' },++ exchange: { url: 'https://exchange.test', audience: 'openvibe.ai', scope: 'exchange.search.icons', timeoutMs: 1000 },++ },++ fetchImpl,++ });++ const refs = await search('hot tub', { limit: 3 });++ const token = calls.find((c) => new URL(c.url).pathname === '/oauth/token');++ assert.equal(token.url, 'https://network.test/oauth/token');++ const form = new URLSearchParams(String(token.opts.body));++ assert.equal(form.get('grant_type'), 'client_credentials');++ assert.equal(form.get('client_id'), 'design');++ assert.equal(form.get('audience'), 'openvibe.ai');++ assert.equal(form.get('scope'), 'exchange.search.icons');++ const exchange = calls.find((c) => new URL(c.url).pathname === '/v1/search/icons');++ assert.equal(exchange.url, 'https://exchange.test/v1/search/icons');++ assert.equal(exchange.opts.headers.authorization, 'Bearer svc_design');++ assert.equal(refs[0].source, 'exchange');++ // The token is cached: a second search does not mint another.++ await search('hot tub');++ assert.equal(calls.filter((c) => new URL(c.url).pathname === '/oauth/token').length, 1);++ }++ // EXCHANGE_SEARCH_KEY stays the override: with it set, no token is minted.++ {++ let tokens = 0, authorization;++ const search = createReferenceSearch({++ config: {++ oauth: { clientId: 'design', clientSecret: 's3cret' },++ exchange: { url: 'https://exchange.test', key: 'ovx_owner' },++ },++ fetchImpl: async (url, opts) => {++ if (new URL(url).pathname === '/oauth/token') { tokens += 1; throw new Error('must not mint'); }++ authorization = opts.headers.authorization;++ return { ok: true, async json() { return { results: [] }; } };++ },++ });++ await search('hot tub');++ assert.equal(tokens, 0);++ assert.equal(authorization, 'Bearer ovx_owner');}-- console.log('glyph references: Exchange first, Iconify fallback, attribution kept and unlicensed results filtered');++ console.log('glyph references: Exchange by service token or key, Iconify fallback, attribution and source kept, unlicensed results filtered');})().catch((err) => { console.error(err); process.exit(1); });
@@ -38,6 +38,15 @@ OV_AI_INTERNAL_URL=http://127.0.0.1:4700
@@ -38,6 +38,15 @@ OV_AI_INTERNAL_URL=http://127.0.0.1:4700
# DESIGN_AI_WAIT_MS=55000
# DESIGN_AI_WAIT_MS=55000
# DESIGN_AI_TIMEOUT_MS=120000
# DESIGN_AI_TIMEOUT_MS=120000
# DESIGN_AI_POLL_MS=1500
# DESIGN_AI_POLL_MS=1500
+
+# OpenVibe.Exchange's icon reference search (the Exchange platform API, served by OpenVibe.AI): Design mints its own
+# client-credentials token (audience openvibe.ai, scope exchange.search.icons), so no Exchange key is needed.
+# EXCHANGE_SEARCH_KEY is the owner's override; Iconify is the fallback when neither answers.
+# EXCHANGE_API_URL=https://api.openvibe.exchange
+# DESIGN_EXCHANGE_AUDIENCE=openvibe.ai
+# DESIGN_EXCHANGE_SCOPE=exchange.search.icons
+# DESIGN_EXCHANGE_TIMEOUT_MS=5000
+# EXCHANGE_SEARCH_KEY=
# Three explicit provider:model slots; unset pins AI's generate-route offers (fewer than three falls back to its pool).
# Three explicit provider:model slots; unset pins AI's generate-route offers (fewer than three falls back to its pool).
# DESIGN_ARENA_MODELS=shared:gpt-5-mini,local:qwen2.5
# DESIGN_ARENA_MODELS=shared:gpt-5-mini,local:qwen2.5
@@ -43,7 +43,7 @@ Network's theme API.
@@ -43,7 +43,7 @@ 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. Reference icons for a draft come from OpenVibe.Exchange (`POST /v1/search/icons`) when `EXCHANGE_SEARCH_KEY` is set in the service environment; without it Design reads Iconify, applies the same licence filter and sends only names and concepts to the model. 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.
+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. Reference icons for a draft come from OpenVibe.Exchange (`POST /v1/search/icons`, the Exchange platform API served by OpenVibe.AI) with Design's own Network client-credentials token (`exchange.search.icons`), so no key is needed; `EXCHANGE_SEARCH_KEY` is the owner's override. Without either, or when Exchange fails, Design reads Iconify, applies the same licence filter and sends only names and concepts to the model. 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.
@@ -64,6 +64,17 @@ function load(env = process.env) {
@@ -64,6 +64,17 @@ function load(env = process.env) {
maxHtmlBytes: 200 * 1024,
maxHtmlBytes: 200 * 1024,
},
},
+ // OpenVibe.Exchange's icon reference search (the Exchange platform API at api.openvibe.exchange, served by
+ // OpenVibe.AI): Design mints its own client-credentials token (audience openvibe.ai, scope
+ // exchange.search.icons) and needs no Exchange key. EXCHANGE_SEARCH_KEY is the owner's override.
+ exchange: {
+ url: trim(env.EXCHANGE_API_URL || 'https://api.openvibe.exchange'),
+ audience: env.DESIGN_EXCHANGE_AUDIENCE || 'openvibe.ai',
+ scope: env.DESIGN_EXCHANGE_SCOPE || 'exchange.search.icons',
+ key: String(env.EXCHANGE_SEARCH_KEY || ''),
+ timeoutMs: Math.max(1000, int(env.DESIGN_EXCHANGE_TIMEOUT_MS, 5000)),
+ },
+
// OpenVibe.Media: a starting picture is uploaded there by the page and the create request carries its id;
// OpenVibe.Media: a starting picture is uploaded there by the page and the create request carries its id;
// Design names its public file address in the prompt and never takes raw image bytes.
// Design names its public file address in the prompt and never takes raw image bytes.
media: {
media: {
@@ -1,8 +1,12 @@
@@ -1,8 +1,12 @@
'use strict';
'use strict';
-// Icon references for the glyph drafter. OpenVibe.Exchange is the network's own search (Seller and buyer API,
-// POST /v1/search/icons with an Exchange key); Iconify is the fallback while this service has no key. The model
-// receives names and concepts only; SVG paths are never sent to it.
+// Icon references for the glyph drafter. OpenVibe.Exchange is the network's own search (the Exchange platform API,
+// POST /v1/search/icons at api.openvibe.exchange, served by OpenVibe.AI). Design asks with its own Network
+// client-credentials token (audience openvibe.ai, scope exchange.search.icons) and no Exchange key; an
+// EXCHANGE_SEARCH_KEY override still wins when the owner sets one. Iconify is the fallback when neither answers. The
+// model receives names and concepts only; SVG paths are never sent to it.
+const { serviceAuth } = require('openvibe-contracts');
+
const EXCHANGE_API = 'https://api.openvibe.exchange';
const EXCHANGE_API = 'https://api.openvibe.exchange';
const ICONIFY_API = 'https://api.iconify.design';
const ICONIFY_API = 'https://api.iconify.design';
@@ -18,27 +22,27 @@ function fromExchange(body, limit) {
@@ -18,27 +22,27 @@ function fromExchange(body, limit) {
const license = clean(item && item.license, 80);
const license = clean(item && item.license, 80);
if (!name || !set || !license) return [];
if (!name || !set || !license) return [];
return [{ name, set, setName: clean(item.title || item.name || set, 100).replace(/^[^:]+:\s*/, ''),
return [{ name, set, setName: clean(item.title || item.name || set, 100).replace(/^[^:]+:\s*/, ''),
- author: clean(item.author, 100), license,
+ author: clean(item.author, 100), license, source: 'exchange',
licenseUrl: clean(item.license_url, 300), preview: clean(item.url || item.thumbnail, 500) }];
licenseUrl: clean(item.license_url, 300), preview: clean(item.url || item.thumbnail, 500) }];
});
});
}
}
-async function searchExchange(query, { fetchImpl, limit, key, baseUrl }) {
+async function searchExchange(query, { fetchImpl, limit, authorization, baseUrl, timeoutMs }) {
const response = await fetchImpl(`${String(baseUrl || EXCHANGE_API).replace(/\/+$/, '')}/v1/search/icons`, {
const response = await fetchImpl(`${String(baseUrl || EXCHANGE_API).replace(/\/+$/, '')}/v1/search/icons`, {
method: 'POST',
method: 'POST',
- headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${key}` },
+ headers: { accept: 'application/json', 'content-type': 'application/json', authorization },
body: JSON.stringify({ q: query, limit }),
body: JSON.stringify({ q: query, limit }),
- signal: AbortSignal.timeout(5000),
+ signal: AbortSignal.timeout(timeoutMs || 5000),
});
});
if (!response.ok) throw new Error(`Exchange icon references returned ${response.status}`);
if (!response.ok) throw new Error(`Exchange icon references returned ${response.status}`);
return fromExchange(await response.json(), limit);
return fromExchange(await response.json(), limit);
}
}
-async function searchIconify(query, { fetchImpl, limit }) {
+async function searchIconify(query, { fetchImpl, limit, timeoutMs }) {
const url = new URL('/search', ICONIFY_API);
const url = new URL('/search', ICONIFY_API);
url.searchParams.set('query', query);
url.searchParams.set('query', query);
url.searchParams.set('limit', String(limit));
url.searchParams.set('limit', String(limit));
- const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
+ const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs || 5000) });
if (!response.ok) throw new Error(`Icon references returned ${response.status}`);
if (!response.ok) throw new Error(`Icon references returned ${response.status}`);
const body = await response.json();
const body = await response.json();
return (Array.isArray(body.icons) ? body.icons : []).slice(0, 8).flatMap((fullName) => {
return (Array.isArray(body.icons) ? body.icons : []).slice(0, 8).flatMap((fullName) => {
@@ -49,28 +53,61 @@ async function searchIconify(query, { fetchImpl, limit }) {
@@ -49,28 +53,61 @@ async function searchIconify(query, { fetchImpl, limit }) {
return [{ name, set: prefix, setName: String(collection.name || prefix).slice(0, 100),
return [{ name, set: prefix, setName: String(collection.name || prefix).slice(0, 100),
author: String((collection.author && collection.author.name) || '').slice(0, 100),
author: String((collection.author && collection.author.name) || '').slice(0, 100),
license: String(license.spdx).slice(0, 80), licenseUrl: String(license.url || '').slice(0, 300),
license: String(license.spdx).slice(0, 80), licenseUrl: String(license.url || '').slice(0, 300),
- preview: `${ICONIFY_API}/${prefix}/${name}.svg` }];
+ source: 'iconify', preview: `${ICONIFY_API}/${prefix}/${name}.svg` }];
});
});
}
}
/**
/**
- * Reference icons for a glyph name and sentence, from the Exchange when this service holds an Exchange key
- * (EXCHANGE_SEARCH_KEY; the owner creates it on openvibe.exchange) and from Iconify otherwise.
+ * Reference icons for a glyph name and sentence. Exchange answers when this service holds an Exchange key
+ * (EXCHANGE_SEARCH_KEY, the owner's override) or a Network service token can be minted for exchange.search.icons;
+ * Iconify answers otherwise, and after any Exchange failure.
*/
*/
async function searchReferences(query, {
async function searchReferences(query, {
fetchImpl = globalThis.fetch,
fetchImpl = globalThis.fetch,
limit = 5,
limit = 5,
key = process.env.EXCHANGE_SEARCH_KEY,
key = process.env.EXCHANGE_SEARCH_KEY,
+ getToken,
baseUrl = process.env.EXCHANGE_API_URL,
baseUrl = process.env.EXCHANGE_API_URL,
+ timeoutMs = 5000,
} = {}) {
} = {}) {
const q = String(query || '').trim().slice(0, 120);
const q = String(query || '').trim().slice(0, 120);
const n = Math.min(Math.max(Number(limit) || 5, 1), 8);
const n = Math.min(Math.max(Number(limit) || 5, 1), 8);
if (!q) return [];
if (!q) return [];
- if (key) {
- try { return await searchExchange(q, { fetchImpl, limit: n, key, baseUrl }); }
- catch { /* the fallback keeps a draft working while Exchange is unreachable */ }
+ if (key || getToken) {
+ try {
+ const authorization = key ? `Bearer ${key}` : `Bearer ${await getToken()}`;
+ return await searchExchange(q, { fetchImpl, limit: n, authorization, baseUrl, timeoutMs });
+ } catch { /* the fallback keeps a draft working while Exchange is unreachable */ }
}
}
- return await searchIconify(q, { fetchImpl, limit: n });
+ return await searchIconify(q, { fetchImpl, limit: n, timeoutMs });
+}
+
+/**
+ * The service's reference search, bound to its own Network token. EXCHANGE_SEARCH_KEY, when the owner sets it,
+ * replaces the token; with no secret the service cannot mint one and Iconify answers. searchReferences keeps its
+ * direct-test form above.
+ */
+function createReferenceSearch({ config = {}, fetchImpl = globalThis.fetch, tokenClient } = {}) {
+ const exchange = config.exchange || {};
+ const key = String(exchange.key || '');
+ const tokens = key ? null : (tokenClient || (config.oauth && config.oauth.clientSecret
+ ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl || config.networkUrl || 'http://127.0.0.1:4000'}/oauth/token`,
+ clientId: config.oauth.clientId || 'design',
+ clientSecret: config.oauth.clientSecret,
+ audience: exchange.audience || 'openvibe.ai',
+ scope: exchange.scope || 'exchange.search.icons',
+ fetchImpl,
+ })
+ : null));
+ return (query, opts = {}) => searchReferences(query, {
+ fetchImpl,
+ ...opts,
+ key: opts.key !== undefined ? opts.key : key,
+ baseUrl: opts.baseUrl || exchange.url,
+ getToken: opts.getToken || (tokens ? () => tokens.getToken() : undefined),
+ timeoutMs: opts.timeoutMs || exchange.timeoutMs || 5000,
+ });
}
}
-module.exports = { searchReferences, fromExchange };
+module.exports = { searchReferences, createReferenceSearch, fromExchange };
@@ -37,6 +37,7 @@ const aliases = require('../glyphs/aliases');
@@ -37,6 +37,7 @@ const aliases = require('../glyphs/aliases');
const kits = require('../glyphs/kits');
const kits = require('../glyphs/kits');
const ex = require('../glyphs/export');
const ex = require('../glyphs/export');
const { createGlyphDrafts } = require('../glyphs/drafts');
const { createGlyphDrafts } = require('../glyphs/drafts');
+const { createReferenceSearch } = require('../glyphs/references');
const formats = require('../glyphs/formats');
const formats = require('../glyphs/formats');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -49,7 +50,8 @@ const OPEN = { 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy
@@ -49,7 +50,8 @@ const OPEN = { 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy
function createIconRoutes(ctx) {
function createIconRoutes(ctx) {
const { config, s, ai, log = console } = ctx;
const { config, s, ai, log = console } = ctx;
const guestCaps = config.glyphGuests || { perGuest: 2, perAddress: 4, daily: 25 };
const guestCaps = config.glyphGuests || { perGuest: 2, perAddress: 4, daily: 25 };
- const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai, guests: guestCaps });
+ const referenceSearch = ctx.referenceSearch || createReferenceSearch({ config });
+ const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai, guests: guestCaps, referenceSearch });
// A guest (L50 step 5): a random id in an httpOnly cookie, stored only as a keyed hash; the address only as a hash
// 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).
// 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 GUEST_COOKIE = 'ovd_guest';
@@ -103,7 +103,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
@@ -103,7 +103,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
const path = candidate ? (style === 'solid' && d.solid_svg ? d.solid_svg : d.svg) : (glyphs.path(name) || '');
const path = candidate ? (style === 'solid' && d.solid_svg ? d.solid_svg : d.svg) : (glyphs.path(name) || '');
return sample(path, size, width, style);
return sample(path, size, width, style);
};
};
- return `<details class="ic-compare"><summary>Compare styles with four neighbours</summary>${styles.map(([style, width]) => `<div class="ic-compare-row"><b>${style}</b>${[16, 20, 32].map((size) => `<div class="ic-compare-size"><small>${size}px</small><div>${samples(d.name, size, width, style, true)}${neighbours.map((n) => samples(n, size, width, style, false)).join('')}</div></div>`).join('')}</div>`).join('')}${(d.references_json || d.references || []).length ? `<p class="ic-compare-refs">References: ${(d.references_json || d.references).map((r) => `<a href="${esc(r.preview)}" target="_blank" rel="noopener">${esc(r.setName)} · ${esc(r.name)} · ${esc(r.license)}</a>`).join(' · ')}</p>` : ''}</details>`;
+ return `<details class="ic-compare"><summary>Compare styles with four neighbours</summary>${styles.map(([style, width]) => `<div class="ic-compare-row"><b>${style}</b>${[16, 20, 32].map((size) => `<div class="ic-compare-size"><small>${size}px</small><div>${samples(d.name, size, width, style, true)}${neighbours.map((n) => samples(n, size, width, style, false)).join('')}</div></div>`).join('')}</div>`).join('')}${(d.references_json || d.references || []).length ? `<p class="ic-compare-refs">References: ${(d.references_json || d.references).map((r) => `<a href="${esc(r.preview)}" target="_blank" rel="noopener">${esc(r.setName)} · ${esc(r.name)} · ${esc(r.license)}${r.source ? ` · via ${esc(r.source)}` : ''}</a>`).join(' · ')}</p>` : ''}</details>`;
};
};
const draftCard = (d, actions = '') => `<li class="ic-draft is-${esc(d.status)}"><div class="ic-draft-art"${d.solid_svg ? ' title="The line drawing, and its solid twin as the sites show it at 18 px and under"' : ''}>${svg(d.svg, 40)}${d.solid_svg ? solidSvg(d.solid_svg, 20) : svg(d.svg, 20)}</div><div class="ic-draft-t"><b>${esc(d.name)}</b>${actions && d.sentence ? `<span>${esc(d.sentence)}</span>` : ''}<small>${esc(d.status === 'draft' ? 'waiting for review' : d.status)}${d.model ? ` · ${esc(d.model)}` : ''}</small></div>${actions}${actions ? compare(d) : ''}</li>`;
const draftCard = (d, actions = '') => `<li class="ic-draft is-${esc(d.status)}"><div class="ic-draft-art"${d.solid_svg ? ' title="The line drawing, and its solid twin as the sites show it at 18 px and under"' : ''}>${svg(d.svg, 40)}${d.solid_svg ? solidSvg(d.solid_svg, 20) : svg(d.svg, 20)}</div><div class="ic-draft-t"><b>${esc(d.name)}</b>${actions && d.sentence ? `<span>${esc(d.sentence)}</span>` : ''}<small>${esc(d.status === 'draft' ? 'waiting for review' : d.status)}${d.model ? ` · ${esc(d.model)}` : ''}</small></div>${actions}${actions ? compare(d) : ''}</li>`;
const reviewForm = (d) => `<div class="ic-draft-acts">${['approve', 'reject'].map((a) => `<form method="post" action="/icons/drafts/${esc(d.id)}/review"><input type="hidden" name="action" value="${a}"><button type="submit" class="ic-btn${a === 'approve' ? ' is-go' : ''}">${a === 'approve' ? 'Approve' : 'Reject'}</button></form>`).join('')}</div>`;
const reviewForm = (d) => `<div class="ic-draft-acts">${['approve', 'reject'].map((a) => `<form method="post" action="/icons/drafts/${esc(d.id)}/review"><input type="hidden" name="action" value="${a}"><button type="submit" class="ic-btn${a === 'approve' ? ' is-go' : ''}">${a === 'approve' ? 'Approve' : 'Reject'}</button></form>`).join('')}</div>`;
@@ -1,6 +1,11 @@
@@ -1,6 +1,11 @@
'use strict';
'use strict';
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
-const { searchReferences } = require('../server/glyphs/references');
+const { searchReferences, createReferenceSearch } = require('../server/glyphs/references');
+
+const iconifyAnswer = {
+ icons: ['lucide:hot-tub', 'unknown:no-license'],
+ collections: { lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } },
+};
(async () => {
(async () => {
// With an Exchange key the drafter reads the network's own icon search, not Iconify.
// With an Exchange key the drafter reads the network's own icon search, not Iconify.
@@ -19,33 +24,112 @@ const { searchReferences } = require('../server/glyphs/references');
@@ -19,33 +24,112 @@ const { searchReferences } = require('../server/glyphs/references');
assert.equal(sent.headers.authorization, 'Bearer ovx_test');
assert.equal(sent.headers.authorization, 'Bearer ovx_test');
assert.deepEqual(JSON.parse(sent.body), { q: 'hot tub', limit: 5 });
assert.deepEqual(JSON.parse(sent.body), { q: 'hot tub', limit: 5 });
assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'hot tub', author: 'Lucide', license: 'ISC',
assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'hot tub', author: 'Lucide', license: 'ISC',
- licenseUrl: 'https://example.test/license', preview: 'https://cdn.test/lucide/hot-tub.svg' }]);
+ source: 'exchange', licenseUrl: 'https://example.test/license', preview: 'https://cdn.test/lucide/hot-tub.svg' }]);
+ }
+ // Without a key but with a Network service token, Exchange answers with that token and names the source.
+ {
+ let requested, sent, minted = 0;
+ const refs = await searchReferences('hot tub', { key: '', baseUrl: 'https://exchange.test', getToken: async () => {
+ minted += 1; return 'svc_design';
+ }, fetchImpl: async (url, opts) => {
+ requested = new URL(url); sent = opts;
+ return { ok: true, async json() { return { results: [
+ { name: 'lucide:hot-tub', set: 'lucide', title: 'Lucide: hot tub', author: 'Lucide', license: 'ISC', url: 'https://cdn.test/lucide/hot-tub.svg' },
+ ] }; } };
+ } });
+ assert.equal(minted, 1);
+ assert.equal(requested.pathname, '/v1/search/icons');
+ assert.equal(sent.headers.authorization, 'Bearer svc_design');
+ assert.equal(refs[0].source, 'exchange');
}
}
- // Without one it keeps Iconify as the fallback, with the same attribution and the same licence filter.
+ // Without either it keeps Iconify as the fallback, with the same attribution and the same licence filter.
{
{
let requested;
let requested;
const refs = await searchReferences('hot tub', { key: '', fetchImpl: async (url) => {
const refs = await searchReferences('hot tub', { key: '', fetchImpl: async (url) => {
requested = new URL(url);
requested = new URL(url);
- return { ok: true, async json() { return {
- icons: ['lucide:hot-tub', 'unknown:no-license'],
- collections: { lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } },
- }; } };
+ return { ok: true, async json() { return iconifyAnswer; } };
} });
} });
assert.equal(requested.pathname, '/search');
assert.equal(requested.pathname, '/search');
assert.equal(requested.searchParams.get('query'), 'hot tub');
assert.equal(requested.searchParams.get('query'), 'hot tub');
assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'Lucide', author: 'Lucide', license: 'ISC',
assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'Lucide', author: 'Lucide', license: 'ISC',
- licenseUrl: 'https://example.test/license', preview: 'https://api.iconify.design/lucide/hot-tub.svg' }]);
+ source: 'iconify', licenseUrl: 'https://example.test/license', preview: 'https://api.iconify.design/lucide/hot-tub.svg' }]);
+ }
+ // A token that cannot be minted (Network down) does not stop a draft: the fallback answers.
+ {
+ let calls = 0;
+ const refs = await searchReferences('hot tub', { key: '', getToken: async () => { throw new Error('no token'); }, fetchImpl: async (url) => {
+ calls += 1;
+ assert.equal(new URL(url).hostname, 'api.iconify.design');
+ return { ok: true, async json() { return iconifyAnswer; } };
+ } });
+ assert.equal(calls, 1, 'the Iconify search carries its collections');
+ assert.equal(refs[0].source, 'iconify');
}
}
// A configured Exchange that fails does not stop a draft: the fallback answers.
// A configured Exchange that fails does not stop a draft: the fallback answers.
{
{
let calls = 0;
let calls = 0;
const refs = await searchReferences('hot tub', { key: 'ovx_test', fetchImpl: async (url) => {
const refs = await searchReferences('hot tub', { key: 'ovx_test', fetchImpl: async (url) => {
calls += 1;
calls += 1;
- if (calls === 1) return { ok: false, status: 503 };
- return { ok: true, async json() { return { icons: ['lucide:hot-tub'], collections: { lucide: { name: 'Lucide', license: { spdx: 'ISC' } } } }; } };
+ if (new URL(url).hostname !== 'api.iconify.design') return { ok: false, status: 503 };
+ return { ok: true, async json() { return iconifyAnswer; } };
} });
} });
- assert.equal(calls, 2);
- assert.equal(refs[0].name, 'hot-tub');
+ assert.equal(calls, 2, 'the failed Exchange call and the Iconify fallback');
+ assert.equal(refs[0].source, 'iconify');
+ }
+ // The service's own search: a Network client-credentials token for exchange.search.icons, minted from its
+ // OAuth client, and Exchange called with it. No Exchange key anywhere.
+ {
+ const calls = [];
+ const fetchImpl = async (url, opts) => {
+ calls.push({ url: String(url), opts });
+ if (new URL(url).pathname === '/oauth/token') {
+ return { ok: true, async json() { return { access_token: 'svc_design', expires_in: 300 }; } };
+ }
+ return { ok: true, async json() { return { results: [
+ { name: 'lucide:hot-tub', set: 'lucide', title: 'Lucide: hot tub', author: 'Lucide', license: 'ISC', url: 'https://cdn.test/lucide/hot-tub.svg' },
+ ] }; } };
+ };
+ const search = createReferenceSearch({
+ config: {
+ networkInternalUrl: 'https://network.test',
+ oauth: { clientId: 'design', clientSecret: 's3cret' },
+ exchange: { url: 'https://exchange.test', audience: 'openvibe.ai', scope: 'exchange.search.icons', timeoutMs: 1000 },
+ },
+ fetchImpl,
+ });
+ const refs = await search('hot tub', { limit: 3 });
+ const token = calls.find((c) => new URL(c.url).pathname === '/oauth/token');
+ assert.equal(token.url, 'https://network.test/oauth/token');
+ const form = new URLSearchParams(String(token.opts.body));
+ assert.equal(form.get('grant_type'), 'client_credentials');
+ assert.equal(form.get('client_id'), 'design');
+ assert.equal(form.get('audience'), 'openvibe.ai');
+ assert.equal(form.get('scope'), 'exchange.search.icons');
+ const exchange = calls.find((c) => new URL(c.url).pathname === '/v1/search/icons');
+ assert.equal(exchange.url, 'https://exchange.test/v1/search/icons');
+ assert.equal(exchange.opts.headers.authorization, 'Bearer svc_design');
+ assert.equal(refs[0].source, 'exchange');
+ // The token is cached: a second search does not mint another.
+ await search('hot tub');
+ assert.equal(calls.filter((c) => new URL(c.url).pathname === '/oauth/token').length, 1);
+ }
+ // EXCHANGE_SEARCH_KEY stays the override: with it set, no token is minted.
+ {
+ let tokens = 0, authorization;
+ const search = createReferenceSearch({
+ config: {
+ oauth: { clientId: 'design', clientSecret: 's3cret' },
+ exchange: { url: 'https://exchange.test', key: 'ovx_owner' },
+ },
+ fetchImpl: async (url, opts) => {
+ if (new URL(url).pathname === '/oauth/token') { tokens += 1; throw new Error('must not mint'); }
+ authorization = opts.headers.authorization;
+ return { ok: true, async json() { return { results: [] }; } };
+ },
+ });
+ await search('hot tub');
+ assert.equal(tokens, 0);
+ assert.equal(authorization, 'Bearer ovx_owner');
}
}
- console.log('glyph references: Exchange first, Iconify fallback, attribution kept and unlicensed results filtered');
+ console.log('glyph references: Exchange by service token or key, Iconify fallback, attribution and source kept, unlicensed results filtered');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });