@electric-ax/agents-server 0.4.12 → 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.
- package/dist/entrypoint.js +276 -26
- package/dist/index.cjs +276 -26
- package/dist/index.d.cts +50 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +276 -26
- package/package.json +8 -8
- 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/dist/entrypoint.js
CHANGED
|
@@ -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
|
|
738
|
-
return
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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) {
|
|
@@ -1228,6 +1210,7 @@ async function handleStreamAppend(request, runtime, forward) {
|
|
|
1228
1210
|
const { manager } = runtime;
|
|
1229
1211
|
const entity = await manager.registry.getEntityByStream(path$1);
|
|
1230
1212
|
const isSharedState = path$1.startsWith(`/_electric/shared-state/`);
|
|
1213
|
+
if (!entity && manager.isAttachmentStreamPath(path$1)) return apiError(401, ErrCodeUnauthorized, `Invalid write token`);
|
|
1231
1214
|
if (!entity && !isSharedState) return void 0;
|
|
1232
1215
|
const body = await request.readBody();
|
|
1233
1216
|
const event = decodeStreamAppendEvent(body);
|
|
@@ -1721,8 +1704,9 @@ async function streamAppend(request, ctx) {
|
|
|
1721
1704
|
}));
|
|
1722
1705
|
}
|
|
1723
1706
|
async function proxyPassThrough(request, ctx) {
|
|
1724
|
-
const upstream = await forwardToDurableStreams(ctx, request);
|
|
1725
1707
|
const streamPath = new URL(request.url).pathname;
|
|
1708
|
+
if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) return new Response(null, { status: 404 });
|
|
1709
|
+
const upstream = await forwardToDurableStreams(ctx, request);
|
|
1726
1710
|
const method = request.method.toUpperCase();
|
|
1727
1711
|
const endTrackedRead = method === `GET` ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
|
|
1728
1712
|
try {
|
|
@@ -2720,9 +2704,45 @@ function createInitialQueuePosition(date) {
|
|
|
2720
2704
|
const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
|
|
2721
2705
|
const DEFAULT_FORK_WAIT_POLL_MS = 250;
|
|
2722
2706
|
const SERVER_SIGNAL_SENDER = `/_electric/server`;
|
|
2707
|
+
const DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
2723
2708
|
function sleep(ms) {
|
|
2724
2709
|
return new Promise((resolve$1) => setTimeout(resolve$1, ms));
|
|
2725
2710
|
}
|
|
2711
|
+
function maxAttachmentBytes() {
|
|
2712
|
+
const configured = Number(process.env.ELECTRIC_AGENTS_MAX_ATTACHMENT_BYTES);
|
|
2713
|
+
return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
2714
|
+
}
|
|
2715
|
+
function manifestAttachmentKey(id) {
|
|
2716
|
+
return `attachment:${id}`;
|
|
2717
|
+
}
|
|
2718
|
+
function getEntityAttachmentStreamPath(entityUrl, attachmentId) {
|
|
2719
|
+
return `${entityUrl.replace(/\/+$/, ``)}/attachments/${attachmentId}`;
|
|
2720
|
+
}
|
|
2721
|
+
function isStreamCreateConflict(error) {
|
|
2722
|
+
return !!error && typeof error === `object` && (`status` in error && error.status === 409 || `code` in error && error.code === `CONFLICT_SEQ`);
|
|
2723
|
+
}
|
|
2724
|
+
function assertCanonicalAttachmentStreamPath(entityUrl, attachment) {
|
|
2725
|
+
const expected = getEntityAttachmentStreamPath(entityUrl, attachment.id);
|
|
2726
|
+
if (attachment.streamPath === expected) return;
|
|
2727
|
+
throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment stream path does not match its entity and id`, 409);
|
|
2728
|
+
}
|
|
2729
|
+
function validateAttachmentId(id) {
|
|
2730
|
+
if (!id || id.includes(`/`) || id.startsWith(`.`)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `attachment id must not be empty, start with ".", or contain forward slashes`, 400);
|
|
2731
|
+
}
|
|
2732
|
+
function validateAttachmentSubject(subject) {
|
|
2733
|
+
if (!subject.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `attachment subject key is required`, 400);
|
|
2734
|
+
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);
|
|
2735
|
+
}
|
|
2736
|
+
function concatByteMessages(messages) {
|
|
2737
|
+
const total = messages.reduce((sum, message) => sum + message.data.length, 0);
|
|
2738
|
+
const bytes = new Uint8Array(total);
|
|
2739
|
+
let offset = 0;
|
|
2740
|
+
for (const message of messages) {
|
|
2741
|
+
bytes.set(message.data, offset);
|
|
2742
|
+
offset += message.data.length;
|
|
2743
|
+
}
|
|
2744
|
+
return bytes;
|
|
2745
|
+
}
|
|
2726
2746
|
function omitUndefined$1(value) {
|
|
2727
2747
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== void 0));
|
|
2728
2748
|
}
|
|
@@ -3088,6 +3108,15 @@ var EntityManager = class {
|
|
|
3088
3108
|
await this.streamClient.fork(forkPath, sourcePath);
|
|
3089
3109
|
createdStreams.push(forkPath);
|
|
3090
3110
|
}
|
|
3111
|
+
for (const plan of entityPlans) {
|
|
3112
|
+
const manifests = snapshot.manifestsByEntity.get(plan.source.url) ?? new Map();
|
|
3113
|
+
for (const manifest of manifests.values()) {
|
|
3114
|
+
if (manifest.kind !== `attachment` || typeof manifest.streamPath !== `string` || typeof manifest.id !== `string`) continue;
|
|
3115
|
+
const forkPath = getEntityAttachmentStreamPath(plan.fork.url, manifest.id);
|
|
3116
|
+
await this.streamClient.fork(forkPath, manifest.streamPath);
|
|
3117
|
+
createdStreams.push(forkPath);
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3091
3120
|
for (const plan of entityPlans) {
|
|
3092
3121
|
const reconciliation = this.buildForkReconciliation(plan, snapshot, entityUrlMap, sharedStateIdMap, stringMap);
|
|
3093
3122
|
activeManifestsByEntity.set(plan.fork.url, reconciliation.manifests);
|
|
@@ -3497,6 +3526,16 @@ var EntityManager = class {
|
|
|
3497
3526
|
changed: true
|
|
3498
3527
|
};
|
|
3499
3528
|
}
|
|
3529
|
+
if (next.kind === `attachment` && typeof next.streamPath === `string` && typeof next.id === `string`) for (const [sourceUrl, forkUrl] of entityUrlMap) {
|
|
3530
|
+
const prefix = `${sourceUrl}/attachments/`;
|
|
3531
|
+
if (!next.streamPath.startsWith(prefix)) continue;
|
|
3532
|
+
next.streamPath = getEntityAttachmentStreamPath(forkUrl, next.id);
|
|
3533
|
+
return {
|
|
3534
|
+
key,
|
|
3535
|
+
value: next,
|
|
3536
|
+
changed: true
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3500
3539
|
if (next.kind === `schedule` && next.scheduleType === `future_send`) {
|
|
3501
3540
|
let changed = false;
|
|
3502
3541
|
if (typeof next.targetUrl === `string`) {
|
|
@@ -3694,6 +3733,93 @@ var EntityManager = class {
|
|
|
3694
3733
|
const envelope = entityStateSchema.inbox.delete({ key });
|
|
3695
3734
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
3696
3735
|
}
|
|
3736
|
+
isAttachmentStreamPath(path$1) {
|
|
3737
|
+
return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
|
|
3738
|
+
}
|
|
3739
|
+
async createAttachment(entityUrl, req) {
|
|
3740
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
3741
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3742
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3743
|
+
if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
|
|
3744
|
+
const id = req.id ?? randomUUID();
|
|
3745
|
+
validateAttachmentId(id);
|
|
3746
|
+
validateAttachmentSubject(req.subject);
|
|
3747
|
+
const limit = maxAttachmentBytes();
|
|
3748
|
+
if (req.bytes.length > limit) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment exceeds maximum size of ${limit} bytes`, 413);
|
|
3749
|
+
const mimeType = req.mimeType.trim() || `application/octet-stream`;
|
|
3750
|
+
const streamPath = getEntityAttachmentStreamPath(entityUrl, id);
|
|
3751
|
+
const manifestKey = manifestAttachmentKey(id);
|
|
3752
|
+
const txid = randomUUID();
|
|
3753
|
+
const now = new Date().toISOString();
|
|
3754
|
+
const sha256 = createHash(`sha256`).update(req.bytes).digest(`hex`);
|
|
3755
|
+
const attachment = {
|
|
3756
|
+
key: manifestKey,
|
|
3757
|
+
kind: `attachment`,
|
|
3758
|
+
id,
|
|
3759
|
+
streamPath,
|
|
3760
|
+
status: `complete`,
|
|
3761
|
+
subject: req.subject,
|
|
3762
|
+
role: req.role ?? `input`,
|
|
3763
|
+
mimeType,
|
|
3764
|
+
...req.filename ? { filename: req.filename } : {},
|
|
3765
|
+
byteLength: req.bytes.length,
|
|
3766
|
+
sha256,
|
|
3767
|
+
createdAt: now,
|
|
3768
|
+
...req.createdBy ? { createdBy: req.createdBy } : {},
|
|
3769
|
+
...req.meta ? { meta: req.meta } : {}
|
|
3770
|
+
};
|
|
3771
|
+
let streamCreated = false;
|
|
3772
|
+
try {
|
|
3773
|
+
await this.streamClient.create(streamPath, {
|
|
3774
|
+
contentType: mimeType,
|
|
3775
|
+
body: req.bytes,
|
|
3776
|
+
closed: true
|
|
3777
|
+
});
|
|
3778
|
+
streamCreated = true;
|
|
3779
|
+
await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, attachment, { txid });
|
|
3780
|
+
} catch (error) {
|
|
3781
|
+
if (streamCreated) await this.streamClient.delete(streamPath).catch(() => void 0);
|
|
3782
|
+
if (!streamCreated && isStreamCreateConflict(error)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment already exists at id "${id}"`, 409);
|
|
3783
|
+
throw error;
|
|
3784
|
+
}
|
|
3785
|
+
return {
|
|
3786
|
+
txid,
|
|
3787
|
+
attachment
|
|
3788
|
+
};
|
|
3789
|
+
}
|
|
3790
|
+
async getAttachment(entityUrl, id) {
|
|
3791
|
+
validateAttachmentId(id);
|
|
3792
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
3793
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3794
|
+
const events = await this.streamClient.readJson(entity.streams.main);
|
|
3795
|
+
const manifest = this.reduceStateRows(events, `manifest`).get(manifestAttachmentKey(id));
|
|
3796
|
+
if (!manifest || manifest.kind !== `attachment`) return null;
|
|
3797
|
+
return manifest;
|
|
3798
|
+
}
|
|
3799
|
+
async readAttachment(entityUrl, id) {
|
|
3800
|
+
const attachment = await this.getAttachment(entityUrl, id);
|
|
3801
|
+
if (!attachment) throw new ElectricAgentsError(ErrCodeNotFound, `Attachment not found`, 404);
|
|
3802
|
+
if (attachment.status !== `complete`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment is not complete`, 409);
|
|
3803
|
+
assertCanonicalAttachmentStreamPath(entityUrl, attachment);
|
|
3804
|
+
const result = await this.streamClient.read(attachment.streamPath);
|
|
3805
|
+
return {
|
|
3806
|
+
attachment,
|
|
3807
|
+
bytes: concatByteMessages(result.messages)
|
|
3808
|
+
};
|
|
3809
|
+
}
|
|
3810
|
+
async deleteAttachment(entityUrl, id) {
|
|
3811
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
3812
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3813
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3814
|
+
if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
|
|
3815
|
+
const attachment = await this.getAttachment(entityUrl, id);
|
|
3816
|
+
if (!attachment) throw new ElectricAgentsError(ErrCodeNotFound, `Attachment not found`, 404);
|
|
3817
|
+
assertCanonicalAttachmentStreamPath(entityUrl, attachment);
|
|
3818
|
+
const txid = randomUUID();
|
|
3819
|
+
await this.writeManifestEntry(entityUrl, manifestAttachmentKey(id), `delete`, void 0, { txid });
|
|
3820
|
+
await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
|
|
3821
|
+
return { txid };
|
|
3822
|
+
}
|
|
3697
3823
|
async setTag(entityUrl, key, req, token) {
|
|
3698
3824
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3699
3825
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
@@ -4579,6 +4705,13 @@ const eventSourceSubscriptionBodySchema = Type.Object({
|
|
|
4579
4705
|
lifetime: Type.Optional(subscriptionLifetimeSchema),
|
|
4580
4706
|
reason: Type.Optional(Type.String())
|
|
4581
4707
|
});
|
|
4708
|
+
const attachmentSubjectTypes = new Set([
|
|
4709
|
+
`inbox`,
|
|
4710
|
+
`run`,
|
|
4711
|
+
`text`,
|
|
4712
|
+
`tool_call`,
|
|
4713
|
+
`context`
|
|
4714
|
+
]);
|
|
4582
4715
|
const entitiesRouter = Router({ base: `/_electric/entities` });
|
|
4583
4716
|
entitiesRouter.get(`/`, listEntities);
|
|
4584
4717
|
entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), spawnEntity);
|
|
@@ -4587,6 +4720,9 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
|
|
|
4587
4720
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
|
|
4588
4721
|
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
|
|
4589
4722
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
|
|
4723
|
+
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, createAttachment);
|
|
4724
|
+
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, readAttachment);
|
|
4725
|
+
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, deleteAttachment);
|
|
4590
4726
|
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
|
|
4591
4727
|
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
|
|
4592
4728
|
entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), forkEntity);
|
|
@@ -4613,6 +4749,82 @@ function requireExistingEntityRoute(request) {
|
|
|
4613
4749
|
if (!request.entityRoute) throw new Error(`existing entity middleware did not run`);
|
|
4614
4750
|
return request.entityRoute;
|
|
4615
4751
|
}
|
|
4752
|
+
function invalidAttachmentRequest(message) {
|
|
4753
|
+
throw new ElectricAgentsError(ErrCodeInvalidRequest, message, 400);
|
|
4754
|
+
}
|
|
4755
|
+
function formString(form, key) {
|
|
4756
|
+
const value = form.get(key);
|
|
4757
|
+
if (typeof value !== `string`) return void 0;
|
|
4758
|
+
const trimmed = value.trim();
|
|
4759
|
+
return trimmed || void 0;
|
|
4760
|
+
}
|
|
4761
|
+
function parseJsonFormField(form, key) {
|
|
4762
|
+
const raw = formString(form, key);
|
|
4763
|
+
if (!raw) return void 0;
|
|
4764
|
+
try {
|
|
4765
|
+
return JSON.parse(raw);
|
|
4766
|
+
} catch {
|
|
4767
|
+
invalidAttachmentRequest(`Invalid JSON field: ${key}`);
|
|
4768
|
+
}
|
|
4769
|
+
}
|
|
4770
|
+
function parseAttachmentSubject(form) {
|
|
4771
|
+
const explicit = parseJsonFormField(form, `subject`);
|
|
4772
|
+
if (explicit !== void 0) {
|
|
4773
|
+
if (!explicit || typeof explicit !== `object` || Array.isArray(explicit)) invalidAttachmentRequest(`attachment subject must be an object`);
|
|
4774
|
+
const subject = explicit;
|
|
4775
|
+
const type$1 = subject.type;
|
|
4776
|
+
const key$1 = subject.key;
|
|
4777
|
+
if (typeof type$1 !== `string` || typeof key$1 !== `string`) invalidAttachmentRequest(`attachment subject requires type and key`);
|
|
4778
|
+
if (!attachmentSubjectTypes.has(type$1)) invalidAttachmentRequest(`invalid attachment subject type`);
|
|
4779
|
+
return {
|
|
4780
|
+
type: type$1,
|
|
4781
|
+
key: key$1
|
|
4782
|
+
};
|
|
4783
|
+
}
|
|
4784
|
+
const type = formString(form, `subjectType`);
|
|
4785
|
+
const key = formString(form, `subjectKey`);
|
|
4786
|
+
if (!type || !key) invalidAttachmentRequest(`attachment subject is required`);
|
|
4787
|
+
if (!attachmentSubjectTypes.has(type)) invalidAttachmentRequest(`invalid attachment subject type`);
|
|
4788
|
+
return {
|
|
4789
|
+
type,
|
|
4790
|
+
key
|
|
4791
|
+
};
|
|
4792
|
+
}
|
|
4793
|
+
function getUploadedFormFile(value) {
|
|
4794
|
+
if (value !== null && typeof value === `object` && `arrayBuffer` in value && typeof value.arrayBuffer === `function`) return value;
|
|
4795
|
+
return null;
|
|
4796
|
+
}
|
|
4797
|
+
async function parseAttachmentForm(request) {
|
|
4798
|
+
const contentType = request.headers.get(`content-type`)?.toLowerCase() ?? ``;
|
|
4799
|
+
if (!contentType.includes(`multipart/form-data`)) invalidAttachmentRequest(`Attachment uploads must use multipart/form-data`);
|
|
4800
|
+
let form;
|
|
4801
|
+
try {
|
|
4802
|
+
form = await request.formData();
|
|
4803
|
+
} catch {
|
|
4804
|
+
invalidAttachmentRequest(`Invalid multipart form data`);
|
|
4805
|
+
}
|
|
4806
|
+
const file = getUploadedFormFile(form.get(`file`));
|
|
4807
|
+
if (!file) invalidAttachmentRequest(`Missing file field`);
|
|
4808
|
+
const role = formString(form, `role`);
|
|
4809
|
+
if (role !== void 0 && role !== `input` && role !== `output`) invalidAttachmentRequest(`invalid attachment role`);
|
|
4810
|
+
const fileName = formString(form, `filename`) ?? (typeof file.name === `string` ? file.name : void 0);
|
|
4811
|
+
const mimeType = formString(form, `mimeType`) || (typeof file.type === `string` ? file.type : void 0) || `application/octet-stream`;
|
|
4812
|
+
const meta = parseJsonFormField(form, `meta`);
|
|
4813
|
+
if (meta !== void 0 && (typeof meta !== `object` || Array.isArray(meta))) invalidAttachmentRequest(`attachment meta must be an object`);
|
|
4814
|
+
return {
|
|
4815
|
+
id: formString(form, `id`),
|
|
4816
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
4817
|
+
mimeType,
|
|
4818
|
+
filename: fileName,
|
|
4819
|
+
subject: parseAttachmentSubject(form),
|
|
4820
|
+
role,
|
|
4821
|
+
meta
|
|
4822
|
+
};
|
|
4823
|
+
}
|
|
4824
|
+
function contentDisposition(filename) {
|
|
4825
|
+
const fallback = filename.replace(/["\\\r\n]/g, `_`);
|
|
4826
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
4827
|
+
}
|
|
4616
4828
|
function rejectPrincipalEntityMutation(request, action) {
|
|
4617
4829
|
const { entity } = requireExistingEntityRoute(request);
|
|
4618
4830
|
if (entity.type !== `principal`) return void 0;
|
|
@@ -4797,6 +5009,44 @@ async function sendEntity(request, ctx) {
|
|
|
4797
5009
|
});
|
|
4798
5010
|
return status(204);
|
|
4799
5011
|
}
|
|
5012
|
+
async function createAttachment(request, ctx) {
|
|
5013
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
|
|
5014
|
+
if (principalMutationError) return principalMutationError;
|
|
5015
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
5016
|
+
const form = await parseAttachmentForm(request);
|
|
5017
|
+
const result = await ctx.entityManager.createAttachment(entityUrl, {
|
|
5018
|
+
id: form.id,
|
|
5019
|
+
bytes: form.bytes,
|
|
5020
|
+
mimeType: form.mimeType,
|
|
5021
|
+
filename: form.filename,
|
|
5022
|
+
subject: form.subject,
|
|
5023
|
+
role: form.role,
|
|
5024
|
+
createdBy: ctx.principal.url,
|
|
5025
|
+
meta: form.meta
|
|
5026
|
+
});
|
|
5027
|
+
return json(result, { status: 201 });
|
|
5028
|
+
}
|
|
5029
|
+
async function readAttachment(request, ctx) {
|
|
5030
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
5031
|
+
const result = await ctx.entityManager.readAttachment(entityUrl, decodeURIComponent(request.params.attachmentId));
|
|
5032
|
+
const headers = new Headers({
|
|
5033
|
+
"content-type": result.attachment.mimeType,
|
|
5034
|
+
"content-length": String(result.bytes.length),
|
|
5035
|
+
"cache-control": `private, max-age=31536000, immutable`
|
|
5036
|
+
});
|
|
5037
|
+
if (result.attachment.filename) headers.set(`content-disposition`, contentDisposition(result.attachment.filename));
|
|
5038
|
+
return new Response(result.bytes, {
|
|
5039
|
+
status: 200,
|
|
5040
|
+
headers
|
|
5041
|
+
});
|
|
5042
|
+
}
|
|
5043
|
+
async function deleteAttachment(request, ctx) {
|
|
5044
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `stripped of attachments`);
|
|
5045
|
+
if (principalMutationError) return principalMutationError;
|
|
5046
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
5047
|
+
const result = await ctx.entityManager.deleteAttachment(entityUrl, decodeURIComponent(request.params.attachmentId));
|
|
5048
|
+
return json(result);
|
|
5049
|
+
}
|
|
4800
5050
|
async function updateInboxMessage(request, ctx) {
|
|
4801
5051
|
const parsed = routeBody(request);
|
|
4802
5052
|
const { entityUrl } = requireExistingEntityRoute(request);
|