@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/src/files.ts ADDED
@@ -0,0 +1,117 @@
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(
13
+ actor: Actor,
14
+ id: string,
15
+ file: { readonly mime: string; readonly data: Uint8Array },
16
+ ): Promise<StoredFile>
17
+ get(actor: Actor, id: string): Promise<StoredFile | null>
18
+ remove(actor: Actor, id: string): Promise<void>
19
+ /** An authenticated application route, never an R2 public/presigned secret URL. */
20
+ url(actor: Actor, id: string): string
21
+ }
22
+ const MIME_TYPES: Readonly<Record<string, true>> = {
23
+ 'image/png': true,
24
+ 'image/jpeg': true,
25
+ 'image/webp': true,
26
+ 'image/gif': true,
27
+ 'application/json': true,
28
+ 'application/octet-stream': true,
29
+ }
30
+ function fileId(id: string): void {
31
+ if (!/^[A-Za-z0-9_-]{1,128}$/u.test(id)) throw badRequest('fileId', 'invalid')
32
+ }
33
+ function matchesMime(data: Uint8Array, mime: string): boolean {
34
+ if (mime === 'image/png')
35
+ return (
36
+ data[0] === 137 &&
37
+ data[1] === 80 &&
38
+ data[2] === 78 &&
39
+ data[3] === 71 &&
40
+ data[4] === 13 &&
41
+ data[5] === 10 &&
42
+ data[6] === 26 &&
43
+ data[7] === 10
44
+ )
45
+ if (mime === 'image/jpeg') return data[0] === 255 && data[1] === 216 && data[2] === 255
46
+ if (mime === 'image/gif')
47
+ return (
48
+ new TextDecoder().decode(data.subarray(0, 6)) === 'GIF87a' ||
49
+ new TextDecoder().decode(data.subarray(0, 6)) === 'GIF89a'
50
+ )
51
+ if (mime === 'image/webp')
52
+ return (
53
+ new TextDecoder().decode(data.subarray(0, 4)) === 'RIFF' &&
54
+ new TextDecoder().decode(data.subarray(8, 12)) === 'WEBP'
55
+ )
56
+ return mime === 'application/json' || mime === 'application/octet-stream'
57
+ }
58
+ export class R2FileStore implements FileStore {
59
+ readonly #bucket: R2Bucket
60
+ readonly #maxBytes: number
61
+ constructor(bucket: R2Bucket, options: { readonly maxBytes: number }) {
62
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0)
63
+ throw badRequest('fileLimit', 'invalid')
64
+ this.#bucket = bucket
65
+ this.#maxBytes = options.maxBytes
66
+ }
67
+ async #key(actor: Actor, id: string): Promise<string> {
68
+ fileId(id)
69
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ownerKey(actor)))
70
+ const ownerHash = Array.from(new Uint8Array(digest), (byte) =>
71
+ byte.toString(16).padStart(2, '0'),
72
+ ).join('')
73
+ return `aether/files/v1/${ownerHash}/${id}`
74
+ }
75
+ async put(
76
+ actor: Actor,
77
+ id: string,
78
+ file: { readonly mime: string; readonly data: Uint8Array },
79
+ ): Promise<StoredFile> {
80
+ if (!MIME_TYPES[file.mime] || !matchesMime(file.data, file.mime))
81
+ throw badRequest('mime', 'unsupported')
82
+ if (file.data.byteLength === 0 || file.data.byteLength > this.#maxBytes)
83
+ throw badRequest('file', 'too_large', this.#maxBytes)
84
+ const key = await this.#key(actor, id)
85
+ const written = await this.#bucket.put(key, file.data, {
86
+ httpMetadata: { contentType: file.mime },
87
+ onlyIf: { etagDoesNotMatch: '*' },
88
+ })
89
+ if (written === null) throw new KitFailure('conflict', 'File already exists')
90
+ return { id, mime: file.mime, size: file.data.byteLength, data: file.data }
91
+ }
92
+ async get(actor: Actor, id: string): Promise<StoredFile | null> {
93
+ const object = await this.#bucket.get(await this.#key(actor, id))
94
+ if (!object) return null
95
+ const mime = object.httpMetadata?.contentType
96
+ if (
97
+ !mime ||
98
+ !MIME_TYPES[mime] ||
99
+ !Number.isSafeInteger(object.size) ||
100
+ object.size < 1 ||
101
+ object.size > this.#maxBytes
102
+ )
103
+ throw new KitFailure('internal', 'Stored file is unreadable')
104
+ const data = new Uint8Array(await object.arrayBuffer())
105
+ if (data.byteLength !== object.size || !matchesMime(data, mime))
106
+ throw new KitFailure('internal', 'Stored file is unreadable')
107
+ return { id, mime, size: object.size, data }
108
+ }
109
+ async remove(actor: Actor, id: string): Promise<void> {
110
+ await this.#bucket.delete(await this.#key(actor, id))
111
+ }
112
+ url(actor: Actor, id: string): string {
113
+ ownerKey(actor)
114
+ fileId(id)
115
+ return `/api/v1/files/${encodeURIComponent(id)}`
116
+ }
117
+ }
package/src/host.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Approval, ModelRef, PermissionMode, RunRequest } from '@ouraihub/aether-contracts'
2
+ import type {
3
+ AuthorizeContext,
4
+ ToolCall,
5
+ ToolExecution,
6
+ ToolSpec,
7
+ } from '@ouraihub/aether-core/types'
8
+ import type { WorkerContext } from './context.js'
9
+
10
+ /** Business authorization remains the host's responsibility for every actor and resource. */
11
+ export interface WorkerHost {
12
+ selectModel(request: RunRequest, context: WorkerContext): Promise<ModelRef>
13
+ systemPrompt(request: RunRequest, context: WorkerContext): Promise<string | null>
14
+ context(
15
+ request: RunRequest,
16
+ context: WorkerContext,
17
+ ): Promise<{ source: string; text: string } | null>
18
+ tools(request: RunRequest, runId: string, context: WorkerContext): Promise<readonly ToolSpec[]>
19
+ permissionModes(request: RunRequest, context: WorkerContext): readonly PermissionMode[]
20
+ authorize(call: ToolCall, authorization: AuthorizeContext, context: WorkerContext): Approval
21
+ callTool(call: ToolCall, signal: AbortSignal, context: WorkerContext): Promise<ToolExecution>
22
+ runFinished(runId: string, context: WorkerContext): Promise<void>
23
+ mcpTools?(request: RunRequest, context: WorkerContext): boolean
24
+ paintingModel?(request: RunRequest, context: WorkerContext): Promise<ModelRef | null>
25
+ }
@@ -0,0 +1,227 @@
1
+ import { badRequest, KitFailure } from '@ouraihub/aether-core'
2
+ import { immutableActor, isActor, ownerKey, type Actor } from './context.ts'
3
+ import { retryCas } from './storage.ts'
4
+ import type { DocumentStore, StoredDocument } from './storage.ts'
5
+
6
+ export interface IdentityPort {
7
+ resolve(request: Request): Promise<Actor | null>
8
+ verifyFresh(request: Request, actor: Actor): Promise<boolean>
9
+ }
10
+ /** Login, cookies, CSRF and reauthentication remain product-owned callbacks. */
11
+ export function createIdentityPort(options: {
12
+ readonly applicationId: string
13
+ readonly resolve: (request: Request) => Promise<Actor | null>
14
+ readonly verifyFresh: (request: Request, actor: Actor) => Promise<boolean>
15
+ }): IdentityPort {
16
+ if (!options.applicationId)
17
+ throw new KitFailure('needs_config', 'Identity application is required')
18
+ return Object.freeze({
19
+ async resolve(request: Request): Promise<Actor | null> {
20
+ const actor = await options.resolve(request)
21
+ return isActor(actor) && actor.applicationId === options.applicationId
22
+ ? immutableActor(actor)
23
+ : null
24
+ },
25
+ async verifyFresh(request: Request, actor: Actor): Promise<boolean> {
26
+ if (!isActor(actor) || actor.applicationId !== options.applicationId) return false
27
+ const current = await options.resolve(request)
28
+ if (
29
+ !isActor(current) ||
30
+ ownerKey(current) !== ownerKey(actor) ||
31
+ current.sessionId !== actor.sessionId ||
32
+ current.kind !== actor.kind
33
+ )
34
+ return false
35
+ // Never cache this result: each reveal invokes the host's fresh verification.
36
+ return options.verifyFresh(request, immutableActor(current))
37
+ },
38
+ })
39
+ }
40
+ interface SessionRecord {
41
+ readonly actor: Actor
42
+ readonly createdAt: number
43
+ readonly lastSeenAt: number
44
+ readonly expiresAt: number
45
+ }
46
+ function parseSession(value: unknown): SessionRecord | null {
47
+ if (
48
+ typeof value !== 'object' ||
49
+ value === null ||
50
+ !('actor' in value) ||
51
+ !isActor(value.actor) ||
52
+ value.actor.kind !== 'user' ||
53
+ value.actor.sessionId === null ||
54
+ !('createdAt' in value) ||
55
+ typeof value.createdAt !== 'number' ||
56
+ !Number.isSafeInteger(value.createdAt) ||
57
+ !('lastSeenAt' in value) ||
58
+ typeof value.lastSeenAt !== 'number' ||
59
+ !Number.isSafeInteger(value.lastSeenAt) ||
60
+ !('expiresAt' in value) ||
61
+ typeof value.expiresAt !== 'number' ||
62
+ !Number.isSafeInteger(value.expiresAt) ||
63
+ value.createdAt < 0 ||
64
+ value.lastSeenAt < value.createdAt ||
65
+ value.expiresAt <= value.lastSeenAt
66
+ )
67
+ return null
68
+ return {
69
+ actor: value.actor,
70
+ createdAt: value.createdAt,
71
+ lastSeenAt: value.lastSeenAt,
72
+ expiresAt: value.expiresAt,
73
+ }
74
+ }
75
+ function opaqueToken(): string {
76
+ return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
77
+ byte.toString(16).padStart(2, '0'),
78
+ ).join('')
79
+ }
80
+ async function tokenKey(token: string): Promise<string | null> {
81
+ if (!/^[a-f0-9]{64}$/u.test(token)) return null
82
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
83
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
84
+ }
85
+ /** Opaque browser sessions only; not OAuth bearer credentials or a login provider. */
86
+ export class OpaqueSessionStore {
87
+ readonly #store: DocumentStore
88
+ readonly #registry: Actor
89
+ readonly #idleMs: number
90
+ readonly #absoluteMs: number
91
+ readonly #now: () => number
92
+ constructor(options: {
93
+ readonly store: DocumentStore
94
+ readonly applicationId: string
95
+ readonly idleMs: number
96
+ readonly absoluteMs: number
97
+ readonly now?: () => number
98
+ }) {
99
+ if (
100
+ !options.applicationId ||
101
+ !Number.isSafeInteger(options.idleMs) ||
102
+ options.idleMs <= 0 ||
103
+ !Number.isSafeInteger(options.absoluteMs) ||
104
+ options.absoluteMs < options.idleMs
105
+ )
106
+ throw badRequest('session', 'invalid_configuration')
107
+ this.#store = options.store
108
+ this.#registry = immutableActor({
109
+ applicationId: options.applicationId,
110
+ tenantId: null,
111
+ subjectId: 'aether:session-registry',
112
+ kind: 'service',
113
+ sessionId: null,
114
+ scopes: [],
115
+ })
116
+ this.#idleMs = options.idleMs
117
+ this.#absoluteMs = options.absoluteMs
118
+ this.#now = options.now ?? Date.now
119
+ }
120
+ async issue(
121
+ actor: Actor,
122
+ ): Promise<{ readonly token: string; readonly actor: Actor; readonly expiresAt: number }> {
123
+ if (
124
+ !isActor(actor) ||
125
+ actor.kind !== 'user' ||
126
+ actor.applicationId !== this.#registry.applicationId
127
+ )
128
+ throw badRequest('actor', 'invalid_session_owner')
129
+ const token = opaqueToken()
130
+ const key = await tokenKey(token)
131
+ if (key === null) throw new KitFailure('internal', 'Session generation failed')
132
+ const now = this.#now()
133
+ const sessionActor = immutableActor({ ...actor, sessionId: crypto.randomUUID() })
134
+ const expiresAt = now + this.#absoluteMs
135
+ await this.#store.compareAndSet(this.#registry, 'opaque_sessions', key, null, {
136
+ actor: sessionActor,
137
+ createdAt: now,
138
+ lastSeenAt: now,
139
+ expiresAt,
140
+ })
141
+ return { token, actor: sessionActor, expiresAt }
142
+ }
143
+ async #load(token: string): Promise<{
144
+ readonly key: string
145
+ readonly document: StoredDocument
146
+ readonly session: SessionRecord
147
+ } | null> {
148
+ const key = await tokenKey(token)
149
+ if (key === null) return null
150
+ const document = await this.#store.get(this.#registry, 'opaque_sessions', key)
151
+ if (!document) return null
152
+ const session = parseSession(document.value)
153
+ const now = this.#now()
154
+ if (
155
+ !session ||
156
+ session.actor.applicationId !== this.#registry.applicationId ||
157
+ now < session.lastSeenAt ||
158
+ now >= session.expiresAt ||
159
+ now - session.lastSeenAt >= this.#idleMs
160
+ )
161
+ return null
162
+ return { key, document, session }
163
+ }
164
+ async resolve(token: string): Promise<Actor | null> {
165
+ const found = await this.#load(token)
166
+ if (!found) return null
167
+ try {
168
+ await this.#store.compareAndSet(
169
+ this.#registry,
170
+ 'opaque_sessions',
171
+ found.key,
172
+ found.document.revision,
173
+ {
174
+ ...found.session,
175
+ lastSeenAt: this.#now(),
176
+ },
177
+ )
178
+ return immutableActor(found.session.actor)
179
+ } catch (error) {
180
+ if (error instanceof KitFailure && error.code === 'conflict') return null
181
+ throw error
182
+ }
183
+ }
184
+ async revoke(token: string): Promise<void> {
185
+ const key = await tokenKey(token)
186
+ if (key === null) return
187
+ // Retry only metadata CAS, never authentication or external side effects.
188
+ await retryCas(
189
+ async (cas) => {
190
+ const document = await this.#store.get(this.#registry, 'opaque_sessions', key)
191
+ if (document)
192
+ await cas(() =>
193
+ this.#store.remove(this.#registry, 'opaque_sessions', key, document.revision),
194
+ )
195
+ },
196
+ () => new KitFailure('conflict', 'Session revocation conflicted'),
197
+ )
198
+ }
199
+ async rotate(
200
+ token: string,
201
+ ): Promise<{ readonly token: string; readonly actor: Actor; readonly expiresAt: number } | null> {
202
+ const found = await this.#load(token)
203
+ if (!found) return null
204
+ try {
205
+ await this.#store.remove(
206
+ this.#registry,
207
+ 'opaque_sessions',
208
+ found.key,
209
+ found.document.revision,
210
+ )
211
+ } catch (error) {
212
+ if (error instanceof KitFailure && error.code === 'conflict') return null
213
+ throw error
214
+ }
215
+ // Old token is already invalid even if issuing the replacement fails.
216
+ const replacement = opaqueToken()
217
+ const key = await tokenKey(replacement)
218
+ if (key === null) throw new KitFailure('internal', 'Session generation failed')
219
+ const actor = immutableActor({ ...found.session.actor, sessionId: crypto.randomUUID() })
220
+ await this.#store.compareAndSet(this.#registry, 'opaque_sessions', key, null, {
221
+ ...found.session,
222
+ actor,
223
+ lastSeenAt: this.#now(),
224
+ })
225
+ return { token: replacement, actor, expiresAt: found.session.expiresAt }
226
+ }
227
+ }
@@ -0,0 +1,73 @@
1
+ import type { ImagePart, ImageRef } from '@ouraihub/aether-contracts'
2
+ import {
3
+ generateImage,
4
+ ImageFailure,
5
+ type ImageConnection,
6
+ type ImageRequest,
7
+ type ImageGeneration,
8
+ type ImageLimits,
9
+ } from '@ouraihub/aether-core/images'
10
+ import type { OutboundHttp } from '@ouraihub/aether-core/ports'
11
+ import type { WorkerContext } from '../context.js'
12
+
13
+ export interface GeneratedImage extends ImageGeneration {
14
+ readonly reference: ImageRef
15
+ readonly persisted: boolean
16
+ }
17
+ export interface ImageServiceOptions {
18
+ readonly outbound: OutboundHttp
19
+ readonly limits?: ImageLimits
20
+ /** Host stores kind=generated under context.actor and returns a controlled display URL. */
21
+ readonly persist?: (image: ImagePart, context: WorkerContext) => Promise<ImageRef>
22
+ }
23
+
24
+ /** Explicit-input operation; runtime owns tool authorization, references and per-run limits. */
25
+ export class ImageService {
26
+ private readonly options: ImageServiceOptions
27
+ constructor(options: ImageServiceOptions) {
28
+ this.options = options
29
+ }
30
+
31
+ async generate(
32
+ connection: ImageConnection,
33
+ input: ImageRequest,
34
+ context: WorkerContext,
35
+ ): Promise<GeneratedImage> {
36
+ const result = await generateImage(
37
+ this.options.outbound,
38
+ connection,
39
+ input,
40
+ context.signal,
41
+ this.options.limits,
42
+ )
43
+ const { image } = result
44
+ if (!this.options.persist) {
45
+ return {
46
+ ...result,
47
+ reference: { mime: image.mime, url: `data:${image.mime};base64,${image.data}` },
48
+ persisted: false,
49
+ }
50
+ }
51
+ try {
52
+ if (context.signal.aborted)
53
+ throw new ImageFailure(
54
+ 'cancelled',
55
+ 'Image generated; persistence was cancelled',
56
+ 'generated',
57
+ )
58
+ const reference = await this.options.persist(image, context)
59
+ return { ...result, reference, persisted: true }
60
+ } catch {
61
+ // Generation succeeded. Do not claim the provider did nothing or that saving succeeded.
62
+ throw new ImageFailure(
63
+ 'internal',
64
+ 'Image generated but persistence could not be confirmed',
65
+ 'generated',
66
+ {
67
+ field: 'images',
68
+ reason: 'generated_save_failed',
69
+ },
70
+ )
71
+ }
72
+ }
73
+ }
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,98 @@
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)
62
+ throw new Error('Invalid log route template')
63
+ return Object.freeze({
64
+ log(event: LogEvent, fields: Readonly<Record<string, unknown>>): void {
65
+ if (!Object.hasOwn(EVENTS, event)) throw new Error('Invalid log event')
66
+ const safe: Record<string, LogValue> = {}
67
+ for (const [key, value] of Object.entries(fields)) {
68
+ if (
69
+ Object.hasOwn(IDENTIFIERS, key) &&
70
+ typeof value === 'string' &&
71
+ /^[A-Za-z0-9_.:-]{1,128}$/u.test(value)
72
+ )
73
+ safe[key] = value
74
+ else if (
75
+ Object.hasOwn(COUNTERS, key) &&
76
+ typeof value === 'number' &&
77
+ Number.isSafeInteger(value) &&
78
+ value >= 0
79
+ )
80
+ safe[key] = value
81
+ else if (key === 'usageAvailable' && typeof value === 'boolean') safe[key] = value
82
+ else if (key === 'route' && typeof value === 'string' && routes.has(value))
83
+ safe[key] = value
84
+ else if (
85
+ key === 'state' &&
86
+ (value === 'accepted' ||
87
+ value === 'running' ||
88
+ value === 'completed' ||
89
+ value === 'failed' ||
90
+ value === 'cancelled' ||
91
+ value === 'unknown')
92
+ )
93
+ safe[key] = value
94
+ }
95
+ options.sink(Object.freeze({ event, fields: Object.freeze(safe) }))
96
+ },
97
+ })
98
+ }