@lobu/gateway 3.0.5 → 3.0.7

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 (175) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/agent-config-routes.test.ts +254 -0
  3. package/src/__tests__/agent-history-routes.test.ts +72 -0
  4. package/src/__tests__/agent-routes.test.ts +68 -0
  5. package/src/__tests__/agent-schedules-routes.test.ts +59 -0
  6. package/src/__tests__/agent-settings-store.test.ts +323 -0
  7. package/src/__tests__/chat-instance-manager-slack.test.ts +204 -0
  8. package/src/__tests__/chat-response-bridge.test.ts +131 -0
  9. package/src/__tests__/config-memory-plugins.test.ts +92 -0
  10. package/src/__tests__/config-request-store.test.ts +127 -0
  11. package/src/__tests__/connection-routes.test.ts +144 -0
  12. package/src/__tests__/core-services-store-selection.test.ts +92 -0
  13. package/src/__tests__/docker-deployment.test.ts +1211 -0
  14. package/src/__tests__/embedded-deployment.test.ts +342 -0
  15. package/src/__tests__/grant-store.test.ts +148 -0
  16. package/src/__tests__/http-proxy.test.ts +281 -0
  17. package/src/__tests__/instruction-service.test.ts +37 -0
  18. package/src/__tests__/link-buttons.test.ts +112 -0
  19. package/src/__tests__/lobu.test.ts +32 -0
  20. package/src/__tests__/mcp-config-service.test.ts +347 -0
  21. package/src/__tests__/mcp-proxy.test.ts +696 -0
  22. package/src/__tests__/message-handler-bridge.test.ts +17 -0
  23. package/src/__tests__/model-selection.test.ts +172 -0
  24. package/src/__tests__/oauth-templates.test.ts +39 -0
  25. package/src/__tests__/platform-adapter-slack-send.test.ts +114 -0
  26. package/src/__tests__/platform-helpers-model-resolution.test.ts +253 -0
  27. package/src/__tests__/provider-inheritance.test.ts +212 -0
  28. package/src/__tests__/routes/cli-auth.test.ts +337 -0
  29. package/src/__tests__/routes/interactions.test.ts +121 -0
  30. package/src/__tests__/secret-proxy.test.ts +85 -0
  31. package/src/__tests__/session-manager.test.ts +572 -0
  32. package/src/__tests__/setup.ts +133 -0
  33. package/src/__tests__/skill-and-mcp-registry.test.ts +203 -0
  34. package/src/__tests__/slack-routes.test.ts +161 -0
  35. package/src/__tests__/system-config-resolver.test.ts +75 -0
  36. package/src/__tests__/system-message-limiter.test.ts +89 -0
  37. package/src/__tests__/system-skills-service.test.ts +362 -0
  38. package/src/__tests__/transcription-service.test.ts +222 -0
  39. package/src/__tests__/utils/rate-limiter.test.ts +102 -0
  40. package/src/__tests__/worker-connection-manager.test.ts +497 -0
  41. package/src/__tests__/worker-job-router.test.ts +722 -0
  42. package/src/api/index.ts +1 -0
  43. package/src/api/platform.ts +292 -0
  44. package/src/api/response-renderer.ts +157 -0
  45. package/src/auth/agent-metadata-store.ts +168 -0
  46. package/src/auth/api-auth-middleware.ts +69 -0
  47. package/src/auth/api-key-provider-module.ts +213 -0
  48. package/src/auth/base-provider-module.ts +201 -0
  49. package/src/auth/chatgpt/chatgpt-oauth-module.ts +185 -0
  50. package/src/auth/chatgpt/device-code-client.ts +218 -0
  51. package/src/auth/chatgpt/index.ts +1 -0
  52. package/src/auth/claude/oauth-module.ts +280 -0
  53. package/src/auth/cli/token-service.ts +249 -0
  54. package/src/auth/external/client.ts +560 -0
  55. package/src/auth/external/device-code-client.ts +225 -0
  56. package/src/auth/mcp/config-service.ts +392 -0
  57. package/src/auth/mcp/proxy.ts +1088 -0
  58. package/src/auth/mcp/string-substitution.ts +17 -0
  59. package/src/auth/mcp/tool-cache.ts +90 -0
  60. package/src/auth/oauth/base-client.ts +267 -0
  61. package/src/auth/oauth/client.ts +153 -0
  62. package/src/auth/oauth/credentials.ts +7 -0
  63. package/src/auth/oauth/providers.ts +69 -0
  64. package/src/auth/oauth/state-store.ts +150 -0
  65. package/src/auth/oauth-templates.ts +179 -0
  66. package/src/auth/provider-catalog.ts +220 -0
  67. package/src/auth/provider-model-options.ts +41 -0
  68. package/src/auth/settings/agent-settings-store.ts +565 -0
  69. package/src/auth/settings/auth-profiles-manager.ts +216 -0
  70. package/src/auth/settings/index.ts +12 -0
  71. package/src/auth/settings/model-preference-store.ts +52 -0
  72. package/src/auth/settings/model-selection.ts +135 -0
  73. package/src/auth/settings/resolved-settings-view.ts +298 -0
  74. package/src/auth/settings/template-utils.ts +44 -0
  75. package/src/auth/settings/token-service.ts +88 -0
  76. package/src/auth/system-env-store.ts +98 -0
  77. package/src/auth/user-agents-store.ts +68 -0
  78. package/src/channels/binding-service.ts +214 -0
  79. package/src/channels/index.ts +4 -0
  80. package/src/cli/gateway.ts +1304 -0
  81. package/src/cli/index.ts +74 -0
  82. package/src/commands/built-in-commands.ts +80 -0
  83. package/src/commands/command-dispatcher.ts +94 -0
  84. package/src/commands/command-reply-adapters.ts +27 -0
  85. package/src/config/file-loader.ts +618 -0
  86. package/src/config/index.ts +588 -0
  87. package/src/config/network-allowlist.ts +71 -0
  88. package/src/connections/chat-instance-manager.ts +1284 -0
  89. package/src/connections/chat-response-bridge.ts +618 -0
  90. package/src/connections/index.ts +7 -0
  91. package/src/connections/interaction-bridge.ts +831 -0
  92. package/src/connections/message-handler-bridge.ts +415 -0
  93. package/src/connections/platform-auth-methods.ts +15 -0
  94. package/src/connections/types.ts +84 -0
  95. package/src/gateway/connection-manager.ts +291 -0
  96. package/src/gateway/index.ts +700 -0
  97. package/src/gateway/job-router.ts +201 -0
  98. package/src/gateway-main.ts +200 -0
  99. package/src/index.ts +41 -0
  100. package/src/infrastructure/queue/index.ts +12 -0
  101. package/src/infrastructure/queue/queue-producer.ts +148 -0
  102. package/src/infrastructure/queue/redis-queue.ts +361 -0
  103. package/src/infrastructure/queue/types.ts +133 -0
  104. package/src/infrastructure/redis/system-message-limiter.ts +94 -0
  105. package/src/interactions/config-request-store.ts +198 -0
  106. package/src/interactions.ts +363 -0
  107. package/src/lobu.ts +311 -0
  108. package/src/metrics/prometheus.ts +159 -0
  109. package/src/modules/module-system.ts +179 -0
  110. package/src/orchestration/base-deployment-manager.ts +900 -0
  111. package/src/orchestration/deployment-utils.ts +98 -0
  112. package/src/orchestration/impl/docker-deployment.ts +620 -0
  113. package/src/orchestration/impl/embedded-deployment.ts +268 -0
  114. package/src/orchestration/impl/index.ts +8 -0
  115. package/src/orchestration/impl/k8s/deployment.ts +1061 -0
  116. package/src/orchestration/impl/k8s/helpers.ts +610 -0
  117. package/src/orchestration/impl/k8s/index.ts +1 -0
  118. package/src/orchestration/index.ts +333 -0
  119. package/src/orchestration/message-consumer.ts +584 -0
  120. package/src/orchestration/scheduled-wakeup.ts +704 -0
  121. package/src/permissions/approval-policy.ts +36 -0
  122. package/src/permissions/grant-store.ts +219 -0
  123. package/src/platform/file-handler.ts +66 -0
  124. package/src/platform/link-buttons.ts +57 -0
  125. package/src/platform/renderer-utils.ts +44 -0
  126. package/src/platform/response-renderer.ts +84 -0
  127. package/src/platform/unified-thread-consumer.ts +187 -0
  128. package/src/platform.ts +318 -0
  129. package/src/proxy/http-proxy.ts +752 -0
  130. package/src/proxy/proxy-manager.ts +81 -0
  131. package/src/proxy/secret-proxy.ts +402 -0
  132. package/src/proxy/token-refresh-job.ts +143 -0
  133. package/src/routes/internal/audio.ts +141 -0
  134. package/src/routes/internal/device-auth.ts +566 -0
  135. package/src/routes/internal/files.ts +226 -0
  136. package/src/routes/internal/history.ts +69 -0
  137. package/src/routes/internal/images.ts +127 -0
  138. package/src/routes/internal/interactions.ts +84 -0
  139. package/src/routes/internal/middleware.ts +23 -0
  140. package/src/routes/internal/schedule.ts +226 -0
  141. package/src/routes/internal/types.ts +22 -0
  142. package/src/routes/openapi-auto.ts +239 -0
  143. package/src/routes/public/agent-access.ts +23 -0
  144. package/src/routes/public/agent-config.ts +675 -0
  145. package/src/routes/public/agent-history.ts +422 -0
  146. package/src/routes/public/agent-schedules.ts +296 -0
  147. package/src/routes/public/agent.ts +1086 -0
  148. package/src/routes/public/agents.ts +373 -0
  149. package/src/routes/public/channels.ts +191 -0
  150. package/src/routes/public/cli-auth.ts +883 -0
  151. package/src/routes/public/connections.ts +574 -0
  152. package/src/routes/public/landing.ts +16 -0
  153. package/src/routes/public/oauth.ts +147 -0
  154. package/src/routes/public/settings-auth.ts +104 -0
  155. package/src/routes/public/slack.ts +173 -0
  156. package/src/routes/shared/agent-ownership.ts +101 -0
  157. package/src/routes/shared/token-verifier.ts +34 -0
  158. package/src/services/core-services.ts +1053 -0
  159. package/src/services/image-generation-service.ts +257 -0
  160. package/src/services/instruction-service.ts +318 -0
  161. package/src/services/mcp-registry.ts +94 -0
  162. package/src/services/platform-helpers.ts +287 -0
  163. package/src/services/session-manager.ts +262 -0
  164. package/src/services/settings-resolver.ts +74 -0
  165. package/src/services/system-config-resolver.ts +90 -0
  166. package/src/services/system-skills-service.ts +229 -0
  167. package/src/services/transcription-service.ts +684 -0
  168. package/src/session.ts +110 -0
  169. package/src/spaces/index.ts +1 -0
  170. package/src/spaces/space-resolver.ts +17 -0
  171. package/src/stores/in-memory-agent-store.ts +403 -0
  172. package/src/stores/redis-agent-store.ts +279 -0
  173. package/src/utils/public-url.ts +44 -0
  174. package/src/utils/rate-limiter.ts +94 -0
  175. package/tsconfig.json +33 -0
