@adea-ai/plugins 4.0.0 → 4.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-plugins-catalog.d.ts +2 -4
- package/dist/agent-plugins-catalog.js +42 -50
- package/dist/agent-plugins-catalog.js.map +1 -1
- package/dist/agent-plugins-schema.d.ts +8 -0
- package/dist/agent-plugins-schema.js +10 -1
- package/dist/agent-plugins-schema.js.map +1 -1
- package/dist/artifacts.d.ts +57 -0
- package/dist/artifacts.js +159 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/build.d.ts +20 -0
- package/dist/build.js +103 -0
- package/dist/build.js.map +1 -0
- package/dist/capabilities.d.ts +7 -0
- package/dist/capabilities.js +116 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/catalog-store.d.ts +76 -0
- package/dist/catalog-store.js +463 -0
- package/dist/catalog-store.js.map +1 -0
- package/dist/concurrency.d.ts +1 -0
- package/dist/concurrency.js +15 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/consumer-index.d.ts +7 -29
- package/dist/consumer-index.js +24 -15
- package/dist/consumer-index.js.map +1 -1
- package/dist/digest.d.ts +13 -0
- package/dist/digest.js +47 -0
- package/dist/digest.js.map +1 -0
- package/dist/fs-atomic.d.ts +7 -0
- package/dist/fs-atomic.js +30 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/harness.js +2 -1
- package/dist/harness.js.map +1 -1
- package/dist/icon-mirror.d.ts +5 -4
- package/dist/icon-mirror.js +73 -72
- package/dist/icon-mirror.js.map +1 -1
- package/dist/icons.d.ts +2 -2
- package/dist/icons.js +4 -12
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +16 -309
- package/dist/index.js +12 -1900
- package/dist/index.js.map +1 -1
- package/dist/integrity-manifest.d.ts +43 -13
- package/dist/integrity-manifest.js +28 -17
- package/dist/integrity-manifest.js.map +1 -1
- package/dist/json-values.d.ts +3 -0
- package/dist/json-values.js +10 -0
- package/dist/json-values.js.map +1 -0
- package/dist/plugin-release.d.ts +38 -0
- package/dist/plugin-release.js +321 -0
- package/dist/plugin-release.js.map +1 -0
- package/dist/plugin-skips.d.ts +19 -0
- package/dist/plugin-skips.js +65 -0
- package/dist/plugin-skips.js.map +1 -0
- package/dist/policy.d.ts +13 -0
- package/dist/policy.js +26 -0
- package/dist/policy.js.map +1 -0
- package/dist/portable-catalog.d.ts +4 -4
- package/dist/portable-catalog.js +14 -126
- package/dist/portable-catalog.js.map +1 -1
- package/dist/portable-verify.d.ts +2 -0
- package/dist/portable-verify.js +68 -0
- package/dist/portable-verify.js.map +1 -0
- package/dist/publication.d.ts +12 -12
- package/dist/publication.js +31 -21
- package/dist/publication.js.map +1 -1
- package/dist/published-output.d.ts +17 -0
- package/dist/published-output.js +49 -0
- package/dist/published-output.js.map +1 -0
- package/dist/site-icons.d.ts +4 -3
- package/dist/site-icons.js +38 -11
- package/dist/site-icons.js.map +1 -1
- package/dist/snapshots.d.ts +39 -0
- package/dist/snapshots.js +238 -0
- package/dist/snapshots.js.map +1 -0
- package/dist/source-resolution.d.ts +28 -0
- package/dist/source-resolution.js +158 -0
- package/dist/source-resolution.js.map +1 -0
- package/dist/sources.d.ts +5 -1
- package/dist/sources.js +21 -27
- package/dist/sources.js.map +1 -1
- package/dist/synchronize.d.ts +44 -0
- package/dist/synchronize.js +118 -0
- package/dist/synchronize.js.map +1 -0
- package/dist/taxonomy.d.ts +23 -0
- package/dist/taxonomy.js +62 -0
- package/dist/taxonomy.js.map +1 -0
- package/dist/upstream.d.ts +42 -0
- package/dist/upstream.js +179 -0
- package/dist/upstream.js.map +1 -0
- package/dist/verify-artifacts.d.ts +36 -0
- package/dist/verify-artifacts.js +286 -0
- package/dist/verify-artifacts.js.map +1 -0
- package/native/build-provenance.txt +5 -5
- package/native/staged-reader-win32-arm64.node +0 -0
- package/native/staged-reader-win32-x64.node +0 -0
- package/package.json +3 -3
- package/src/agent-plugins-catalog.test.ts +2 -2
- package/src/agent-plugins-catalog.ts +46 -49
- package/src/agent-plugins-schema.ts +11 -1
- package/src/artifacts.test.ts +80 -0
- package/src/artifacts.ts +234 -0
- package/src/build.ts +131 -0
- package/src/capabilities.ts +153 -0
- package/src/catalog-store.ts +519 -0
- package/src/concurrency.ts +19 -0
- package/src/consumer-index.test.ts +147 -16
- package/src/consumer-index.ts +35 -43
- package/src/default-head.test.ts +143 -0
- package/src/digest.ts +55 -0
- package/src/fs-atomic.ts +32 -0
- package/src/harness.test.ts +4 -32
- package/src/harness.ts +2 -1
- package/src/icon-mirror.ts +78 -72
- package/src/icons.test.ts +0 -4
- package/src/icons.ts +4 -4
- package/src/index.test.ts +172 -46
- package/src/index.ts +58 -2562
- package/src/integrity-manifest.ts +41 -21
- package/src/json-values.ts +11 -0
- package/src/manifest-retry.test.ts +49 -0
- package/src/plugin-release.ts +457 -0
- package/src/plugin-skips.ts +110 -0
- package/src/policy.ts +39 -0
- package/src/portable-catalog.test.ts +0 -5
- package/src/portable-catalog.ts +16 -143
- package/src/portable-verify.ts +71 -0
- package/src/publication.test.ts +29 -1
- package/src/publication.ts +38 -22
- package/src/published-output.ts +60 -0
- package/src/site-icons.test.ts +73 -1
- package/src/site-icons.ts +31 -14
- package/src/snapshots.ts +287 -0
- package/src/source-config-sync.test.ts +84 -0
- package/src/source-digest.test.ts +61 -8
- package/src/source-resolution.ts +233 -0
- package/src/sources-json.test.ts +18 -2
- package/src/sources.ts +27 -33
- package/src/synchronize.ts +213 -0
- package/src/taxonomy.ts +87 -0
- package/src/upstream.ts +194 -0
- package/src/verify-artifacts.ts +365 -0
- package/src/write-artifacts.test.ts +374 -0
- package/dist/source-digest.d.ts +0 -2
- package/dist/source-digest.js +0 -12
- package/dist/source-digest.js.map +0 -1
- package/native/README.md +0 -24
- package/src/source-digest.ts +0 -14
package/src/upstream.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
3
|
+
import { canonicalRepositoryUrl } from './sources.js'
|
|
4
|
+
import type { SiteIconFetcher } from './site-icons.js'
|
|
5
|
+
|
|
6
|
+
export function externalGitRef(ref: string | undefined): string {
|
|
7
|
+
if (ref === undefined) return 'HEAD'
|
|
8
|
+
return ref.startsWith('refs/') ? ref : `refs/heads/${ref}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function resolveGitRef(repositoryUrl: string, ref: string): Promise<string> {
|
|
12
|
+
const peeled = ref.startsWith('refs/tags/') && !ref.endsWith('^{}') ? `${ref}^{}` : undefined
|
|
13
|
+
const result = spawn(
|
|
14
|
+
'git',
|
|
15
|
+
['ls-remote', canonicalRepositoryUrl(repositoryUrl), ref, ...(peeled ? [peeled] : [])],
|
|
16
|
+
{
|
|
17
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
21
|
+
new Promise<string>((resolveOutput, reject) => {
|
|
22
|
+
const chunks: Buffer[] = []
|
|
23
|
+
result.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
24
|
+
result.stdout?.on('error', reject)
|
|
25
|
+
result.stdout?.on('end', () => resolveOutput(Buffer.concat(chunks).toString('utf8')))
|
|
26
|
+
}),
|
|
27
|
+
new Promise<string>((resolveError) => {
|
|
28
|
+
const chunks: Buffer[] = []
|
|
29
|
+
result.stderr?.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
30
|
+
result.stderr?.on('error', () => resolveError(''))
|
|
31
|
+
result.stderr?.on('end', () => resolveError(Buffer.concat(chunks).toString('utf8')))
|
|
32
|
+
}),
|
|
33
|
+
new Promise<number | null>((resolveExit, reject) => {
|
|
34
|
+
result.on('error', reject)
|
|
35
|
+
result.on('close', (code) => resolveExit(code ?? 1))
|
|
36
|
+
}),
|
|
37
|
+
])
|
|
38
|
+
if (exitCode !== 0)
|
|
39
|
+
throw new Error(`GIT_REF_LOOKUP_FAILED: ${repositoryUrl}:${ref}:${stderr.trim()}`)
|
|
40
|
+
const rows = stdout
|
|
41
|
+
.trim()
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map((line) => line.split(/\s+/))
|
|
44
|
+
const sha =
|
|
45
|
+
(peeled && rows.find((row) => row[1] === peeled)?.[0]) ??
|
|
46
|
+
rows.find((row) => row[1] === ref)?.[0]
|
|
47
|
+
if (!sha || !/^[a-f0-9]{40}$/.test(sha))
|
|
48
|
+
throw new Error(`GIT_REF_NOT_FOUND: ${repositoryUrl}:${ref}`)
|
|
49
|
+
return sha
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function fetchUpstreamBytes(url: string, signal?: AbortSignal): Promise<Uint8Array> {
|
|
53
|
+
const response = await fetchUpstream(url, {
|
|
54
|
+
headers: upstreamRequestHeaders(url),
|
|
55
|
+
...(signal ? { signal } : {}),
|
|
56
|
+
})
|
|
57
|
+
if (!response.ok) throw new Error(`HTTP_FETCH_FAILED: ${response.status}: ${url}`)
|
|
58
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function rawContentUrl(repositoryUrl: string, commitSha: string, path: string): string {
|
|
62
|
+
const parsed = new URL(canonicalRepositoryUrl(repositoryUrl))
|
|
63
|
+
return `https://raw.githubusercontent.com${parsed.pathname}/${commitSha}/${path.split('/').map(encodeURIComponent).join('/')}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function fetchText(url: string): Promise<string> {
|
|
67
|
+
const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) })
|
|
68
|
+
if (!response.ok) throw new Error(`HTTP_FETCH_FAILED: ${response.status}:${url}`)
|
|
69
|
+
return response.text()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const UPSTREAM_USER_AGENT = 'agent-hq-plugin-marketplace'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Hosts that may receive the repository credential.
|
|
76
|
+
*
|
|
77
|
+
* Everything else is fetched anonymously. The token this build runs with
|
|
78
|
+
* authorises repository writes for the duration of the job, so a vendor
|
|
79
|
+
* homepage, its icon CDN or any other third party must never be handed it.
|
|
80
|
+
*/
|
|
81
|
+
/** The hosts the network snapshot loader reads repositories from. */
|
|
82
|
+
export const GITHUB_REPOSITORY_HOSTS: readonly string[] = ['github.com', 'www.github.com']
|
|
83
|
+
|
|
84
|
+
const GITHUB_CREDENTIAL_HOSTS = /(^|\.)(github\.com|githubusercontent\.com)$/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Request headers for one upstream URL.
|
|
88
|
+
*
|
|
89
|
+
* The credential and GitHub's media type are attached only for GitHub hosts.
|
|
90
|
+
* Sending them elsewhere is not merely a needless disclosure: `appwrite.io`
|
|
91
|
+
* answers a page carrying an `Authorization` header with HTTP 500, and a CDN
|
|
92
|
+
* asked for an image with `accept: application/vnd.github+json` can answer with
|
|
93
|
+
* bytes that are not the mark. A build that did this dropped marks it had
|
|
94
|
+
* already curated, and the curation verifier — which used its own anonymous
|
|
95
|
+
* headers — could not see it.
|
|
96
|
+
*/
|
|
97
|
+
export function upstreamRequestHeaders(url: string): Record<string, string> {
|
|
98
|
+
const anonymous = { 'user-agent': UPSTREAM_USER_AGENT }
|
|
99
|
+
let host: string
|
|
100
|
+
try {
|
|
101
|
+
host = new URL(url).hostname.toLowerCase()
|
|
102
|
+
} catch {
|
|
103
|
+
return anonymous
|
|
104
|
+
}
|
|
105
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
|
|
106
|
+
if (!token || !GITHUB_CREDENTIAL_HOSTS.test(host)) return anonymous
|
|
107
|
+
return {
|
|
108
|
+
accept: 'application/vnd.github+json',
|
|
109
|
+
authorization: `Bearer ${token}`,
|
|
110
|
+
'user-agent': UPSTREAM_USER_AGENT,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fetches vendor-site documents exactly as a live build does.
|
|
116
|
+
*
|
|
117
|
+
* Shared with the curation verifier so both ask a vendor the same question: a
|
|
118
|
+
* verifier with its own headers reports sites healthy that the build then fails
|
|
119
|
+
* to resolve.
|
|
120
|
+
*
|
|
121
|
+
* `deadline` optionally bounds one product's whole resolution. The per-request
|
|
122
|
+
* ceiling is always created fresh inside each fetch, so a caller that passes a
|
|
123
|
+
* deadline — or shares one — can never hand later requests an already-expired
|
|
124
|
+
* signal.
|
|
125
|
+
*/
|
|
126
|
+
export function createSiteIconFetcher(deadline?: AbortSignal): SiteIconFetcher {
|
|
127
|
+
const bounded = (): AbortSignal => {
|
|
128
|
+
const ceiling = AbortSignal.timeout(SITE_FETCH_TIMEOUT_MS)
|
|
129
|
+
return deadline ? AbortSignal.any([deadline, ceiling]) : ceiling
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
fetchText: async (url) => new TextDecoder().decode(await fetchUpstreamBytes(url, bounded())),
|
|
133
|
+
fetchBytes: (url) => fetchUpstreamBytes(url, bounded()),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const SITE_FETCH_TIMEOUT_MS = 15_000
|
|
138
|
+
|
|
139
|
+
const UPSTREAM_FETCH_MAX_ATTEMPTS = 3
|
|
140
|
+
const UPSTREAM_FETCH_BASE_DELAY_MS = 1000
|
|
141
|
+
const UPSTREAM_FETCH_MAX_DELAY_MS = 30000
|
|
142
|
+
|
|
143
|
+
function upstreamRetryDelayMs(attempt: number, response?: Response): number {
|
|
144
|
+
const retryAfter = response?.headers.get('retry-after')
|
|
145
|
+
if (retryAfter !== null && retryAfter !== undefined) {
|
|
146
|
+
const seconds = Number(retryAfter)
|
|
147
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
148
|
+
return Math.min(seconds * 1000, UPSTREAM_FETCH_MAX_DELAY_MS)
|
|
149
|
+
const date = Date.parse(retryAfter)
|
|
150
|
+
if (!Number.isNaN(date))
|
|
151
|
+
return Math.min(Math.max(date - Date.now(), 0), UPSTREAM_FETCH_MAX_DELAY_MS)
|
|
152
|
+
}
|
|
153
|
+
return Math.min(UPSTREAM_FETCH_BASE_DELAY_MS * 2 ** attempt, UPSTREAM_FETCH_MAX_DELAY_MS)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isRateLimitedUpstream(response: Response): boolean {
|
|
157
|
+
return (
|
|
158
|
+
response.headers.get('x-ratelimit-remaining') === '0' ||
|
|
159
|
+
response.headers.get('retry-after') !== null
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isRetryableUpstreamStatus(status: number, response?: Response): boolean {
|
|
164
|
+
if (status === 429) return true
|
|
165
|
+
if (status >= 500) return true
|
|
166
|
+
if (status === 403 && response !== undefined && isRateLimitedUpstream(response)) return true
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function fetchUpstream(url: string, init: RequestInit): Promise<Response> {
|
|
171
|
+
let attempt = 0
|
|
172
|
+
for (;;) {
|
|
173
|
+
let response: Response
|
|
174
|
+
try {
|
|
175
|
+
response = await fetch(url, init)
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (init.signal?.aborted || attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1) throw error
|
|
178
|
+
await sleep(upstreamRetryDelayMs(attempt), undefined, { signal: init.signal ?? undefined })
|
|
179
|
+
attempt += 1
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (
|
|
183
|
+
response.ok ||
|
|
184
|
+
!isRetryableUpstreamStatus(response.status, response) ||
|
|
185
|
+
attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1
|
|
186
|
+
)
|
|
187
|
+
return response
|
|
188
|
+
await response.body?.cancel().catch(() => {})
|
|
189
|
+
await sleep(upstreamRetryDelayMs(attempt, response), undefined, {
|
|
190
|
+
signal: init.signal ?? undefined,
|
|
191
|
+
})
|
|
192
|
+
attempt += 1
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import type { GeneratedArtifacts } from './artifacts.js'
|
|
2
|
+
import {
|
|
3
|
+
parseIntegrityManifest,
|
|
4
|
+
publishedAssetOf,
|
|
5
|
+
type PublishedAsset,
|
|
6
|
+
} from './integrity-manifest.js'
|
|
7
|
+
import {
|
|
8
|
+
CATALOG_INDEX_ARTIFACT,
|
|
9
|
+
iconCoverage,
|
|
10
|
+
type ConsumerCategory,
|
|
11
|
+
type ConsumerIndex,
|
|
12
|
+
type ConsumerProduct,
|
|
13
|
+
type LeadingDiagnostic,
|
|
14
|
+
} from './consumer-index.js'
|
|
15
|
+
import { digest } from './digest.js'
|
|
16
|
+
import { assertRepositoryAllowed, DEFAULT_POLICY, type CatalogPolicy } from './policy.js'
|
|
17
|
+
import { CatalogSchema, SourcesLockSchema, type Catalog } from './schema.js'
|
|
18
|
+
import { safeRelativePath } from './sources.js'
|
|
19
|
+
|
|
20
|
+
export interface ConsumerIndexVerification {
|
|
21
|
+
readonly hasConsumerIndex: boolean
|
|
22
|
+
readonly diagnostics: readonly LeadingDiagnostic[]
|
|
23
|
+
readonly counts?: ConsumerIndex['counts']
|
|
24
|
+
readonly topCount?: number
|
|
25
|
+
readonly products?: Readonly<Record<string, ConsumerProduct>>
|
|
26
|
+
readonly categories?: readonly ConsumerCategory[]
|
|
27
|
+
/** Shards the published integrity file set must carry for this index. */
|
|
28
|
+
readonly shards: readonly ShardArtifact[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ShardArtifact {
|
|
32
|
+
readonly name: string
|
|
33
|
+
readonly kind: 'index' | 'shelf' | 'list'
|
|
34
|
+
readonly category?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function verifyArtifacts(artifacts: GeneratedArtifacts): void {
|
|
38
|
+
const parsedCatalog = CatalogSchema.parse(JSON.parse(artifacts['catalog.v1.json']))
|
|
39
|
+
verifyCatalogReferences(parsedCatalog)
|
|
40
|
+
const lock = SourcesLockSchema.parse(JSON.parse(artifacts['sources.lock.json']))
|
|
41
|
+
const summary = JSON.parse(artifacts['catalog-summary.v1.json']) as {
|
|
42
|
+
schemaVersion?: unknown
|
|
43
|
+
catalogId?: unknown
|
|
44
|
+
sourceCount?: unknown
|
|
45
|
+
pluginCount?: unknown
|
|
46
|
+
search?: unknown
|
|
47
|
+
}
|
|
48
|
+
const compatibility = JSON.parse(artifacts['compatibility.v1.json']) as {
|
|
49
|
+
schemaVersion?: unknown
|
|
50
|
+
catalogId?: unknown
|
|
51
|
+
plugins?: unknown
|
|
52
|
+
}
|
|
53
|
+
const categories = JSON.parse(artifacts['categories.v1.json']) as {
|
|
54
|
+
schemaVersion?: unknown
|
|
55
|
+
catalogId?: unknown
|
|
56
|
+
topCount?: unknown
|
|
57
|
+
counts?: { plugins?: unknown; products?: unknown; redundantPlugins?: unknown }
|
|
58
|
+
categories?: unknown
|
|
59
|
+
products?: unknown
|
|
60
|
+
diagnostics?: unknown
|
|
61
|
+
}
|
|
62
|
+
const integrity = parseIntegrityManifest(JSON.parse(artifacts['integrity.json']))
|
|
63
|
+
if (parsedCatalog.sources.length !== lock.sources.length)
|
|
64
|
+
throw new Error('INTEGRITY_SOURCE_COUNT_MISMATCH')
|
|
65
|
+
if (
|
|
66
|
+
summary.schemaVersion !== 1 ||
|
|
67
|
+
summary.catalogId !== parsedCatalog.catalogId ||
|
|
68
|
+
summary.sourceCount !== parsedCatalog.sources.length ||
|
|
69
|
+
summary.pluginCount !== parsedCatalog.plugins.length ||
|
|
70
|
+
!Array.isArray(summary.search) ||
|
|
71
|
+
compatibility.schemaVersion !== 1 ||
|
|
72
|
+
compatibility.catalogId !== parsedCatalog.catalogId ||
|
|
73
|
+
!Array.isArray(compatibility.plugins) ||
|
|
74
|
+
compatibility.plugins.length !== parsedCatalog.plugins.length ||
|
|
75
|
+
categories.schemaVersion !== 1 ||
|
|
76
|
+
categories.catalogId !== parsedCatalog.catalogId ||
|
|
77
|
+
!Array.isArray(categories.categories) ||
|
|
78
|
+
(categories.products !== undefined &&
|
|
79
|
+
(typeof categories.products !== 'object' || categories.products === null)) ||
|
|
80
|
+
(categories.diagnostics !== undefined && !Array.isArray(categories.diagnostics)) ||
|
|
81
|
+
integrity.catalogId !== parsedCatalog.catalogId
|
|
82
|
+
) {
|
|
83
|
+
throw new Error('INTEGRITY_ARTIFACT_METADATA_MISMATCH')
|
|
84
|
+
}
|
|
85
|
+
// The published file set is exactly what integrity.json enumerates, so the
|
|
86
|
+
// shards can grow with the category configuration without a fixed list here.
|
|
87
|
+
for (const name of Object.keys(artifacts))
|
|
88
|
+
if (name !== 'integrity.json' && integrity.files[name] === undefined)
|
|
89
|
+
throw new Error(`INTEGRITY_FILE_SET_MISMATCH: ${name}`)
|
|
90
|
+
for (const [name, expected] of Object.entries(integrity.files)) {
|
|
91
|
+
const content = artifacts[name]
|
|
92
|
+
if (content === undefined) throw new Error(`INTEGRITY_FILE_SET_MISMATCH: ${name}`)
|
|
93
|
+
if (digest(content) !== expected) throw new Error(`INTEGRITY_DIGEST_MISMATCH: ${name}`)
|
|
94
|
+
}
|
|
95
|
+
const indexArtifact = artifacts[CATALOG_INDEX_ARTIFACT]
|
|
96
|
+
const verification = verifyConsumerIndex(
|
|
97
|
+
artifacts['categories.v1.json'],
|
|
98
|
+
parsedCatalog,
|
|
99
|
+
indexArtifact
|
|
100
|
+
)
|
|
101
|
+
if (verification.hasConsumerIndex && indexArtifact === undefined)
|
|
102
|
+
throw new Error(`SHARD_MISSING: ${CATALOG_INDEX_ARTIFACT}`)
|
|
103
|
+
for (const shard of verification.shards)
|
|
104
|
+
verifyShardArtifact(shard, artifacts[shard.name], verification, parsedCatalog.catalogId)
|
|
105
|
+
verifyIconAssets(integrity.assets ?? [], verification)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Re-checks one consumer shard against the navigation index: a shelf carries
|
|
110
|
+
* exactly the curated products in order, and a category list carries exactly
|
|
111
|
+
* the category's products in order. The expected catalog ID must come from a
|
|
112
|
+
* separately verified catalog, not from the shard being checked.
|
|
113
|
+
*/
|
|
114
|
+
export function verifyShardArtifact(
|
|
115
|
+
shard: ShardArtifact,
|
|
116
|
+
content: string | undefined,
|
|
117
|
+
verification: ConsumerIndexVerification,
|
|
118
|
+
catalogId: string
|
|
119
|
+
): void {
|
|
120
|
+
if (!CatalogSchema.shape.catalogId.safeParse(catalogId).success)
|
|
121
|
+
throw new Error('SHARD_CATALOG_ID_REQUIRED')
|
|
122
|
+
if (content === undefined) throw new Error(`SHARD_MISSING: ${shard.name}`)
|
|
123
|
+
const parsed = JSON.parse(content) as {
|
|
124
|
+
schemaVersion?: unknown
|
|
125
|
+
catalogId?: unknown
|
|
126
|
+
category?: unknown
|
|
127
|
+
topCount?: unknown
|
|
128
|
+
products?: unknown
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
parsed.schemaVersion !== 1 ||
|
|
132
|
+
parsed.catalogId !== catalogId ||
|
|
133
|
+
(shard.kind !== 'index' && parsed.category !== shard.category)
|
|
134
|
+
)
|
|
135
|
+
throw new Error(`SHARD_IDENTITY_MISMATCH: ${shard.name}`)
|
|
136
|
+
const products: readonly ConsumerProduct[] =
|
|
137
|
+
shard.kind === 'index'
|
|
138
|
+
? Object.values((parsed.products ?? {}) as Record<string, ConsumerProduct>)
|
|
139
|
+
: (parsed.products as readonly ConsumerProduct[])
|
|
140
|
+
if (!Array.isArray(products)) throw new Error(`SHARD_PRODUCTS_INVALID: ${shard.name}`)
|
|
141
|
+
const keys = products.map((product) => product.productKey)
|
|
142
|
+
if (new Set(keys).size !== keys.length) throw new Error(`SHARD_DUPLICATE_PRODUCT: ${shard.name}`)
|
|
143
|
+
if (shard.kind === 'index') {
|
|
144
|
+
const expected = Object.keys(verification.products ?? {}).toSorted()
|
|
145
|
+
if (keys.toSorted().join('\n') !== expected.join('\n'))
|
|
146
|
+
throw new Error('SHARD_INDEX_MEMBERSHIP_MISMATCH')
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const category = verification.categories?.find((item) => item.category === shard.category)
|
|
150
|
+
if (!category) throw new Error(`SHARD_CATEGORY_UNKNOWN: ${shard.name}`)
|
|
151
|
+
const expected = shard.kind === 'shelf' ? category.topProductKeys : category.productKeys
|
|
152
|
+
if (keys.join('\n') !== expected.join('\n'))
|
|
153
|
+
throw new Error(`SHARD_ORDER_MISMATCH: ${shard.name}`)
|
|
154
|
+
for (const product of products) {
|
|
155
|
+
const index = verification.products?.[product.productKey]
|
|
156
|
+
if (!index || digest(index) !== digest(product))
|
|
157
|
+
throw new Error(`SHARD_PRODUCT_MISMATCH: ${shard.name}: ${product.productKey}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function verifyIconAssets(
|
|
162
|
+
declared: readonly PublishedAsset[],
|
|
163
|
+
verification: ConsumerIndexVerification
|
|
164
|
+
): void {
|
|
165
|
+
const referenced = new Map<string, PublishedAsset>()
|
|
166
|
+
for (const product of Object.values(verification.products ?? {})) {
|
|
167
|
+
if (!product.icon) continue
|
|
168
|
+
const existing = referenced.get(product.icon.asset)
|
|
169
|
+
if (existing && existing.digest !== product.icon.digest)
|
|
170
|
+
throw new Error(`ICON_ASSET_DIGEST_CONFLICT: ${product.icon.asset}`)
|
|
171
|
+
referenced.set(product.icon.asset, publishedAssetOf(product.icon))
|
|
172
|
+
}
|
|
173
|
+
if (declared.length !== referenced.size) throw new Error('INTEGRITY_ASSET_SET_MISMATCH')
|
|
174
|
+
for (const asset of declared) {
|
|
175
|
+
const expected = referenced.get(asset.name)
|
|
176
|
+
if (!expected) throw new Error(`INTEGRITY_ASSET_UNDECLARED: ${asset.name}`)
|
|
177
|
+
if (
|
|
178
|
+
expected.digest !== asset.digest ||
|
|
179
|
+
expected.bytes !== asset.bytes ||
|
|
180
|
+
expected.contentType !== asset.contentType ||
|
|
181
|
+
expected.kind !== asset.kind ||
|
|
182
|
+
expected.origin !== asset.origin
|
|
183
|
+
)
|
|
184
|
+
throw new Error(`INTEGRITY_ASSET_MISMATCH: ${asset.name}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Re-checks the published consumer index against the catalog it indexes. The
|
|
190
|
+
* index is what Adea renders directly, so a contradiction between the two
|
|
191
|
+
* artifacts must never reach a release. Snapshots published before the index
|
|
192
|
+
* existed carry membership only; those are still membership-checked and
|
|
193
|
+
* reported as `hasConsumerIndex: false`.
|
|
194
|
+
*/
|
|
195
|
+
export function verifyConsumerIndex(
|
|
196
|
+
content: string,
|
|
197
|
+
catalog: Pick<Catalog, 'plugins'>,
|
|
198
|
+
indexContent?: string
|
|
199
|
+
): ConsumerIndexVerification {
|
|
200
|
+
const index = JSON.parse(content) as Partial<ConsumerIndex>
|
|
201
|
+
const knownPlugins = new Map(catalog.plugins.map((plugin) => [plugin.pluginId, plugin]))
|
|
202
|
+
// Products live in the index shard; the navigation artifact only points at it.
|
|
203
|
+
const shard = indexContent ? (JSON.parse(indexContent) as Partial<ConsumerIndex>) : undefined
|
|
204
|
+
const products =
|
|
205
|
+
shard && typeof shard.products === 'object' && shard.products !== null
|
|
206
|
+
? shard.products
|
|
207
|
+
: typeof index.products === 'object' && index.products !== null
|
|
208
|
+
? index.products
|
|
209
|
+
: undefined
|
|
210
|
+
const diagnostics = Array.isArray(index.diagnostics) ? index.diagnostics : []
|
|
211
|
+
for (const category of index.categories ?? []) {
|
|
212
|
+
const members = catalog.plugins.filter((plugin) =>
|
|
213
|
+
plugin.categories.includes(category.category)
|
|
214
|
+
)
|
|
215
|
+
if (category.pluginCount !== undefined && category.pluginCount !== members.length)
|
|
216
|
+
throw new Error(`CONSUMER_INDEX_PLUGIN_COUNT_MISMATCH: ${category.category}`)
|
|
217
|
+
if (category.pluginIds.length !== members.length)
|
|
218
|
+
throw new Error(`CONSUMER_INDEX_PLUGIN_LIST_MISMATCH: ${category.category}`)
|
|
219
|
+
if (new Set(category.pluginIds).size !== category.pluginIds.length)
|
|
220
|
+
throw new Error(`CONSUMER_INDEX_DUPLICATE_PLUGIN: ${category.category}`)
|
|
221
|
+
for (const pluginId of category.pluginIds) {
|
|
222
|
+
const plugin = knownPlugins.get(pluginId)
|
|
223
|
+
if (!plugin || !plugin.categories.includes(category.category))
|
|
224
|
+
throw new Error(`CONSUMER_INDEX_FOREIGN_PLUGIN: ${category.category}: ${pluginId}`)
|
|
225
|
+
}
|
|
226
|
+
if (!products) continue
|
|
227
|
+
if (category.productKeys === undefined || category.topProductKeys === undefined)
|
|
228
|
+
throw new Error(`CONSUMER_INDEX_PRODUCTS_INCOMPLETE: ${category.category}`)
|
|
229
|
+
if (new Set(category.productKeys).size !== category.productKeys.length)
|
|
230
|
+
throw new Error(`CONSUMER_INDEX_DUPLICATE_PRODUCT: ${category.category}`)
|
|
231
|
+
for (const productKey of category.productKeys) {
|
|
232
|
+
const product = products[productKey]
|
|
233
|
+
if (!product) throw new Error(`CONSUMER_INDEX_UNKNOWN_PRODUCT: ${productKey}`)
|
|
234
|
+
if (!product.categories.includes(category.category))
|
|
235
|
+
throw new Error(`CONSUMER_INDEX_PRODUCT_CATEGORY_MISMATCH: ${productKey}`)
|
|
236
|
+
}
|
|
237
|
+
if (category.productCount !== category.productKeys.length)
|
|
238
|
+
throw new Error(`CONSUMER_INDEX_PRODUCT_COUNT_MISMATCH: ${category.category}`)
|
|
239
|
+
const topCount = index.topCount
|
|
240
|
+
if (topCount === undefined || !Number.isInteger(topCount) || topCount < 0)
|
|
241
|
+
throw new Error('CONSUMER_INDEX_TOP_COUNT_INVALID')
|
|
242
|
+
if (category.topProductKeys.length > topCount)
|
|
243
|
+
throw new Error(`CONSUMER_INDEX_TOP_OVERFLOW: ${category.category}`)
|
|
244
|
+
const shelved = new Set<string>()
|
|
245
|
+
for (const product of Object.values(products)) {
|
|
246
|
+
// Indexes published before the install facts existed carry no `release`,
|
|
247
|
+
// and are still valid; a record that does carry one must agree with the
|
|
248
|
+
// catalog it claims to describe.
|
|
249
|
+
if (product.release) {
|
|
250
|
+
const plugin = knownPlugins.get(product.pluginId)
|
|
251
|
+
const release = plugin?.availableReleases.find(
|
|
252
|
+
(candidate) => candidate.releaseId === plugin.currentReleaseId
|
|
253
|
+
)
|
|
254
|
+
if (
|
|
255
|
+
!plugin ||
|
|
256
|
+
!release ||
|
|
257
|
+
product.release.releaseId !== release.releaseId ||
|
|
258
|
+
product.release.canonicalContentDigest !== release.canonicalContentDigest ||
|
|
259
|
+
product.release.sourceRevision !== release.resolvedCommitSha ||
|
|
260
|
+
product.provenance?.resolvedCommitSha !== release.resolvedCommitSha
|
|
261
|
+
)
|
|
262
|
+
throw new Error(`CONSUMER_INDEX_RELEASE_MISMATCH: ${product.productKey}`)
|
|
263
|
+
}
|
|
264
|
+
const icon = product.icon
|
|
265
|
+
if (!icon?.assetUrl) continue
|
|
266
|
+
if (!icon.assetUrl.startsWith('https://') || !icon.assetUrl.endsWith(`/${icon.asset}`))
|
|
267
|
+
throw new Error(`CONSUMER_INDEX_ASSET_URL_INVALID: ${icon.asset}`)
|
|
268
|
+
}
|
|
269
|
+
for (const productKey of category.topProductKeys) {
|
|
270
|
+
if (!category.productKeys.includes(productKey))
|
|
271
|
+
throw new Error(`CONSUMER_INDEX_TOP_NOT_MEMBER: ${category.category}: ${productKey}`)
|
|
272
|
+
if (shelved.has(productKey)) throw new Error(`CONSUMER_INDEX_TOP_REPEATED: ${productKey}`)
|
|
273
|
+
shelved.add(productKey)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (products) {
|
|
277
|
+
// A product headlines one category only, so no product is shelved twice.
|
|
278
|
+
const claimed = new Set<string>()
|
|
279
|
+
for (const category of index.categories ?? [])
|
|
280
|
+
for (const productKey of category.topProductKeys ?? []) {
|
|
281
|
+
if (claimed.has(productKey))
|
|
282
|
+
throw new Error(`CONSUMER_INDEX_TOP_CROSS_CATEGORY: ${productKey}`)
|
|
283
|
+
claimed.add(productKey)
|
|
284
|
+
}
|
|
285
|
+
for (const [productKey, product] of Object.entries(products)) {
|
|
286
|
+
if (productKey !== product.productKey) throw new Error('CONSUMER_INDEX_PRODUCT_KEY_MISMATCH')
|
|
287
|
+
if (product.variantPluginIds[0] !== product.pluginId)
|
|
288
|
+
throw new Error(`CONSUMER_INDEX_CANONICAL_NOT_FIRST: ${productKey}`)
|
|
289
|
+
const variants = new Set(
|
|
290
|
+
product.variantPluginIds.map((pluginId) => {
|
|
291
|
+
const plugin = knownPlugins.get(pluginId)
|
|
292
|
+
if (!plugin) throw new Error(`CONSUMER_INDEX_UNKNOWN_PLUGIN: ${pluginId}`)
|
|
293
|
+
if (plugin.productGroupingKey !== productKey)
|
|
294
|
+
throw new Error(`CONSUMER_INDEX_VARIANT_PRODUCT_MISMATCH: ${pluginId}`)
|
|
295
|
+
return pluginId
|
|
296
|
+
})
|
|
297
|
+
)
|
|
298
|
+
if (variants.size !== product.variantPluginIds.length)
|
|
299
|
+
throw new Error(`CONSUMER_INDEX_DUPLICATE_VARIANT: ${productKey}`)
|
|
300
|
+
}
|
|
301
|
+
const counts = shard?.counts ?? index.counts
|
|
302
|
+
if (counts?.plugins !== catalog.plugins.length) throw new Error('CONSUMER_INDEX_TOTAL_MISMATCH')
|
|
303
|
+
if (counts?.products !== Object.keys(products).length)
|
|
304
|
+
throw new Error('CONSUMER_INDEX_PRODUCT_TOTAL_MISMATCH')
|
|
305
|
+
if (counts?.redundantPlugins !== catalog.plugins.length - counts.products)
|
|
306
|
+
throw new Error('CONSUMER_INDEX_REDUNDANCY_MISMATCH')
|
|
307
|
+
if (index.counts && shard?.counts && digest(index.counts) !== digest(shard.counts))
|
|
308
|
+
throw new Error('CONSUMER_INDEX_COUNTS_MISMATCH')
|
|
309
|
+
if (shard?.topCount !== undefined && index.topCount !== shard.topCount)
|
|
310
|
+
throw new Error('CONSUMER_INDEX_TOP_COUNT_MISMATCH')
|
|
311
|
+
}
|
|
312
|
+
if (products && index.iconCoverage) {
|
|
313
|
+
const expected = iconCoverage({ products })
|
|
314
|
+
if (
|
|
315
|
+
index.iconCoverage.total !== expected.total ||
|
|
316
|
+
index.iconCoverage.content !== expected.content ||
|
|
317
|
+
index.iconCoverage.favicon !== expected.favicon ||
|
|
318
|
+
index.iconCoverage.monogramOnly !== expected.monogramOnly ||
|
|
319
|
+
index.iconCoverage.monogramProductKeys.join('\n') !== expected.monogramProductKeys.join('\n')
|
|
320
|
+
)
|
|
321
|
+
throw new Error('CONSUMER_INDEX_ICON_COVERAGE_MISMATCH')
|
|
322
|
+
}
|
|
323
|
+
const shards: ShardArtifact[] = [
|
|
324
|
+
...(products ? [{ name: CATALOG_INDEX_ARTIFACT, kind: 'index' as const }] : []),
|
|
325
|
+
...(index.categories ?? []).flatMap((category) =>
|
|
326
|
+
products && category.shelfArtifact && category.listArtifact
|
|
327
|
+
? [
|
|
328
|
+
{ name: category.shelfArtifact, kind: 'shelf' as const, category: category.category },
|
|
329
|
+
{ name: category.listArtifact, kind: 'list' as const, category: category.category },
|
|
330
|
+
]
|
|
331
|
+
: []
|
|
332
|
+
),
|
|
333
|
+
]
|
|
334
|
+
return {
|
|
335
|
+
hasConsumerIndex: products !== undefined,
|
|
336
|
+
diagnostics,
|
|
337
|
+
...(products ? { counts: index.counts as ConsumerIndex['counts'] } : {}),
|
|
338
|
+
...(products && index.topCount !== undefined ? { topCount: index.topCount } : {}),
|
|
339
|
+
...(products ? { products, categories: index.categories ?? [] } : {}),
|
|
340
|
+
shards: products ? shards : [],
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function verifyCatalogReferences(
|
|
345
|
+
catalog: Catalog,
|
|
346
|
+
policy: CatalogPolicy = DEFAULT_POLICY
|
|
347
|
+
): void {
|
|
348
|
+
for (const source of catalog.sources) {
|
|
349
|
+
assertRepositoryAllowed(source.repositoryUrl, policy)
|
|
350
|
+
validateCatalogPath(source.manifestPath, 'SOURCE_MANIFEST_PATH_INVALID')
|
|
351
|
+
}
|
|
352
|
+
for (const plugin of catalog.plugins) {
|
|
353
|
+
assertRepositoryAllowed(plugin.provenance.repositoryUrl, policy)
|
|
354
|
+
validateCatalogPath(plugin.provenance.pluginSubdirectory, 'PROVENANCE_PATH_INVALID')
|
|
355
|
+
for (const release of plugin.availableReleases) {
|
|
356
|
+
assertRepositoryAllowed(release.resolvedRepositoryUrl, policy)
|
|
357
|
+
validateCatalogPath(release.pluginSubdirectory, 'RELEASE_PATH_INVALID')
|
|
358
|
+
for (const path of release.fileIndex) validateCatalogPath(path, 'RELEASE_FILE_PATH_INVALID')
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function validateCatalogPath(path: string, code: string): void {
|
|
364
|
+
if (path !== '.') safeRelativePath(path, code)
|
|
365
|
+
}
|