@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,491 @@
1
+ import type { ServerDto, ToolDto } from '@ouraihub/aether-contracts'
2
+ import { badRequest, KitFailure } from '@ouraihub/aether-core/error'
3
+ import { isRecord as object } from '@ouraihub/aether-core/guard'
4
+ import {
5
+ McpRuntime,
6
+ type Approval,
7
+ type McpCachedTool,
8
+ type McpConfigStore,
9
+ type McpServerConfig,
10
+ } from '@ouraihub/aether-core/mcp'
11
+ import type { OutboundHttp } from '@ouraihub/aether-core/ports'
12
+ import { ownerKey, type Actor, type WorkerContext } from '../context.js'
13
+ import {
14
+ isSealedCredential,
15
+ type CredentialPort,
16
+ type CredentialScope,
17
+ type SealedCredential,
18
+ } from '../credentials.js'
19
+ import type { DocumentStore } from '../storage.js'
20
+
21
+ const COLLECTION = 'mcp-client'
22
+ const KEY = 'servers'
23
+ const SECRET_PARAMS: Readonly<Record<string, true>> = {
24
+ key: true,
25
+ api_key: true,
26
+ apikey: true,
27
+ token: true,
28
+ access_token: true,
29
+ ak: true,
30
+ sk: true,
31
+ secret: true,
32
+ client_secret: true,
33
+ password: true,
34
+ signature: true,
35
+ }
36
+ const SECRET_PATTERN =
37
+ /(?:\b(?:sk-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza|glpat-)[A-Za-z0-9_./+=-]{0,}|\bBearer\s+[A-Za-z0-9_./+=-]{20,}|-----BEGIN[^\r\n]*PRIVATE KEY)/g
38
+
39
+ interface Secret {
40
+ headers: Record<string, string>
41
+ query: Record<string, string>
42
+ }
43
+ interface Server {
44
+ id: string
45
+ slug: string
46
+ name: string
47
+ url: string
48
+ enabled: boolean
49
+ trusted: boolean
50
+ allowPrivate: boolean
51
+ secret: SealedCredential | null
52
+ secretKeys: string[]
53
+ tools: McpCachedTool[]
54
+ }
55
+ interface State {
56
+ version: 1
57
+ servers: Server[]
58
+ deleted: string[]
59
+ }
60
+ interface Snapshot {
61
+ revision: number | null
62
+ state: State
63
+ }
64
+
65
+ function fields(value: object, allowed: readonly string[], field: string): void {
66
+ if (Object.keys(value).some((key) => !allowed.includes(key))) throw badRequest(field, 'invalid')
67
+ }
68
+ function text(value: unknown, field: string): string {
69
+ if (typeof value !== 'string' || !value.trim()) throw badRequest(field, 'required')
70
+ return value
71
+ }
72
+ function bool(value: unknown, field: string, fallback?: boolean): boolean {
73
+ if (value === undefined && fallback !== undefined) return fallback
74
+ if (typeof value !== 'boolean') throw badRequest(field, 'invalid')
75
+ return value
76
+ }
77
+ function approval(value: unknown): Approval {
78
+ if (value !== 'auto' && value !== 'ask' && value !== 'deny')
79
+ throw badRequest('approval', 'invalid')
80
+ return value
81
+ }
82
+ function looksSecret(value: string): boolean {
83
+ for (const match of value.matchAll(SECRET_PATTERN))
84
+ if (match[0].length >= 20 || match[0].startsWith('-----BEGIN')) return true
85
+ return false
86
+ }
87
+ function serverUrl(raw: unknown, allowPrivate: boolean): string {
88
+ const input = text(raw, 'url')
89
+ let url: URL
90
+ try {
91
+ url = new URL(input)
92
+ } catch {
93
+ throw badRequest('url', 'invalid_url')
94
+ }
95
+ if (url.username || url.password) throw badRequest('url', 'userinfo')
96
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw badRequest('url', 'scheme')
97
+ if (!url.hostname) throw badRequest('url', 'invalid_url')
98
+ if (url.hash) throw badRequest('url', 'fragment')
99
+ for (const [name, value] of url.searchParams) {
100
+ if (Object.hasOwn(SECRET_PARAMS, name.toLowerCase()) || looksSecret(value))
101
+ throw badRequest('url', 'credential_query')
102
+ }
103
+ const host = url.hostname.replace(/^\[|\]$/g, '')
104
+ const loopback =
105
+ host === 'localhost' || host.endsWith('.localhost') || host === '::1' || /^127\./.test(host)
106
+ if (url.protocol === 'http:' && !allowPrivate) throw badRequest('url', 'plain_http')
107
+ // DNS and connection-time restrictions remain the injected outbound port's responsibility.
108
+ const restricted =
109
+ /^(?:0\.|10\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|198\.(?:18|19)\.|(?:22[4-9]|23\d|24\d|25[0-5])\.)/.test(
110
+ host,
111
+ ) ||
112
+ host === '::' ||
113
+ /^(?:f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:|ff[0-9a-f]{2}:)/i.test(host)
114
+ if ((restricted || loopback) && !allowPrivate) throw badRequest('url', 'blocked_address')
115
+ return url.href
116
+ }
117
+ function maskedUrl(raw: string): string {
118
+ const url = new URL(raw)
119
+ url.username = ''
120
+ url.password = ''
121
+ for (const [name, value] of url.searchParams)
122
+ if (Object.hasOwn(SECRET_PARAMS, name.toLowerCase()) || looksSecret(value))
123
+ url.searchParams.set(name, '***')
124
+ return url.href
125
+ }
126
+ function strings(value: unknown, field: string): Record<string, string> {
127
+ if (value === undefined) return {}
128
+ if (!object(value)) throw badRequest(field, 'invalid')
129
+ const result: Record<string, string> = {}
130
+ for (const [key, entry] of Object.entries(value)) {
131
+ if (!key || typeof entry !== 'string') throw badRequest(field, 'invalid')
132
+ Object.defineProperty(result, key, {
133
+ value: entry,
134
+ enumerable: true,
135
+ writable: true,
136
+ configurable: true,
137
+ })
138
+ }
139
+ return result
140
+ }
141
+ function parseSecret(value: unknown): Secret {
142
+ if (!object(value)) throw badRequest('secret', 'invalid')
143
+ fields(value, ['headers', 'query', 'env'], 'secret')
144
+ if (value.env !== undefined && Object.keys(strings(value.env, 'secret')).length)
145
+ throw badRequest('secret', 'transport_mismatch')
146
+ const headers = strings(value.headers, 'secret.headers')
147
+ const query = strings(value.query, 'secret.query')
148
+ for (const [name, entry] of Object.entries(headers)) {
149
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || /[^\t\x20-\x7e\x80-\xff]/.test(entry))
150
+ throw badRequest('headers', 'invalid')
151
+ }
152
+ return { headers, query }
153
+ }
154
+ function cached(value: unknown): McpCachedTool {
155
+ if (!object(value)) throw badRequest('tools', 'invalid')
156
+ fields(value, ['name', 'description', 'schema', 'readOnly', 'approval', 'fetchedAt'], 'tools')
157
+ if (
158
+ typeof value.description !== 'string' ||
159
+ !object(value.schema) ||
160
+ typeof value.fetchedAt !== 'number' ||
161
+ !Number.isSafeInteger(value.fetchedAt) ||
162
+ value.fetchedAt < 0
163
+ )
164
+ throw badRequest('tools', 'invalid')
165
+ if ([...value.description].length > 1024) throw badRequest('tools', 'too_large')
166
+ if (new TextEncoder().encode(JSON.stringify(value.schema)).length > 32768)
167
+ throw badRequest('tools', 'too_large')
168
+ return {
169
+ name: text(value.name, 'tool'),
170
+ description: value.description,
171
+ schema: value.schema,
172
+ readOnly: bool(value.readOnly, 'readOnly'),
173
+ approval: approval(value.approval),
174
+ fetchedAt: value.fetchedAt,
175
+ }
176
+ }
177
+ function toolList(value: unknown): McpCachedTool[] {
178
+ if (!Array.isArray(value) || value.length > 200) throw badRequest('tools', 'invalid')
179
+ const tools = value.map(cached)
180
+ if (new Set(tools.map((tool) => tool.name)).size !== tools.length)
181
+ throw badRequest('tools', 'duplicate')
182
+ return tools
183
+ }
184
+ function readState(value: unknown): State {
185
+ try {
186
+ if (
187
+ !object(value) ||
188
+ value.version !== 1 ||
189
+ !Array.isArray(value.servers) ||
190
+ !Array.isArray(value.deleted)
191
+ )
192
+ throw badRequest('mcp', 'invalid')
193
+ fields(value, ['version', 'servers', 'deleted'], 'mcp')
194
+ const deleted = value.deleted.map((id: unknown) => text(id, 'id'))
195
+ const servers = value.servers.map((entry: unknown): Server => {
196
+ if (!object(entry)) throw badRequest('mcp', 'invalid')
197
+ fields(
198
+ entry,
199
+ [
200
+ 'id',
201
+ 'slug',
202
+ 'name',
203
+ 'url',
204
+ 'enabled',
205
+ 'trusted',
206
+ 'allowPrivate',
207
+ 'secret',
208
+ 'secretKeys',
209
+ 'tools',
210
+ ],
211
+ 'mcp',
212
+ )
213
+ const slug = text(entry.slug, 'slug')
214
+ if (
215
+ !/^[a-z0-9_]{1,24}$/.test(slug) ||
216
+ !Array.isArray(entry.secretKeys) ||
217
+ !(entry.secret === null || isSealedCredential(entry.secret))
218
+ )
219
+ throw badRequest('mcp', 'invalid')
220
+ if (entry.secret !== null)
221
+ fields(entry.secret, ['version', 'keyId', 'nonce', 'ciphertext'], 'secret')
222
+ if (
223
+ new Set(entry.secretKeys).size !== entry.secretKeys.length ||
224
+ (entry.secret !== null && !entry.secretKeys.length)
225
+ )
226
+ throw badRequest('mcp', 'invalid')
227
+ const allowPrivate = bool(entry.allowPrivate, 'allowPrivate')
228
+ return {
229
+ id: text(entry.id, 'id'),
230
+ slug,
231
+ name: text(entry.name, 'name'),
232
+ url: serverUrl(entry.url, allowPrivate),
233
+ enabled: bool(entry.enabled, 'enabled'),
234
+ trusted: bool(entry.trusted, 'trusted'),
235
+ allowPrivate,
236
+ secret: entry.secret,
237
+ secretKeys: entry.secretKeys.map((key: unknown) => text(key, 'secretKeys')),
238
+ tools: toolList(entry.tools),
239
+ }
240
+ })
241
+ if (
242
+ new Set(servers.map((server) => server.id)).size !== servers.length ||
243
+ new Set(servers.map((server) => server.slug)).size !== servers.length ||
244
+ new Set(deleted).size !== deleted.length ||
245
+ servers.some(
246
+ (server) =>
247
+ deleted.includes(server.id) || (server.secret === null && server.secretKeys.length !== 0),
248
+ )
249
+ )
250
+ throw badRequest('mcp', 'invalid')
251
+ return { version: 1, servers, deleted }
252
+ } catch {
253
+ throw new KitFailure('internal', 'Invalid stored MCP configuration')
254
+ }
255
+ }
256
+ function toolDto(tool: McpCachedTool): ToolDto {
257
+ return {
258
+ name: tool.name,
259
+ description: tool.description,
260
+ readOnly: tool.readOnly,
261
+ approval: tool.approval,
262
+ }
263
+ }
264
+ function dto(server: Server): ServerDto {
265
+ return {
266
+ id: server.id,
267
+ slug: server.slug,
268
+ name: server.name,
269
+ transport: { kind: 'http', url: maskedUrl(server.url) },
270
+ enabled: server.enabled,
271
+ trusted: server.trusted,
272
+ allowPrivate: server.allowPrivate,
273
+ secretKeys: server.secretKeys,
274
+ tools: server.tools.map(toolDto),
275
+ }
276
+ }
277
+ function missing(): KitFailure {
278
+ return new KitFailure('not_found', 'MCP server or tool not found')
279
+ }
280
+ async function body(request: Request): Promise<Record<string, unknown>> {
281
+ let value: unknown
282
+ try {
283
+ value = await request.json()
284
+ } catch {
285
+ throw badRequest('body', 'invalid_json')
286
+ }
287
+ if (!object(value)) throw badRequest('body', 'invalid')
288
+ return value
289
+ }
290
+
291
+ export class McpClientSettings {
292
+ private readonly runtimes = new Map<string, McpRuntime>()
293
+ private readonly store: DocumentStore
294
+ private readonly credentials: CredentialPort
295
+ private readonly outbound: OutboundHttp
296
+ constructor(store: DocumentStore, credentials: CredentialPort, outbound: OutboundHttp) {
297
+ this.store = store
298
+ this.credentials = credentials
299
+ this.outbound = outbound
300
+ }
301
+
302
+ private scope(actor: Actor, id: string): CredentialScope {
303
+ return {
304
+ applicationId: actor.applicationId,
305
+ tenantId: actor.tenantId,
306
+ subjectId: actor.subjectId,
307
+ purpose: 'mcp',
308
+ recordId: id,
309
+ }
310
+ }
311
+ private async snapshot(actor: Actor): Promise<Snapshot> {
312
+ const document = await this.store.get(actor, COLLECTION, KEY)
313
+ return document
314
+ ? { revision: document.revision, state: readState(document.value) }
315
+ : { revision: null, state: { version: 1, servers: [], deleted: [] } }
316
+ }
317
+ private async commit(actor: Actor, snapshot: Snapshot): Promise<void> {
318
+ await this.store.compareAndSet(actor, COLLECTION, KEY, snapshot.revision, snapshot.state)
319
+ }
320
+ private async config(actor: Actor, server: Server): Promise<McpServerConfig> {
321
+ let secret: Secret = { headers: {}, query: {} }
322
+ if (server.secret) {
323
+ let bytes: Uint8Array | undefined
324
+ try {
325
+ bytes = await this.credentials.open(this.scope(actor, server.id), server.secret)
326
+ const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
327
+ secret = parseSecret(value)
328
+ } catch {
329
+ throw new KitFailure('needs_config', 'MCP credentials are unreadable', {
330
+ reason: 'key_unreadable',
331
+ })
332
+ } finally {
333
+ bytes?.fill(0)
334
+ }
335
+ }
336
+ return {
337
+ id: server.id,
338
+ slug: server.slug,
339
+ name: server.name,
340
+ url: server.url,
341
+ enabled: server.enabled,
342
+ trusted: server.trusted,
343
+ allowPrivate: server.allowPrivate,
344
+ headers: secret.headers,
345
+ query: secret.query,
346
+ tools: server.tools,
347
+ }
348
+ }
349
+ runtime(actor: Actor): McpRuntime {
350
+ const key = ownerKey(actor)
351
+ const existing = this.runtimes.get(key)
352
+ if (existing) return existing
353
+ const owner: Actor = { ...actor, scopes: [...actor.scopes] }
354
+ const configs: McpConfigStore = {
355
+ list: async () => {
356
+ const { state } = await this.snapshot(owner)
357
+ return Promise.all(state.servers.map((server) => this.config(owner, server)))
358
+ },
359
+ get: async (id) => {
360
+ const { state } = await this.snapshot(owner)
361
+ const server = state.servers.find((entry) => entry.id === id)
362
+ return server ? this.config(owner, server) : null
363
+ },
364
+ replaceTools: async (id, tools) => {
365
+ const snapshot = await this.snapshot(owner)
366
+ const server = snapshot.state.servers.find((entry) => entry.id === id)
367
+ if (!server) throw missing()
368
+ const approvals = new Map(server.tools.map((tool) => [tool.name, tool.approval]))
369
+ server.tools = toolList(tools).map((tool) => ({
370
+ ...tool,
371
+ approval: approvals.get(tool.name) ?? 'ask',
372
+ }))
373
+ await this.commit(owner, snapshot)
374
+ },
375
+ }
376
+ const runtime = new McpRuntime(this.outbound, configs)
377
+ this.runtimes.set(key, runtime)
378
+ return runtime
379
+ }
380
+ private async save(
381
+ actor: Actor,
382
+ input: Record<string, unknown>,
383
+ id?: string,
384
+ ): Promise<ServerDto[]> {
385
+ fields(
386
+ input,
387
+ ['id', 'slug', 'name', 'transport', 'secret', 'enabled', 'trusted', 'allowPrivate'],
388
+ 'body',
389
+ )
390
+ if (input.id !== undefined && input.id !== id) throw badRequest('id', 'mismatch')
391
+ const snapshot = await this.snapshot(actor)
392
+ const existing =
393
+ id === undefined ? undefined : snapshot.state.servers.find((server) => server.id === id)
394
+ if (id !== undefined && !existing) throw missing()
395
+ const slug = text(input.slug, 'slug')
396
+ if (!/^[a-z0-9_]{1,24}$/.test(slug)) throw badRequest('slug', 'invalid')
397
+ if (snapshot.state.servers.some((server) => server.slug === slug && server.id !== id))
398
+ throw new KitFailure('conflict', 'MCP slug already exists', {
399
+ reason: 'duplicate_slug',
400
+ field: 'slug',
401
+ })
402
+ if (!object(input.transport)) throw badRequest('transport', 'invalid')
403
+ if (input.transport.kind === 'stdio')
404
+ throw new KitFailure('unsupported', 'Worker MCP clients support HTTP only')
405
+ fields(input.transport, ['kind', 'url'], 'transport')
406
+ if (input.transport.kind !== 'http') throw badRequest('transport', 'invalid')
407
+ const allowPrivate = bool(input.allowPrivate, 'allowPrivate', false)
408
+ const server: Server = {
409
+ id: existing?.id ?? crypto.randomUUID(),
410
+ slug,
411
+ name: text(input.name, 'name'),
412
+ url: serverUrl(input.transport.url, allowPrivate),
413
+ enabled: bool(input.enabled, 'enabled', false),
414
+ trusted: bool(input.trusted, 'trusted', false),
415
+ allowPrivate,
416
+ secret: existing?.secret ?? null,
417
+ secretKeys: existing?.secretKeys ?? [],
418
+ tools: existing?.tools ?? [],
419
+ }
420
+ if (input.secret !== undefined) {
421
+ const secret = parseSecret(input.secret)
422
+ server.secretKeys = [
423
+ ...new Set([...Object.keys(secret.headers), ...Object.keys(secret.query)]),
424
+ ].sort()
425
+ if (server.secretKeys.length) {
426
+ const bytes = new TextEncoder().encode(JSON.stringify(secret))
427
+ try {
428
+ server.secret = await this.credentials.seal(this.scope(actor, server.id), bytes)
429
+ } finally {
430
+ bytes.fill(0)
431
+ }
432
+ } else server.secret = null
433
+ }
434
+ if (existing) snapshot.state.servers[snapshot.state.servers.indexOf(existing)] = server
435
+ else snapshot.state.servers.push(server)
436
+ await this.commit(actor, snapshot)
437
+ return snapshot.state.servers.map(dto)
438
+ }
439
+ async handle(request: Request, context: WorkerContext): Promise<Response | null> {
440
+ const path = new URL(request.url).pathname
441
+ const match =
442
+ /^\/api\/v1\/mcp\/servers(?:\/([^/]+)(?:\/(refresh)|\/tools\/([^/]+)\/(approval))?)?$/.exec(
443
+ path,
444
+ )
445
+ if (!match) return null
446
+ let id: string | undefined
447
+ let tool: string | undefined
448
+ try {
449
+ id = match[1] === undefined ? undefined : decodeURIComponent(match[1])
450
+ tool = match[3] === undefined ? undefined : decodeURIComponent(match[3])
451
+ } catch {
452
+ throw badRequest('path', 'invalid')
453
+ }
454
+ const actor = context.actor
455
+ if (!id && request.method === 'GET')
456
+ return Response.json((await this.snapshot(actor)).state.servers.map(dto))
457
+ if (!id && request.method === 'POST')
458
+ return Response.json(await this.save(actor, await body(request)))
459
+ if (id && !match[2] && !match[4] && request.method === 'PUT')
460
+ return Response.json(await this.save(actor, await body(request), id))
461
+ if (id && !match[2] && !match[4] && request.method === 'DELETE') {
462
+ const snapshot = await this.snapshot(actor)
463
+ if (!snapshot.state.servers.some((server) => server.id === id)) throw missing()
464
+ snapshot.state.servers = snapshot.state.servers.filter((server) => server.id !== id)
465
+ snapshot.state.deleted.push(id)
466
+ await this.commit(actor, snapshot)
467
+ return Response.json(snapshot.state.servers.map(dto))
468
+ }
469
+ if (id && match[2] && request.method === 'POST') {
470
+ await this.runtime(actor).refresh(id, context.signal)
471
+ const server = (await this.snapshot(actor)).state.servers.find((entry) => entry.id === id)
472
+ if (!server) throw missing()
473
+ return Response.json(server.tools.map(toolDto))
474
+ }
475
+ if (id && tool && match[4] && request.method === 'PUT') {
476
+ const input = await body(request)
477
+ fields(input, ['approval'], 'body')
478
+ const value = approval(input.approval)
479
+ const snapshot = await this.snapshot(actor)
480
+ const server = snapshot.state.servers.find((entry) => entry.id === id)
481
+ const cached = server?.tools.find((entry) => entry.name === tool)
482
+ if (!server || !cached) throw missing()
483
+ server.tools = server.tools.map((entry) =>
484
+ entry === cached ? { ...entry, approval: value } : entry,
485
+ )
486
+ await this.commit(actor, snapshot)
487
+ return Response.json({ ok: true })
488
+ }
489
+ throw badRequest('method', 'unsupported')
490
+ }
491
+ }
@@ -0,0 +1,124 @@
1
+ import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
2
+ import type { Actor } from '../context.js'
3
+ import type { DocumentStore, StoredDocument } from '../storage.js'
4
+
5
+ export function object(value: unknown): value is Record<string, unknown> {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
7
+ }
8
+ export function strings(value: unknown): value is string[] {
9
+ return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string')
10
+ }
11
+ export function text(value: unknown, field: string, max = 1024): string {
12
+ if (typeof value !== 'string' || !value.trim() || value.length > max)
13
+ throw badRequest(field, 'invalid')
14
+ return value
15
+ }
16
+ export function unique(values: readonly string[]): string[] {
17
+ return [...new Set(values)]
18
+ }
19
+ export function random(): string {
20
+ return base64url(crypto.getRandomValues(new Uint8Array(32)))
21
+ }
22
+ export function base64url(bytes: Uint8Array): string {
23
+ let value = ''
24
+ for (const byte of bytes) value += String.fromCharCode(byte)
25
+ return btoa(value).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '')
26
+ }
27
+ export async function hash(value: string): Promise<string> {
28
+ return base64url(
29
+ new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))),
30
+ )
31
+ }
32
+ export function equal(a: string, b: string): boolean {
33
+ let difference = a.length ^ b.length
34
+ for (let index = 0; index < Math.max(a.length, b.length); index++)
35
+ difference |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0)
36
+ return difference === 0
37
+ }
38
+ export async function body(request: Request, max = 65536): Promise<string> {
39
+ if (Number(request.headers.get('Content-Length')) > max)
40
+ throw badRequest('body', 'too_large', max)
41
+ if (!request.body) return ''
42
+ const reader = request.body.getReader()
43
+ const chunks: Uint8Array[] = []
44
+ let size = 0
45
+ try {
46
+ while (true) {
47
+ const next = await reader.read()
48
+ if (next.done) break
49
+ size += next.value.byteLength
50
+ if (size > max) {
51
+ await reader.cancel()
52
+ throw badRequest('body', 'too_large', max)
53
+ }
54
+ chunks.push(next.value)
55
+ }
56
+ } finally {
57
+ reader.releaseLock()
58
+ }
59
+ const bytes = new Uint8Array(size)
60
+ let offset = 0
61
+ for (const chunk of chunks) {
62
+ bytes.set(chunk, offset)
63
+ offset += chunk.byteLength
64
+ }
65
+ try {
66
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
67
+ } catch {
68
+ throw badRequest('body', 'invalid_utf8')
69
+ }
70
+ }
71
+ export async function jsonBody(request: Request, max?: number): Promise<Record<string, unknown>> {
72
+ let value: unknown
73
+ try {
74
+ value = JSON.parse(await body(request, max))
75
+ } catch (error) {
76
+ if (error instanceof KitFailure) throw error
77
+ throw badRequest('body', 'invalid_json')
78
+ }
79
+ if (!object(value)) throw badRequest('body', 'object_required')
80
+ return value
81
+ }
82
+ export function json(value: unknown, status = 200, extra?: HeadersInit): Response {
83
+ const headers = new Headers(extra)
84
+ headers.set('Content-Type', 'application/json')
85
+ headers.set('Cache-Control', 'no-store')
86
+ headers.set('X-Content-Type-Options', 'nosniff')
87
+ return new Response(JSON.stringify(value), { status, headers })
88
+ }
89
+ export function forbidden(): never {
90
+ throw new KitFailure('forbidden', 'Access denied')
91
+ }
92
+ export function notFound(): never {
93
+ throw new KitFailure('not_found', 'Record not found')
94
+ }
95
+ export function validActor(value: unknown, applicationId: string): value is Actor {
96
+ return (
97
+ object(value) &&
98
+ value.applicationId === applicationId &&
99
+ typeof value.subjectId === 'string' &&
100
+ value.subjectId.length > 0 &&
101
+ (value.kind === 'user' || value.kind === 'service' || value.kind === 'mcp_client') &&
102
+ (value.tenantId === null || typeof value.tenantId === 'string') &&
103
+ (value.sessionId === null || typeof value.sessionId === 'string') &&
104
+ strings(value.scopes)
105
+ )
106
+ }
107
+ export async function required(
108
+ store: DocumentStore,
109
+ actor: Actor,
110
+ collection: string,
111
+ key: string,
112
+ ): Promise<StoredDocument> {
113
+ const document = await store.get(actor, collection, key)
114
+ if (!document) return notFound()
115
+ return document
116
+ }
117
+ export function escapeHtml(value: string): string {
118
+ return value
119
+ .replaceAll('&', '&amp;')
120
+ .replaceAll('<', '&lt;')
121
+ .replaceAll('>', '&gt;')
122
+ .replaceAll('"', '&quot;')
123
+ .replaceAll("'", '&#39;')
124
+ }