@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/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +38 -3
- package/src/app.ts +363 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +55 -0
- package/src/conversations/action.ts +257 -0
- package/src/conversations/index.ts +480 -0
- package/src/conversations/model.ts +427 -0
- package/src/conversations/routes.ts +101 -0
- package/src/coordinator-helpers.ts +131 -0
- package/src/coordinator.ts +488 -0
- package/src/credentials.ts +171 -0
- package/src/files.ts +117 -0
- package/src/host.ts +25 -0
- package/src/identity.ts +227 -0
- package/src/images/index.ts +73 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +98 -0
- package/src/mcp/auth.ts +398 -0
- package/src/mcp/client.ts +491 -0
- package/src/mcp/common.ts +124 -0
- package/src/mcp/index.ts +292 -0
- package/src/mcp/oauth.ts +343 -0
- package/src/mcp/types.ts +81 -0
- package/src/outbound.ts +320 -0
- package/src/providers/catalog.ts +63 -0
- package/src/providers/http.ts +88 -0
- package/src/providers/index.ts +445 -0
- package/src/providers/model-options.ts +28 -0
- package/src/runtime-input.ts +44 -0
- package/src/runtime-tools.ts +298 -0
- package/src/runtime.ts +476 -0
- package/src/search/index.ts +497 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +153 -0
- package/src/skills/service.ts +250 -0
- package/src/storage.ts +293 -0
- package/src/testing/fakes.ts +97 -0
- package/README.md +0 -3
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { isContract, type RunEvent, type RunSnapshot } from '@ouraihub/aether-contracts'
|
|
2
|
+
import { KitFailure, type Approver } from '@ouraihub/aether-core'
|
|
3
|
+
import type { DurableObjectState } from './cloudflare.js'
|
|
4
|
+
import { immutableActor, isActor, ownerKey, type Actor, type WorkerContext } from './context.js'
|
|
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'
|
|
20
|
+
|
|
21
|
+
type Runtime = Pick<WorkerRuntime, 'prepare' | 'execute' | 'abandon' | 'recover'>
|
|
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
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The router alone constructs this private protocol after authentication. */
|
|
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())
|
|
36
|
+
return class RunCoordinator {
|
|
37
|
+
private readonly state: DurableObjectState
|
|
38
|
+
private readonly runtime: Runtime
|
|
39
|
+
private readonly ready: Promise<void>
|
|
40
|
+
private queue: Promise<void> = Promise.resolve()
|
|
41
|
+
private metadata: Metadata | null = null
|
|
42
|
+
private prepared: PreparedExecution | null = null
|
|
43
|
+
private readonly abort = new AbortController()
|
|
44
|
+
private operation: Promise<void> | null = null
|
|
45
|
+
private pending: Pending | null = null
|
|
46
|
+
private controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
47
|
+
private detach: (() => void) | null = null
|
|
48
|
+
private sequence = 0
|
|
49
|
+
private subscribed = false
|
|
50
|
+
|
|
51
|
+
constructor(state: DurableObjectState, env: Env) {
|
|
52
|
+
this.state = state
|
|
53
|
+
this.runtime = createRuntime(env)
|
|
54
|
+
this.ready = state.blockConcurrencyWhile(async () => {
|
|
55
|
+
this.metadata = (await state.storage.get<Metadata>(storageKey)) ?? null
|
|
56
|
+
const record = this.metadata
|
|
57
|
+
if (!record || terminal(record.phase)) return
|
|
58
|
+
let recoveryFailure: unknown
|
|
59
|
+
if (record.handle) {
|
|
60
|
+
try {
|
|
61
|
+
await this.runtime.recover(record.handle, this.context(record.actor))
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (!(
|
|
64
|
+
record.phase === 'creating' &&
|
|
65
|
+
error instanceof KitFailure &&
|
|
66
|
+
error.code === 'not_found'
|
|
67
|
+
))
|
|
68
|
+
recoveryFailure = error
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
record.phase = 'failed'
|
|
72
|
+
record.snapshot = { ...record.snapshot, status: 'failed', error: interrupted(newId) }
|
|
73
|
+
record.pending = null
|
|
74
|
+
record.acceptedUntil = null
|
|
75
|
+
await this.persist()
|
|
76
|
+
await state.storage.deleteAlarm()
|
|
77
|
+
if (recoveryFailure !== undefined) throw recoveryFailure
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private context(actor: Actor): WorkerContext {
|
|
82
|
+
return { actor, signal: this.abort.signal, requestId: newId(), now: now() }
|
|
83
|
+
}
|
|
84
|
+
private serialize<T>(action: () => Promise<T>): Promise<T> {
|
|
85
|
+
const result = this.queue.then(action)
|
|
86
|
+
this.queue = result.then(
|
|
87
|
+
() => undefined,
|
|
88
|
+
() => undefined,
|
|
89
|
+
)
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
private async persist(): Promise<void> {
|
|
93
|
+
if (!this.metadata) return
|
|
94
|
+
this.metadata.revision++
|
|
95
|
+
await this.state.storage.put(storageKey, this.metadata)
|
|
96
|
+
}
|
|
97
|
+
private owns(actor: Actor): boolean {
|
|
98
|
+
return (
|
|
99
|
+
this.metadata !== null &&
|
|
100
|
+
ownerKey(actor) === ownerKey(this.metadata.actor) &&
|
|
101
|
+
actor.sessionId === this.metadata.actor.sessionId
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
private publish(event: RunEvent): void {
|
|
105
|
+
const controller = this.controller
|
|
106
|
+
if (!controller) return
|
|
107
|
+
try {
|
|
108
|
+
// Bound an unread subscriber rather than retaining an unbounded transcript in memory.
|
|
109
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0)
|
|
110
|
+
throw new Error('Subscriber stalled')
|
|
111
|
+
controller.enqueue(
|
|
112
|
+
encoder.encode(
|
|
113
|
+
`event: run_event\nid: ${++this.sequence}\ndata: ${JSON.stringify(event)}\n\n`,
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
} catch {
|
|
117
|
+
this.close()
|
|
118
|
+
this.state.waitUntil(this.cancelRun())
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
private close(): void {
|
|
122
|
+
const controller = this.controller
|
|
123
|
+
this.controller = null
|
|
124
|
+
this.detach?.()
|
|
125
|
+
this.detach = null
|
|
126
|
+
try {
|
|
127
|
+
controller?.close()
|
|
128
|
+
} catch {
|
|
129
|
+
/* The consumer may already have cancelled. */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
private receive = (event: RunEvent): void => {
|
|
133
|
+
if (event.kind === 'end' || this.abort.signal.aborted) return
|
|
134
|
+
let approval: Pending | null = null
|
|
135
|
+
if (event.kind === 'approval_required') {
|
|
136
|
+
if (this.pending) throw conflict('approval_already_pending')
|
|
137
|
+
approval = {
|
|
138
|
+
callId: event.callId,
|
|
139
|
+
revision: (this.metadata?.revision ?? 0) + 1,
|
|
140
|
+
decision: undefined,
|
|
141
|
+
resolve: null,
|
|
142
|
+
}
|
|
143
|
+
this.pending = approval
|
|
144
|
+
}
|
|
145
|
+
const registered = approval
|
|
146
|
+
const write = this.serialize(async () => {
|
|
147
|
+
if (!this.metadata || terminal(this.metadata.phase) || this.abort.signal.aborted) return
|
|
148
|
+
if (registered) {
|
|
149
|
+
this.metadata.pending = { callId: registered.callId, revision: registered.revision }
|
|
150
|
+
await this.persist()
|
|
151
|
+
}
|
|
152
|
+
this.publish(event)
|
|
153
|
+
})
|
|
154
|
+
this.state.waitUntil(write.catch(() => this.cancelRun()))
|
|
155
|
+
}
|
|
156
|
+
private readonly approver: Approver = {
|
|
157
|
+
wait: async (runId, call, signal) => {
|
|
158
|
+
const pending = this.pending
|
|
159
|
+
if (!pending || pending.callId !== call.id || this.metadata?.runId !== runId)
|
|
160
|
+
throw conflict('approval_not_pending')
|
|
161
|
+
if (signal.aborted || this.abort.signal.aborted) return false
|
|
162
|
+
let decision = pending.decision
|
|
163
|
+
if (decision === undefined) {
|
|
164
|
+
const { promise, resolve } = Promise.withResolvers<boolean>()
|
|
165
|
+
const abort = (): void => resolve(false)
|
|
166
|
+
pending.resolve = resolve
|
|
167
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
168
|
+
this.abort.signal.addEventListener('abort', abort, { once: true })
|
|
169
|
+
if (signal.aborted || this.abort.signal.aborted) resolve(false)
|
|
170
|
+
try {
|
|
171
|
+
decision = await promise
|
|
172
|
+
} finally {
|
|
173
|
+
signal.removeEventListener('abort', abort)
|
|
174
|
+
this.abort.signal.removeEventListener('abort', abort)
|
|
175
|
+
pending.resolve = null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
await this.serialize(async () => {
|
|
179
|
+
if (this.pending !== pending || !this.metadata) return
|
|
180
|
+
this.pending = null
|
|
181
|
+
this.metadata.pending = null
|
|
182
|
+
await this.persist()
|
|
183
|
+
})
|
|
184
|
+
return decision
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
private async finish(snapshot: RunSnapshot): Promise<void> {
|
|
188
|
+
await this.serialize(async () => {
|
|
189
|
+
const record = this.metadata
|
|
190
|
+
if (!record || terminal(record.phase)) return
|
|
191
|
+
if (snapshot.status === 'running') throw conflict('runtime_not_terminal')
|
|
192
|
+
// Rebuild the DTO: no runtime-private additions or unsanitized error details persist.
|
|
193
|
+
record.snapshot = {
|
|
194
|
+
runId: record.runId,
|
|
195
|
+
conversationId: record.snapshot.conversationId,
|
|
196
|
+
turnId: record.snapshot.turnId,
|
|
197
|
+
status: snapshot.status,
|
|
198
|
+
cancelRequested: record.snapshot.cancelRequested || snapshot.cancelRequested,
|
|
199
|
+
...(snapshot.error ? { error: publicError(snapshot.error, newId) } : {}),
|
|
200
|
+
...(snapshot.exhausted === undefined ? {} : { exhausted: snapshot.exhausted }),
|
|
201
|
+
}
|
|
202
|
+
record.phase = snapshot.status
|
|
203
|
+
record.pending = null
|
|
204
|
+
record.acceptedUntil = null
|
|
205
|
+
this.pending?.resolve?.(false)
|
|
206
|
+
this.pending = null
|
|
207
|
+
await this.persist()
|
|
208
|
+
await this.state.storage.deleteAlarm()
|
|
209
|
+
this.publish(endEvent(record.snapshot))
|
|
210
|
+
this.close()
|
|
211
|
+
this.prepared = null
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
private async fail(error: unknown): Promise<void> {
|
|
215
|
+
const record = this.metadata
|
|
216
|
+
if (!record || terminal(record.phase)) return
|
|
217
|
+
if (record.handle) {
|
|
218
|
+
try {
|
|
219
|
+
await this.runtime.recover(record.handle, this.context(record.actor))
|
|
220
|
+
} catch (recoveryError) {
|
|
221
|
+
if (!(
|
|
222
|
+
record.phase === 'creating' &&
|
|
223
|
+
recoveryError instanceof KitFailure &&
|
|
224
|
+
recoveryError.code === 'not_found'
|
|
225
|
+
))
|
|
226
|
+
throw recoveryError
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
await this.finish({ ...record.snapshot, status: 'failed', error: publicError(error, newId) })
|
|
230
|
+
}
|
|
231
|
+
private async run(prepared: PreparedExecution, execute: boolean): Promise<void> {
|
|
232
|
+
try {
|
|
233
|
+
const snapshot = execute
|
|
234
|
+
? await this.runtime.execute(prepared, this.abort.signal, this.receive, this.approver)
|
|
235
|
+
: await this.runtime.abandon(prepared, 'cancelled')
|
|
236
|
+
await this.finish(snapshot)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
await this.fail(error)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
private async cancelRun(): Promise<void> {
|
|
242
|
+
await this.serialize(async () => {
|
|
243
|
+
const record = this.metadata
|
|
244
|
+
if (!record || terminal(record.phase)) return
|
|
245
|
+
this.abort.abort()
|
|
246
|
+
this.pending?.resolve?.(false)
|
|
247
|
+
record.snapshot.cancelRequested = true
|
|
248
|
+
await this.persist()
|
|
249
|
+
if ((record.phase === 'accepted' || !this.operation) && this.prepared) {
|
|
250
|
+
const prepared = this.prepared
|
|
251
|
+
record.phase = 'running'
|
|
252
|
+
record.acceptedUntil = null
|
|
253
|
+
await this.persist()
|
|
254
|
+
this.operation = Promise.resolve().then(() => this.run(prepared, false))
|
|
255
|
+
this.state.waitUntil(this.operation)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
await this.operation
|
|
259
|
+
}
|
|
260
|
+
private async accept(request: Request): Promise<Response> {
|
|
261
|
+
let value: unknown
|
|
262
|
+
try {
|
|
263
|
+
value = await request.json()
|
|
264
|
+
} catch {
|
|
265
|
+
throw new KitFailure('bad_request', 'Invalid JSON')
|
|
266
|
+
}
|
|
267
|
+
if (
|
|
268
|
+
typeof value !== 'object' ||
|
|
269
|
+
value === null ||
|
|
270
|
+
!('actor' in value) ||
|
|
271
|
+
!isActor(value.actor) ||
|
|
272
|
+
!('runId' in value) ||
|
|
273
|
+
typeof value.runId !== 'string' ||
|
|
274
|
+
!/^[A-Za-z0-9_-]{1,128}$/u.test(value.runId) ||
|
|
275
|
+
!('request' in value) ||
|
|
276
|
+
!isContract('RunRequest', value.request)
|
|
277
|
+
)
|
|
278
|
+
throw new KitFailure('bad_request', 'Invalid run request')
|
|
279
|
+
const actor = immutableActor(value.actor),
|
|
280
|
+
runId = value.runId,
|
|
281
|
+
input = value.request
|
|
282
|
+
await this.serialize(async () => {
|
|
283
|
+
if (this.metadata) throw conflict('run_already_exists')
|
|
284
|
+
this.metadata = {
|
|
285
|
+
phase: 'creating',
|
|
286
|
+
actor,
|
|
287
|
+
runId,
|
|
288
|
+
handle: null,
|
|
289
|
+
revision: 0,
|
|
290
|
+
pending: null,
|
|
291
|
+
acceptedUntil: null,
|
|
292
|
+
snapshot: {
|
|
293
|
+
runId,
|
|
294
|
+
conversationId: null,
|
|
295
|
+
turnId: null,
|
|
296
|
+
status: 'running',
|
|
297
|
+
cancelRequested: false,
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
await this.persist()
|
|
301
|
+
})
|
|
302
|
+
const abort = (): void => {
|
|
303
|
+
this.state.waitUntil(this.cancelRun())
|
|
304
|
+
}
|
|
305
|
+
request.signal.addEventListener('abort', abort, { once: true })
|
|
306
|
+
if (request.signal.aborted) this.abort.abort()
|
|
307
|
+
let accepted = false
|
|
308
|
+
const preparation = async (): Promise<void> => {
|
|
309
|
+
try {
|
|
310
|
+
const prepared = await this.runtime.prepare(runId, input, this.context(actor), (handle) =>
|
|
311
|
+
this.serialize(async () => {
|
|
312
|
+
if (!this.metadata || this.abort.signal.aborted)
|
|
313
|
+
throw new KitFailure('cancelled', 'Cancelled')
|
|
314
|
+
this.metadata.handle = handle
|
|
315
|
+
this.metadata.snapshot.conversationId = handle?.conversationId ?? null
|
|
316
|
+
this.metadata.snapshot.turnId = handle?.turnId ?? null
|
|
317
|
+
await this.persist()
|
|
318
|
+
}),
|
|
319
|
+
)
|
|
320
|
+
this.prepared = prepared
|
|
321
|
+
if (this.abort.signal.aborted) {
|
|
322
|
+
await this.run(prepared, false)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
await this.serialize(async () => {
|
|
326
|
+
if (!this.metadata) throw missing()
|
|
327
|
+
if (this.abort.signal.aborted) throw new KitFailure('cancelled', 'Cancelled')
|
|
328
|
+
this.metadata.phase = 'accepted'
|
|
329
|
+
this.metadata.snapshot.conversationId = prepared.accepted.conversationId
|
|
330
|
+
this.metadata.snapshot.turnId = prepared.accepted.turnId
|
|
331
|
+
this.metadata.acceptedUntil = now() + 30000
|
|
332
|
+
await this.state.storage.setAlarm(this.metadata.acceptedUntil)
|
|
333
|
+
await this.persist()
|
|
334
|
+
})
|
|
335
|
+
accepted = true
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (this.prepared) await this.run(this.prepared, false)
|
|
338
|
+
else await this.fail(error)
|
|
339
|
+
throw error
|
|
340
|
+
} finally {
|
|
341
|
+
request.signal.removeEventListener('abort', abort)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const work = preparation()
|
|
345
|
+
this.operation = work
|
|
346
|
+
this.state.waitUntil(work.catch(() => undefined))
|
|
347
|
+
await work
|
|
348
|
+
if (!accepted || !this.prepared) throw new KitFailure('cancelled', 'Cancelled')
|
|
349
|
+
return Response.json(this.prepared.accepted, { status: 202 })
|
|
350
|
+
}
|
|
351
|
+
private async events(request: Request, actor: Actor): Promise<Response> {
|
|
352
|
+
try {
|
|
353
|
+
return await this.serialize(async () => {
|
|
354
|
+
const record = this.metadata
|
|
355
|
+
if (!record || !this.owns(actor)) throw missing()
|
|
356
|
+
if (request.headers.has('Last-Event-ID')) throw conflict('event_replay_unavailable')
|
|
357
|
+
if (request.signal.aborted) throw new KitFailure('cancelled', 'Cancelled')
|
|
358
|
+
if (!terminal(record.phase) && (record.phase !== 'accepted' || this.subscribed))
|
|
359
|
+
throw conflict('subscriber_already_active')
|
|
360
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
361
|
+
{
|
|
362
|
+
start: (controller) => {
|
|
363
|
+
this.controller = controller
|
|
364
|
+
},
|
|
365
|
+
cancel: async () => {
|
|
366
|
+
this.close()
|
|
367
|
+
await this.cancelRun()
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
{ highWaterMark: 1024 * 1024, size: (chunk) => chunk.byteLength },
|
|
371
|
+
)
|
|
372
|
+
if (terminal(record.phase)) {
|
|
373
|
+
this.publish(endEvent(record.snapshot))
|
|
374
|
+
this.close()
|
|
375
|
+
} else {
|
|
376
|
+
if (!this.prepared) throw conflict('prepared_execution_unavailable')
|
|
377
|
+
this.subscribed = true
|
|
378
|
+
record.phase = 'running'
|
|
379
|
+
this.operation = null
|
|
380
|
+
record.acceptedUntil = null
|
|
381
|
+
await this.persist()
|
|
382
|
+
await this.state.storage.deleteAlarm()
|
|
383
|
+
const abort = (): void => {
|
|
384
|
+
this.close()
|
|
385
|
+
this.state.waitUntil(this.cancelRun())
|
|
386
|
+
}
|
|
387
|
+
request.signal.addEventListener('abort', abort, { once: true })
|
|
388
|
+
this.detach = () => request.signal.removeEventListener('abort', abort)
|
|
389
|
+
const prepared = this.prepared
|
|
390
|
+
this.operation = Promise.resolve().then(() => this.run(prepared, true))
|
|
391
|
+
this.state.waitUntil(this.operation)
|
|
392
|
+
if (request.signal.aborted) abort()
|
|
393
|
+
}
|
|
394
|
+
return new Response(stream, {
|
|
395
|
+
headers: {
|
|
396
|
+
'Content-Type': 'text/event-stream',
|
|
397
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
398
|
+
'X-Accel-Buffering': 'no',
|
|
399
|
+
'X-Content-Type-Options': 'nosniff',
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
} catch (error) {
|
|
404
|
+
// A rejected competing subscriber must never cancel the actual owner stream.
|
|
405
|
+
if (
|
|
406
|
+
this.owns(actor) &&
|
|
407
|
+
((this.subscribed && !this.operation) ||
|
|
408
|
+
(this.metadata?.phase === 'accepted' && request.signal.aborted))
|
|
409
|
+
) {
|
|
410
|
+
this.close()
|
|
411
|
+
await this.cancelRun()
|
|
412
|
+
}
|
|
413
|
+
throw error
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async fetch(request: Request): Promise<Response> {
|
|
417
|
+
try {
|
|
418
|
+
await this.ready
|
|
419
|
+
const path = new URL(request.url).pathname
|
|
420
|
+
if (request.method === 'POST' && path === '/accept') return await this.accept(request)
|
|
421
|
+
const actor = actorHeader(request)
|
|
422
|
+
if (request.method === 'POST' && path === '/cancel') {
|
|
423
|
+
if (this.owns(actor)) await this.cancelRun()
|
|
424
|
+
return Response.json({ ok: true })
|
|
425
|
+
}
|
|
426
|
+
if (!this.owns(actor)) throw missing()
|
|
427
|
+
if (request.method === 'GET' && path === '/events') return await this.events(request, actor)
|
|
428
|
+
if (request.method === 'GET' && path === '/snapshot')
|
|
429
|
+
return await this.serialize(async () => Response.json(this.metadata?.snapshot))
|
|
430
|
+
if (request.method === 'POST' && path === '/approval') {
|
|
431
|
+
let value: unknown
|
|
432
|
+
try {
|
|
433
|
+
value = await request.json()
|
|
434
|
+
} catch {
|
|
435
|
+
throw new KitFailure('bad_request', 'Invalid JSON')
|
|
436
|
+
}
|
|
437
|
+
if (
|
|
438
|
+
typeof value !== 'object' ||
|
|
439
|
+
value === null ||
|
|
440
|
+
!('callId' in value) ||
|
|
441
|
+
typeof value.callId !== 'string' ||
|
|
442
|
+
!('decision' in value) ||
|
|
443
|
+
(value.decision !== 'allow' && value.decision !== 'deny')
|
|
444
|
+
)
|
|
445
|
+
throw new KitFailure('bad_request', 'Invalid approval')
|
|
446
|
+
const callId = value.callId,
|
|
447
|
+
allowed = value.decision === 'allow'
|
|
448
|
+
return await this.serialize(async () => {
|
|
449
|
+
const pending = this.pending,
|
|
450
|
+
stored = this.metadata?.pending
|
|
451
|
+
if (
|
|
452
|
+
!pending ||
|
|
453
|
+
!stored ||
|
|
454
|
+
pending.callId !== callId ||
|
|
455
|
+
stored.callId !== pending.callId ||
|
|
456
|
+
stored.revision !== pending.revision ||
|
|
457
|
+
pending.decision !== undefined ||
|
|
458
|
+
this.abort.signal.aborted ||
|
|
459
|
+
this.metadata?.phase !== 'running'
|
|
460
|
+
)
|
|
461
|
+
throw conflict('approval_not_pending')
|
|
462
|
+
pending.decision = allowed
|
|
463
|
+
this.metadata.pending = null
|
|
464
|
+
await this.persist()
|
|
465
|
+
pending.resolve?.(pending.decision)
|
|
466
|
+
return Response.json({ ok: true })
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
throw missing()
|
|
470
|
+
} catch (error) {
|
|
471
|
+
const safe = publicError(error, newId)
|
|
472
|
+
return Response.json(safe, { status: statusCode(safe) })
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async alarm(): Promise<void> {
|
|
476
|
+
await this.ready
|
|
477
|
+
const record = this.metadata
|
|
478
|
+
if (record?.phase === 'accepted' && record.acceptedUntil !== null) {
|
|
479
|
+
if (now() >= record.acceptedUntil) await this.cancelRun()
|
|
480
|
+
else
|
|
481
|
+
await this.serialize(async () => {
|
|
482
|
+
if (this.metadata?.phase === 'accepted' && this.metadata.acceptedUntil !== null)
|
|
483
|
+
await this.state.storage.setAlarm(this.metadata.acceptedUntil)
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
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))
|
|
32
|
+
throw unreadable()
|
|
33
|
+
const bytes = Uint8Array.from(atob(text), (character) => character.charCodeAt(0))
|
|
34
|
+
if (base64(bytes) !== text) throw unreadable()
|
|
35
|
+
return bytes
|
|
36
|
+
}
|
|
37
|
+
export function isSealedCredential(value: unknown): value is SealedCredential {
|
|
38
|
+
return (
|
|
39
|
+
typeof value === 'object' &&
|
|
40
|
+
value !== null &&
|
|
41
|
+
'version' in value &&
|
|
42
|
+
value.version === 1 &&
|
|
43
|
+
'keyId' in value &&
|
|
44
|
+
typeof value.keyId === 'string' &&
|
|
45
|
+
value.keyId.length > 0 &&
|
|
46
|
+
value.keyId.length <= 128 &&
|
|
47
|
+
'nonce' in value &&
|
|
48
|
+
typeof value.nonce === 'string' &&
|
|
49
|
+
value.nonce.length === 16 &&
|
|
50
|
+
'ciphertext' in value &&
|
|
51
|
+
typeof value.ciphertext === 'string' &&
|
|
52
|
+
value.ciphertext.length >= 24 &&
|
|
53
|
+
value.ciphertext.length <= Math.ceil((MAX_BYTES + 16) / 3) * 4
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
function aad(scope: CredentialScope, keyId: string): Uint8Array<ArrayBuffer> {
|
|
57
|
+
if (
|
|
58
|
+
!scope.applicationId ||
|
|
59
|
+
!scope.recordId ||
|
|
60
|
+
scope.applicationId.length > 256 ||
|
|
61
|
+
scope.recordId.length > 256 ||
|
|
62
|
+
(scope.tenantId !== null && (!scope.tenantId || scope.tenantId.length > 256)) ||
|
|
63
|
+
(scope.subjectId !== null && (!scope.subjectId || scope.subjectId.length > 256)) ||
|
|
64
|
+
!['provider', 'search', 'mcp', 'oauth'].includes(scope.purpose)
|
|
65
|
+
)
|
|
66
|
+
throw badRequest('credentialScope', 'invalid')
|
|
67
|
+
return new TextEncoder().encode(
|
|
68
|
+
JSON.stringify([
|
|
69
|
+
1,
|
|
70
|
+
keyId,
|
|
71
|
+
scope.applicationId,
|
|
72
|
+
scope.tenantId,
|
|
73
|
+
scope.subjectId,
|
|
74
|
+
scope.purpose,
|
|
75
|
+
scope.recordId,
|
|
76
|
+
]),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
/** Supply non-extractable AES-GCM keys from deployment secrets/KMS; old keys are read-only until explicit rotation. */
|
|
80
|
+
export function createCredentialPort(options: {
|
|
81
|
+
readonly currentKeyId: string
|
|
82
|
+
readonly keys: ReadonlyMap<string, CryptoKey>
|
|
83
|
+
}): CredentialPort {
|
|
84
|
+
const keys = new Map(options.keys)
|
|
85
|
+
const currentKeyId = options.currentKeyId
|
|
86
|
+
if (!currentKeyId || currentKeyId.length > 128 || !keys.has(currentKeyId))
|
|
87
|
+
throw new KitFailure('needs_config', 'Credential key configuration is required')
|
|
88
|
+
for (const [id, key] of keys) {
|
|
89
|
+
if (
|
|
90
|
+
!id ||
|
|
91
|
+
id.length > 128 ||
|
|
92
|
+
key.type !== 'secret' ||
|
|
93
|
+
key.algorithm.name !== 'AES-GCM' ||
|
|
94
|
+
key.extractable ||
|
|
95
|
+
!key.usages.includes('decrypt') ||
|
|
96
|
+
(id === currentKeyId && !key.usages.includes('encrypt'))
|
|
97
|
+
)
|
|
98
|
+
throw new KitFailure('needs_config', 'Invalid credential key configuration')
|
|
99
|
+
}
|
|
100
|
+
async function seal(scope: CredentialScope, value: Uint8Array): Promise<SealedCredential> {
|
|
101
|
+
if (value.byteLength > MAX_BYTES) throw badRequest('credential', 'too_large', MAX_BYTES)
|
|
102
|
+
const key = keys.get(currentKeyId)
|
|
103
|
+
if (!key) throw unreadable()
|
|
104
|
+
const additionalData = aad(scope, currentKeyId)
|
|
105
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12))
|
|
106
|
+
try {
|
|
107
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
108
|
+
{ name: 'AES-GCM', iv: nonce, additionalData, tagLength: 128 },
|
|
109
|
+
key,
|
|
110
|
+
new Uint8Array(value),
|
|
111
|
+
)
|
|
112
|
+
return {
|
|
113
|
+
version: 1,
|
|
114
|
+
keyId: currentKeyId,
|
|
115
|
+
nonce: base64(nonce),
|
|
116
|
+
ciphertext: base64(new Uint8Array(ciphertext)),
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
throw unreadable()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function open(scope: CredentialScope, sealed: SealedCredential): Promise<Uint8Array> {
|
|
123
|
+
try {
|
|
124
|
+
if (!isSealedCredential(sealed)) throw unreadable()
|
|
125
|
+
const key = keys.get(sealed.keyId)
|
|
126
|
+
if (!key) throw unreadable()
|
|
127
|
+
const nonce = unbase64(sealed.nonce)
|
|
128
|
+
const ciphertext = unbase64(sealed.ciphertext)
|
|
129
|
+
if (
|
|
130
|
+
nonce.byteLength !== 12 ||
|
|
131
|
+
ciphertext.byteLength < 16 ||
|
|
132
|
+
ciphertext.byteLength > MAX_BYTES + 16
|
|
133
|
+
)
|
|
134
|
+
throw unreadable()
|
|
135
|
+
return new Uint8Array(
|
|
136
|
+
await crypto.subtle.decrypt(
|
|
137
|
+
{ name: 'AES-GCM', iv: nonce, additionalData: aad(scope, sealed.keyId), tagLength: 128 },
|
|
138
|
+
key,
|
|
139
|
+
ciphertext,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
} catch {
|
|
143
|
+
throw unreadable()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return Object.freeze({
|
|
147
|
+
seal,
|
|
148
|
+
open,
|
|
149
|
+
async rotate(scope: CredentialScope, sealed: SealedCredential) {
|
|
150
|
+
const plaintext = await open(scope, sealed)
|
|
151
|
+
try {
|
|
152
|
+
return await seal(scope, plaintext)
|
|
153
|
+
} finally {
|
|
154
|
+
plaintext.fill(0)
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
export async function importCredentialKey(encoded: string): Promise<CryptoKey> {
|
|
160
|
+
try {
|
|
161
|
+
const raw = unbase64(encoded)
|
|
162
|
+
if (raw.byteLength !== 32) throw unreadable()
|
|
163
|
+
try {
|
|
164
|
+
return await crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
|
|
165
|
+
} finally {
|
|
166
|
+
raw.fill(0)
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
throw new KitFailure('needs_config', 'A 256-bit credential secret is required')
|
|
170
|
+
}
|
|
171
|
+
}
|