@aprovan/chat-backend 0.1.0-dev.4d82df8
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/.turbo/turbo-build.log +20 -0
- package/LICENSE +373 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.js +1158 -0
- package/dist/index.js.map +1 -0
- package/dist/lambda.d.ts +5 -0
- package/dist/lambda.js +1145 -0
- package/dist/lambda.js.map +1 -0
- package/package.json +47 -0
- package/src/app.ts +33 -0
- package/src/env.ts +27 -0
- package/src/fallback-prompts.ts +343 -0
- package/src/gateway-session.ts +148 -0
- package/src/index.ts +19 -0
- package/src/lambda.ts +17 -0
- package/src/middleware/.gitkeep +0 -0
- package/src/middleware/auth.ts +38 -0
- package/src/middleware/plan.ts +67 -0
- package/src/middleware/workspace.ts +96 -0
- package/src/posthog.ts +48 -0
- package/src/providers/openrouter.ts +37 -0
- package/src/routes/chat.ts +235 -0
- package/src/routes/edit.ts +57 -0
- package/src/routes/health.ts +7 -0
- package/src/routes/proxy.ts +60 -0
- package/src/routes/services.ts +82 -0
- package/src/routes/workspaces.ts +89 -0
- package/src/session.ts +80 -0
- package/src/tool-docs.ts +77 -0
- package/src/types.ts +29 -0
- package/test/app.test.ts +62 -0
- package/test/gateway-session.test.ts +211 -0
- package/test/middleware/auth.test.ts +87 -0
- package/test/middleware/plan.test.ts +99 -0
- package/test/middleware/workspace.test.ts +122 -0
- package/test/routes/chat.test.ts +587 -0
- package/test/routes/proxy.test.ts +133 -0
- package/test/routes/services.test.ts +128 -0
- package/test/routes/workspaces.test.ts +164 -0
- package/test/session.test.ts +56 -0
- package/tsconfig.json +13 -0
- package/tsup.config.ts +17 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { withTracing } from "@posthog/ai";
|
|
2
|
+
import {
|
|
3
|
+
streamText,
|
|
4
|
+
convertToModelMessages,
|
|
5
|
+
wrapLanguageModel,
|
|
6
|
+
stepCountIs,
|
|
7
|
+
jsonSchema,
|
|
8
|
+
type LanguageModelMiddleware,
|
|
9
|
+
type UIMessage,
|
|
10
|
+
type Tool,
|
|
11
|
+
} from "ai";
|
|
12
|
+
import { Hono } from "hono";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { CHAT_PROMPT_ALLOWLIST } from "../fallback-prompts.js";
|
|
15
|
+
import {
|
|
16
|
+
evictGatewaySession,
|
|
17
|
+
getGatewaySession,
|
|
18
|
+
getCachedTools,
|
|
19
|
+
setCachedTools,
|
|
20
|
+
} from "../gateway-session.js";
|
|
21
|
+
import { getPrompt, compilePrompt, getPostHogClient } from "../posthog.js";
|
|
22
|
+
import {
|
|
23
|
+
getOpenRouterKey,
|
|
24
|
+
createOpenRouterProvider,
|
|
25
|
+
} from "../providers/openrouter.js";
|
|
26
|
+
import { getToolDocs, makeHttpGatewayClient } from "../tool-docs.js";
|
|
27
|
+
import type { AppVariables } from "../types.js";
|
|
28
|
+
|
|
29
|
+
const chatBodySchema = z.object({
|
|
30
|
+
id: z.string(),
|
|
31
|
+
messages: z.array(z.any()),
|
|
32
|
+
trigger: z.string(),
|
|
33
|
+
model: z.string().optional(),
|
|
34
|
+
metadata: z.unknown().optional(),
|
|
35
|
+
prompt: z
|
|
36
|
+
.object({
|
|
37
|
+
id: z.string(),
|
|
38
|
+
vars: z
|
|
39
|
+
.object({
|
|
40
|
+
compilers: z.array(z.string()).optional(),
|
|
41
|
+
})
|
|
42
|
+
.optional(),
|
|
43
|
+
})
|
|
44
|
+
.optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Retry once (200 ms backoff) if the provider rejects before the first
|
|
48
|
+
// streamed byte. Once doStream() resolves (headers received, 2xx), the
|
|
49
|
+
// connection is committed and mid-stream errors surface as error UI parts.
|
|
50
|
+
const retryAtStartMiddleware: LanguageModelMiddleware = {
|
|
51
|
+
specificationVersion: "v3",
|
|
52
|
+
async wrapStream({ doStream }) {
|
|
53
|
+
try {
|
|
54
|
+
return await doStream();
|
|
55
|
+
} catch {
|
|
56
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
57
|
+
return doStream();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const chatRoute = new Hono<{ Variables: AppVariables }>();
|
|
63
|
+
|
|
64
|
+
interface GatewayToolEntry {
|
|
65
|
+
provider: string;
|
|
66
|
+
name: string;
|
|
67
|
+
operation: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
inputSchema?: unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function fetchGatewayTools(
|
|
73
|
+
gatewayUrl: string,
|
|
74
|
+
bearerToken: string,
|
|
75
|
+
): Promise<GatewayToolEntry[]> {
|
|
76
|
+
const res = await fetch(`${gatewayUrl}/tools`, {
|
|
77
|
+
headers: { Authorization: `Bearer ${bearerToken}` },
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) return [];
|
|
80
|
+
const data = (await res.json()) as { tools: GatewayToolEntry[] };
|
|
81
|
+
return data.tools ?? [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildTools(
|
|
85
|
+
gatewayTools: GatewayToolEntry[],
|
|
86
|
+
gatewayUrl: string,
|
|
87
|
+
bearerToken: string,
|
|
88
|
+
): Record<string, Tool> {
|
|
89
|
+
const tools: Record<string, Tool> = {};
|
|
90
|
+
|
|
91
|
+
for (const t of gatewayTools) {
|
|
92
|
+
// Replace dots with underscores — some models reject dots in function names.
|
|
93
|
+
const toolKey = t.name.replace(/\./g, "_");
|
|
94
|
+
|
|
95
|
+
const rawSchema =
|
|
96
|
+
t.inputSchema && typeof t.inputSchema === "object"
|
|
97
|
+
? t.inputSchema
|
|
98
|
+
: { type: "object", properties: {} };
|
|
99
|
+
|
|
100
|
+
const parameters = jsonSchema<Record<string, unknown>>(
|
|
101
|
+
rawSchema as Parameters<typeof jsonSchema>[0],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const execute = async (args: Record<string, unknown>): Promise<unknown> => {
|
|
105
|
+
const res = await fetch(`${gatewayUrl}/tools/${t.provider}/${t.operation}`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: {
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify(args),
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
115
|
+
return { error: (err as { error?: string }).error ?? res.statusText };
|
|
116
|
+
}
|
|
117
|
+
return res.json();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
tools[toolKey] = {
|
|
121
|
+
description: t.description ?? `Call ${t.name}`,
|
|
122
|
+
parameters,
|
|
123
|
+
execute,
|
|
124
|
+
} as unknown as Tool;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return tools;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
chatRoute.post("/", async (c) => {
|
|
131
|
+
const body = await c.req.json().catch(() => null);
|
|
132
|
+
const parsed = chatBodySchema.safeParse(body);
|
|
133
|
+
if (!parsed.success) {
|
|
134
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { messages, model: requestedModel, prompt: promptBody } = parsed.data;
|
|
138
|
+
const claims = c.get("claims");
|
|
139
|
+
const workspaceId = c.get("workspaceId");
|
|
140
|
+
const workspace = c.get("workspace");
|
|
141
|
+
|
|
142
|
+
if (requestedModel !== undefined && !workspace.limits.maxModels.includes(requestedModel)) {
|
|
143
|
+
return c.json(
|
|
144
|
+
{ error: "Model not allowed on your current plan", model: requestedModel },
|
|
145
|
+
403,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const promptId = promptBody?.id ?? "chat-patchwork-widget";
|
|
150
|
+
if (!CHAT_PROMPT_ALLOWLIST.has(promptId)) {
|
|
151
|
+
return c.json({ error: "Unknown prompt id" }, 400);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Gateway tools are additive — chat works without them.
|
|
155
|
+
const gatewayUrl = process.env["GATEWAY_URL"]?.replace(/\/$/, "");
|
|
156
|
+
let sessionToken: string | null = null;
|
|
157
|
+
|
|
158
|
+
if (gatewayUrl) {
|
|
159
|
+
const authHeader = c.req.header("Authorization") ?? "";
|
|
160
|
+
const cognitoToken = authHeader.replace(/^Bearer /, "");
|
|
161
|
+
try {
|
|
162
|
+
const session = await getGatewaySession(claims, workspaceId, cognitoToken);
|
|
163
|
+
sessionToken = session.token;
|
|
164
|
+
} catch {
|
|
165
|
+
// Non-fatal — continue without gateway tools
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let gatewayTools: GatewayToolEntry[] = [];
|
|
170
|
+
if (sessionToken && gatewayUrl) {
|
|
171
|
+
const cached = getCachedTools(claims.sub) as GatewayToolEntry[] | undefined;
|
|
172
|
+
if (cached) {
|
|
173
|
+
gatewayTools = cached;
|
|
174
|
+
} else {
|
|
175
|
+
gatewayTools = await fetchGatewayTools(gatewayUrl, sessionToken).catch(
|
|
176
|
+
() => [] as GatewayToolEntry[],
|
|
177
|
+
);
|
|
178
|
+
if (gatewayTools.length === 0) {
|
|
179
|
+
evictGatewaySession(claims.sub);
|
|
180
|
+
} else {
|
|
181
|
+
setCachedTools(claims.sub, gatewayTools);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const tools =
|
|
187
|
+
sessionToken && gatewayUrl
|
|
188
|
+
? buildTools(gatewayTools, gatewayUrl, sessionToken)
|
|
189
|
+
: {};
|
|
190
|
+
|
|
191
|
+
// Load system prompt from PostHog (cached, with fallback to bundled copy)
|
|
192
|
+
const gatewayClient = gatewayUrl ? makeHttpGatewayClient(gatewayUrl) : null;
|
|
193
|
+
const [promptResult, toolDocs] = await Promise.all([
|
|
194
|
+
getPrompt(promptId),
|
|
195
|
+
getToolDocs(gatewayClient).catch(() => ""),
|
|
196
|
+
]);
|
|
197
|
+
const compilers = (promptBody?.vars?.compilers ?? []).join(", ");
|
|
198
|
+
const systemPrompt = compilePrompt(promptResult.prompt, {
|
|
199
|
+
compilers,
|
|
200
|
+
tool_docs: toolDocs,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const apiKey = await getOpenRouterKey();
|
|
204
|
+
const provider = createOpenRouterProvider(apiKey);
|
|
205
|
+
const modelId = requestedModel ?? workspace.limits.maxModels[0] ?? "openrouter/auto";
|
|
206
|
+
const baseModel = provider(modelId);
|
|
207
|
+
|
|
208
|
+
const phClient = getPostHogClient();
|
|
209
|
+
const tracedModel =
|
|
210
|
+
phClient && promptResult.source !== "code_fallback"
|
|
211
|
+
? withTracing(baseModel, phClient, {
|
|
212
|
+
posthogDistinctId: claims.sub,
|
|
213
|
+
posthogProperties: {
|
|
214
|
+
$ai_prompt_name: promptResult.name,
|
|
215
|
+
$ai_prompt_version: promptResult.version,
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
: baseModel;
|
|
219
|
+
|
|
220
|
+
const model = wrapLanguageModel({
|
|
221
|
+
model: tracedModel,
|
|
222
|
+
middleware: retryAtStartMiddleware,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const result = streamText({
|
|
226
|
+
model,
|
|
227
|
+
system: systemPrompt,
|
|
228
|
+
messages: await convertToModelMessages(messages as UIMessage[]),
|
|
229
|
+
stopWhen: stepCountIs(workspace.limits.maxToolSteps),
|
|
230
|
+
maxOutputTokens: workspace.limits.maxTokensPerRequest,
|
|
231
|
+
tools,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return result.toUIMessageStreamResponse();
|
|
235
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { withTracing } from "@posthog/ai";
|
|
2
|
+
import { streamText } from "ai";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { EDIT_PROMPT_ID } from "../fallback-prompts.js";
|
|
6
|
+
import { getPrompt, compilePrompt, getPostHogClient } from "../posthog.js";
|
|
7
|
+
import {
|
|
8
|
+
getOpenRouterKey,
|
|
9
|
+
createOpenRouterProvider,
|
|
10
|
+
} from "../providers/openrouter.js";
|
|
11
|
+
import type { AppVariables } from "../types.js";
|
|
12
|
+
|
|
13
|
+
const editBodySchema = z.object({
|
|
14
|
+
code: z.string(),
|
|
15
|
+
prompt: z.string(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const MODEL_ID = "openrouter/auto";
|
|
19
|
+
|
|
20
|
+
export const editRoute = new Hono<{ Variables: AppVariables }>();
|
|
21
|
+
|
|
22
|
+
editRoute.post("/", async (c) => {
|
|
23
|
+
const body = await c.req.json().catch(() => null);
|
|
24
|
+
const parsed = editBodySchema.safeParse(body);
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const promptResult = await getPrompt(EDIT_PROMPT_ID);
|
|
30
|
+
const systemPrompt = compilePrompt(promptResult.prompt, {
|
|
31
|
+
code: parsed.data.code,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const apiKey = await getOpenRouterKey();
|
|
35
|
+
const provider = createOpenRouterProvider(apiKey);
|
|
36
|
+
const baseModel = provider(MODEL_ID);
|
|
37
|
+
|
|
38
|
+
const phClient = getPostHogClient();
|
|
39
|
+
const model =
|
|
40
|
+
phClient && promptResult.source !== "code_fallback"
|
|
41
|
+
? withTracing(baseModel, phClient, {
|
|
42
|
+
posthogDistinctId: "chat-api",
|
|
43
|
+
posthogProperties: {
|
|
44
|
+
$ai_prompt_name: promptResult.name,
|
|
45
|
+
$ai_prompt_version: promptResult.version,
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
: baseModel;
|
|
49
|
+
|
|
50
|
+
const result = streamText({
|
|
51
|
+
model,
|
|
52
|
+
system: systemPrompt,
|
|
53
|
+
messages: [{ role: "user", content: parsed.data.prompt }],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return result.toTextStreamResponse();
|
|
57
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/proxy/:ns/:proc
|
|
3
|
+
*
|
|
4
|
+
* Forwards the request body to `POST <gateway>/tools/:ns/:proc` using the
|
|
5
|
+
* caller's gateway session bearer. This keeps tool invocations server-side
|
|
6
|
+
* (credentials never reach the browser).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { evictGatewaySession, GatewaySessionError, getGatewaySession } from "../gateway-session.js";
|
|
11
|
+
import type { AppVariables } from "../types.js";
|
|
12
|
+
|
|
13
|
+
export const proxy = new Hono<{ Variables: AppVariables }>();
|
|
14
|
+
|
|
15
|
+
proxy.post("/:ns/:proc{.*}", async (c) => {
|
|
16
|
+
const claims = c.get("claims");
|
|
17
|
+
const workspaceId = c.get("workspaceId");
|
|
18
|
+
|
|
19
|
+
const ns = c.req.param("ns");
|
|
20
|
+
const proc = c.req.param("proc");
|
|
21
|
+
|
|
22
|
+
const authHeader = c.req.header("Authorization")!;
|
|
23
|
+
const cognitoToken = authHeader.slice("Bearer ".length);
|
|
24
|
+
|
|
25
|
+
let sessionToken: string;
|
|
26
|
+
try {
|
|
27
|
+
const session = await getGatewaySession(claims, workspaceId, cognitoToken);
|
|
28
|
+
sessionToken = session.token;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err instanceof GatewaySessionError && err.status === 401) {
|
|
31
|
+
return c.json({ error: "Gateway session setup failed" }, 401);
|
|
32
|
+
}
|
|
33
|
+
return c.json({ error: "Failed to connect to gateway" }, 502);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let body: unknown = {};
|
|
37
|
+
try {
|
|
38
|
+
body = await c.req.json();
|
|
39
|
+
} catch {
|
|
40
|
+
// empty body is fine
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const gatewayUrl = process.env["GATEWAY_URL"]!.replace(/\/$/, "");
|
|
44
|
+
const res = await fetch(`${gatewayUrl}/tools/${ns}/${proc}`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: {
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
Authorization: `Bearer ${sessionToken}`,
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (res.status === 401) {
|
|
54
|
+
evictGatewaySession(claims.sub);
|
|
55
|
+
return c.json({ error: "Gateway authentication failed" }, 502);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const responseData = await res.json();
|
|
59
|
+
return c.json(responseData, res.status as 200);
|
|
60
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/services
|
|
3
|
+
*
|
|
4
|
+
* Fetches the gateway's tool list and returns a service summary the frontend
|
|
5
|
+
* uses to populate namespace suggestions and the ServicesInspector panel.
|
|
6
|
+
*
|
|
7
|
+
* Response shape:
|
|
8
|
+
* { namespaces: string[], services: ServiceInfo[] }
|
|
9
|
+
*
|
|
10
|
+
* where ServiceInfo matches the frontend's expected `ServiceInfo` type:
|
|
11
|
+
* { namespace: string, name: string, description?: string }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Hono } from "hono";
|
|
15
|
+
import { evictGatewaySession, GatewaySessionError, getGatewaySession } from "../gateway-session.js";
|
|
16
|
+
import type { AppVariables } from "../types.js";
|
|
17
|
+
|
|
18
|
+
export interface ServiceInfo {
|
|
19
|
+
namespace: string;
|
|
20
|
+
name: string;
|
|
21
|
+
procedure: string;
|
|
22
|
+
description: string;
|
|
23
|
+
parameters?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const services = new Hono<{ Variables: AppVariables }>();
|
|
27
|
+
|
|
28
|
+
services.get("/", async (c) => {
|
|
29
|
+
const claims = c.get("claims");
|
|
30
|
+
const workspaceId = c.get("workspaceId");
|
|
31
|
+
|
|
32
|
+
const authHeader = c.req.header("Authorization")!;
|
|
33
|
+
const cognitoToken = authHeader.slice("Bearer ".length);
|
|
34
|
+
|
|
35
|
+
let sessionToken: string;
|
|
36
|
+
try {
|
|
37
|
+
const session = await getGatewaySession(claims, workspaceId, cognitoToken);
|
|
38
|
+
sessionToken = session.token;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err instanceof GatewaySessionError && err.status === 401) {
|
|
41
|
+
return c.json({ error: "Gateway session setup failed" }, 401);
|
|
42
|
+
}
|
|
43
|
+
return c.json({ error: "Failed to connect to gateway" }, 502);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const gatewayUrl = process.env["GATEWAY_URL"]!.replace(/\/$/, "");
|
|
47
|
+
const res = await fetch(`${gatewayUrl}/tools`, {
|
|
48
|
+
headers: { Authorization: `Bearer ${sessionToken}` },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (res.status === 401) {
|
|
52
|
+
evictGatewaySession(claims.sub);
|
|
53
|
+
return c.json({ error: "Gateway authentication failed" }, 502);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
return c.json({ error: "Gateway tools fetch failed" }, 502);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data = await res.json() as {
|
|
61
|
+
tools: Array<{
|
|
62
|
+
provider: string;
|
|
63
|
+
name: string;
|
|
64
|
+
operation: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
inputSchema?: unknown;
|
|
67
|
+
}>;
|
|
68
|
+
workspace_id: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const serviceList: ServiceInfo[] = data.tools.map((t) => ({
|
|
72
|
+
namespace: t.provider,
|
|
73
|
+
name: t.name,
|
|
74
|
+
procedure: t.operation,
|
|
75
|
+
description: t.description ?? "",
|
|
76
|
+
parameters: t.inputSchema as Record<string, unknown> | undefined,
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
const namespaces = Array.from(new Set(data.tools.map((t) => t.provider)));
|
|
80
|
+
|
|
81
|
+
return c.json({ namespaces, services: serviceList });
|
|
82
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
import { BatchGetCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
|
|
3
|
+
import { zValidator } from "@hono/zod-validator";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { listWorkspaceMemberships } from "../middleware/workspace.js";
|
|
7
|
+
import { getSessionWorkspaceId, setSessionWorkspaceId } from "../session.js";
|
|
8
|
+
import type { AppVariables, WorkspaceItem } from "../types.js";
|
|
9
|
+
|
|
10
|
+
let ddbClient: DynamoDBDocumentClient | null = null;
|
|
11
|
+
|
|
12
|
+
function getDdb() {
|
|
13
|
+
if (!ddbClient) {
|
|
14
|
+
ddbClient = DynamoDBDocumentClient.from(
|
|
15
|
+
new DynamoDBClient({ region: process.env["AWS_REGION"] }),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return ddbClient;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function batchGetWorkspaces(
|
|
22
|
+
workspaceIds: string[],
|
|
23
|
+
): Promise<WorkspaceItem[]> {
|
|
24
|
+
if (workspaceIds.length === 0) return [];
|
|
25
|
+
const tableName = process.env["WORKSPACE_TABLE_NAME"]!;
|
|
26
|
+
const result = await getDdb().send(
|
|
27
|
+
new BatchGetCommand({
|
|
28
|
+
RequestItems: {
|
|
29
|
+
[tableName]: { Keys: workspaceIds.map((id) => ({ workspaceId: id })) },
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
return (result.Responses?.[tableName] ?? []) as WorkspaceItem[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const workspacesRoute = new Hono<{ Variables: AppVariables }>();
|
|
37
|
+
|
|
38
|
+
workspacesRoute.get("/", async (c) => {
|
|
39
|
+
const claims = c.get("claims");
|
|
40
|
+
const userSub = claims.sub;
|
|
41
|
+
|
|
42
|
+
const [workspaceIds, activeWorkspaceId] = await Promise.all([
|
|
43
|
+
listWorkspaceMemberships(userSub),
|
|
44
|
+
getSessionWorkspaceId(userSub),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const workspaces = await batchGetWorkspaces(workspaceIds);
|
|
48
|
+
|
|
49
|
+
const effectiveActiveId =
|
|
50
|
+
activeWorkspaceId && workspaceIds.includes(activeWorkspaceId)
|
|
51
|
+
? activeWorkspaceId
|
|
52
|
+
: (workspaceIds[0] ?? null);
|
|
53
|
+
|
|
54
|
+
const sorted = workspaceIds
|
|
55
|
+
.map((id) => workspaces.find((w) => w.workspaceId === id))
|
|
56
|
+
.filter((w): w is WorkspaceItem => w !== undefined);
|
|
57
|
+
|
|
58
|
+
return c.json({
|
|
59
|
+
workspaces: sorted.map((w) => ({
|
|
60
|
+
workspaceId: w.workspaceId,
|
|
61
|
+
name: w.name,
|
|
62
|
+
plan: w.plan,
|
|
63
|
+
active: w.workspaceId === effectiveActiveId,
|
|
64
|
+
})),
|
|
65
|
+
activeWorkspaceId: effectiveActiveId,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const setActiveSchema = z.object({
|
|
70
|
+
workspaceId: z.string().min(1),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
workspacesRoute.put(
|
|
74
|
+
"/active",
|
|
75
|
+
zValidator("json", setActiveSchema),
|
|
76
|
+
async (c) => {
|
|
77
|
+
const claims = c.get("claims");
|
|
78
|
+
const userSub = claims.sub;
|
|
79
|
+
const { workspaceId } = c.req.valid("json");
|
|
80
|
+
|
|
81
|
+
const workspaceIds = await listWorkspaceMemberships(userSub);
|
|
82
|
+
if (!workspaceIds.includes(workspaceId)) {
|
|
83
|
+
return c.json({ error: "Not a member of this workspace" }, 403);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await setSessionWorkspaceId(userSub, workspaceId);
|
|
87
|
+
return c.json({ activeWorkspaceId: workspaceId });
|
|
88
|
+
},
|
|
89
|
+
);
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
import {
|
|
3
|
+
DynamoDBDocumentClient,
|
|
4
|
+
GetCommand,
|
|
5
|
+
PutCommand,
|
|
6
|
+
} from "@aws-sdk/lib-dynamodb";
|
|
7
|
+
|
|
8
|
+
const SESSION_CACHE_TTL_MS = 15_000;
|
|
9
|
+
|
|
10
|
+
interface SessionCacheEntry {
|
|
11
|
+
activeWorkspaceId: string | null;
|
|
12
|
+
fetchedAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sessionCache = new Map<string, SessionCacheEntry>();
|
|
16
|
+
|
|
17
|
+
let ddbClient: DynamoDBDocumentClient | null = null;
|
|
18
|
+
|
|
19
|
+
function getDdb() {
|
|
20
|
+
if (!ddbClient) {
|
|
21
|
+
ddbClient = DynamoDBDocumentClient.from(
|
|
22
|
+
new DynamoDBClient({ region: process.env["AWS_REGION"] }),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return ddbClient;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function tableName(): string {
|
|
29
|
+
return process.env["USER_SESSIONS_TABLE_NAME"]!;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getSessionWorkspaceId(
|
|
33
|
+
userSub: string,
|
|
34
|
+
): Promise<string | null> {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const cached = sessionCache.get(userSub);
|
|
37
|
+
if (cached && now - cached.fetchedAt < SESSION_CACHE_TTL_MS) {
|
|
38
|
+
return cached.activeWorkspaceId;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = await getDdb().send(
|
|
42
|
+
new GetCommand({
|
|
43
|
+
TableName: tableName(),
|
|
44
|
+
Key: { userSub },
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const activeWorkspaceId =
|
|
49
|
+
(result.Item?.activeWorkspaceId as string | undefined) ?? null;
|
|
50
|
+
sessionCache.set(userSub, { activeWorkspaceId, fetchedAt: now });
|
|
51
|
+
return activeWorkspaceId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function setSessionWorkspaceId(
|
|
55
|
+
userSub: string,
|
|
56
|
+
workspaceId: string,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
await getDdb().send(
|
|
59
|
+
new PutCommand({
|
|
60
|
+
TableName: tableName(),
|
|
61
|
+
Item: {
|
|
62
|
+
userSub,
|
|
63
|
+
activeWorkspaceId: workspaceId,
|
|
64
|
+
updatedAt: new Date().toISOString(),
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
sessionCache.set(userSub, {
|
|
69
|
+
activeWorkspaceId: workspaceId,
|
|
70
|
+
fetchedAt: Date.now(),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function clearSessionCache(userSub: string): void {
|
|
75
|
+
sessionCache.delete(userSub);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resetSessionCache(): void {
|
|
79
|
+
sessionCache.clear();
|
|
80
|
+
}
|
package/src/tool-docs.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface ToolInfo {
|
|
2
|
+
namespace: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface GatewayClient {
|
|
8
|
+
listTools(): Promise<ToolInfo[]>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Module-scope cache — 60 s TTL, survives Lambda warm invocations
|
|
12
|
+
let _cachedToolDocs = "";
|
|
13
|
+
let _toolDocsCachedAt = 0;
|
|
14
|
+
const TOOL_DOCS_TTL_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
export async function getToolDocs(
|
|
17
|
+
gateway: GatewayClient | null,
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
if (!gateway) return "";
|
|
20
|
+
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
if (_cachedToolDocs && now - _toolDocsCachedAt < TOOL_DOCS_TTL_MS) {
|
|
23
|
+
return _cachedToolDocs;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const tools = await gateway.listTools();
|
|
27
|
+
_cachedToolDocs = renderToolDocs(tools);
|
|
28
|
+
_toolDocsCachedAt = now;
|
|
29
|
+
return _cachedToolDocs;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderToolDocs(tools: ToolInfo[]): string {
|
|
33
|
+
if (tools.length === 0) return "";
|
|
34
|
+
|
|
35
|
+
const byNamespace = new Map<string, ToolInfo[]>();
|
|
36
|
+
for (const tool of tools) {
|
|
37
|
+
const existing = byNamespace.get(tool.namespace) ?? [];
|
|
38
|
+
existing.push(tool);
|
|
39
|
+
byNamespace.set(tool.namespace, existing);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const namespaces = [...byNamespace.keys()];
|
|
43
|
+
let doc = `## Services\n\nThe following services are available for generated widgets to call:\n\n`;
|
|
44
|
+
|
|
45
|
+
for (const [ns, nsTools] of byNamespace) {
|
|
46
|
+
doc += `### \`${ns}\`\n`;
|
|
47
|
+
for (const tool of nsTools) {
|
|
48
|
+
doc += `- \`${ns}.${tool.name}()\``;
|
|
49
|
+
if (tool.description) doc += `: ${tool.description}`;
|
|
50
|
+
doc += "\n";
|
|
51
|
+
}
|
|
52
|
+
doc += "\n";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const firstNs = namespaces[0] ?? "service";
|
|
56
|
+
const firstTool = byNamespace.get(firstNs)?.[0]?.name ?? "example";
|
|
57
|
+
doc += `**Usage in widgets:**
|
|
58
|
+
\`\`\`tsx
|
|
59
|
+
// Services are available as global namespaces
|
|
60
|
+
const result = await ${firstNs}.${firstTool}({ /* args */ });
|
|
61
|
+
\`\`\`
|
|
62
|
+
|
|
63
|
+
Make sure to handle loading states and errors when calling services.
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
return doc;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function makeHttpGatewayClient(gatewayUrl: string): GatewayClient {
|
|
70
|
+
return {
|
|
71
|
+
async listTools(): Promise<ToolInfo[]> {
|
|
72
|
+
const res = await fetch(`${gatewayUrl}/tools`);
|
|
73
|
+
if (!res.ok) throw new Error(`Gateway returned ${res.status}`);
|
|
74
|
+
return res.json() as Promise<ToolInfo[]>;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|