@ouraihub/aether-worker 0.1.0 → 0.1.2

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,131 @@
1
+ import {
2
+ isContract,
3
+ type EndStatus,
4
+ type KitError,
5
+ type RunEvent,
6
+ type RunSnapshot,
7
+ } from '@ouraihub/aether-contracts'
8
+ import { KitFailure } from '@ouraihub/aether-core'
9
+ import { immutableActor, isActor, type Actor } from './context.js'
10
+ import type { AcceptedHandle } from './conversations/index.js'
11
+
12
+ export type Phase = 'creating' | 'accepted' | 'running' | EndStatus
13
+ export interface Metadata {
14
+ phase: Phase
15
+ runId: string
16
+ actor: Actor
17
+ handle: AcceptedHandle | null
18
+ snapshot: RunSnapshot
19
+ revision: number
20
+ acceptedUntil: number | null
21
+ pending: { callId: string; revision: number } | null
22
+ }
23
+ export interface Pending {
24
+ callId: string
25
+ revision: number
26
+ decision: boolean | undefined
27
+ resolve: ((decision: boolean) => void) | null
28
+ }
29
+
30
+ export const storageKey = 'run'
31
+ export const encoder = new TextEncoder()
32
+ export const conflict = (reason: string): KitFailure =>
33
+ new KitFailure('conflict', 'Run state conflict', { reason })
34
+ export const missing = (): KitFailure => new KitFailure('not_found', 'Run not found')
35
+ export const interrupted = (newId: () => string): KitError => ({
36
+ code: 'internal',
37
+ message: 'Run interrupted',
38
+ details: { reason: 'interrupted', errorId: newId() },
39
+ })
40
+
41
+ /** No arbitrary messages/details cross this boundary, even from host KitFailures. */
42
+ export function publicError(error: unknown, newId: () => string): KitError {
43
+ const code =
44
+ error instanceof KitFailure
45
+ ? error.code
46
+ : isContract('KitError', error)
47
+ ? error.code
48
+ : 'internal'
49
+ const messages: Record<KitError['code'], string> = {
50
+ bad_request: 'Invalid request',
51
+ not_found: 'Run not found',
52
+ conflict: 'Run state conflict',
53
+ unsupported: 'Unsupported operation',
54
+ cancelled: 'Cancelled',
55
+ unauthorized: 'Authentication required',
56
+ forbidden: 'Access denied',
57
+ rate_limited: 'Rate limit exceeded',
58
+ upstream: 'Upstream execution failed',
59
+ timeout: 'Execution timed out',
60
+ network: 'Network execution failed',
61
+ needs_config: 'Configuration required',
62
+ budget_exceeded: 'Execution budget exceeded',
63
+ incompatible_db: 'Incompatible database',
64
+ internal: 'Internal execution failure',
65
+ }
66
+ // Error IDs are generated here, never copied from a potentially secret-bearing host error.
67
+ const reason = error instanceof KitFailure ? error.details?.reason : undefined
68
+ const safeReason =
69
+ reason === 'event_replay_unavailable' ||
70
+ reason === 'subscriber_already_active' ||
71
+ reason === 'approval_not_pending'
72
+ ? reason
73
+ : undefined
74
+ return {
75
+ code,
76
+ message: messages[code],
77
+ ...(code === 'internal' || safeReason
78
+ ? {
79
+ details: {
80
+ ...(safeReason ? { reason: safeReason } : {}),
81
+ ...(code === 'internal' ? { errorId: newId() } : {}),
82
+ },
83
+ }
84
+ : {}),
85
+ }
86
+ }
87
+ export function statusCode(error: KitError): number {
88
+ switch (error.code) {
89
+ case 'bad_request':
90
+ return 400
91
+ case 'unauthorized':
92
+ return 401
93
+ case 'forbidden':
94
+ return 403
95
+ case 'not_found':
96
+ return 404
97
+ case 'conflict':
98
+ return 409
99
+ case 'rate_limited':
100
+ return 429
101
+ case 'internal':
102
+ case 'incompatible_db':
103
+ return 500
104
+ default:
105
+ return 422
106
+ }
107
+ }
108
+ export function actorHeader(request: Request): Actor {
109
+ try {
110
+ const encoded = request.headers.get('x-aether-actor')
111
+ if (!encoded || encoded.length > 131072) throw missing()
112
+ const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0))
113
+ const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
114
+ if (!isActor(value)) throw missing()
115
+ return immutableActor(value)
116
+ } catch {
117
+ throw missing()
118
+ }
119
+ }
120
+ export function terminal(phase: Phase): phase is EndStatus {
121
+ return phase === 'completed' || phase === 'failed' || phase === 'cancelled'
122
+ }
123
+ export function endEvent(snapshot: RunSnapshot): RunEvent {
124
+ if (snapshot.status === 'running') throw conflict('run_not_terminal')
125
+ return {
126
+ kind: 'end',
127
+ status: snapshot.status,
128
+ ...(snapshot.error ? { error: snapshot.error } : {}),
129
+ ...(snapshot.exhausted === undefined ? {} : { exhausted: snapshot.exhausted }),
130
+ }
131
+ }
@@ -1,125 +1,38 @@
1
- import { isContract, type EndStatus, type KitError, type RunEvent, type RunSnapshot } from '@ouraihub/aether-contracts'
1
+ import { isContract, type RunEvent, type RunSnapshot } from '@ouraihub/aether-contracts'
2
2
  import { KitFailure, type Approver } from '@ouraihub/aether-core'
