responses-proxy 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.
Files changed (161) hide show
  1. package/README.md +56 -0
  2. package/cli.js +118 -0
  3. package/dist/anthropic-messages.js +383 -0
  4. package/dist/anthropic-messages.test.js +209 -0
  5. package/dist/audit-log.js +138 -0
  6. package/dist/audit-log.test.js +480 -0
  7. package/dist/billing-expiration.js +70 -0
  8. package/dist/billing-expiration.test.js +114 -0
  9. package/dist/billing.js +716 -0
  10. package/dist/billing.test.js +228 -0
  11. package/dist/chatgpt-oauth-store.js +240 -0
  12. package/dist/chatgpt-oauth-store.test.js +88 -0
  13. package/dist/chatgpt-oauth.js +118 -0
  14. package/dist/chatgpt-oauth.test.js +63 -0
  15. package/dist/chatgpt-provider-auth.js +60 -0
  16. package/dist/chatgpt-provider-auth.test.js +101 -0
  17. package/dist/client/app-icon.svg +17 -0
  18. package/dist/client/assets/index-C7Vvhst8.js +14 -0
  19. package/dist/client/assets/index-DpqgYK3L.css +1 -0
  20. package/dist/client/favicon.svg +17 -0
  21. package/dist/client/index.html +31 -0
  22. package/dist/client-config-apply.js +345 -0
  23. package/dist/client-config-apply.test.js +185 -0
  24. package/dist/client-token-limits.js +111 -0
  25. package/dist/client-token-limits.test.js +129 -0
  26. package/dist/codex-config.js +47 -0
  27. package/dist/codex-setup.js +87 -0
  28. package/dist/codex-setup.test.js +30 -0
  29. package/dist/config.js +314 -0
  30. package/dist/cost-analytics.js +31 -0
  31. package/dist/cost-analytics.test.js +38 -0
  32. package/dist/customer-key-access.js +126 -0
  33. package/dist/customer-key-access.test.js +178 -0
  34. package/dist/customer-keys.js +209 -0
  35. package/dist/customer-keys.test.js +68 -0
  36. package/dist/customer-usage.js +18 -0
  37. package/dist/customer-usage.test.js +55 -0
  38. package/dist/dashboard-auth.js +318 -0
  39. package/dist/dashboard-auth.test.js +133 -0
  40. package/dist/dashboard-serving.test.js +235 -0
  41. package/dist/error-response.js +174 -0
  42. package/dist/error-response.test.js +88 -0
  43. package/dist/forward.js +357 -0
  44. package/dist/health-websocket-manager.js +174 -0
  45. package/dist/http-rate-limit.js +36 -0
  46. package/dist/http-rate-limit.test.js +62 -0
  47. package/dist/kiro-auth.js +136 -0
  48. package/dist/kiro-auth.test.js +234 -0
  49. package/dist/kiro-codewhisperer.js +646 -0
  50. package/dist/kiro-codewhisperer.test.js +219 -0
  51. package/dist/kiro-device-login.js +338 -0
  52. package/dist/kiro-eventstream.js +219 -0
  53. package/dist/kiro-eventstream.test.js +79 -0
  54. package/dist/kiro-forward.js +401 -0
  55. package/dist/kiro-import-cli.js +69 -0
  56. package/dist/kiro-import.js +94 -0
  57. package/dist/kiro-import.test.js +125 -0
  58. package/dist/kiro-token-store.js +196 -0
  59. package/dist/kiro-token-store.test.js +207 -0
  60. package/dist/krouter-usage.js +243 -0
  61. package/dist/model-combo-repository.js +147 -0
  62. package/dist/model-routing.js +69 -0
  63. package/dist/model-routing.test.js +41 -0
  64. package/dist/normalize-request.js +531 -0
  65. package/dist/normalize-request.test.js +277 -0
  66. package/dist/omv-public-firewall.test.js +11 -0
  67. package/dist/package.json +17 -0
  68. package/dist/prompt-cache-state.js +146 -0
  69. package/dist/prompt-cache-state.test.js +71 -0
  70. package/dist/prompt-cache.js +229 -0
  71. package/dist/provider-health-service.js +404 -0
  72. package/dist/provider-request-parameters.js +107 -0
  73. package/dist/provider-request-parameters.test.js +26 -0
  74. package/dist/provider-routing.js +114 -0
  75. package/dist/provider-routing.test.js +64 -0
  76. package/dist/provider-usage.js +314 -0
  77. package/dist/request-timeout-policy.js +61 -0
  78. package/dist/request-timeout-policy.test.js +40 -0
  79. package/dist/response-cache.js +69 -0
  80. package/dist/response-cache.test.js +28 -0
  81. package/dist/routing-combo-repository.js +300 -0
  82. package/dist/routing-engine.js +377 -0
  83. package/dist/routing-integration.js +155 -0
  84. package/dist/routing-simulation-engine.js +326 -0
  85. package/dist/rtk-layer.js +483 -0
  86. package/dist/rtk-layer.test.js +198 -0
  87. package/dist/runtime-provider-repository.js +1742 -0
  88. package/dist/runtime-provider-repository.test.js +1177 -0
  89. package/dist/schema.js +118 -0
  90. package/dist/schema.test.js +16 -0
  91. package/dist/sepay-webhook.js +87 -0
  92. package/dist/sepay-webhook.test.js +142 -0
  93. package/dist/server-body-limit.test.js +35 -0
  94. package/dist/server-client-token-limits.test.js +161 -0
  95. package/dist/server-codex-config-setup.test.js +76 -0
  96. package/dist/server-http-rate-limit.test.js +80 -0
  97. package/dist/server-response-cache.test.js +105 -0
  98. package/dist/server-routes-alias.test.js +39 -0
  99. package/dist/server-sepay-webhook-security.test.js +59 -0
  100. package/dist/server.js +5906 -0
  101. package/dist/session-log.js +178 -0
  102. package/dist/tailnet-funnel-script.test.js +33 -0
  103. package/dist/telegram-bot/actions.js +118 -0
  104. package/dist/telegram-bot/admin-actions.js +103 -0
  105. package/dist/telegram-bot/auth.js +46 -0
  106. package/dist/telegram-bot/auth.test.js +1 -0
  107. package/dist/telegram-bot/bot-identity-repository.js +189 -0
  108. package/dist/telegram-bot/bot-identity-repository.test.js +78 -0
  109. package/dist/telegram-bot/callbacks.js +30 -0
  110. package/dist/telegram-bot/codex-config-delivery.js +38 -0
  111. package/dist/telegram-bot/codex-config-delivery.test.js +75 -0
  112. package/dist/telegram-bot/commands/accounts.js +140 -0
  113. package/dist/telegram-bot/commands/apikey.js +737 -0
  114. package/dist/telegram-bot/commands/apply.js +265 -0
  115. package/dist/telegram-bot/commands/clients.js +13 -0
  116. package/dist/telegram-bot/commands/customer-billing.test.js +271 -0
  117. package/dist/telegram-bot/commands/grant.js +138 -0
  118. package/dist/telegram-bot/commands/grant.test.js +217 -0
  119. package/dist/telegram-bot/commands/help.js +52 -0
  120. package/dist/telegram-bot/commands/me.js +53 -0
  121. package/dist/telegram-bot/commands/models.js +6 -0
  122. package/dist/telegram-bot/commands/oauth.js +64 -0
  123. package/dist/telegram-bot/commands/plans.js +96 -0
  124. package/dist/telegram-bot/commands/providers.js +27 -0
  125. package/dist/telegram-bot/commands/quota.js +10 -0
  126. package/dist/telegram-bot/commands/renew-user.js +139 -0
  127. package/dist/telegram-bot/commands/renew-user.test.js +184 -0
  128. package/dist/telegram-bot/commands/renew.js +1369 -0
  129. package/dist/telegram-bot/commands/renew.test.js +1633 -0
  130. package/dist/telegram-bot/commands/start.js +212 -0
  131. package/dist/telegram-bot/commands/start.test.js +280 -0
  132. package/dist/telegram-bot/commands/status.js +6 -0
  133. package/dist/telegram-bot/commands/tailscale.js +15 -0
  134. package/dist/telegram-bot/commands/tailscale.test.js +76 -0
  135. package/dist/telegram-bot/commands/test.js +51 -0
  136. package/dist/telegram-bot/commands/test.test.js +14 -0
  137. package/dist/telegram-bot/commands/usage.js +10 -0
  138. package/dist/telegram-bot/config.js +98 -0
  139. package/dist/telegram-bot/config.test.js +42 -0
  140. package/dist/telegram-bot/customer-actions.js +160 -0
  141. package/dist/telegram-bot/customer-api-keys.js +68 -0
  142. package/dist/telegram-bot/customer-billing.js +72 -0
  143. package/dist/telegram-bot/customer-workspace-repository.js +134 -0
  144. package/dist/telegram-bot/customer-workspace-repository.test.js +47 -0
  145. package/dist/telegram-bot/dashboard-login.js +39 -0
  146. package/dist/telegram-bot/format.js +140 -0
  147. package/dist/telegram-bot/grants.js +370 -0
  148. package/dist/telegram-bot/grants.test.js +290 -0
  149. package/dist/telegram-bot/index.js +85 -0
  150. package/dist/telegram-bot/message-cleanup.js +55 -0
  151. package/dist/telegram-bot/message-cleanup.test.js +77 -0
  152. package/dist/telegram-bot/message-format.js +45 -0
  153. package/dist/telegram-bot/message-format.test.js +10 -0
  154. package/dist/telegram-bot/proxy-client.js +174 -0
  155. package/dist/telegram-bot/rate-limit.js +95 -0
  156. package/dist/telegram-bot/rate-limit.test.js +58 -0
  157. package/dist/telegram-bot/sessions.js +171 -0
  158. package/dist/telegram-bot/sessions.test.js +107 -0
  159. package/dist/telegram-bot/telegram-adapter.js +126 -0
  160. package/dist/telegram-bot/worker.js +63 -0
  161. package/package.json +39 -0