@@ -0,0 +1,212 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { MockRedisClient } from "@lobu/core/testing";
3
+ import {
4
+ ProviderCatalogService,
5
+ resolveInstalledProviders,
6
+ } from "../auth/provider-catalog";
7
+ import { AgentSettingsStore } from "../auth/settings/agent-settings-store";
8
+ import { AuthProfilesManager } from "../auth/settings/auth-profiles-manager";
9
+ import {
10
+ canEditSettingsSection,
11
+ canViewSettingsSection,
12
+ resolveSettingsView,
13
+ } from "../auth/settings/resolved-settings-view";
14
+ import { buildDefaultSettingsFromSource } from "../auth/settings/template-utils";
15
+ import { hasConfiguredProvider } from "../services/platform-helpers";
16
+
17
+ describe("sandbox provider inheritance", () => {
18
+ let redis: MockRedisClient;
19
+ let store: AgentSettingsStore;
20
+ let authProfilesManager: AuthProfilesManager;
21
+
22
+ beforeEach(() => {
23
+ redis = new MockRedisClient();
24
+ store = new AgentSettingsStore(redis as any);
25
+ authProfilesManager = new AuthProfilesManager(store);
26
+ });
27
+
28
+ test("inherits installed providers through metadata and connection template fallback", async () => {
29
+ await store.saveSettings("template-agent", {
30
+ installedProviders: [{ providerId: "z-ai", installedAt: 1 }],
31
+ });
32
+ await redis.set(
33
+ "agent_metadata:telegram-6570514069",
34
+ JSON.stringify({ parentConnectionId: "conn-1" })
35
+ );
36
+ await redis.set(
37
+ "connection:conn-1",
38
+ JSON.stringify({ templateAgentId: "template-agent" })
39
+ );
40
+
41
+ const providers = await resolveInstalledProviders(
42
+ store,
43
+ "telegram-6570514069"
44
+ );
45
+
46
+ expect(providers).toEqual([{ providerId: "z-ai", installedAt: 1 }]);
47
+ });
48
+
49
+ test("inherits auth profiles through metadata and connection template fallback", async () => {
50
+ await store.saveSettings("template-agent", {
51
+ authProfiles: [
52
+ {
53
+ id: "profile-1",
54
+ provider: "z-ai",
55
+ credential: "secret",
56
+ authType: "api-key",
57
+ label: "z.ai",
58
+ model: "*",
59
+ createdAt: 1,
60
+ },
61
+ ],
62
+ installedProviders: [{ providerId: "z-ai", installedAt: 1 }],
63
+ });
64
+ await redis.set(
65
+ "agent_metadata:telegram-6570514069",
66
+ JSON.stringify({ parentConnectionId: "conn-1" })
67
+ );
68
+ await redis.set(
69
+ "connection:conn-1",
70
+ JSON.stringify({ templateAgentId: "template-agent" })
71
+ );
72
+
73
+ const profiles = await authProfilesManager.listProfiles(
74
+ "telegram-6570514069"
75
+ );
76
+
77
+ expect(profiles).toHaveLength(1);
78
+ expect(profiles[0]?.provider).toBe("z-ai");
79
+ expect(profiles[0]?.credential).toBe("secret");
80
+ });
81
+
82
+ test("inherits auth profiles for cloned sandbox settings that copied providers", async () => {
83
+ await store.saveSettings("template-agent", {
84
+ authProfiles: [
85
+ {
86
+ id: "profile-1",
87
+ provider: "z-ai",
88
+ credential: "secret",
89
+ authType: "api-key",
90
+ label: "z.ai",
91
+ model: "*",
92
+ createdAt: 1,
93
+ },
94
+ ],
95
+ installedProviders: [{ providerId: "z-ai", installedAt: 1 }],
96
+ });
97
+
98
+ const templateSettings = await store.getSettings("template-agent");
99
+ const cloned = buildDefaultSettingsFromSource(templateSettings);
100
+ cloned.templateAgentId = "template-agent";
101
+ await store.saveSettings("telegram-6570514069", cloned);
102
+
103
+ const effective = await store.getEffectiveSettings("telegram-6570514069");
104
+ const profiles = await authProfilesManager.listProfiles(
105
+ "telegram-6570514069"
106
+ );
107
+
108
+ expect(cloned.authProfiles).toBeUndefined();
109
+ expect(effective?.authProfiles).toHaveLength(1);
110
+ expect(profiles).toHaveLength(1);
111
+ });
112
+
113
+ test("treats cloned sandbox settings as configured when template provides credentials", async () => {
114
+ await store.saveSettings("template-agent", {
115
+ authProfiles: [
116
+ {
117
+ id: "profile-1",
118
+ provider: "z-ai",
119
+ credential: "secret",
120
+ authType: "api-key",
121
+ label: "z.ai",
122
+ model: "*",
123
+ createdAt: 1,
124
+ },
125
+ ],
126
+ installedProviders: [{ providerId: "z-ai", installedAt: 1 }],
127
+ });
128
+
129
+ const templateSettings = await store.getSettings("template-agent");
130
+ const cloned = buildDefaultSettingsFromSource(templateSettings);
131
+ cloned.templateAgentId = "template-agent";
132
+ await store.saveSettings("telegram-6570514069", cloned);
133
+
134
+ await expect(
135
+ hasConfiguredProvider("telegram-6570514069", store)
136
+ ).resolves.toBe(true);
137
+ });
138
+
139
+ test("exposes inherited provider state with read-only model visibility", async () => {
140
+ await store.saveSettings("template-agent", {
141
+ installedProviders: [{ providerId: "z-ai", installedAt: 1 }],
142
+ });
143
+ await redis.set(
144
+ "agent_metadata:telegram-6570514069",
145
+ JSON.stringify({ parentConnectionId: "conn-1" })
146
+ );
147
+ await redis.set(
148
+ "connection:conn-1",
149
+ JSON.stringify({ templateAgentId: "template-agent" })
150
+ );
151
+
152
+ const settingsView = await resolveSettingsView({
153
+ agentId: "telegram-6570514069",
154
+ agentSettingsStore: store,
155
+ viewer: {
156
+ settingsMode: "user",
157
+ allowedScopes: ["view-model"],
158
+ isAdmin: false,
159
+ },
160
+ });
161
+
162
+ expect(
163
+ canViewSettingsSection("model", {
164
+ settingsMode: "user",
165
+ allowedScopes: ["view-model"],
166
+ isAdmin: false,
167
+ })
168
+ ).toBe(true);
169
+ expect(
170
+ canEditSettingsSection("model", {
171
+ settingsMode: "user",
172
+ allowedScopes: ["view-model"],
173
+ isAdmin: false,
174
+ })
175
+ ).toBe(false);
176
+ expect(settingsView.scope).toBe("sandbox");
177
+ expect(settingsView.sections.model.source).toBe("inherited");
178
+ expect(settingsView.sections.model.editable).toBe(false);
179
+ expect(settingsView.providerSources["z-ai"]?.source).toBe("inherited");
180
+ expect(settingsView.providerSources["z-ai"]?.canEdit).toBe(false);
181
+ });
182
+
183
+ test("uninstalling an inherited sandbox provider writes a local override list", async () => {
184
+ await store.saveSettings("template-agent", {
185
+ installedProviders: [
186
+ { providerId: "z-ai", installedAt: 1 },
187
+ { providerId: "openai", installedAt: 2 },
188
+ ],
189
+ });
190
+ await redis.set(
191
+ "agent_metadata:telegram-6570514069",
192
+ JSON.stringify({ parentConnectionId: "conn-1" })
193
+ );
194
+ await redis.set(
195
+ "connection:conn-1",
196
+ JSON.stringify({ templateAgentId: "template-agent" })
197
+ );
198
+
199
+ const catalog = new ProviderCatalogService(store, authProfilesManager);
200
+ await catalog.uninstallProvider("telegram-6570514069", "z-ai");
201
+
202
+ const local = await store.getSettings("telegram-6570514069");
203
+ const effective = await store.getEffectiveSettings("telegram-6570514069");
204
+
205
+ expect(local?.installedProviders).toEqual([
206
+ { providerId: "openai", installedAt: 2 },
207
+ ]);
208
+ expect(effective?.installedProviders).toEqual([
209
+ { providerId: "openai", installedAt: 2 },
210
+ ]);
211
+ });
212
+ });
@@ -0,0 +1,337 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { decrypt } from "@lobu/core";
3
+ import { MockRedisClient } from "../../../../core/src/__tests__/fixtures/mock-redis";
4
+ import {
5
+ createCliAuthRoutes,
6
+ createConnectAuthRoutes,
7
+ } from "../../routes/public/cli-auth";
8
+
9
+ describe("cli auth routes", () => {
10
+ let originalKey: string | undefined;
11
+ let redis: MockRedisClient;
12
+ let queue: { getRedisClient(): MockRedisClient };
13
+
14
+ beforeEach(() => {
15
+ mock.restore();
16
+ originalKey = process.env.ENCRYPTION_KEY;
17
+ process.env.ENCRYPTION_KEY =
18
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
19
+ redis = new MockRedisClient();
20
+ queue = {
21
+ getRedisClient: () => redis,
22
+ };
23
+ });
24
+
25
+ afterEach(() => {
26
+ if (originalKey !== undefined) {
27
+ process.env.ENCRYPTION_KEY = originalKey;
28
+ } else {
29
+ delete process.env.ENCRYPTION_KEY;
30
+ }
31
+ });
32
+
33
+ test("POST /cli/start returns device mode when the external provider supports device auth", async () => {
34
+ const router = createCliAuthRoutes({
35
+ queue: queue as any,
36
+ externalAuthClient: {
37
+ getCapabilities: mock(async () => ({ browser: true, device: true })),
38
+ startDeviceAuthorization: mock(async () => ({
39
+ deviceAuthId: "device-123",
40
+ userCode: "ABCD-EFGH",
41
+ verificationUri: "https://issuer.example.com/device",
42
+ verificationUriComplete:
43
+ "https://issuer.example.com/device?user_code=ABCD-EFGH",
44
+ interval: 5,
45
+ expiresIn: 600,
46
+ })),
47
+ } as any,
48
+ });
49
+
50
+ const res = await router.request("/cli/start", {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ });
54
+
55
+ expect(res.status).toBe(200);
56
+ const body = await res.json();
57
+ expect(body.mode).toBe("device");
58
+ expect(body.deviceAuthId).toBe("device-123");
59
+ expect(body.userCode).toBe("ABCD-EFGH");
60
+ });
61
+
62
+ test("POST /cli/start falls back to browser mode when device auth is unavailable", async () => {
63
+ const router = createCliAuthRoutes({
64
+ queue: queue as any,
65
+ externalAuthClient: {
66
+ getCapabilities: mock(async () => ({ browser: true, device: false })),
67
+ } as any,
68
+ });
69
+
70
+ const res = await router.request("https://gateway.example.com/cli/start", {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ });
74
+
75
+ expect(res.status).toBe(200);
76
+ const body = await res.json();
77
+ expect(body.mode).toBe("browser");
78
+ expect(typeof body.requestId).toBe("string");
79
+ expect(body.loginUrl).toContain("/api/v1/auth/cli/session/login?request=");
80
+ });
81
+
82
+ test("GET /connect/oauth/login redirects into external browser auth", async () => {
83
+ const router = createConnectAuthRoutes({
84
+ queue: queue as any,
85
+ externalAuthClient: {
86
+ generateCodeVerifier: () => "code-verifier",
87
+ buildAuthUrl: mock(async (state: string, codeVerifier: string) => {
88
+ expect(state).toBeTruthy();
89
+ expect(codeVerifier).toBe("code-verifier");
90
+ return "https://issuer.example.com/oauth/authorize";
91
+ }),
92
+ } as any,
93
+ });
94
+
95
+ const res = await router.request(
96
+ "https://gateway.example.com/connect/oauth/login?returnUrl=%2Fdone"
97
+ );
98
+
99
+ expect(res.status).toBe(302);
100
+ expect(res.headers.get("location")).toBe(
101
+ "https://issuer.example.com/oauth/authorize"
102
+ );
103
+ });
104
+
105
+ test("GET /connect/oauth/callback sets a settings session and redirects back", async () => {
106
+ await redis.setex(
107
+ "cli:auth:connect:state-123",
108
+ 600,
109
+ JSON.stringify({
110
+ returnUrl: "/done",
111
+ codeVerifier: "code-verifier",
112
+ })
113
+ );
114
+
115
+ const router = createConnectAuthRoutes({
116
+ queue: queue as any,
117
+ externalAuthClient: {
118
+ exchangeCodeForToken: mock(async () => ({
119
+ accessToken: "provider-access-token",
120
+ refreshToken: "provider-refresh-token",
121
+ tokenType: "Bearer",
122
+ expiresAt: Date.now() + 3600_000,
123
+ scopes: ["profile:read"],
124
+ })),
125
+ fetchUserInfo: mock(async () => ({
126
+ sub: "user-123",
127
+ email: "user@example.com",
128
+ name: "Example User",
129
+ })),
130
+ } as any,
131
+ });
132
+
133
+ const res = await router.request(
134
+ "https://gateway.example.com/connect/oauth/callback?code=auth-code&state=state-123"
135
+ );
136
+
137
+ expect(res.status).toBe(302);
138
+ expect(res.headers.get("location")).toBe("/done");
139
+ expect(res.headers.get("set-cookie")).toContain("lobu_settings_session=");
140
+
141
+ const setCookie = res.headers.get("set-cookie");
142
+ const token = setCookie?.match(/lobu_settings_session=([^;]+)/)?.[1];
143
+ expect(token).toBeTruthy();
144
+
145
+ const payload = JSON.parse(decrypt(decodeURIComponent(token!))) as Record<
146
+ string,
147
+ unknown
148
+ >;
149
+ expect(payload.userId).toBe("user-123");
150
+ expect(payload.platform).toBe("external");
151
+ expect(payload.isAdmin).toBeUndefined();
152
+ expect(payload.settingsMode).toBeUndefined();
153
+ });
154
+
155
+ test("POST /cli/poll mints Lobu tokens after device auth completes", async () => {
156
+ await redis.setex(
157
+ "cli:auth:device:device-123",
158
+ 600,
159
+ JSON.stringify({
160
+ status: "pending",
161
+ createdAt: Date.now(),
162
+ expiresAt: Date.now() + 600_000,
163
+ interval: 5,
164
+ userCode: "ABCD-EFGH",
165
+ verificationUri: "https://issuer.example.com/device",
166
+ })
167
+ );
168
+
169
+ const router = createCliAuthRoutes({
170
+ queue: queue as any,
171
+ externalAuthClient: {
172
+ pollDeviceAuthorization: mock(async () => ({
173
+ status: "complete",
174
+ credentials: {
175
+ accessToken: "provider-access-token",
176
+ refreshToken: "provider-refresh-token",
177
+ tokenType: "Bearer",
178
+ expiresAt: Date.now() + 3600_000,
179
+ scopes: ["profile:read"],
180
+ },
181
+ user: {
182
+ sub: "user-123",
183
+ email: "user@example.com",
184
+ name: "Example User",
185
+ },
186
+ })),
187
+ } as any,
188
+ });
189
+
190
+ const res = await router.request("/cli/poll", {
191
+ method: "POST",
192
+ headers: { "Content-Type": "application/json" },
193
+ body: JSON.stringify({ deviceAuthId: "device-123" }),
194
+ });
195
+
196
+ expect(res.status).toBe(200);
197
+ const body = await res.json();
198
+ expect(body.status).toBe("complete");
199
+ expect(typeof body.accessToken).toBe("string");
200
+ expect(typeof body.refreshToken).toBe("string");
201
+ expect(body.user.userId).toBe("user-123");
202
+ expect(body.user.email).toBe("user@example.com");
203
+ });
204
+
205
+ test("POST /cli/poll returns a completed browser result from stored request state", async () => {
206
+ await redis.setex(
207
+ "cli:auth:request:req-123",
208
+ 600,
209
+ JSON.stringify({
210
+ status: "complete",
211
+ createdAt: Date.now(),
212
+ result: {
213
+ accessToken: "lobu-access-token",
214
+ refreshToken: "lobu-refresh-token",
215
+ expiresAt: Date.now() + 3600_000,
216
+ user: {
217
+ userId: "user-123",
218
+ email: "user@example.com",
219
+ name: "Example User",
220
+ },
221
+ },
222
+ })
223
+ );
224
+
225
+ const router = createCliAuthRoutes({
226
+ queue: queue as any,
227
+ externalAuthClient: {} as any,
228
+ });
229
+
230
+ const res = await router.request("/cli/poll", {
231
+ method: "POST",
232
+ headers: { "Content-Type": "application/json" },
233
+ body: JSON.stringify({ requestId: "req-123" }),
234
+ });
235
+
236
+ expect(res.status).toBe(200);
237
+ const body = await res.json();
238
+ expect(body.status).toBe("complete");
239
+ expect(body.user.userId).toBe("user-123");
240
+ });
241
+
242
+ test("POST /cli/admin-login mints tokens when development fallback is enabled", async () => {
243
+ const router = createCliAuthRoutes({
244
+ queue: queue as any,
245
+ allowAdminPasswordLogin: true,
246
+ adminPassword: "dev-secret",
247
+ });
248
+
249
+ const res = await router.request("/cli/admin-login", {
250
+ method: "POST",
251
+ headers: {
252
+ "Content-Type": "application/json",
253
+ "X-Forwarded-For": "10.0.0.1",
254
+ },
255
+ body: JSON.stringify({ password: "dev-secret" }),
256
+ });
257
+
258
+ expect(res.status).toBe(200);
259
+ const body = await res.json();
260
+ expect(body.status).toBe("complete");
261
+ expect(typeof body.accessToken).toBe("string");
262
+ expect(body.user.userId).toBe("admin");
263
+ });
264
+
265
+ test("POST /cli/admin-login is rejected when disabled or password is wrong", async () => {
266
+ const disabledRouter = createCliAuthRoutes({
267
+ queue: queue as any,
268
+ allowAdminPasswordLogin: false,
269
+ adminPassword: "dev-secret",
270
+ });
271
+
272
+ const disabled = await disabledRouter.request("/cli/admin-login", {
273
+ method: "POST",
274
+ headers: { "Content-Type": "application/json" },
275
+ body: JSON.stringify({ password: "dev-secret" }),
276
+ });
277
+ expect(disabled.status).toBe(403);
278
+
279
+ const enabledRouter = createCliAuthRoutes({
280
+ queue: queue as any,
281
+ allowAdminPasswordLogin: true,
282
+ adminPassword: "dev-secret",
283
+ });
284
+
285
+ const wrong = await enabledRouter.request("/cli/admin-login", {
286
+ method: "POST",
287
+ headers: {
288
+ "Content-Type": "application/json",
289
+ "X-Forwarded-For": "10.0.0.1",
290
+ },
291
+ body: JSON.stringify({ password: "wrong-secret" }),
292
+ });
293
+ expect(wrong.status).toBe(401);
294
+ });
295
+
296
+ test("POST /cli/admin-login is rate limited per client IP", async () => {
297
+ const router = createCliAuthRoutes({
298
+ queue: queue as any,
299
+ allowAdminPasswordLogin: true,
300
+ adminPassword: "dev-secret",
301
+ });
302
+
303
+ for (let attempt = 0; attempt < 5; attempt += 1) {
304
+ const res = await router.request("/cli/admin-login", {
305
+ method: "POST",
306
+ headers: {
307
+ "Content-Type": "application/json",
308
+ "X-Forwarded-For": "10.0.0.9",
309
+ },
310
+ body: JSON.stringify({ password: "wrong-secret" }),
311
+ });
312
+ expect(res.status).toBe(401);
313
+ }
314
+
315
+ const limited = await router.request("/cli/admin-login", {
316
+ method: "POST",
317
+ headers: {
318
+ "Content-Type": "application/json",
319
+ "X-Forwarded-For": "10.0.0.9",
320
+ },
321
+ body: JSON.stringify({ password: "wrong-secret" }),
322
+ });
323
+
324
+ expect(limited.status).toBe(429);
325
+ expect(limited.headers.get("retry-after")).toBeTruthy();
326
+
327
+ const differentIp = await router.request("/cli/admin-login", {
328
+ method: "POST",
329
+ headers: {
330
+ "Content-Type": "application/json",
331
+ "X-Forwarded-For": "10.0.0.10",
332
+ },
333
+ body: JSON.stringify({ password: "dev-secret" }),
334
+ });
335
+ expect(differentIp.status).toBe(200);
336
+ });
337
+ });
@@ -0,0 +1,121 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { generateWorkerToken } from "@lobu/core";
3
+ import { createInteractionRoutes } from "../../routes/internal/interactions";
4
+
5
+ describe("interaction routes", () => {
6
+ let originalKey: string | undefined;
7
+ let workerToken: string;
8
+ let mockInteractionService: any;
9
+ let router: ReturnType<typeof createInteractionRoutes>;
10
+
11
+ beforeEach(() => {
12
+ originalKey = process.env.ENCRYPTION_KEY;
13
+ process.env.ENCRYPTION_KEY =
14
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
15
+
16
+ workerToken = generateWorkerToken("user-1", "conv-1", "deploy-1", {
17
+ channelId: "chan-1",
18
+ teamId: "team-1",
19
+ });
20
+
21
+ mockInteractionService = {
22
+ postQuestion: mock(() => Promise.resolve({ id: "interaction-123" })),
23
+ createSuggestion: mock(() => Promise.resolve()),
24
+ };
25
+
26
+ router = createInteractionRoutes(mockInteractionService);
27
+ });
28
+
29
+ afterEach(() => {
30
+ if (originalKey !== undefined) {
31
+ process.env.ENCRYPTION_KEY = originalKey;
32
+ } else {
33
+ delete process.env.ENCRYPTION_KEY;
34
+ }
35
+ });
36
+
37
+ describe("POST /internal/interactions/create", () => {
38
+ test("returns 401 without auth header", async () => {
39
+ const res = await router.request("/internal/interactions/create", {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ question: "test?", options: [] }),
43
+ });
44
+ expect(res.status).toBe(401);
45
+ });
46
+
47
+ test("returns 401 with invalid token", async () => {
48
+ const res = await router.request("/internal/interactions/create", {
49
+ method: "POST",
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ Authorization: "Bearer invalid-token",
53
+ },
54
+ body: JSON.stringify({ question: "test?", options: [] }),
55
+ });
56
+ expect(res.status).toBe(401);
57
+ });
58
+
59
+ test("posts question and returns id", async () => {
60
+ const res = await router.request("/internal/interactions/create", {
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ Authorization: `Bearer ${workerToken}`,
65
+ },
66
+ body: JSON.stringify({
67
+ question: "Which option?",
68
+ options: ["A", "B"],
69
+ }),
70
+ });
71
+ expect(res.status).toBe(200);
72
+ const body = await res.json();
73
+ expect(body.id).toBe("interaction-123");
74
+ expect(body.status).toBe("posted");
75
+ expect(mockInteractionService.postQuestion).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ test("returns 500 on service error", async () => {
79
+ mockInteractionService.postQuestion = mock(() =>
80
+ Promise.reject(new Error("service down"))
81
+ );
82
+ const res = await router.request("/internal/interactions/create", {
83
+ method: "POST",
84
+ headers: {
85
+ "Content-Type": "application/json",
86
+ Authorization: `Bearer ${workerToken}`,
87
+ },
88
+ body: JSON.stringify({ question: "test?", options: [] }),
89
+ });
90
+ expect(res.status).toBe(500);
91
+ });
92
+ });
93
+
94
+ describe("POST /internal/suggestions/create", () => {
95
+ test("creates suggestions", async () => {
96
+ const res = await router.request("/internal/suggestions/create", {
97
+ method: "POST",
98
+ headers: {
99
+ "Content-Type": "application/json",
100
+ Authorization: `Bearer ${workerToken}`,
101
+ },
102
+ body: JSON.stringify({
103
+ prompts: ["Try this", "Or this"],
104
+ }),
105
+ });
106
+ expect(res.status).toBe(200);
107
+ const body = await res.json();
108
+ expect(body.success).toBe(true);
109
+ expect(mockInteractionService.createSuggestion).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ test("returns 401 without auth", async () => {
113
+ const res = await router.request("/internal/suggestions/create", {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify({ prompts: ["test"] }),
117
+ });
118
+ expect(res.status).toBe(401);
119
+ });
120
+ });
121
+ });