@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.
@@ -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
+ }