@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,81 @@
1
+ import type { OutboundHttp } from '@ouraihub/aether-core'
2
+ import type { Actor } from '../context.js'
3
+ import type { CredentialPort } from '../credentials.js'
4
+ import type { DocumentStore } from '../storage.js'
5
+
6
+ export interface McpScope {
7
+ readonly id: string
8
+ readonly label: string
9
+ readonly destructive: boolean
10
+ }
11
+ export interface ExposedMcpTool {
12
+ readonly name: string
13
+ readonly description: string
14
+ readonly inputSchema: Readonly<Record<string, unknown>>
15
+ readonly scope: string
16
+ readonly readOnly: boolean
17
+ readonly destructive: boolean
18
+ readonly idempotent: boolean
19
+ readonly maxRequestBytes?: number
20
+ readonly validate: (args: Readonly<Record<string, unknown>>) => boolean
21
+ readonly handler: (
22
+ args: Readonly<Record<string, unknown>>,
23
+ context: McpCallContext,
24
+ ) => Promise<unknown>
25
+ }
26
+ export interface McpCallContext {
27
+ readonly actor: Actor
28
+ readonly tokenId: string
29
+ readonly scopes: readonly string[]
30
+ readonly signal: AbortSignal
31
+ }
32
+ export interface McpServiceOptions {
33
+ readonly applicationId: string
34
+ readonly origin: string
35
+ readonly store: DocumentStore
36
+ readonly credentials: CredentialPort
37
+ readonly outbound: OutboundHttp
38
+ readonly scopes: readonly McpScope[]
39
+ readonly tools: readonly ExposedMcpTool[]
40
+ readonly resolveIdentity: (request: Request) => Promise<Actor | null>
41
+ /** Must check the actor may grant every requested scope, not just that login exists. */
42
+ readonly authorizeConsent: (
43
+ actor: Actor,
44
+ scopes: readonly string[],
45
+ request: Request,
46
+ ) => Promise<boolean>
47
+ /** Must check resource/business access immediately before external tool dispatch. */
48
+ readonly authorizeTool: (
49
+ actor: Actor,
50
+ tool: ExposedMcpTool,
51
+ args: Readonly<Record<string, unknown>>,
52
+ signal: AbortSignal,
53
+ ) => Promise<boolean>
54
+ readonly mcpOrigins?: readonly string[]
55
+ readonly now?: () => number
56
+ }
57
+ export interface McpSettings {
58
+ readonly enabled: boolean
59
+ readonly enabledScopes: readonly string[]
60
+ }
61
+ export interface GrantRecord {
62
+ readonly id: string
63
+ readonly applicationId: string
64
+ readonly tenantId: string | null
65
+ readonly subjectId: string
66
+ readonly clientId: string
67
+ readonly redirectUri: string
68
+ readonly scopes: readonly string[]
69
+ readonly createdAt: number
70
+ readonly expiresAt: number | null
71
+ readonly revokedAt: number | null
72
+ }
73
+ export interface ApiKeyRecord {
74
+ readonly id: string
75
+ readonly name: string
76
+ readonly scopes: readonly string[]
77
+ readonly prefix: string
78
+ readonly createdAt: number
79
+ readonly expiresAt: number | null
80
+ readonly revokedAt: number | null
81
+ }
@@ -0,0 +1,320 @@
1
+ import { badRequest, KitFailure } from '@ouraihub/aether-core'
2
+ import type { OutboundHttp } from '@ouraihub/aether-core/ports'
3
+ import type { FetchBinding } from './cloudflare.ts'
4
+
5
+ export interface OutboundLimits {
6
+ readonly firstByteMs: number
7
+ readonly idleMs: number
8
+ readonly totalMs: number
9
+ readonly maxResponseBytes: number
10
+ }
11
+ /** This binding MUST enforce public-only DNS at connect time, including all resolved
12
+ * IPv4/IPv6 addresses and rebinding. A DNS preflight followed by ordinary fetch is
13
+ * not this contract. It must not follow redirects or forward through an open proxy.
14
+ * Deploy a trusted egress service binding; absent that binding remote egress fails.
15
+ */
16
+ export interface TrustedOutboundGateway {
17
+ readonly security: 'public-addresses-at-connect-time'
18
+ readonly binding: FetchBinding
19
+ }
20
+ /** Separately deployed, target-bound internal gateway. It must enforce exact
21
+ * configured origin/port at connect time, including DNS, with no redirect/proxy. */
22
+ export interface TrustedPrivateOutboundGateway {
23
+ readonly security: 'explicit-private-targets-at-connect-time'
24
+ readonly binding: FetchBinding
25
+ readonly allowedOrigins: readonly string[]
26
+ }
27
+ export interface WorkerOutboundOptions {
28
+ readonly gateway?: TrustedOutboundGateway
29
+ readonly privateGateway?: TrustedPrivateOutboundGateway
30
+ readonly allowedOrigins: readonly string[]
31
+ readonly limits: OutboundLimits
32
+ /** Local integration tests only, literal loopback hosts; never hostname DNS. */
33
+ readonly allowLoopbackForTests?: boolean
34
+ readonly localFetch?: (url: string, init: RequestInit) => Promise<Response>
35
+ }
36
+ function isLoopback(hostname: string): boolean {
37
+ return hostname === '[::1]' || /^127(?:\.(?:\d{1,3})){3}$/u.test(hostname)
38
+ }
39
+ function privateLiteral(host: string): boolean {
40
+ if (host.startsWith('[')) {
41
+ const ip = host.slice(1, -1).toLowerCase()
42
+ // Public IPv6 unicast only; mapped/compatible IPv4, special and local ranges fail closed.
43
+ return (
44
+ !/^[23][0-9a-f]{0,3}:/u.test(ip) ||
45
+ ip.startsWith('2001:db8:') ||
46
+ ip.startsWith('2001:0:') ||
47
+ ip.startsWith('2002:')
48
+ )
49
+ }
50
+ if (!/^\d+(?:\.\d+){3}$/u.test(host))
51
+ return (
52
+ host === 'localhost' ||
53
+ host.endsWith('.localhost') ||
54
+ host.endsWith('.local') ||
55
+ !host.includes('.')
56
+ )
57
+ const parts = host.split('.').map(Number)
58
+ const a = parts[0],
59
+ b = parts[1],
60
+ c = parts[2]
61
+ return (
62
+ a === undefined ||
63
+ b === undefined ||
64
+ c === undefined ||
65
+ a === 0 ||
66
+ a === 10 ||
67
+ a === 127 ||
68
+ a >= 224 ||
69
+ (a === 100 && b >= 64 && b <= 127) ||
70
+ (a === 169 && b === 254) ||
71
+ (a === 172 && b >= 16 && b <= 31) ||
72
+ (a === 192 && (b === 168 || b === 0 || b === 2)) ||
73
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
74
+ (a === 203 && b === 0 && c === 113)
75
+ )
76
+ }
77
+ export function createWorkerOutbound(options: WorkerOutboundOptions): OutboundHttp {
78
+ const limits = Object.freeze({ ...options.limits })
79
+ for (const value of Object.values(limits))
80
+ if (!Number.isSafeInteger(value) || value <= 0) throw badRequest('outboundLimits', 'invalid')
81
+ const origins = new Set(
82
+ options.allowedOrigins.map((origin) => {
83
+ let url: URL
84
+ try {
85
+ url = new URL(origin)
86
+ } catch {
87
+ throw badRequest('allowedOrigins', 'invalid')
88
+ }
89
+ if (url.origin !== origin || url.username || url.password)
90
+ throw badRequest('allowedOrigins', 'invalid')
91
+ return origin
92
+ }),
93
+ )
94
+ if (origins.size === 0)
95
+ throw new KitFailure('needs_config', 'Outbound origins must be explicitly configured')
96
+ const privateOrigins = new Set(options.privateGateway?.allowedOrigins ?? [])
97
+ for (const origin of privateOrigins) {
98
+ let target: URL
99
+ try {
100
+ target = new URL(origin)
101
+ } catch {
102
+ throw badRequest('privateOrigins', 'invalid')
103
+ }
104
+ if (
105
+ target.origin !== origin ||
106
+ !origins.has(origin) ||
107
+ (target.protocol !== 'http:' && target.protocol !== 'https:')
108
+ )
109
+ throw badRequest('privateOrigins', 'invalid')
110
+ }
111
+ return Object.freeze({
112
+ async fetch(url: string, init: RequestInit): Promise<Response> {
113
+ let target: URL
114
+ try {
115
+ target = new URL(url)
116
+ } catch {
117
+ throw badRequest('url', 'invalid')
118
+ }
119
+ const local = options.allowLoopbackForTests === true && isLoopback(target.hostname)
120
+ const privateTarget = privateOrigins.has(target.origin)
121
+ if (
122
+ target.username ||
123
+ target.password ||
124
+ target.hash ||
125
+ (!local && !privateTarget && target.protocol !== 'https:') ||
126
+ ((local || privateTarget) && target.protocol !== 'https:' && target.protocol !== 'http:') ||
127
+ (!local && !privateTarget && privateLiteral(target.hostname)) ||
128
+ !origins.has(target.origin)
129
+ )
130
+ throw badRequest('url', 'outbound_policy')
131
+ const gateway = privateTarget ? options.privateGateway : options.gateway
132
+ if (
133
+ !local &&
134
+ (!gateway ||
135
+ gateway.security !==
136
+ (privateTarget
137
+ ? 'explicit-private-targets-at-connect-time'
138
+ : 'public-addresses-at-connect-time'))
139
+ )
140
+ throw new KitFailure('needs_config', 'Trusted connect-time outbound gateway is required')
141
+ const abort = new AbortController()
142
+ let failure: KitFailure | null = null
143
+ const firstTimer = setTimeout(
144
+ () => fail(new KitFailure('timeout', 'Outbound first-byte deadline exceeded')),
145
+ limits.firstByteMs,
146
+ )
147
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
148
+ const totalTimer = setTimeout(
149
+ () => fail(new KitFailure('timeout', 'Outbound total deadline exceeded')),
150
+ limits.totalMs,
151
+ )
152
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
153
+ let bodyController: ReadableStreamDefaultController<Uint8Array> | undefined
154
+ let finished = false
155
+ function clean(): void {
156
+ clearTimeout(firstTimer)
157
+ clearTimeout(idleTimer ?? 0)
158
+ clearTimeout(totalTimer)
159
+ init.signal?.removeEventListener('abort', callerAbort)
160
+ abort.signal.removeEventListener('abort', onAbort)
161
+ }
162
+ function fail(error: KitFailure): void {
163
+ if (!abort.signal.aborted) {
164
+ failure = error
165
+ abort.abort()
166
+ }
167
+ }
168
+ function callerAbort(): void {
169
+ fail(new KitFailure('cancelled', 'Outbound request cancelled'))
170
+ }
171
+ function onAbort(): void {
172
+ if (finished) return
173
+ finished = true
174
+ bodyController?.error(failure ?? new KitFailure('cancelled', 'Outbound request cancelled'))
175
+ if (reader) void reader.cancel().catch(() => {})
176
+ clean()
177
+ }
178
+ init.signal?.addEventListener('abort', callerAbort, { once: true })
179
+ abort.signal.addEventListener('abort', onAbort, { once: true })
180
+ if (init.signal?.aborted) callerAbort()
181
+ if (abort.signal.aborted)
182
+ throw failure ?? new KitFailure('cancelled', 'Outbound request cancelled')
183
+ try {
184
+ const headers = new Headers(init.headers)
185
+ headers.set('User-Agent', 'Aether-Worker/1')
186
+ const request: RequestInit & { credentials: 'omit' } = {
187
+ ...init,
188
+ headers,
189
+ redirect: 'manual',
190
+ signal: abort.signal,
191
+ credentials: 'omit',
192
+ }
193
+ // Remote fetch is always the trusted binding. Global fetch is only the literal-loopback opt-in.
194
+ const pending = local
195
+ ? (options.localFetch ?? fetch)(target.href, request)
196
+ : gateway?.binding.fetch(target.href, request)
197
+ if (!pending)
198
+ throw new KitFailure('needs_config', 'Trusted connect-time outbound gateway is required')
199
+ void pending.then(
200
+ (response) => {
201
+ if (abort.signal.aborted) void response.body?.cancel().catch(() => {})
202
+ },
203
+ () => {},
204
+ )
205
+ const aborted = Promise.withResolvers<never>()
206
+ const rejectAbort = () =>
207
+ aborted.reject(failure ?? new KitFailure('cancelled', 'Outbound request cancelled'))
208
+ abort.signal.addEventListener('abort', rejectAbort, { once: true })
209
+ let response: Response
210
+ try {
211
+ response = await Promise.race([pending, aborted.promise])
212
+ } finally {
213
+ abort.signal.removeEventListener('abort', rejectAbort)
214
+ }
215
+ if (abort.signal.aborted) {
216
+ void response.body?.cancel().catch(() => {})
217
+ throw failure
218
+ }
219
+ if (response.redirected || (response.url && response.url !== target.href)) {
220
+ void response.body?.cancel().catch(() => {})
221
+ throw badRequest('url', 'redirect_forbidden')
222
+ }
223
+ const length = response.headers.get('content-length')
224
+ if (
225
+ length !== null &&
226
+ (!/^\d+$/u.test(length) || Number(length) > limits.maxResponseBytes)
227
+ ) {
228
+ void response.body?.cancel().catch(() => {})
229
+ throw badRequest('response', 'too_large', limits.maxResponseBytes)
230
+ }
231
+ if (!response.body) {
232
+ finished = true
233
+ clean()
234
+ return response
235
+ }
236
+ reader = response.body.getReader()
237
+ let received = 0
238
+ const body = new ReadableStream<Uint8Array>({
239
+ start(controller) {
240
+ bodyController = controller
241
+ },
242
+ async pull(controller) {
243
+ if (!reader || finished) return
244
+ idleTimer = setTimeout(
245
+ () => fail(new KitFailure('timeout', 'Outbound idle deadline exceeded')),
246
+ limits.idleMs,
247
+ )
248
+ try {
249
+ const result = await reader.read()
250
+ clearTimeout(idleTimer)
251
+ if (finished) return
252
+ if (result.done) {
253
+ finished = true
254
+ clean()
255
+ controller.close()
256
+ reader.releaseLock()
257
+ return
258
+ }
259
+ clearTimeout(firstTimer)
260
+ received += result.value.byteLength
261
+ if (received > limits.maxResponseBytes) {
262
+ fail(badRequest('response', 'too_large', limits.maxResponseBytes))
263
+ return
264
+ }
265
+ controller.enqueue(result.value)
266
+ } catch {
267
+ fail(new KitFailure('internal', 'Outbound response failed'))
268
+ }
269
+ },
270
+ async cancel() {
271
+ finished = true
272
+ clean()
273
+ abort.abort()
274
+ await reader?.cancel().catch(() => {})
275
+ },
276
+ })
277
+ return new Response(body, {
278
+ status: response.status,
279
+ statusText: response.statusText,
280
+ headers: response.headers,
281
+ })
282
+ } catch (error) {
283
+ finished = true
284
+ clean()
285
+ abort.abort()
286
+ if (error instanceof KitFailure) throw error
287
+ throw failure ?? new KitFailure('internal', 'Outbound request failed')
288
+ }
289
+ },
290
+ })
291
+ }
292
+
293
+ export interface TrustedTargetChecker {
294
+ readonly security: 'all-dns-addresses-preflight'
295
+ /** Reject if any A/AAAA result is private/special/unknown, or lookup fails.
296
+ * The content proxy must independently enforce its own connect-time address policy. */
297
+ checkTarget(url: URL, signal: AbortSignal): Promise<void>
298
+ }
299
+ export function createWorkerTargetCheck(options: {
300
+ readonly checker: TrustedTargetChecker
301
+ readonly allowLoopbackForTests?: boolean
302
+ }): (url: URL, signal: AbortSignal) => Promise<void> {
303
+ if (!options.checker || options.checker.security !== 'all-dns-addresses-preflight')
304
+ throw new KitFailure('needs_config', 'Trusted DNS target checker is required')
305
+ return async (url: URL, signal: AbortSignal): Promise<void> => {
306
+ if (signal.aborted) throw new KitFailure('cancelled', 'Target check cancelled')
307
+ const local = options.allowLoopbackForTests === true && isLoopback(url.hostname)
308
+ if (
309
+ url.username ||
310
+ url.password ||
311
+ url.hash ||
312
+ (!local && url.protocol !== 'https:') ||
313
+ (local && url.protocol !== 'http:' && url.protocol !== 'https:') ||
314
+ (!local && privateLiteral(url.hostname))
315
+ )
316
+ throw badRequest('url', 'outbound_policy')
317
+ if (!local) await options.checker.checkTarget(new URL(url.href), signal)
318
+ if (signal.aborted) throw new KitFailure('cancelled', 'Target check cancelled')
319
+ }
320
+ }
@@ -0,0 +1,63 @@
1
+ import { isContract, type Provider } from '@ouraihub/aether-contracts'
2
+ import { KitFailure } from '@ouraihub/aether-core/error'
3
+ import { inferReasoning } from '@ouraihub/aether-core/providers/infer'
4
+ import { isSealedCredential, type SealedCredential } from '../credentials.js'
5
+
6
+ export interface Secret {
7
+ endpointId: string
8
+ keyId: string
9
+ sealed: SealedCredential
10
+ }
11
+
12
+ export interface Catalog {
13
+ providers: Provider[]
14
+ secrets: Secret[]
15
+ inferred: string[]
16
+ }
17
+
18
+ function record(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
20
+ }
21
+
22
+ export function parseCatalog(value: unknown): Catalog {
23
+ if (
24
+ !record(value) ||
25
+ !Array.isArray(value.providers) ||
26
+ !Array.isArray(value.secrets) ||
27
+ !Array.isArray(value.inferred)
28
+ )
29
+ throw new KitFailure('incompatible_db', 'Invalid provider catalog')
30
+ const providers: Provider[] = []
31
+ const secrets: Secret[] = []
32
+ const inferred: string[] = []
33
+ for (const item of value.providers) {
34
+ if (!isContract('Provider', item))
35
+ throw new KitFailure('incompatible_db', 'Invalid provider document')
36
+ providers.push(item)
37
+ }
38
+ for (const item of value.secrets) {
39
+ if (
40
+ !record(item) ||
41
+ typeof item.endpointId !== 'string' ||
42
+ typeof item.keyId !== 'string' ||
43
+ !isSealedCredential(item.sealed)
44
+ )
45
+ throw new KitFailure('incompatible_db', 'Invalid provider credential')
46
+ secrets.push({ endpointId: item.endpointId, keyId: item.keyId, sealed: item.sealed })
47
+ }
48
+ for (const item of value.inferred) {
49
+ if (typeof item !== 'string')
50
+ throw new KitFailure('incompatible_db', 'Invalid reasoning declaration')
51
+ inferred.push(item)
52
+ }
53
+ const automatic = new Set(inferred)
54
+ for (const provider of providers)
55
+ for (const endpoint of provider.endpoints)
56
+ for (const model of endpoint.models)
57
+ if (automatic.has(JSON.stringify([endpoint.id, model.id])))
58
+ model.reasoning =
59
+ model.modelType === 'chat'
60
+ ? inferReasoning(model.id, endpoint.protocol, endpoint.baseUrl, model.maxOutput)
61
+ : null
62
+ return { providers, secrets, inferred }
63
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ isContract,
3
+ type ModelFilter,
4
+ type ProviderSave,
5
+ type EndpointKeySave,
6
+ type ProbeTarget,
7
+ } from '@ouraihub/aether-contracts'
8
+ import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
9
+ import type { WorkerContext } from '../context.js'
10
+ import type { ProviderService } from './index.js'
11
+
12
+ function record(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
14
+ }
15
+
16
+ export async function handleProviderRequest(
17
+ service: ProviderService,
18
+ request: Request,
19
+ context: WorkerContext,
20
+ authorizeReveal: (request: Request, context: WorkerContext) => Promise<void>,
21
+ ): Promise<Response | null> {
22
+ const url = new URL(request.url)
23
+ const path = url.pathname
24
+ const root = '/api/v1/providers'
25
+ if (path !== root && !path.startsWith(`${root}/`)) return null
26
+ const actor = context.actor
27
+ const json = (value: unknown): Response => Response.json(value)
28
+ if (path === root && request.method === 'GET') return json(await service.list(actor))
29
+ if (path === root && request.method === 'POST') {
30
+ const value: unknown = await request.json()
31
+ if (!isContract('ProviderSave', value)) throw badRequest('provider', 'invalid')
32
+ return json(await service.save(actor, value as ProviderSave))
33
+ }
34
+ if (path === `${root}/model-options` && request.method === 'GET') {
35
+ const value: unknown = {
36
+ ...(url.searchParams.has('modelType')
37
+ ? { modelType: url.searchParams.get('modelType') }
38
+ : {}),
39
+ ...(url.searchParams.has('supportsTools')
40
+ ? { supportsTools: url.searchParams.get('supportsTools') === 'true' }
41
+ : {}),
42
+ }
43
+ if (
44
+ url.searchParams.has('supportsTools') &&
45
+ !['true', 'false'].includes(url.searchParams.get('supportsTools') ?? '')
46
+ )
47
+ throw badRequest('supportsTools', 'invalid')
48
+ if (!isContract('ModelFilter', value)) throw badRequest('filter', 'invalid')
49
+ return json(await service.modelOptions(actor, value as ModelFilter))
50
+ }
51
+ if (path === `${root}/probe` && request.method === 'POST') {
52
+ const value: unknown = await request.json()
53
+ if (!isContract('ProbeTarget', value)) throw badRequest('target', 'invalid')
54
+ return json(await service.probe(actor, value as ProbeTarget, context.signal))
55
+ }
56
+ if (path === `${root}/keys` && request.method === 'POST') {
57
+ const value: unknown = await request.json()
58
+ if (!isContract('EndpointKeySave', value)) throw badRequest('key', 'invalid')
59
+ return json(await service.saveKey(actor, value as EndpointKeySave))
60
+ }
61
+ const reveal = path.match(/^\/api\/v1\/providers\/keys\/([^/]+)\/reveal$/)
62
+ if (reveal && request.method === 'POST') {
63
+ await authorizeReveal(request, context)
64
+ return json(await service.revealKey(actor, decodeURIComponent(reveal[1] ?? '')))
65
+ }
66
+ const key = path.match(/^\/api\/v1\/providers\/([^/]+)\/keys\/([^/]+)$/)
67
+ if (key && request.method === 'DELETE')
68
+ return json(
69
+ await service.removeKey(
70
+ actor,
71
+ decodeURIComponent(key[1] ?? ''),
72
+ decodeURIComponent(key[2] ?? ''),
73
+ ),
74
+ )
75
+ const enabled = path.match(/^\/api\/v1\/providers\/([^/]+)\/enabled$/)
76
+ if (enabled && request.method === 'POST') {
77
+ const value: unknown = await request.json()
78
+ if (!record(value) || Object.keys(value).length !== 1 || typeof value.enabled !== 'boolean')
79
+ throw badRequest('enabled', 'invalid')
80
+ return json(
81
+ await service.setEnabled(actor, decodeURIComponent(enabled[1] ?? ''), value.enabled),
82
+ )
83
+ }
84
+ const provider = path.match(/^\/api\/v1\/providers\/([^/]+)$/)
85
+ if (provider && request.method === 'DELETE')
86
+ return json(await service.remove(actor, decodeURIComponent(provider[1] ?? '')))
87
+ throw new KitFailure('not_found', 'Unknown route', { entity: 'route' })
88
+ }