openvibers/openvibe.design Commits

Icon references: ask Exchange with Design's service token (#279)

OP OpenVibers committed 1 days ago · 4b9a8cc44c · 7 files, +176 −33

Changes

ddiff --git a/.env.example b/.env.example
iindex a6cfbb1..f1fc416 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 91d50d4..4e513d3 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/server/config.js b/server/config.js
iindex f1ebd7e..ae2db19 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/glyphs/references.js b/server/glyphs/references.js
iindex 9e12436..762b418 100644
---- a/server/glyphs/references.js
++++ b/server/glyphs/references.js
@@@ -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 };
ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex 47a9278..cfddc41 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -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-Policy
function 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';
ddiff --git a/server/views/icons.js b/server/views/icons.js
iindex b615714..6acaad1 100644
---- a/server/views/icons.js
++++ b/server/views/icons.js
@@@ -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) || '');
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>`;
ddiff --git a/test/glyph-references.test.js b/test/glyph-references.test.js
iindex 057d07f..dca3237 100644
---- a/test/glyph-references.test.js
++++ b/test/glyph-references.test.js
@@@ -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); });