3
3
  import type { DurableObjectState } from './cloudflare.js'
4
4
  import { immutableActor, isActor, ownerKey, type Actor, type WorkerContext } from './context.js'
5
- import type { AcceptedHandle } from './conversations/index.js'
6
5
  import type { PreparedExecution, WorkerRuntime } from './runtime.js'
6
+ import {
7
+ actorHeader,
8
+ conflict,
9
+ encoder,
10
+ endEvent,
11
+ interrupted,
12
+ missing,
13
+ publicError,
14
+ statusCode,
15
+ storageKey,
16
+ terminal,
17
+ type Metadata,
18
+ type Pending,
19
+ } from './coordinator-helpers.js'
7
20
 
8
21
  type Runtime = Pick<WorkerRuntime, 'prepare' | 'execute' | 'abandon' | 'recover'>
9
- type Phase = 'creating' | 'accepted' | 'running' | EndStatus
10
- interface Metadata {
11
- phase: Phase
12
- runId: string
13
- actor: Actor
14
- handle: AcceptedHandle | null
15
- snapshot: RunSnapshot
16
- revision: number
17
- acceptedUntil: number | null
18
- pending: { callId: string; revision: number } | null
19
- }
20
- interface Pending {
21
- callId: string
22
- revision: number
23
- decision: boolean | undefined
24
- resolve: ((decision: boolean) => void) | null
25
- }
26
- const storageKey = 'run'
27
- const encoder = new TextEncoder()
28
- const conflict = (reason: string): KitFailure => new KitFailure('conflict', 'Run state conflict', { reason })
29
- const missing = (): KitFailure => new KitFailure('not_found', 'Run not found')
30
- const interrupted = (): KitError => ({
31
- code: 'internal',
32
- message: 'Run interrupted',
33
- details: { reason: 'interrupted', errorId: crypto.randomUUID() },
34
- })
35
-
36
- /** No arbitrary messages/details cross this boundary, even from host KitFailures. */
37
- function publicError(error: unknown): KitError {
38
- const code = error instanceof KitFailure ? error.code : isContract('KitError', error) ? error.code : 'internal'
39
- const messages: Record<KitError['code'], string> = {
40
- bad_request: 'Invalid request',
41
- not_found: 'Run not found',
42
- conflict: 'Run state conflict',
43
- unsupported: 'Unsupported operation',
44
- cancelled: 'Cancelled',
45
- unauthorized: 'Authentication required',
46
- forbidden: 'Access denied',
47
- rate_limited: 'Rate limit exceeded',
48
- upstream: 'Upstream execution failed',
49
- timeout: 'Execution timed out',
50
- network: 'Network execution failed',
51
- needs_config: 'Configuration required',
52
- budget_exceeded: 'Execution budget exceeded',
53
- incompatible_db: 'Incompatible database',
54
- internal: 'Internal execution failure',
55
- }
56
- // Error IDs are generated here, never copied from a potentially secret-bearing host error.
57
- const reason = error instanceof KitFailure ? error.details?.reason : undefined
58
- const safeReason =
59
- reason === 'event_replay_unavailable' || reason === 'subscriber_already_active' || reason === 'approval_not_pending'
60
- ? reason
61
- : undefined
62
- return {
63
- code,
64
- message: messages[code],
65
- ...(code === 'internal' || safeReason
66
- ? {
67
- details: {
68
- ...(safeReason ? { reason: safeReason } : {}),
69
- ...(code === 'internal' ? { errorId: crypto.randomUUID() } : {}),
70
- },
71
- }
72
- : {}),
73
- }
74
- }
75
- function statusCode(error: KitError): number {
76
- switch (error.code) {
77
- case 'bad_request':
78
- return 400
79
- case 'unauthorized':
80
- return 401
81
- case 'forbidden':
82
- return 403
83
- case 'not_found':
84
- return 404
85
- case 'conflict':
86
- return 409
87
- case 'rate_limited':
88
- return 429
89
- case 'internal':
90
- case 'incompatible_db':
91
- return 500
92
- default:
93
- return 422
94
- }
95
- }
96
- function actorHeader(request: Request): Actor {
97
- try {
98
- const encoded = request.headers.get('x-aether-actor')
99
- if (!encoded || encoded.length > 131072) throw missing()
100
- const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0))
101
- const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
102
- if (!isActor(value)) throw missing()
103
- return immutableActor(value)
104
- } catch {
105
- throw missing()
106
- }
107
- }
108
- function terminal(phase: Phase): phase is EndStatus {
109
- return phase === 'completed' || phase === 'failed' || phase === 'cancelled'
110
- }
111
- function endEvent(snapshot: RunSnapshot): RunEvent {
112
- if (snapshot.status === 'running') throw conflict('run_not_terminal')
113
- return {
114
- kind: 'end',
115
- status: snapshot.status,
116
- ...(snapshot.error ? { error: snapshot.error } : {}),
117
- ...(snapshot.exhausted === undefined ? {} : { exhausted: snapshot.exhausted }),
118
- }
22
+ /** Time and identifiers the coordinator uses for leases, request ids and error ids. */
23
+ export interface CoordinatorOptions {
24
+ /** Milliseconds since the epoch; drives the 30 s subscription lease and alarms. */
25
+ readonly now?: () => number
26
+ readonly newId?: () => string
119
27
  }
120
28
 
121
29
  /** The router alone constructs this private protocol after authentication. */
122
- export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime) {
30
+ export function createRunCoordinator<Env>(
31
+ createRuntime: (env: Env) => Runtime,
32
+ options: CoordinatorOptions = {},
33
+ ) {
34
+ const now = options.now ?? Date.now
35
+ const newId = options.newId ?? (() => crypto.randomUUID())
123
36
  return class RunCoordinator {
124
37
  private readonly state: DurableObjectState
125
38
  private readonly runtime: Runtime
@@ -147,12 +60,16 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
147
60
  try {
148
61
  await this.runtime.recover(record.handle, this.context(record.actor))
149
62
  } catch (error) {
150
- if (!(record.phase === 'creating' && error instanceof KitFailure && error.code === 'not_found'))
63
+ if (!(
64
+ record.phase === 'creating' &&
65
+ error instanceof KitFailure &&
66
+ error.code === 'not_found'
67
+ ))
151
68
  recoveryFailure = error
152
69
  }
153
70
  }
154
71
  record.phase = 'failed'
155
- record.snapshot = { ...record.snapshot, status: 'failed', error: interrupted() }
72
+ record.snapshot = { ...record.snapshot, status: 'failed', error: interrupted(newId) }
156
73
  record.pending = null
157
74
  record.acceptedUntil = null
158
75
  await this.persist()
@@ -162,7 +79,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
162
79
  }
163
80
 
164
81
  private context(actor: Actor): WorkerContext {
165
- return { actor, signal: this.abort.signal, requestId: crypto.randomUUID(), now: Date.now() }
82
+ return { actor, signal: this.abort.signal, requestId: newId(), now: now() }
166
83
  }
167
84
  private serialize<T>(action: () => Promise<T>): Promise<T> {
168
85
  const result = this.queue.then(action)
@@ -189,9 +106,12 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
189
106
  if (!controller) return
190
107
  try {
191
108
  // Bound an unread subscriber rather than retaining an unbounded transcript in memory.
192
- if (controller.desiredSize !== null && controller.desiredSize <= 0) throw new Error('Subscriber stalled')
109
+ if (controller.desiredSize !== null && controller.desiredSize <= 0)
110
+ throw new Error('Subscriber stalled')
193
111
  controller.enqueue(
194
- encoder.encode(`event: run_event\nid: ${++this.sequence}\ndata: ${JSON.stringify(event)}\n\n`),
112
+ encoder.encode(
113
+ `event: run_event\nid: ${++this.sequence}\ndata: ${JSON.stringify(event)}\n\n`,
114
+ ),
195
115
  )
196
116
  } catch {
197
117
  this.close()
@@ -276,7 +196,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
276
196
  turnId: record.snapshot.turnId,
277
197
  status: snapshot.status,
278
198
  cancelRequested: record.snapshot.cancelRequested || snapshot.cancelRequested,
279
- ...(snapshot.error ? { error: publicError(snapshot.error) } : {}),
199
+ ...(snapshot.error ? { error: publicError(snapshot.error, newId) } : {}),
280
200
  ...(snapshot.exhausted === undefined ? {} : { exhausted: snapshot.exhausted }),
281
201
  }
282
202
  record.phase = snapshot.status
@@ -306,7 +226,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
306
226
  throw recoveryError
307
227
  }
308
228
  }
