@tangle-network/create-agent-app 0.44.36

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.
Files changed (42) hide show
  1. package/README.md +9 -0
  2. package/index.mjs +183 -0
  3. package/package.json +34 -0
  4. package/template/.dev.vars.example +11 -0
  5. package/template/AGENTS.md +67 -0
  6. package/template/CLAUDE.md +6 -0
  7. package/template/CUSTOMIZE.md +83 -0
  8. package/template/KNOWLEDGE.md +73 -0
  9. package/template/README.md +41 -0
  10. package/template/_gitignore +5 -0
  11. package/template/_package.json +33 -0
  12. package/template/_tsconfig.json +16 -0
  13. package/template/_wrangler.toml +22 -0
  14. package/template/agent.config.ts +113 -0
  15. package/template/knowledge/.gitkeep +0 -0
  16. package/template/knowledge/README.md +24 -0
  17. package/template/scripts/knowledge-ingest.mjs +86 -0
  18. package/template/src/agent-app.ts +77 -0
  19. package/template/src/worker.ts +128 -0
  20. package/template/tests/agent-app.test.ts +71 -0
  21. package/template/vitest.config.ts +7 -0
  22. package/template-chat/.dev.vars.example +18 -0
  23. package/template-chat/AGENTS.md +73 -0
  24. package/template-chat/CLAUDE.md +6 -0
  25. package/template-chat/CUSTOMIZE.md +88 -0
  26. package/template-chat/README.md +50 -0
  27. package/template-chat/_gitignore +6 -0
  28. package/template-chat/_package.json +38 -0
  29. package/template-chat/_tsconfig.json +16 -0
  30. package/template-chat/_wrangler.toml +38 -0
  31. package/template-chat/agent.config.ts +73 -0
  32. package/template-chat/declarations.d.ts +9 -0
  33. package/template-chat/migrations/0001_init.sql +109 -0
  34. package/template-chat/prompts/system.md +11 -0
  35. package/template-chat/public/index.html +236 -0
  36. package/template-chat/src/chat.ts +210 -0
  37. package/template-chat/src/db/schema.ts +70 -0
  38. package/template-chat/src/env.ts +33 -0
  39. package/template-chat/src/sandbox.ts +159 -0
  40. package/template-chat/src/worker.ts +58 -0
  41. package/template-chat/tests/chat-turn.e2e.test.ts +277 -0
  42. package/template-chat/vitest.config.ts +22 -0
