@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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/LICENSE +373 -0
  3. package/dist/index.d.ts +85 -0
  4. package/dist/index.js +1158 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lambda.d.ts +5 -0
  7. package/dist/lambda.js +1145 -0
  8. package/dist/lambda.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/app.ts +33 -0
  11. package/src/env.ts +27 -0
  12. package/src/fallback-prompts.ts +343 -0
  13. package/src/gateway-session.ts +148 -0
  14. package/src/index.ts +19 -0
  15. package/src/lambda.ts +17 -0
  16. package/src/middleware/.gitkeep +0 -0
  17. package/src/middleware/auth.ts +38 -0
  18. package/src/middleware/plan.ts +67 -0
  19. package/src/middleware/workspace.ts +96 -0
  20. package/src/posthog.ts +48 -0
  21. package/src/providers/openrouter.ts +37 -0
  22. package/src/routes/chat.ts +235 -0
  23. package/src/routes/edit.ts +57 -0
  24. package/src/routes/health.ts +7 -0
  25. package/src/routes/proxy.ts +60 -0
  26. package/src/routes/services.ts +82 -0
  27. package/src/routes/workspaces.ts +89 -0
  28. package/src/session.ts +80 -0
  29. package/src/tool-docs.ts +77 -0
  30. package/src/types.ts +29 -0
  31. package/test/app.test.ts +62 -0
  32. package/test/gateway-session.test.ts +211 -0
  33. package/test/middleware/auth.test.ts +87 -0
  34. package/test/middleware/plan.test.ts +99 -0
  35. package/test/middleware/workspace.test.ts +122 -0
  36. package/test/routes/chat.test.ts +587 -0
  37. package/test/routes/proxy.test.ts +133 -0
  38. package/test/routes/services.test.ts +128 -0
  39. package/test/routes/workspaces.test.ts +164 -0
  40. package/test/session.test.ts +56 -0
  41. package/tsconfig.json +13 -0
  42. package/tsup.config.ts +17 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Gateway session client.
