@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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-SE67PTJ6.js";
4
- import "./chunk-N2UOENDY.js";
3
+ } from "./chunk-SONICKNY.js";
4
+ import "./chunk-IUMHKBOY.js";
5
5
  import "./chunk-R2V3X6SQ.js";
6
6
  import "./chunk-A3QHHUN5.js";
7
7
  import "./chunk-4GJGBGJK.js";
@@ -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>;
@@ -7,7 +7,7 @@ import {
7
7
  sessionFrom,
8
8
  telemetry,
9
9
  understandQuery
10
- } from "./chunk-N2UOENDY.js";
10
+ } from "./chunk-IUMHKBOY.js";
11
11
  import {
12
12
  corsHeaders,
13
13
  err,
@@ -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,
@@ -35,7 +35,7 @@ import {
35
35
  syntheticUnderstanding,
36
36
  telemetry,
37
37
  understandQuery
38
- } from "./chunk-N2UOENDY.js";
38
+ } from "./chunk-IUMHKBOY.js";
39
39
  import {
40
40
  corsHeaders,
41
41
  err,
package/dist/env.d.ts CHANGED
@@ -20,6 +20,7 @@ export interface Env {
20
20
  ASSETS: Fetcher;
21
21
  INDEX_VERSION: string;
22
22
  UNIT_ASSETS: R2Bucket;
23
+ CHAT_UPLOADS: R2Bucket;
23
24
  ANON_DAY_ASK: string;
24
25
  ANON_DAY_SEARCH: string;
25
26
  KEY_DAY_ASK_DEFAULT: string;
@@ -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
  })[];
@@ -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,7 +1,7 @@
1
1
  import {
2
2
  handleSearch
3
- } from "./chunk-AKAFLMIB.js";
4
- import "./chunk-N2UOENDY.js";
3
+ } from "./chunk-47VBMM7I.js";
4
+ import "./chunk-IUMHKBOY.js";
5
5
  import "./chunk-R2V3X6SQ.js";
6
6
  import "./chunk-A3QHHUN5.js";
7
7
  import "./chunk-4GJGBGJK.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  handleSearch
3
- } from "../../chunk-AKAFLMIB.js";
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-SE67PTJ6.js";
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-N2UOENDY.js";
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-6QPD3QA5.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-QZDQVB57.js")).handleSearch(env, ctx, inner, tier, key);
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
- await c.env.DB.prepare("INSERT INTO feedback (query_hash, rating, ts) VALUES (?1,?2,?3)").bind(queryHash, rating, (/* @__PURE__ */ new Date()).toISOString()).run();
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.46",
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,3 @@
1
+ -- the feedback vote's optional reason (the 👎 reason chips) — read by the
2
+ -- triage clustering, never by the answer path
3
+ ALTER TABLE feedback ADD COLUMN note TEXT;
@@ -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
+ }
@@ -22,6 +22,7 @@ export interface Env {
22
22
  ASSETS: Fetcher;
23
23
  INDEX_VERSION: string;
24
24
  UNIT_ASSETS: R2Bucket;
25
+ CHAT_UPLOADS: R2Bucket;
25
26
  ANON_DAY_ASK: string;
26
27
  ANON_DAY_SEARCH: string;
27
28
  KEY_DAY_ASK_DEFAULT: string;
@@ -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
- await c.env.DB.prepare("INSERT INTO feedback (query_hash, rating, ts) VALUES (?1,?2,?3)")
463
- .bind(queryHash, rating, new Date().toISOString())
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",
@@ -4,4 +4,5 @@
4
4
  export interface Blobs {
5
5
  get(key: string): Promise<{ body: ReadableStream | null; contentType?: string } | null>;
6
6
  put(key: string, value: string | ReadableStream | ArrayBuffer, contentType?: string): Promise<void>;
7
+ delete(key: string): Promise<void>;
7
8
  }
@@ -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