@ouraihub/aether-worker 0.0.0-stage → 0.1.0

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,153 @@
1
+ import { badRequest, KitFailure } from '@ouraihub/aether-core'
2
+
3
+ export interface CredentialScope {
4
+ readonly applicationId: string
5
+ readonly tenantId: string | null
6
+ readonly subjectId: string | null
7
+ readonly purpose: 'provider' | 'search' | 'mcp' | 'oauth'
8
+ readonly recordId: string
9
+ }
10
+ export interface SealedCredential {
11
+ readonly version: 1
12
+ readonly keyId: string
13
+ readonly nonce: string
14
+ readonly ciphertext: string
15
+ }
16
+ export interface CredentialPort {
17
+ seal(scope: CredentialScope, value: Uint8Array): Promise<SealedCredential>
18
+ open(scope: CredentialScope, sealed: SealedCredential): Promise<Uint8Array>
19
+ rotate(scope: CredentialScope, sealed: SealedCredential): Promise<SealedCredential>
20
+ }
21
+ const MAX_BYTES = 64 * 1024
22
+ function unreadable(): KitFailure {
23
+ return new KitFailure('needs_config', 'Credential is unreadable', { reason: 'key_unreadable' })
24
+ }
25
+ function base64(bytes: Uint8Array): string {
26
+ let text = ''
27
+ for (const byte of bytes) text += String.fromCharCode(byte)
28
+ return btoa(text)
29
+ }
30
+ function unbase64(text: string): Uint8Array<ArrayBuffer> {
31
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(text)) throw unreadable()
32
+ const bytes = Uint8Array.from(atob(text), (character) => character.charCodeAt(0))
33
+ if (base64(bytes) !== text) throw unreadable()
34
+ return bytes
35
+ }
36
+ export function isSealedCredential(value: unknown): value is SealedCredential {
37
+ return (
38
+ typeof value === 'object' &&
39
+ value !== null &&
40
+ 'version' in value &&
41
+ value.version === 1 &&
42
+ 'keyId' in value &&
43
+ typeof value.keyId === 'string' &&
44
+ value.keyId.length > 0 &&
45
+ value.keyId.length <= 128 &&
46
+ 'nonce' in value &&
47
+ typeof value.nonce === 'string' &&
48
+ value.nonce.length === 16 &&
49
+ 'ciphertext' in value &&
50
+ typeof value.ciphertext === 'string' &&
51
+ value.ciphertext.length >= 24 &&
52
+ value.ciphertext.length <= Math.ceil((MAX_BYTES + 16) / 3) * 4
53
+ )
54
+ }
55
+ function aad(scope: CredentialScope, keyId: string): Uint8Array<ArrayBuffer> {
56
+ if (
57
+ !scope.applicationId ||
58
+ !scope.recordId ||
59
+ scope.applicationId.length > 256 ||
60
+ scope.recordId.length > 256 ||
61
+ (scope.tenantId !== null && (!scope.tenantId || scope.tenantId.length > 256)) ||
62
+ (scope.subjectId !== null && (!scope.subjectId || scope.subjectId.length > 256)) ||
63
+ !['provider', 'search', 'mcp', 'oauth'].includes(scope.purpose)
64
+ )
65
+ throw badRequest('credentialScope', 'invalid')
66
+ return new TextEncoder().encode(
67
+ JSON.stringify([1, keyId, scope.applicationId, scope.tenantId, scope.subjectId, scope.purpose, scope.recordId]),
68
+ )
69
+ }
70
+ /** Supply non-extractable AES-GCM keys from deployment secrets/KMS; old keys are read-only until explicit rotation. */
71
+ export function createCredentialPort(options: {
72
+ readonly currentKeyId: string
73
+ readonly keys: ReadonlyMap<string, CryptoKey>
74
+ }): CredentialPort {
75
+ const keys = new Map(options.keys)
76
+ const currentKeyId = options.currentKeyId
77
+ if (!currentKeyId || currentKeyId.length > 128 || !keys.has(currentKeyId))
78
+ throw new KitFailure('needs_config', 'Credential key configuration is required')
79
+ for (const [id, key] of keys) {
80
+ if (
81
+ !id ||
82
+ id.length > 128 ||
83
+ key.type !== 'secret' ||
84
+ key.algorithm.name !== 'AES-GCM' ||
85
+ key.extractable ||
86
+ !key.usages.includes('decrypt') ||
87
+ (id === currentKeyId && !key.usages.includes('encrypt'))
88
+ )
89
+ throw new KitFailure('needs_config', 'Invalid credential key configuration')
90
+ }
91
+ async function seal(scope: CredentialScope, value: Uint8Array): Promise<SealedCredential> {
92
+ if (value.byteLength > MAX_BYTES) throw badRequest('credential', 'too_large', MAX_BYTES)
93
+ const key = keys.get(currentKeyId)
94
+ if (!key) throw unreadable()
95
+ const additionalData = aad(scope, currentKeyId)
96
+ const nonce = crypto.getRandomValues(new Uint8Array(12))
97
+ try {
98
+ const ciphertext = await crypto.subtle.encrypt(
99
+ { name: 'AES-GCM', iv: nonce, additionalData, tagLength: 128 },
100
+ key,
101
+ new Uint8Array(value),
102
+ )
103
+ return { version: 1, keyId: currentKeyId, nonce: base64(nonce), ciphertext: base64(new Uint8Array(ciphertext)) }
104
+ } catch {
105
+ throw unreadable()
106
+ }
107
+ }
108
+ async function open(scope: CredentialScope, sealed: SealedCredential): Promise<Uint8Array> {
109
+ try {
110
+ if (!isSealedCredential(sealed)) throw unreadable()
111
+ const key = keys.get(sealed.keyId)
112
+ if (!key) throw unreadable()
113
+ const nonce = unbase64(sealed.nonce)
114
+ const ciphertext = unbase64(sealed.ciphertext)
115
+ if (nonce.byteLength !== 12 || ciphertext.byteLength < 16 || ciphertext.byteLength > MAX_BYTES + 16)
116
+ throw unreadable()
117
+ return new Uint8Array(
118
+ await crypto.subtle.decrypt(
119
+ { name: 'AES-GCM', iv: nonce, additionalData: aad(scope, sealed.keyId), tagLength: 128 },
120
+ key,
121
+ ciphertext,
122
+ ),
123
+ )
124
+ } catch {
125
+ throw unreadable()
126
+ }
127
+ }
128
+ return Object.freeze({
129
+ seal,
130
+ open,
131
+ async rotate(scope: CredentialScope, sealed: SealedCredential) {
132
+ const plaintext = await open(scope, sealed)
133
+ try {
134
+ return await seal(scope, plaintext)
135
+ } finally {
136
+ plaintext.fill(0)
137
+ }
138
+ },
139
+ })
140
+ }
141
+ export async function importCredentialKey(encoded: string): Promise<CryptoKey> {
142
+ try {
143
+ const raw = unbase64(encoded)
144
+ if (raw.byteLength !== 32) throw unreadable()
145
+ try {
146
+ return await crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
147
+ } finally {
148
+ raw.fill(0)
149
+ }
150
+ } catch {
151
+ throw new KitFailure('needs_config', 'A 256-bit credential secret is required')
152
+ }
153
+ }
package/src/files.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { badRequest, KitFailure } from '@ouraihub/aether-core'
2
+ import { ownerKey, type Actor } from './context.ts'
3
+ import type { R2Bucket } from './cloudflare.ts'
4
+
5
+ export interface StoredFile {
6
+ readonly id: string
7
+ readonly mime: string
8
+ readonly size: number
9
+ readonly data: Uint8Array
10
+ }
11
+ export interface FileStore {
12
+ put(actor: Actor, id: string, file: { readonly mime: string; readonly data: Uint8Array }): Promise<StoredFile>
13
+ get(actor: Actor, id: string): Promise<StoredFile | null>
14
+ remove(actor: Actor, id: string): Promise<void>
15
+ /** An authenticated application route, never an R2 public/presigned secret URL. */
16
+ url(actor: Actor, id: string): string
17
+ }
18
+ const MIME_TYPES: Readonly<Record<string, true>> = {
19
+ 'image/png': true,
20
+ 'image/jpeg': true,
21
+ 'image/webp': true,
22
+ 'image/gif': true,
23
+ 'application/json': true,
24
+ 'application/octet-stream': true,
25
+ }
26
+ function fileId(id: string): void {
27
+ if (!/^[A-Za-z0-9_-]{1,128}$/u.test(id)) throw badRequest('fileId', 'invalid')
28
+ }
29
+ function matchesMime(data: Uint8Array, mime: string): boolean {
30
+ if (mime === 'image/png')
31
+ return (
32
+ data[0] === 137 &&
33
+ data[1] === 80 &&
34
+ data[2] === 78 &&
35
+ data[3] === 71 &&
36
+ data[4] === 13 &&
37
+ data[5] === 10 &&
38
+ data[6] === 26 &&
39
+ data[7] === 10
40
+ )
41
+ if (mime === 'image/jpeg') return data[0] === 255 && data[1] === 216 && data[2] === 255
42
+ if (mime === 'image/gif')
43
+ return (
44
+ new TextDecoder().decode(data.subarray(0, 6)) === 'GIF87a' ||
45
+ new TextDecoder().decode(data.subarray(0, 6)) === 'GIF89a'
46
+ )
47
+ if (mime === 'image/webp')
48
+ return (
49
+ new TextDecoder().decode(data.subarray(0, 4)) === 'RIFF' &&
50
+ new TextDecoder().decode(data.subarray(8, 12)) === 'WEBP'
51
+ )
52
+ return mime === 'application/json' || mime === 'application/octet-stream'
53
+ }
54
+ export class R2FileStore implements FileStore {
55
+ readonly #bucket: R2Bucket
56
+ readonly #maxBytes: number
57
+ constructor(bucket: R2Bucket, options: { readonly maxBytes: number }) {
58
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0) throw badRequest('fileLimit', 'invalid')
59
+ this.#bucket = bucket
60
+ this.#maxBytes = options.maxBytes
61
+ }
62
+ async #key(actor: Actor, id: string): Promise<string> {
63
+ fileId(id)
64
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ownerKey(actor)))
65
+ const ownerHash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
66
+ return `aether/files/v1/${ownerHash}/${id}`
67
+ }
68
+ async put(actor: Actor, id: string, file: { readonly mime: string; readonly data: Uint8Array }): Promise<StoredFile> {
69
+ if (!MIME_TYPES[file.mime] || !matchesMime(file.data, file.mime)) throw badRequest('mime', 'unsupported')
70
+ if (file.data.byteLength === 0 || file.data.byteLength > this.#maxBytes)
71
+ throw badRequest('file', 'too_large', this.#maxBytes)
72
+ const key = await this.#key(actor, id)
73
+ const written = await this.#bucket.put(key, file.data, {
74
+ httpMetadata: { contentType: file.mime },
75
+ onlyIf: { etagDoesNotMatch: '*' },
76
+ })
77
+ if (written === null) throw new KitFailure('conflict', 'File already exists')
78
+ return { id, mime: file.mime, size: file.data.byteLength, data: file.data }
79
+ }
80
+ async get(actor: Actor, id: string): Promise<StoredFile | null> {
81
+ const object = await this.#bucket.get(await this.#key(actor, id))
82
+ if (!object) return null
83
+ const mime = object.httpMetadata?.contentType
84
+ if (
85
+ !mime ||
86
+ !MIME_TYPES[mime] ||
87
+ !Number.isSafeInteger(object.size) ||
88
+ object.size < 1 ||
89
+ object.size > this.#maxBytes
90
+ )
91
+ throw new KitFailure('internal', 'Stored file is unreadable')
92
+ const data = new Uint8Array(await object.arrayBuffer())
93
+ if (data.byteLength !== object.size || !matchesMime(data, mime))
94
+ throw new KitFailure('internal', 'Stored file is unreadable')
95
+ return { id, mime, size: object.size, data }
96
+ }
97
+ async remove(actor: Actor, id: string): Promise<void> {
98
+ await this.#bucket.delete(await this.#key(actor, id))
99
+ }
100
+ url(actor: Actor, id: string): string {
101
+ ownerKey(actor)
102
+ fileId(id)
103
+ return `/api/v1/files/${encodeURIComponent(id)}`
104
+ }
105
+ }
package/src/host.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { Approval, ModelRef, PermissionMode, RunRequest } from '@ouraihub/aether-contracts'
2
+ import type { AuthorizeContext, ToolCall, ToolExecution, ToolSpec } from '@ouraihub/aether-core/types'
3
+ import type { WorkerContext } from './context.js'
4
+
5
+ /** Business authorization remains the host's responsibility for every actor and resource. */
6
+ export interface WorkerHost {
7
+ selectModel(request: RunRequest, context: WorkerContext): Promise<ModelRef>
8
+ systemPrompt(request: RunRequest, context: WorkerContext): Promise<string | null>
9
+ context(request: RunRequest, context: WorkerContext): Promise<{ source: string; text: string } | null>
10
+ tools(request: RunRequest, runId: string, context: WorkerContext): Promise<readonly ToolSpec[]>
11
+ permissionModes(request: RunRequest, context: WorkerContext): readonly PermissionMode[]
12
+ authorize(call: ToolCall, authorization: AuthorizeContext, context: WorkerContext): Approval
13
+ callTool(call: ToolCall, signal: AbortSignal, context: WorkerContext): Promise<ToolExecution>
14
+ runFinished(runId: string, context: WorkerContext): Promise<void>
15
+ mcpTools?(request: RunRequest, context: WorkerContext): boolean
16
+ paintingModel?(request: RunRequest, context: WorkerContext): Promise<ModelRef | null>
17
+ }
@@ -0,0 +1,198 @@
1
+ import { badRequest, KitFailure } from '@ouraihub/aether-core'
2
+ import { immutableActor, isActor, ownerKey, type Actor } from './context.ts'
3
+ import type { DocumentStore, StoredDocument } from './storage.ts'
4
+
5
+ export interface IdentityPort {
6
+ resolve(request: Request): Promise<Actor | null>
7
+ verifyFresh(request: Request, actor: Actor): Promise<boolean>
8
+ }
9
+ /** Login, cookies, CSRF and reauthentication remain product-owned callbacks. */
10
+ export function createIdentityPort(options: {
11
+ readonly applicationId: string
12
+ readonly resolve: (request: Request) => Promise<Actor | null>
13
+ readonly verifyFresh: (request: Request, actor: Actor) => Promise<boolean>
14
+ }): IdentityPort {
15
+ if (!options.applicationId) throw new KitFailure('needs_config', 'Identity application is required')
16
+ return Object.freeze({
17
+ async resolve(request: Request): Promise<Actor | null> {
18
+ const actor = await options.resolve(request)
19
+ return isActor(actor) && actor.applicationId === options.applicationId ? immutableActor(actor) : null
20
+ },
21
+ async verifyFresh(request: Request, actor: Actor): Promise<boolean> {
22
+ if (!isActor(actor) || actor.applicationId !== options.applicationId) return false
23
+ const current = await options.resolve(request)
24
+ if (
25
+ !isActor(current) ||
26
+ ownerKey(current) !== ownerKey(actor) ||
27
+ current.sessionId !== actor.sessionId ||
28
+ current.kind !== actor.kind
29
+ )
30
+ return false
31
+ // Never cache this result: each reveal invokes the host's fresh verification.
32
+ return options.verifyFresh(request, immutableActor(current))
33
+ },
34
+ })
35
+ }
36
+ interface SessionRecord {
37
+ readonly actor: Actor
38
+ readonly createdAt: number
39
+ readonly lastSeenAt: number
40
+ readonly expiresAt: number
41
+ }
42
+ function parseSession(value: unknown): SessionRecord | null {
43
+ if (
44
+ typeof value !== 'object' ||
45
+ value === null ||
46
+ !('actor' in value) ||
47
+ !isActor(value.actor) ||
48
+ value.actor.kind !== 'user' ||
49
+ value.actor.sessionId === null ||
50
+ !('createdAt' in value) ||
51
+ typeof value.createdAt !== 'number' ||
52
+ !Number.isSafeInteger(value.createdAt) ||
53
+ !('lastSeenAt' in value) ||
54
+ typeof value.lastSeenAt !== 'number' ||
55
+ !Number.isSafeInteger(value.lastSeenAt) ||
56
+ !('expiresAt' in value) ||
57
+ typeof value.expiresAt !== 'number' ||
58
+ !Number.isSafeInteger(value.expiresAt) ||
59
+ value.createdAt < 0 ||
60
+ value.lastSeenAt < value.createdAt ||
61
+ value.expiresAt <= value.lastSeenAt
62
+ )
63
+ return null
64
+ return { actor: value.actor, createdAt: value.createdAt, lastSeenAt: value.lastSeenAt, expiresAt: value.expiresAt }
65
+ }
66
+ function opaqueToken(): string {
67
+ return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, '0')).join('')
68
+ }
69
+ async function tokenKey(token: string): Promise<string | null> {
70
+ if (!/^[a-f0-9]{64}$/u.test(token)) return null
71
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
72
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
73
+ }
74
+ /** Opaque browser sessions only; not OAuth bearer credentials or a login provider. */
75
+ export class OpaqueSessionStore {
76
+ readonly #store: DocumentStore
77
+ readonly #registry: Actor
78
+ readonly #idleMs: number
79
+ readonly #absoluteMs: number
80
+ readonly #now: () => number
81
+ constructor(options: {
82
+ readonly store: DocumentStore
83
+ readonly applicationId: string
84
+ readonly idleMs: number
85
+ readonly absoluteMs: number
86
+ readonly now?: () => number
87
+ }) {
88
+ if (
89
+ !options.applicationId ||
90
+ !Number.isSafeInteger(options.idleMs) ||
91
+ options.idleMs <= 0 ||
92
+ !Number.isSafeInteger(options.absoluteMs) ||
93
+ options.absoluteMs < options.idleMs
94
+ )
95
+ throw badRequest('session', 'invalid_configuration')
96
+ this.#store = options.store
97
+ this.#registry = immutableActor({
98
+ applicationId: options.applicationId,
99
+ tenantId: null,
100
+ subjectId: 'aether:session-registry',
101
+ kind: 'service',
102
+ sessionId: null,
103
+ scopes: [],
104
+ })
105
+ this.#idleMs = options.idleMs
106
+ this.#absoluteMs = options.absoluteMs
107
+ this.#now = options.now ?? Date.now
108
+ }
109
+ async issue(actor: Actor): Promise<{ readonly token: string; readonly actor: Actor; readonly expiresAt: number }> {
110
+ if (!isActor(actor) || actor.kind !== 'user' || actor.applicationId !== this.#registry.applicationId)
111
+ throw badRequest('actor', 'invalid_session_owner')
112
+ const token = opaqueToken()
113
+ const key = await tokenKey(token)
114
+ if (key === null) throw new KitFailure('internal', 'Session generation failed')
115
+ const now = this.#now()
116
+ const sessionActor = immutableActor({ ...actor, sessionId: crypto.randomUUID() })
117
+ const expiresAt = now + this.#absoluteMs
118
+ await this.#store.compareAndSet(this.#registry, 'opaque_sessions', key, null, {
119
+ actor: sessionActor,
120
+ createdAt: now,
121
+ lastSeenAt: now,
122
+ expiresAt,
123
+ })
124
+ return { token, actor: sessionActor, expiresAt }
125
+ }
126
+ async #load(
127
+ token: string,
128
+ ): Promise<{ readonly key: string; readonly document: StoredDocument; readonly session: SessionRecord } | null> {
129
+ const key = await tokenKey(token)
130
+ if (key === null) return null
131
+ const document = await this.#store.get(this.#registry, 'opaque_sessions', key)
132
+ if (!document) return null
133
+ const session = parseSession(document.value)
134
+ const now = this.#now()
135
+ if (
136
+ !session ||
137
+ session.actor.applicationId !== this.#registry.applicationId ||
138
+ now < session.lastSeenAt ||
139
+ now >= session.expiresAt ||
140
+ now - session.lastSeenAt >= this.#idleMs
141
+ )
142
+ return null
143
+ return { key, document, session }
144
+ }
145
+ async resolve(token: string): Promise<Actor | null> {
146
+ const found = await this.#load(token)
147
+ if (!found) return null
148
+ try {
149
+ await this.#store.compareAndSet(this.#registry, 'opaque_sessions', found.key, found.document.revision, {
150
+ ...found.session,
151
+ lastSeenAt: this.#now(),
152
+ })
153
+ return immutableActor(found.session.actor)
154
+ } catch (error) {
155
+ if (error instanceof KitFailure && error.code === 'conflict') return null
156
+ throw error
157
+ }
158
+ }
159
+ async revoke(token: string): Promise<void> {
160
+ const key = await tokenKey(token)
161
+ if (key === null) return
162
+ // Retry only metadata CAS, never authentication or external side effects.
163
+ for (let attempt = 0; attempt < 8; attempt++) {
164
+ const document = await this.#store.get(this.#registry, 'opaque_sessions', key)
165
+ if (!document) return
166
+ try {
167
+ await this.#store.remove(this.#registry, 'opaque_sessions', key, document.revision)
168
+ return
169
+ } catch (error) {
170
+ if (!(error instanceof KitFailure) || error.code !== 'conflict') throw error
171
+ }
172
+ }
173
+ throw new KitFailure('conflict', 'Session revocation conflicted')
174
+ }
175
+ async rotate(
176
+ token: string,
177
+ ): Promise<{ readonly token: string; readonly actor: Actor; readonly expiresAt: number } | null> {
178
+ const found = await this.#load(token)
179
+ if (!found) return null
180
+ try {
181
+ await this.#store.remove(this.#registry, 'opaque_sessions', found.key, found.document.revision)
182
+ } catch (error) {
183
+ if (error instanceof KitFailure && error.code === 'conflict') return null
184
+ throw error
185
+ }
186
+ // Old token is already invalid even if issuing the replacement fails.
187
+ const replacement = opaqueToken()
188
+ const key = await tokenKey(replacement)
189
+ if (key === null) throw new KitFailure('internal', 'Session generation failed')
190
+ const actor = immutableActor({ ...found.session.actor, sessionId: crypto.randomUUID() })
191
+ await this.#store.compareAndSet(this.#registry, 'opaque_sessions', key, null, {
192
+ ...found.session,
193
+ actor,
194
+ lastSeenAt: this.#now(),
195
+ })
196
+ return { token: replacement, actor, expiresAt: found.session.expiresAt }
197
+ }
198
+ }
@@ -0,0 +1,46 @@
1
+ import type { ImagePart, ImageRef } from '@ouraihub/aether-contracts'
2
+ import { generateImage, ImageFailure, type ImageConnection, type ImageRequest } from '@ouraihub/aether-core/images'
3
+ import type { OutboundHttp } from '@ouraihub/aether-core/ports'
4
+ import type { WorkerContext } from '../context.js'
5
+
6
+ export interface GeneratedImage {
7
+ readonly image: ImagePart
8
+ readonly reference: ImageRef
9
+ readonly persisted: boolean
10
+ }
11
+ export interface ImageServiceOptions {
12
+ readonly outbound: OutboundHttp
13
+ /** Host stores kind=generated under context.actor and returns a controlled display URL. */
14
+ readonly persist?: (image: ImagePart, context: WorkerContext) => Promise<ImageRef>
15
+ }
16
+
17
+ /** Explicit-input operation; runtime owns tool authorization, references and per-run limits. */
18
+ export class ImageService {
19
+ private readonly options: ImageServiceOptions
20
+ constructor(options: ImageServiceOptions) {
21
+ this.options = options
22
+ }
23
+
24
+ async generate(connection: ImageConnection, input: ImageRequest, context: WorkerContext): Promise<GeneratedImage> {
25
+ const image = await generateImage(this.options.outbound, connection, input, context.signal)
26
+ if (!this.options.persist) {
27
+ return {
28
+ image,
29
+ reference: { mime: image.mime, url: `data:${image.mime};base64,${image.data}` },
30
+ persisted: false,
31
+ }
32
+ }
33
+ try {
34
+ if (context.signal.aborted)
35
+ throw new ImageFailure('cancelled', 'Image generated; persistence was cancelled', 'generated')
36
+ const reference = await this.options.persist(image, context)
37
+ return { image, reference, persisted: true }
38
+ } catch {
39
+ // Generation succeeded. Do not claim the provider did nothing or that saving succeeded.
40
+ throw new ImageFailure('internal', 'Image generated but persistence could not be confirmed', 'generated', {
41
+ field: 'images',
42
+ reason: 'generated_save_failed',
43
+ })
44
+ }
45
+ }
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ export { createAetherWorkerApp } from './app.ts'
2
+ export type { WorkerAppOptions, WorkerHandler, PublicWorkerHandler } from './app.ts'
3
+ export { createRunCoordinator } from './coordinator.ts'
4
+ export { WorkerRuntime } from './runtime.ts'
5
+ export type { RuntimeOptions, PreparedExecution } from './runtime.ts'
6
+ export type { WorkerHost } from './host.ts'
7
+ export { immutableActor, isActor, ownerKey } from './context.ts'
8
+ export type { Actor, WorkerContext } from './context.ts'
9
+ export type * from './cloudflare.ts'
10
+ export { D1DocumentStore, validateD1Schema } from './storage.ts'
11
+ export type { DocumentStore, StoredDocument, DocumentListOptions } from './storage.ts'
12
+ export { createCredentialPort, importCredentialKey, isSealedCredential } from './credentials.ts'
13
+ export type { CredentialPort, CredentialScope, SealedCredential } from './credentials.ts'
14
+ export { createIdentityPort, OpaqueSessionStore } from './identity.ts'
15
+ export type { IdentityPort } from './identity.ts'
16
+ export { createWorkerOutbound, createWorkerTargetCheck } from './outbound.ts'
17
+ export type { WorkerOutboundOptions, OutboundLimits } from './outbound.ts'
18
+ export { R2FileStore } from './files.ts'
19
+ export type { FileStore, StoredFile } from './files.ts'
20
+ export { createLogger } from './logger.ts'
21
+ export type { Logger, LogRecord } from './logger.ts'
22
+ export { ProviderService } from './providers/index.ts'
23
+ export { SearchService } from './search/index.ts'
24
+ export { ImageService } from './images/index.ts'
25
+ export { SkillsService } from './skills/service.ts'
26
+ export { ConversationService } from './conversations/index.ts'
27
+ export type { AcceptedHandle } from './conversations/index.ts'
28
+ export { createMcpService } from './mcp/index.ts'
29
+ export type { McpService } from './mcp/index.ts'
30
+ export type { McpServiceOptions } from './mcp/types.ts'
package/src/logger.ts ADDED
@@ -0,0 +1,87 @@
1
+ export type LogEvent =
2
+ | 'request.finished'
3
+ | 'auth.rejected'
4
+ | 'session.revoked'
5
+ | 'mcp.grant.created'
6
+ | 'mcp.grant.revoked'
7
+ | 'mcp.api_key.created'
8
+ | 'mcp.api_key.revoked'
9
+ | 'tool.denied'
10
+ | 'run.finished'
11
+ | 'run.unknown'
12
+ | 'credential.decrypt_failed'
13
+ export type LogValue = string | number | boolean
14
+ export interface Logger {
15
+ log(event: LogEvent, fields: Readonly<Record<string, unknown>>): void
16
+ }
17
+ export interface LogRecord {
18
+ readonly event: LogEvent
19
+ readonly fields: Readonly<Record<string, LogValue>>
20
+ }
21
+ const EVENTS: Readonly<Record<LogEvent, true>> = {
22
+ 'request.finished': true,
23
+ 'auth.rejected': true,
24
+ 'session.revoked': true,
25
+ 'mcp.grant.created': true,
26
+ 'mcp.grant.revoked': true,
27
+ 'mcp.api_key.created': true,
28
+ 'mcp.api_key.revoked': true,
29
+ 'tool.denied': true,
30
+ 'run.finished': true,
31
+ 'run.unknown': true,
32
+ 'credential.decrypt_failed': true,
33
+ }
34
+ const IDENTIFIERS: Readonly<Record<string, true>> = {
35
+ requestId: true,
36
+ runId: true,
37
+ applicationId: true,
38
+ actorHash: true,
39
+ providerId: true,
40
+ endpointId: true,
41
+ modelId: true,
42
+ toolId: true,
43
+ errorCode: true,
44
+ }
45
+ const COUNTERS: Readonly<Record<string, true>> = {
46
+ status: true,
47
+ durationMs: true,
48
+ count: true,
49
+ eventCount: true,
50
+ revision: true,
51
+ rounds: true,
52
+ }
53
+ /** Pass only internal opaque identifiers, never prompt/header objects. Unknown fields are dropped.
54
+ * Route values must be registered templates, not request URLs or user-supplied paths. */
55
+ export function createLogger(options: {
56
+ readonly sink: (record: LogRecord) => void
57
+ readonly routes: readonly string[]
58
+ }): Logger {
59
+ const routes = new Set(options.routes)
60
+ for (const route of routes)
61
+ if (!/^\/[A-Za-z0-9_/:.{}-]*$/u.test(route) || route.length > 256) throw new Error('Invalid log route template')
62
+ return Object.freeze({
63
+ log(event: LogEvent, fields: Readonly<Record<string, unknown>>): void {
64
+ if (!Object.hasOwn(EVENTS, event)) throw new Error('Invalid log event')
65
+ const safe: Record<string, LogValue> = {}
66
+ for (const [key, value] of Object.entries(fields)) {
67
+ if (Object.hasOwn(IDENTIFIERS, key) && typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/u.test(value))
68
+ safe[key] = value
69
+ else if (Object.hasOwn(COUNTERS, key) && typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
70
+ safe[key] = value
71
+ else if (key === 'usageAvailable' && typeof value === 'boolean') safe[key] = value
72
+ else if (key === 'route' && typeof value === 'string' && routes.has(value)) safe[key] = value
73
+ else if (
74
+ key === 'state' &&
75
+ (value === 'accepted' ||
76
+ value === 'running' ||
77
+ value === 'completed' ||
78
+ value === 'failed' ||
79
+ value === 'cancelled' ||
80
+ value === 'unknown')
81
+ )
82
+ safe[key] = value
83
+ }
84
+ options.sink(Object.freeze({ event, fields: Object.freeze(safe) }))
85
+ },
86
+ })
87
+ }