@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.
Files changed (147) hide show
  1. package/dist/agent-plugins-catalog.d.ts +2 -4
  2. package/dist/agent-plugins-catalog.js +42 -50
  3. package/dist/agent-plugins-catalog.js.map +1 -1
  4. package/dist/agent-plugins-schema.d.ts +8 -0
  5. package/dist/agent-plugins-schema.js +10 -1
  6. package/dist/agent-plugins-schema.js.map +1 -1
  7. package/dist/artifacts.d.ts +57 -0
  8. package/dist/artifacts.js +159 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/build.d.ts +20 -0
  11. package/dist/build.js +103 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/capabilities.d.ts +7 -0
  14. package/dist/capabilities.js +116 -0
  15. package/dist/capabilities.js.map +1 -0
  16. package/dist/catalog-store.d.ts +76 -0
  17. package/dist/catalog-store.js +463 -0
  18. package/dist/catalog-store.js.map +1 -0
  19. package/dist/concurrency.d.ts +1 -0
  20. package/dist/concurrency.js +15 -0
  21. package/dist/concurrency.js.map +1 -0
  22. package/dist/consumer-index.d.ts +7 -29
  23. package/dist/consumer-index.js +24 -15
  24. package/dist/consumer-index.js.map +1 -1
  25. package/dist/digest.d.ts +13 -0
  26. package/dist/digest.js +47 -0
  27. package/dist/digest.js.map +1 -0
  28. package/dist/fs-atomic.d.ts +7 -0
  29. package/dist/fs-atomic.js +30 -0
  30. package/dist/fs-atomic.js.map +1 -0
  31. package/dist/harness.js +2 -1
  32. package/dist/harness.js.map +1 -1
  33. package/dist/icon-mirror.d.ts +5 -4
  34. package/dist/icon-mirror.js +73 -72
  35. package/dist/icon-mirror.js.map +1 -1
  36. package/dist/icons.d.ts +2 -2
  37. package/dist/icons.js +4 -12
  38. package/dist/icons.js.map +1 -1
  39. package/dist/index.d.ts +16 -309
  40. package/dist/index.js +12 -1900
  41. package/dist/index.js.map +1 -1
  42. package/dist/integrity-manifest.d.ts +43 -13
  43. package/dist/integrity-manifest.js +28 -17
  44. package/dist/integrity-manifest.js.map +1 -1
  45. package/dist/json-values.d.ts +3 -0
  46. package/dist/json-values.js +10 -0
  47. package/dist/json-values.js.map +1 -0
  48. package/dist/plugin-release.d.ts +38 -0
  49. package/dist/plugin-release.js +321 -0
  50. package/dist/plugin-release.js.map +1 -0
  51. package/dist/plugin-skips.d.ts +19 -0
  52. package/dist/plugin-skips.js +65 -0
  53. package/dist/plugin-skips.js.map +1 -0
  54. package/dist/policy.d.ts +13 -0
  55. package/dist/policy.js +26 -0
  56. package/dist/policy.js.map +1 -0
  57. package/dist/portable-catalog.d.ts +4 -4
  58. package/dist/portable-catalog.js +14 -126
  59. package/dist/portable-catalog.js.map +1 -1
  60. package/dist/portable-verify.d.ts +2 -0
  61. package/dist/portable-verify.js +68 -0
  62. package/dist/portable-verify.js.map +1 -0
  63. package/dist/publication.d.ts +12 -12
  64. package/dist/publication.js +31 -21
  65. package/dist/publication.js.map +1 -1
  66. package/dist/published-output.d.ts +17 -0
  67. package/dist/published-output.js +49 -0
  68. package/dist/published-output.js.map +1 -0
  69. package/dist/site-icons.d.ts +4 -3
  70. package/dist/site-icons.js +38 -11
  71. package/dist/site-icons.js.map +1 -1
  72. package/dist/snapshots.d.ts +39 -0
  73. package/dist/snapshots.js +238 -0
  74. package/dist/snapshots.js.map +1 -0
  75. package/dist/source-resolution.d.ts +28 -0
  76. package/dist/source-resolution.js +158 -0
  77. package/dist/source-resolution.js.map +1 -0
  78. package/dist/sources.d.ts +5 -1
  79. package/dist/sources.js +21 -27
  80. package/dist/sources.js.map +1 -1
  81. package/dist/synchronize.d.ts +44 -0
  82. package/dist/synchronize.js +118 -0
  83. package/dist/synchronize.js.map +1 -0
  84. package/dist/taxonomy.d.ts +23 -0
  85. package/dist/taxonomy.js +62 -0
  86. package/dist/taxonomy.js.map +1 -0
  87. package/dist/upstream.d.ts +42 -0
  88. package/dist/upstream.js +179 -0
  89. package/dist/upstream.js.map +1 -0
  90. package/dist/verify-artifacts.d.ts +36 -0
  91. package/dist/verify-artifacts.js +286 -0
  92. package/dist/verify-artifacts.js.map +1 -0
  93. package/native/build-provenance.txt +5 -5
  94. package/native/staged-reader-win32-arm64.node +0 -0
  95. package/native/staged-reader-win32-x64.node +0 -0
  96. package/package.json +3 -3
  97. package/src/agent-plugins-catalog.test.ts +2 -2
  98. package/src/agent-plugins-catalog.ts +46 -49
  99. package/src/agent-plugins-schema.ts +11 -1
  100. package/src/artifacts.test.ts +80 -0
  101. package/src/artifacts.ts +234 -0
  102. package/src/build.ts +131 -0
  103. package/src/capabilities.ts +153 -0
  104. package/src/catalog-store.ts +519 -0
  105. package/src/concurrency.ts +19 -0
  106. package/src/consumer-index.test.ts +147 -16
  107. package/src/consumer-index.ts +35 -43
  108. package/src/default-head.test.ts +143 -0
  109. package/src/digest.ts +55 -0
  110. package/src/fs-atomic.ts +32 -0
  111. package/src/harness.test.ts +4 -32
  112. package/src/harness.ts +2 -1
  113. package/src/icon-mirror.ts +78 -72
  114. package/src/icons.test.ts +0 -4
  115. package/src/icons.ts +4 -4
  116. package/src/index.test.ts +172 -46
  117. package/src/index.ts +58 -2562
  118. package/src/integrity-manifest.ts +41 -21
  119. package/src/json-values.ts +11 -0
  120. package/src/manifest-retry.test.ts +49 -0
  121. package/src/plugin-release.ts +457 -0
  122. package/src/plugin-skips.ts +110 -0
  123. package/src/policy.ts +39 -0
  124. package/src/portable-catalog.test.ts +0 -5
  125. package/src/portable-catalog.ts +16 -143
  126. package/src/portable-verify.ts +71 -0
  127. package/src/publication.test.ts +29 -1
  128. package/src/publication.ts +38 -22
  129. package/src/published-output.ts +60 -0
  130. package/src/site-icons.test.ts +73 -1
  131. package/src/site-icons.ts +31 -14
  132. package/src/snapshots.ts +287 -0
  133. package/src/source-config-sync.test.ts +84 -0
  134. package/src/source-digest.test.ts +61 -8
  135. package/src/source-resolution.ts +233 -0
  136. package/src/sources-json.test.ts +18 -2
  137. package/src/sources.ts +27 -33
  138. package/src/synchronize.ts +213 -0
  139. package/src/taxonomy.ts +87 -0
  140. package/src/upstream.ts +194 -0
  141. package/src/verify-artifacts.ts +365 -0
  142. package/src/write-artifacts.test.ts +374 -0
  143. package/dist/source-digest.d.ts +0 -2
  144. package/dist/source-digest.js +0 -12
  145. package/dist/source-digest.js.map +0 -1
  146. package/native/README.md +0 -24
  147. package/src/source-digest.ts +0 -14
@@ -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
+ }