@@ -0,0 +1,236 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>__PROJECT_NAME__ — dev chat</title>
7
+ <!--
8
+ The DEV chat page: a dependency-free client for the API this worker ships,
9
+ good enough to exercise auth, threads, multimodal turns, and streaming from
10
+ a browser today. It is NOT the product surface — build that in React with
11
+ @tangle-network/agent-app/web-react (ChatComposer, ChatMessages,
12
+ streamChatTurn, useChatInteractions), which owns reconnect/replay, question
13
+ cards, and the typed part renderers. See CUSTOMIZE.md step 5.
14
+ -->
15
+ <style>
16
+ :root { color-scheme: light dark; --border: #d0d3da; --muted: #6b7280; --accent: #4f46e5; }
17
+ @media (prefers-color-scheme: dark) { :root { --border: #333842; --muted: #9aa1ad; --accent: #818cf8; } }
18
+ * { box-sizing: border-box; }
19
+ body { margin: 0; font: 15px/1.5 system-ui, sans-serif; display: flex; height: 100dvh; }
20
+ aside { width: 240px; border-right: 1px solid var(--border); padding: 12px; display: flex; flex-direction: column; gap: 8px; }
21
+ main { flex: 1; display: flex; flex-direction: column; }
22
+ header { padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
23
+ #transcript { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
24
+ .msg { max-width: 52rem; white-space: pre-wrap; }
25
+ .msg.user { align-self: flex-end; background: color-mix(in srgb, var(--accent) 12%, transparent); border-radius: 10px; padding: 8px 12px; }
26
+ .msg .meta { font-size: 12px; color: var(--muted); }
27
+ .chip { display: inline-block; font-size: 12px; border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; margin: 2px 4px 2px 0; color: var(--muted); }
28
+ .reasoning { color: var(--muted); font-size: 13px; }
29
+ form.composer { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
30
+ textarea { flex: 1; resize: none; font: inherit; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: inherit; }
31
+ button { font: inherit; border: 1px solid var(--border); background: transparent; color: inherit; border-radius: 8px; padding: 6px 12px; cursor: pointer; }
32
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
33
+ .thread { text-align: left; border: none; padding: 6px 8px; border-radius: 6px; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
34
+ .thread.active { background: color-mix(in srgb, var(--accent) 15%, transparent); }
35
+ dialog { border: 1px solid var(--border); border-radius: 12px; padding: 20px; min-width: 320px; }
36
+ dialog form { display: flex; flex-direction: column; gap: 10px; }
37
+ dialog input { font: inherit; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: inherit; }
38
+ #files { padding: 0 16px; }
39
+ .error { color: #dc2626; font-size: 13px; padding: 0 16px; }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <aside>
44
+ <button id="new-thread" class="primary">New thread</button>
45
+ <nav id="threads" style="overflow-y:auto"></nav>
46
+ </aside>
47
+ <main>
48
+ <header>
49
+ <strong>__PROJECT_NAME__ <span style="color:var(--muted);font-weight:400">dev chat</span></strong>
50
+ <span id="who" style="color:var(--muted);font-size:13px"></span>
51
+ </header>
52
+ <div id="transcript"></div>
53
+ <div id="files"></div>
54
+ <div id="error" class="error"></div>
55
+ <form class="composer" id="composer">
56
+ <button type="button" id="attach" title="Attach files">+</button>
57
+ <input type="file" id="file-input" multiple hidden />
58
+ <textarea id="input" rows="2" placeholder="Message the agent…"></textarea>
59
+ <button type="submit" class="primary">Send</button>
60
+ </form>
61
+ </main>
62
+
63
+ <dialog id="auth">
64
+ <form method="dialog" id="auth-form">
65
+ <strong id="auth-title">Sign in</strong>
66
+ <input id="email" type="email" placeholder="email" required autocomplete="email" />
67
+ <input id="password" type="password" placeholder="password" required minlength="8" autocomplete="current-password" />
68
+ <button class="primary" value="submit">Continue</button>
69
+ <button type="button" id="auth-toggle" style="border:none;color:var(--muted)">No account? Sign up</button>
70
+ <span id="auth-error" class="error" style="padding:0"></span>
71
+ </form>
72
+ </dialog>
73
+
74
+ <script type="module">
75
+ const $ = (id) => document.getElementById(id)
76
+ const state = { threadId: null, parts: [], signUp: false }
77
+
78
+ const api = async (path, init) => {
79
+ const res = await fetch(path, { headers: { 'content-type': 'application/json' }, ...init })
80
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? `${res.status} on ${path}`)
81
+ return res.json()
82
+ }
83
+
84
+ // ── auth ────────────────────────────────────────────────────────────────────
85
+ async function ensureSession() {
86
+ const session = await fetch('/api/auth/get-session').then((r) => r.json()).catch(() => null)
87
+ if (session?.user) { $('who').textContent = session.user.email; return }
88
+ $('auth').showModal()
89
+ await new Promise((resolve) => {
90
+ $('auth-toggle').onclick = () => {
91
+ state.signUp = !state.signUp
92
+ $('auth-title').textContent = state.signUp ? 'Sign up' : 'Sign in'
93
+ $('auth-toggle').textContent = state.signUp ? 'Have an account? Sign in' : 'No account? Sign up'
94
+ }
95
+ $('auth-form').onsubmit = async (e) => {
96
+ e.preventDefault()
97
+ const email = $('email').value, password = $('password').value
98
+ try {
99
+ await api(`/api/auth/sign-${state.signUp ? 'up' : 'in'}/email`, {
100
+ method: 'POST',
101
+ body: JSON.stringify({ email, password, ...(state.signUp ? { name: email.split('@')[0] } : {}) }),
102
+ })
103
+ $('who').textContent = email
104
+ $('auth').close(); resolve()
105
+ } catch (err) { $('auth-error').textContent = err.message }
106
+ }
107
+ })
108
+ }
109
+
110
+ // ── threads + transcript ────────────────────────────────────────────────────
111
+ async function loadThreads() {
112
+ const { threads } = await api('/api/threads')
113
+ $('threads').replaceChildren(...threads.map((t) => {
114
+ const b = document.createElement('button')
115
+ b.className = `thread${t.id === state.threadId ? ' active' : ''}`
116
+ b.textContent = t.title
117
+ b.onclick = () => openThread(t.id)
118
+ return b
119
+ }))
120
+ }
121
+
122
+ function row(role, text = '') {
123
+ const div = document.createElement('div')
124
+ div.className = `msg ${role}`
125
+ div.textContent = text
126
+ $('transcript').append(div)
127
+ div.scrollIntoView()
128
+ return div
129
+ }
130
+
131
+ // Chips are built with textContent — part fields are agent-produced strings
132
+ // and must never be interpreted as markup.
133
+ function chip(container, text) {
134
+ const wrap = document.createElement('div')
135
+ const span = document.createElement('span')
136
+ span.className = 'chip'
137
+ span.textContent = text
138
+ wrap.append(span)
139
+ container.append(wrap)
140
+ }
141
+
142
+ function renderParts(container, parts = []) {
143
+ for (const p of parts) {
144
+ if (p.type === 'tool') chip(container, `${p.tool ?? 'tool'} · ${p.state?.status ?? ''}`)
145
+ if (p.type === 'file' || p.type === 'image') chip(container, `📎 ${p.filename ?? p.path ?? p.type}`)
146
+ }
147
+ }
148
+
149
+ async function openThread(id) {
150
+ state.threadId = id
151
+ const { messages } = await api(`/api/threads/${id}/messages`)
152
+ $('transcript').replaceChildren()
153
+ for (const m of messages) {
154
+ const div = row(m.role, m.content)
155
+ renderParts(div, m.parts)
156
+ if (m.role === 'assistant' && m.inputTokens != null) {
157
+ const meta = document.createElement('div')
158
+ meta.className = 'meta'
159
+ meta.textContent = `${m.model ?? ''} · ${m.inputTokens}→${m.outputTokens} tokens`
160
+ div.append(meta)
161
+ }
162
+ }
163
+ loadThreads()
164
+ }
165
+
166
+ // ── uploads → prompt parts ──────────────────────────────────────────────────
167
+ $('attach').onclick = () => $('file-input').click()
168
+ $('file-input').onchange = async () => {
169
+ const form = new FormData()
170
+ for (const f of $('file-input').files) form.append('files', f)
171
+ const res = await fetch('/api/chat/upload', { method: 'POST', body: form })
172
+ if (!res.ok) { $('error').textContent = (await res.json()).error; return }
173
+ const { files } = await res.json()
174
+ state.parts.push(...files.map((f) => f.part))
175
+ $('files').replaceChildren(...state.parts.map((p) => Object.assign(document.createElement('span'), { className: 'chip', textContent: p.filename ?? p.path ?? p.type })))
176
+ }
177
+
178
+ // ── one streamed turn ───────────────────────────────────────────────────────
179
+ // Minimal NDJSON consumer for the dev page. The production client is
180
+ // streamChatTurn from @tangle-network/agent-app/web-react — it also resumes
181
+ // via /api/chat/replay/:turnId after a dropped connection.
182
+ async function runTurn(content, parts) {
183
+ const res = await fetch('/api/chat', {
184
+ method: 'POST',
185
+ headers: { 'content-type': 'application/json' },
186
+ body: JSON.stringify({ threadId: state.threadId, content, parts }),
187
+ })
188
+ if (!res.ok) { $('error').textContent = (await res.json()).error; return }
189
+ const assistant = row('assistant')
190
+ let text = ''
191
+ const reader = res.body.getReader()
192
+ const decoder = new TextDecoder()
193
+ let buffer = ''
194
+ const handle = (line) => {
195
+ if (!line.trim()) return
196
+ let parsed; try { parsed = JSON.parse(line) } catch { return }
197
+ const evt = parsed.kind === 'event' ? parsed.event : parsed
198
+ if (evt.type === 'text') { text += evt.text; assistant.textContent = text }
199
+ else if (evt.type === 'reasoning') { /* keep the dev page quiet; web-react renders these */ }
200
+ else if (evt.type === 'tool_call') chip(assistant, `${evt.call?.toolName ?? 'tool'}…`)
201
+ else if (evt.type === 'error') $('error').textContent = String(evt.details ?? evt.error)
202
+ assistant.scrollIntoView()
203
+ }
204
+ for (;;) {
205
+ const { done, value } = await reader.read()
206
+ if (done) { if (buffer.trim()) handle(buffer); break }
207
+ buffer += decoder.decode(value, { stream: true })
208
+ const lines = buffer.split('\n'); buffer = lines.pop() ?? ''
209
+ lines.forEach(handle)
210
+ }
211
+ openThread(state.threadId)
212
+ }
213
+
214
+ $('composer').onsubmit = async (e) => {
215
+ e.preventDefault()
216
+ const content = $('input').value.trim()
217
+ const parts = state.parts.splice(0)
218
+ if (!content && parts.length === 0) return
219
+ $('error').textContent = ''
220
+ $('files').replaceChildren()
221
+ $('input').value = ''
222
+ if (!state.threadId) {
223
+ const { thread } = await api('/api/threads', { method: 'POST', body: JSON.stringify({ firstMessage: content || 'New thread' }) })
224
+ state.threadId = thread.id
225
+ }
226
+ row('user', content)
227
+ await runTurn(content, parts).catch((err) => { $('error').textContent = err.message })
228
+ }
229
+
230
+ $('new-thread').onclick = () => { state.threadId = null; $('transcript').replaceChildren() }
231
+
232
+ await ensureSession()
233
+ await loadThreads()
234
+ </script>
235
+ </body>
236
+ </html>
@@ -0,0 +1,210 @@
1
+ /**
2
+ * src/chat.ts — the COMPOSER. The whole server chat vertical assembled from
3
+ * `@tangle-network/agent-app` factories, exactly the `examples/chat-app.md`
4
+ * assembly made runnable:
5
+ *
6
+ * auth `createAppAuth` (better-auth over drizzle/D1) + its guards
7
+ * persistence `createChatStore` over the tables in `src/db/schema.ts`
8
+ * turn `createChatTurnRoutes` — body validation, turn identity,
9
+ * the default turn-buffer tap (replay after a drop), user and
10
+ * assistant rows persisted with typed parts + usage receipt
11
+ * producer the sandbox lane from `src/sandbox.ts`
12
+ * uploads `createUploadRoute` — small files inline (`data:` URI),
13
+ * large files into the sandbox workspace by path
14
+ * asks `/interactions` list/answer endpoints over the sidecar
15
+ *
16
+ * You extend THIS file (and `src/worker.ts`); you never edit the shell. The
17
+ * `overrides` seams exist so the e2e test in `tests/` can run the identical
18
+ * assembly against an in-memory database and a fake sandbox producer — the
19
+ * production wiring is the zero-override call.
20
+ *
21
+ * Workspaces are single-user here (workspace id = user id). Multi-user teams
22
+ * later: `@tangle-network/agent-app/teams` + `createChatTables({ workspaceTable })`.
23
+ */
24
+
25
+ import { config } from '../agent.config'
26
+ import { createAppAuth, type AppAuth } from '@tangle-network/agent-app/app-auth'
27
+ import {
28
+ createChatTurnRoutes,
29
+ createUploadRoute,
30
+ type ChatTurnAuthorization,
31
+ type ChatTurnProduceArgs,
32
+ type ChatTurnRouteProducer,
33
+ type ChatTurnRoutes,
34
+ type SandboxUploadSink,
35
+ } from '@tangle-network/agent-app/chat-routes'
36
+ import {
37
+ createChatStore,
38
+ type ChatDatabase,
39
+ type ChatStore,
40
+ } from '@tangle-network/agent-app/chat-store'
41
+ import { guardResolution } from '@tangle-network/agent-app/platform'
42
+ import {
43
+ createD1TurnEventStore,
44
+ type TurnEventStore,
45
+ } from '@tangle-network/agent-app/stream'
46
+ import { drizzle } from 'drizzle-orm/d1'
47
+ import { accounts, messages, sessions, threads, users, verifications } from './db/schema'
48
+ import type { AppEnv } from './env'
49
+ import { appSlug, createSandboxProduce, resolveSidecarConnection, resolveUploadSink } from './sandbox'
50
+
51
+ export interface ChatAppOverrides {
52
+ /** Test seam: an in-memory drizzle db (the e2e test runs the REAL
53
+ * `migrations/0001_init.sql` into better-sqlite3). Default: `drizzle(env.DB)`. */
54
+ db?: ChatDatabase
55
+ /** Test seam: `createMemoryTurnEventStore()`. Default: D1-backed buffer. */
56
+ turnStore?: TurnEventStore
57
+ /** Test seam: a fake sandbox producer. Default: the real sandbox lane. */
58
+ produce?: (args: ChatTurnProduceArgs<void>) => ChatTurnRouteProducer | Promise<ChatTurnRouteProducer>
59
+ /** Test seam: where large uploads land. Default: the workspace box's fs. */
60
+ uploadSink?: (scope: { workspaceId: string; userId: string }) => Promise<SandboxUploadSink | null>
61
+ }
62
+
63
+ export interface ChatApp {
64
+ auth: AppAuth
65
+ store: ChatStore
66
+ routes: ChatTurnRoutes & {
67
+ /** POST `{ title?, firstMessage? }` → `{ thread }`. */
68
+ createThread(request: Request): Promise<Response>
69
+ /** GET → `{ threads, total, limit, offset }`. */
70
+ listThreads(request: Request): Promise<Response>
71
+ /** GET → `{ thread, messages }` — the full typed transcript. */
72
+ threadMessages(request: Request, params: { threadId: string }): Promise<Response>
73
+ }
74
+ upload(request: Request): Promise<Response>
75
+ }
76
+
77
+ const notFound = () => Response.json({ error: 'Thread not found' }, { status: 404 })
78
+
79
+ export function buildChatApp(env: AppEnv, overrides: ChatAppOverrides = {}): ChatApp {
80
+ const db = overrides.db ?? (drizzle(env.DB) as unknown as ChatDatabase)
81
+ const store = createChatStore(db, { threads, messages })
82
+
83
+ const auth = createAppAuth({
84
+ appName: config.name,
85
+ baseURL: env.BETTER_AUTH_URL,
86
+ secret: env.BETTER_AUTH_SECRET,
87
+ db,
88
+ schema: { users, sessions, accounts, verifications },
89
+ // For a throwaway prototype you can swap the drizzle pair for
90
+ // `database: memoryAdapter({ user: [], session: [], account: [], verification: [] })`
91
+ // (from 'better-auth/adapters/memory') — nothing survives a restart.
92
+ })
93
+
94
+ /** Session → identity + thread access, for both routes and seams. Guards
95
+ * throw JSON Responses; `guardResolution` adapts them to `{ ok, response }`. */
96
+ async function requireUser(request: Request) {
97
+ return guardResolution(() => auth.requireApiUser(request))
98
+ }
99
+
100
+ /** The one product-supplied access step for turn + replay. Identity comes
101
+ * from the SESSION, never from the request body — a client cannot forge
102
+ * `userId`/`workspaceId`. */
103
+ async function authorize(args: {
104
+ request: Request
105
+ intent: 'turn' | 'replay' | 'running'
106
+ body?: Record<string, unknown>
107
+ threadId?: string
108
+ }): Promise<ChatTurnAuthorization<void>> {
109
+ const session = await requireUser(args.request)
110
+ if (!session.ok) return session
111
+ const { user } = session.value
112
+ // `turn` and `running` are thread-scoped and MUST verify ownership: both name
113
+ // a thread the caller supplies (POST body for turn, `?threadId=` for running,
114
+ // whose response enumerates that thread's live turn ids). Inaccessible reads
115
+ // are indistinguishable from missing ones — a cross-workspace probe must not
116
+ // learn the thread exists.
117
+ if (args.intent === 'turn' || args.intent === 'running') {
118
+ const threadId = args.intent === 'turn' ? String(args.body?.threadId ?? '') : args.threadId
119
+ const thread = threadId ? await store.getThread(threadId) : null
120
+ if (!thread || thread.workspaceId !== user.id) return { ok: false, response: notFound() }
121
+ }
122
+ // Replay authorizes on session only: turn ids are unguessable UUIDs minted
123
+ // server-side and announced only on the owner's live stream.
124
+ return { ok: true, tenantId: user.id, userId: user.id, context: undefined }
125
+ }
126
+
127
+ const routes = createChatTurnRoutes<void>({
128
+ projectId: appSlug,
129
+ authorize,
130
+ store,
131
+ turnStore: overrides.turnStore ?? createD1TurnEventStore(env.DB),
132
+ produce: overrides.produce ?? createSandboxProduce(env),
133
+ interactions: {
134
+ resolveConnection: async ({ request, intent, body }) => {
135
+ const session = await requireUser(request)
136
+ if (!session.ok) return session
137
+ const { user } = session.value
138
+ const threadId =
139
+ intent === 'answer'
140
+ ? String(body?.threadId ?? '')
141
+ : (new URL(request.url).searchParams.get('threadId') ?? '')
142
+ const thread = await store.getThread(threadId)
143
+ if (!thread || thread.workspaceId !== user.id) return { ok: false, response: notFound() }
144
+ const connection = await resolveSidecarConnection(env, {
145
+ workspaceId: user.id,
146
+ userId: user.id,
147
+ threadId,
148
+ })
149
+ if (!connection) return { ok: false, unavailable: 'SANDBOX_UNAVAILABLE' }
150
+ return { ok: true, connection }
151
+ },
152
+ },
153
+ })
154
+
155
+ const upload = createUploadRoute({
156
+ authorize: async ({ request }) => {
157
+ const session = await requireUser(request)
158
+ if (!session.ok) return session
159
+ const { user } = session.value
160
+ const resolveSink = overrides.uploadSink ?? ((scope) => resolveUploadSink(env, scope))
161
+ return {
162
+ ok: true,
163
+ sink: await resolveSink({ workspaceId: user.id, userId: user.id }),
164
+ }
165
+ },
166
+ })
167
+
168
+ async function createThread(request: Request): Promise<Response> {
169
+ const session = await requireUser(request)
170
+ if (!session.ok) return session.response
171
+ const body = (await request.json().catch(() => null)) as
172
+ | { title?: string; firstMessage?: string }
173
+ | null
174
+ const thread = await store.createThread({
175
+ workspaceId: session.value.user.id,
176
+ ...(body?.title ? { title: body.title } : {}),
177
+ ...(body?.firstMessage ? { firstMessage: body.firstMessage } : {}),
178
+ })
179
+ return Response.json({ thread })
180
+ }
181
+
182
+ async function listThreads(request: Request): Promise<Response> {
183
+ const session = await requireUser(request)
184
+ if (!session.ok) return session.response
185
+ const url = new URL(request.url)
186
+ const limit = Number(url.searchParams.get('limit')) || undefined
187
+ const offset = Number(url.searchParams.get('offset')) || undefined
188
+ const result = await store.listThreads({
189
+ workspaceId: session.value.user.id,
190
+ ...(limit !== undefined ? { limit } : {}),
191
+ ...(offset !== undefined ? { offset } : {}),
192
+ })
193
+ return Response.json(result)
194
+ }
195
+
196
+ async function threadMessages(request: Request, params: { threadId: string }): Promise<Response> {
197
+ const session = await requireUser(request)
198
+ if (!session.ok) return session.response
199
+ const thread = await store.getThread(params.threadId)
200
+ if (!thread || thread.workspaceId !== session.value.user.id) return notFound()
201
+ return Response.json({ thread, messages: await store.listMessages(params.threadId) })
202
+ }
203
+
204
+ return {
205
+ auth,
206
+ store,
207
+ routes: { ...routes, createThread, listThreads, threadMessages },
208
+ upload,
209
+ }
210
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * src/db/schema.ts — the whole database graph, one drizzle schema.
3
+ *
4
+ * Two halves:
5
+ * - better-auth's users/sessions/accounts/verifications tables (the standard
6
+ * shape its drizzle adapter expects — column names must match
7
+ * `migrations/0001_init.sql`, which the e2e test executes for real).
8
+ * - the chat thread/message pair from `createChatTables()` (the shell owns
9
+ * the columns; you never hand-roll them).
10
+ *
11
+ * `workspace_id` on threads is a plain text column here: this template ships
12
+ * single-user workspaces (workspace = user id). Adopting real teams later
13
+ * means passing your workspace table as `createChatTables({ workspaceTable })`
14
+ * — see `@tangle-network/agent-app/teams`.
15
+ */
16
+
17
+ import { createChatTables } from '@tangle-network/agent-app/chat-store'
18
+ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
19
+
20
+ // ── better-auth tables ──────────────────────────────────────────────────────
21
+
22
+ export const users = sqliteTable('users', {
23
+ id: text('id').primaryKey(),
24
+ name: text('name').notNull(),
25
+ email: text('email').notNull().unique(),
26
+ emailVerified: integer('email_verified', { mode: 'boolean' }).notNull(),
27
+ image: text('image'),
28
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
29
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
30
+ })
31
+
32
+ export const sessions = sqliteTable('sessions', {
33
+ id: text('id').primaryKey(),
34
+ expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
35
+ token: text('token').notNull().unique(),
36
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
37
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
38
+ ipAddress: text('ip_address'),
39
+ userAgent: text('user_agent'),
40
+ userId: text('user_id').notNull(),
41
+ })
42
+
43
+ export const accounts = sqliteTable('accounts', {
44
+ id: text('id').primaryKey(),
45
+ accountId: text('account_id').notNull(),
46
+ providerId: text('provider_id').notNull(),
47
+ userId: text('user_id').notNull(),
48
+ accessToken: text('access_token'),
49
+ refreshToken: text('refresh_token'),
50
+ idToken: text('id_token'),
51
+ accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }),
52
+ refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }),
53
+ scope: text('scope'),
54
+ password: text('password'),
55
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
56
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
57
+ })
58
+
59
+ export const verifications = sqliteTable('verifications', {
60
+ id: text('id').primaryKey(),
61
+ identifier: text('identifier').notNull(),
62
+ value: text('value').notNull(),
63
+ expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
64
+ createdAt: integer('created_at', { mode: 'timestamp' }),
65
+ updatedAt: integer('updated_at', { mode: 'timestamp' }),
66
+ })
67
+
68
+ // ── chat tables (shell-owned columns) ───────────────────────────────────────
69
+
70
+ export const { threads, messages } = createChatTables()
@@ -0,0 +1,33 @@
1
+ /**
2
+ * src/env.ts — the Cloudflare bindings + vars this worker reads.
3
+ *
4
+ * Locally these come from wrangler.toml `[vars]` + `.dev.vars` (secrets); in
5
+ * production from the dashboard / `wrangler secret put`. See
6
+ * `.dev.vars.example` for the full list with comments.
7
+ */
8
+
9
+ export interface AppEnv {
10
+ /** D1 database — run `migrations/` against it before first boot. */
11
+ DB: D1Database
12
+
13
+ /** Absolute origin better-auth serves from (e.g. http://localhost:8787). */
14
+ BETTER_AUTH_URL: string
15
+ /** better-auth HMAC secret (secret; set in .dev.vars / `wrangler secret`). */
16
+ BETTER_AUTH_SECRET: string
17
+
18
+ /** Overrides `config.model.default` without a redeploy. */
19
+ MODEL_NAME?: string
20
+ /** Tangle Router key the harness bills model calls against. */
21
+ TANGLE_API_KEY?: string
22
+ /** Tangle Router base URL; omit for the platform default. */
23
+ TANGLE_ROUTER_URL?: string
24
+
25
+ /** Sandbox gateway credentials. Without them every turn fails loud with a
26
+ * clear error — there is no mock fallback. */
27
+ SANDBOX_API_KEY?: string
28
+ SANDBOX_GATEWAY_URL?: string
29
+
30
+ // Optional R2 bucket for product artifacts — OFF by default. Uncomment the
31
+ // `[[r2_buckets]]` block in wrangler.toml and this binding together.
32
+ // ARTIFACTS: R2Bucket
33
+ }