openvibers/openvibe.ai Commits

Exchange icon search: accept the service capability for Design (#480)

OP OpenVibers committed 2 days ago · b4341bcb8b · 10 files, +130 −9

* Exchange icon search: accept the service capability for Design

* Name exchange.search.icons in AI's capability proposal

* Merge origin/main into the Exchange icon-search branch

* Re-run CI

Changes

ddiff --git a/docs/capabilities-proposal/exchange.search.icons.json b/docs/capabilities-proposal/exchange.search.icons.json
nnew file mode 100644
iindex 0000000..aae8156
---- /dev/null
++++ b/docs/capabilities-proposal/exchange.search.icons.json
@@@ -0,0 +1,20 @@
++{
++ "id": "exchange.search.icons",
++ "version": "1.0",
++ "owner": "ai",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Search icon references through the Exchange platform API (POST /v1/search/icons at api.openvibe.exchange, served by OpenVibe.AI). A first-party service holds only this route and its own free-search quota: no Exchange key, no inference, no web, image or fetch search. The receipt names the engine, the provider cost and each skipped attempt; results carry the icon set, author and licence. OpenVibe.Design uses it to show references beside a glyph draft.",
++ "permissions": [
++ "exchange:search:icons"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "search-icons",
++ "events": [],
++ "implementedBy": [
++ "POST /v1/search/icons"
++ ],
++ "outputSchema": "exchange.image-result@1"
++}
ddiff --git a/docs/capabilities-proposal/service-manifest.ai.json b/docs/capabilities-proposal/service-manifest.ai.json
iindex 40b775c..39847cf 100644
---- a/docs/capabilities-proposal/service-manifest.ai.json
++++ b/docs/capabilities-proposal/service-manifest.ai.json
@@@ -29,7 +29,8 @@
"ai.run.create",
"ai.run.read",
"ai.usage.read",
-- "ai.workflow.manage"
++ "ai.workflow.manage",
++ "exchange.search.icons"
],
"eventsProduced": [
"ai.run.queued",
ddiff --git a/docs/exchange-platform.md b/docs/exchange-platform.md
iindex 2755c8c..80cd472 100644
---- a/docs/exchange-platform.md
++++ b/docs/exchange-platform.md
@@@ -8,7 +8,9 @@ The API host is `https://api.openvibe.exchange` (OpenVibe.AI). Exchange is the h
- `GET /api/v1/exchange/key-usage`: private key/model/day usage, at most 1,000 grouped rows.
- `/api/v1/exchange/deployments` aliases the existing private `/api/v1/own-endpoints` APIs and owner gates.
- `POST /v1/search`, `/v1/search/images`, `/v1/search/icons`: `{q, engine?: "auto", limit?: 10}`.
-- Receipts contain engine, cost in USD and elapsed milliseconds. Openverse, Commons and Iconify need no key;
++ Receipts contain engine, cost in USD and elapsed milliseconds. `/v1/search/icons` also accepts a
++ first-party service token holding `exchange.search.icons` (OpenVibe.Design); it reaches only that
++ route, has its own minute quota and needs no Exchange key. Openverse, Commons and Iconify need no key;
SearXNG requires `EXCHANGE_SEARXNG_URL` on an HTTPS host with JSON search enabled. OpenVibe Search uses its public index.
Paid engines are not enabled. Unsupported filters return an error rather than silently broadening a licence search.
- `POST /v1/fetch {url}` uses guarded HTTPS egress with pinned public DNS, no redirects, an 8 MB transfer cap and a
ddiff --git a/docs/search-engines.md b/docs/search-engines.md
iindex 64ffe2d..f1a20b3 100644
---- a/docs/search-engines.md
++++ b/docs/search-engines.md
@@@ -2,6 +2,8 @@
`POST /v1/search`, `/v1/search/images`, `/v1/search/icons` and `/v1/fetch` use the existing Exchange key, person, service or app authentication. The routes and JSON schemas are in `/openapi.json`. Search responses validate against `exchange.search-result@1` and `exchange.image-result@1`. Contracts **0.399.0** carries the search receipts and their optional ordered attempts.
++The icons route also takes the first-party capability `exchange.search.icons` (Contracts **0.407.0**): a service holding it and nothing else reaches `/v1/search/icons` and gets its own free-search bucket (`AI_EXCHANGE_ICONS_RPM`, 60 calls a minute by default), never web, image or fetch search. OpenVibe.Design holds it for the references shown beside a glyph draft, so no Exchange key is needed there.
++
```json
{"q":"solar lantern","engine":"auto","freshness":"month","limit":10,"max_cost_usd":0.01}
```
ddiff --git a/package-lock.json b/package-lock.json
iindex efbccb0..2324d43 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.407.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"pg": "^8.23.0"
@@@ -1624,9 +1624,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.406.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
-- "integrity": "sha512-V/Tjss2UWvzI551bw3ACb/RqWg0lChxGITxfCVZ8qou2Docg0pBMvIjkeQoib56yq3r6UZGB6DGl+pA+qfjOiA==",
++ "version": "0.407.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.407.0",
++ "integrity": "sha512-i+brTUApefkumQu1Ra4GFkZqwd2+mWBIEW8Ra55Eid5Bn6Q29cVNMl57EKI9uzlOauzc6s6ut5r55NWepRoMSQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex b72ce76..49e7060 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.407.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"pg": "^8.23.0"
ddiff --git a/server/auth.js b/server/auth.js
iindex 4da2339..5859ac6 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -38,6 +38,8 @@ const CAPS = Object.freeze({
actionsRead: 'ai.actions.read',
// D11: Network's admin proxy, acting for the owner, manages ai.exchange and ai.provider_keys (Contracts 0.288.0).
configManage: 'ai.config.manage',
++ // The Exchange platform API's icon reference search, held by OpenVibe.Design alone (Contracts 0.407.0).
++ exchangeSearchIcons: 'exchange.search.icons',
});
// A person's own Network token (audience openvibe.ai): the same checks server/me.js applies, so a person may run
ddiff --git a/server/platform/router.js b/server/platform/router.js
iindex 7936887..e58e1a1 100644
---- a/server/platform/router.js
++++ b/server/platform/router.js
@@@ -68,6 +68,9 @@ function platform({ db, auth, clock, log, searchFetch, credentials, registry, en
({ engines: await search.engines() })),
);
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
++ // The icon reference search takes one more first-party service capability: exchange.search.icons,
++ // held by OpenVibe.Design. It opens only this route and never web, image or fetch search.
++ const allowIcons = auth.appOrCap(CAPS.appRun, CAPS.runCreate, CAPS.exchangeSearchIcons);
const searchWindows = new Map();
const searchLimit = (req, res, next) => {
const id = req.principal?.subject?.id || req.principal?.sub;
@@@ -85,13 +88,45 @@ function platform({ db, auth, clock, log, searchFetch, credentials, registry, en
searchWindows.set(id, entry);
next();
};
-- for (const kind of ['web', 'images', 'icons'])
++ // A service calling with exchange.search.icons alone (Design) gets its own minute bucket and its own
++ // limit, separate from people and from services that hold ai.run.create.
++ const iconService = (req) =>
++ Array.isArray(req.principal?.cap) &&
++ req.principal.cap.includes(CAPS.exchangeSearchIcons) &&
++ !req.principal.cap.includes(CAPS.appRun) &&
++ !req.principal.cap.includes(CAPS.runCreate);
++ const iconWindows = new Map();
++ const iconRpm = Math.max(1, Number(env.AI_EXCHANGE_ICONS_RPM) || 60);
++ const iconLimit = (req, res, next) => {
++ if (!iconService(req)) return searchLimit(req, res, next);
++ const id = req.principal?.sub || 'unknown';
++ const now = clock.now();
++ for (const [key, value] of iconWindows)
++ if (value.until <= now) iconWindows.delete(key);
++ const entry = iconWindows.get(id) || { until: now + 60000, count: 0 };
++ if (entry.count >= iconRpm) {
++ res.locals.searchCharge = 0;
++ return res
++ .status(429)
++ .json({ error: 'Icon search limit reached; try again in a minute' });
++ }
++ entry.count++;
++ iconWindows.set(id, entry);
++ next();
++ };
++ for (const kind of ['web', 'images'])
r.post(
'/v1/search' + (kind === 'web' ? '' : '/' + kind),
allow,
searchLimit,
wrap(async (req) => search.search(kind, req.body || {}, searchContext(req))),
);
++ r.post(
++ '/v1/search/icons',
++ allowIcons,
++ iconLimit,
++ wrap(async (req) => search.search('icons', req.body || {}, searchContext(req))),
++ );
r.post(
'/v1/fetch',
allow,
ddiff --git a/server/search/openapi.js b/server/search/openapi.js
iindex ce17939..76e5bda 100644
---- a/server/search/openapi.js
++++ b/server/search/openapi.js
@@@ -18,7 +18,7 @@ for (const kind of ['web', 'images', 'icons']) {
paths['/v1/search' + (kind === 'web' ? '' : '/' + kind)] = { post: {
operationId: 'search' + kind[0].toUpperCase() + kind.slice(1), tags: ['exchange'],
summary: `Search ${kind} through Exchange`, security: [{ bearer: [] }],
-- description: 'Accepts an Exchange key, person token, authorized service or app token. Receipt reports engine, provider cost in USD and elapsed milliseconds, including fallbacks, and lists each ordered attempt (ok, error or skipped) with its provider, engine, safe reason, latency and cost. If a timed-out or malformed upstream answer may already have been billed, its reserved ceiling is included conservatively. BYOK has zero Exchange charge and zero fee; its provider cost remains visible in the receipt. Shared keys settle that cost against the Exchange key cap. Sources and checked dates are in GET /api/v1/exchange/search/engines.',
++ description: 'Accepts an Exchange key, person token, authorized service or app token.' + (kind === 'icons' ? ' A first-party service holding exchange.search.icons reaches this route only, with its own minute quota.' : '') + ' Receipt reports engine, provider cost in USD and elapsed milliseconds, including fallbacks, and lists each ordered attempt (ok, error or skipped) with its provider, engine, safe reason, latency and cost. If a timed-out or malformed upstream answer may already have been billed, its reserved ceiling is included conservatively. BYOK has zero Exchange charge and zero fee; its provider cost remains visible in the receipt. Shared keys settle that cost against the Exchange key cap. Sources and checked dates are in GET /api/v1/exchange/search/engines.',
requestBody: { required: true, ...json({ type: 'object', required: ['q'], properties: {
...properties,
...(kind === 'web' ? {} : {
ddiff --git a/test/exchange-icons-cap.test.js b/test/exchange-icons-cap.test.js
nnew file mode 100644
iindex 0000000..b9daf0e
---- /dev/null
++++ b/test/exchange-icons-cap.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++// The Exchange platform API's icon reference search takes a first-party service capability:
++// exchange.search.icons (contracts 0.407.0), held by OpenVibe.Design. It opens POST /v1/search/icons
++// only, has its own minute bucket (AI_EXCHANGE_ICONS_RPM) and still takes ai.run.create as before.
++//
++// node test/exchange-icons-cap.test.js
++const assert = require('node:assert/strict');
++const { boot, request, token, suite } = require('./helpers');
++
++const t = suite('exchange.search.icons');
++let h;
++const iconifyFetch = async (url) => {
++ const u = new URL(url);
++ if (u.pathname === '/search') {
++ return new Response(JSON.stringify({ icons: ['lucide:hot-tub'] }), { status: 200, headers: { 'content-type': 'application/json' } });
++ }
++ if (u.pathname === '/collections') {
++ return new Response(JSON.stringify({ lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } }), { status: 200, headers: { 'content-type': 'application/json' } });
++ }
++ throw new Error(`unexpected ${url}`);
++};
++
++t.test('boot', async () => {
++ h = await boot({ searchFetch: iconifyFetch, env: { AI_EXCHANGE_ICONS_RPM: '2' } });
++});
++
++t.test('a service with exchange.search.icons searches icons and nothing else', async () => {
++ const design = token('design', ['exchange.search.icons'], { ns: [] });
++ const icons = await request(h.base, 'POST', '/v1/search/icons', { tok: design, body: { q: 'hot tub', engine: 'iconify', limit: 1 } });
++ assert.equal(icons.status, 200);
++ assert.equal(icons.body.results[0].name, 'lucide:hot-tub');
++ assert.equal(icons.body.results[0].engine, 'iconify');
++ assert.equal(icons.body.receipt.engine, 'iconify');
++
++ for (const [method, path] of [['POST', '/v1/search'], ['POST', '/v1/search/images'], ['POST', '/v1/fetch']]) {
++ const r = await request(h.base, method, path, { tok: design, body: path === '/v1/fetch' ? { url: 'https://example.org/' } : { q: 'hot tub' } });
++ assert.equal(r.status, 403, `${path} is not opened by exchange.search.icons`);
++ assert.equal(r.body.code, 'capability.denied');
++ }
++});
++
++t.test('its own minute limit answers 429, not the general search limit', async () => {
++ const design = token('design', ['exchange.search.icons'], { ns: [] });
++ const ok = await request(h.base, 'POST', '/v1/search/icons', { tok: design, body: { q: 'bell', engine: 'iconify' } });
++ assert.equal(ok.status, 200);
++ const limited = await request(h.base, 'POST', '/v1/search/icons', { tok: design, body: { q: 'bell', engine: 'iconify' } });
++ assert.equal(limited.status, 429);
++ assert.match(limited.body.error, /Icon search limit/);
++});
++
++t.test('ai.run.create still searches icons', async () => {
++ const live = token('live', ['ai.run.create'], { ns: ['live.*'] });
++ const r = await request(h.base, 'POST', '/v1/search/icons', { tok: live, body: { q: 'bell', engine: 'iconify' } });
++ assert.equal(r.status, 200);
++});
++
++t.test('stop', async () => { await h.stop(); });
++
++t.run();