@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.
- package/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +37 -3
- package/src/app.ts +327 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +49 -0
- package/src/conversations/action.ts +234 -0
- package/src/conversations/index.ts +477 -0
- package/src/conversations/model.ts +402 -0
- package/src/coordinator.ts +560 -0
- package/src/credentials.ts +153 -0
- package/src/files.ts +105 -0
- package/src/host.ts +17 -0
- package/src/identity.ts +198 -0
- package/src/images/index.ts +46 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +87 -0
- package/src/mcp/auth.ts +350 -0
- package/src/mcp/client.ts +426 -0
- package/src/mcp/common.ts +120 -0
- package/src/mcp/index.ts +239 -0
- package/src/mcp/oauth.ts +284 -0
- package/src/mcp/types.ts +74 -0
- package/src/outbound.ts +305 -0
- package/src/providers/index.ts +505 -0
- package/src/runtime.ts +635 -0
- package/src/search/index.ts +431 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +140 -0
- package/src/skills/service.ts +203 -0
- package/src/storage.ts +198 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -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
|
+
}
|