309
- await this.finish({ ...record.snapshot, status: 'failed', error: publicError(error) })
229
+ await this.finish({ ...record.snapshot, status: 'failed', error: publicError(error, newId) })
310
230
  }
311
231
  private async run(prepared: PreparedExecution, execute: boolean): Promise<void> {
312
232
  try {
@@ -369,7 +289,13 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
369
289
  revision: 0,
370
290
  pending: null,
371
291
  acceptedUntil: null,
372
- snapshot: { runId, conversationId: null, turnId: null, status: 'running', cancelRequested: false },
292
+ snapshot: {
293
+ runId,
294
+ conversationId: null,
295
+ turnId: null,
296
+ status: 'running',
297
+ cancelRequested: false,
298
+ },
373
299
  }
374
300
  await this.persist()
375
301
  })
@@ -383,7 +309,8 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
383
309
  try {
384
310
  const prepared = await this.runtime.prepare(runId, input, this.context(actor), (handle) =>
385
311
  this.serialize(async () => {
386
- if (!this.metadata || this.abort.signal.aborted) throw new KitFailure('cancelled', 'Cancelled')
312
+ if (!this.metadata || this.abort.signal.aborted)
313
+ throw new KitFailure('cancelled', 'Cancelled')
387
314
  this.metadata.handle = handle
388
315
  this.metadata.snapshot.conversationId = handle?.conversationId ?? null
389
316
  this.metadata.snapshot.turnId = handle?.turnId ?? null
@@ -401,7 +328,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
401
328
  this.metadata.phase = 'accepted'
402
329
  this.metadata.snapshot.conversationId = prepared.accepted.conversationId
403
330
  this.metadata.snapshot.turnId = prepared.accepted.turnId
404
- this.metadata.acceptedUntil = Date.now() + 30000
331
+ this.metadata.acceptedUntil = now() + 30000
405
332
  await this.state.storage.setAlarm(this.metadata.acceptedUntil)
406
333
  await this.persist()
407
334
  })
@@ -477,7 +404,8 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
477
404
  // A rejected competing subscriber must never cancel the actual owner stream.
478
405
  if (
479
406
  this.owns(actor) &&
480
- ((this.subscribed && !this.operation) || (this.metadata?.phase === 'accepted' && request.signal.aborted))
407
+ ((this.subscribed && !this.operation) ||
408
+ (this.metadata?.phase === 'accepted' && request.signal.aborted))
481
409
  ) {
482
410
  this.close()
483
411
  await this.cancelRun()
@@ -540,7 +468,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
540
468
  }
541
469
  throw missing()
542
470
  } catch (error) {
543
- const safe = publicError(error)
471
+ const safe = publicError(error, newId)
544
472
  return Response.json(safe, { status: statusCode(safe) })
545
473
  }
546
474
  }
