Skip to content

Commit c1b64d6

Browse files
authored
feat(kilo-chat): chat backend service (#2410)
* feat(kilo-chat): scaffold service with wrangler, vitest, drizzle * feat(kilo-chat): add ULID generation utility * feat(kilo-chat): add SSE formatting helpers * feat(kilo-chat): add Drizzle schemas for ConversationDO and MembershipDO * feat(kilo-chat): add dual auth middleware (JWT + API key) * feat(kilo-chat): add MembershipDO with conversation index Per-user/bot Durable Object storing a conversation membership list with CRUD ops (add, list, update lastMessageId, remove). Includes vitest tests and updated wrangler types. * feat(kilo-chat): add ConversationDO with message CRUD * feat(kilo-chat): add conversation routes (create, list, get) Implements POST /v1/conversations, GET /v1/conversations, and GET /v1/conversations/:id behind auth middleware, with full integration test coverage. * feat(kilo-chat): add message routes (create, list, edit, delete) Implements POST /v1/messages, GET /v1/conversations/:id/messages, PATCH /v1/messages/:id, and DELETE /v1/messages/:id with membership checks, webhook queue enqueue for bot members, and MembershipDO lastMessageId updates. * feat(kilo-chat): add SSE events endpoint with fan-out and replay - Add in-memory SSE client tracking and broadcast() to ConversationDO - Broadcast message.created, message.updated, message.deleted events after each DB write - Add fetch() handler on ConversationDO to handle /subscribe with member auth - Support Last-Event-ID replay by querying messages with id > lastEventId - Add alarm() keepalive that pings all connected clients every 30s - Add /v1/conversations/:id/events route that forwards to DO's fetch handler - Tests: access control (403/404), broadcast no-crash, streaming header checks, replay verification - Streaming tests placed last in file to work around miniflare SQLite WAL isolated storage limitation * feat(kilo-chat): add typing indicator endpoint with SSE broadcast * feat(kilo-chat): add webhook queue delivery with HMAC signing Implements the WEBHOOK_QUEUE consumer that delivers HMAC-SHA256 signed payloads to the kiloclaw webhook endpoint, with per-message ack/retry and graceful handling when secrets are not configured. * fix(kilo-chat): resolve all oxlint errors * refactor(kilo-chat): replace hand-rolled ULID with ulid package, use monotonicFactory for message IDs * fix(kilo-chat): review fixes — webhook payload, timing-safe auth, SSE replay, writer cleanup - Fix webhook queue message shape to match WebhookMessage type (was sending wrong fields) - Use constant-time comparison for API key auth via crypto.subtle.timingSafeEqual - SSE replay always sends message.created for missed messages (client never saw them) - Close dead SSE writers on disconnect to prevent resource leaks - Remove redundant callerKind guard in message creation * fix(kilo-chat): review round 2 — timing-safe fix, server-controlled versioning, single webhook - Remove redundant string-length check in timingSafeEqual (keep byte-length only) - Server now controls message version (increments from current), not client-supplied - Send one webhook per message (not one per bot member) since payloads are identical - Allow version 0 in edit schema for stale clients * fix(kilo-chat): harden webhook error path against body read failure * refactor(kilo-chat): rename KILOCHAT_API_KEY to KILOCHAT_API_TOKEN to match kiloclaw * feat(kilo-chat): add chat.kiloapps.io custom domain route * fix(kilo-chat): bind NEXTAUTH_SECRET to NEXTAUTH_SECRET_PROD in secrets store * fix(kiloclaw/kilo-chat): update plugin to send content blocks instead of flat text Replace `text: string` with `content: ContentBlock[]` in CreateMessageParams and EditMessageParams, wrap text strings in `[{ type: 'text', text }]` blocks at all call sites (preview-stream and webhook deliver), and update all three test files to match. Also change the e2e script SANDBOX_ID default to 'e2e-test-sandbox'. * fix(kilo-chat): add zod validation at all input boundaries
1 parent b636200 commit c1b64d6

45 files changed

Lines changed: 14750 additions & 40 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pnpm-lock.yaml

Lines changed: 42 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'drizzle-kit';
2+
export default defineConfig({
3+
out: './drizzle/conversation',
4+
schema: './src/db/conversation-schema.ts',
5+
dialect: 'sqlite',
6+
driver: 'durable-sqlite',
7+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'drizzle-kit';
2+
export default defineConfig({
3+
out: './drizzle/membership',
4+
schema: './src/db/membership-schema.ts',
5+
dialect: 'sqlite',
6+
driver: 'durable-sqlite',
7+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
CREATE TABLE `conversation` (
2+
`id` text PRIMARY KEY NOT NULL,
3+
`title` text,
4+
`created_by` text NOT NULL,
5+
`created_at` integer NOT NULL
6+
);
7+
--> statement-breakpoint
8+
CREATE TABLE `members` (
9+
`id` text PRIMARY KEY NOT NULL,
10+
`kind` text NOT NULL,
11+
`joined_at` integer NOT NULL
12+
);
13+
--> statement-breakpoint
14+
CREATE TABLE `messages` (
15+
`id` text PRIMARY KEY NOT NULL,
16+
`sender_id` text NOT NULL,
17+
`content` text NOT NULL,
18+
`in_reply_to_message_id` text,
19+
`version` integer DEFAULT 1 NOT NULL,
20+
`updated_at` integer,
21+
`deleted` integer DEFAULT 0 NOT NULL
22+
);
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
{
2+
"version": "6",
3+
"dialect": "sqlite",
4+
"id": "bccc296c-3003-4ee0-9589-b866db164844",
5+
"prevId": "00000000-0000-0000-0000-000000000000",
6+
"tables": {
7+
"conversation": {
8+
"name": "conversation",
9+
"columns": {
10+
"id": {
11+
"name": "id",
12+
"type": "text",
13+
"primaryKey": true,
14+
"notNull": true,
15+
"autoincrement": false
16+
},
17+
"title": {
18+
"name": "title",
19+
"type": "text",
20+
"primaryKey": false,
21+
"notNull": false,
22+
"autoincrement": false
23+
},
24+
"created_by": {
25+
"name": "created_by",
26+
"type": "text",
27+
"primaryKey": false,
28+
"notNull": true,
29+
"autoincrement": false
30+
},
31+
"created_at": {
32+
"name": "created_at",
33+
"type": "integer",
34+
"primaryKey": false,
35+
"notNull": true,
36+
"autoincrement": false
37+
}
38+
},
39+
"indexes": {},
40+
"foreignKeys": {},
41+
"compositePrimaryKeys": {},
42+
"uniqueConstraints": {},
43+
"checkConstraints": {}
44+
},
45+
"members": {
46+
"name": "members",
47+
"columns": {
48+
"id": {
49+
"name": "id",
50+
"type": "text",
51+
"primaryKey": true,
52+
"notNull": true,
53+
"autoincrement": false
54+
},
55+
"kind": {
56+
"name": "kind",
57+
"type": "text",
58+
"primaryKey": false,
59+
"notNull": true,
60+
"autoincrement": false
61+
},
62+
"joined_at": {
63+
"name": "joined_at",
64+
"type": "integer",
65+
"primaryKey": false,
66+
"notNull": true,
67+
"autoincrement": false
68+
}
69+
},
70+
"indexes": {},
71+
"foreignKeys": {},
72+
"compositePrimaryKeys": {},
73+
"uniqueConstraints": {},
74+
"checkConstraints": {}
75+
},
76+
"messages": {
77+
"name": "messages",
78+
"columns": {
79+
"id": {
80+
"name": "id",
81+
"type": "text",
82+
"primaryKey": true,
83+
"notNull": true,
84+
"autoincrement": false
85+
},
86+
"sender_id": {
87+
"name": "sender_id",
88+
"type": "text",
89+
"primaryKey": false,
90+
"notNull": true,
91+
"autoincrement": false
92+
},
93+
"content": {
94+
"name": "content",
95+
"type": "text",
96+
"primaryKey": false,
97+
"notNull": true,
98+
"autoincrement": false
99+
},
100+
"in_reply_to_message_id": {
101+
"name": "in_reply_to_message_id",
102+
"type": "text",
103+
"primaryKey": false,
104+
"notNull": false,
105+
"autoincrement": false
106+
},
107+
"version": {
108+
"name": "version",
109+
"type": "integer",
110+
"primaryKey": false,
111+
"notNull": true,
112+
"autoincrement": false,
113+
"default": 1
114+
},
115+
"updated_at": {
116+
"name": "updated_at",
117+
"type": "integer",
118+
"primaryKey": false,
119+
"notNull": false,
120+
"autoincrement": false
121+
},
122+
"deleted": {
123+
"name": "deleted",
124+
"type": "integer",
125+
"primaryKey": false,
126+
"notNull": true,
127+
"autoincrement": false,
128+
"default": 0
129+
}
130+
},
131+
"indexes": {},
132+
"foreignKeys": {},
133+
"compositePrimaryKeys": {},
134+
"uniqueConstraints": {},
135+
"checkConstraints": {}
136+
}
137+
},
138+
"views": {},
139+
"enums": {},
140+
"_meta": {
141+
"schemas": {},
142+
"tables": {},
143+
"columns": {}
144+
},
145+
"internal": {
146+
"indexes": {}
147+
}
148+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"version": "7",
3+
"dialect": "sqlite",
4+
"entries": [
5+
{
6+
"idx": 0,
7+
"version": "6",
8+
"when": 1776174192890,
9+
"tag": "0000_typical_lady_deathstrike",
10+
"breakpoints": true
11+
}
12+
]
13+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import journal from './meta/_journal.json';
2+
import m0000 from './0000_typical_lady_deathstrike.sql';
3+
4+
export default {
5+
journal,
6+
migrations: {
7+
m0000,
8+
},
9+
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CREATE TABLE `conversations` (
2+
`conversation_id` text PRIMARY KEY NOT NULL,
3+
`conversation_title` text,
4+
`last_message_id` text,
5+
`last_read_message_id` text,
6+
`joined_at` integer NOT NULL
7+
);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"version": "6",
3+
"dialect": "sqlite",
4+
"id": "634e621f-31e6-4ad3-b897-1aadef274e84",
5+
"prevId": "00000000-0000-0000-0000-000000000000",
6+
"tables": {
7+
"conversations": {
8+
"name": "conversations",
9+
"columns": {
10+
"conversation_id": {
11+
"name": "conversation_id",
12+
"type": "text",
13+
"primaryKey": true,
14+
"notNull": true,
15+
"autoincrement": false
16+
},
17+
"conversation_title": {
18+
"name": "conversation_title",
19+
"type": "text",
20+
"primaryKey": false,
21+
"notNull": false,
22+
"autoincrement": false
23+
},
24+
"last_message_id": {
25+
"name": "last_message_id",
26+
"type": "text",
27+
"primaryKey": false,
28+
"notNull": false,
29+
"autoincrement": false
30+
},
31+
"last_read_message_id": {
32+
"name": "last_read_message_id",
33+
"type": "text",
34+
"primaryKey": false,
35+
"notNull": false,
36+
"autoincrement": false
37+
},
38+
"joined_at": {
39+
"name": "joined_at",
40+
"type": "integer",
41+
"primaryKey": false,
42+
"notNull": true,
43+
"autoincrement": false
44+
}
45+
},
46+
"indexes": {},
47+
"foreignKeys": {},
48+
"compositePrimaryKeys": {},
49+
"uniqueConstraints": {},
50+
"checkConstraints": {}
51+
}
52+
},
53+
"views": {},
54+
"enums": {},
55+
"_meta": {
56+
"schemas": {},
57+
"tables": {},
58+
"columns": {}
59+
},
60+
"internal": {
61+
"indexes": {}
62+
}
63+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"version": "7",
3+
"dialect": "sqlite",
4+
"entries": [
5+
{
6+
"idx": 0,
7+
"version": "6",
8+
"when": 1776174193328,
9+
"tag": "0000_high_trish_tilby",
10+
"breakpoints": true
11+
}
12+
]
13+
}

0 commit comments

Comments
 (0)