@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.
@@ -634,7 +634,8 @@ var StreamClient = class {
634
634
  url: this.streamUrl(path$1),
635
635
  headers: this.streamHeaders(),
636
636
  contentType: opts.contentType,
637
- body: opts.body
637
+ body: opts.body,
638
+ closed: opts.closed
638
639
  });
639
640
  });
640
641
  }
@@ -734,30 +735,11 @@ var StreamClient = class {
734
735
  offset: fromOffset ?? `-1`,
735
736
  live: false
736
737
  });
737
- const messages = [];
738
- return await new Promise((resolve$1, reject) => {
739
- let settled = false;
740
- let unsub = () => {};
741
- const finish = (r) => {
742
- if (settled) return;
743
- settled = true;
744
- unsub();
745
- resolve$1(r);
746
- };
747
- unsub = response.subscribeBytes((chunk) => {
748
- messages.push({
749
- data: chunk.data,
750
- offset: chunk.offset
751
- });
752
- if (chunk.upToDate || chunk.streamClosed) finish({ messages });
753
- });
754
- response.closed.then(() => finish({ messages })).catch((err) => {
755
- if (settled) return;
756
- settled = true;
757
- unsub();
758
- reject(err);
759
- });
760
- });
738
+ const body = await response.body();
739
+ return { messages: body.length === 0 ? [] : [{
740
+ data: body,
741
+ offset: response.offset
742
+ }] };
761
743
  });
762
744
  }
