@adea-ai/plugins 4.0.1 → 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 (142) 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 +0 -5
  17. package/dist/catalog-store.js +1 -25
  18. package/dist/catalog-store.js.map +1 -1
  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 +6 -28
  23. package/dist/consumer-index.js +7 -8
  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 +3 -2
  34. package/dist/icon-mirror.js +51 -63
  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 -315
  40. package/dist/index.js +12 -1905
  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 +6 -3
  64. package/dist/publication.js +25 -12
  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 +2 -3
  70. package/dist/site-icons.js +4 -3
  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 +2 -4
  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.ts +234 -0
  101. package/src/build.ts +131 -0
  102. package/src/capabilities.ts +153 -0
  103. package/src/catalog-store.ts +1 -28
  104. package/src/concurrency.ts +19 -0
  105. package/src/consumer-index.test.ts +27 -21
  106. package/src/consumer-index.ts +12 -35
  107. package/src/digest.ts +55 -0
  108. package/src/fs-atomic.ts +32 -0
  109. package/src/harness.test.ts +4 -32
  110. package/src/harness.ts +2 -1
  111. package/src/icon-mirror.ts +57 -64
  112. package/src/icons.test.ts +0 -4
  113. package/src/icons.ts +4 -4
  114. package/src/index.test.ts +52 -48
  115. package/src/index.ts +50 -2583
  116. package/src/integrity-manifest.ts +41 -21
  117. package/src/json-values.ts +11 -0
  118. package/src/plugin-release.ts +457 -0
  119. package/src/plugin-skips.ts +110 -0
  120. package/src/policy.ts +39 -0
  121. package/src/portable-catalog.test.ts +0 -5
  122. package/src/portable-catalog.ts +16 -143
  123. package/src/portable-verify.ts +71 -0
  124. package/src/publication.test.ts +29 -1
  125. package/src/publication.ts +32 -13
  126. package/src/published-output.ts +60 -0
  127. package/src/site-icons.test.ts +0 -1
  128. package/src/site-icons.ts +4 -4
  129. package/src/snapshots.ts +287 -0
  130. package/src/source-digest.test.ts +3 -7
  131. package/src/source-resolution.ts +233 -0
  132. package/src/sources-json.test.ts +0 -1
  133. package/src/sources.ts +27 -33
  134. package/src/synchronize.ts +213 -0
  135. package/src/taxonomy.ts +87 -0
  136. package/src/upstream.ts +194 -0
  137. package/src/verify-artifacts.ts +365 -0
  138. package/src/write-artifacts.test.ts +0 -1
  139. package/dist/source-digest.d.ts +0 -2
  140. package/dist/source-digest.js +0 -12
  141. package/dist/source-digest.js.map +0 -1
  142. package/src/source-digest.ts +0 -14
package/src/policy.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { PACKAGE_LIMITS } from './agent-plugins-schema.js'
2
+ import { canonicalRepositoryUrl } from './sources.js'
3
+
4
+ export interface CatalogPolicy {
5
+ readonly allowedRepositoryProtocols: readonly string[]
6
+ readonly allowedRepositoryHosts: readonly string[]
7
+ readonly maxFilesPerPlugin: number
8
+ readonly maxBytesPerPlugin: number
9
+ readonly maxFileBytes: number
10
+ readonly maxMarketplacePlugins: number
11
+ readonly denyExecutableLifecycleScripts: boolean
12
+ readonly publishRequiresCompleteContent: boolean
13
+ readonly sensitiveCapabilityTypes: readonly string[]
14
+ }
15
+
16
+ export const DEFAULT_POLICY: CatalogPolicy = {
17
+ allowedRepositoryProtocols: ['https:'],
18
+ allowedRepositoryHosts: ['github.com', 'www.github.com'],
19
+ ...PACKAGE_LIMITS,
20
+ maxMarketplacePlugins: 4096,
21
+ denyExecutableLifecycleScripts: true,
22
+ publishRequiresCompleteContent: false,
23
+ sensitiveCapabilityTypes: [
24
+ 'mcp-server',
25
+ 'connector',
26
+ 'hook',
27
+ 'browser',
28
+ 'scheduled-task',
29
+ 'executable',
30
+ ],
31
+ }
32
+
33
+ export function assertRepositoryAllowed(repositoryUrl: string, policy: CatalogPolicy): void {
34
+ const parsed = new URL(canonicalRepositoryUrl(repositoryUrl))
35
+ if (!policy.allowedRepositoryProtocols.includes(parsed.protocol))
36
+ throw new Error(`GIT_PROTOCOL_POLICY: ${parsed.protocol}`)
37
+ if (!policy.allowedRepositoryHosts.includes(parsed.hostname.toLowerCase()))
38
+ throw new Error(`GIT_HOST_POLICY: ${parsed.hostname}`)
39
+ }
@@ -17,7 +17,6 @@ import type { HarnessProfile } from './agent-plugins-schema.js'
17
17
  import { snapshotFromDirectory } from './index.js'