@@ -0,0 +1,219 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { DEFAULT_KIRO_MODEL_ID, KiroResponseAccumulator, buildCodeWhispererRequest, buildCodeWhispererRequestFromTurns, buildResponsesJson, buildResponsesSseFrames, collectAssistantText, collectInputText, extractAssistantDelta, flattenResponsesConversation, mapModelToCodeWhisperer, } from "./kiro-codewhisperer.js";
4
+ import { encodeEventStreamMessage, parseEventStream } from "./kiro-eventstream.js";
5
+ function toolUseFrame(payload) {
6
+ return encodeEventStreamMessage({ ":event-type": "toolUseEvent", ":content-type": "application/json" }, Buffer.from(JSON.stringify(payload), "utf8"));
7
+ }
8
+ function assistantFrame(content) {
9
+ return encodeEventStreamMessage({ ":event-type": "assistantResponseEvent", ":content-type": "application/json" }, Buffer.from(JSON.stringify({ content }), "utf8"));
10
+ }
11
+ test("mapModelToCodeWhisperer resolves known aliases case-insensitively", () => {
12
+ assert.equal(mapModelToCodeWhisperer("kiro-claude-sonnet-4"), "claude-sonnet-4");
13
+ assert.equal(mapModelToCodeWhisperer("Claude-Sonnet-4-6"), "claude-sonnet-4-6");
14
+ });
15
+ test("mapModelToCodeWhisperer falls back to the default for unknown aliases", () => {
16
+ assert.equal(mapModelToCodeWhisperer("gpt-5.5"), DEFAULT_KIRO_MODEL_ID);
17
+ assert.equal(mapModelToCodeWhisperer(undefined), DEFAULT_KIRO_MODEL_ID);
18
+ });
19
+ test("mapModelToCodeWhisperer passes through recognized lowercase Kiro ids", () => {
20
+ assert.equal(mapModelToCodeWhisperer("auto"), "auto");
21
+ assert.equal(mapModelToCodeWhisperer("claude-sonnet-4.5"), "claude-sonnet-4.5");
22
+ });
23
+ test("mapModelToCodeWhisperer strips Anthropic date suffixes (Claude Code ids)", () => {
24
+ assert.equal(mapModelToCodeWhisperer("claude-sonnet-4-20250514"), "claude-sonnet-4");
25
+ assert.equal(mapModelToCodeWhisperer("claude-3-5-haiku-20241022"), "claude-3-5-haiku");
26
+ });
27
+ test("flattenResponsesConversation folds instructions into the first user turn", () => {
28
+ const turns = flattenResponsesConversation({
29
+ instructions: "You are helpful.",
30
+ input: [
31
+ { role: "user", content: "Hello" },
32
+ { role: "assistant", content: "Hi there" },
33
+ { role: "user", content: "How are you?" },
34
+ ],
35
+ });
36
+ assert.equal(turns.length, 3);
37
+ assert.equal(turns[0].role, "user");
38
+ assert.match(turns[0].content, /You are helpful\./);
39
+ assert.match(turns[0].content, /Hello/);
40
+ assert.equal(turns[2].content, "How are you?");
41
+ });
42
+ test("flattenResponsesConversation accepts a plain string input", () => {
43
+ const turns = flattenResponsesConversation({ input: "just a string" });
44
+ assert.deepEqual(turns, [{ role: "user", content: "just a string" }]);
45
+ });
46
+ test("flattenResponsesConversation extracts text from content-part arrays", () => {
47
+ const turns = flattenResponsesConversation({
48
+ input: [
49
+ {
50
+ role: "user",
51
+ content: [
52
+ { type: "input_text", text: "part one " },
53
+ { type: "input_text", text: "part two" },
54
+ ],
55
+ },
56
+ ],
57
+ });
58
+ assert.equal(turns[0].content, "part one part two");
59
+ });
60
+ test("buildCodeWhispererRequest puts the last user turn as the current message", () => {
61
+ const request = buildCodeWhispererRequest({
62
+ body: {
63
+ input: [
64
+ { role: "user", content: "first question" },
65
+ { role: "assistant", content: "first answer" },
66
+ { role: "user", content: "second question" },
67
+ ],
68
+ },
69
+ modelId: "claude-sonnet-4",
70
+ profileArn: "arn:aws:codewhisperer:profile/x",
71
+ conversationId: "conv-1",
72
+ });
73
+ assert.match(request.conversationState.currentMessage.userInputMessage.content, /second question$/);
74
+ assert.equal(request.conversationState.currentMessage.userInputMessage.modelId, "claude-sonnet-4");
75
+ assert.equal(request.conversationState.conversationId, "conv-1");
76
+ assert.equal(request.profileArn, "arn:aws:codewhisperer:profile/x");
77
+ assert.equal(request.inferenceConfig.maxTokens, 32000);
78
+ assert.equal(request.conversationState.history.length, 2);
79
+ assert.ok("userInputMessage" in request.conversationState.history[0]);
80
+ assert.ok("assistantResponseMessage" in request.conversationState.history[1]);
81
+ });
82
+ test("buildCodeWhispererRequest omits profileArn when absent", () => {
83
+ const request = buildCodeWhispererRequest({
84
+ body: { input: "hi" },
85
+ modelId: DEFAULT_KIRO_MODEL_ID,
86
+ });
87
+ assert.equal("profileArn" in request, false);
88
+ assert.match(request.conversationState.currentMessage.userInputMessage.content, /hi$/);
89
+ });
90
+ test("extractAssistantDelta reads content from a parsed frame", () => {
91
+ const [message] = parseEventStream(assistantFrame("chunk-text"));
92
+ assert.equal(extractAssistantDelta(message), "chunk-text");
93
+ });
94
+ test("collectAssistantText concatenates all assistant deltas", () => {
95
+ const buffer = Buffer.concat([
96
+ assistantFrame("Hello, "),
97
+ assistantFrame("world"),
98
+ assistantFrame("!"),
99
+ ]);
100
+ const { text, error } = collectAssistantText(buffer);
101
+ assert.equal(text, "Hello, world!");
102
+ assert.equal(error, undefined);
103
+ });
104
+ test("collectAssistantText surfaces error frames", () => {
105
+ const errorFrame = encodeEventStreamMessage({ ":event-type": "errorEvent", ":content-type": "application/json" }, Buffer.from(JSON.stringify({ message: "quota exceeded" }), "utf8"));
106
+ const buffer = Buffer.concat([assistantFrame("partial"), errorFrame]);
107
+ const { text, error } = collectAssistantText(buffer);
108
+ assert.equal(text, "partial");
109
+ assert.equal(error, "quota exceeded");
110
+ });
111
+ test("buildResponsesJson produces a completed response with usage", () => {
112
+ const json = buildResponsesJson({
113
+ text: "answer text",
114
+ model: "kiro-claude-sonnet-4",
115
+ inputText: "some input prompt",
116
+ });
117
+ assert.equal(json.status, "completed");
118
+ assert.equal(json.model, "kiro-claude-sonnet-4");
119
+ const output = json.output;
120
+ const content = output[0].content;
121
+ assert.equal(content[0].text, "answer text");
122
+ const usage = json.usage;
123
+ assert.ok(usage.total_tokens > 0);
124
+ assert.equal(usage.total_tokens, usage.input_tokens + usage.output_tokens);
125
+ });
126
+ test("buildResponsesSseFrames ends with a completed event and [DONE]", () => {
127
+ const frames = buildResponsesSseFrames({
128
+ text: "streamed answer",
129
+ model: "kiro-claude-sonnet-4",
130
+ inputText: "prompt",
131
+ });
132
+ const joined = frames.join("");
133
+ assert.match(joined, /event: response\.created/);
134
+ assert.match(joined, /event: response\.output_text\.delta/);
135
+ assert.match(joined, /"delta":"streamed answer"/);
136
+ assert.match(joined, /event: response\.completed/);
137
+ assert.ok(frames[frames.length - 1].includes("[DONE]"));
138
+ });
139
+ test("collectInputText joins all turns for token estimation", () => {
140
+ const text = collectInputText({
141
+ instructions: "sys",
142
+ input: [
143
+ { role: "user", content: "a" },
144
+ { role: "assistant", content: "b" },
145
+ ],
146
+ });
147
+ assert.match(text, /sys/);
148
+ assert.match(text, /a/);
149
+ assert.match(text, /b/);
150
+ });
151
+ test("buildCodeWhispererRequestFromTurns puts tool specs in current message context", () => {
152
+ const request = buildCodeWhispererRequestFromTurns({
153
+ turns: [{ role: "user", content: "weather?" }],
154
+ modelId: "claude-sonnet-4",
155
+ tools: [
156
+ { name: "get_weather", description: "Get it", inputSchema: { type: "object", properties: {} } },
157
+ ],
158
+ });
159
+ const context = request.conversationState.currentMessage.userInputMessage
160
+ .userInputMessageContext;
161
+ const tools = context.tools;
162
+ assert.equal(tools.length, 1);
163
+ const spec = tools[0].toolSpecification;
164
+ assert.equal(spec.name, "get_weather");
165
+ assert.deepEqual(spec.inputSchema, { json: { type: "object", properties: {}, required: [] } });
166
+ });
167
+ test("buildCodeWhispererRequestFromTurns maps tool results on the current user turn", () => {
168
+ const request = buildCodeWhispererRequestFromTurns({
169
+ turns: [
170
+ { role: "user", content: "weather?" },
171
+ { role: "assistant", content: "", toolUses: [{ toolUseId: "tu_1", name: "get_weather", input: { city: "NYC" } }] },
172
+ { role: "user", content: "", toolResults: [{ toolUseId: "tu_1", content: "Sunny" }] },
173
+ ],
174
+ modelId: "claude-sonnet-4",
175
+ });
176
+ const context = request.conversationState.currentMessage.userInputMessage
177
+ .userInputMessageContext;
178
+ const toolResults = context.toolResults;
179
+ assert.equal(toolResults.length, 1);
180
+ assert.equal(toolResults[0].toolUseId, "tu_1");
181
+ assert.equal(toolResults[0].status, "success");
182
+ assert.deepEqual(toolResults[0].content, [{ text: "Sunny" }]);
183
+ // The assistant tool call is preserved in history.
184
+ const history = request.conversationState.history;
185
+ const assistantEntry = history.find((h) => "assistantResponseMessage" in h);
186
+ assert.ok(assistantEntry);
187
+ });
188
+ test("KiroResponseAccumulator accumulates a tool call across frames", () => {
189
+ const accumulator = new KiroResponseAccumulator();
190
+ const frames = [
191
+ toolUseFrame({ toolUseId: "tu_1", name: "get_weather", input: '{"ci' }),
192
+ toolUseFrame({ toolUseId: "tu_1", input: 'ty":"NYC"}', stop: true }),
193
+ ];
194
+ for (const frame of frames) {
195
+ for (const message of parseEventStream(frame)) {
196
+ accumulator.push(message);
197
+ }
198
+ }
199
+ assert.ok(accumulator.hasToolUses());
200
+ const toolUses = accumulator.toolUses();
201
+ assert.equal(toolUses.length, 1);
202
+ assert.equal(toolUses[0].toolUseId, "tu_1");
203
+ assert.equal(toolUses[0].name, "get_weather");
204
+ assert.deepEqual(toolUses[0].input, { city: "NYC" });
205
+ });
206
+ test("KiroResponseAccumulator collects text and reports per-frame deltas", () => {
207
+ const accumulator = new KiroResponseAccumulator();
208
+ const frame = encodeEventStreamMessage({ ":event-type": "assistantResponseEvent", ":content-type": "application/json" }, Buffer.from(JSON.stringify({ content: "hello" }), "utf8"));
209
+ let textDelta = "";
210
+ for (const message of parseEventStream(frame)) {
211
+ const result = accumulator.push(message);
212
+ if (result.textDelta) {
213
+ textDelta += result.textDelta;
214
+ }
215
+ }
216
+ assert.equal(textDelta, "hello");
217
+ assert.equal(accumulator.text, "hello");
218
+ assert.equal(accumulator.hasToolUses(), false);
219
+ });
@@ -0,0 +1,338 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import BetterSqlite3 from "better-sqlite3";
3
+ import { PROVIDER_CONNECTIONS_DDL } from "./kiro-import.js";
4
+ // ─── Error Class ───
5
+ export class DeviceLoginError extends Error {
6
+ statusCode;
7
+ body;
8
+ constructor(statusCode, body) {
9
+ super(body.message);
10
+ this.statusCode = statusCode;
11
+ this.body = body;
12
+ }
13
+ }
14
+ // ─── Registration Cache DDL & Helpers (Task 2.2) ───
15
+ export const REGISTRATION_CACHE_DDL = `
16
+ CREATE TABLE IF NOT EXISTS kiro_registration_cache (
17
+ region TEXT NOT NULL,
18
+ startUrl TEXT NOT NULL,
19
+ clientId TEXT NOT NULL,
20
+ clientSecret TEXT NOT NULL,
21
+ clientSecretExpiresAt INTEGER NOT NULL,
22
+ createdAt TEXT NOT NULL DEFAULT (datetime('now')),
23
+ PRIMARY KEY (region, startUrl)
24
+ );
25
+ `;
26
+ export function getRegistrationCache(db, region, startUrl) {
27
+ const nowUnixSeconds = Math.floor(Date.now() / 1000);
28
+ const row = db
29
+ .prepare(`SELECT region, startUrl, clientId, clientSecret, clientSecretExpiresAt
30
+ FROM kiro_registration_cache
31
+ WHERE region = ? AND startUrl = ? AND clientSecretExpiresAt > ?`)
32
+ .get(region, startUrl, nowUnixSeconds);
33
+ if (!row)
34
+ return undefined;
35
+ return {
36
+ region: row.region,
37
+ startUrl: row.startUrl,
38
+ clientId: row.clientId,
39
+ clientSecret: row.clientSecret,
40
+ clientSecretExpiresAt: row.clientSecretExpiresAt,
41
+ };
42
+ }
43
+ export function setRegistrationCache(db, entry) {
44
+ db.prepare(`INSERT INTO kiro_registration_cache (region, startUrl, clientId, clientSecret, clientSecretExpiresAt, createdAt)
45
+ VALUES (?, ?, ?, ?, ?, ?)
46
+ ON CONFLICT(region, startUrl) DO UPDATE SET
47
+ clientId = excluded.clientId,
48
+ clientSecret = excluded.clientSecret,
49
+ clientSecretExpiresAt = excluded.clientSecretExpiresAt,
50
+ createdAt = excluded.createdAt`).run(entry.region, entry.startUrl, entry.clientId, entry.clientSecret, entry.clientSecretExpiresAt, new Date().toISOString());
51
+ }
52
+ // ─── DeviceLoginService (Tasks 2.3–2.6) ───
53
+ export class DeviceLoginService {
54
+ sessions = new Map();
55
+ config;
56
+ appDb;
57
+ kiroDbPath;
58
+ fetchImpl;
59
+ constructor(deps) {
60
+ this.config = deps.config;
61
+ this.appDb = deps.appDb;
62
+ this.kiroDbPath = deps.kiroDbPath;
63
+ this.fetchImpl = deps.fetchImpl ?? fetch;
64
+ // Ensure the registration cache table exists
65
+ this.appDb.exec(REGISTRATION_CACHE_DDL);
66
+ }
67
+ // ─── Session Management (Task 2.3) ───
68
+ pruneExpiredSessions() {
69
+ const now = Date.now();
70
+ for (const [id, session] of this.sessions) {
71
+ if (session.expiresAt < now) {
72
+ this.sessions.delete(id);
73
+ }
74
+ }
75
+ }
76
+ // ─── startDeviceLogin (Task 2.4) ───
77
+ async startDeviceLogin(input) {
78
+ // Validate IDC inputs
79
+ if (input.authMethod === "idc") {
80
+ if (!input.startUrl?.trim()) {
81
+ throw new DeviceLoginError(422, {
82
+ type: "validation_error",
83
+ code: "VALIDATION_MISSING_START_URL",
84
+ message: "IDC login requires a non-empty startUrl.",
85
+ });
86
+ }
87
+ if (!input.region?.trim()) {
88
+ throw new DeviceLoginError(422, {
89
+ type: "validation_error",
90
+ code: "VALIDATION_MISSING_REGION",
91
+ message: "IDC login requires a non-empty region.",
92
+ });
93
+ }
94
+ }
95
+ // Resolve region and startUrl based on authMethod
96
+ const region = input.authMethod === "builder_id"
97
+ ? this.config.KIRO_DEFAULT_REGION
98
+ : input.region.trim();
99
+ const startUrl = input.authMethod === "builder_id"
100
+ ? this.config.KIRO_BUILDER_ID_START_URL
101
+ : input.startUrl.trim();
102
+ // Check registration cache
103
+ let clientId;
104
+ let clientSecret;
105
+ const cached = getRegistrationCache(this.appDb, region, startUrl);
106
+ if (cached) {
107
+ clientId = cached.clientId;
108
+ clientSecret = cached.clientSecret;
109
+ }
110
+ else {
111
+ // Call RegisterClient
112
+ const registerResult = await this.registerClient(region, startUrl, input.authMethod);
113
+ clientId = registerResult.clientId;
114
+ clientSecret = registerResult.clientSecret;
115
+ // Store in cache
116
+ setRegistrationCache(this.appDb, {
117
+ region,
118
+ startUrl,
119
+ clientId,
120
+ clientSecret,
121
+ clientSecretExpiresAt: registerResult.clientSecretExpiresAt,
122
+ });
123
+ }
124
+ // Call StartDeviceAuthorization
125
+ const oidcBase = `https://oidc.${region}.amazonaws.com`;
126
+ let deviceAuthResponse;
127
+ try {
128
+ deviceAuthResponse = await this.fetchImpl(`${oidcBase}/device_authorization`, {
129
+ method: "POST",
130
+ headers: { "Content-Type": "application/json" },
131
+ body: JSON.stringify({ clientId, clientSecret, startUrl }),
132
+ });
133
+ }
134
+ catch {
135
+ throw new DeviceLoginError(502, {
136
+ type: "upstream_error",
137
+ code: "OIDC_NETWORK_ERROR",
138
+ message: "Cannot reach AWS SSO-OIDC endpoint.",
139
+ });
140
+ }
141
+ if (!deviceAuthResponse.ok) {
142
+ const status = deviceAuthResponse.status;
143
+ throw new DeviceLoginError(502, {
144
+ type: "upstream_error",
145
+ code: "OIDC_DEVICE_AUTH_FAILED",
146
+ message: `Device authorization failed: ${status}.`,
147
+ });
148
+ }
149
+ const deviceAuthData = (await deviceAuthResponse.json());
150
+ // Create DeviceSession
151
+ const sessionId = randomUUID();
152
+ const session = {
153
+ sessionId,
154
+ deviceCode: deviceAuthData.deviceCode,
155
+ clientId,
156
+ clientSecret,
157
+ interval: deviceAuthData.interval,
158
+ expiresAt: Date.now() + deviceAuthData.expiresIn * 1000,
159
+ region,
160
+ startUrl,
161
+ authMethod: input.authMethod,
162
+ status: "pending",
163
+ lastPollAt: 0,
164
+ };
165
+ this.sessions.set(sessionId, session);
166
+ return {
167
+ sessionId,
168
+ userCode: deviceAuthData.userCode,
169
+ verificationUri: deviceAuthData.verificationUri,
170
+ verificationUriComplete: deviceAuthData.verificationUriComplete,
171
+ expiresIn: deviceAuthData.expiresIn,
172
+ interval: deviceAuthData.interval,
173
+ };
174
+ }
175
+ // ─── pollDeviceLogin (Task 2.5) ───
176
+ async pollDeviceLogin(sessionId) {
177
+ const session = this.sessions.get(sessionId);
178
+ // Session not found or already completed/errored
179
+ if (!session || session.status === "completed" || session.status === "error") {
180
+ throw new DeviceLoginError(404, {
181
+ type: "internal_error",
182
+ code: "SESSION_NOT_FOUND",
183
+ message: "Device login session not found or already completed.",
184
+ });
185
+ }
186
+ // Check expiry
187
+ const now = Date.now();
188
+ if (now >= session.expiresAt) {
189
+ session.status = "expired";
190
+ return { status: "expired" };
191
+ }
192
+ // Rate limit: if interval hasn't elapsed since last poll, return current status
193
+ if (session.lastPollAt > 0 && now - session.lastPollAt < session.interval * 1000) {
194
+ return { status: "pending", interval: session.interval };
195
+ }
196
+ // Call CreateToken
197
+ session.lastPollAt = now;
198
+ const oidcBase = `https://oidc.${session.region}.amazonaws.com`;
199
+ let tokenResponse;
200
+ try {
201
+ tokenResponse = await this.fetchImpl(`${oidcBase}/token`, {
202
+ method: "POST",
203
+ headers: { "Content-Type": "application/json" },
204
+ body: JSON.stringify({
205
+ clientId: session.clientId,
206
+ clientSecret: session.clientSecret,
207
+ deviceCode: session.deviceCode,
208
+ grantType: "urn:ietf:params:oauth:grant-type:device_code",
209
+ }),
210
+ });
211
+ }
212
+ catch {
213
+ // Network error — don't mark session as errored, allow retry
214
+ throw new DeviceLoginError(502, {
215
+ type: "upstream_error",
216
+ code: "OIDC_NETWORK_ERROR",
217
+ message: "Cannot reach AWS SSO-OIDC endpoint.",
218
+ });
219
+ }
220
+ if (tokenResponse.ok) {
221
+ // Success — persist account and mark completed
222
+ const tokenData = (await tokenResponse.json());
223
+ const account = this.persistAccount(session, tokenData);
224
+ session.status = "completed";
225
+ session.completedAccount = account;
226
+ return {
227
+ status: "completed",
228
+ account,
229
+ };
230
+ }
231
+ // Error response from OIDC
232
+ const errorBody = (await tokenResponse.json().catch(() => ({})));
233
+ const errorCode = errorBody.error ?? "unknown_error";
234
+ if (errorCode === "authorization_pending") {
235
+ return { status: "pending", interval: session.interval };
236
+ }
237
+ if (errorCode === "slow_down") {
238
+ session.interval += 5;
239
+ return { status: "pending", interval: session.interval };
240
+ }
241
+ // Terminal error
242
+ const errorMessage = errorBody.error_description ?? `Authorization failed: ${errorCode}`;
243
+ session.status = "error";
244
+ session.error = { code: errorCode, message: errorMessage };
245
+ return {
246
+ status: "error",
247
+ error: { code: errorCode, message: errorMessage },
248
+ };
249
+ }
250
+ // ─── Account Persistence (Task 2.6) ───
251
+ persistAccount(session, tokenData) {
252
+ const accountId = randomUUID();
253
+ const now = new Date();
254
+ const expiresAt = new Date(now.getTime() + tokenData.expiresIn * 1000).toISOString();
255
+ const name = "Kiro Device Login";
256
+ const dataJson = JSON.stringify({
257
+ accessToken: tokenData.accessToken,
258
+ refreshToken: tokenData.refreshToken,
259
+ expiresAt,
260
+ expiresIn: tokenData.expiresIn,
261
+ providerSpecificData: {
262
+ clientId: session.clientId,
263
+ clientSecret: session.clientSecret,
264
+ region: session.region,
265
+ authMethod: session.authMethod,
266
+ startUrl: session.startUrl,
267
+ profileArn: null,
268
+ },
269
+ });
270
+ let db;
271
+ try {
272
+ db = new BetterSqlite3(this.kiroDbPath);
273
+ db.pragma("journal_mode = WAL");
274
+ db.exec(PROVIDER_CONNECTIONS_DDL);
275
+ db.prepare(`INSERT INTO providerConnections
276
+ (id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt)
277
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(accountId, "kiro", "sso-oidc", name, null, 100, 1, dataJson, now.toISOString(), now.toISOString());
278
+ }
279
+ catch (err) {
280
+ throw new DeviceLoginError(500, {
281
+ type: "internal_error",
282
+ code: "ACCOUNT_PERSIST_FAILED",
283
+ message: "Failed to save account after successful login.",
284
+ });
285
+ }
286
+ finally {
287
+ db?.close();
288
+ }
289
+ return { id: accountId, name };
290
+ }
291
+ // ─── RegisterClient Helper ───
292
+ async registerClient(region, startUrl, authMethod) {
293
+ const oidcBase = `https://oidc.${region}.amazonaws.com`;
294
+ const body = {
295
+ clientName: this.config.KIRO_DEVICE_CLIENT_NAME,
296
+ clientType: "public",
297
+ scopes: this.config.KIRO_DEVICE_SCOPES,
298
+ };
299
+ if (authMethod === "idc") {
300
+ body.issuerUrl = startUrl;
301
+ body.grantTypes = [
302
+ "urn:ietf:params:oauth:grant-type:device_code",
303
+ "refresh_token",
304
+ ];
305
+ }
306
+ let response;
307
+ try {
308
+ response = await this.fetchImpl(`${oidcBase}/client/register`, {
309
+ method: "POST",
310
+ headers: { "Content-Type": "application/json" },
311
+ body: JSON.stringify(body),
312
+ });
313
+ }
314
+ catch {
315
+ throw new DeviceLoginError(502, {
316
+ type: "upstream_error",
317
+ code: "OIDC_NETWORK_ERROR",
318
+ message: "Cannot reach AWS SSO-OIDC endpoint.",
319
+ });
320
+ }
321
+ if (!response.ok) {
322
+ const status = response.status;
323
+ const text = await response.text().catch(() => "");
324
+ const excerpt = text.slice(0, 200);
325
+ throw new DeviceLoginError(502, {
326
+ type: "upstream_error",
327
+ code: "OIDC_REGISTER_FAILED",
328
+ message: `Client registration failed: ${status} ${excerpt}.`,
329
+ });
330
+ }
331
+ const data = (await response.json());
332
+ return {
333
+ clientId: data.clientId,
334
+ clientSecret: data.clientSecret,
335
+ clientSecretExpiresAt: data.clientSecretExpiresAt,
336
+ };
337
+ }
338
+ }