@ouraihub/aether-worker 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,497 @@
1
+ import type {
2
+ SearchCap,
3
+ SearchKey,
4
+ SearchPage,
5
+ SearchPreferences,
6
+ SearchProvider,
7
+ SearchSettingsDto,
8
+ } from '@ouraihub/aether-contracts'
9
+ import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
10
+ import type { OutboundHttp } from '@ouraihub/aether-core/ports'
11
+ import {
12
+ DEFAULT_PROVIDERS,
13
+ PROVIDERS,
14
+ defaultPreferences,
15
+ fetch as fetchPage,
16
+ providerFor,
17
+ search as searchQuery,
18
+ validatePreferences,
19
+ validateQuery,
20
+ validateTarget,
21
+ type CheckTarget,
22
+ type ResolvedSearchConfig,
23
+ } from '@ouraihub/aether-core/search'
24
+ import type { Actor, WorkerContext } from '../context.js'
25
+ import { ownerKey } from '../context.js'
26
+ import type { CredentialPort, CredentialScope, SealedCredential } from '../credentials.js'
27
+ import { isSealedCredential } from '../credentials.js'
28
+ import type { DocumentStore, StoredDocument } from '../storage.js'
29
+
30
+ interface KeyRecord {
31
+ id: string
32
+ sealed: SealedCredential
33
+ }
34
+ interface ProviderRecord {
35
+ baseUrl?: string
36
+ basicAuthUsername?: string
37
+ keys: KeyRecord[]
38
+ }
39
+ export interface SearchServiceOptions {
40
+ documents: DocumentStore
41
+ credentials: CredentialPort
42
+ outbound: OutboundHttp
43
+ checkTarget: CheckTarget
44
+ /** Must verify fresh host authentication; never automatically approve a reveal. */
45
+ verifyCurrentPassword(actor: Actor, password: string, signal: AbortSignal): Promise<void>
46
+ }
47
+ function object(value: unknown): value is Record<string, unknown> {
48
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
49
+ }
50
+ function input(value: unknown, names: readonly string[]): Record<string, unknown> {
51
+ if (!object(value) || Object.keys(value).some((name) => !names.includes(name)))
52
+ throw badRequest('input', 'unknown_fields')
53
+ return value
54
+ }
55
+ function string(value: unknown, field: string): string {
56
+ if (typeof value !== 'string') throw badRequest(field, 'invalid')
57
+ return value
58
+ }
59
+ function cap(value: unknown): SearchCap {
60
+ if (value !== 'search' && value !== 'fetch') throw badRequest('capability', 'invalid')
61
+ return value
62
+ }
63
+ function parseProvider(value: unknown): ProviderRecord {
64
+ const data = input(value, ['baseUrl', 'basicAuthUsername', 'keys'])
65
+ if (!Array.isArray(data.keys) || data.keys.length > 16)
66
+ throw new KitFailure('needs_config', 'Invalid stored search credentials')
67
+ const keys: KeyRecord[] = []
68
+ for (const item of data.keys) {
69
+ if (
70
+ !object(item) ||
71
+ typeof item.id !== 'string' ||
72
+ !isSealedCredential(item.sealed) ||
73
+ keys.some((key) => key.id === item.id)
74
+ )
75
+ throw new KitFailure('needs_config', 'Invalid stored search credentials')
76
+ keys.push({ id: item.id, sealed: item.sealed })
77
+ }
78
+ return {
79
+ keys,
80
+ ...(data.baseUrl === undefined ? {} : { baseUrl: string(data.baseUrl, 'baseUrl') }),
81
+ ...(data.basicAuthUsername === undefined
82
+ ? {}
83
+ : { basicAuthUsername: string(data.basicAuthUsername, 'basicAuthUsername') }),
84
+ }
85
+ }
86
+ function scope(
87
+ actor: Actor,
88
+ capability: SearchCap,
89
+ provider: string,
90
+ keyId: string,
91
+ ): CredentialScope {
92
+ return {
93
+ applicationId: actor.applicationId,
94
+ tenantId: actor.tenantId,
95
+ subjectId: actor.subjectId,
96
+ purpose: 'search',
97
+ recordId: JSON.stringify([capability, provider, keyId]),
98
+ }
99
+ }
100
+ function partition(capability: SearchCap, provider: string): string {
101
+ return JSON.stringify([capability, provider])
102
+ }
103
+ function mask(value: string): string {
104
+ return value.length <= 4
105
+ ? '••••'
106
+ : value.slice(0, 2) + '•'.repeat(Math.min(12, Math.max(4, value.length - 4))) + value.slice(-2)
107
+ }
108
+
109
+ /** All encrypted keys and provider configuration share one CAS document per partition. */
110
+ export class SearchService {
111
+ private readonly rotations = new Map<string, number>()
112
+ private readonly options: SearchServiceOptions
113
+ constructor(options: SearchServiceOptions) {
114
+ this.options = options
115
+ }
116
+ private async stored(
117
+ actor: Actor,
118
+ capability: SearchCap,
119
+ provider: string,
120
+ ): Promise<{ document: StoredDocument | null; value: ProviderRecord }> {
121
+ providerFor(capability, provider)
122
+ const document = await this.options.documents.get(
123
+ actor,
124
+ 'search.providers',
125
+ partition(capability, provider),
126
+ )
127
+ return { document, value: document ? parseProvider(document.value) : { keys: [] } }
128
+ }
129
+ async preferences(actor: Actor): Promise<SearchPreferences> {
130
+ const document = await this.options.documents.get(actor, 'search.preferences', 'global')
131
+ return document ? validatePreferences(document.value) : defaultPreferences()
132
+ }
133
+ async savePreferences(actor: Actor, value: unknown): Promise<SearchPreferences> {
134
+ const preferences = validatePreferences(value)
135
+ const old = await this.options.documents.get(actor, 'search.preferences', 'global')
136
+ await this.options.documents.compareAndSet(
137
+ actor,
138
+ 'search.preferences',
139
+ 'global',
140
+ old?.revision ?? null,
141
+ preferences,
142
+ )
143
+ return preferences
144
+ }
145
+ private async selected(actor: Actor, capability: SearchCap): Promise<string> {
146
+ const document = await this.options.documents.get(actor, 'search.defaults', capability)
147
+ const provider = document
148
+ ? string(input(document.value, ['provider']).provider, 'provider')
149
+ : DEFAULT_PROVIDERS[capability]
150
+ providerFor(capability, provider)
151
+ return provider
152
+ }
153
+ private async open(
154
+ actor: Actor,
155
+ capability: SearchCap,
156
+ provider: string,
157
+ key: KeyRecord,
158
+ ): Promise<string> {
159
+ const bytes = await this.options.credentials.open(
160
+ scope(actor, capability, provider, key.id),
161
+ key.sealed,
162
+ )
163
+ try {
164
+ const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
165
+ if (!value.trim() || value.length > 4096)
166
+ throw new KitFailure('needs_config', 'Search credential unavailable')
167
+ return value
168
+ } finally {
169
+ bytes.fill(0)
170
+ }
171
+ }
172
+ async settings(actor: Actor, capability: SearchCap): Promise<SearchSettingsDto> {
173
+ const providers: SearchProvider[] = []
174
+ const order =
175
+ capability === 'search'
176
+ ? PROVIDERS.filter((item) => item.search).map((item) => item.id)
177
+ : ['querit', 'fetch', 'jina', 'firecrawl']
178
+ for (const id of order) {
179
+ const { spec, config } = providerFor(capability, id)
180
+ const { value } = await this.stored(actor, capability, id)
181
+ let readable = 0
182
+ const keys: SearchKey[] = []
183
+ for (const key of value.keys) {
184
+ try {
185
+ keys.push({ id: key.id, masked: mask(await this.open(actor, capability, id, key)) })
186
+ readable++
187
+ } catch {
188
+ keys.push({ id: key.id, masked: 'Unavailable' })
189
+ }
190
+ }
191
+ providers.push({
192
+ id,
193
+ name: spec.name,
194
+ description: spec.description,
195
+ keyMode: config.keyMode,
196
+ basicAuth: spec.basicAuth,
197
+ ...(config.defaultBaseUrl ? { defaultBaseUrl: config.defaultBaseUrl } : {}),
198
+ ...(value.baseUrl ? { baseUrl: value.baseUrl } : {}),
199
+ ...(value.basicAuthUsername ? { basicAuthUsername: value.basicAuthUsername } : {}),
200
+ keys,
201
+ ready:
202
+ (config.keyMode !== 'required' || readable > 0) && (!value.keys.length || readable > 0),
203
+ })
204
+ }
205
+ return {
206
+ capability,
207
+ defaultProvider: await this.selected(actor, capability),
208
+ preferences: await this.preferences(actor),
209
+ providers,
210
+ }
211
+ }
212
+ async save(actor: Actor, raw: unknown, signal: AbortSignal): Promise<SearchSettingsDto> {
213
+ const data = input(raw, ['capability', 'provider', 'baseUrl', 'basicAuthUsername'])
214
+ const capability = cap(data.capability)
215
+ const provider = string(data.provider, 'provider')
216
+ const { spec } = providerFor(capability, provider)
217
+ const { document, value } = await this.stored(actor, capability, provider)
218
+ if (data.baseUrl !== undefined) {
219
+ const baseUrl = string(data.baseUrl, 'baseUrl').trim()
220
+ if (new TextEncoder().encode(string(data.baseUrl, 'baseUrl')).length > 8192)
221
+ throw badRequest('baseUrl', 'too_long', 8192)
222
+ if (provider !== 'fetch' && baseUrl)
223
+ await this.options.checkTarget(validateTarget(baseUrl), signal)
224
+ delete value.baseUrl
225
+ if (provider !== 'fetch' && baseUrl) value.baseUrl = baseUrl
226
+ }
227
+ if (data.basicAuthUsername !== undefined) {
228
+ const username = string(data.basicAuthUsername, 'basicAuthUsername').trim()
229
+ if (new TextEncoder().encode(username).length > 200)
230
+ throw badRequest('basicAuthUsername', 'too_long', 200)
231
+ if (!spec.basicAuth && username) throw badRequest('basicAuthUsername', 'unsupported')
232
+ delete value.basicAuthUsername
233
+ if (spec.basicAuth && username) value.basicAuthUsername = username
234
+ }
235
+ await this.options.documents.compareAndSet(
236
+ actor,
237
+ 'search.providers',
238
+ partition(capability, provider),
239
+ document?.revision ?? null,
240
+ value,
241
+ )
242
+ return this.settings(actor, capability)
243
+ }
244
+ async setDefault(
245
+ actor: Actor,
246
+ capability: SearchCap,
247
+ provider: string,
248
+ ): Promise<SearchSettingsDto> {
249
+ providerFor(capability, provider)
250
+ const old = await this.options.documents.get(actor, 'search.defaults', capability)
251
+ await this.options.documents.compareAndSet(
252
+ actor,
253
+ 'search.defaults',
254
+ capability,
255
+ old?.revision ?? null,
256
+ {
257
+ provider,
258
+ },
259
+ )
260
+ return this.settings(actor, capability)
261
+ }
262
+ async saveKey(
263
+ actor: Actor,
264
+ capability: SearchCap,
265
+ provider: string,
266
+ raw: string,
267
+ keyId?: string,
268
+ ): Promise<SearchSettingsDto> {
269
+ const { spec, config } = providerFor(capability, provider)
270
+ if (config.keyMode === 'none' && !spec.basicAuth) throw badRequest('keys', 'unsupported')
271
+ const plaintext = raw.trim()
272
+ if (!plaintext || plaintext.length > 4096) throw badRequest('value', 'invalid', 4096)
273
+ const { document, value } = await this.stored(actor, capability, provider)
274
+ const existing = keyId === undefined ? undefined : value.keys.find((key) => key.id === keyId)
275
+ if (keyId !== undefined && !existing)
276
+ throw new KitFailure('not_found', 'Search key not found', { field: 'key' })
277
+ if (!existing && value.keys.length >= 16) throw badRequest('keys', 'too_many', 16)
278
+ const id = keyId ?? crypto.randomUUID()
279
+ const bytes = new TextEncoder().encode(plaintext)
280
+ let sealed: SealedCredential
281
+ try {
282
+ sealed = await this.options.credentials.seal(scope(actor, capability, provider, id), bytes)
283
+ } finally {
284
+ bytes.fill(0)
285
+ }
286
+ if (existing) existing.sealed = sealed
287
+ else value.keys.push({ id, sealed })
288
+ await this.options.documents.compareAndSet(
289
+ actor,
290
+ 'search.providers',
291
+ partition(capability, provider),
292
+ document?.revision ?? null,
293
+ value,
294
+ )
295
+ return this.settings(actor, capability)
296
+ }
297
+ async removeKey(
298
+ actor: Actor,
299
+ capability: SearchCap,
300
+ provider: string,
301
+ keyId: string,
302
+ ): Promise<SearchSettingsDto> {
303
+ const { document, value } = await this.stored(actor, capability, provider)
304
+ if (!value.keys.some((key) => key.id === keyId))
305
+ throw new KitFailure('not_found', 'Search key not found', { field: 'key' })
306
+ value.keys = value.keys.filter((key) => key.id !== keyId)
307
+ await this.options.documents.compareAndSet(
308
+ actor,
309
+ 'search.providers',
310
+ partition(capability, provider),
311
+ document?.revision ?? null,
312
+ value,
313
+ )
314
+ return this.settings(actor, capability)
315
+ }
316
+ async reveal(
317
+ actor: Actor,
318
+ capability: SearchCap,
319
+ provider: string,
320
+ keyId: string,
321
+ password: string,
322
+ signal: AbortSignal,
323
+ ): Promise<string> {
324
+ await this.options.verifyCurrentPassword(actor, password, signal)
325
+ const { value } = await this.stored(actor, capability, provider)
326
+ const key = value.keys.find((item) => item.id === keyId)
327
+ if (!key) throw new KitFailure('not_found', 'Search key not found', { field: 'key' })
328
+ try {
329
+ return await this.open(actor, capability, provider, key)
330
+ } catch {
331
+ throw new KitFailure('needs_config', 'Search credential unavailable', { field: 'keys' })
332
+ }
333
+ }
334
+ async rotateCredentials(actor: Actor, capability: SearchCap, provider: string): Promise<void> {
335
+ const { document, value } = await this.stored(actor, capability, provider)
336
+ if (!document) return
337
+ for (const key of value.keys)
338
+ key.sealed = await this.options.credentials.rotate(
339
+ scope(actor, capability, provider, key.id),
340
+ key.sealed,
341
+ )
342
+ await this.options.documents.compareAndSet(
343
+ actor,
344
+ 'search.providers',
345
+ partition(capability, provider),
346
+ document.revision,
347
+ value,
348
+ )
349
+ }
350
+ async resolve(
351
+ actor: Actor,
352
+ capability: SearchCap,
353
+ explicitProvider?: string,
354
+ ): Promise<ResolvedSearchConfig> {
355
+ const provider = explicitProvider ?? (await this.selected(actor, capability))
356
+ const { config } = providerFor(capability, provider)
357
+ const { value } = await this.stored(actor, capability, provider)
358
+ const preferences = await this.preferences(actor)
359
+ const keys: string[] = []
360
+ for (const key of value.keys) {
361
+ try {
362
+ keys.push(await this.open(actor, capability, provider, key))
363
+ } catch {
364
+ /* Unreadable keys are deliberately skipped. */
365
+ }
366
+ }
367
+ if ((value.keys.length && !keys.length) || (config.keyMode === 'required' && !keys.length))
368
+ throw new KitFailure('needs_config', 'Search credentials required', { field: 'keys' })
369
+ const rotationKey = JSON.stringify([ownerKey(actor), capability, provider])
370
+ const start = (this.rotations.get(rotationKey) ?? 0) % Math.max(1, keys.length)
371
+ if (keys.length) this.rotations.set(rotationKey, (start + 1) % keys.length)
372
+ return {
373
+ provider,
374
+ baseUrl: value.baseUrl || config.defaultBaseUrl,
375
+ apiKeys: keys.slice(start).concat(keys.slice(0, start)),
376
+ ...(value.basicAuthUsername ? { basicAuthUsername: value.basicAuthUsername } : {}),
377
+ preferences,
378
+ }
379
+ }
380
+ async search(actor: Actor, rawQuery: string, signal?: AbortSignal) {
381
+ const query = validateQuery(rawQuery)
382
+ return searchQuery(await this.resolve(actor, 'search'), query, this.options.outbound, signal)
383
+ }
384
+ async fetch(actor: Actor, rawUrl: string, signal?: AbortSignal): Promise<SearchPage> {
385
+ const url = validateTarget(rawUrl)
386
+ return fetchPage(
387
+ await this.resolve(actor, 'fetch'),
388
+ url,
389
+ this.options.outbound,
390
+ this.options.checkTarget,
391
+ signal,
392
+ )
393
+ }
394
+ async check(
395
+ actor: Actor,
396
+ capability: SearchCap,
397
+ provider: string,
398
+ signal?: AbortSignal,
399
+ ): Promise<{ count: number }> {
400
+ const snapshot = await this.resolve(actor, capability, provider)
401
+ if (capability === 'fetch') {
402
+ await fetchPage(
403
+ snapshot,
404
+ 'https://example.com',
405
+ this.options.outbound,
406
+ this.options.checkTarget,
407
+ signal,
408
+ )
409
+ return { count: 1 }
410
+ }
411
+ const result = await searchQuery(snapshot, 'web search', this.options.outbound, signal)
412
+ if (!result.hits.length) throw new KitFailure('upstream', 'Search provider returned no results')
413
+ return { count: result.hits.length }
414
+ }
415
+ async handle(request: Request, context: WorkerContext): Promise<Response | null> {
416
+ const url = new URL(request.url)
417
+ if (!url.pathname.startsWith('/api/v1/search/')) return null
418
+ const route = url.pathname.slice('/api/v1/search/'.length)
419
+ const { actor, signal } = context
420
+ if (route === 'settings' && request.method === 'GET') {
421
+ if (
422
+ [...url.searchParams.keys()].some((key) => key !== 'capability') ||
423
+ url.searchParams.getAll('capability').length !== 1
424
+ )
425
+ throw badRequest('query', 'invalid')
426
+ return Response.json(await this.settings(actor, cap(url.searchParams.get('capability'))))
427
+ }
428
+ const methods: Readonly<Record<string, string>> = {
429
+ settings: 'POST',
430
+ default: 'POST',
431
+ preferences: 'PUT',
432
+ keys: request.method === 'DELETE' ? 'DELETE' : 'POST',
433
+ 'keys/reveal': 'POST',
434
+ check: 'POST',
435
+ search: 'POST',
436
+ fetch: 'POST',
437
+ }
438
+ if (!methods[route] || request.method !== methods[route])
439
+ throw badRequest('route', 'unsupported')
440
+ if (url.search) throw badRequest('query', 'unsupported')
441
+ let raw: unknown
442
+ try {
443
+ raw = await request.json()
444
+ } catch {
445
+ throw badRequest('body', 'invalid_json')
446
+ }
447
+ if (route === 'preferences') return Response.json(await this.savePreferences(actor, raw))
448
+ if (route === 'settings') return Response.json(await this.save(actor, raw, signal))
449
+ if (route === 'search') {
450
+ const data = input(raw, ['query'])
451
+ return Response.json(await this.search(actor, string(data.query, 'query'), signal))
452
+ }
453
+ if (route === 'fetch') {
454
+ const data = input(raw, ['url'])
455
+ return Response.json(await this.fetch(actor, string(data.url, 'url'), signal))
456
+ }
457
+ const names =
458
+ route === 'keys/reveal'
459
+ ? ['capability', 'provider', 'keyId', 'currentPassword']
460
+ : route === 'keys'
461
+ ? request.method === 'DELETE'
462
+ ? ['capability', 'provider', 'keyId']
463
+ : ['capability', 'provider', 'value', 'keyId']
464
+ : ['capability', 'provider']
465
+ const data = input(raw, names)
466
+ const capability = cap(data.capability)
467
+ const provider = string(data.provider, 'provider')
468
+ if (route === 'default')
469
+ return Response.json(await this.setDefault(actor, capability, provider))
470
+ if (route === 'check')
471
+ return Response.json(await this.check(actor, capability, provider, signal))
472
+ if (route === 'keys/reveal')
473
+ return Response.json(
474
+ await this.reveal(
475
+ actor,
476
+ capability,
477
+ provider,
478
+ string(data.keyId, 'keyId'),
479
+ string(data.currentPassword, 'currentPassword'),
480
+ signal,
481
+ ),
482
+ )
483
+ if (request.method === 'DELETE')
484
+ return Response.json(
485
+ await this.removeKey(actor, capability, provider, string(data.keyId, 'keyId')),
486
+ )
487
+ return Response.json(
488
+ await this.saveKey(
489
+ actor,
490
+ capability,
491
+ provider,
492
+ string(data.value, 'value'),
493
+ data.keyId === undefined ? undefined : string(data.keyId, 'keyId'),
494
+ ),
495
+ )
496
+ }
497
+ }
@@ -0,0 +1 @@
1
+ export { SkillsService } from './service.js'
@@ -0,0 +1,153 @@
1
+ import type { SkillMeta } from '@ouraihub/aether-contracts'
2
+ import { KitFailure } from '@ouraihub/aether-core'
3
+ import { digest, sha256 } from '@ouraihub/aether-core/skills/content'
4
+ import {
5
+ parseCatalog,
6
+ type SkillCatalog,
7
+ type SkillRecord,
8
+ } from '@ouraihub/aether-core/skills/service'
9
+ import { exact, object, revision, text } from '@ouraihub/aether-core/skills/validation'
10
+ import type { Actor } from '../context.js'
11
+ import type { FileStore } from '../files.js'
12
+
13
+ export interface SkillBlobRef extends SkillMeta {
14
+ readonly blobId: string
15
+ readonly digest: string
16
+ }
17
+ export interface LoadedSkills {
18
+ readonly catalog: SkillCatalog
19
+ readonly refs: readonly SkillBlobRef[]
20
+ }
21
+
22
+ export function parseRefs(value: unknown): SkillBlobRef[] {
23
+ if (!Array.isArray(value)) throw new KitFailure('internal', 'Invalid skill catalog')
24
+ const ids = new Set<string>()
25
+ const names = new Set<string>()
26
+ return value.map((raw: unknown) => {
27
+ const entry = object(raw, 'skill')
28
+ exact(
29
+ entry,
30
+ [
31
+ 'id',
32
+ 'blobId',
33
+ 'digest',
34
+ 'name',
35
+ 'description',
36
+ 'version',
37
+ 'origin',
38
+ 'revision',
39
+ 'bodyChars',
40
+ 'updatedAt',
41
+ 'hasScripts',
42
+ 'fileCount',
43
+ ],
44
+ 'skill',
45
+ )
46
+ const origin = entry.origin
47
+ if (origin !== 'manual' && origin !== 'imported' && origin !== 'builtin')
48
+ throw new KitFailure('internal', 'Invalid skill origin')
49
+ const { bodyChars, updatedAt, hasScripts, fileCount } = entry
50
+ if (
51
+ typeof bodyChars !== 'number' ||
52
+ !Number.isSafeInteger(bodyChars) ||
53
+ bodyChars < 1 ||
54
+ typeof updatedAt !== 'number' ||
55
+ !Number.isSafeInteger(updatedAt) ||
56
+ typeof hasScripts !== 'boolean' ||
57
+ typeof fileCount !== 'number' ||
58
+ !Number.isInteger(fileCount) ||
59
+ fileCount < 0 ||
60
+ fileCount > 200
61
+ )
62
+ throw new KitFailure('internal', 'Invalid skill metadata')
63
+ const ref: SkillBlobRef = {
64
+ id: text(entry.id, 'id'),
65
+ blobId: text(entry.blobId, 'blobId'),
66
+ digest: text(entry.digest, 'digest'),
67
+ name: text(entry.name, 'name'),
68
+ description: text(entry.description, 'description'),
69
+ version: entry.version === null ? null : text(entry.version, 'version'),
70
+ origin,
71
+ revision: revision(entry.revision),
72
+ bodyChars,
73
+ updatedAt,
74
+ hasScripts,
75
+ fileCount,
76
+ }
77
+ if (
78
+ ids.has(ref.id) ||
79
+ names.has(ref.name) ||
80
+ !/^[A-Za-z0-9_-]{1,128}$/u.test(ref.blobId) ||
81
+ !/^[0-9a-f]{64}$/u.test(ref.digest)
82
+ )
83
+ throw new KitFailure('internal', 'Invalid skill catalog')
84
+ ids.add(ref.id)
85
+ names.add(ref.name)
86
+ return ref
87
+ })
88
+ }
89
+
90
+ function encode(bytes: readonly number[]): string {
91
+ let binary = ''
92
+ for (const byte of bytes) binary += String.fromCharCode(byte)
93
+ return btoa(binary)
94
+ }
95
+ function decode(value: unknown): number[] {
96
+ const encoded = text(value, 'file.bytes')
97
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded))
98
+ throw new KitFailure('internal', 'Invalid skill blob')
99
+ const binary = atob(encoded)
100
+ return Array.from(binary, (char) => char.charCodeAt(0))
101
+ }
102
+ export function serializeSkill(skill: SkillRecord): Uint8Array {
103
+ return new TextEncoder().encode(
104
+ JSON.stringify({
105
+ ...skill,
106
+ files: skill.files.map((file) => ({ ...file, bytes: encode(file.bytes) })),
107
+ }),
108
+ )
109
+ }
110
+ export async function loadSkills(
111
+ files: FileStore,
112
+ actor: Actor,
113
+ value: unknown,
114
+ ): Promise<LoadedSkills> {
115
+ const refs = parseRefs(value)
116
+ const catalog: SkillRecord[] = []
117
+ for (const ref of refs) {
118
+ const blob = await files.get(actor, ref.blobId)
119
+ if (!blob || blob.mime !== 'application/json')
120
+ throw new KitFailure('internal', 'Skill package is unavailable')
121
+ let parsed: unknown
122
+ try {
123
+ parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(blob.data))
124
+ } catch {
125
+ throw new KitFailure('internal', 'Invalid skill package')
126
+ }
127
+ const record = object(parsed, 'skill')
128
+ const storedFiles: unknown = record.files
129
+ if (!Array.isArray(storedFiles)) throw new KitFailure('internal', 'Invalid skill files')
130
+ const decodedFiles = storedFiles.map((value: unknown) => {
131
+ const file = object(value, 'file')
132
+ return { ...file, bytes: decode(file.bytes) }
133
+ })
134
+ const skill = parseCatalog([{ ...record, files: decodedFiles }])[0]
135
+ if (
136
+ !skill ||
137
+ skill.id !== ref.id ||
138
+ skill.digest !== ref.digest ||
139
+ skill.revision !== ref.revision ||
140
+ skill.name !== ref.name ||
141
+ catalog.some((entry) => entry.id === skill.id || entry.name === skill.name)
142
+ )
143
+ throw new KitFailure('internal', 'Invalid skill catalog')
144
+ for (const file of skill.files) {
145
+ if ((await sha256(new Uint8Array(file.bytes))) !== file.sha256)
146
+ throw new KitFailure('internal', 'Skill file integrity failure')
147
+ }
148
+ if ((await digest(skill, skill.files)) !== skill.digest)
149
+ throw new KitFailure('internal', 'Skill integrity failure')
150
+ catalog.push(skill)
151
+ }
152
+ return { catalog, refs }
153
+ }