@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.
- package/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +38 -3
- package/src/app.ts +363 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +55 -0
- package/src/conversations/action.ts +257 -0
- package/src/conversations/index.ts +480 -0
- package/src/conversations/model.ts +427 -0
- package/src/conversations/routes.ts +101 -0
- package/src/coordinator-helpers.ts +131 -0
- package/src/coordinator.ts +488 -0
- package/src/credentials.ts +171 -0
- package/src/files.ts +117 -0
- package/src/host.ts +25 -0
- package/src/identity.ts +227 -0
- package/src/images/index.ts +73 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +98 -0
- package/src/mcp/auth.ts +398 -0
- package/src/mcp/client.ts +491 -0
- package/src/mcp/common.ts +124 -0
- package/src/mcp/index.ts +292 -0
- package/src/mcp/oauth.ts +343 -0
- package/src/mcp/types.ts +81 -0
- package/src/outbound.ts +320 -0
- package/src/providers/catalog.ts +63 -0
- package/src/providers/http.ts +88 -0
- package/src/providers/index.ts +445 -0
- package/src/providers/model-options.ts +28 -0
- package/src/runtime-input.ts +44 -0
- package/src/runtime-tools.ts +298 -0
- package/src/runtime.ts +476 -0
- package/src/search/index.ts +497 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +153 -0
- package/src/skills/service.ts +250 -0
- package/src/storage.ts +293 -0
- package/src/testing/fakes.ts +97 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|