From 49b2b151b773122a72d44da965b31d136cd394fc Mon Sep 17 00:00:00 2001
From: Ettore Di Giacinto
Date: Wed, 21 Oct 2026 18:15:15 +0000
Subject: [PATCH] feat(ui): show the kind of library entries and install
backends from them
The Browse tab lists the entries of every kind this host reads and marks
each one as an add-on or a backend. A backend installs through the
backend route and not through the add-on lifecycle: the confirmation
says that the backend gallery is not changed and that a backend runs
native code, and a refusal of the host, such as a name installed from
another origin, is shown with its own reason. The details give the kind,
the alias and the signer pattern.
The library editor can name the signer with a pattern. Without this a
save would drop the pattern of a backend library. The Libraries tab
reports the entries a refresh skipped because their kind is not read.
Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto
---
.../e2e/addons-backend-library.spec.js | 114 ++++++++++++++++++
.../react-ui/public/locales/en/addons.json | 28 ++++-
.../src/components/addons/BrowseView.jsx | 56 +++++++--
.../src/components/catalog/SourcesView.jsx | 6 +-
.../src/components/catalog/catalogState.js | 12 +-
core/http/react-ui/src/utils/addonsApi.js | 18 ++-
core/http/react-ui/tests/unit/addons.test.mjs | 39 +++++-
7 files changed, 253 insertions(+), 20 deletions(-)
create mode 100644 core/http/react-ui/e2e/addons-backend-library.spec.js
diff --git a/core/http/react-ui/e2e/addons-backend-library.spec.js b/core/http/react-ui/e2e/addons-backend-library.spec.js
new file mode 100644
index 000000000..d360681c7
--- /dev/null
+++ b/core/http/react-ui/e2e/addons-backend-library.spec.js
@@ -0,0 +1,114 @@
+import { test, expect } from '@playwright/test'
+import { ADMIN, release, sessionFor, mockAddons } from './addons-fixtures.js'
+
+// Backends in the library (phase 1): an entry has a kind, the Browse tab shows
+// it, and a backend installs through the backend route, not the add-on
+// lifecycle. The server is the authority, so these specs drive the page
+// against a stand-in and assert what the page sent.
+
+const POLICY = { requireSignature: true, issuer: 'https://token.actions.githubusercontent.com', identityRegex: '^https://github\\.com/o/r/\\.github/workflows/w\\.yml@refs/(heads/master|tags/.+)$' }
+const backend = (over = {}) => release('cpu-local-store', {
+ kind: 'backend', name: 'cpu-local-store', alias: 'local-store', description: 'A vector store.', version: '0.0.0-latest.0a1056fd8633',
+ sourceId: 'official-backends', artifact: 'oci://registry.example/backends:latest-cpu-local-store', policy: POLICY, ...over,
+})
+
+async function signIn(page) {
+ await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) }))
+}
+
+// The backend routes of the host. `installed` is the mutable list GET /backends answers.
+async function mockBackends(page, { installed = [], install } = {}) {
+ const state = { installed: [...installed], installs: [] }
+ await page.route(/\/backends$/, route => route.fulfill({ json: state.installed }))
+ await page.route(/\/backends\/library\/install$/, route => {
+ const body = route.request().postDataJSON()
+ state.installs.push(body)
+ if (install) {
+ const custom = install(body)
+ if (custom) return route.fulfill(custom)
+ }
+ state.installed.push({ Name: body.name, Metadata: { library: body.sourceId } })
+ return route.fulfill({ json: { status: 'installed', name: body.name, sourceId: body.sourceId } })
+ })
+ return state
+}
+
+test.describe('Library backends', () => {
+ test('browse: an entry shows its kind, and add-ons are still add-ons', async ({ page }) => {
+ await signIn(page)
+ const state = await mockAddons(page, { installed: [], releases: [release('tables'), backend()] })
+ await mockBackends(page)
+ await page.goto('/app/addons?tab=browse')
+ await expect(page.locator('.cat-item')).toHaveCount(2)
+ await expect(page.locator('.cat-item', { hasText: 'Tables' }).getByTestId('entry-kind')).toHaveText('Add-on')
+ const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
+ await expect(row.getByTestId('entry-kind')).toHaveText('Backend')
+ await expect(row.getByTestId('entry-kind')).toHaveAttribute('data-kind', 'backend')
+ // The page asks for every kind this host reads.
+ expect(state.log.dispatch.find(d => d.action === 'catalog-search').arguments.kind).toBe('all')
+ })
+
+ test('browse: a backend installs through the backend route and never through the add-on lifecycle', async ({ page }) => {
+ await signIn(page)
+ const state = await mockAddons(page, { installed: [], releases: [backend()] })
+ const backends = await mockBackends(page)
+ await page.goto('/app/addons?tab=browse')
+ const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
+ await row.getByRole('button', { name: 'Install' }).click()
+ const dialog = page.getByRole('alertdialog')
+ await expect(dialog).toContainText('Install cpu-local-store?')
+ await expect(dialog).toContainText('backend gallery is not changed')
+ await expect(dialog).toContainText('native code')
+ expect(backends.installs).toHaveLength(0)
+ await dialog.getByRole('button', { name: 'Install' }).click()
+ await expect(row.getByText('Installed as a backend on this node')).toBeVisible()
+ expect(backends.installs).toEqual([{ sourceId: 'official-backends', name: 'cpu-local-store', version: '0.0.0-latest.0a1056fd8633' }])
+ expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0)
+ // Once installed the entry says so and offers no second install.
+ await expect(row.getByText('Installed', { exact: true })).toBeVisible()
+ await expect(row.getByRole('button', { name: 'Install' })).toBeDisabled()
+ })
+
+ test('browse: a refusal of the host is shown with its own reason', async ({ page }) => {
+ await signIn(page)
+ await mockAddons(page, { installed: [], releases: [backend()] })
+ await mockBackends(page, {
+ install: () => ({ status: 409, json: { error: { type: 'backend_library', code: 409, message: 'backend is already installed from another origin: "cpu-local-store"' } } }),
+ })
+ await page.goto('/app/addons?tab=browse')
+ const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
+ await row.getByRole('button', { name: 'Install' }).click()
+ await page.getByRole('alertdialog').getByRole('button', { name: 'Install' }).click()
+ await expect(row.getByRole('alert')).toContainText('The backend was not installed.')
+ await expect(row.getByRole('alert')).toContainText('already installed from another origin')
+ await expect(row.getByRole('button', { name: 'Install' })).toBeEnabled()
+ })
+
+ test('browse: the details give the kind, the alias and the signer pattern', async ({ page }) => {
+ await signIn(page)
+ await mockAddons(page, { installed: [], releases: [backend()] })
+ await mockBackends(page)
+ await page.goto('/app/addons?tab=browse')
+ await page.locator('.cat-item', { hasText: 'cpu-local-store' }).getByRole('button', { name: 'Details' }).click()
+ await expect(page.getByTestId('details-kind')).toHaveText('Backend')
+ await expect(page.getByText('local-store', { exact: true })).toBeVisible()
+ await expect(page.getByText('workflows/w', { exact: false })).toBeVisible()
+ })
+
+ test('libraries: a signer pattern is edited and saved without an exact identity, and skipped entries are reported', async ({ page }) => {
+ await signIn(page)
+ const state = await mockAddons(page, {
+ sources: [{ id: 'official-backends', name: 'Official backends', url: 'https://catalog.example/backends.yaml', enabled: true, policy: POLICY, refresh: { checkedAt: new Date().toISOString(), lastSuccess: new Date().toISOString(), count: 1, skipped: 2, skippedKinds: ['model'], failed: false } }],
+ })
+ await mockBackends(page)
+ await page.goto('/app/addons?tab=libraries')
+ await expect(page.getByTestId('skipped-kinds')).toContainText('2 entries skipped: kind model')
+ await page.getByRole('button', { name: 'Edit' }).click()
+ const pattern = page.getByLabel('Signer identity pattern (optional)')
+ await expect(pattern).toHaveValue(POLICY.identityRegex)
+ await page.getByRole('button', { name: 'Save' }).click()
+ await expect.poll(() => state.log.dispatch.filter(d => d.action === 'catalog-source-save').length).toBe(1)
+ const saved = state.log.dispatch.find(d => d.action === 'catalog-source-save').arguments
+ expect(saved.policy).toEqual(POLICY)
+ })
+})
diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json
index 043de95a3..15574ba6c 100644
--- a/core/http/react-ui/public/locales/en/addons.json
+++ b/core/http/react-ui/public/locales/en/addons.json
@@ -67,7 +67,7 @@
"details": "Details",
"detailsLead": "Library entry for {{id}}",
"emptyBody": "Add a library on the Libraries tab, then refresh it.",
- "emptyTitle": "No add-ons found",
+ "emptyTitle": "No library entries found",
"fromLibrary": "from {{library}}",
"install": "Install",
"installedDetail": "Version {{version}}, generation {{generation}}",
@@ -76,7 +76,18 @@
"installedSame": "Installed {{version}}",
"installedState": "Installed",
"installing": "Installing",
- "search": "Search the libraries"
+ "search": "Search the libraries",
+ "kind": {
+ "addon": "Add-on",
+ "backend": "Backend"
+ },
+ "installedBackend": "Installed",
+ "installedBackendNote": "Installed as a backend on this node. Models can use it now.",
+ "confirmBackendBody": "Version {{version}} of the backend from the library {{library}} will be downloaded, checked against its digest and signature, and installed on this node. The backend gallery is not changed.",
+ "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
+ "backendFailed": "The backend was not installed.",
+ "alias": "Alias",
+ "kindLabel": "Kind"
},
"catalog": {
"allLibraries": "All libraries",
@@ -156,7 +167,8 @@
"required": "Required when a signature is required.",
"url": "Enter an http or https address.",
"urlCredentials": "The address must not contain a user name or password.",
- "urlQuery": "The address must not contain a query string or fragment."
+ "urlQuery": "The address must not contain a query string or fragment.",
+ "either": "Use the exact identity or the pattern, not both."
},
"fields": {
"enabled": "Enabled",
@@ -166,12 +178,14 @@
"name": "Name",
"reference": "Credential reference (optional)",
"requireSignature": "Require a signature",
- "url": "Catalog URL"
+ "url": "Catalog URL",
+ "identityRegex": "Signer identity pattern (optional)"
},
"hints": {
"id": "Lowercase letters, digits and hyphens. It cannot change later.",
"reference": "The name of a credential the owner configured. Never paste a token or password here.",
- "url": "An https address without credentials or a query string."
+ "url": "An https address without credentials or a query string.",
+ "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify."
},
"lastRefresh": "Refreshed {{when}}, {{count}} entries",
"lead": "A library is a catalog document that lists add-ons. Credentials are never typed here: a private library names a credential the owner configured.",
@@ -187,7 +201,9 @@
"removeTitle": "Remove {{name}}?",
"removed": "Removed {{name}}.",
"save": "Save",
- "saved": "Saved {{name}}."
+ "saved": "Saved {{name}}.",
+ "skipped_one": "{{count}} entry skipped: kind {{kinds}} is not read by this version",
+ "skipped_other": "{{count}} entries skipped: kind {{kinds}} is not read by this version"
},
"manage": {
"api": "Add-on API",
diff --git a/core/http/react-ui/src/components/addons/BrowseView.jsx b/core/http/react-ui/src/components/addons/BrowseView.jsx
index 8f70f38fb..d921a1fea 100644
--- a/core/http/react-ui/src/components/addons/BrowseView.jsx
+++ b/core/http/react-ui/src/components/addons/BrowseView.jsx
@@ -20,6 +20,10 @@ export default function BrowseView({ api, adapter }) {
const { t } = useTranslation('addons')
const [params, setParams] = useSearchParams()
const [installed, setInstalled] = useState({})
+ // Backends are installed by the backend code of the host, not by the add-on
+ // owner, so they have their own installed list and their own install state.
+ const [installedBackends, setInstalledBackends] = useState({})
+ const [backendWork, setBackendWork] = useState({})
const [reload, setReload] = useState(0)
const [confirm, setConfirm] = useState(null)
const [details, setDetails] = useState(null)
@@ -43,9 +47,31 @@ export default function BrowseView({ api, adapter }) {
return () => ctl.abort()
}, [api, reload])
+ useEffect(() => {
+ const ctl = new AbortController()
+ api.backends.installed(ctl.signal)
+ .then(list => setInstalledBackends(Object.fromEntries(list.map(b => [b.id, b]))))
+ .catch(() => { if (!ctl.signal.aborted) setInstalledBackends({}) })
+ return () => ctl.abort()
+ }, [api, reload])
+
const actions = useAddonActions(api, { onSettled: () => setReload(n => n + 1) })
+ const installBackend = async (entry) => {
+ setConfirm(null)
+ const key = `install:${entry.key}`
+ setBackendWork(w => ({ ...w, [key]: { phase: 'working' } }))
+ try {
+ await api.backends.install({ sourceId: entry.sourceId, name: entry.id, version: entry.version })
+ setBackendWork(w => ({ ...w, [key]: { phase: 'done' } }))
+ } catch (error) {
+ setBackendWork(w => ({ ...w, [key]: { phase: 'failed', error } }))
+ }
+ setReload(n => n + 1)
+ }
+
const install = (entry) => {
+ if (entry.kind === 'backend') return installBackend(entry)
setConfirm(null)
actions.run(`install:${entry.key}`, {
addonId: entry.id,
@@ -57,11 +83,13 @@ export default function BrowseView({ api, adapter }) {
}
const renderEntry = (entry) => {
- const have = installed[entry.id]
+ const isBackend = entry.kind === 'backend'
+ const have = isBackend ? installedBackends[entry.id] : installed[entry.id]
const key = `install:${entry.key}`
- const st = actions.state[key]
- const pending = Boolean(actions.unknown[entry.id])
- const working = st?.phase === 'working'
+ const bw = isBackend ? backendWork[key] : null
+ const st = isBackend ? null : actions.state[key]
+ const pending = !isBackend && Boolean(actions.unknown[entry.id])
+ const working = isBackend ? bw?.phase === 'working' : st?.phase === 'working'
return (
@@ -76,19 +104,27 @@ export default function BrowseView({ api, adapter }) {
{entry.description && {entry.description}
}
+ {t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })}
{entry.platform.os}/{entry.platform.arch}
{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
{entry.channel && {entry.channel}}
- {have && {have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })}}
- {have && updateView(have.update).state === 'update-available' && {t('updates.badge.available', { version: updateView(have.update).version })}}
+ {isBackend && have && {t('browse.installedBackend')}}
+ {!isBackend && have && {have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })}}
+ {!isBackend && have && updateView(have.update).state === 'update-available' && {t('updates.badge.available', { version: updateView(have.update).version })}}
{pending && }
{!pending && st && !working && st.outcome !== 'completed' && (
{describeOutcome(st.outcome, t, st.error)}
)}
{st && st.outcome === 'completed' && {t('browse.installedNotEnabled')}
}
+ {bw?.phase === 'failed' && (
+
+ {t('browse.backendFailed')} {bw.error?.status >= 400 && bw.error.message && bw.error.message !== bw.error.kind ? bw.error.message : describeError(bw.error, t)}
+
+ )}
+ {bw?.phase === 'done' && {t('browse.installedBackendNote')}
}
@@ -214,6 +217,7 @@ function SourceEditor({ draft, onSave, onClose, saving, t }) {
{form.requireSignature && field('issuer', t('libraries.fields.issuer'), { autoComplete: 'off' })}
{form.requireSignature && field('identity', t('libraries.fields.identity'), { autoComplete: 'off' })}
+ {form.requireSignature && field('identityRegex', t('libraries.fields.identityRegex'), { autoComplete: 'off', spellCheck: false }, t('libraries.hints.identityRegex'))}
{field('authReference', t('libraries.fields.reference'), { autoComplete: 'off', spellCheck: false }, t('libraries.hints.reference'))}
diff --git a/core/http/react-ui/src/components/catalog/catalogState.js b/core/http/react-ui/src/components/catalog/catalogState.js
index 3987688cb..c8dc5e333 100644
--- a/core/http/react-ui/src/components/catalog/catalogState.js
+++ b/core/http/react-ui/src/components/catalog/catalogState.js
@@ -142,7 +142,11 @@ export function validateSourceDraft(draft = {}) {
if (ref && !REFERENCE.test(ref)) errors.authReference = 'reference'
if (draft.requireSignature) {
if (!String(draft.issuer || '').trim()) errors.issuer = 'required'
- if (!String(draft.identity || '').trim()) errors.identity = 'required'
+ // A signer is named exactly or by a pattern (backend libraries), not both.
+ const exact = String(draft.identity || '').trim()
+ const pattern = String(draft.identityRegex || '').trim()
+ if (!exact && !pattern) errors.identity = 'required'
+ else if (exact && pattern) errors.identityRegex = 'either'
}
return errors
}
@@ -159,7 +163,10 @@ export function draftToSource(draft) {
}
if (draft.requireSignature) {
source.policy.issuer = String(draft.issuer).trim()
- source.policy.identity = String(draft.identity).trim()
+ const exact = String(draft.identity || '').trim()
+ const pattern = String(draft.identityRegex || '').trim()
+ if (exact) source.policy.identity = exact
+ if (pattern) source.policy.identityRegex = pattern
}
const ref = String(draft.authReference || '').trim()
if (ref) source.authReference = ref
@@ -177,6 +184,7 @@ export function sourceToDraft(source = {}) {
requireSignature: Boolean(source.policy?.requireSignature),
issuer: source.policy?.issuer || '',
identity: source.policy?.identity || '',
+ identityRegex: source.policy?.identityRegex || '',
authReference: source.authReference || '',
allowLoopbackHTTP: Boolean(source.allowLoopbackHTTP),
}
diff --git a/core/http/react-ui/src/utils/addonsApi.js b/core/http/react-ui/src/utils/addonsApi.js
index a98a06ea1..90ba13bce 100644
--- a/core/http/react-ui/src/utils/addonsApi.js
+++ b/core/http/react-ui/src/utils/addonsApi.js
@@ -128,6 +128,19 @@ export function createAddonsApi({ fetch: fetchImpl, base = p => p } = {}) {
prune(id, { operationId }, signal) { return api.recovery.mutate(id, 'recovery/prune', { operationId }, signal) },
purge(id, body, signal) { return api.recovery.mutate(id, 'purge', body, signal) },
},
+ // Backends of the libraries. The library entry is resolved by the server;
+ // the request names the entry and nothing else. The install runs inside
+ // the request and answers when the backend is in place.
+ backends: {
+ async installed(signal) {
+ const out = await call('/backends', { signal })
+ const list = Array.isArray(out) ? out : (Array.isArray(out?.backends) ? out.backends : [])
+ return list.map(b => ({ id: b.Name || b.name || '', library: b.Metadata?.library || '' })).filter(b => b.id)
+ },
+ install({ sourceId, name, version }, signal) {
+ return call('/backends/library/install', { method: 'POST', body: { sourceId, name, version }, signal })
+ },
+ },
// Update detection and the explicit upgrade. The status is the owner's
// answer and the page never derives it. Check and upgrade are the
// administrator's own action: the operation ID is also the confirmation
@@ -174,6 +187,9 @@ export function createAddonsCatalogAdapter(api, { pageSize = 24 } = {}) {
description: release.description || release.ui?.description || '',
version: release.version,
sourceId: release.sourceId,
+ // The kind of the entry: addon when the library does not say.
+ kind: release.kind || 'addon',
+ alias: release.alias || '',
channel: release.channel || '',
digest: release.digest || '',
platform: { os: release.os, arch: release.arch },
@@ -188,7 +204,7 @@ export function createAddonsCatalogAdapter(api, { pageSize = 24 } = {}) {
const payload = await api.dispatch({
operationId: newOperationId(),
action: 'catalog-search',
- args: { text: query?.text || '', sourceId: query?.sourceId || '', offset, limit: pageSize },
+ args: { text: query?.text || '', kind: 'all', sourceId: query?.sourceId || '', offset, limit: pageSize },
}, signal)
const releases = Array.isArray(payload?.releases) ? payload.releases : []
return { entries: releases.map(toEntry), total: Number(payload?.total) || 0, offset }
diff --git a/core/http/react-ui/tests/unit/addons.test.mjs b/core/http/react-ui/tests/unit/addons.test.mjs
index 455da2e96..b158da001 100644
--- a/core/http/react-ui/tests/unit/addons.test.mjs
+++ b/core/http/react-ui/tests/unit/addons.test.mjs
@@ -153,11 +153,12 @@ test('the api sends one dispatch shape and classifies refusals', async () => {
assert.equal(calls[0].init.method, 'POST')
assert.equal(calls[0].init.credentials, 'same-origin')
const sent = JSON.parse(calls[0].init.body)
- assert.deepEqual(sent.arguments, { text: 'no', sourceId: 'main', offset: 10, limit: 10 })
+ assert.deepEqual(sent.arguments, { text: 'no', kind: 'all', sourceId: 'main', offset: 10, limit: 10 })
assert.equal(sent.action, 'catalog-search')
assert.ok(sent.operationId)
assert.equal(page.total, 31)
assert.equal(page.entries[0].key, 'main/notes/1.0.0')
+ assert.equal(page.entries[0].kind, 'addon')
await assert.rejects(adapter.listSources(), e => e instanceof AddonsError && e.kind === 'denied')
await assert.rejects(
adapter.submitLifecycle({ operationId: 'op-1', action: 'install', addonId: 'notes', sourceId: 'main', version: '1.0.0' }),
@@ -278,3 +279,39 @@ test('a refusal shows the server reason and falls back to the generic text', ()
// Other kinds keep their own words.
assert.equal(describeError(new AddonsError(classifyResponse(401, { error: { message: 'x' } })), t), 'T:errors.auth')
})
+
+test('a backend entry keeps its kind and installs through the backend route', async () => {
+ const calls = []
+ const api = createAddonsApi({
+ fetch: async (url, init) => {
+ calls.push({ url, init })
+ const body = init.body ? JSON.parse(init.body) : null
+ if (body?.action === 'catalog-search') return json(200, { payload: { releases: [{ kind: 'backend', addonId: 'cpu-local-store', alias: 'local-store', name: 'cpu-local-store', version: '0.0.0-latest.aaaaaaaaaaaa', sourceId: 'official-backends', os: 'linux', arch: 'amd64', digest: 'sha256:a' }], total: 1 } })
+ if (url === '/backends/library/install') return json(409, { error: { type: 'backend_library', message: 'backend is already installed from another origin: "cpu-local-store"' } })
+ if (url === '/backends') return json(200, [{ Name: 'cpu-local-store', Metadata: { library: 'official-backends' } }, { Name: 'llama-cpp' }])
+ return json(200, {})
+ },
+ })
+ const adapter = createAddonsCatalogAdapter(api, { pageSize: 10 })
+ const page = await adapter.search({})
+ assert.equal(page.entries[0].kind, 'backend')
+ assert.equal(page.entries[0].alias, 'local-store')
+ assert.equal(page.entries[0].key, 'official-backends/cpu-local-store/0.0.0-latest.aaaaaaaaaaaa')
+ assert.deepEqual(await api.backends.installed(), [{ id: 'cpu-local-store', library: 'official-backends' }, { id: 'llama-cpp', library: '' }])
+ await assert.rejects(
+ api.backends.install({ sourceId: 'official-backends', name: 'cpu-local-store', version: 'v' }),
+ e => e instanceof AddonsError && e.kind === 'unavailable' && e.status === 409 && /another origin/.test(e.message),
+ )
+ const sent = JSON.parse(calls.at(-1).init.body)
+ assert.deepEqual(sent, { sourceId: 'official-backends', name: 'cpu-local-store', version: 'v' })
+})
+
+test('a library signer is named exactly or by a pattern, never both', () => {
+ const ok = { id: 'b', name: 'B', url: 'https://library.example/i.yaml', enabled: true, requireSignature: true, issuer: 'i' }
+ assert.equal(validateSourceDraft({ ...ok }).identity, 'required')
+ assert.deepEqual(validateSourceDraft({ ...ok, identityRegex: '^a$' }), {})
+ assert.equal(validateSourceDraft({ ...ok, identity: 'x', identityRegex: '^a$' }).identityRegex, 'either')
+ const wire = draftToSource({ ...ok, identityRegex: ' ^a$ ' })
+ assert.deepEqual(wire.policy, { requireSignature: true, issuer: 'i', identityRegex: '^a$' })
+ assert.equal(sourceToDraft({ policy: { requireSignature: true, issuer: 'i', identityRegex: '^a$' } }).identityRegex, '^a$')
+})