feat(community): 增加签名目录与七类发行入口

This commit is contained in:
2026-09-07 16:52:49 +08:00
parent afb76dc325
commit 508f13e5d8
17 changed files with 1429 additions and 2 deletions
@@ -30,6 +30,7 @@ const selectionSnapshot = ref<string | null>(null)
interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> }
const builtinCommands = computed<Command[]>(() => [
{ id: 'community', label: t('社区目录', 'Community catalog'), hint: t('导航', 'Navigation'), run: () => router.push('/community') },
{ id: 'themes', label: t('主题管理', 'Manage themes'), hint: t('导航', 'Navigation'), run: () => router.push('/themes') },
{ id: 'tasks', label: t('任务列表', 'Tasks'), hint: t('导航', 'Navigation'), run: () => router.push('/tasks') },
{ id: 'workspace', label: t('打开工作区', 'Open workspace'), hint: t('导航', 'Navigation'), run: () => router.push('/workspace') },
@@ -20,6 +20,7 @@ const navItems = computed(() => [
{ name: 'plugins', icon: Connection, label: 'Plugin' },
{ name: 'mcp-servers', icon: Monitor, label: 'MCP' },
{ name: 'themes', icon: Brush, label: t('主题', 'Themes') },
{ name: 'community', icon: Connection, label: t('社区', 'Community') },
{ name: 'benchmarks', icon: Monitor, label: 'Benchmark' },
{ name: 'logs', icon: Document, label: t('日志', 'Logs') },
{ name: 'settings', icon: Setting, label: t('设置', 'Settings') },
@@ -42,7 +43,8 @@ function toggleExpanded() {
<template>
<aside class="primary-sidebar" :class="{ expanded }">
<nav class="nav-list">
<div
<button
type="button"
v-for="item in navItems"
:key="item.name"
class="nav-item"
@@ -52,7 +54,7 @@ function toggleExpanded() {
>
<AppIcon class="nav-icon" :icon="item.icon" :size="20" />
<span class="nav-label">{{ item.label }}</span>
</div>
</button>
</nav>
<div class="sidebar-footer">
<button class="nav-item collapse-button" type="button" :title="expanded ? t('收起导航', 'Collapse navigation') : t('展开导航', 'Expand navigation')" @click="toggleExpanded">
@@ -89,6 +91,9 @@ function toggleExpanded() {
}
.nav-item {
border: 0;
background: transparent;
font: inherit;
display: flex;
flex-direction: column;
align-items: center;
+12
View File
@@ -0,0 +1,12 @@
/** 社区协议 v1;签名元数据与安装运行状态分离。 */
export type PackageKind = 'theme' | 'skill' | 'plugin' | 'mcp' | 'persona' | 'template' | 'model'
export interface CommunityKey { key_id: string; namespace: string; public_key: string; revoked: boolean }
export interface CommunitySource { id: string; url: string; enabled: boolean; keys: CommunityKey[]; fetchedAt?: string }
export interface CommunityRelease {
schema_version: 1; namespace: string; package_id: string; type: PackageKind; version: string
name: string; author_id: string; license: string; description: string; sha256: string; size: number
platforms: string[]; architectures: string[]; min_app_version: string; max_app_version: string | null
dependencies: Record<string, string>; permissions: string[]; changelog: string; published_at: string
key_id: string; signature: string; release_id: string; withdrawn: boolean; download_path: string
}
export interface CommunityCatalog { schema_version: 1; items: CommunityRelease[]; total: number; offset: number }
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import type { CommunityKey, CommunityRelease, CommunitySource, PackageKind } from '@/contracts/community'
import { cachedCatalog, discoverKeys, fetchCatalog, installRelease, loadSources, saveSources } from '@/services/communityService'
import AppDialog from '@/components/common/AppDialog.vue'
const kinds: { id: PackageKind | ''; label: string }[] = [
{ id: '', label: '全部' }, { id: 'theme', label: '主题' }, { id: 'skill', label: 'Skill' },
{ id: 'plugin', label: 'Plugin' }, { id: 'mcp', label: 'MCP 配置' }, { id: 'persona', label: '人设' },
{ id: 'template', label: '笔记模板' }, { id: 'model', label: '模型方案' },
]
const sources = ref(loadSources()), selectedSource = ref(sources.value[0]?.id ?? '')
const url = ref(''), query = ref(''), kind = ref<PackageKind | ''>('')
const items = ref<CommunityRelease[]>([]), detail = ref<CommunityRelease | null>(null)
const candidateKeys = ref<CommunityKey[]>([]), candidateUrl = ref('')
const busy = ref(false), error = ref(''), notice = ref(''), offline = ref(false)
let controller: AbortController | undefined
let version = 0
const candidates = ref<{ key: string; value: string }[]>([])
function refreshCandidates() {
candidates.value = Object.keys(localStorage).filter(key => key.startsWith('community-candidate:')).map(key => ({ key, value: localStorage.getItem(key) ?? '' }))
}
refreshCandidates()
function removeCandidate(key: string) { localStorage.removeItem(key); refreshCandidates() }
function cancel() { version++; controller?.abort(); busy.value = false }
onBeforeUnmount(cancel)
function source(): CommunitySource {
const value = sources.value.find(item => item.id === selectedSource.value)
if (!value) throw new Error('请先添加并选择一个来源')
return value
}
async function run(action: (signal: AbortSignal, current: () => boolean) => Promise<void>) {
cancel(); const request = ++version
controller = new AbortController(); busy.value = true; error.value = ''; notice.value = ''
try { await action(controller.signal, () => request === version) }
catch (reason) { if (request === version) error.value = reason instanceof Error ? reason.message : String(reason) }
finally { if (request === version) busy.value = false }
}
function inspectSource() {
const snapshot = url.value.trim()
void run(async (signal, current) => { const keys = await discoverKeys(snapshot, signal); if (current()) { candidateKeys.value = keys; candidateUrl.value = snapshot } })
}
function trustSource() {
const existing = sources.value.find(item => item.url === candidateUrl.value)
const value: CommunitySource = { id: existing?.id ?? crypto.randomUUID(), url: candidateUrl.value, enabled: true, keys: candidateKeys.value, fetchedAt: new Date().toISOString() }
sources.value = [...sources.value.filter(item => item.id !== value.id), value]
saveSources(sources.value); selectedSource.value = value.id; candidateUrl.value = ''; candidateKeys.value = []
search()
}
function search() {
void run(async (signal, current) => {
const selected = source()
try {
const result = await fetchCatalog(selected, query.value, kind.value, signal)
if (current()) { items.value = result.items; offline.value = false; notice.value = result.total > result.items.length ? `展示前 ${result.items.length} 项,请缩小搜索范围。` : '' }
} catch (reason) {
const cached = cachedCatalog(selected)
if (current() && cached) { items.value = cached.items; offline.value = true }
throw reason
}
})
}
function install() {
if (!detail.value) return
const selected = detail.value, selectedRegistry = source()
void run(async (signal, current) => {
const result = await installRelease(selectedRegistry, selected, signal)
if (current()) { notice.value = result; refreshCandidates() }
})
}
function toggleSource() {
const selected = source(); selected.enabled = !selected.enabled; saveSources(sources.value)
items.value = []; cancel()
}
</script>
<template>
<main class="community-page">
<h1>社区目录</h1>
<p>连接您选择的来源安装后仍需独立启用和授权关闭社区不影响本地编辑</p>
<section class="community-controls" aria-label="社区来源">
<label>来源地址 <input v-model="url" placeholder="https://community.example.org" :disabled="busy" /></label>
<button class="btn" :disabled="busy || !url.trim()" @click="inspectSource">检查来源与公钥</button>
<label>已添加来源 <select v-model="selectedSource" @change="search"><option value="">请选择</option><option v-for="item in sources" :key="item.id" :value="item.id">{{ item.url }}{{ item.enabled ? '' : '已停用' }}</option></select></label>
<button class="btn" :disabled="!selectedSource || busy" @click="toggleSource">启用 / 停用来源</button>
</section>
<section class="community-controls" aria-label="搜索目录">
<label>关键词 <input v-model="query" @keydown.enter="search" /></label>
<label>类别 <select v-model="kind"><option v-for="item in kinds" :key="item.id" :value="item.id">{{ item.label }}</option></select></label>
<button class="btn btn-primary" :disabled="busy || !selectedSource" @click="search">搜索 / 刷新</button>
<button v-if="busy" class="btn" @click="cancel">取消</button>
</section>
<p v-if="busy" role="status">正在处理</p>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="notice" role="status">{{ notice }}</p>
<p v-if="offline">当前为离线缓存仅供浏览安装需要重新核对撤回和签名状态</p>
<p v-if="!busy && !items.length">尚无发行记录添加来源后搜索目录</p>
<div class="community-grid">
<button v-for="item in items" :key="item.release_id" class="community-card" @click="detail = item">
<strong>{{ item.name }}</strong><span>{{ item.type }} · {{ item.version }}</span>
<span>{{ item.description }}</span><span>{{ item.namespace }}/{{ item.package_id }} · {{ item.license }}</span>
<span v-if="item.withdrawn">已撤回</span>
</button>
</div>
<section v-if="candidates.length">
<h2>已保存的声明式候选</h2><p>这些候选尚未应用到人设MCP 或模型运行配置</p>
<details v-for="item in candidates" :key="item.key"><summary>{{ item.key.replace('community-candidate:', '') }}</summary><pre>{{ item.value }}</pre><button class="btn" @click="removeCandidate(item.key)">删除候选</button></details>
</section>
<AppDialog v-if="candidateUrl" label="核对来源公钥" @close="candidateUrl = ''">
<p>{{ candidateUrl }}</p><p>请与来源维护者公布的公钥核对确认后固定这些公钥密钥改变时不会自动信任</p>
<pre>{{ JSON.stringify(candidateKeys, null, 2) }}</pre>
<button class="btn btn-primary" :disabled="!candidateKeys.length" @click="trustSource">确认并固定公钥</button>
</AppDialog>
<AppDialog v-if="detail" label="发行详情与安装" @close="detail = null">
<template v-if="detail">
<h2>{{ detail.name }} {{ detail.version }}</h2><p>{{ detail.description }}</p>
<dl><dt>作者 / 来源</dt><dd>{{ detail.author_id }} / {{ detail.namespace }}</dd><dt>许可证</dt><dd>{{ detail.license }}</dd><dt>大小 / 摘要</dt><dd>{{ detail.size }} 字节<br />{{ detail.sha256 }}</dd><dt>兼容平台</dt><dd>{{ detail.platforms.join(', ') }} / {{ detail.architectures.join(', ') }}</dd><dt>权限</dt><dd>{{ detail.permissions.join(', ') || '无' }}</dd><dt>依赖</dt><dd>{{ JSON.stringify(detail.dependencies) }}</dd></dl>
<pre>{{ detail.changelog }}</pre>
<p>安装不会自动启用包或其依赖人设模板MCP 与模型方案仅保存为可检查的候选</p>
<button class="btn btn-primary" :disabled="busy || detail.withdrawn || offline" @click="install">校验并安装</button>
</template>
</AppDialog>
</main>
</template>
<style scoped>
.community-page { padding: var(--space-xl); overflow: auto; min-width: 0; }
.community-controls { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-md); margin-block: var(--space-lg); }
label { display: grid; gap: var(--space-xs); }
input, select { color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); padding: var(--space-sm); }
.community-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-md); }
.community-card { display: grid; gap: var(--space-sm); text-align: left; padding: var(--space-lg); color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); overflow-wrap: anywhere; }
pre, dd { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; }
</style>
+1
View File
@@ -3,6 +3,7 @@ import { useWorkspaceStore } from '@/stores/workspace'
import { t } from '@/i18n'
const routes = [
{ path: '/community', name: 'community', component: () => import('@/features/community/CommunityView.vue'), meta: { title: '社区目录' } },
{ path: '/benchmarks', name: 'benchmarks', component: () => import('@/features/benchmarks/BenchmarkView.vue'), meta: { title: 'Benchmark' } },
{ path: '/logs', name: 'logs', component: () => import('@/features/logs/LogsView.vue'), meta: { title: '运行日志' } },
{ path: '/media', name: 'media', component: () => import('@/features/media/MediaView.vue'), meta: { title: '音视频转写', requiresVault: true } },
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { webcrypto } from 'node:crypto'
import vector from './fixtures/community-python-vector.json'
import type { CommunityRelease, CommunitySource } from '@/contracts/community'
import { discoverKeys, fetchCatalog, verifyRelease } from './communityService'
afterEach(() => vi.unstubAllGlobals())
const release = vector.release as CommunityRelease
const bytes = Uint8Array.from(atob(vector.archive_base64), c => c.charCodeAt(0))
describe('社区 Python / TypeScript 签名契约', () => {
it('校验 Python canonical JSON 与真实 Ed25519 签名', async () => {
vi.stubGlobal('crypto', webcrypto)
await expect(verifyRelease(release, vector.key, bytes)).resolves.toBeUndefined()
})
it('权限篡改、错误摘要和撤回均拒绝', async () => {
vi.stubGlobal('crypto', webcrypto)
await expect(verifyRelease({ ...release, permissions: ['network.request'] }, vector.key, bytes)).rejects.toThrow('签名')
await expect(verifyRelease(release, vector.key, new Uint8Array([0]))).rejects.toThrow('摘要')
await expect(verifyRelease({ ...release, withdrawn: true }, vector.key, bytes)).rejects.toThrow('撤回')
await expect(verifyRelease(release, { ...vector.key, revoked: true }, bytes)).rejects.toThrow('撤回')
})
it('不携带凭据、拒绝重定向并支持取消', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ schema_version: 1, keys: [] })))
vi.stubGlobal('fetch', fetch)
await discoverKeys('https://example.org')
expect(fetch.mock.calls[0]?.[1]).toMatchObject({ credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer' })
await expect(discoverKeys('file:///tmp/catalog')).rejects.toThrow('HTTPS')
})
it('停用来源不发请求', async () => {
const source: CommunitySource = { id: 'fixture', url: 'https://example.org', enabled: false, keys: [] }
const fetch = vi.fn(); vi.stubGlobal('fetch', fetch)
await expect(fetchCatalog(source)).rejects.toThrow('停用')
expect(fetch).not.toHaveBeenCalled()
})
})
+122
View File
@@ -0,0 +1,122 @@
/** 只请求用户配置来源;公钥固定、签名及摘要检查先于任何安装 API。 */
import type { CommunityCatalog, CommunityRelease, CommunitySource, CommunityKey } from '@/contracts/community'
import { valid, gt, lt } from 'semver'
import appPackage from '../../package.json'
import { decodeThemePackage, inspectThemePackage, installTheme } from './themePackageService'
import { installSkill } from './skillService'
import { installPlugin } from './pluginService'
const sourceStorage = 'community-sources-v1'
export function loadSources(): CommunitySource[] {
try { return JSON.parse(localStorage.getItem(sourceStorage) ?? '[]') as CommunitySource[] } catch { return [] }
}
export function saveSources(sources: CommunitySource[]) { localStorage.setItem(sourceStorage, JSON.stringify(sources)) }
function sourceUrl(source: CommunitySource, path: string) {
const base = new URL(source.url)
if (base.username || base.password || base.search || base.hash || (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname)))) throw new Error('来源必须是 HTTPS;本机开发可用 HTTP。')
const url = new URL(path, base)
if (url.origin !== base.origin || !url.pathname.startsWith('/catalog/v1/')) throw new Error('发行地址不属于已固定的社区来源')
return url
}
async function download(source: CommunitySource, path: string, maxSize: number, signal?: AbortSignal): Promise<Uint8Array> {
const controller = new AbortController()
const abort = () => controller.abort()
signal?.addEventListener('abort', abort, { once: true })
if (signal?.aborted) abort()
const timeout = setTimeout(abort, 30000)
try {
const response = await fetch(sourceUrl(source, path), { credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer', signal: controller.signal })
if (!response.ok || !response.body) throw new Error(`社区请求失败 (${response.status})`)
const reader = response.body.getReader(), chunks: Uint8Array[] = []
let size = 0
try {
while (true) {
const part = await reader.read()
if (part.done) break
size += part.value.length
if (size > maxSize) throw new Error('社区响应超过大小限制')
chunks.push(part.value)
}
} finally { await reader.cancel().catch(() => undefined) }
const bytes = new Uint8Array(size)
let offset = 0
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length }
return bytes
} finally { clearTimeout(timeout); signal?.removeEventListener('abort', abort) }
}
export async function discoverKeys(url: string, signal?: AbortSignal): Promise<CommunityKey[]> {
const source: CommunitySource = { id: 'candidate', url, enabled: true, keys: [] }
const value = JSON.parse(new TextDecoder().decode(await download(source, '/catalog/v1/sources', 1024 * 1024, signal)))
if (value.schema_version !== 1 || !Array.isArray(value.keys)) throw new Error('不支持的社区来源协议')
return value.keys
}
export async function fetchCatalog(source: CommunitySource, q = '', kind = '', signal?: AbortSignal): Promise<CommunityCatalog> {
if (!source.enabled) throw new Error('来源已停用')
const value = JSON.parse(new TextDecoder().decode(await download(source, `/catalog/v1/packages?q=${encodeURIComponent(q)}${kind ? `&type=${encodeURIComponent(kind)}` : ''}&limit=100`, 2 * 1024 * 1024, signal)))
if (value.schema_version !== 1 || !Array.isArray(value.items)) throw new Error('不支持的社区目录协议')
// 缓存只用于离线浏览;安装仍会重新拉取发行与撤回状态。
localStorage.setItem(`community-cache:${source.id}`, JSON.stringify(value))
return value
}
export function cachedCatalog(source: CommunitySource): CommunityCatalog | null {
try { return JSON.parse(localStorage.getItem(`community-cache:${source.id}`) ?? 'null') } catch { return null }
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`).join(',')}}`
return JSON.stringify(value)
}
const bytes64 = (value: string) => Uint8Array.from(atob(value), char => char.charCodeAt(0))
const signedFields = ['schema_version', 'namespace', 'package_id', 'type', 'version', 'name', 'author_id', 'license', 'description', 'sha256', 'size', 'platforms', 'architectures', 'min_app_version', 'max_app_version', 'dependencies', 'permissions', 'changelog', 'published_at', 'key_id'] as const
export async function verifyRelease(release: CommunityRelease, pinned: CommunityKey, bytes: Uint8Array) {
if (release.withdrawn || pinned.revoked || pinned.key_id !== release.key_id || pinned.namespace !== release.namespace) throw new Error('发行或签名密钥已撤回,或来源不匹配')
if (!valid(release.version) || !valid(release.min_app_version) || gt(release.min_app_version, appPackage.version)
|| (release.max_app_version && (!valid(release.max_app_version) || lt(release.max_app_version, appPackage.version)))) throw new Error('发行版本与当前应用不兼容')
const key = await crypto.subtle.importKey('raw', bytes64(pinned.public_key), { name: 'Ed25519' }, false, ['verify'])
const metadata = Object.fromEntries(signedFields.map(field => [field, release[field]]))
if (!await crypto.subtle.verify('Ed25519', key, bytes64(release.signature), new TextEncoder().encode(canonical(metadata)))) throw new Error('发行签名无效')
const digest = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes as Uint8Array<ArrayBuffer>))).map(x => x.toString(16).padStart(2, '0')).join('')
if (digest !== release.sha256 || bytes.length !== release.size) throw new Error('发行内容摘要或长度无效')
}
export async function installRelease(source: CommunitySource, selected: CommunityRelease, signal?: AbortSignal): Promise<string> {
const catalog = await fetchCatalog(source, '', selected.type, signal)
const release = catalog.items.find(item => item.release_id === selected.release_id)
if (!release || release.sha256 !== selected.sha256 || release.withdrawn) throw new Error('发行已变更或撤回,请刷新目录')
const liveKeys = await discoverKeys(source.url, signal)
const pinned = source.keys.find(key => key.key_id === release.key_id && key.namespace === release.namespace)
const live = liveKeys.find(key => key.key_id === release.key_id)
if (!pinned || !live || live.revoked || live.public_key !== pinned.public_key) throw new Error('签名密钥未固定或已变更,需重新检查来源')
const bytes = await download(source, release.download_path, release.type === 'theme' ? 5 * 1024 * 1024 : 10 * 1024 * 1024, signal)
await verifyRelease(release, pinned, bytes)
signal?.throwIfAborted()
if (Object.keys(release.dependencies).length) throw new Error('该包存在依赖,请先在扩展管理中核对依赖版本;不会自动启用依赖。')
if (release.type === 'theme') {
const inspection = await inspectThemePackage(await decodeThemePackage(bytes))
if (!inspection.compatible || inspection.manifest.theme_id !== release.package_id || inspection.manifest.version !== release.version) throw new Error('主题包类型或身份校验失败')
await installTheme(inspection.manifest, inspection.css ?? '')
} else if (release.type === 'skill' || release.type === 'plugin') {
const file = new File([bytes as Uint8Array<ArrayBuffer>], `${release.package_id}.zip`, { type: 'application/zip' })
await (release.type === 'skill' ? installSkill(file) : installPlugin(file))
} else {
// 声明式包仅存为候选,用户可预览/删除;不会替换全局人设或启动模型/MCP。
const { unzipSync } = await import('fflate')
let total = 0, count = 0
const files = unzipSync(bytes, { filter: entry => {
total += entry.originalSize
if (++count > 2048 || total > 50 * 1024 * 1024) throw new Error('包展开超限')
return entry.name.endsWith(`${release.type}.json`)
} })
const values = Object.values(files)
if (values.length !== 1) throw new Error('类型清单不唯一')
const candidate = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(values[0]))
localStorage.setItem(`community-candidate:${release.namespace}/${release.package_id}`, JSON.stringify({ release, candidate }))
}
return release.type === 'theme' || release.type === 'skill' || release.type === 'plugin' ? '已安装,尚未启用' : '已保存为候选,尚未应用'
}
@@ -0,0 +1,39 @@
{
"release": {
"schema_version": 1,
"namespace": "examples",
"package_id": "test-package",
"type": "persona",
"version": "1.0.0",
"name": "受控示例",
"author_id": "author",
"license": "MIT",
"description": "用于隔离验收",
"sha256": "d4a3dc84580c311e877f0b9c2f4022c14cb8923f757cb2309a55b75d0dd242f5",
"size": 167,
"platforms": [
"windows"
],
"architectures": [
"x86_64"
],
"min_app_version": "0.2.0",
"max_app_version": null,
"dependencies": {},
"permissions": [],
"changelog": "初始版本",
"published_at": "2026-09-07T00:00:00Z",
"key_id": "test-key",
"signature": "PwaYH7leaURJJryfBzmZXWNoNgZqCxsM16iYpIgCx5cvMNzm2tltlV5gz49ykNOW3WH/1u8SEGwdgiTnvNzkCg==",
"release_id": "fixture",
"withdrawn": false,
"download_path": "/catalog/v1/releases/fixture/archive"
},
"archive_base64": "UEsDBBQAAAAAAEOBJ11uLuwILQAAAC0AAAAMAAAAcGVyc29uYS5qc29ueyJzeXN0ZW1fcHJvbXB0IjogIlx1NTNkN1x1NjNhN1x1NjgzN1x1NGY4YiJ9UEsBAhQAFAAAAAAAQ4EnXW4u7AgtAAAALQAAAAwAAAAAAAAAAAAAAIABAAAAAHBlcnNvbmEuanNvblBLBQYAAAAAAQABADoAAABXAAAAAAA=",
"key": {
"key_id": "test-key",
"namespace": "examples",
"public_key": "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=",
"revoked": false
}
}