@checkstack/ai-backend 0.1.0
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/CHANGELOG.md +97 -0
- package/drizzle/0000_productive_jackpot.sql +26 -0
- package/drizzle/0001_puzzling_purple_man.sql +26 -0
- package/drizzle/0002_sparkling_paper_doll.sql +15 -0
- package/drizzle/0003_married_senator_kelly.sql +1 -0
- package/drizzle/0004_crazy_miek.sql +2 -0
- package/drizzle/0005_tearful_randall_flagg.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +232 -0
- package/drizzle/meta/0001_snapshot.json +434 -0
- package/drizzle/meta/0002_snapshot.json +551 -0
- package/drizzle/meta/0003_snapshot.json +557 -0
- package/drizzle/meta/0004_snapshot.json +573 -0
- package/drizzle/meta/0005_snapshot.json +574 -0
- package/drizzle/meta/_journal.json +48 -0
- package/drizzle.config.ts +7 -0
- package/package.json +42 -0
- package/src/agent-runner.test.ts +262 -0
- package/src/agent-runner.ts +262 -0
- package/src/chat/agent-loop.test.ts +119 -0
- package/src/chat/agent-loop.ts +73 -0
- package/src/chat/auto-apply.test.ts +237 -0
- package/src/chat/chat-handler.ts +111 -0
- package/src/chat/chat-service.streamturn.test.ts +417 -0
- package/src/chat/chat-service.test.ts +250 -0
- package/src/chat/chat-service.ts +923 -0
- package/src/chat/classifier-service.ts +64 -0
- package/src/chat/classifier.logic.test.ts +92 -0
- package/src/chat/classifier.logic.ts +71 -0
- package/src/chat/conversation-store.it.test.ts +203 -0
- package/src/chat/conversation-store.test.ts +248 -0
- package/src/chat/conversation-store.ts +237 -0
- package/src/chat/decision.logic.test.ts +45 -0
- package/src/chat/decision.logic.ts +54 -0
- package/src/chat/llm-provider.test.ts +63 -0
- package/src/chat/llm-provider.ts +67 -0
- package/src/chat/model-error.logic.test.ts +60 -0
- package/src/chat/model-error.logic.ts +65 -0
- package/src/chat/normalize-messages.logic.test.ts +101 -0
- package/src/chat/normalize-messages.logic.ts +65 -0
- package/src/chat/permission-mode.logic.test.ts +70 -0
- package/src/chat/permission-mode.logic.ts +45 -0
- package/src/chat/read-invoker.ts +72 -0
- package/src/chat/replay.test.ts +174 -0
- package/src/chat/scrub-content.test.ts +183 -0
- package/src/chat/scrub-content.ts +154 -0
- package/src/chat/sdk-tools.test.ts +168 -0
- package/src/chat/sdk-tools.ts +181 -0
- package/src/chat/title-service.test.ts +146 -0
- package/src/chat/title-service.ts +111 -0
- package/src/chat/title.logic.test.ts +98 -0
- package/src/chat/title.logic.ts +102 -0
- package/src/extension-points.ts +41 -0
- package/src/generated/docs-index.ts +3020 -0
- package/src/hardening/handler-authz.test.ts +282 -0
- package/src/hardening/no-secret-leak.test.ts +303 -0
- package/src/hooks.ts +33 -0
- package/src/index.ts +542 -0
- package/src/mcp/connection-registry.test.ts +25 -0
- package/src/mcp/connection-registry.ts +54 -0
- package/src/mcp/mcp-conformance.it.test.ts +128 -0
- package/src/mcp/server.test.ts +285 -0
- package/src/mcp/server.ts +300 -0
- package/src/mcp/tool-invoker.ts +65 -0
- package/src/openai-provider.test.ts +64 -0
- package/src/openai-provider.ts +146 -0
- package/src/projection.test.ts +97 -0
- package/src/projection.ts +132 -0
- package/src/propose-apply/args-hash.test.ts +26 -0
- package/src/propose-apply/args-hash.ts +30 -0
- package/src/propose-apply/service.test.ts +423 -0
- package/src/propose-apply/service.ts +419 -0
- package/src/propose-apply/store.test.ts +136 -0
- package/src/propose-apply/store.ts +224 -0
- package/src/propose-apply/token.test.ts +52 -0
- package/src/propose-apply/token.ts +71 -0
- package/src/rate-limit/spend-ledger.it.test.ts +224 -0
- package/src/rate-limit/spend-ledger.test.ts +176 -0
- package/src/rate-limit/spend-ledger.ts +162 -0
- package/src/rate-limit/tool-budget.it.test.ts +173 -0
- package/src/rate-limit/tool-budget.test.ts +58 -0
- package/src/rate-limit/tool-budget.ts +107 -0
- package/src/registry-wiring.test.ts +131 -0
- package/src/registry-wiring.ts +68 -0
- package/src/resolver.test.ts +156 -0
- package/src/resolver.ts +78 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +345 -0
- package/src/schema.ts +284 -0
- package/src/serializer.test.ts +88 -0
- package/src/serializer.ts +42 -0
- package/src/tool-registry.ts +58 -0
- package/src/tools/composite-tools.ts +24 -0
- package/src/tools/docs-tools.test.ts +150 -0
- package/src/tools/docs-tools.ts +115 -0
- package/src/tools/probe-url.test.ts +51 -0
- package/src/tools/probe-url.ts +146 -0
- package/src/tools/rank-docs.test.ts +153 -0
- package/src/tools/rank-docs.ts +209 -0
- package/src/tools/script-context-extract.test.ts +93 -0
- package/src/tools/script-context-extract.ts +283 -0
- package/src/tools/ssrf-guard.test.ts +69 -0
- package/src/tools/ssrf-guard.ts +108 -0
- package/src/tools/tool-set.e2e.test.ts +64 -0
- package/src/user-rpc-client.test.ts +45 -0
- package/src/user-rpc-client.ts +60 -0
- package/tsconfig.json +26 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { implement, ORPCError } from "@orpc/server";
|
|
2
|
+
import {
|
|
3
|
+
autoAuthMiddleware,
|
|
4
|
+
correlationMiddleware,
|
|
5
|
+
type RpcContext,
|
|
6
|
+
} from "@checkstack/backend-api";
|
|
7
|
+
import { aiContract } from "@checkstack/ai-common";
|
|
8
|
+
import type { AiToolResolver } from "./resolver";
|
|
9
|
+
import { serializeTools } from "./serializer";
|
|
10
|
+
import {
|
|
11
|
+
ProposeApplyError,
|
|
12
|
+
type ProposeApplyService,
|
|
13
|
+
type ProposeApplyErrorCode,
|
|
14
|
+
} from "./propose-apply/service";
|
|
15
|
+
import {
|
|
16
|
+
createUserScopedRpcClient,
|
|
17
|
+
forwardableAuthHeadersFrom,
|
|
18
|
+
} from "./user-rpc-client";
|
|
19
|
+
|
|
20
|
+
import type { AiConversationStore } from "./chat/conversation-store";
|
|
21
|
+
import type {
|
|
22
|
+
AiConversationRow,
|
|
23
|
+
AiMessageRow,
|
|
24
|
+
} from "./schema";
|
|
25
|
+
|
|
26
|
+
/** A selectable AI integration's non-secret model UX metadata. */
|
|
27
|
+
export interface ChatIntegrationInfo {
|
|
28
|
+
connectionId: string;
|
|
29
|
+
name: string;
|
|
30
|
+
defaultModel: string;
|
|
31
|
+
availableModels?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Lists selectable AI integrations (non-secret model UX metadata) for chat. */
|
|
35
|
+
export interface ChatIntegrationLister {
|
|
36
|
+
list(): Promise<ChatIntegrationInfo[]>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Coerce a caller-supplied model id against an integration's `availableModels`
|
|
41
|
+
* allowlist (untrusted wire input). Returns the requested model when allowed,
|
|
42
|
+
* otherwise the connection's `defaultModel`. Returns `undefined` only when the
|
|
43
|
+
* integration cannot be resolved (no model can be validated). Uses only the
|
|
44
|
+
* non-secret model metadata, never the credential.
|
|
45
|
+
*/
|
|
46
|
+
export async function coerceConversationModel({
|
|
47
|
+
integrations,
|
|
48
|
+
integrationId,
|
|
49
|
+
model,
|
|
50
|
+
}: {
|
|
51
|
+
integrations: ChatIntegrationLister;
|
|
52
|
+
integrationId: string | undefined;
|
|
53
|
+
model: string | undefined;
|
|
54
|
+
}): Promise<string | undefined> {
|
|
55
|
+
if (model === undefined) return undefined;
|
|
56
|
+
if (!integrationId) {
|
|
57
|
+
// No integration to validate against: drop the unvalidated model id rather
|
|
58
|
+
// than persist an arbitrary one.
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const list = await integrations.list();
|
|
62
|
+
const info = list.find((i) => i.connectionId === integrationId);
|
|
63
|
+
if (!info) return undefined;
|
|
64
|
+
const allow = info.availableModels;
|
|
65
|
+
if (allow && allow.length > 0 && !allow.includes(model)) {
|
|
66
|
+
return info.defaultModel;
|
|
67
|
+
}
|
|
68
|
+
return model;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface AiRouterDeps {
|
|
72
|
+
resolver: AiToolResolver;
|
|
73
|
+
proposeApply: ProposeApplyService;
|
|
74
|
+
conversations: AiConversationStore;
|
|
75
|
+
integrations: ChatIntegrationLister;
|
|
76
|
+
/** Loopback base URL for the user-scoped RPC client (re-enters `/api`). */
|
|
77
|
+
internalUrl: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Serialize a conversation row for the wire (no secret fields exist on it). */
|
|
81
|
+
function toConversationDto(row: AiConversationRow) {
|
|
82
|
+
return {
|
|
83
|
+
id: row.id,
|
|
84
|
+
title: row.title,
|
|
85
|
+
integrationId: row.integrationId,
|
|
86
|
+
model: row.model,
|
|
87
|
+
permissionMode: row.permissionMode,
|
|
88
|
+
createdAt: row.createdAt,
|
|
89
|
+
updatedAt: row.updatedAt,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toMessageDto(row: AiMessageRow) {
|
|
94
|
+
return {
|
|
95
|
+
id: row.id,
|
|
96
|
+
conversationId: row.conversationId,
|
|
97
|
+
role: row.role,
|
|
98
|
+
content: row.content,
|
|
99
|
+
toolCalls: row.toolCalls ?? null,
|
|
100
|
+
createdAt: row.createdAt,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Map a propose/apply error to the closest oRPC error code. */
|
|
105
|
+
function toOrpcError(error: ProposeApplyError): ORPCError<string, unknown> {
|
|
106
|
+
const mapping: Record<ProposeApplyErrorCode, string> = {
|
|
107
|
+
forbidden: "FORBIDDEN",
|
|
108
|
+
not_found: "NOT_FOUND",
|
|
109
|
+
not_proposable: "BAD_REQUEST",
|
|
110
|
+
invalid_token: "BAD_REQUEST",
|
|
111
|
+
expired: "CONFLICT", // 409 — token no longer valid (TTL elapsed)
|
|
112
|
+
consumed: "CONFLICT", // 409 — single-use violated
|
|
113
|
+
execute_failed: "BAD_REQUEST",
|
|
114
|
+
};
|
|
115
|
+
return new ORPCError(mapping[error.code], { message: error.message });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* AI router (Phase 1 read-only introspection + Phase 3 propose/apply).
|
|
120
|
+
*
|
|
121
|
+
* `autoAuthMiddleware` enforces the contract's access gates, so the router is
|
|
122
|
+
* the single enforcement point even though the resolver / propose-apply service
|
|
123
|
+
* also re-check per-tool authorization. The model is treated as an untrusted
|
|
124
|
+
* caller throughout.
|
|
125
|
+
*/
|
|
126
|
+
export function createAiRouter({
|
|
127
|
+
resolver,
|
|
128
|
+
proposeApply,
|
|
129
|
+
conversations,
|
|
130
|
+
integrations,
|
|
131
|
+
internalUrl,
|
|
132
|
+
}: AiRouterDeps) {
|
|
133
|
+
const os = implement(aiContract)
|
|
134
|
+
.$context<RpcContext>()
|
|
135
|
+
.use(correlationMiddleware)
|
|
136
|
+
.use(autoAuthMiddleware);
|
|
137
|
+
|
|
138
|
+
return os.router({
|
|
139
|
+
listTools: os.listTools.handler(async ({ context }) => {
|
|
140
|
+
const principal = context.user;
|
|
141
|
+
if (!principal) {
|
|
142
|
+
return { tools: [] };
|
|
143
|
+
}
|
|
144
|
+
const tools = resolver.resolveTools(principal);
|
|
145
|
+
return { tools: serializeTools({ tools }) };
|
|
146
|
+
}),
|
|
147
|
+
|
|
148
|
+
proposeTool: os.proposeTool.handler(async ({ context, input }) => {
|
|
149
|
+
const principal = context.user;
|
|
150
|
+
if (!principal) {
|
|
151
|
+
throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
|
|
152
|
+
}
|
|
153
|
+
// USER-SCOPED RPC client: the tool's dry-run re-enters `/api` as THIS
|
|
154
|
+
// user (forwarding the request's own cookie/bearer), so handler authz +
|
|
155
|
+
// per-resource/team scope apply - never the trusted service client.
|
|
156
|
+
const rpcClient = createUserScopedRpcClient({
|
|
157
|
+
internalUrl,
|
|
158
|
+
forwardHeaders: forwardableAuthHeadersFrom(context.requestHeaders),
|
|
159
|
+
});
|
|
160
|
+
try {
|
|
161
|
+
const proposal = await proposeApply.propose({
|
|
162
|
+
principal,
|
|
163
|
+
toolName: input.toolName,
|
|
164
|
+
input: input.input,
|
|
165
|
+
transport: "chat",
|
|
166
|
+
rpcClient,
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
token: proposal.token,
|
|
170
|
+
summary: proposal.summary,
|
|
171
|
+
payload: proposal.payload,
|
|
172
|
+
toolCallId: proposal.toolCallId,
|
|
173
|
+
expiresAt: proposal.expiresAt,
|
|
174
|
+
};
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error instanceof ProposeApplyError) throw toOrpcError(error);
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}),
|
|
180
|
+
|
|
181
|
+
applyTool: os.applyTool.handler(async ({ context, input }) => {
|
|
182
|
+
const principal = context.user;
|
|
183
|
+
if (!principal) {
|
|
184
|
+
throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
|
|
185
|
+
}
|
|
186
|
+
// USER-SCOPED RPC client (see proposeTool): the tool's commit runs as
|
|
187
|
+
// THIS user, so handler authz + per-resource/team scope apply at apply
|
|
188
|
+
// time exactly as a direct UI/RPC call would.
|
|
189
|
+
const rpcClient = createUserScopedRpcClient({
|
|
190
|
+
internalUrl,
|
|
191
|
+
forwardHeaders: forwardableAuthHeadersFrom(context.requestHeaders),
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
const applied = await proposeApply.apply({
|
|
195
|
+
principal,
|
|
196
|
+
token: input.token,
|
|
197
|
+
transport: "chat",
|
|
198
|
+
rpcClient,
|
|
199
|
+
});
|
|
200
|
+
return { toolCallId: applied.toolCallId, result: applied.result };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error instanceof ProposeApplyError) throw toOrpcError(error);
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}),
|
|
206
|
+
|
|
207
|
+
listChatIntegrations: os.listChatIntegrations.handler(
|
|
208
|
+
async ({ context }) => {
|
|
209
|
+
const principal = context.user;
|
|
210
|
+
if (!principal) {
|
|
211
|
+
throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
|
|
212
|
+
}
|
|
213
|
+
const integrationList = await integrations.list();
|
|
214
|
+
return { integrations: integrationList };
|
|
215
|
+
},
|
|
216
|
+
),
|
|
217
|
+
|
|
218
|
+
listConversations: os.listConversations.handler(async ({ context }) => {
|
|
219
|
+
const principal = context.user;
|
|
220
|
+
if (!principal || principal.type !== "user") {
|
|
221
|
+
throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
|
|
222
|
+
}
|
|
223
|
+
const rows = await conversations.listConversations({
|
|
224
|
+
userId: principal.id,
|
|
225
|
+
});
|
|
226
|
+
return { conversations: rows.map((r) => toConversationDto(r)) };
|
|
227
|
+
}),
|
|
228
|
+
|
|
229
|
+
createConversation: os.createConversation.handler(
|
|
230
|
+
async ({ context, input }) => {
|
|
231
|
+
const principal = context.user;
|
|
232
|
+
if (!principal || principal.type !== "user") {
|
|
233
|
+
throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
|
|
234
|
+
}
|
|
235
|
+
// The model id is untrusted wire input — coerce it against the chosen
|
|
236
|
+
// integration's allowlist before persisting (defeats model/cost-control
|
|
237
|
+
// bypass via a hand-crafted create call).
|
|
238
|
+
const model = await coerceConversationModel({
|
|
239
|
+
integrations,
|
|
240
|
+
integrationId: input.integrationId,
|
|
241
|
+
model: input.model,
|
|
242
|
+
});
|
|
243
|
+
const row = await conversations.createConversation({
|
|
244
|
+
userId: principal.id,
|
|
245
|
+
title: input.title,
|
|
246
|
+
integrationId: input.integrationId,
|
|
247
|
+
model,
|
|
248
|
+
permissionMode: input.permissionMode,
|
|
249
|
+
});
|
|
250
|
+
return toConversationDto(row);
|
|
251
|
+
},
|
|
252
|
+
),
|
|
253
|
+
|
|
254
|
+
getConversation: os.getConversation.handler(async ({ context, input }) => {
|
|
255
|
+
const principal = context.user;
|
|
256
|
+
if (!principal || principal.type !== "user") {
|
|
257
|
+
throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
|
|
258
|
+
}
|
|
259
|
+
const conversation = await conversations.getConversation({
|
|
260
|
+
id: input.id,
|
|
261
|
+
userId: principal.id,
|
|
262
|
+
});
|
|
263
|
+
if (!conversation) {
|
|
264
|
+
throw new ORPCError("NOT_FOUND", { message: "Conversation not found" });
|
|
265
|
+
}
|
|
266
|
+
const messages = await conversations.listMessages({
|
|
267
|
+
conversationId: input.id,
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
conversation: toConversationDto(conversation),
|
|
271
|
+
messages: messages.map((m) => toMessageDto(m)),
|
|
272
|
+
};
|
|
273
|
+
}),
|
|
274
|
+
|
|
275
|
+
updateConversation: os.updateConversation.handler(
|
|
276
|
+
async ({ context, input }) => {
|
|
277
|
+
const principal = context.user;
|
|
278
|
+
if (!principal || principal.type !== "user") {
|
|
279
|
+
throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
|
|
280
|
+
}
|
|
281
|
+
// Coerce the (untrusted) model id against the conversation's integration
|
|
282
|
+
// allowlist. Fetch the owned conversation first to read its integration
|
|
283
|
+
// id (also enforces ownership before any write).
|
|
284
|
+
let model = input.model;
|
|
285
|
+
if (model !== undefined) {
|
|
286
|
+
const existing = await conversations.getConversation({
|
|
287
|
+
id: input.id,
|
|
288
|
+
userId: principal.id,
|
|
289
|
+
});
|
|
290
|
+
if (!existing) {
|
|
291
|
+
throw new ORPCError("NOT_FOUND", {
|
|
292
|
+
message: "Conversation not found",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
model = await coerceConversationModel({
|
|
296
|
+
integrations,
|
|
297
|
+
integrationId: existing.integrationId ?? undefined,
|
|
298
|
+
model,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const row = await conversations.updateConversation({
|
|
302
|
+
id: input.id,
|
|
303
|
+
userId: principal.id,
|
|
304
|
+
title: input.title,
|
|
305
|
+
model,
|
|
306
|
+
permissionMode: input.permissionMode,
|
|
307
|
+
});
|
|
308
|
+
if (!row) {
|
|
309
|
+
throw new ORPCError("NOT_FOUND", {
|
|
310
|
+
message: "Conversation not found",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return toConversationDto(row);
|
|
314
|
+
},
|
|
315
|
+
),
|
|
316
|
+
|
|
317
|
+
archiveConversation: os.archiveConversation.handler(
|
|
318
|
+
async ({ context, input }) => {
|
|
319
|
+
const principal = context.user;
|
|
320
|
+
if (!principal || principal.type !== "user") {
|
|
321
|
+
throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
|
|
322
|
+
}
|
|
323
|
+
const archived = await conversations.archiveConversation({
|
|
324
|
+
id: input.id,
|
|
325
|
+
userId: principal.id,
|
|
326
|
+
});
|
|
327
|
+
return { archived };
|
|
328
|
+
},
|
|
329
|
+
),
|
|
330
|
+
|
|
331
|
+
deleteConversation: os.deleteConversation.handler(
|
|
332
|
+
async ({ context, input }) => {
|
|
333
|
+
const principal = context.user;
|
|
334
|
+
if (!principal || principal.type !== "user") {
|
|
335
|
+
throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
|
|
336
|
+
}
|
|
337
|
+
const deleted = await conversations.deleteConversation({
|
|
338
|
+
id: input.id,
|
|
339
|
+
userId: principal.id,
|
|
340
|
+
});
|
|
341
|
+
return { deleted };
|
|
342
|
+
},
|
|
343
|
+
),
|
|
344
|
+
});
|
|
345
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
text,
|
|
4
|
+
jsonb,
|
|
5
|
+
timestamp,
|
|
6
|
+
index,
|
|
7
|
+
integer,
|
|
8
|
+
} from "drizzle-orm/pg-core";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* AI platform — Phase 3 data model (Drizzle).
|
|
12
|
+
*
|
|
13
|
+
* Phase 3 introduces ONE durable table, `ai_tool_calls`, which serves two
|
|
14
|
+
* purposes at once (decision §4, §13.4):
|
|
15
|
+
*
|
|
16
|
+
* 1. An append-only **audit log** for every AI tool invocation across both
|
|
17
|
+
* transports (chat + MCP), regardless of effect.
|
|
18
|
+
* 2. The **propose/apply token store**. A `proposed` row IS the token: the
|
|
19
|
+
* opaque token handed to the caller is `propose:<rowId>.<nonce>`; `apply`
|
|
20
|
+
* looks the row up by id, checks the nonce + TTL + status, then transitions
|
|
21
|
+
* it to `applied` in a single atomic UPDATE (single-use even under
|
|
22
|
+
* concurrent applies). There is no separate ephemeral table.
|
|
23
|
+
*
|
|
24
|
+
* State & scale (.claude/rules/state-and-scale.md): this is a shared-Postgres
|
|
25
|
+
* table, so every pod reads/writes the same audit + token store. A token
|
|
26
|
+
* proposed on pod A is consumable on pod B; an expired token is rejected on any
|
|
27
|
+
* pod. No proposal/audit state is pod-local.
|
|
28
|
+
*
|
|
29
|
+
* Phase 4 adds the chat surface: `ai_conversations` + `ai_messages` (durable,
|
|
30
|
+
* continuable from any pod — state-and-scale §9) and the `ai_message_role` enum.
|
|
31
|
+
* `ai_tool_calls.conversationId` now carries the deferred FK to
|
|
32
|
+
* `ai_conversations` (set null on delete, so audit history outlives a deleted
|
|
33
|
+
* chat).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { pgEnum } from "drizzle-orm/pg-core";
|
|
37
|
+
|
|
38
|
+
/** Which transport drove the tool call. */
|
|
39
|
+
export const aiTransportEnum = pgEnum("ai_transport", [
|
|
40
|
+
"chat",
|
|
41
|
+
"mcp",
|
|
42
|
+
"automation",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Per-conversation permission mode (Phase 4). Durable (not pod-local) so a read
|
|
47
|
+
* returns the same answer on every pod. Governs the `mutate` tool branch only;
|
|
48
|
+
* destructive tools always require a human apply regardless of this value.
|
|
49
|
+
*/
|
|
50
|
+
export const aiPermissionModeEnum = pgEnum("ai_permission_mode", [
|
|
51
|
+
"approve",
|
|
52
|
+
"auto",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** Effect classification mirrored from the `AiTool` descriptor. */
|
|
56
|
+
export const aiToolEffectEnum = pgEnum("ai_tool_effect", [
|
|
57
|
+
"read",
|
|
58
|
+
"mutate",
|
|
59
|
+
"destructive",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** Role of a persisted chat message (AI-SDK roles). */
|
|
63
|
+
export const aiMessageRoleEnum = pgEnum("ai_message_role", [
|
|
64
|
+
"system",
|
|
65
|
+
"user",
|
|
66
|
+
"assistant",
|
|
67
|
+
"tool",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Lifecycle of an `ai_tool_calls` row. */
|
|
71
|
+
export const aiToolCallStatusEnum = pgEnum("ai_tool_call_status", [
|
|
72
|
+
"proposed", // dry-run done, token issued, awaiting apply
|
|
73
|
+
"applied", // apply consumed the proposal token and committed
|
|
74
|
+
"executed", // read tool ran directly (no proposal step)
|
|
75
|
+
"failed", // execute / apply threw
|
|
76
|
+
"expired", // proposal token TTL elapsed before apply
|
|
77
|
+
"rejected", // human declined the confirm card / apply never called
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A durable chat conversation, continuable from any pod (state-and-scale §9).
|
|
82
|
+
* Owned by a single RealUser; no FK to the auth plugin's user table
|
|
83
|
+
* (cross-plugin tables are not FK-linked in this codebase) — ownership is
|
|
84
|
+
* enforced at the handler via the session principal.
|
|
85
|
+
*/
|
|
86
|
+
export const aiConversations = pgTable(
|
|
87
|
+
"ai_conversations",
|
|
88
|
+
{
|
|
89
|
+
id: text("id")
|
|
90
|
+
.primaryKey()
|
|
91
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
92
|
+
/** Owning real user id (chat is RealUser-only). */
|
|
93
|
+
userId: text("user_id").notNull(),
|
|
94
|
+
title: text("title"),
|
|
95
|
+
/** Qualified integration connection id used for this conversation. */
|
|
96
|
+
integrationId: text("integration_id"),
|
|
97
|
+
/** Model id selected for this conversation (defaults to the connection's). */
|
|
98
|
+
model: text("model"),
|
|
99
|
+
/**
|
|
100
|
+
* Per-conversation permission mode (Phase 4). Durable + shared-Postgres so a
|
|
101
|
+
* turn handled on any pod reads the SAME mode. Governs the `mutate` tool
|
|
102
|
+
* branch only (`auto` auto-applies a mutate proposal server-side; `approve`
|
|
103
|
+
* surfaces a confirm card). Reads always run and destructive tools always
|
|
104
|
+
* require a human apply, regardless of this column. Safe-by-default `approve`.
|
|
105
|
+
*/
|
|
106
|
+
permissionMode: aiPermissionModeEnum("permission_mode")
|
|
107
|
+
.notNull()
|
|
108
|
+
.default("approve"),
|
|
109
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
110
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
111
|
+
/**
|
|
112
|
+
* Soft-delete marker (Phase 7). The user-facing "Delete" action ARCHIVES a
|
|
113
|
+
* conversation by stamping this column rather than hard-deleting the row, so
|
|
114
|
+
* the transcript is retained for later abuse introspection. `listConversations`
|
|
115
|
+
* filters `archivedAt IS NULL`, so archived chats disappear from the sidebar
|
|
116
|
+
* but the rows (and their messages) live on. Null = active.
|
|
117
|
+
*/
|
|
118
|
+
archivedAt: timestamp("archived_at"),
|
|
119
|
+
},
|
|
120
|
+
(t) => ({
|
|
121
|
+
userIdx: index("ai_conversations_user_idx").on(t.userId, t.updatedAt),
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/** Append-only message log for a conversation. */
|
|
126
|
+
export const aiMessages = pgTable(
|
|
127
|
+
"ai_messages",
|
|
128
|
+
{
|
|
129
|
+
id: text("id")
|
|
130
|
+
.primaryKey()
|
|
131
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
132
|
+
conversationId: text("conversation_id")
|
|
133
|
+
.notNull()
|
|
134
|
+
.references(() => aiConversations.id, { onDelete: "cascade" }),
|
|
135
|
+
role: aiMessageRoleEnum("role").notNull(),
|
|
136
|
+
/** AI-SDK message parts: text + tool-call / tool-result parts. Secrets are
|
|
137
|
+
* masked before persist (see `scrubContent`). */
|
|
138
|
+
content: jsonb("content").$type<Record<string, unknown>>().notNull(),
|
|
139
|
+
/** Tool calls emitted by an assistant turn (denormalized for fast render). */
|
|
140
|
+
toolCalls: jsonb("tool_calls").$type<Array<Record<string, unknown>>>(),
|
|
141
|
+
/**
|
|
142
|
+
* Tool-call REPLAY (additive, Phase 6): the canonical AI-SDK
|
|
143
|
+
* `ResponseMessage[]` (assistant tool-call parts + tool-result parts) the
|
|
144
|
+
* model produced this turn. Stored verbatim (after secret-scrubbing) so a
|
|
145
|
+
* RESUMED multi-turn conversation replays the full prior tool interaction to
|
|
146
|
+
* the model — not just the rendered text. Null for plain user/system rows
|
|
147
|
+
* and for legacy assistant rows written before this column existed (those
|
|
148
|
+
* fall back to text-only replay). Shared Postgres, so replay is identical on
|
|
149
|
+
* whichever pod handles the next turn (state-and-scale §9).
|
|
150
|
+
*/
|
|
151
|
+
modelMessages: jsonb("model_messages").$type<Array<Record<string, unknown>>>(),
|
|
152
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
153
|
+
},
|
|
154
|
+
(t) => ({
|
|
155
|
+
convIdx: index("ai_messages_conversation_idx").on(
|
|
156
|
+
t.conversationId,
|
|
157
|
+
t.createdAt,
|
|
158
|
+
),
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
export const aiToolCalls = pgTable(
|
|
163
|
+
"ai_tool_calls",
|
|
164
|
+
{
|
|
165
|
+
id: text("id")
|
|
166
|
+
.primaryKey()
|
|
167
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
168
|
+
/** "user" | "application" — never "service" (services bypass the registry). */
|
|
169
|
+
principalKind: text("principal_kind").notNull(),
|
|
170
|
+
principalId: text("principal_id").notNull(),
|
|
171
|
+
transport: aiTransportEnum("transport").notNull(),
|
|
172
|
+
/** Optional link back to a chat turn (null for MCP). Phase 4 deferred FK. */
|
|
173
|
+
conversationId: text("conversation_id").references(
|
|
174
|
+
() => aiConversations.id,
|
|
175
|
+
{ onDelete: "set null" },
|
|
176
|
+
),
|
|
177
|
+
toolName: text("tool_name").notNull(),
|
|
178
|
+
effect: aiToolEffectEnum("effect").notNull(),
|
|
179
|
+
/** SHA-256 of the canonical-JSON args (never the raw args — may hold PII). */
|
|
180
|
+
argsHash: text("args_hash").notNull(),
|
|
181
|
+
status: aiToolCallStatusEnum("status").notNull(),
|
|
182
|
+
/** Propose/apply token nonce (random 32 bytes hex). Null for read tools. */
|
|
183
|
+
proposalNonce: text("proposal_nonce"),
|
|
184
|
+
/** Hard expiry of a `proposed` row (now + TTL, §13.4). */
|
|
185
|
+
proposalExpiresAt: timestamp("proposal_expires_at"),
|
|
186
|
+
/**
|
|
187
|
+
* WHO applied the proposal (P3 review item 1). The proposer is recorded in
|
|
188
|
+
* `principalKind`/`principalId`; these two columns record the principal that
|
|
189
|
+
* actually consumed the token at `apply` time. They are normally identical,
|
|
190
|
+
* but the propose/apply security invariant (single-use, 256-bit secret
|
|
191
|
+
* token, live authz re-check) holds regardless of caller identity, so a
|
|
192
|
+
* cross-principal apply is RECORDED rather than rejected — the audit log must
|
|
193
|
+
* never silently attribute an apply to the wrong principal. Null until apply.
|
|
194
|
+
*/
|
|
195
|
+
appliedByKind: text("applied_by_kind"),
|
|
196
|
+
appliedById: text("applied_by_id"),
|
|
197
|
+
/** dryRun preview / execute result snapshot (masked). */
|
|
198
|
+
resultSnapshot: jsonb("result_snapshot").$type<Record<string, unknown>>(),
|
|
199
|
+
/** The validated, ready-to-apply payload captured at propose time. */
|
|
200
|
+
proposedPayload: jsonb("proposed_payload").$type<Record<string, unknown>>(),
|
|
201
|
+
error: text("error"),
|
|
202
|
+
proposedAt: timestamp("proposed_at"),
|
|
203
|
+
appliedAt: timestamp("applied_at"),
|
|
204
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
205
|
+
},
|
|
206
|
+
(t) => ({
|
|
207
|
+
// Per-principal budget counter window scan + audit listing.
|
|
208
|
+
principalCreatedIdx: index("ai_tool_calls_principal_created_idx").on(
|
|
209
|
+
t.principalKind,
|
|
210
|
+
t.principalId,
|
|
211
|
+
t.createdAt,
|
|
212
|
+
),
|
|
213
|
+
// Proposal-token lookup at apply time + the TTL prune sweep.
|
|
214
|
+
statusExpiresIdx: index("ai_tool_calls_status_expires_idx").on(
|
|
215
|
+
t.status,
|
|
216
|
+
t.proposalExpiresAt,
|
|
217
|
+
),
|
|
218
|
+
convIdx: index("ai_tool_calls_conversation_idx").on(t.conversationId),
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Per-integration LLM SPEND LEDGER (Phase 6, the locked-off spend-cap knob).
|
|
224
|
+
*
|
|
225
|
+
* An append-only token-usage ledger: ONE row per completed chat turn, keyed by
|
|
226
|
+
* the integration connection AND the principal, carrying the AI-SDK token usage
|
|
227
|
+
* (`inputTokens` + `outputTokens` -> `totalTokens`) for that turn. The optional
|
|
228
|
+
* per-integration spend cap is a ROLLING-WINDOW SUM over this table, mirroring
|
|
229
|
+
* the per-principal tool rate-limit budget exactly:
|
|
230
|
+
*
|
|
231
|
+
* - State lives in shared Postgres, so the cap is COUNTED ACROSS ALL PODS. An
|
|
232
|
+
* in-memory per-pod token counter would let N pods each allow the cap = N x
|
|
233
|
+
* the intended spend — a leak a single-process test can never catch
|
|
234
|
+
* (state-and-scale §14.5). Every pod sums the SAME table.
|
|
235
|
+
* - Token-count (not USD) is deliberate: it is deterministic and provider-
|
|
236
|
+
* agnostic. OpenAI-compatible endpoints span OpenAI, Azure, OpenRouter,
|
|
237
|
+
* Ollama, vLLM, LM Studio — there is no single price table, and self-hosted
|
|
238
|
+
* models have no per-token price at all. Tokens are the one unit every
|
|
239
|
+
* provider reports via the AI SDK's `usage`.
|
|
240
|
+
* - The cap is OFF by default: no cap is enforced unless the connection
|
|
241
|
+
* configures `spendCap`.
|
|
242
|
+
*/
|
|
243
|
+
export const aiSpend = pgTable(
|
|
244
|
+
"ai_spend",
|
|
245
|
+
{
|
|
246
|
+
id: text("id")
|
|
247
|
+
.primaryKey()
|
|
248
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
249
|
+
/** Qualified integration connection id the spend was incurred against. */
|
|
250
|
+
integrationId: text("integration_id").notNull(),
|
|
251
|
+
/** "user" | "application" — the principal that incurred the spend. */
|
|
252
|
+
principalKind: text("principal_kind").notNull(),
|
|
253
|
+
principalId: text("principal_id").notNull(),
|
|
254
|
+
/** Optional link back to the conversation (audit; null-safe). */
|
|
255
|
+
conversationId: text("conversation_id"),
|
|
256
|
+
/** Model id the turn ran against. */
|
|
257
|
+
model: text("model"),
|
|
258
|
+
/** Prompt (input) tokens for the turn. */
|
|
259
|
+
inputTokens: integer("input_tokens").notNull().default(0),
|
|
260
|
+
/** Completion (output) tokens for the turn. */
|
|
261
|
+
outputTokens: integer("output_tokens").notNull().default(0),
|
|
262
|
+
/** input + output, persisted so the window SUM is a single indexed column. */
|
|
263
|
+
totalTokens: integer("total_tokens").notNull().default(0),
|
|
264
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
265
|
+
},
|
|
266
|
+
(t) => ({
|
|
267
|
+
// Rolling-window SUM scan: filter by integration + principal + createdAt.
|
|
268
|
+
windowIdx: index("ai_spend_integration_principal_created_idx").on(
|
|
269
|
+
t.integrationId,
|
|
270
|
+
t.principalKind,
|
|
271
|
+
t.principalId,
|
|
272
|
+
t.createdAt,
|
|
273
|
+
),
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
export type AiSpendRow = typeof aiSpend.$inferSelect;
|
|
278
|
+
export type AiSpendInsert = typeof aiSpend.$inferInsert;
|
|
279
|
+
export type AiToolCallRow = typeof aiToolCalls.$inferSelect;
|
|
280
|
+
export type AiToolCallInsert = typeof aiToolCalls.$inferInsert;
|
|
281
|
+
export type AiConversationRow = typeof aiConversations.$inferSelect;
|
|
282
|
+
export type AiConversationInsert = typeof aiConversations.$inferInsert;
|
|
283
|
+
export type AiMessageRow = typeof aiMessages.$inferSelect;
|
|
284
|
+
export type AiMessageInsert = typeof aiMessages.$inferInsert;
|