@ouraihub/aether-worker 0.0.0-stage → 0.1.0

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