@@ -548,7 +476,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
548
476
  await this.ready
549
477
  const record = this.metadata
550
478
  if (record?.phase === 'accepted' && record.acceptedUntil !== null) {
551
- if (Date.now() >= record.acceptedUntil) await this.cancelRun()
479
+ if (now() >= record.acceptedUntil) await this.cancelRun()
552
480
  else
553
481
  await this.serialize(async () => {
554
482
  if (this.metadata?.phase === 'accepted' && this.metadata.acceptedUntil !== null)
@@ -28,7 +28,8 @@ function base64(bytes: Uint8Array): string {
28
28
  return btoa(text)
29
29
  }
30
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()
31
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(text))
32
+ throw unreadable()
32
33
  const bytes = Uint8Array.from(atob(text), (character) => character.charCodeAt(0))
33
34
  if (base64(bytes) !== text) throw unreadable()
34
35
  return bytes
@@ -64,7 +65,15 @@ function aad(scope: CredentialScope, keyId: string): Uint8Array<ArrayBuffer> {
64
65
  )
65
66
  throw badRequest('credentialScope', 'invalid')
66
67
  return new TextEncoder().encode(
67
- JSON.stringify([1, keyId, scope.applicationId, scope.tenantId, scope.subjectId, scope.purpose, scope.recordId]),
68
+ JSON.stringify([
69
+ 1,
70
+ keyId,
71
+ scope.applicationId,
72
+ scope.tenantId,
73
+ scope.subjectId,
74
+ scope.purpose,
75
+ scope.recordId,
76
+ ]),
68
77
  )
69
78
  }
70
79
  /** Supply non-extractable AES-GCM keys from deployment secrets/KMS; old keys are read-only until explicit rotation. */
@@ -100,7 +109,12 @@ export function createCredentialPort(options: {
100
109
  key,
101
110
  new Uint8Array(value),
102
111
  )
103
- return { version: 1, keyId: currentKeyId, nonce: base64(nonce), ciphertext: base64(new Uint8Array(ciphertext)) }
112
+ return {
113
+ version: 1,
114
+ keyId: currentKeyId,
115
+ nonce: base64(nonce),
116
+ ciphertext: base64(new Uint8Array(ciphertext)),
117
+ }
104
118
  } catch {
105
119
  throw unreadable()
106
120
  }
@@ -112,7 +126,11 @@ export function createCredentialPort(options: {
112
126
  if (!key) throw unreadable()
113
127
  const nonce = unbase64(sealed.nonce)
114
128
  const ciphertext = unbase64(sealed.ciphertext)
115
- if (nonce.byteLength !== 12 || ciphertext.byteLength < 16 || ciphertext.byteLength > MAX_BYTES + 16)
129
+ if (
130
+ nonce.byteLength !== 12 ||
131
+ ciphertext.byteLength < 16 ||
132
+ ciphertext.byteLength > MAX_BYTES + 16
133
+ )
116
134
  throw unreadable()
117
135
  return new Uint8Array(
118
136
  await crypto.subtle.decrypt(
package/src/files.ts CHANGED
@@ -9,7 +9,11 @@ export interface StoredFile {
9
9
  readonly data: Uint8Array
10
10
  }
11
11
  export interface FileStore {
12
- put(actor: Actor, id: string, file: { readonly mime: string; readonly data: Uint8Array }): Promise<StoredFile>
12
+ put(
13
+ actor: Actor,
14
+ id: string,
15
+ file: { readonly mime: string; readonly data: Uint8Array },
16
+ ): Promise<StoredFile>
13
17
  get(actor: Actor, id: string): Promise<StoredFile | null>
14
18
  remove(actor: Actor, id: string): Promise<void>
15
19
  /** An authenticated application route, never an R2 public/presigned secret URL. */
@@ -55,18 +59,26 @@ export class R2FileStore implements FileStore {
55
59
  readonly #bucket: R2Bucket
56
60
  readonly #maxBytes: number
57
61
  constructor(bucket: R2Bucket, options: { readonly maxBytes: number }) {
58
- if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0) throw badRequest('fileLimit', 'invalid')
62
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0)
63
+ throw badRequest('fileLimit', 'invalid')
59
64
  this.#bucket = bucket
60
65
  this.#maxBytes = options.maxBytes
61
66
  }
62
67
  async #key(actor: Actor, id: string): Promise<string> {
63
68
  fileId(id)
64
69
  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('')
70
+ const ownerHash = Array.from(new Uint8Array(digest), (byte) =>
71
+ byte.toString(16).padStart(2, '0'),
72
+ ).join('')
66
73
  return `aether/files/v1/${ownerHash}/${id}`
67
74
  }
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')
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')
70
82
  if (file.data.byteLength === 0 || file.data.byteLength > this.#maxBytes)
71
83
  throw badRequest('file', 'too_large', this.#maxBytes)
72
84
  const key = await this.#key(actor, id)
package/src/host.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  import type { Approval, ModelRef, PermissionMode, RunRequest } from '@ouraihub/aether-contracts'
2
- import type { AuthorizeContext, ToolCall, ToolExecution, ToolSpec } from '@ouraihub/aether-core/types'
2
+ import type {
3
+ AuthorizeContext,
4
+ ToolCall,
5
+ ToolExecution,
6
+ ToolSpec,
7
+ } from '@ouraihub/aether-core/types'
3
8
  import type { WorkerContext } from './context.js'
4
9
 
5
10
  /** Business authorization remains the host's responsibility for every actor and resource. */
6
11
  export interface WorkerHost {
7
12
  selectModel(request: RunRequest, context: WorkerContext): Promise<ModelRef>
8
13
  systemPrompt(request: RunRequest, context: WorkerContext): Promise<string | null>
9
- context(request: RunRequest, context: WorkerContext): Promise<{ source: string; text: string } | null>
14
+ context(
15
+ request: RunRequest,
16
+ context: WorkerContext,
17
+ ): Promise<{ source: string; text: string } | null>
10
18
  tools(request: RunRequest, runId: string, context: WorkerContext): Promise<readonly ToolSpec[]>
11
19
  permissionModes(request: RunRequest, context: WorkerContext): readonly PermissionMode[]
12
20
  authorize(call: ToolCall, authorization: AuthorizeContext, context: WorkerContext): Approval
package/src/identity.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { badRequest, KitFailure } from '@ouraihub/aether-core'
2
2
  import { immutableActor, isActor, ownerKey, type Actor } from './context.ts'
3
+ import { retryCas } from './storage.ts'
3
4
  import type { DocumentStore, StoredDocument } from './storage.ts'
4
5
 
5
6
  export interface IdentityPort {
@@ -12,11 +13,14 @@ export function createIdentityPort(options: {
12
13
  readonly resolve: (request: Request) => Promise<Actor | null>
13
14
  readonly verifyFresh: (request: Request, actor: Actor) => Promise<boolean>
14
15
  }): IdentityPort {
15
- if (!options.applicationId) throw new KitFailure('needs_config', 'Identity application is required')
16
+ if (!options.applicationId)
17
+ throw new KitFailure('needs_config', 'Identity application is required')
16
18
  return Object.freeze({
17
19
  async resolve(request: Request): Promise<Actor | null> {
18
20
  const actor = await options.resolve(request)
19
- return isActor(actor) && actor.applicationId === options.applicationId ? immutableActor(actor) : null
21
+ return isActor(actor) && actor.applicationId === options.applicationId
22
+ ? immutableActor(actor)
23
+ : null
20
24
  },
21
25
  async verifyFresh(request: Request, actor: Actor): Promise<boolean> {
22
26
  if (!isActor(actor) || actor.applicationId !== options.applicationId) return false
@@ -61,10 +65,17 @@ function parseSession(value: unknown): SessionRecord | null {
61
65
  value.expiresAt <= value.lastSeenAt
62
66
  )
63
67
  return null
64
- return { actor: value.actor, createdAt: value.createdAt, lastSeenAt: value.lastSeenAt, expiresAt: value.expiresAt }
68
+ return {
69
+ actor: value.actor,
70
+ createdAt: value.createdAt,
71
+ lastSeenAt: value.lastSeenAt,
72
+ expiresAt: value.expiresAt,
73
+ }
65
74
  }
66
75
  function opaqueToken(): string {
67
- return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, '0')).join('')
76
+ return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
77
+ byte.toString(16).padStart(2, '0'),
78
+ ).join('')
68
79
  }
69
80
  async function tokenKey(token: string): Promise<string | null> {
70
81
  if (!/^[a-f0-9]{64}$/u.test(token)) return null
@@ -106,8 +117,14 @@ export class OpaqueSessionStore {
106
117
  this.#absoluteMs = options.absoluteMs
107
118
  this.#now = options.now ?? Date.now
108
119
  }
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)
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
+ )
111
128
  throw badRequest('actor', 'invalid_session_owner')
112
129
  const token = opaqueToken()
113
130
  const key = await tokenKey(token)
@@ -123,9 +140,11 @@ export class OpaqueSessionStore {
123
140
  })
124
141
  return { token, actor: sessionActor, expiresAt }
125
142
  }
126
- async #load(
127
- token: string,
128
- ): Promise<{ readonly key: string; readonly document: StoredDocument; readonly session: SessionRecord } | null> {
143
+ async #load(token: string): Promise<{
144
+ readonly key: string
145
+ readonly document: StoredDocument
146
+ readonly session: SessionRecord
147
+ } | null> {
129
148
  const key = await tokenKey(token)
130
149
  if (key === null) return null
131
150
  const document = await this.#store.get(this.#registry, 'opaque_sessions', key)
@@ -146,10 +165,16 @@ export class OpaqueSessionStore {
146
165
  const found = await this.#load(token)
147
166
  if (!found) return null
148
167
  try {
149
- await this.#store.compareAndSet(this.#registry, 'opaque_sessions', found.key, found.document.revision, {
150
- ...found.session,
151
- lastSeenAt: this.#now(),
152
- })
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
+ )
153
178
  return immutableActor(found.session.actor)
154
179
  } catch (error) {
155
180
  if (error instanceof KitFailure && error.code === 'conflict') return null
@@ -160,17 +185,16 @@ export class OpaqueSessionStore {
160
185
  const key = await tokenKey(token)
161
186
  if (key === null) return
162
187
  // 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')
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
+ )
174
198
  }
175
199
  async rotate(
176
200
  token: string,
@@ -178,7 +202,12 @@ export class OpaqueSessionStore {
178
202
  const found = await this.#load(token)
179
203
  if (!found) return null
180
204
  try {
181
- await this.#store.remove(this.#registry, 'opaque_sessions', found.key, found.document.revision)
205
+ await this.#store.remove(
206
+ this.#registry,
207
+ 'opaque_sessions',
208
+ found.key,
209
+ found.document.revision,
210
+ )
182
211
  } catch (error) {
183
212
  if (error instanceof KitFailure && error.code === 'conflict') return null
184
213
  throw error