@electric-ax/agents-server 0.4.11 → 0.4.13

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.
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto'
1
+ import { createHash, randomUUID } from 'node:crypto'
2
2
  import fastq from 'fastq'
3
3
  import {
4
4
  assertTags,
@@ -110,6 +110,48 @@ type ServerSignalEvent = {
110
110
  txid: string
111
111
  }
112
112
  }
113
+ type AttachmentSubjectType = `inbox` | `run` | `text` | `tool_call` | `context`
114
+ type AttachmentRole = `input` | `output`
115
+
116
+ export interface CreateAttachmentRequest {
117
+ id?: string
118
+ bytes: Uint8Array
119
+ mimeType: string
120
+ filename?: string
121
+ subject: {
122
+ type: AttachmentSubjectType
123
+ key: string
124
+ }
125
+ role?: AttachmentRole
126
+ createdBy?: string
127
+ meta?: Record<string, unknown>
128
+ }
129
+
130
+ export interface ReadAttachmentResult {
131
+ attachment: ManifestAttachmentEntry
132
+ bytes: Uint8Array
133
+ }
134
+
135
+ type ManifestAttachmentEntry = {
136
+ key: string
137
+ kind: `attachment`
138
+ id: string
139
+ streamPath: string
140
+ status: `pending` | `complete` | `failed`
141
+ subject: {
142
+ type: AttachmentSubjectType
143
+ key: string
144
+ }
145
+ role: AttachmentRole
146
+ mimeType: string
147
+ filename?: string
148
+ byteLength?: number
149
+ sha256?: string
150
+ createdAt: string
151
+ createdBy?: string
152
+ error?: string
153
+ meta?: Record<string, unknown>
154
+ }
113
155
 
