@electric-ax/agents-server 0.4.12 → 0.4.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/entrypoint.js +325 -62
- package/dist/index.cjs +325 -62
- package/dist/index.d.cts +50 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +325 -62
- package/package.json +9 -9
- package/src/entity-manager.ts +351 -1
- package/src/routing/durable-streams-router.ts +4 -1
- package/src/routing/entities-router.ts +226 -0
- package/src/routing/stream-append.ts +3 -0
- package/src/stream-client.ts +14 -33
- package/src/utils/log.ts +63 -52
package/src/entity-manager.ts
CHANGED
|
@@ -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
|
}
|