3
+ *
4
+ * The registry gateway authenticates every request with the caller's Cognito
5
+ * access token and requires the active workspace to be persisted in DDB via
6
+ * `POST /auth/sessions` before other endpoints will accept requests.
7
+ *
8
+ * GatewaySessionClient handles that one-time setup per user and returns the
9
+ * Cognito token itself as the bearer to use on subsequent gateway calls. The
10
+ * result is cached in-memory per user sub for the remaining lifetime of the
11
+ * Cognito token.
12
+ */
13
+
14
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
15
+
16
+ interface SessionEntry {
17
+ /** The Cognito access token to use as bearer for gateway calls. */
18
+ token: string;
19
+ /** Unix seconds — mirrors the Cognito token's `exp` claim. */
20
+ expires_at: number;
21
+ }
22
+
23
+ const sessionCache = new Map<string, SessionEntry>();
24
+
25
+ /**
26
+ * Per-session tool-list cache. Keyed by userSub, parallel to sessionCache.
27
+ * Cleared on session eviction so a fresh session always re-fetches tools.
28
+ */
29
+ const toolsCache = new Map<string, unknown[]>();
30
+
31
+ export function getCachedTools(sub: string): unknown[] | undefined {
32
+ return toolsCache.get(sub);
33
+ }
34
+
35
+ export function setCachedTools(sub: string, tools: unknown[]): void {
36
+ toolsCache.set(sub, tools);
37
+ }
38
+
39
+ export function evictCachedTools(sub: string): void {
40
+ toolsCache.delete(sub);
41
+ }
42
+
43
+ function gatewayUrl(): string {
44
+ const url = process.env["GATEWAY_URL"];
45
+ if (!url) throw new Error("GATEWAY_URL is not set");
46
+ return url.replace(/\/$/, "");
47
+ }
48
+
49
+ /**
50
+ * Establish (or reuse) a gateway session for the caller.
51
+ *
52
+ * 1. Returns the cached entry if still valid.
53
+ * 2. Calls `POST /auth/sessions` on the gateway to register the active
54
+ * workspace for this user.
55
+ * 3. Returns the Cognito token as the bearer — the gateway uses it directly.
56
+ * 4. On 401, evicts the cache and retries once.
57
+ */
58
+ export async function getGatewaySession(
59
+ claims: CognitoAccessTokenPayload,
60
+ workspaceId: string,
61
+ cognitoToken: string,
62
+ ): Promise<SessionEntry> {
63
+ const sub = claims.sub;
64
+ const now = Math.floor(Date.now() / 1000);
65
+
66
+ const cached = sessionCache.get(sub);
67
+ if (cached && cached.expires_at > now + 60) {
68
+ return cached;
69
+ }
70
+
71
+ const entry = await exchangeSession(cognitoToken, workspaceId, claims.exp as number);
72
+ sessionCache.set(sub, entry);
73
+ return entry;
74
+ }
75
+
76
+ async function exchangeSession(
77
+ cognitoToken: string,
78
+ workspaceId: string,
79
+ exp: number,
80
+ ): Promise<SessionEntry> {
81
+ let res: Response;
82
+ try {
83
+ res = await callAuthSessions(cognitoToken, workspaceId);
84
+ } catch {
85
+ // Network error — retry once after 200ms.
86
+ await new Promise<void>((r) => setTimeout(r, 200));
87
+ res = await callAuthSessions(cognitoToken, workspaceId);
88
+ }
89
+
90
+ if (res.status === 401) {
91
+ // Retry once — token may have just been refreshed by the caller.
92
+ const retryRes = await callAuthSessions(cognitoToken, workspaceId);
93
+ if (!retryRes.ok) {
94
+ throw new GatewaySessionError(retryRes.status, await retryRes.text());
95
+ }
96
+ return { token: cognitoToken, expires_at: exp };
97
+ }
98
+
99
+ if (res.status >= 500) {
100
+ // 5xx — retry once after 200ms.
101
+ await new Promise<void>((r) => setTimeout(r, 200));
102
+ const retryRes = await callAuthSessions(cognitoToken, workspaceId);
103
+ if (!retryRes.ok) {
104
+ throw new GatewaySessionError(retryRes.status, await retryRes.text());
105
+ }
106
+ return { token: cognitoToken, expires_at: exp };
107
+ }
108
+
109
+ if (!res.ok) {
110
+ throw new GatewaySessionError(res.status, await res.text());
111
+ }
112
+
113
+ return { token: cognitoToken, expires_at: exp };
114
+ }
115
+
116
+ async function callAuthSessions(
117
+ cognitoToken: string,
118
+ workspaceId: string,
119
+ ): Promise<Response> {
120
+ return fetch(`${gatewayUrl()}/auth/sessions`, {
121
+ method: "POST",
122
+ headers: {
123
+ "Content-Type": "application/json",
124
+ Authorization: `Bearer ${cognitoToken}`,
125
+ },
126
+ body: JSON.stringify({ workspace_id: workspaceId }),
127
+ });
128
+ }
129
+
130
+ export function evictGatewaySession(sub: string): void {
131
+ sessionCache.delete(sub);
132
+ toolsCache.delete(sub);
133
+ }
134
+
135
+ export function resetGatewaySessionCache(): void {
136
+ sessionCache.clear();
137
+ toolsCache.clear();
138
+ }
139
+
140
+ export class GatewaySessionError extends Error {
141
+ constructor(
142
+ public readonly status: number,
143
+ message: string,
144
+ ) {
145
+ super(`Gateway session exchange failed (${status}): ${message}`);
146
+ this.name = "GatewaySessionError";
147
+ }
148
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { createChatApp, initPostHog } from "./app.js";
3
+ import { parseEnv } from "./env.js";
4
+
5
+ export { createChatApp, initPostHog } from "./app.js";
6
+ export type { ChatApp } from "./app.js";
7
+ export { handler } from "./lambda.js";
8
+ export type { LambdaEvent, LambdaContext } from "hono/aws-lambda";
9
+
10
+ const env = parseEnv(process.env);
11
+
12
+ initPostHog(env);
13
+
14
+ if (env.NODE_ENV !== "test") {
15
+ const app = createChatApp();
16
+ serve({ fetch: app.fetch, port: env.PORT }, () => {
17
+ console.log(`Chat API listening on :${env.PORT}`);
18
+ });
19
+ }
package/src/lambda.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { streamHandle } from "hono/aws-lambda";
2
+ import { createChatApp, initPostHog } from "./app.js";
3
+ import { parseEnv } from "./env.js";
4
+ import type { LambdaEvent } from "hono/aws-lambda";
5
+
6
+ const env = parseEnv(process.env);
7
+
8
+ // Initialize module-scope PostHog singletons once per cold start
9
+ initPostHog(env);
10
+
11
+ const app = createChatApp();
12
+
13
+ export const handler = streamHandle(app) as (
14
+ event: LambdaEvent,
15
+ context: unknown,
16
+ callback: unknown,
17
+ ) => Promise<unknown>;
File without changes
@@ -0,0 +1,38 @@
1
+ import { CognitoJwtVerifier } from "aws-jwt-verify";
2
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
3
+ import type { MiddlewareHandler } from "hono";
4
+ import type { AppVariables } from "../types";
5
+
6
+ interface JwtVerifier {
7
+ verify(token: string): Promise<CognitoAccessTokenPayload>;
8
+ }
9
+
10
+ let verifier: JwtVerifier | null = null;
11
+
12
+ function getVerifier(): JwtVerifier {
13
+ if (!verifier) {
14
+ verifier = CognitoJwtVerifier.create({
15
+ userPoolId: process.env["COGNITO_USER_POOL_ID"]!,
16
+ clientId: process.env["COGNITO_CLIENT_ID"]!,
17
+ tokenUse: "access",
18
+ });
19
+ }
20
+ return verifier;
21
+ }
22
+
23
+ export const authMiddleware: MiddlewareHandler<{ Variables: AppVariables }> =
24
+ async (c, next) => {
25
+ const authHeader = c.req.header("Authorization");
26
+ if (!authHeader?.startsWith("Bearer ")) {
27
+ return c.json({ error: "Unauthorized" }, 401);
28
+ }
29
+
30
+ const token = authHeader.slice(7);
31
+ try {
32
+ const payload = await getVerifier().verify(token);
33
+ c.set("claims", payload);
34
+ return next();
35
+ } catch {
36
+ return c.json({ error: "Unauthorized" }, 401);
37
+ }
38
+ };
@@ -0,0 +1,67 @@
1
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
3
+ import type { MiddlewareHandler } from "hono";
4
+ import type { AppVariables, WorkspaceItem } from "../types";
5
+
6
+ const WORKSPACE_CACHE_TTL_MS = 60_000;
7
+
8
+ interface CacheEntry {
9
+ workspace: WorkspaceItem;
10
+ fetchedAt: number;
11
+ }
12
+
13
+ const workspaceCache = new Map<string, CacheEntry>();
14
+
15
+ let ddbClient: DynamoDBDocumentClient | null = null;
16
+
17
+ function getDdb() {
18
+ if (!ddbClient) {
19
+ ddbClient = DynamoDBDocumentClient.from(
20
+ new DynamoDBClient({ region: process.env["AWS_REGION"] }),
21
+ );
22
+ }
23
+ return ddbClient;
24
+ }
25
+
26
+ export async function getWorkspace(workspaceId: string): Promise<WorkspaceItem | null> {
27
+ const now = Date.now();
28
+ const cached = workspaceCache.get(workspaceId);
29
+ if (cached && now - cached.fetchedAt < WORKSPACE_CACHE_TTL_MS) {
30
+ return cached.workspace;
31
+ }
32
+
33
+ const result = await getDdb().send(
34
+ new GetCommand({
35
+ TableName: process.env["WORKSPACE_TABLE_NAME"]!,
36
+ Key: { workspaceId },
37
+ }),
38
+ );
39
+
40
+ if (!result.Item) return null;
41
+
42
+ const workspace = result.Item as WorkspaceItem;
43
+ workspaceCache.set(workspaceId, { workspace, fetchedAt: now });
44
+ return workspace;
45
+ }
46
+
47
+ export const planMiddleware: MiddlewareHandler<{ Variables: AppVariables }> =
48
+ async (c, next) => {
49
+ const workspaceId = c.get("workspaceId");
50
+ const workspace = await getWorkspace(workspaceId);
51
+ if (!workspace) {
52
+ return c.json({ error: "Workspace not found" }, 404);
53
+ }
54
+
55
+ // 402 Payment Required when chat is not included in the plan.
56
+ // Currently all plans include chat; this gate is a forward-looking hook
57
+ // for future restricted plans where chat is a paid add-on.
58
+ if ("chat" in workspace.features && !workspace.features.chat) {
59
+ return c.json(
60
+ { error: "Chat is not available on your current plan", plan: workspace.plan },
61
+ 402,
62
+ );
63
+ }
64
+
65
+ c.set("workspace", workspace);
66
+ return next();
67
+ };
@@ -0,0 +1,96 @@
1
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
3
+ import { getSessionWorkspaceId } from "../session.js";
4
+ import type { AppVariables } from "../types.js";
5
+ import type { MiddlewareHandler } from "hono";
6
+
7
+
8
+
9
+ const MEMBERSHIP_CACHE_TTL_MS = 300_000;
10
+
11
+ interface CacheEntry {
12
+ workspaceIds: string[];
13
+ fetchedAt: number;
14
+ }
15
+
16
+ const membershipCache = new Map<string, CacheEntry>();
17
+
18
+ let ddbClient: DynamoDBDocumentClient | null = null;
19
+
20
+ function getDdb() {
21
+ if (!ddbClient) {
22
+ ddbClient = DynamoDBDocumentClient.from(
23
+ new DynamoDBClient({ region: process.env["AWS_REGION"] }),
24
+ );
25
+ }
26
+ return ddbClient;
27
+ }
28
+
29
+ /** Returns all workspace IDs the user is a member of. Empty array = no memberships. */
30
+ export async function listWorkspaceMemberships(
31
+ userSub: string,
32
+ ): Promise<string[]> {
33
+ const now = Date.now();
34
+ const cached = membershipCache.get(userSub);
35
+ if (cached && now - cached.fetchedAt < MEMBERSHIP_CACHE_TTL_MS) {
36
+ return cached.workspaceIds;
37
+ }
38
+
39
+ const result = await getDdb().send(
40
+ new QueryCommand({
41
+ TableName: process.env["MEMBERSHIPS_TABLE_NAME"]!,
42
+ IndexName: "ByUserSub",
43
+ KeyConditionExpression: "userSub = :sub",
44
+ ExpressionAttributeValues: { ":sub": userSub },
45
+ }),
46
+ );
47
+
48
+ const workspaceIds = (result.Items ?? []).map(
49
+ (item) => (item as { workspaceId: string }).workspaceId,
50
+ );
51
+ membershipCache.set(userSub, { workspaceIds, fetchedAt: now });
52
+ return workspaceIds;
53
+ }
54
+
55
+ export function clearMembershipCache(userSub: string): void {
56
+ membershipCache.delete(userSub);
57
+ }
58
+
59
+ export function resetMembershipCache(): void {
60
+ membershipCache.clear();
61
+ }
62
+
63
+ /**
64
+ * Resolves the active workspace ID for the user:
65
+ * 1. Check the session table for an explicit activeWorkspaceId.
66
+ * 2. Verify the user still has membership for that workspace.
67
+ * 3. Fall back to the first membership if no session preference or membership expired.
68
+ */
69
+ export async function resolveWorkspaceId(
70
+ userSub: string,
71
+ ): Promise<string | null> {
72
+ const [sessionWsId, workspaceIds] = await Promise.all([
73
+ getSessionWorkspaceId(userSub),
74
+ listWorkspaceMemberships(userSub),
75
+ ]);
76
+
77
+ if (workspaceIds.length === 0) return null;
78
+
79
+ if (sessionWsId && workspaceIds.includes(sessionWsId)) {
80
+ return sessionWsId;
81
+ }
82
+
83
+ return workspaceIds[0] ?? null;
84
+ }
85
+
86
+ export const workspaceMiddleware: MiddlewareHandler<{
87
+ Variables: AppVariables;
88
+ }> = async (c, next) => {
89
+ const claims = c.get("claims");
90
+ const workspaceId = await resolveWorkspaceId(claims.sub);
91
+ if (!workspaceId) {
92
+ return c.json({ error: "No workspace membership" }, 403);
93
+ }
94
+ c.set("workspaceId", workspaceId);
95
+ return next();
96
+ };
package/src/posthog.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { Prompts, type PromptResult } from "@posthog/ai";
2
+ import { PostHog } from "posthog-node";
3
+ import { FALLBACK_PROMPTS } from "./fallback-prompts.js";
4
+ import type { Env } from "./env.js";
5
+
6
+ // Module-scope singletons — survive Lambda warm invocations
7
+ let _client: PostHog | null = null;
8
+ let _prompts: Prompts | null = null;
9
+
10
+ export function initPostHog(env: Env): void {
11
+ if (!env.POSTHOG_PROJECT_API_KEY || !env.POSTHOG_PERSONAL_API_KEY) return;
12
+
13
+ _client = new PostHog(env.POSTHOG_PROJECT_API_KEY, {
14
+ host: env.POSTHOG_HOST,
15
+ personalApiKey: env.POSTHOG_PERSONAL_API_KEY,
16
+ });
17
+
18
+ _prompts = new Prompts({ posthog: _client });
19
+ }
20
+
21
+ export async function getPrompt(
22
+ name: string,
23
+ cacheTtlSeconds = 300,
24
+ ): Promise<PromptResult> {
25
+ const fallback = FALLBACK_PROMPTS[name] ?? "";
26
+
27
+ if (!_prompts) {
28
+ return { source: "code_fallback", prompt: fallback, name: undefined, version: undefined };
29
+ }
30
+
31
+ return _prompts.get(name, { cacheTtlSeconds, fallback });
32
+ }
33
+
34
+ export function compilePrompt(
35
+ template: string,
36
+ vars: Record<string, string>,
37
+ ): string {
38
+ if (_prompts) {
39
+ return _prompts.compile(template, vars);
40
+ }
41
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
42
+ }
43
+
44
+ export function getPostHogClient(): PostHog | null {
45
+ return _client;
46
+ }
47
+
48
+ export type { PostHog };
@@ -0,0 +1,37 @@
1
+ import {
2
+ SecretsManagerClient,
3
+ GetSecretValueCommand,
4
+ } from "@aws-sdk/client-secrets-manager";
5
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
6
+
7
+ let secretsClient: SecretsManagerClient | null = null;
8
+ let cachedKey: string | null = null;
9
+
10
+ function getSecretsClient(): SecretsManagerClient {
11
+ if (!secretsClient) {
12
+ secretsClient = new SecretsManagerClient({
13
+ region: process.env["AWS_REGION"],
14
+ });
15
+ }
16
+ return secretsClient;
17
+ }
18
+
19
+ export async function getOpenRouterKey(): Promise<string> {
20
+ if (cachedKey) return cachedKey;
21
+
22
+ const result = await getSecretsClient().send(
23
+ new GetSecretValueCommand({
24
+ SecretId: process.env["OPENROUTER_SECRET_ARN"]!,
25
+ }),
26
+ );
27
+ cachedKey = result.SecretString!;
28
+ return cachedKey!;
29
+ }
30
+
31
+ export function createOpenRouterProvider(apiKey: string) {
32
+ return createOpenAICompatible({
33
+ name: "openrouter",
34
+ apiKey,
35
+ baseURL: "https://openrouter.ai/api/v1",
36
+ });
37
+ }