@konneal/engine 0.2.46 → 0.2.48
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/{ask-6QPD3QA5.js → ask-KL7XUELS.js} +2 -2
- package/dist/attachments.d.ts +31 -0
- package/dist/{chunk-AKAFLMIB.js → chunk-47VBMM7I.js} +1 -1
- package/dist/{chunk-N2UOENDY.js → chunk-IUMHKBOY.js} +17 -0
- package/dist/{chunk-SE67PTJ6.js → chunk-SONICKNY.js} +1 -1
- package/dist/env.d.ts +1 -0
- package/dist/openapi-surface.gen.d.ts +1 -1
- package/dist/openapi-types.d.ts +149 -0
- package/dist/{search-QZDQVB57.js → search-NTQVSSAE.js} +2 -2
- package/dist/worker_public/src/index.js +119 -5
- package/package.json +1 -1
- package/workers/worker_public/migrations/0016_feedback_note.sql +3 -0
- package/workers/worker_public/migrations/0017_attachments.sql +12 -0
- package/workers/worker_public/openapi.yaml +69 -0
- package/workers/worker_public/schema.sql +10 -0
- package/workers/worker_public/src/attachments.ts +102 -0
- package/workers/worker_public/src/env.ts +1 -0
- package/workers/worker_public/src/index.ts +47 -2
- package/workers/worker_public/src/openapi-surface.gen.ts +24 -0
- package/workers/worker_public/src/ports/blobs.ts +1 -0
- package/workers/worker_public/src/ports/cloudflare/adapters.ts +3 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Blobs } from "./ports/blobs.ts";
|
|
2
|
+
import type { StoreQuery } from "./ports/store.ts";
|
|
3
|
+
export interface AttachmentDeps {
|
|
4
|
+
blobs: Blobs;
|
|
5
|
+
store: StoreQuery;
|
|
6
|
+
}
|
|
7
|
+
export interface AttachmentRow {
|
|
8
|
+
id: string;
|
|
9
|
+
sub: string;
|
|
10
|
+
mime: string;
|
|
11
|
+
bytes: number;
|
|
12
|
+
created_at: string;
|
|
13
|
+
}
|
|
14
|
+
/** Sniff the image type from the magic bytes — the declared type is
|
|
15
|
+
* never trusted. Returns null for anything that is not one of the four
|
|
16
|
+
* allowed images or that exceeds the cap. */
|
|
17
|
+
export declare function sniffImage(head: Uint8Array, totalBytes: number): string | null;
|
|
18
|
+
/** A data URL (the composer's current shape) → { mime, bytes } or null. */
|
|
19
|
+
export declare function parseDataUrl(dataUrl: string): {
|
|
20
|
+
mime: string;
|
|
21
|
+
bytes: Uint8Array;
|
|
22
|
+
} | null;
|
|
23
|
+
export declare function uploadAttachment(deps: AttachmentDeps, sub: string, dataUrl: string): Promise<{
|
|
24
|
+
id: string;
|
|
25
|
+
} | Response>;
|
|
26
|
+
/** The ownership check every access runs first: the row must exist and
|
|
27
|
+
* belong to the caller. A foreign id is indistinguishable from a
|
|
28
|
+
* missing one (404 — never a confirmation of someone else's file). */
|
|
29
|
+
export declare function ownedAttachment(deps: AttachmentDeps, sub: string, id: string): Promise<AttachmentRow | null>;
|
|
30
|
+
export declare function readAttachment(deps: AttachmentDeps, sub: string, id: string): Promise<Response | null>;
|
|
31
|
+
export declare function deleteAttachment(deps: AttachmentDeps, sub: string, id: string): Promise<boolean>;
|
|
@@ -22214,6 +22214,22 @@ function cfVectorIndex(index) {
|
|
|
22214
22214
|
}
|
|
22215
22215
|
};
|
|
22216
22216
|
}
|
|
22217
|
+
function cfBlobs(bucket) {
|
|
22218
|
+
const r2 = bucket;
|
|
22219
|
+
return {
|
|
22220
|
+
async get(key) {
|
|
22221
|
+
const obj = await r2.get(key);
|
|
22222
|
+
if (!obj) return null;
|
|
22223
|
+
return { body: obj.body, contentType: obj.httpMetadata?.contentType };
|
|
22224
|
+
},
|
|
22225
|
+
async put(key, value2, contentType) {
|
|
22226
|
+
await r2.put(key, value2, contentType ? { httpMetadata: { contentType } } : void 0);
|
|
22227
|
+
},
|
|
22228
|
+
async delete(key) {
|
|
22229
|
+
await r2.delete(key);
|
|
22230
|
+
}
|
|
22231
|
+
};
|
|
22232
|
+
}
|
|
22217
22233
|
function cfStore(db) {
|
|
22218
22234
|
return db;
|
|
22219
22235
|
}
|
|
@@ -24618,6 +24634,7 @@ export {
|
|
|
24618
24634
|
parseAppliedContext,
|
|
24619
24635
|
contextNote,
|
|
24620
24636
|
syntheticUnderstanding,
|
|
24637
|
+
cfBlobs,
|
|
24621
24638
|
portModelRunner,
|
|
24622
24639
|
portStore,
|
|
24623
24640
|
promptVars,
|
package/dist/env.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export interface OpenApiRoute {
|
|
|
3
3
|
pattern: string;
|
|
4
4
|
operationId: string;
|
|
5
5
|
}
|
|
6
|
-
export type OpenApiOperationId = "askAnonymous" | "askKeyed" | "search" | "searchKeyed" | "absence" | "absenceKeyed" | "verify" | "verifyKeyed" | "research" | "researchKeyed" | "mcp" | "datasets" | "keyUsage" | "health" | "listConversations" | "createConversation" | "getConversation" | "renameConversation" | "deleteConversation" | "appendMessage" | "shareConversation" | "getShared" | "listMemories" | "createMemory" | "deleteMemory" | "listProjects" | "createProject" | "deleteProject" | "listProjectFiles" | "attachProjectFile" | "detachProjectFile" | "laneQuery" | "laneKeyed" | "feedback" | "adminEnrich" | "adminEnrichAlias" | "adminSection" | "adminSectionAlias" | "adminVectors" | "adminCaption" | "adminJudge" | "adminJudgeAlias" | "adminRevokeKey" | "authMe" | "authLogin" | "authCallback" | "authLogout" | "authLogoutLink" | "adminStats" | "adminListKeys" | "adminCreateKey";
|
|
6
|
+
export type OpenApiOperationId = "askAnonymous" | "askKeyed" | "search" | "searchKeyed" | "absence" | "absenceKeyed" | "verify" | "verifyKeyed" | "research" | "researchKeyed" | "mcp" | "datasets" | "keyUsage" | "health" | "listConversations" | "createConversation" | "getConversation" | "renameConversation" | "deleteConversation" | "appendMessage" | "shareConversation" | "getShared" | "uploadAttachment" | "listAttachments" | "getAttachment" | "deleteAttachment" | "listMemories" | "createMemory" | "deleteMemory" | "listProjects" | "createProject" | "deleteProject" | "listProjectFiles" | "attachProjectFile" | "detachProjectFile" | "laneQuery" | "laneKeyed" | "feedback" | "adminEnrich" | "adminEnrichAlias" | "adminSection" | "adminSectionAlias" | "adminVectors" | "adminCaption" | "adminJudge" | "adminJudgeAlias" | "adminRevokeKey" | "authMe" | "authLogin" | "authCallback" | "authLogout" | "authLogoutLink" | "adminStats" | "adminListKeys" | "adminCreateKey";
|
|
7
7
|
export declare const OPENAPI_SURFACE: readonly (Omit<OpenApiRoute, "operationId"> & {
|
|
8
8
|
operationId: OpenApiOperationId;
|
|
9
9
|
})[];
|
package/dist/openapi-types.d.ts
CHANGED
|
@@ -368,6 +368,51 @@ export interface paths {
|
|
|
368
368
|
patch?: never;
|
|
369
369
|
trace?: never;
|
|
370
370
|
};
|
|
371
|
+
"/api/attachments": {
|
|
372
|
+
parameters: {
|
|
373
|
+
query?: never;
|
|
374
|
+
header?: never;
|
|
375
|
+
path?: never;
|
|
376
|
+
cookie?: never;
|
|
377
|
+
};
|
|
378
|
+
/** List the member's attachments */
|
|
379
|
+
get: operations["listAttachments"];
|
|
380
|
+
put?: never;
|
|
381
|
+
/**
|
|
382
|
+
* Upload an image attachment
|
|
383
|
+
* @description Stores a member-uploaded image (PNG, JPEG, WebP or GIF, at most 4 MB) and returns its identifier. The attachment persists until the member deletes it. The content is verified from its bytes, never from a declared type. The body carries the image as a data URL — the same shape the composer produces.
|
|
384
|
+
*/
|
|
385
|
+
post: operations["uploadAttachment"];
|
|
386
|
+
delete?: never;
|
|
387
|
+
options?: never;
|
|
388
|
+
head?: never;
|
|
389
|
+
patch?: never;
|
|
390
|
+
trace?: never;
|
|
391
|
+
};
|
|
392
|
+
"/api/attachments/{id}": {
|
|
393
|
+
parameters: {
|
|
394
|
+
query?: never;
|
|
395
|
+
header?: never;
|
|
396
|
+
path?: never;
|
|
397
|
+
cookie?: never;
|
|
398
|
+
};
|
|
399
|
+
/**
|
|
400
|
+
* Read one attachment's image
|
|
401
|
+
* @description Streams the stored image. The response is 404 for a foreign identifier — ownership is checked on every fetch, and a file belonging to another account is indistinguishable from a missing one.
|
|
402
|
+
*/
|
|
403
|
+
get: operations["getAttachment"];
|
|
404
|
+
put?: never;
|
|
405
|
+
post?: never;
|
|
406
|
+
/**
|
|
407
|
+
* Delete an attachment
|
|
408
|
+
* @description Removes the stored object and its record. Stored until deleted — and this deletes it.
|
|
409
|
+
*/
|
|
410
|
+
delete: operations["deleteAttachment"];
|
|
411
|
+
options?: never;
|
|
412
|
+
head?: never;
|
|
413
|
+
patch?: never;
|
|
414
|
+
trace?: never;
|
|
415
|
+
};
|
|
371
416
|
"/api/memories": {
|
|
372
417
|
parameters: {
|
|
373
418
|
query?: never;
|
|
@@ -1479,6 +1524,110 @@ export interface operations {
|
|
|
1479
1524
|
};
|
|
1480
1525
|
};
|
|
1481
1526
|
};
|
|
1527
|
+
listAttachments: {
|
|
1528
|
+
parameters: {
|
|
1529
|
+
query?: never;
|
|
1530
|
+
header?: never;
|
|
1531
|
+
path?: never;
|
|
1532
|
+
cookie?: never;
|
|
1533
|
+
};
|
|
1534
|
+
requestBody?: never;
|
|
1535
|
+
responses: {
|
|
1536
|
+
/** @description The attachment identifiers, newest first. */
|
|
1537
|
+
200: {
|
|
1538
|
+
headers: {
|
|
1539
|
+
[name: string]: unknown;
|
|
1540
|
+
};
|
|
1541
|
+
content?: never;
|
|
1542
|
+
};
|
|
1543
|
+
};
|
|
1544
|
+
};
|
|
1545
|
+
uploadAttachment: {
|
|
1546
|
+
parameters: {
|
|
1547
|
+
query?: never;
|
|
1548
|
+
header?: never;
|
|
1549
|
+
path?: never;
|
|
1550
|
+
cookie?: never;
|
|
1551
|
+
};
|
|
1552
|
+
requestBody: {
|
|
1553
|
+
content: {
|
|
1554
|
+
"application/json": {
|
|
1555
|
+
/** @description The image as a data URL. */
|
|
1556
|
+
data_url: string;
|
|
1557
|
+
};
|
|
1558
|
+
};
|
|
1559
|
+
};
|
|
1560
|
+
responses: {
|
|
1561
|
+
/** @description The attachment's identifier. */
|
|
1562
|
+
201: {
|
|
1563
|
+
headers: {
|
|
1564
|
+
[name: string]: unknown;
|
|
1565
|
+
};
|
|
1566
|
+
content?: never;
|
|
1567
|
+
};
|
|
1568
|
+
/** @description The content is not an allowed image, or exceeds the size cap. */
|
|
1569
|
+
400: {
|
|
1570
|
+
headers: {
|
|
1571
|
+
[name: string]: unknown;
|
|
1572
|
+
};
|
|
1573
|
+
content?: never;
|
|
1574
|
+
};
|
|
1575
|
+
};
|
|
1576
|
+
};
|
|
1577
|
+
getAttachment: {
|
|
1578
|
+
parameters: {
|
|
1579
|
+
query?: never;
|
|
1580
|
+
header?: never;
|
|
1581
|
+
path: {
|
|
1582
|
+
id: string;
|
|
1583
|
+
};
|
|
1584
|
+
cookie?: never;
|
|
1585
|
+
};
|
|
1586
|
+
requestBody?: never;
|
|
1587
|
+
responses: {
|
|
1588
|
+
/** @description The image bytes. */
|
|
1589
|
+
200: {
|
|
1590
|
+
headers: {
|
|
1591
|
+
[name: string]: unknown;
|
|
1592
|
+
};
|
|
1593
|
+
content?: never;
|
|
1594
|
+
};
|
|
1595
|
+
/** @description No such attachment owned by the caller. */
|
|
1596
|
+
404: {
|
|
1597
|
+
headers: {
|
|
1598
|
+
[name: string]: unknown;
|
|
1599
|
+
};
|
|
1600
|
+
content?: never;
|
|
1601
|
+
};
|
|
1602
|
+
};
|
|
1603
|
+
};
|
|
1604
|
+
deleteAttachment: {
|
|
1605
|
+
parameters: {
|
|
1606
|
+
query?: never;
|
|
1607
|
+
header?: never;
|
|
1608
|
+
path: {
|
|
1609
|
+
id: string;
|
|
1610
|
+
};
|
|
1611
|
+
cookie?: never;
|
|
1612
|
+
};
|
|
1613
|
+
requestBody?: never;
|
|
1614
|
+
responses: {
|
|
1615
|
+
/** @description Deleted. */
|
|
1616
|
+
200: {
|
|
1617
|
+
headers: {
|
|
1618
|
+
[name: string]: unknown;
|
|
1619
|
+
};
|
|
1620
|
+
content?: never;
|
|
1621
|
+
};
|
|
1622
|
+
/** @description No such attachment owned by the caller. */
|
|
1623
|
+
404: {
|
|
1624
|
+
headers: {
|
|
1625
|
+
[name: string]: unknown;
|
|
1626
|
+
};
|
|
1627
|
+
content?: never;
|
|
1628
|
+
};
|
|
1629
|
+
};
|
|
1630
|
+
};
|
|
1482
1631
|
listMemories: {
|
|
1483
1632
|
parameters: {
|
|
1484
1633
|
query?: never;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
handleSearch
|
|
3
|
-
} from "../../chunk-
|
|
3
|
+
} from "../../chunk-47VBMM7I.js";
|
|
4
4
|
import {
|
|
5
5
|
bindModelNode,
|
|
6
6
|
checkQuoteAnchors,
|
|
@@ -9,9 +9,10 @@ import {
|
|
|
9
9
|
modelGroundingBlock,
|
|
10
10
|
scoreJudge,
|
|
11
11
|
standardForDocNumber
|
|
12
|
-
} from "../../chunk-
|
|
12
|
+
} from "../../chunk-SONICKNY.js";
|
|
13
13
|
import {
|
|
14
14
|
buildMessages,
|
|
15
|
+
cfBlobs,
|
|
15
16
|
citations,
|
|
16
17
|
editionNote,
|
|
17
18
|
embed,
|
|
@@ -35,7 +36,7 @@ import {
|
|
|
35
36
|
sessionFrom,
|
|
36
37
|
telemetry,
|
|
37
38
|
understandQuery
|
|
38
|
-
} from "../../chunk-
|
|
39
|
+
} from "../../chunk-IUMHKBOY.js";
|
|
39
40
|
import {
|
|
40
41
|
authenticate,
|
|
41
42
|
corsHeaders,
|
|
@@ -936,6 +937,64 @@ ${recent.map((h, n) => `[${n + 1}] ${h.metadata.docidentifier ?? ""} \xA7${h.met
|
|
|
936
937
|
return out;
|
|
937
938
|
}
|
|
938
939
|
|
|
940
|
+
// workers/worker_public/src/attachments.ts
|
|
941
|
+
var MAX_BYTES = 4e6;
|
|
942
|
+
var ALLOWED = {
|
|
943
|
+
"image/png": "png",
|
|
944
|
+
"image/jpeg": "jpg",
|
|
945
|
+
"image/webp": "webp",
|
|
946
|
+
"image/gif": "gif"
|
|
947
|
+
};
|
|
948
|
+
function sniffImage(head, totalBytes) {
|
|
949
|
+
if (totalBytes > MAX_BYTES || totalBytes < 12) return null;
|
|
950
|
+
if (head[0] === 137 && head[1] === 80 && head[2] === 78 && head[3] === 71) return "image/png";
|
|
951
|
+
if (head[0] === 255 && head[1] === 216) return "image/jpeg";
|
|
952
|
+
if (head.length >= 12 && head[8] === 87 && head[9] === 69 && head[10] === 66 && head[11] === 80) return "image/webp";
|
|
953
|
+
if (head[0] === 71 && head[1] === 73 && head[2] === 70) return "image/gif";
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
function parseDataUrl(dataUrl) {
|
|
957
|
+
const m = /^data:(image\/[a-z+]+);base64,([\s\S]+)$/.exec(dataUrl);
|
|
958
|
+
if (!m) return null;
|
|
959
|
+
const raw = atob(m[2]);
|
|
960
|
+
const bytes = new Uint8Array(raw.length);
|
|
961
|
+
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
962
|
+
const mime = ALLOWED[m[1]] ? m[1] : sniffImage(bytes.subarray(0, 12), bytes.length);
|
|
963
|
+
if (!mime || bytes.length > MAX_BYTES) return null;
|
|
964
|
+
return { mime, bytes };
|
|
965
|
+
}
|
|
966
|
+
async function uploadAttachment(deps, sub, dataUrl) {
|
|
967
|
+
const parsed = parseDataUrl(dataUrl);
|
|
968
|
+
if (!parsed) {
|
|
969
|
+
return new Response(JSON.stringify({ error: { code: "invalid_input", message: "A PNG, JPEG, WebP or GIF image of at most 4 MB is required" } }), { status: 400, headers: { "content-type": "application/json" } });
|
|
970
|
+
}
|
|
971
|
+
const id = crypto.randomUUID();
|
|
972
|
+
const key = `att/${sub}/${id}`;
|
|
973
|
+
await deps.blobs.put(key, parsed.bytes.buffer, parsed.mime);
|
|
974
|
+
await deps.store.prepare("INSERT INTO attachments (id, sub, mime, bytes, r2_key) VALUES (?1,?2,?3,?4,?5)").bind(id, sub, parsed.mime, parsed.bytes.length, key).run();
|
|
975
|
+
return { id };
|
|
976
|
+
}
|
|
977
|
+
async function ownedAttachment(deps, sub, id) {
|
|
978
|
+
const row = await deps.store.prepare("SELECT id, sub, mime, bytes, created_at FROM attachments WHERE id = ?1 AND sub = ?2").bind(id, sub).first();
|
|
979
|
+
return row ?? null;
|
|
980
|
+
}
|
|
981
|
+
async function readAttachment(deps, sub, id) {
|
|
982
|
+
const row = await ownedAttachment(deps, sub, id);
|
|
983
|
+
if (!row) return null;
|
|
984
|
+
const obj = await deps.blobs.get(`att/${row.sub}/${row.id}`);
|
|
985
|
+
if (!obj) return null;
|
|
986
|
+
return new Response(obj.body, {
|
|
987
|
+
headers: { "content-type": obj.contentType ?? row.mime, "cache-control": "private, max-age=86400" }
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
async function deleteAttachment(deps, sub, id) {
|
|
991
|
+
const row = await ownedAttachment(deps, sub, id);
|
|
992
|
+
if (!row) return false;
|
|
993
|
+
await deps.blobs.delete(`att/${sub}/${id}`);
|
|
994
|
+
await deps.store.prepare("DELETE FROM attachments WHERE id = ?1 AND sub = ?2").bind(id, sub).run();
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
997
|
+
|
|
939
998
|
// workers/worker_public/src/mcp-proto.ts
|
|
940
999
|
var PROTOCOL_VERSION = "2025-06-18";
|
|
941
1000
|
var TOOLS = [
|
|
@@ -999,7 +1058,7 @@ async function handleMcp(env, ctx, req, tier, key) {
|
|
|
999
1058
|
// stream:false forces the JSON lane (anon defaults to SSE)
|
|
1000
1059
|
body: JSON.stringify({ ...args, stream: false })
|
|
1001
1060
|
});
|
|
1002
|
-
const res = name === "ask" ? await (await import("../../ask-
|
|
1061
|
+
const res = name === "ask" ? await (await import("../../ask-KL7XUELS.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-NTQVSSAE.js")).handleSearch(env, ctx, inner, tier, key);
|
|
1003
1062
|
return res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
|
|
1004
1063
|
});
|
|
1005
1064
|
if (out.ok && "accepted" in out) return new Response(null, { status: 202 });
|
|
@@ -1149,6 +1208,26 @@ var OPENAPI_SURFACE = [
|
|
|
1149
1208
|
"pattern": "/api/shared/:slug",
|
|
1150
1209
|
"operationId": "getShared"
|
|
1151
1210
|
},
|
|
1211
|
+
{
|
|
1212
|
+
"method": "POST",
|
|
1213
|
+
"pattern": "/api/attachments",
|
|
1214
|
+
"operationId": "uploadAttachment"
|
|
1215
|
+
},
|
|
1216
|
+
{
|
|
1217
|
+
"method": "GET",
|
|
1218
|
+
"pattern": "/api/attachments",
|
|
1219
|
+
"operationId": "listAttachments"
|
|
1220
|
+
},
|
|
1221
|
+
{
|
|
1222
|
+
"method": "GET",
|
|
1223
|
+
"pattern": "/api/attachments/:id",
|
|
1224
|
+
"operationId": "getAttachment"
|
|
1225
|
+
},
|
|
1226
|
+
{
|
|
1227
|
+
"method": "DELETE",
|
|
1228
|
+
"pattern": "/api/attachments/:id",
|
|
1229
|
+
"operationId": "deleteAttachment"
|
|
1230
|
+
},
|
|
1152
1231
|
{
|
|
1153
1232
|
"method": "GET",
|
|
1154
1233
|
"pattern": "/api/memories",
|
|
@@ -1325,6 +1404,36 @@ function opWriteRefused(c, who) {
|
|
|
1325
1404
|
}
|
|
1326
1405
|
return null;
|
|
1327
1406
|
}
|
|
1407
|
+
async function attachmentsUploadRoute(c) {
|
|
1408
|
+
const session = await sessionFrom(c.req, c.env);
|
|
1409
|
+
if (!session) return withCors(err(401, "unauthorized", "Sign in to store attachments"), corsHeaders(c.req));
|
|
1410
|
+
const deps = { blobs: cfBlobs(c.env.CHAT_UPLOADS), store: c.env.DB };
|
|
1411
|
+
const body = await readJson(c.req);
|
|
1412
|
+
const out = await uploadAttachment(deps, session.sub, String(body?.data_url ?? ""));
|
|
1413
|
+
if (out instanceof Response) return withCors(out, corsHeaders(c.req));
|
|
1414
|
+
return withCors(json({ id: out.id }, 201), corsHeaders(c.req));
|
|
1415
|
+
}
|
|
1416
|
+
async function attachmentsListRoute(c) {
|
|
1417
|
+
const session = await sessionFrom(c.req, c.env);
|
|
1418
|
+
if (!session) return withCors(err(401, "unauthorized", "Sign in to list attachments"), corsHeaders(c.req));
|
|
1419
|
+
const rows = await c.env.DB.prepare("SELECT id, mime, bytes, created_at FROM attachments WHERE sub = ?1 ORDER BY created_at DESC LIMIT 200").bind(session.sub).all();
|
|
1420
|
+
return withCors(json({ attachments: rows.results ?? [] }), corsHeaders(c.req));
|
|
1421
|
+
}
|
|
1422
|
+
async function attachmentsGetRoute(c) {
|
|
1423
|
+
const session = await sessionFrom(c.req, c.env);
|
|
1424
|
+
if (!session) return err(404, "not_found", "No such attachment");
|
|
1425
|
+
const deps = { blobs: cfBlobs(c.env.CHAT_UPLOADS), store: c.env.DB };
|
|
1426
|
+
const out = await readAttachment(deps, session.sub, c.params.id);
|
|
1427
|
+
return out ?? err(404, "not_found", "No such attachment");
|
|
1428
|
+
}
|
|
1429
|
+
async function attachmentsDeleteRoute(c) {
|
|
1430
|
+
const session = await sessionFrom(c.req, c.env);
|
|
1431
|
+
if (!session) return withCors(err(401, "unauthorized", "Sign in to manage attachments"), corsHeaders(c.req));
|
|
1432
|
+
const deps = { blobs: cfBlobs(c.env.CHAT_UPLOADS), store: c.env.DB };
|
|
1433
|
+
const gone = await deleteAttachment(deps, session.sub, c.params.id);
|
|
1434
|
+
if (!gone) return withCors(err(404, "not_found", "No such attachment"), corsHeaders(c.req));
|
|
1435
|
+
return withCors(json({ ok: true }), corsHeaders(c.req));
|
|
1436
|
+
}
|
|
1328
1437
|
async function memoriesRoute(c) {
|
|
1329
1438
|
const who = await opIdentity(c, "Sign in to use memory files");
|
|
1330
1439
|
if (who instanceof Response) return withCors(who, corsHeaders(c.req));
|
|
@@ -1643,7 +1752,8 @@ async function feedbackRoute(c) {
|
|
|
1643
1752
|
if (!/^[a-f0-9]{64}$/.test(queryHash) || ![1, -1].includes(rating)) {
|
|
1644
1753
|
return withCors(err(400, "invalid_input", "query_hash and rating (1 or -1) are required"), corsHeaders(c.req));
|
|
1645
1754
|
}
|
|
1646
|
-
|
|
1755
|
+
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 280) : "";
|
|
1756
|
+
await c.env.DB.prepare("INSERT INTO feedback (query_hash, rating, ts, note) VALUES (?1,?2,?3,?4)").bind(queryHash, rating, (/* @__PURE__ */ new Date()).toISOString(), note || null).run();
|
|
1647
1757
|
return json({ ok: true, ...corsHeaders(c.req) });
|
|
1648
1758
|
}
|
|
1649
1759
|
async function unitAssetRoute(c) {
|
|
@@ -1741,6 +1851,10 @@ var OPENAPI_HANDLERS = {
|
|
|
1741
1851
|
appendMessage: appendMessageRoute,
|
|
1742
1852
|
shareConversation: shareRoute,
|
|
1743
1853
|
getShared: getSharedRoute,
|
|
1854
|
+
uploadAttachment: attachmentsUploadRoute,
|
|
1855
|
+
listAttachments: attachmentsListRoute,
|
|
1856
|
+
getAttachment: attachmentsGetRoute,
|
|
1857
|
+
deleteAttachment: attachmentsDeleteRoute,
|
|
1744
1858
|
listMemories: memoriesRoute,
|
|
1745
1859
|
createMemory: memoriesRoute,
|
|
1746
1860
|
deleteMemory: memoriesRoute,
|
package/package.json
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"playwright": "^1.62.1",
|
|
28
28
|
"yaml": "^2.9.1"
|
|
29
29
|
},
|
|
30
|
-
"version": "0.2.
|
|
30
|
+
"version": "0.2.48",
|
|
31
31
|
"description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
|
|
32
32
|
"license": "BSD-3-Clause",
|
|
33
33
|
"type": "module",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- conversation attachments (TODO.new-era/8 Tier 1): a member's uploaded
|
|
2
|
+
-- images persist until they delete them; the R2 object key embeds the
|
|
3
|
+
-- owner's subject so the storage partition is the isolation
|
|
4
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
sub TEXT NOT NULL,
|
|
7
|
+
mime TEXT NOT NULL,
|
|
8
|
+
bytes INTEGER NOT NULL,
|
|
9
|
+
r2_key TEXT NOT NULL,
|
|
10
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
11
|
+
);
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_sub ON attachments (sub);
|
|
@@ -545,6 +545,75 @@ paths:
|
|
|
545
545
|
responses:
|
|
546
546
|
'200':
|
|
547
547
|
description: The shared conversation, without member identity.
|
|
548
|
+
/api/attachments:
|
|
549
|
+
post:
|
|
550
|
+
tags: [Attachments]
|
|
551
|
+
summary: Upload an image attachment
|
|
552
|
+
description: >-
|
|
553
|
+
Stores a member-uploaded image (PNG, JPEG, WebP or GIF, at most
|
|
554
|
+
4 MB) and returns its identifier. The attachment persists until
|
|
555
|
+
the member deletes it. The content is verified from its bytes,
|
|
556
|
+
never from a declared type. The body carries the image as a data
|
|
557
|
+
URL — the same shape the composer produces.
|
|
558
|
+
operationId: uploadAttachment
|
|
559
|
+
requestBody:
|
|
560
|
+
required: true
|
|
561
|
+
content:
|
|
562
|
+
application/json:
|
|
563
|
+
schema:
|
|
564
|
+
type: object
|
|
565
|
+
required: [data_url]
|
|
566
|
+
properties:
|
|
567
|
+
data_url:
|
|
568
|
+
type: string
|
|
569
|
+
description: The image as a data URL.
|
|
570
|
+
responses:
|
|
571
|
+
'201':
|
|
572
|
+
description: The attachment's identifier.
|
|
573
|
+
'400':
|
|
574
|
+
description: The content is not an allowed image, or exceeds the size cap.
|
|
575
|
+
get:
|
|
576
|
+
tags: [Attachments]
|
|
577
|
+
summary: List the member's attachments
|
|
578
|
+
operationId: listAttachments
|
|
579
|
+
responses:
|
|
580
|
+
'200':
|
|
581
|
+
description: The attachment identifiers, newest first.
|
|
582
|
+
/api/attachments/{id}:
|
|
583
|
+
get:
|
|
584
|
+
tags: [Attachments]
|
|
585
|
+
summary: Read one attachment's image
|
|
586
|
+
description: >-
|
|
587
|
+
Streams the stored image. The response is 404 for a foreign
|
|
588
|
+
identifier — ownership is checked on every fetch, and a file
|
|
589
|
+
belonging to another account is indistinguishable from a missing
|
|
590
|
+
one.
|
|
591
|
+
operationId: getAttachment
|
|
592
|
+
parameters:
|
|
593
|
+
- name: id
|
|
594
|
+
in: path
|
|
595
|
+
required: true
|
|
596
|
+
schema: { type: string }
|
|
597
|
+
responses:
|
|
598
|
+
'200':
|
|
599
|
+
description: The image bytes.
|
|
600
|
+
'404':
|
|
601
|
+
description: No such attachment owned by the caller.
|
|
602
|
+
delete:
|
|
603
|
+
tags: [Attachments]
|
|
604
|
+
summary: Delete an attachment
|
|
605
|
+
description: Removes the stored object and its record. Stored until deleted — and this deletes it.
|
|
606
|
+
operationId: deleteAttachment
|
|
607
|
+
parameters:
|
|
608
|
+
- name: id
|
|
609
|
+
in: path
|
|
610
|
+
required: true
|
|
611
|
+
schema: { type: string }
|
|
612
|
+
responses:
|
|
613
|
+
'200':
|
|
614
|
+
description: Deleted.
|
|
615
|
+
'404':
|
|
616
|
+
description: No such attachment owned by the caller.
|
|
548
617
|
/api/memories:
|
|
549
618
|
get:
|
|
550
619
|
tags: [Memory]
|
|
@@ -248,3 +248,13 @@ CREATE TABLE IF NOT EXISTS model_plane_meta (
|
|
|
248
248
|
node_count INTEGER NOT NULL,
|
|
249
249
|
indexed_at TEXT NOT NULL -- when the index landed (operational truth)
|
|
250
250
|
);
|
|
251
|
+
|
|
252
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
253
|
+
id TEXT PRIMARY KEY,
|
|
254
|
+
sub TEXT NOT NULL,
|
|
255
|
+
mime TEXT NOT NULL,
|
|
256
|
+
bytes INTEGER NOT NULL,
|
|
257
|
+
r2_key TEXT NOT NULL,
|
|
258
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
259
|
+
);
|
|
260
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_sub ON attachments (sub);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Conversation attachments (TODO.new-era/8, Tier 1): a member's uploaded
|
|
2
|
+
// image persists until they delete it. The guarantees, in order:
|
|
3
|
+
//
|
|
4
|
+
// 1. NO CROSS-ACCOUNT READS — the R2 object key embeds the owner's
|
|
5
|
+
// subject (`att/{sub}/{id}`), and every route re-checks the D1
|
|
6
|
+
// row's ownership against the caller before touching storage. A
|
|
7
|
+
// logic slip cannot cross accounts, because the key-space itself
|
|
8
|
+
// is partitioned.
|
|
9
|
+
// 2. Stored until deleted — by the owner, or by the cascade when
|
|
10
|
+
// their conversation is deleted. No TTL quietly eats member files.
|
|
11
|
+
// 3. The content is verified, not the extension: the magic bytes
|
|
12
|
+
// decide what the file is.
|
|
13
|
+
//
|
|
14
|
+
// Storage rides the Blobs port and the row rides the StoreQuery port —
|
|
15
|
+
// the module never names a provider binding (the ports purity lint).
|
|
16
|
+
|
|
17
|
+
import type { Blobs } from "./ports/blobs.ts";
|
|
18
|
+
import type { StoreQuery } from "./ports/store.ts";
|
|
19
|
+
|
|
20
|
+
export interface AttachmentDeps {
|
|
21
|
+
blobs: Blobs;
|
|
22
|
+
store: StoreQuery;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAX_BYTES = 4_000_000;
|
|
26
|
+
const ALLOWED: Record<string, string> = {
|
|
27
|
+
"image/png": "png",
|
|
28
|
+
"image/jpeg": "jpg",
|
|
29
|
+
"image/webp": "webp",
|
|
30
|
+
"image/gif": "gif",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface AttachmentRow {
|
|
34
|
+
id: string;
|
|
35
|
+
sub: string;
|
|
36
|
+
mime: string;
|
|
37
|
+
bytes: number;
|
|
38
|
+
created_at: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Sniff the image type from the magic bytes — the declared type is
|
|
42
|
+
* never trusted. Returns null for anything that is not one of the four
|
|
43
|
+
* allowed images or that exceeds the cap. */
|
|
44
|
+
export function sniffImage(head: Uint8Array, totalBytes: number): string | null {
|
|
45
|
+
if (totalBytes > MAX_BYTES || totalBytes < 12) return null;
|
|
46
|
+
if (head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47) return "image/png";
|
|
47
|
+
if (head[0] === 0xff && head[1] === 0xd8) return "image/jpeg";
|
|
48
|
+
if (head.length >= 12 && head[8] === 0x57 && head[9] === 0x45 && head[10] === 0x42 && head[11] === 0x50) return "image/webp";
|
|
49
|
+
if (head[0] === 0x47 && head[1] === 0x49 && head[2] === 0x46) return "image/gif";
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A data URL (the composer's current shape) → { mime, bytes } or null. */
|
|
54
|
+
export function parseDataUrl(dataUrl: string): { mime: string; bytes: Uint8Array } | null {
|
|
55
|
+
const m = /^data:(image\/[a-z+]+);base64,([\s\S]+)$/.exec(dataUrl);
|
|
56
|
+
if (!m) return null;
|
|
57
|
+
const raw = atob(m[2]);
|
|
58
|
+
const bytes = new Uint8Array(raw.length);
|
|
59
|
+
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
60
|
+
const mime = ALLOWED[m[1]] ? m[1] : sniffImage(bytes.subarray(0, 12), bytes.length);
|
|
61
|
+
if (!mime || bytes.length > MAX_BYTES) return null;
|
|
62
|
+
return { mime, bytes };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function uploadAttachment(deps: AttachmentDeps, sub: string, dataUrl: string): Promise<{ id: string } | Response> {
|
|
66
|
+
const parsed = parseDataUrl(dataUrl);
|
|
67
|
+
if (!parsed) {
|
|
68
|
+
return new Response(JSON.stringify({ error: { code: "invalid_input", message: "A PNG, JPEG, WebP or GIF image of at most 4 MB is required" } }), { status: 400, headers: { "content-type": "application/json" } });
|
|
69
|
+
}
|
|
70
|
+
const id = crypto.randomUUID();
|
|
71
|
+
// the key embeds the owner — the storage partition IS the isolation
|
|
72
|
+
const key = `att/${sub}/${id}`;
|
|
73
|
+
await deps.blobs.put(key, parsed.bytes.buffer as ArrayBuffer, parsed.mime);
|
|
74
|
+
await deps.store.prepare("INSERT INTO attachments (id, sub, mime, bytes, r2_key) VALUES (?1,?2,?3,?4,?5)").bind(id, sub, parsed.mime, parsed.bytes.length, key).run();
|
|
75
|
+
return { id };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The ownership check every access runs first: the row must exist and
|
|
79
|
+
* belong to the caller. A foreign id is indistinguishable from a
|
|
80
|
+
* missing one (404 — never a confirmation of someone else's file). */
|
|
81
|
+
export async function ownedAttachment(deps: AttachmentDeps, sub: string, id: string): Promise<AttachmentRow | null> {
|
|
82
|
+
const row = await deps.store.prepare("SELECT id, sub, mime, bytes, created_at FROM attachments WHERE id = ?1 AND sub = ?2").bind(id, sub).first();
|
|
83
|
+
return (row as AttachmentRow) ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function readAttachment(deps: AttachmentDeps, sub: string, id: string): Promise<Response | null> {
|
|
87
|
+
const row = await ownedAttachment(deps, sub, id);
|
|
88
|
+
if (!row) return null;
|
|
89
|
+
const obj = await deps.blobs.get(`att/${row.sub}/${row.id}`);
|
|
90
|
+
if (!obj) return null;
|
|
91
|
+
return new Response(obj.body, {
|
|
92
|
+
headers: { "content-type": obj.contentType ?? row.mime, "cache-control": "private, max-age=86400" },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function deleteAttachment(deps: AttachmentDeps, sub: string, id: string): Promise<boolean> {
|
|
97
|
+
const row = await ownedAttachment(deps, sub, id);
|
|
98
|
+
if (!row) return false;
|
|
99
|
+
await deps.blobs.delete(`att/${sub}/${id}`);
|
|
100
|
+
await deps.store.prepare("DELETE FROM attachments WHERE id = ?1 AND sub = ?2").bind(id, sub).run();
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
@@ -24,6 +24,8 @@ import { json, err, corsHeaders, withCors, readJson, authenticate, type ApiKey }
|
|
|
24
24
|
import { handleSearch } from "./search";
|
|
25
25
|
import { handleEnrich, handleSectionUnit, handleCaption, handleVectors, handleJudge, handleCreateKey, handleListKeys, handleRevokeKey, handleKeyUsage } from "./admin";
|
|
26
26
|
import { handleResearch } from "./research";
|
|
27
|
+
import { uploadAttachment, readAttachment, deleteAttachment, type AttachmentDeps } from "./attachments";
|
|
28
|
+
import { cfBlobs } from "./ports/cloudflare/adapters";
|
|
27
29
|
import { handleAsk } from "./ask";
|
|
28
30
|
import { handleMcp } from "./mcp";
|
|
29
31
|
|
|
@@ -80,6 +82,42 @@ function opWriteRefused(c: RouteContext, who: { write: boolean } | Response): Re
|
|
|
80
82
|
return null;
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
async function attachmentsUploadRoute(c: RouteContext): Promise<Response> {
|
|
86
|
+
const session = await sessionFrom(c.req, c.env as any);
|
|
87
|
+
if (!session) return withCors(err(401, "unauthorized", "Sign in to store attachments"), corsHeaders(c.req));
|
|
88
|
+
const deps: AttachmentDeps = { blobs: cfBlobs(c.env.CHAT_UPLOADS), store: c.env.DB };
|
|
89
|
+
const body = await readJson(c.req);
|
|
90
|
+
const out = await uploadAttachment(deps, session.sub, String(body?.data_url ?? ""));
|
|
91
|
+
if (out instanceof Response) return withCors(out, corsHeaders(c.req));
|
|
92
|
+
return withCors(json({ id: out.id }, 201), corsHeaders(c.req));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function attachmentsListRoute(c: RouteContext): Promise<Response> {
|
|
96
|
+
const session = await sessionFrom(c.req, c.env as any);
|
|
97
|
+
if (!session) return withCors(err(401, "unauthorized", "Sign in to list attachments"), corsHeaders(c.req));
|
|
98
|
+
const rows = await c.env.DB.prepare("SELECT id, mime, bytes, created_at FROM attachments WHERE sub = ?1 ORDER BY created_at DESC LIMIT 200")
|
|
99
|
+
.bind(session.sub)
|
|
100
|
+
.all();
|
|
101
|
+
return withCors(json({ attachments: rows.results ?? [] }), corsHeaders(c.req));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function attachmentsGetRoute(c: RouteContext): Promise<Response> {
|
|
105
|
+
const session = await sessionFrom(c.req, c.env as any);
|
|
106
|
+
if (!session) return err(404, "not_found", "No such attachment");
|
|
107
|
+
const deps: AttachmentDeps = { blobs: cfBlobs(c.env.CHAT_UPLOADS), store: c.env.DB };
|
|
108
|
+
const out = await readAttachment(deps, session.sub, c.params.id!);
|
|
109
|
+
return out ?? err(404, "not_found", "No such attachment");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function attachmentsDeleteRoute(c: RouteContext): Promise<Response> {
|
|
113
|
+
const session = await sessionFrom(c.req, c.env as any);
|
|
114
|
+
if (!session) return withCors(err(401, "unauthorized", "Sign in to manage attachments"), corsHeaders(c.req));
|
|
115
|
+
const deps: AttachmentDeps = { blobs: cfBlobs(c.env.CHAT_UPLOADS), store: c.env.DB };
|
|
116
|
+
const gone = await deleteAttachment(deps, session.sub, c.params.id!);
|
|
117
|
+
if (!gone) return withCors(err(404, "not_found", "No such attachment"), corsHeaders(c.req));
|
|
118
|
+
return withCors(json({ ok: true }), corsHeaders(c.req));
|
|
119
|
+
}
|
|
120
|
+
|
|
83
121
|
async function memoriesRoute(c: RouteContext): Promise<Response> {
|
|
84
122
|
const who = await opIdentity(c, "Sign in to use memory files");
|
|
85
123
|
if (who instanceof Response) return withCors(who, corsHeaders(c.req));
|
|
@@ -459,8 +497,11 @@ async function feedbackRoute(c: RouteContext): Promise<Response> {
|
|
|
459
497
|
if (!/^[a-f0-9]{64}$/.test(queryHash) || ![1, -1].includes(rating)) {
|
|
460
498
|
return withCors(err(400, "invalid_input", "query_hash and rating (1 or -1) are required"), corsHeaders(c.req));
|
|
461
499
|
}
|
|
462
|
-
|
|
463
|
-
|
|
500
|
+
// an optional free-text note (the reason chips) - capped, stored with
|
|
501
|
+
// the vote, and read by the triage script's clustering
|
|
502
|
+
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 280) : "";
|
|
503
|
+
await c.env.DB.prepare("INSERT INTO feedback (query_hash, rating, ts, note) VALUES (?1,?2,?3,?4)")
|
|
504
|
+
.bind(queryHash, rating, new Date().toISOString(), note || null)
|
|
464
505
|
.run();
|
|
465
506
|
return json({ ok: true, ...corsHeaders(c.req) });
|
|
466
507
|
}
|
|
@@ -580,6 +621,10 @@ const OPENAPI_HANDLERS: Record<OpenApiOperationId, RouteHandler> = {
|
|
|
580
621
|
appendMessage: appendMessageRoute,
|
|
581
622
|
shareConversation: shareRoute,
|
|
582
623
|
getShared: getSharedRoute,
|
|
624
|
+
uploadAttachment: attachmentsUploadRoute,
|
|
625
|
+
listAttachments: attachmentsListRoute,
|
|
626
|
+
getAttachment: attachmentsGetRoute,
|
|
627
|
+
deleteAttachment: attachmentsDeleteRoute,
|
|
583
628
|
listMemories: memoriesRoute,
|
|
584
629
|
createMemory: memoriesRoute,
|
|
585
630
|
deleteMemory: memoriesRoute,
|
|
@@ -29,6 +29,10 @@ export type OpenApiOperationId =
|
|
|
29
29
|
| "appendMessage"
|
|
30
30
|
| "shareConversation"
|
|
31
31
|
| "getShared"
|
|
32
|
+
| "uploadAttachment"
|
|
33
|
+
| "listAttachments"
|
|
34
|
+
| "getAttachment"
|
|
35
|
+
| "deleteAttachment"
|
|
32
36
|
| "listMemories"
|
|
33
37
|
| "createMemory"
|
|
34
38
|
| "deleteMemory"
|
|
@@ -170,6 +174,26 @@ export const OPENAPI_SURFACE: readonly (Omit<OpenApiRoute, "operationId"> & { op
|
|
|
170
174
|
"pattern": "/api/shared/:slug",
|
|
171
175
|
"operationId": "getShared"
|
|
172
176
|
},
|
|
177
|
+
{
|
|
178
|
+
"method": "POST",
|
|
179
|
+
"pattern": "/api/attachments",
|
|
180
|
+
"operationId": "uploadAttachment"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"method": "GET",
|
|
184
|
+
"pattern": "/api/attachments",
|
|
185
|
+
"operationId": "listAttachments"
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"method": "GET",
|
|
189
|
+
"pattern": "/api/attachments/:id",
|
|
190
|
+
"operationId": "getAttachment"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"method": "DELETE",
|
|
194
|
+
"pattern": "/api/attachments/:id",
|
|
195
|
+
"operationId": "deleteAttachment"
|
|
196
|
+
},
|
|
173
197
|
{
|
|
174
198
|
"method": "GET",
|
|
175
199
|
"pattern": "/api/memories",
|
|
@@ -164,6 +164,9 @@ export function cfBlobs(bucket: unknown): Blobs {
|
|
|
164
164
|
async put(key, value, contentType) {
|
|
165
165
|
await r2.put(key, value, contentType ? { httpMetadata: { contentType } } : undefined);
|
|
166
166
|
},
|
|
167
|
+
async delete(key) {
|
|
168
|
+
await r2.delete(key);
|
|
169
|
+
},
|
|
167
170
|
};
|
|
168
171
|
}
|
|
169
172
|
|