114
156
  function createInitialQueuePosition(date: Date): string {
115
157
  return `${String(date.getTime()).padStart(16, `0`)}:a0`
@@ -142,11 +184,98 @@ const DEFAULT_FORK_WAIT_TIMEOUT_MS = 120_000
142
184
  const DEFAULT_FORK_WAIT_POLL_MS = 250
143
185
 
144
186
  const SERVER_SIGNAL_SENDER = `/_electric/server`
187
+ const DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
145
188
 
146
189
  function sleep(ms: number): Promise<void> {
147
190
  return new Promise((resolve) => setTimeout(resolve, ms))
148
191
  }
149
192
 
193
+ function maxAttachmentBytes(): number {
194
+ const configured = Number(process.env.ELECTRIC_AGENTS_MAX_ATTACHMENT_BYTES)
195
+ return Number.isFinite(configured) && configured > 0
196
+ ? Math.floor(configured)
197
+ : DEFAULT_MAX_ATTACHMENT_BYTES
198
+ }
199
+
200
+ function manifestAttachmentKey(id: string): string {
201
+ return `attachment:${id}`
202
+ }
203
+
204
+ function getEntityAttachmentStreamPath(
205
+ entityUrl: string,
206
+ attachmentId: string
207
+ ): string {
208
+ return `${entityUrl.replace(/\/+$/, ``)}/attachments/${attachmentId}`
209
+ }
210
+
211
+ function isStreamCreateConflict(error: unknown): boolean {
212
+ return (
213
+ !!error &&
214
+ typeof error === `object` &&
215
+ ((`status` in error && error.status === 409) ||
216
+ (`code` in error && error.code === `CONFLICT_SEQ`))
217
+ )
218
+ }
219
+
220
+ function assertCanonicalAttachmentStreamPath(
221
+ entityUrl: string,
222
+ attachment: ManifestAttachmentEntry
223
+ ): void {
224
+ const expected = getEntityAttachmentStreamPath(entityUrl, attachment.id)
225
+ if (attachment.streamPath === expected) return
226
+ throw new ElectricAgentsError(
227
+ ErrCodeInvalidRequest,
228
+ `Attachment stream path does not match its entity and id`,
229
+ 409
230
+ )
231
+ }
232
+
233
+ function validateAttachmentId(id: string): void {
234
+ if (!id || id.includes(`/`) || id.startsWith(`.`)) {
235
+ throw new ElectricAgentsError(
236
+ ErrCodeInvalidRequest,
237
+ `attachment id must not be empty, start with ".", or contain forward slashes`,
238
+ 400
239
+ )
240
+ }
241
+ }
242
+
243
+ function validateAttachmentSubject(
244
+ subject: CreateAttachmentRequest[`subject`]
245
+ ): void {
246
+ if (!subject.key) {
247
+ throw new ElectricAgentsError(
248
+ ErrCodeInvalidRequest,
249
+ `attachment subject key is required`,
250
+ 400
251
+ )
252
+ }
253
+ if (
254
+ subject.type !== `inbox` &&
255
+ subject.type !== `run` &&
256
+ subject.type !== `text` &&
257
+ subject.type !== `tool_call` &&
258
+ subject.type !== `context`
259
+ ) {
260
+ throw new ElectricAgentsError(
261
+ ErrCodeInvalidRequest,
262
+ `invalid attachment subject type`,
263
+ 400
264
+ )
265
+ }
266
+ }
267
+
268
+ function concatByteMessages(messages: Array<{ data: Uint8Array }>): Uint8Array {
269
+ const total = messages.reduce((sum, message) => sum + message.data.length, 0)
270
+ const bytes = new Uint8Array(total)
271
+ let offset = 0
272
+ for (const message of messages) {
273
+ bytes.set(message.data, offset)
274
+ offset += message.data.length
275
+ }
276
+ return bytes
277
+ }
278
+
150
279
  function omitUndefined<T extends Record<string, unknown>>(value: T): T {
151
280
  return Object.fromEntries(
152
281
  Object.entries(value).filter(([, entry]) => entry !== undefined)
@@ -811,6 +940,26 @@ export class EntityManager {
811
940
  createdStreams.push(forkPath)
812
941
  }
813
942
 
943
+ for (const plan of entityPlans) {
944
+ const manifests =
945
+ snapshot.manifestsByEntity.get(plan.source.url) ?? new Map()
946
+ for (const manifest of manifests.values()) {
947
+ if (
948
+ manifest.kind !== `attachment` ||
949
+ typeof manifest.streamPath !== `string` ||
950
+ typeof manifest.id !== `string`
951
+ ) {
952
+ continue
953
+ }
954
+ const forkPath = getEntityAttachmentStreamPath(
955
+ plan.fork.url,
956
+ manifest.id
957
+ )
958
+ await this.streamClient.fork(forkPath, manifest.streamPath)
959
+ createdStreams.push(forkPath)
960
+ }
961
+ }
962
+
814
963
  for (const plan of entityPlans) {
815
964
  const reconciliation = this.buildForkReconciliation(
816
965
  plan,
@@ -1474,6 +1623,21 @@ export class EntityManager {
1474
1623
  return { key: String(next.key), value: next, changed: true }
1475
1624
  }
1476
1625
 
1626
+ if (
1627
+ next.kind === `attachment` &&
1628
+ typeof next.streamPath === `string` &&
1629
+ typeof next.id === `string`
1630
+ ) {
1631
+ for (const [sourceUrl, forkUrl] of entityUrlMap) {
1632
+ const prefix = `${sourceUrl}/attachments/`
1633
+ if (!next.streamPath.startsWith(prefix)) {
1634
+ continue
1635
+ }
1636
+ next.streamPath = getEntityAttachmentStreamPath(forkUrl, next.id)
1637
+ return { key, value: next, changed: true }
1638
+ }
1639
+ }
1640
+
1477
1641
  if (next.kind === `schedule` && next.scheduleType === `future_send`) {
1478
1642
  let changed = false
1479
1643
  if (typeof next.targetUrl === `string`) {
@@ -1833,6 +1997,192 @@ export class EntityManager {
1833
1997
  )
1834
1998
  }
1835
1999
 
2000
+ // ==========================================================================
2001
+ // Attachments
2002
+ // ==========================================================================
2003
+
2004
+ isAttachmentStreamPath(path: string): boolean {
2005
+ return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path)
2006
+ }
2007
+
2008
+ async createAttachment(
2009
+ entityUrl: string,
2010
+ req: CreateAttachmentRequest
2011
+ ): Promise<{ txid: string; attachment: ManifestAttachmentEntry }> {
2012
+ const entity = await this.registry.getEntity(entityUrl)
2013
+ if (!entity) {
2014
+ throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
2015
+ }
2016
+ if (rejectsNormalWrites(entity.status)) {
2017
+ throw new ElectricAgentsError(
2018
+ ErrCodeNotRunning,
2019
+ `Entity is not accepting writes`,
2020
+ 409
2021
+ )
2022
+ }
2023
+ if (this.isForkWorkLockedEntity(entityUrl)) {
2024
+ this.assertEntityNotForkWorkLocked(entityUrl)
2025
+ }
2026
+
2027
+ const id = req.id ?? randomUUID()
2028
+ validateAttachmentId(id)
2029
+ validateAttachmentSubject(req.subject)
2030
+
2031
+ const limit = maxAttachmentBytes()
2032
+ if (req.bytes.length > limit) {
2033
+ throw new ElectricAgentsError(
2034
+ ErrCodeInvalidRequest,
2035
+ `Attachment exceeds maximum size of ${limit} bytes`,
2036
+ 413
2037
+ )
2038
+ }
2039
+
2040
+ const mimeType = req.mimeType.trim() || `application/octet-stream`
2041
+ const streamPath = getEntityAttachmentStreamPath(entityUrl, id)
2042
+ const manifestKey = manifestAttachmentKey(id)
2043
+ const txid = randomUUID()
2044
+ const now = new Date().toISOString()
2045
+ const sha256 = createHash(`sha256`).update(req.bytes).digest(`hex`)
2046
+ const attachment: ManifestAttachmentEntry = {
2047
+ key: manifestKey,
2048
+ kind: `attachment`,
2049
+ id,
2050
+ streamPath,
2051
+ status: `complete`,
2052
+ subject: req.subject,
2053
+ role: req.role ?? `input`,
2054
+ mimeType,
2055
+ ...(req.filename ? { filename: req.filename } : {}),
2056
+ byteLength: req.bytes.length,
2057
+ sha256,
2058
+ createdAt: now,
2059
+ ...(req.createdBy ? { createdBy: req.createdBy } : {}),
2060
+ ...(req.meta
2061
+ ? { meta: req.meta as ManifestAttachmentEntry[`meta`] }
2062
+ : {}),
2063
+ }
2064
+
2065
+ let streamCreated = false
2066
+ try {
2067
+ await this.streamClient.create(streamPath, {
2068
+ contentType: mimeType,
2069
+ body: req.bytes,
2070
+ closed: true,
2071
+ })
2072
+ streamCreated = true
2073
+ await this.writeManifestEntry(
2074
+ entityUrl,
2075
+ manifestKey,
2076
+ `upsert`,
2077
+ attachment as unknown as Record<string, unknown>,
2078
+ { txid }
2079
+ )
2080
+ } catch (error) {
2081
+ if (streamCreated) {
2082
+ await this.streamClient.delete(streamPath).catch(() => undefined)
2083
+ }
2084
+ if (!streamCreated && isStreamCreateConflict(error)) {
2085
+ throw new ElectricAgentsError(
2086
+ ErrCodeInvalidRequest,
2087
+ `Attachment already exists at id "${id}"`,
2088
+ 409
2089
+ )
2090
+ }
2091
+ throw error
2092
+ }
2093
+
2094
+ return { txid, attachment }
2095
+ }
2096
+
2097
+ async getAttachment(
2098
+ entityUrl: string,
2099
+ id: string
2100
+ ): Promise<ManifestAttachmentEntry | null> {
2101
+ validateAttachmentId(id)
2102
+ const entity = await this.registry.getEntity(entityUrl)
2103
+ if (!entity) {
2104
+ throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
2105
+ }
2106
+ const events = await this.streamClient.readJson<Record<string, unknown>>(
2107
+ entity.streams.main
2108
+ )
2109
+ const manifest = this.reduceStateRows(events, `manifest`).get(
2110
+ manifestAttachmentKey(id)
2111
+ )
2112
+ if (!manifest || manifest.kind !== `attachment`) {
2113
+ return null
2114
+ }
2115
+ return manifest as unknown as ManifestAttachmentEntry
2116
+ }
2117
+
2118
+ async readAttachment(
2119
+ entityUrl: string,
2120
+ id: string
2121
+ ): Promise<ReadAttachmentResult> {
2122
+ const attachment = await this.getAttachment(entityUrl, id)
2123
+ if (!attachment) {
2124
+ throw new ElectricAgentsError(
2125
+ ErrCodeNotFound,
2126
+ `Attachment not found`,
2127
+ 404
2128
+ )
2129
+ }
2130
+ if (attachment.status !== `complete`) {
2131
+ throw new ElectricAgentsError(
2132
+ ErrCodeInvalidRequest,
2133
+ `Attachment is not complete`,
2134
+ 409
2135
+ )
2136
+ }
2137
+ assertCanonicalAttachmentStreamPath(entityUrl, attachment)
2138
+
2139
+ const result = await this.streamClient.read(attachment.streamPath)
2140
+ return {
2141
+ attachment,
2142
+ bytes: concatByteMessages(result.messages),
2143
+ }
2144
+ }
2145
+
2146
+ async deleteAttachment(
2147
+ entityUrl: string,
2148
+ id: string
2149
+ ): Promise<{ txid: string }> {
2150
+ const entity = await this.registry.getEntity(entityUrl)
2151
+ if (!entity) {
2152
+ throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404)
2153
+ }
2154
+ if (rejectsNormalWrites(entity.status)) {
2155
+ throw new ElectricAgentsError(
2156
+ ErrCodeNotRunning,
2157
+ `Entity is not accepting writes`,
2158
+ 409
2159
+ )
2160
+ }
2161
+ if (this.isForkWorkLockedEntity(entityUrl)) {
2162
+ this.assertEntityNotForkWorkLocked(entityUrl)
2163
+ }
2164
+
2165
+ const attachment = await this.getAttachment(entityUrl, id)
2166
+ if (!attachment) {
2167
+ throw new ElectricAgentsError(
2168
+ ErrCodeNotFound,
2169
+ `Attachment not found`,
2170
+ 404
2171
+ )
2172
+ }
2173
+ assertCanonicalAttachmentStreamPath(entityUrl, attachment)
2174
+ const txid = randomUUID()
2175
+ await this.writeManifestEntry(
2176
+ entityUrl,
2177
+ manifestAttachmentKey(id),
2178
+ `delete`,
2179
+ undefined,
2180
+ { txid }
2181
+ )
2182
+ await this.streamClient.delete(attachment.streamPath).catch(() => undefined)
2183
+ return { txid }
2184
+ }
2185
+
1836
2186
  // ==========================================================================
1837
2187
  // Tag Updates
1838
2188
  // ==========================================================================
@@ -608,8 +608,11 @@ async function proxyPassThrough(
608
608
  request: IRequest,
609
609
  ctx: TenantContext
610
610
  ): Promise<Response> {
611
- const upstream = await forwardToDurableStreams(ctx, request)
612
611
  const streamPath = new URL(request.url).pathname
612
+ if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) {
613
+ return new Response(null, { status: 404 })
614
+ }
615
+ const upstream = await forwardToDurableStreams(ctx, request)
613
616
  const method = request.method.toUpperCase()
614
617
  const endTrackedRead =
615
618
  method === `GET`
@@ -25,6 +25,7 @@ import {
25
25
  shouldLinkDispatchBeforeInitialMessage,
26
26
  unlinkEntityDispatchSubscription,
27
27
  } from './dispatch-policy.js'
28
+ import { ElectricAgentsError } from '../entity-manager.js'
28
29
  import { routeBody, withSchema } from './schema.js'
29
30
  import type { ElectricAgentsEntity } from '../electric-agents-types.js'
30
31
  import type { JsonRouteRequest } from './schema.js'
@@ -203,6 +204,25 @@ type ScheduleBody = Static<typeof scheduleBodySchema>
203
204
  type EventSourceSubscriptionBody = Static<
204
205
  typeof eventSourceSubscriptionBodySchema
205
206
  >
207
+ type AttachmentSubjectType = `inbox` | `run` | `text` | `tool_call` | `context`
208
+ type AttachmentRole = `input` | `output`
209
+ type ParsedAttachmentForm = {
210
+ id?: string
211
+ bytes: Uint8Array
212
+ mimeType: string
213
+ filename?: string
214
+ subject: { type: AttachmentSubjectType; key: string }
215
+ role?: AttachmentRole
216
+ meta?: Record<string, unknown>
217
+ }
218
+
219
+ const attachmentSubjectTypes = new Set<AttachmentSubjectType>([
220
+ `inbox`,
221
+ `run`,
222
+ `text`,
223
+ `tool_call`,
224
+ `context`,
225
+ ])
206
226
 
207
227
  export const entitiesRouter: EntitiesRoutes = Router<
208
228
  AgentsRouteRequest,
@@ -234,6 +254,21 @@ entitiesRouter.post(
234
254
  withSchema(sendBodySchema),
235
255
  sendEntity
236
256
  )
257
+ entitiesRouter.post(
258
+ `/:type/:instanceId/attachments`,
259
+ withExistingEntity,
260
+ createAttachment
261
+ )
262
+ entitiesRouter.get(
263
+ `/:type/:instanceId/attachments/:attachmentId`,
264
+ withExistingEntity,
265
+ readAttachment
266
+ )
267
+ entitiesRouter.delete(
268
+ `/:type/:instanceId/attachments/:attachmentId`,
269
+ withExistingEntity,
270
+ deleteAttachment
271
+ )
237
272
  entitiesRouter.patch(
238
273
  `/:type/:instanceId/inbox/:messageKey`,
239
274
  withExistingEntity,
@@ -318,6 +353,131 @@ function requireExistingEntityRoute(
318
353
  return request.entityRoute
319
354
  }
320
355
 
356
+ function invalidAttachmentRequest(message: string): never {
357
+ throw new ElectricAgentsError(ErrCodeInvalidRequest, message, 400)
358
+ }
359
+
360
+ function formString(form: FormData, key: string): string | undefined {
361
+ const value = form.get(key)
362
+ if (typeof value !== `string`) return undefined
363
+ const trimmed = value.trim()
364
+ return trimmed || undefined
365
+ }
366
+
367
+ function parseJsonFormField<T>(form: FormData, key: string): T | undefined {
368
+ const raw = formString(form, key)
369
+ if (!raw) return undefined
370
+ try {
371
+ return JSON.parse(raw) as T
372
+ } catch {
373
+ invalidAttachmentRequest(`Invalid JSON field: ${key}`)
374
+ }
375
+ }
376
+
377
+ function parseAttachmentSubject(
378
+ form: FormData
379
+ ): ParsedAttachmentForm[`subject`] {
380
+ const explicit = parseJsonFormField<unknown>(form, `subject`)
381
+ if (explicit !== undefined) {
382
+ if (!explicit || typeof explicit !== `object` || Array.isArray(explicit)) {
383
+ invalidAttachmentRequest(`attachment subject must be an object`)
384
+ }
385
+ const subject = explicit as Record<string, unknown>
386
+ const type = subject.type
387
+ const key = subject.key
388
+ if (typeof type !== `string` || typeof key !== `string`) {
389
+ invalidAttachmentRequest(`attachment subject requires type and key`)
390
+ }
391
+ if (!attachmentSubjectTypes.has(type as AttachmentSubjectType)) {
392
+ invalidAttachmentRequest(`invalid attachment subject type`)
393
+ }
394
+ return { type: type as AttachmentSubjectType, key }
395
+ }
396
+
397
+ const type = formString(form, `subjectType`)
398
+ const key = formString(form, `subjectKey`)
399
+ if (!type || !key) {
400
+ invalidAttachmentRequest(`attachment subject is required`)
401
+ }
402
+ if (!attachmentSubjectTypes.has(type as AttachmentSubjectType)) {
403
+ invalidAttachmentRequest(`invalid attachment subject type`)
404
+ }
405
+ return { type: type as AttachmentSubjectType, key }
406
+ }
407
+
408
+ type UploadedFormFile = {
409
+ arrayBuffer: () => Promise<ArrayBuffer>
410
+ type?: string
411
+ name?: string
412
+ }
413
+
414
+ function getUploadedFormFile(
415
+ value: FormDataEntryValue | null
416
+ ): UploadedFormFile | null {
417
+ if (
418
+ value !== null &&
419
+ typeof value === `object` &&
420
+ `arrayBuffer` in value &&
421
+ typeof (value as { arrayBuffer?: unknown }).arrayBuffer === `function`
422
+ ) {
423
+ return value as unknown as UploadedFormFile
424
+ }
425
+ return null
426
+ }
427
+
428
+ async function parseAttachmentForm(
429
+ request: AgentsRouteRequest
430
+ ): Promise<ParsedAttachmentForm> {
431
+ const contentType = request.headers.get(`content-type`)?.toLowerCase() ?? ``
432
+ if (!contentType.includes(`multipart/form-data`)) {
433
+ invalidAttachmentRequest(`Attachment uploads must use multipart/form-data`)
434
+ }
435
+
436
+ let form: FormData
437
+ try {
438
+ form = await (request as unknown as Request).formData()
439
+ } catch {
440
+ invalidAttachmentRequest(`Invalid multipart form data`)
441
+ }
442
+
443
+ const file = getUploadedFormFile(form.get(`file`))
444
+ if (!file) {
445
+ invalidAttachmentRequest(`Missing file field`)
446
+ }
447
+
448
+ const role = formString(form, `role`)
449
+ if (role !== undefined && role !== `input` && role !== `output`) {
450
+ invalidAttachmentRequest(`invalid attachment role`)
451
+ }
452
+
453
+ const fileName =
454
+ formString(form, `filename`) ??
455
+ (typeof file.name === `string` ? file.name : undefined)
456
+ const mimeType =
457
+ formString(form, `mimeType`) ||
458
+ (typeof file.type === `string` ? file.type : undefined) ||
459
+ `application/octet-stream`
460
+ const meta = parseJsonFormField<Record<string, unknown>>(form, `meta`)
461
+ if (meta !== undefined && (typeof meta !== `object` || Array.isArray(meta))) {
462
+ invalidAttachmentRequest(`attachment meta must be an object`)
463
+ }
464
+
465
+ return {
466
+ id: formString(form, `id`),
467
+ bytes: new Uint8Array(await file.arrayBuffer()),
468
+ mimeType,
469
+ filename: fileName,
470
+ subject: parseAttachmentSubject(form),
471
+ role,
472
+ meta,
473
+ }
474
+ }
475
+
476
+ function contentDisposition(filename: string): string {
477
+ const fallback = filename.replace(/["\\\r\n]/g, `_`)
478
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`
479
+ }
480
+
321
481
  function rejectPrincipalEntityMutation(
322
482
  request: AgentsRouteRequest,
323
483
  action: string
@@ -695,6 +855,72 @@ async function sendEntity(
695
855
  return status(204)
696
856
  }
697
857
 
858
+ async function createAttachment(
859
+ request: AgentsRouteRequest,
860
+ ctx: TenantContext
861
+ ): Promise<Response> {
862
+ const principalMutationError = rejectPrincipalEntityMutation(
863
+ request,
864
+ `given attachments`
865
+ )
866
+ if (principalMutationError) return principalMutationError
867
+
868
+ const { entityUrl } = requireExistingEntityRoute(request)
869
+ const form = await parseAttachmentForm(request)
870
+ const result = await ctx.entityManager.createAttachment(entityUrl, {
871
+ id: form.id,
872
+ bytes: form.bytes,
873
+ mimeType: form.mimeType,
874
+ filename: form.filename,
875
+ subject: form.subject,
876
+ role: form.role,
877
+ createdBy: ctx.principal.url,
878
+ meta: form.meta,
879
+ })
880
+ return json(result, { status: 201 })
881
+ }
882
+
883
+ async function readAttachment(
884
+ request: AgentsRouteRequest,
885
+ ctx: TenantContext
886
+ ): Promise<Response> {
887
+ const { entityUrl } = requireExistingEntityRoute(request)
888
+ const result = await ctx.entityManager.readAttachment(
889
+ entityUrl,
890
+ decodeURIComponent(request.params.attachmentId)
891
+ )
892
+ const headers = new Headers({
893
+ 'content-type': result.attachment.mimeType,
894
+ 'content-length': String(result.bytes.length),
895
+ 'cache-control': `private, max-age=31536000, immutable`,
896
+ })
897
+ if (result.attachment.filename) {
898
+ headers.set(
899
+ `content-disposition`,
900
+ contentDisposition(result.attachment.filename)
901
+ )
902
+ }
903
+ return new Response(result.bytes, { status: 200, headers })
904
+ }
905
+
906
+ async function deleteAttachment(
907
+ request: AgentsRouteRequest,
908
+ ctx: TenantContext
909
+ ): Promise<Response> {
910
+ const principalMutationError = rejectPrincipalEntityMutation(
911
+ request,
912
+ `stripped of attachments`
913
+ )
914
+ if (principalMutationError) return principalMutationError
915
+
916
+ const { entityUrl } = requireExistingEntityRoute(request)
917
+ const result = await ctx.entityManager.deleteAttachment(
918
+ entityUrl,
919
+ decodeURIComponent(request.params.attachmentId)
920
+ )
921
+ return json(result)
922
+ }
923
+
698
924
  async function updateInboxMessage(
699
925
  request: AgentsRouteRequest,
700
926
  ctx: TenantContext
@@ -91,6 +91,9 @@ async function handleStreamAppend(
91
91
  const { manager } = runtime
92
92
  const entity = await manager.registry.getEntityByStream(path)
93
93
  const isSharedState = path.startsWith(`/_electric/shared-state/`)
94
+ if (!entity && manager.isAttachmentStreamPath(path)) {
95
+ return apiError(401, ErrCodeUnauthorized, `Invalid write token`)
96
+ }
94
97
  if (!entity && !isSharedState) {
95
98
  return undefined
96
99
  }