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.
- package/README.md +56 -0
- package/cli.js +118 -0
- package/dist/anthropic-messages.js +383 -0
- package/dist/anthropic-messages.test.js +209 -0
- package/dist/audit-log.js +138 -0
- package/dist/audit-log.test.js +480 -0
- package/dist/billing-expiration.js +70 -0
- package/dist/billing-expiration.test.js +114 -0
- package/dist/billing.js +716 -0
- package/dist/billing.test.js +228 -0
- package/dist/chatgpt-oauth-store.js +240 -0
- package/dist/chatgpt-oauth-store.test.js +88 -0
- package/dist/chatgpt-oauth.js +118 -0
- package/dist/chatgpt-oauth.test.js +63 -0
- package/dist/chatgpt-provider-auth.js +60 -0
- package/dist/chatgpt-provider-auth.test.js +101 -0
- package/dist/client/app-icon.svg +17 -0
- package/dist/client/assets/index-C7Vvhst8.js +14 -0
- package/dist/client/assets/index-DpqgYK3L.css +1 -0
- package/dist/client/favicon.svg +17 -0
- package/dist/client/index.html +31 -0
- package/dist/client-config-apply.js +345 -0
- package/dist/client-config-apply.test.js +185 -0
- package/dist/client-token-limits.js +111 -0
- package/dist/client-token-limits.test.js +129 -0
- package/dist/codex-config.js +47 -0
- package/dist/codex-setup.js +87 -0
- package/dist/codex-setup.test.js +30 -0
- package/dist/config.js +314 -0
- package/dist/cost-analytics.js +31 -0
- package/dist/cost-analytics.test.js +38 -0
- package/dist/customer-key-access.js +126 -0
- package/dist/customer-key-access.test.js +178 -0
- package/dist/customer-keys.js +209 -0
- package/dist/customer-keys.test.js +68 -0
- package/dist/customer-usage.js +18 -0
- package/dist/customer-usage.test.js +55 -0
- package/dist/dashboard-auth.js +318 -0
- package/dist/dashboard-auth.test.js +133 -0
- package/dist/dashboard-serving.test.js +235 -0
- package/dist/error-response.js +174 -0
- package/dist/error-response.test.js +88 -0
- package/dist/forward.js +357 -0
- package/dist/health-websocket-manager.js +174 -0
- package/dist/http-rate-limit.js +36 -0
- package/dist/http-rate-limit.test.js +62 -0
- package/dist/kiro-auth.js +136 -0
- package/dist/kiro-auth.test.js +234 -0
- package/dist/kiro-codewhisperer.js +646 -0
- package/dist/kiro-codewhisperer.test.js +219 -0
- package/dist/kiro-device-login.js +338 -0
- package/dist/kiro-eventstream.js +219 -0
- package/dist/kiro-eventstream.test.js +79 -0
- package/dist/kiro-forward.js +401 -0
- package/dist/kiro-import-cli.js +69 -0
- package/dist/kiro-import.js +94 -0
- package/dist/kiro-import.test.js +125 -0
- package/dist/kiro-token-store.js +196 -0
- package/dist/kiro-token-store.test.js +207 -0
- package/dist/krouter-usage.js +243 -0
- package/dist/model-combo-repository.js +147 -0
- package/dist/model-routing.js +69 -0
- package/dist/model-routing.test.js +41 -0
- package/dist/normalize-request.js +531 -0
- package/dist/normalize-request.test.js +277 -0
- package/dist/omv-public-firewall.test.js +11 -0
- package/dist/package.json +17 -0
- package/dist/prompt-cache-state.js +146 -0
- package/dist/prompt-cache-state.test.js +71 -0
- package/dist/prompt-cache.js +229 -0
- package/dist/provider-health-service.js +404 -0
- package/dist/provider-request-parameters.js +107 -0
- package/dist/provider-request-parameters.test.js +26 -0
- package/dist/provider-routing.js +114 -0
- package/dist/provider-routing.test.js +64 -0
- package/dist/provider-usage.js +314 -0
- package/dist/request-timeout-policy.js +61 -0
- package/dist/request-timeout-policy.test.js +40 -0
- package/dist/response-cache.js +69 -0
- package/dist/response-cache.test.js +28 -0
- package/dist/routing-combo-repository.js +300 -0
- package/dist/routing-engine.js +377 -0
- package/dist/routing-integration.js +155 -0
- package/dist/routing-simulation-engine.js +326 -0
- package/dist/rtk-layer.js +483 -0
- package/dist/rtk-layer.test.js +198 -0
- package/dist/runtime-provider-repository.js +1742 -0
- package/dist/runtime-provider-repository.test.js +1177 -0
- package/dist/schema.js +118 -0
- package/dist/schema.test.js +16 -0
- package/dist/sepay-webhook.js +87 -0
- package/dist/sepay-webhook.test.js +142 -0
- package/dist/server-body-limit.test.js +35 -0
- package/dist/server-client-token-limits.test.js +161 -0
- package/dist/server-codex-config-setup.test.js +76 -0
- package/dist/server-http-rate-limit.test.js +80 -0
- package/dist/server-response-cache.test.js +105 -0
- package/dist/server-routes-alias.test.js +39 -0
- package/dist/server-sepay-webhook-security.test.js +59 -0
- package/dist/server.js +5906 -0
- package/dist/session-log.js +178 -0
- package/dist/tailnet-funnel-script.test.js +33 -0
- package/dist/telegram-bot/actions.js +118 -0
- package/dist/telegram-bot/admin-actions.js +103 -0
- package/dist/telegram-bot/auth.js +46 -0
- package/dist/telegram-bot/auth.test.js +1 -0
- package/dist/telegram-bot/bot-identity-repository.js +189 -0
- package/dist/telegram-bot/bot-identity-repository.test.js +78 -0
- package/dist/telegram-bot/callbacks.js +30 -0
- package/dist/telegram-bot/codex-config-delivery.js +38 -0
- package/dist/telegram-bot/codex-config-delivery.test.js +75 -0
- package/dist/telegram-bot/commands/accounts.js +140 -0
- package/dist/telegram-bot/commands/apikey.js +737 -0
- package/dist/telegram-bot/commands/apply.js +265 -0
- package/dist/telegram-bot/commands/clients.js +13 -0
- package/dist/telegram-bot/commands/customer-billing.test.js +271 -0
- package/dist/telegram-bot/commands/grant.js +138 -0
- package/dist/telegram-bot/commands/grant.test.js +217 -0
- package/dist/telegram-bot/commands/help.js +52 -0
- package/dist/telegram-bot/commands/me.js +53 -0
- package/dist/telegram-bot/commands/models.js +6 -0
- package/dist/telegram-bot/commands/oauth.js +64 -0
- package/dist/telegram-bot/commands/plans.js +96 -0
- package/dist/telegram-bot/commands/providers.js +27 -0
- package/dist/telegram-bot/commands/quota.js +10 -0
- package/dist/telegram-bot/commands/renew-user.js +139 -0
- package/dist/telegram-bot/commands/renew-user.test.js +184 -0
- package/dist/telegram-bot/commands/renew.js +1369 -0
- package/dist/telegram-bot/commands/renew.test.js +1633 -0
- package/dist/telegram-bot/commands/start.js +212 -0
- package/dist/telegram-bot/commands/start.test.js +280 -0
- package/dist/telegram-bot/commands/status.js +6 -0
- package/dist/telegram-bot/commands/tailscale.js +15 -0
- package/dist/telegram-bot/commands/tailscale.test.js +76 -0
- package/dist/telegram-bot/commands/test.js +51 -0
- package/dist/telegram-bot/commands/test.test.js +14 -0
- package/dist/telegram-bot/commands/usage.js +10 -0
- package/dist/telegram-bot/config.js +98 -0
- package/dist/telegram-bot/config.test.js +42 -0
- package/dist/telegram-bot/customer-actions.js +160 -0
- package/dist/telegram-bot/customer-api-keys.js +68 -0
- package/dist/telegram-bot/customer-billing.js +72 -0
- package/dist/telegram-bot/customer-workspace-repository.js +134 -0
- package/dist/telegram-bot/customer-workspace-repository.test.js +47 -0
- package/dist/telegram-bot/dashboard-login.js +39 -0
- package/dist/telegram-bot/format.js +140 -0
- package/dist/telegram-bot/grants.js +370 -0
- package/dist/telegram-bot/grants.test.js +290 -0
- package/dist/telegram-bot/index.js +85 -0
- package/dist/telegram-bot/message-cleanup.js +55 -0
- package/dist/telegram-bot/message-cleanup.test.js +77 -0
- package/dist/telegram-bot/message-format.js +45 -0
- package/dist/telegram-bot/message-format.test.js +10 -0
- package/dist/telegram-bot/proxy-client.js +174 -0
- package/dist/telegram-bot/rate-limit.js +95 -0
- package/dist/telegram-bot/rate-limit.test.js +58 -0
- package/dist/telegram-bot/sessions.js +171 -0
- package/dist/telegram-bot/sessions.test.js +107 -0
- package/dist/telegram-bot/telegram-adapter.js +126 -0
- package/dist/telegram-bot/worker.js +63 -0
- 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
|
+
}
|