18
18
  import {
19
19
  copyDependency,
20
- nodeCandidates,
21
20
  packageLifecycleScripts,
22
21
  selectPinnedNode,
23
22
  withPackedConsumerFixture,
@@ -612,10 +611,6 @@ it('qualifies the compiled artifact only on Node 24', async () => {
612
611
  assert.equal(selectPinnedNode([node20, node24]), node24)
613
612
  assert.throws(() => selectPinnedNode([node20]), /needs Node 24/)
614
613
  assert.throws(() => selectPinnedNode([]), /needs Node 24/)
615
- // The discovery ends with the `node` on PATH, which is where a CI
616
- // runner's pinned setup action puts Node 24.
617
- const candidates = nodeCandidates()
618
- assert.ok(candidates.includes('node'))
619
614
  } finally {
620
615
  await rm(directory, { recursive: true, force: true })
621
616
  }
@@ -1,22 +1,13 @@
1
- import { CatalogSchema, HarnessSchema, type Catalog, type Plugin } from './schema.js'
2
- import {
3
- orderLeadingPlugins,
4
- synchronize,
5
- createArtifacts,
6
- verifyArtifacts,
7
- digest,
8
- type SyncInput,
9
- type SyncResult,
10
- type ReleaseTransform,
11
- } from './index.js'
12
- import {
13
- compileAgentPackage,
14
- verifyAgentPackage,
15
- NORMALIZER_VERSION,
16
- } from './agent-plugins-catalog.js'
17
- import { CATALOG_CONTRACT_VERSION } from './index.js'
1
+ import { compileAgentPackage, verifyAgentPackage } from './agent-plugins-catalog.js'
2
+ import { NORMALIZER_VERSION, PACKAGE_METADATA_KEY } from './agent-plugins-schema.js'
3
+
4
+ import { CATALOG_CONTRACT_VERSION, type ReleaseTransform } from './plugin-release.js'
5
+ import { HarnessSchema, type Catalog, type Plugin } from './schema.js'
6
+ import { verifyPortableCatalog } from './portable-verify.js'
7
+ import { synchronize, type SyncInput, type SyncResult } from './synchronize.js'
8
+
9
+ export { PACKAGE_METADATA_KEY, verifyPortableCatalog }
18
10
 
19
- export const PACKAGE_METADATA_KEY = 'agentPlugins'
20
11
  function currentContract(catalog: Catalog | undefined): boolean {
21
12
  return (
22
13
  !!catalog &&
@@ -96,10 +87,14 @@ export async function synchronizePortable(options: SyncInput): Promise<SyncResul
96
87
  }
97
88
  // Compile while each upstream snapshot is already in scope. Do not retain all
98
89
  // marketplace file bytes or fetch every package a second time after cataloging.
99
- const input = {
90
+ const input: SyncInput = {
100
91
  ...options,
101
92
  metadataOnly: options.metadataOnly === true || options.dryRun === true,
102
93
  transformRelease,
94
+ releaseCompatibility: (release) => {
95
+ const pkg = release.releaseMetadata[PACKAGE_METADATA_KEY]
96
+ return pkg ? compatibility(verifyAgentPackage(pkg)) : undefined
97
+ },
103
98
  }
104
99
  let result = await synchronize(input)
105
100
  if (
@@ -112,128 +107,6 @@ export async function synchronizePortable(options: SyncInput): Promise<SyncResul
112
107
  // need not change for a format migration, and dry runs still never publish.
113
108
  result = await synchronize({ ...input, fromLock: input.existingLock })
114
109
  }
115
- if (!result.catalog || !result.lock || input.metadataOnly) return result
116
- const plugins: Plugin[] = []
117
- for (const plugin of result.catalog.plugins) {
118
- const releases = plugin.availableReleases
119
- const selected = releases.find((release) => release.releaseId === plugin.currentReleaseId)
120
- const pkg = selected?.releaseMetadata[PACKAGE_METADATA_KEY]
121
- plugins.push({
122
- ...plugin,
123
- availableReleases: releases,
124
- ...(pkg ? { harnessCompatibility: compatibility(verifyAgentPackage(pkg)) } : {}),
125
- })
126
- }
127
- const { catalogId: _oldId, ...old } = CatalogSchema.parse(result.catalog)
128
- // Ordering is applied before the catalog ID is computed so the emitted
129
- // identifier digests the final plugin order: each category's leading
130
- // products head the list.
131
- const ordered = orderLeadingPlugins({ ...old, plugins }, options.leading)
132
- const catalog = CatalogSchema.parse({
133
- ...ordered,
134
- catalogId: `catalog:${digest(ordered).slice(7)}`,
135
- })
136
- verifyPortableCatalog(catalog)
137
- const artifacts = createArtifacts(catalog, result.lock, {
138
- ...(options.leading ? { leading: options.leading } : {}),
139
- ...(options.topCount !== undefined ? { topCount: options.topCount } : {}),
140
- ...(options.productPreference ? { productPreference: options.productPreference } : {}),
141
- // Without this the shipped artifacts cannot advertise where they are
142
- // published, so brand marks and the browsing index lose their URLs. This
143
- // path replaces the artifacts the legacy compiler already produced, which is
144
- // why it must repeat every option it needs.
145
- ...(options.publicationRepositoryUrl
146
- ? { publicationRepositoryUrl: options.publicationRepositoryUrl }
147
- : {}),
148
- ...(options.publicationAssetsBaseUrl
149
- ? { publicationAssetsBaseUrl: options.publicationAssetsBaseUrl }
150
- : {}),
151
- ...(options.publicationCatalogsBaseUrl
152
- ? { publicationCatalogsBaseUrl: options.publicationCatalogsBaseUrl }
153
- : {}),
154
- curationDiagnostics: (options.mode ?? 'live') !== 'offline',
155
- })
156
- verifyArtifacts(artifacts)
157
- const changed = catalog.plugins
158
- .filter((plugin) => {
159
- const prior = input.existingCatalog?.plugins.find((item) => item.pluginId === plugin.pluginId)
160
- return prior && digest(prior) !== digest(plugin)
161
- })
162
- .map((plugin) => plugin.pluginId)
163
- return {
164
- ...result,
165
- catalog,
166
- artifacts,
167
- changeReport: {
168
- ...result.changeReport,
169
- changedPlugins: [...new Set([...result.changeReport.changedPlugins, ...changed])].toSorted(),
170
- },
171
- }
172
- }
173
-
174
- /**
175
- * A single-line summary of why a release's package descriptor was refused.
176
- *
177
- * The schema's own failure is a structured issue list; the catalog's refusal
178
- * contract is one typed line. The first issue is kept as the detail, because
179
- * it names the field that was wrong.
180
- */
181
- function describeProvenanceFailure(error: unknown): string {
182
- const issues = (error as { issues?: unknown }).issues
183
- if (Array.isArray(issues) && issues.length > 0) {
184
- const first = issues[0] as { path?: unknown; message?: unknown }
185
- const path = Array.isArray(first.path) ? first.path.join('.') : ''
186
- const message = typeof first.message === 'string' ? first.message : ''
187
- return [path, message].filter(Boolean).join(': ').replace(/\s+/g, ' ').slice(0, 200)
188
- }
189
- return error instanceof Error
190
- ? error.message.replace(/\s+/g, ' ').slice(0, 200)
191
- : 'the descriptor is not a package recipe'
192
- }
193
-
194
- export function verifyPortableCatalog(catalog: Catalog, requirePortable = false): void {
195
- const { catalogId, ...body } = CatalogSchema.parse(catalog)
196
- if (catalogId !== `catalog:${digest(body).slice(7)}`) throw new Error('CATALOG_ID_MISMATCH')
197
- const ids = new Set<string>()
198
- for (const plugin of catalog.plugins) {
199
- if (ids.has(plugin.pluginId)) throw new Error('DUPLICATE_PLUGIN_ID')
200
- ids.add(plugin.pluginId)
201
- const releases = new Set(plugin.availableReleases.map((release) => release.releaseId))
202
- if (releases.size !== plugin.availableReleases.length || !releases.has(plugin.currentReleaseId))
203
- throw new Error('RELEASE_SELECTION_INVALID')
204
- }
205
- for (const plugin of catalog.plugins)
206
- for (const release of plugin.availableReleases) {
207
- if (requirePortable && release.contentResolution !== 'complete')
208
- throw new Error('COMPLETE_SOURCE_REQUIRED')
209
- const descriptor = release.releaseMetadata[PACKAGE_METADATA_KEY]
210
- if (descriptor === undefined) {
211
- if (requirePortable && release.contentResolution === 'complete')
212
- throw new Error(`PORTABLE_RESYNC_REQUIRED: ${plugin.pluginId}`)
213
- continue
214
- }
215
- let pkg: ReturnType<typeof verifyAgentPackage>
216
- try {
217
- pkg = verifyAgentPackage(descriptor)
218
- } catch (error) {
219
- // A consumer imports a catalog it did not compile, so a malformed
220
- // provenance descriptor is an IMPORT failure, and it must be refused
221
- // the way every other failure here is refused: one typed, single-line
222
- // reason that names the plugin. The schema's own multi-line issue
223
- // dump is not a refusal a consumer can match on.
224
- throw new Error(
225
- `PACKAGE_PROVENANCE_INVALID: ${plugin.pluginId} (${describeProvenanceFailure(error)})`,
226
- { cause: error }
227
- )
228
- }
229
- if (
230
- release.contentResolution !== 'complete' ||
231
- pkg.sourceDigest !== release.canonicalContentDigest
232
- )
233
- throw new Error(`PACKAGE_PROVENANCE_MISMATCH: ${plugin.pluginId}`)
234
- const sourcePaths = new Set(release.fileIndex)
235
- for (const file of pkg.files)
236
- if (file.action === 'copy' && !sourcePaths.has(file.sourcePath))
237
- throw new Error(`PACKAGE_SOURCE_FILE_MISSING: ${plugin.pluginId}`)
238
- }
110
+ if (result.catalog && !input.metadataOnly) verifyPortableCatalog(result.catalog)
111
+ return result
239
112
  }
@@ -0,0 +1,71 @@
1
+ import { verifyAgentPackage } from './agent-plugins-catalog.js'
2
+ import { PACKAGE_METADATA_KEY } from './agent-plugins-schema.js'
3
+ import { contentId } from './digest.js'
4
+ import { CatalogSchema, type Catalog } from './schema.js'
5
+
6
+ /**
7
+ * A single-line summary of why a release's package descriptor was refused.
8
+ *
9
+ * The schema's own failure is a structured issue list; the catalog's refusal
10
+ * contract is one typed line. The first issue is kept as the detail, because
11
+ * it names the field that was wrong.
12
+ */
13
+ function describeProvenanceFailure(error: unknown): string {
14
+ const issues = (error as { issues?: unknown }).issues
15
+ if (Array.isArray(issues) && issues.length > 0) {
16
+ const first = issues[0] as { path?: unknown; message?: unknown }
17
+ const path = Array.isArray(first.path) ? first.path.join('.') : ''
18
+ const message = typeof first.message === 'string' ? first.message : ''
19
+ return [path, message].filter(Boolean).join(': ').replace(/\s+/g, ' ').slice(0, 200)
20
+ }
21
+ return error instanceof Error
22
+ ? error.message.replace(/\s+/g, ' ').slice(0, 200)
23
+ : 'the descriptor is not a package recipe'
24
+ }
25
+
26
+ export function verifyPortableCatalog(catalog: Catalog, requirePortable = false): void {
27
+ const { catalogId, ...body } = CatalogSchema.parse(catalog)
28
+ if (catalogId !== contentId('catalog', body)) throw new Error('CATALOG_ID_MISMATCH')
29
+ const ids = new Set<string>()
30
+ for (const plugin of catalog.plugins) {
31
+ if (ids.has(plugin.pluginId)) throw new Error('DUPLICATE_PLUGIN_ID')
32
+ ids.add(plugin.pluginId)
33
+ const releases = new Set(plugin.availableReleases.map((release) => release.releaseId))
34
+ if (releases.size !== plugin.availableReleases.length || !releases.has(plugin.currentReleaseId))
35
+ throw new Error('RELEASE_SELECTION_INVALID')
36
+ }
37
+ for (const plugin of catalog.plugins)
38
+ for (const release of plugin.availableReleases) {
39
+ if (requirePortable && release.contentResolution !== 'complete')
40
+ throw new Error('COMPLETE_SOURCE_REQUIRED')
41
+ const descriptor = release.releaseMetadata[PACKAGE_METADATA_KEY]
42
+ if (descriptor === undefined) {
43
+ if (requirePortable && release.contentResolution === 'complete')
44
+ throw new Error(`PORTABLE_RESYNC_REQUIRED: ${plugin.pluginId}`)
45
+ continue
46
+ }
47
+ let pkg: ReturnType<typeof verifyAgentPackage>
48
+ try {
49
+ pkg = verifyAgentPackage(descriptor)
50
+ } catch (error) {
51
+ // A consumer imports a catalog it did not compile, so a malformed
52
+ // provenance descriptor is an IMPORT failure, and it must be refused
53
+ // the way every other failure here is refused: one typed, single-line
54
+ // reason that names the plugin. The schema's own multi-line issue
55
+ // dump is not a refusal a consumer can match on.
56
+ throw new Error(
57
+ `PACKAGE_PROVENANCE_INVALID: ${plugin.pluginId} (${describeProvenanceFailure(error)})`,
58
+ { cause: error }
59
+ )
60
+ }
61
+ if (
62
+ release.contentResolution !== 'complete' ||
63
+ pkg.sourceDigest !== release.canonicalContentDigest
64
+ )
65
+ throw new Error(`PACKAGE_PROVENANCE_MISMATCH: ${plugin.pluginId}`)
66
+ const sourcePaths = new Set(release.fileIndex)
67
+ for (const file of pkg.files)
68
+ if (file.action === 'copy' && !sourcePaths.has(file.sourcePath))
69
+ throw new Error(`PACKAGE_SOURCE_FILE_MISSING: ${plugin.pluginId}`)
70
+ }
71
+ }
@@ -66,7 +66,7 @@ describe('catalog publication addresses', () => {
66
66
  )
67
67
  })
68
68
 
69
- test('only a GitHub repository can be addressed', () => {
69
+ test('refuses a repository URL it cannot address', () => {
70
70
  expect(() => catalogAssetsBaseUrl('https://example.com/adea-ai/plugins')).toThrow(
71
71
  'REPOSITORY_URL_UNSUPPORTED'
72
72
  )
@@ -115,3 +115,31 @@ describe('staged asset audit', () => {
115
115
  expect(audit.unstaged).toEqual(['icon-unstaged.png'])
116
116
  })
117
117
  })
118
+
119
+ describe('GitLab publication URLs', () => {
120
+ const repository = 'https://gitlab.com/adea-ai/ade/plugins'
121
+
122
+ test('addresses the branch through GitLab raw URLs in nested groups', () => {
123
+ expect(catalogAssetsBaseUrl(repository)).toBe(
124
+ 'https://gitlab.com/adea-ai/ade/plugins/-/raw/catalog-assets'
125
+ )
126
+ expect(latestPointerUrl(`${repository}.git`)).toBe(
127
+ 'https://gitlab.com/adea-ai/ade/plugins/-/raw/catalog-assets/catalog-latest.v1.json'
128
+ )
129
+ })
130
+
131
+ test('still addresses GitHub so the forge can switch back', () => {
132
+ expect(catalogAssetsBaseUrl('https://github.com/adea-ai/plugins')).toBe(
133
+ 'https://raw.githubusercontent.com/adea-ai/plugins/catalog-assets'
134
+ )
135
+ })
136
+
137
+ test('rejects other hosts and traversal', () => {
138
+ expect(() => catalogAssetsBaseUrl('https://example.com/a/b')).toThrow(
139
+ 'REPOSITORY_URL_UNSUPPORTED'
140
+ )
141
+ expect(() => catalogAssetsBaseUrl('https://gitlab.com/a/../b')).toThrow(
142
+ 'REPOSITORY_URL_INVALID'
143
+ )
144
+ })
145
+ })
@@ -1,3 +1,5 @@
1
+ import { CatalogSchema } from './schema.js'
2
+
1
3
  /**
2
4
  * Where a published catalog lives, and the URLs that address it.
3
5
  *
@@ -10,9 +12,10 @@
10
12
  *
11
13
  * The branch gives each catalog an immutable path — `catalogs/<catalogId>/…`,
12
14
  * where `catalogId` is the canonical digest of the catalog itself. The
13
- * publisher must reject replacement of existing snapshot bytes, and
14
- * `raw.githubusercontent.com` serves the branch with permissive
15
- * CORS so a browser can fetch it directly.
15
+ * publisher must reject replacement of existing snapshot bytes. GitLab raw URLs
16
+ * serve the branch to servers and image tags; unlike
17
+ * `raw.githubusercontent.com` they send no CORS header, so a browser `fetch`
18
+ * needs a same-origin proxy.
16
19
  */
17
20
 
18
21
  export const publishedArtifactNames = [
@@ -97,9 +100,9 @@ export interface PublicationPlan {
97
100
  * the snapshot path against changes to companion artifacts under the same ID.
98
101
  */
99
102
  export function catalogIdSuffix(catalogId: string): string {
100
- const match = /^catalog:([a-f0-9]{64})$/.exec(catalogId)
101
- if (!match) throw new Error(`CATALOG_ID_INVALID: ${catalogId}`)
102
- return match[1] as string
103
+ if (!CatalogSchema.shape.catalogId.safeParse(catalogId).success)
104
+ throw new Error(`CATALOG_ID_INVALID: ${catalogId}`)
105
+ return catalogId.slice('catalog:'.length)
103
106
  }
104
107
 
105
108
  /**
@@ -107,7 +110,10 @@ export function catalogIdSuffix(catalogId: string): string {
107
110
  * catalog pointer.
108
111
  */
109
112
  export function catalogAssetsBaseUrl(repositoryUrl: string): string {
110
- return `https://raw.githubusercontent.com/${repositorySlug(repositoryUrl)}/${catalogAssetsBranch}`
113
+ const { host, path } = repositoryLocation(repositoryUrl)
114
+ return host === 'gitlab.com'
115
+ ? `https://gitlab.com/${path}/-/raw/${catalogAssetsBranch}`
116
+ : `https://raw.githubusercontent.com/${path}/${catalogAssetsBranch}`
111
117
  }
112
118
 
113
119
  /** Root of one catalog's immutable artifacts, content-addressed by identity. */
@@ -134,6 +140,11 @@ export function createPublicationPlan(input: {
134
140
  }
135
141
  }
136
142
 
143
+ /** A flat file name that is safe as both a local file and a URL path segment. */
144
+ export function isPublishedName(name: string): boolean {
145
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)
146
+ }
147
+
137
148
  /**
138
149
  * Immutable URL for one artifact of one catalog.
139
150
  *
@@ -147,22 +158,30 @@ export function immutableAssetUrl(
147
158
  catalogId: string,
148
159
  assetName: string
149
160
  ): string {
150
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(assetName))
151
- throw new Error(`ASSET_NAME_INVALID: ${assetName}`)
161
+ if (!isPublishedName(assetName)) throw new Error(`ASSET_NAME_INVALID: ${assetName}`)
152
162
  return `${catalogSnapshotBaseUrl(repositoryUrl, catalogId)}/${assetName}`
153
163
  }
154
164
 
155
- function repositorySlug(repositoryUrl: string): string {
165
+ function repositoryLocation(repositoryUrl: string): {
166
+ readonly host: 'github.com' | 'gitlab.com'
167
+ readonly path: string
168
+ } {
156
169
  let parsed: URL
157
170
  try {
158
171
  parsed = new URL(repositoryUrl)
159
172
  } catch {
160
173
  throw new Error(`REPOSITORY_URL_INVALID: ${repositoryUrl}`)
161
174
  }
162
- if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com')
175
+ const host = parsed.hostname
176
+ if (parsed.protocol !== 'https:' || (host !== 'github.com' && host !== 'gitlab.com'))
163
177
  throw new Error(`REPOSITORY_URL_UNSUPPORTED: ${repositoryUrl}`)
164
178
  const path = parsed.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')
165
- if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(path))
179
+ // GitLab projects can sit in nested groups; GitHub is always owner/repo.
180
+ const shape =
181
+ host === 'gitlab.com'
182
+ ? /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/
183
+ : /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
184
+ if (!shape.test(path) || path.split('/').some((part) => part === '.' || part === '..'))
166
185
  throw new Error(`REPOSITORY_URL_INVALID: ${repositoryUrl}`)
167
- return path
186
+ return { host, path }
168
187
  }
@@ -0,0 +1,60 @@
1
+ import type { GeneratedArtifacts } from './artifacts.js'
2
+ import { byteDigest } from './digest.js'
3
+ import { parseIntegrityManifest, type PublishedAsset } from './integrity-manifest.js'
4
+ import { verifyPortableCatalog } from './portable-verify.js'
5
+ import { parseCatalog, type Catalog } from './schema.js'
6
+ import { readStagedFile } from './staged-file.js'
7
+ import { verifyArtifacts } from './verify-artifacts.js'
8
+
9
+ /**
10
+ * Reads a published artifact set. `integrity.json` is the file list, so the
11
+ * consumer shards can grow with the category configuration.
12
+ */
13
+ export async function readArtifacts(directory: string): Promise<GeneratedArtifacts> {
14
+ const integrity = (await readStagedFile(directory, 'integrity.json')).toString('utf8')
15
+ const { files } = parseIntegrityManifest(JSON.parse(integrity))
16
+ const names = Object.keys(files).toSorted()
17
+ const values = await Promise.all(
18
+ names.map(
19
+ async (name) => [name, (await readStagedFile(directory, name)).toString('utf8')] as const
20
+ )
21
+ )
22
+ return Object.fromEntries([
23
+ ...values,
24
+ ['integrity.json', integrity],
25
+ ]) as unknown as GeneratedArtifacts
26
+ }
27
+
28
+ /** Verifies an artifact set and returns its catalog, checked as a portable catalog. */
29
+ export function verifiedCatalog(artifacts: GeneratedArtifacts, requirePortable = false): Catalog {
30
+ verifyArtifacts(artifacts)
31
+ const catalog = parseCatalog(JSON.parse(artifacts['catalog.v1.json']))
32
+ verifyPortableCatalog(catalog, requirePortable)
33
+ return catalog
34
+ }
35
+
36
+ /** The mirrored marks `integrity.json` declares, or undefined for a snapshot without any. */
37
+ export function declaredAssets(
38
+ artifacts: GeneratedArtifacts
39
+ ): readonly PublishedAsset[] | undefined {
40
+ return parseIntegrityManifest(JSON.parse(artifacts['integrity.json'])).assets
41
+ }
42
+
43
+ /** Compares staged mark bytes in `directory` with the digests they were declared with. */
44
+ export async function checkStagedAssets(
45
+ directory: string,
46
+ declared: readonly PublishedAsset[]
47
+ ): Promise<{ readonly missing: readonly string[]; readonly mismatched: readonly string[] }> {
48
+ const missing: string[] = []
49
+ const mismatched: string[] = []
50
+ for (const asset of declared) {
51
+ const bytes = await readStagedFile(directory, asset.name).catch((error: unknown) => {
52
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
53
+ throw error
54
+ })
55
+ if (!bytes) missing.push(asset.name)
56
+ else if (byteDigest(bytes) !== asset.digest || bytes.byteLength !== asset.bytes)
57
+ mismatched.push(asset.name)
58
+ }
59
+ return { missing, mismatched }
60
+ }
@@ -32,7 +32,6 @@ function fetcher(site: Record<string, string | Uint8Array>): SiteIconFetcher & {
32
32
  return {
33
33
  textCalls,
34
34
  byteCalls,
35
- digest: (bytes) => byteDigest(bytes),
36
35
  fetchText: async (url) => {
37
36
  textCalls.push(url)
38
37
  const value = site[url]
package/src/site-icons.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parseFragment, type DefaultTreeAdapterTypes } from 'parse5'
2
+ import { byteDigest } from './digest.js'
2
3
  import { inspectIconBytes, MAX_ICON_BYTES, type IconContentType } from './icons.js'
3
4
 
4
5
  /**
@@ -6,8 +7,8 @@ import { inspectIconBytes, MAX_ICON_BYTES, type IconContentType } from './icons.
6
7
  *
7
8
  * Many plugins are a thin wrapper over a vendor's API: the content carries no
8
9
  * logo, and the declared homepage is the only pointer to the vendor. Fetching
9
- * that site's icon once at publish time, verifying it and mirroring it as a
10
- * release asset replaces a per-render favicon lookup in the client.
10
+ * that site's icon once at publish time, verifying it and mirroring it beside
11
+ * the catalog replaces a per-render favicon lookup in the client.
11
12
  */
12
13
  export const SITE_ICON_MAX_HTML_BYTES = 4 * 1024 * 1024
13
14
 
@@ -151,7 +152,6 @@ export function discoverSiteIcons(html: string, baseUrl: string): readonly strin
151
152
  export interface SiteIconFetcher {
152
153
  readonly fetchText: (url: string) => Promise<string>
153
154
  readonly fetchBytes: (url: string) => Promise<Uint8Array>
154
- readonly digest: (bytes: Uint8Array) => string
155
155
  }
156
156
 
157
157
  /**
@@ -188,7 +188,7 @@ export async function resolveSiteIcon(
188
188
  homepage,
189
189
  bytes,
190
190
  contentType: inspected.contentType,
191
- digest: fetcher.digest(bytes),
191
+ digest: byteDigest(bytes),
192
192
  }
193
193
  }
194
194
  return { failed: 'NO_USABLE_ICON' }