763
745
  async readJson(path$1, fromOffset) {
@@ -911,11 +893,11 @@ var StreamClient = class {
911
893
  if (res.status === 404 || res.status === 204) return;
912
894
  if (!res.ok) throw new Error(`Subscription delete failed: ${res.status} ${await res.text()}`);
913
895
  }
914
- async addSubscriptionStreams(subscriptionId, streams$1) {
896
+ async addSubscriptionStreams(subscriptionId, streams) {
915
897
  const res = await fetch(this.subscriptionChildUrl(subscriptionId, `streams`), {
916
898
  method: `POST`,
917
899
  headers: await this.requestHeaders({ "content-type": `application/json` }),
918
- body: JSON.stringify({ streams: streams$1.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
900
+ body: JSON.stringify({ streams: streams.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
919
901
  });
920
902
  return await this.subscriptionJson(res, `Subscription stream add failed`);
921
903
  }
@@ -1157,35 +1139,48 @@ const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
1157
1139
  const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
1158
1140
  const USE_FILE_LOGS = process.env.ELECTRIC_AGENTS_LOG_FILE !== `false`;
1159
1141
  const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN;
1160
- const LOG_DIR = USE_FILE_LOGS ? process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`) : void 0;
1161
- const LOG_FILE = LOG_DIR ? path.join(LOG_DIR, `agent-server-${Date.now()}.jsonl`) : void 0;
1162
- if (LOG_DIR) fs.mkdirSync(LOG_DIR, { recursive: true });
1163
- const streams = [];
1164
- if (LOG_FILE) streams.push({ stream: pino.destination({
1165
- dest: LOG_FILE,
1166
- sync: IS_ELECTRON_MAIN
1167
- }) });
1168
- if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
1169
- target: `pino-pretty`,
1170
- options: {
1171
- colorize: true,
1172
- ignore: `pid,hostname,name`,
1173
- translateTime: `SYS:HH:MM:ss`
1174
- }
1175
- }) });
1176
- const logger = streams.length > 0 ? pino({
1177
- base: void 0,
1178
- level: LOG_LEVEL
1179
- }, pino.multistream(streams)) : pino({
1180
- base: void 0,
1181
- enabled: false,
1182
- level: LOG_LEVEL
1183
- });
1142
+ let _logger;
1143
+ function getLogger() {
1144
+ if (_logger) return _logger;
1145
+ const streams = [];
1146
+ try {
1147
+ if (USE_FILE_LOGS) {
1148
+ const logDir = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
1149
+ fs.mkdirSync(logDir, { recursive: true });
1150
+ const logFile = path.join(logDir, `agent-server-${Date.now()}.jsonl`);
1151
+ streams.push({ stream: pino.destination({
1152
+ dest: logFile,
1153
+ sync: IS_ELECTRON_MAIN
1154
+ }) });
1155
+ }
1156
+ } catch (err) {
1157
+ process.stderr.write(`[agents-server] Failed to initialize file logging: ${err instanceof Error ? err.message : err}\n`);
1158
+ }
1159
+ try {
1160
+ if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
1161
+ target: `pino-pretty`,
1162
+ options: {
1163
+ colorize: true,
1164
+ ignore: `pid,hostname,name`,
1165
+ translateTime: `SYS:HH:MM:ss`
1166
+ }
1167
+ }) });
1168
+ } catch {}
1169
+ _logger = streams.length > 0 ? pino({
1170
+ base: void 0,
1171
+ level: LOG_LEVEL
1172
+ }, pino.multistream(streams)) : pino({
1173
+ base: void 0,
1174
+ enabled: false,
1175
+ level: LOG_LEVEL
1176
+ });
1177
+ return _logger;
1178
+ }
1184
1179
  function formatArgs(args) {
1185
1180
  const errors = [];
1186
1181
  const parts = [];
1187
- for (const a of args) if (a instanceof Error) errors.push(a);
1188
- else parts.push(typeof a === `string` ? a : JSON.stringify(a));
1182
+ for (const value of args) if (value instanceof Error) errors.push(value);
1183
+ else parts.push(typeof value === `string` ? value : JSON.stringify(value));
1189
1184
  return {
1190
1185
  err: errors[0],
1191
1186
  msg: parts.join(` `)
@@ -1194,20 +1189,20 @@ function formatArgs(args) {
1194
1189
  const serverLog = {
1195
1190
  info(...args) {
1196
1191
  const { msg } = formatArgs(args);
1197
- logger.info(msg);
1192
+ getLogger().info(msg);
1198
1193
  },
1199
1194
  warn(...args) {
1200
1195
  const { err, msg } = formatArgs(args);
1201
- if (err) logger.warn({ err }, msg);
1202
- else logger.warn(msg);
1196
+ if (err) getLogger().warn({ err }, msg);
1197
+ else getLogger().warn(msg);
1203
1198
  },
1204
1199
  error(...args) {
1205
1200
  const { err, msg } = formatArgs(args);
1206
- if (err) logger.error({ err }, msg);
1207
- else logger.error(msg);
1201
+ if (err) getLogger().error({ err }, msg);
1202
+ else getLogger().error(msg);
1208
1203
  },
1209
1204
  event(obj, msg) {
1210
- logger.info(obj, msg);
1205
+ getLogger().info(obj, msg);
1211
1206
  }
1212
1207
  };
1213
1208
 
@@ -1228,6 +1223,7 @@ async function handleStreamAppend(request, runtime, forward) {
1228
1223
  const { manager } = runtime;
1229
1224
  const entity = await manager.registry.getEntityByStream(path$1);
1230
1225
  const isSharedState = path$1.startsWith(`/_electric/shared-state/`);
1226
+ if (!entity && manager.isAttachmentStreamPath(path$1)) return apiError(401, ErrCodeUnauthorized, `Invalid write token`);
1231
1227
  if (!entity && !isSharedState) return void 0;
1232
1228
  const body = await request.readBody();
1233
1229
  const event = decodeStreamAppendEvent(body);
@@ -1721,8 +1717,9 @@ async function streamAppend(request, ctx) {
1721
1717
  }));
1722
1718
  }
1723
1719
  async function proxyPassThrough(request, ctx) {
1724
- const upstream = await forwardToDurableStreams(ctx, request);
1725
1720
  const streamPath = new URL(request.url).pathname;
1721
+ if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) return new Response(null, { status: 404 });
1722
+ const upstream = await forwardToDurableStreams(ctx, request);
1726
1723
  const method = request.method.toUpperCase();
1727
1724
  const endTrackedRead = method === `GET` ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
1728
1725
  try {
@@ -2720,9 +2717,45 @@ function createInitialQueuePosition(date) {
2720
2717
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
2721
2718
  const DEFAULT_FORK_WAIT_POLL_MS = 250;
2722
2719
  const SERVER_SIGNAL_SENDER = `/_electric/server`;
2720
+ const DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
2723
2721
  function sleep(ms) {
2724
2722
  return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2725
2723
  }
2724
+ function maxAttachmentBytes() {
2725
+ const configured = Number(process.env.ELECTRIC_AGENTS_MAX_ATTACHMENT_BYTES);
2726
+ return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : DEFAULT_MAX_ATTACHMENT_BYTES;
2727
+ }
2728
+ function manifestAttachmentKey(id) {
2729
+ return `attachment:${id}`;
2730
+ }
2731
+ function getEntityAttachmentStreamPath(entityUrl, attachmentId) {
2732
+ return `${entityUrl.replace(/\/+$/, ``)}/attachments/${attachmentId}`;
2733
+ }
2734
+ function isStreamCreateConflict(error) {
2735
+ return !!error && typeof error === `object` && (`status` in error && error.status === 409 || `code` in error && error.code === `CONFLICT_SEQ`);
2736
+ }
2737
+ function assertCanonicalAttachmentStreamPath(entityUrl, attachment) {
2738
+ const expected = getEntityAttachmentStreamPath(entityUrl, attachment.id);
2739
+ if (attachment.streamPath === expected) return;
2740
+ throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment stream path does not match its entity and id`, 409);
2741
+ }
2742
+ function validateAttachmentId(id) {
2743
+ if (!id || id.includes(`/`) || id.startsWith(`.`)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `attachment id must not be empty, start with ".", or contain forward slashes`, 400);
2744
+ }
2745
+ function validateAttachmentSubject(subject) {
2746
+ if (!subject.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `attachment subject key is required`, 400);
2747
+ if (subject.type !== `inbox` && subject.type !== `run` && subject.type !== `text` && subject.type !== `tool_call` && subject.type !== `context`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `invalid attachment subject type`, 400);
2748
+ }
2749
+ function concatByteMessages(messages) {
2750
+ const total = messages.reduce((sum, message) => sum + message.data.length, 0);
2751
+ const bytes = new Uint8Array(total);
2752
+ let offset = 0;
2753
+ for (const message of messages) {
2754
+ bytes.set(message.data, offset);
2755
+ offset += message.data.length;
2756
+ }
2757
+ return bytes;
2758
+ }
2726
2759
  function omitUndefined$1(value) {
2727
2760
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== void 0));
2728
2761
  }
@@ -3088,6 +3121,15 @@ var EntityManager = class {
3088
3121
  await this.streamClient.fork(forkPath, sourcePath);
3089
3122
  createdStreams.push(forkPath);
3090
3123
  }
3124
+ for (const plan of entityPlans) {
3125
+ const manifests = snapshot.manifestsByEntity.get(plan.source.url) ?? new Map();
3126
+ for (const manifest of manifests.values()) {
3127
+ if (manifest.kind !== `attachment` || typeof manifest.streamPath !== `string` || typeof manifest.id !== `string`) continue;
3128
+ const forkPath = getEntityAttachmentStreamPath(plan.fork.url, manifest.id);
3129
+ await this.streamClient.fork(forkPath, manifest.streamPath);
3130
+ createdStreams.push(forkPath);
3131
+ }
3132
+ }
3091
3133
  for (const plan of entityPlans) {
3092
3134
  const reconciliation = this.buildForkReconciliation(plan, snapshot, entityUrlMap, sharedStateIdMap, stringMap);
3093
3135
  activeManifestsByEntity.set(plan.fork.url, reconciliation.manifests);
@@ -3497,6 +3539,16 @@ var EntityManager = class {
3497
3539
  changed: true
3498
3540
  };
3499
3541
  }
3542
+ if (next.kind === `attachment` && typeof next.streamPath === `string` && typeof next.id === `string`) for (const [sourceUrl, forkUrl] of entityUrlMap) {
3543
+ const prefix = `${sourceUrl}/attachments/`;
3544
+ if (!next.streamPath.startsWith(prefix)) continue;
3545
+ next.streamPath = getEntityAttachmentStreamPath(forkUrl, next.id);
3546
+ return {
3547
+ key,
3548
+ value: next,
3549
+ changed: true
3550
+ };
3551
+ }
3500
3552
  if (next.kind === `schedule` && next.scheduleType === `future_send`) {
3501
3553
  let changed = false;
3502
3554
  if (typeof next.targetUrl === `string`) {
@@ -3694,6 +3746,93 @@ var EntityManager = class {
3694
3746
  const envelope = entityStateSchema.inbox.delete({ key });
3695
3747
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
3696
3748
  }
3749
+ isAttachmentStreamPath(path$1) {
3750
+ return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
3751
+ }
3752
+ async createAttachment(entityUrl, req) {
3753
+ const entity = await this.registry.getEntity(entityUrl);
3754
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3755
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3756
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
3757
+ const id = req.id ?? randomUUID();
3758
+ validateAttachmentId(id);
3759
+ validateAttachmentSubject(req.subject);
3760
+ const limit = maxAttachmentBytes();
3761
+ if (req.bytes.length > limit) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment exceeds maximum size of ${limit} bytes`, 413);
3762
+ const mimeType = req.mimeType.trim() || `application/octet-stream`;
3763
+ const streamPath = getEntityAttachmentStreamPath(entityUrl, id);
3764
+ const manifestKey = manifestAttachmentKey(id);
3765
+ const txid = randomUUID();
3766
+ const now = new Date().toISOString();
3767
+ const sha256 = createHash(`sha256`).update(req.bytes).digest(`hex`);
3768
+ const attachment = {
3769
+ key: manifestKey,
3770
+ kind: `attachment`,
3771
+ id,
3772
+ streamPath,
3773
+ status: `complete`,
3774
+ subject: req.subject,
3775
+ role: req.role ?? `input`,
3776
+ mimeType,
3777
+ ...req.filename ? { filename: req.filename } : {},
3778
+ byteLength: req.bytes.length,
3779
+ sha256,
3780
+ createdAt: now,
3781
+ ...req.createdBy ? { createdBy: req.createdBy } : {},
3782
+ ...req.meta ? { meta: req.meta } : {}
3783
+ };
3784
+ let streamCreated = false;
3785
+ try {
3786
+ await this.streamClient.create(streamPath, {
3787
+ contentType: mimeType,
3788
+ body: req.bytes,
3789
+ closed: true
3790
+ });
3791
+ streamCreated = true;
3792
+ await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, attachment, { txid });
3793
+ } catch (error) {
3794
+ if (streamCreated) await this.streamClient.delete(streamPath).catch(() => void 0);
3795
+ if (!streamCreated && isStreamCreateConflict(error)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment already exists at id "${id}"`, 409);
3796
+ throw error;
3797
+ }
3798
+ return {
3799
+ txid,
3800
+ attachment
3801
+ };
3802
+ }
3803
+ async getAttachment(entityUrl, id) {
3804
+ validateAttachmentId(id);
3805
+ const entity = await this.registry.getEntity(entityUrl);
3806
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3807
+ const events = await this.streamClient.readJson(entity.streams.main);
3808
+ const manifest = this.reduceStateRows(events, `manifest`).get(manifestAttachmentKey(id));
3809
+ if (!manifest || manifest.kind !== `attachment`) return null;
3810
+ return manifest;
3811
+ }
3812
+ async readAttachment(entityUrl, id) {
3813
+ const attachment = await this.getAttachment(entityUrl, id);
3814
+ if (!attachment) throw new ElectricAgentsError(ErrCodeNotFound, `Attachment not found`, 404);
3815
+ if (attachment.status !== `complete`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment is not complete`, 409);
3816
+ assertCanonicalAttachmentStreamPath(entityUrl, attachment);
3817
+ const result = await this.streamClient.read(attachment.streamPath);
3818
+ return {
3819
+ attachment,
3820
+ bytes: concatByteMessages(result.messages)
3821
+ };
3822
+ }
3823
+ async deleteAttachment(entityUrl, id) {
3824
+ const entity = await this.registry.getEntity(entityUrl);
3825
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3826
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3827
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
3828
+ const attachment = await this.getAttachment(entityUrl, id);
3829
+ if (!attachment) throw new ElectricAgentsError(ErrCodeNotFound, `Attachment not found`, 404);
3830
+ assertCanonicalAttachmentStreamPath(entityUrl, attachment);
3831
+ const txid = randomUUID();
3832
+ await this.writeManifestEntry(entityUrl, manifestAttachmentKey(id), `delete`, void 0, { txid });
3833
+ await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
3834
+ return { txid };
3835
+ }
3697
3836
  async setTag(entityUrl, key, req, token) {
3698
3837
  const entity = await this.registry.getEntity(entityUrl);
3699
3838
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
@@ -4579,6 +4718,13 @@ const eventSourceSubscriptionBodySchema = Type.Object({
4579
4718
  lifetime: Type.Optional(subscriptionLifetimeSchema),
4580
4719
  reason: Type.Optional(Type.String())
4581
4720
  });
4721
+ const attachmentSubjectTypes = new Set([
4722
+ `inbox`,
4723
+ `run`,
4724
+ `text`,
4725
+ `tool_call`,
4726
+ `context`
4727
+ ]);
4582
4728
  const entitiesRouter = Router({ base: `/_electric/entities` });
4583
4729
  entitiesRouter.get(`/`, listEntities);
4584
4730
  entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), spawnEntity);
@@ -4587,6 +4733,9 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
4587
4733
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
4588
4734
  entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
4589
4735
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
4736
+ entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, createAttachment);
4737
+ entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, readAttachment);
4738
+ entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, deleteAttachment);
4590
4739
  entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
4591
4740
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
4592
4741
  entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), forkEntity);
@@ -4613,6 +4762,82 @@ function requireExistingEntityRoute(request) {
4613
4762
  if (!request.entityRoute) throw new Error(`existing entity middleware did not run`);
4614
4763
  return request.entityRoute;
4615
4764
  }
4765
+ function invalidAttachmentRequest(message) {
4766
+ throw new ElectricAgentsError(ErrCodeInvalidRequest, message, 400);
4767
+ }
4768
+ function formString(form, key) {
4769
+ const value = form.get(key);
4770
+ if (typeof value !== `string`) return void 0;
4771
+ const trimmed = value.trim();
4772
+ return trimmed || void 0;
4773
+ }
4774
+ function parseJsonFormField(form, key) {
4775
+ const raw = formString(form, key);
4776
+ if (!raw) return void 0;
4777
+ try {
4778
+ return JSON.parse(raw);
4779
+ } catch {
4780
+ invalidAttachmentRequest(`Invalid JSON field: ${key}`);
4781
+ }
4782
+ }
4783
+ function parseAttachmentSubject(form) {
4784
+ const explicit = parseJsonFormField(form, `subject`);
4785
+ if (explicit !== void 0) {
4786
+ if (!explicit || typeof explicit !== `object` || Array.isArray(explicit)) invalidAttachmentRequest(`attachment subject must be an object`);
4787
+ const subject = explicit;
4788
+ const type$1 = subject.type;
4789
+ const key$1 = subject.key;
4790
+ if (typeof type$1 !== `string` || typeof key$1 !== `string`) invalidAttachmentRequest(`attachment subject requires type and key`);
4791
+ if (!attachmentSubjectTypes.has(type$1)) invalidAttachmentRequest(`invalid attachment subject type`);
4792
+ return {
4793
+ type: type$1,
4794
+ key: key$1
4795
+ };
4796
+ }
4797
+ const type = formString(form, `subjectType`);
4798
+ const key = formString(form, `subjectKey`);
4799
+ if (!type || !key) invalidAttachmentRequest(`attachment subject is required`);
4800
+ if (!attachmentSubjectTypes.has(type)) invalidAttachmentRequest(`invalid attachment subject type`);
4801
+ return {
4802
+ type,
4803
+ key
4804
+ };
4805
+ }
4806
+ function getUploadedFormFile(value) {
4807
+ if (value !== null && typeof value === `object` && `arrayBuffer` in value && typeof value.arrayBuffer === `function`) return value;
4808
+ return null;
4809
+ }
4810
+ async function parseAttachmentForm(request) {
4811
+ const contentType = request.headers.get(`content-type`)?.toLowerCase() ?? ``;
4812
+ if (!contentType.includes(`multipart/form-data`)) invalidAttachmentRequest(`Attachment uploads must use multipart/form-data`);
4813
+ let form;
4814
+ try {
4815
+ form = await request.formData();
4816
+ } catch {
4817
+ invalidAttachmentRequest(`Invalid multipart form data`);
4818
+ }
4819
+ const file = getUploadedFormFile(form.get(`file`));
4820
+ if (!file) invalidAttachmentRequest(`Missing file field`);
4821
+ const role = formString(form, `role`);
4822
+ if (role !== void 0 && role !== `input` && role !== `output`) invalidAttachmentRequest(`invalid attachment role`);
4823
+ const fileName = formString(form, `filename`) ?? (typeof file.name === `string` ? file.name : void 0);
4824
+ const mimeType = formString(form, `mimeType`) || (typeof file.type === `string` ? file.type : void 0) || `application/octet-stream`;
4825
+ const meta = parseJsonFormField(form, `meta`);
4826
+ if (meta !== void 0 && (typeof meta !== `object` || Array.isArray(meta))) invalidAttachmentRequest(`attachment meta must be an object`);
4827
+ return {
4828
+ id: formString(form, `id`),
4829
+ bytes: new Uint8Array(await file.arrayBuffer()),
4830
+ mimeType,
4831
+ filename: fileName,
4832
+ subject: parseAttachmentSubject(form),
4833
+ role,
4834
+ meta
4835
+ };
4836
+ }
4837
+ function contentDisposition(filename) {
4838
+ const fallback = filename.replace(/["\\\r\n]/g, `_`);
4839
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
4840
+ }
4616
4841
  function rejectPrincipalEntityMutation(request, action) {
4617
4842
  const { entity } = requireExistingEntityRoute(request);
4618
4843
  if (entity.type !== `principal`) return void 0;
@@ -4797,6 +5022,44 @@ async function sendEntity(request, ctx) {
4797
5022
  });
4798
5023
  return status(204);
4799
5024
  }
5025
+ async function createAttachment(request, ctx) {
5026
+ const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
5027
+ if (principalMutationError) return principalMutationError;
5028
+ const { entityUrl } = requireExistingEntityRoute(request);
5029
+ const form = await parseAttachmentForm(request);
5030
+ const result = await ctx.entityManager.createAttachment(entityUrl, {
5031
+ id: form.id,
5032
+ bytes: form.bytes,
5033
+ mimeType: form.mimeType,
5034
+ filename: form.filename,
5035
+ subject: form.subject,
5036
+ role: form.role,
5037
+ createdBy: ctx.principal.url,
5038
+ meta: form.meta
5039
+ });
5040
+ return json(result, { status: 201 });
5041
+ }
5042
+ async function readAttachment(request, ctx) {
5043
+ const { entityUrl } = requireExistingEntityRoute(request);
5044
+ const result = await ctx.entityManager.readAttachment(entityUrl, decodeURIComponent(request.params.attachmentId));
5045
+ const headers = new Headers({
5046
+ "content-type": result.attachment.mimeType,
5047
+ "content-length": String(result.bytes.length),
5048
+ "cache-control": `private, max-age=31536000, immutable`
5049
+ });
5050
+ if (result.attachment.filename) headers.set(`content-disposition`, contentDisposition(result.attachment.filename));
5051
+ return new Response(result.bytes, {
5052
+ status: 200,
5053
+ headers
5054
+ });
5055
+ }
5056
+ async function deleteAttachment(request, ctx) {
5057
+ const principalMutationError = rejectPrincipalEntityMutation(request, `stripped of attachments`);
5058
+ if (principalMutationError) return principalMutationError;
5059
+ const { entityUrl } = requireExistingEntityRoute(request);
5060
+ const result = await ctx.entityManager.deleteAttachment(entityUrl, decodeURIComponent(request.params.attachmentId));
5061
+ return json(result);
5062
+ }
4800
5063
  async function updateInboxMessage(request, ctx) {
4801
5064
  const parsed = routeBody(request);
4802
5065
  const { entityUrl } = requireExistingEntityRoute(request);
@@ -5397,7 +5660,7 @@ async function notificationFromClaim(ctx, input) {
5397
5660
  leaseExpiresAt: input.claim.lease_ttl_ms ? new Date(Date.now() + input.claim.lease_ttl_ms) : void 0
5398
5661
  });
5399
5662
  await ctx.entityManager.registry.updateStatus(entity.url, `running`);
5400
- const streams$1 = input.claim.streams.map((stream) => ({
5663
+ const streams = input.claim.streams.map((stream) => ({
5401
5664
  path: withLeadingSlash(stream.path),
5402
5665
  offset: stream.tail_offset ?? ``
5403
5666
  }));
@@ -5406,7 +5669,7 @@ async function notificationFromClaim(ctx, input) {
5406
5669
  epoch: input.claim.generation,
5407
5670
  wakeId: input.claim.wake_id,
5408
5671
  streamPath: primaryStream,
5409
- streams: streams$1,
5672
+ streams,
5410
5673
  callback: appendPathToUrl(ctx.publicUrl, `/_electric/wake-callbacks/${encodeURIComponent(input.claim.wake_id)}`),
5411
5674
  claimToken: input.claim.token,
5412
5675
  triggerEvent: `message_received`,