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,174 @@
|
|
|
1
|
+
import fastifyWebsocket from "@fastify/websocket";
|
|
2
|
+
export class HealthWebSocketManager {
|
|
3
|
+
healthService;
|
|
4
|
+
broadcastInterval;
|
|
5
|
+
connections = new Set();
|
|
6
|
+
updateInterval = null;
|
|
7
|
+
initialized = false;
|
|
8
|
+
constructor(healthService, broadcastInterval = 5000 // 5 seconds
|
|
9
|
+
) {
|
|
10
|
+
this.healthService = healthService;
|
|
11
|
+
this.broadcastInterval = broadcastInterval;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Initialize WebSocket support on Fastify server
|
|
15
|
+
*/
|
|
16
|
+
async initialize(app) {
|
|
17
|
+
if (this.initialized) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
this.initialized = true;
|
|
21
|
+
// Register WebSocket plugin
|
|
22
|
+
await app.register(fastifyWebsocket);
|
|
23
|
+
// Health updates WebSocket endpoint
|
|
24
|
+
await app.register(async (fastify) => {
|
|
25
|
+
fastify.get('/ws/health', { websocket: true }, (connection, req) => {
|
|
26
|
+
console.log('New health WebSocket connection established');
|
|
27
|
+
// Add connection to active connections
|
|
28
|
+
this.connections.add(connection);
|
|
29
|
+
// Send initial health summary
|
|
30
|
+
const summary = this.healthService.getHealthSummary();
|
|
31
|
+
const summaryMessage = {
|
|
32
|
+
type: 'health_summary',
|
|
33
|
+
summary,
|
|
34
|
+
timestamp: Date.now()
|
|
35
|
+
};
|
|
36
|
+
connection.send(JSON.stringify(summaryMessage));
|
|
37
|
+
// Send current health data for all providers
|
|
38
|
+
const allHealth = this.healthService.getAllProviderHealth();
|
|
39
|
+
for (const [providerId, metrics] of allHealth) {
|
|
40
|
+
const updateMessage = {
|
|
41
|
+
type: 'health_update',
|
|
42
|
+
providerId,
|
|
43
|
+
metrics,
|
|
44
|
+
timestamp: Date.now()
|
|
45
|
+
};
|
|
46
|
+
connection.send(JSON.stringify(updateMessage));
|
|
47
|
+
}
|
|
48
|
+
// Handle connection close
|
|
49
|
+
connection.on('close', () => {
|
|
50
|
+
console.log('Health WebSocket connection closed');
|
|
51
|
+
this.connections.delete(connection);
|
|
52
|
+
});
|
|
53
|
+
// Handle connection error
|
|
54
|
+
connection.on('error', (error) => {
|
|
55
|
+
console.error('Health WebSocket error:', error);
|
|
56
|
+
this.connections.delete(connection);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
// Start broadcasting health updates
|
|
61
|
+
this.startBroadcasting();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Start broadcasting health updates to all connected clients
|
|
65
|
+
*/
|
|
66
|
+
startBroadcasting() {
|
|
67
|
+
if (this.updateInterval) {
|
|
68
|
+
clearInterval(this.updateInterval);
|
|
69
|
+
}
|
|
70
|
+
this.updateInterval = setInterval(() => {
|
|
71
|
+
this.broadcastHealthUpdates();
|
|
72
|
+
}, this.broadcastInterval);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Stop broadcasting health updates
|
|
76
|
+
*/
|
|
77
|
+
stopBroadcasting() {
|
|
78
|
+
if (this.updateInterval) {
|
|
79
|
+
clearInterval(this.updateInterval);
|
|
80
|
+
this.updateInterval = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Broadcast health updates to all connected clients
|
|
85
|
+
*/
|
|
86
|
+
broadcastHealthUpdates() {
|
|
87
|
+
if (this.connections.size === 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
// Get current health data
|
|
92
|
+
const allHealth = this.healthService.getAllProviderHealth();
|
|
93
|
+
const summary = this.healthService.getHealthSummary();
|
|
94
|
+
// Broadcast summary
|
|
95
|
+
const summaryMessage = {
|
|
96
|
+
type: 'health_summary',
|
|
97
|
+
summary,
|
|
98
|
+
timestamp: Date.now()
|
|
99
|
+
};
|
|
100
|
+
this.broadcast(summaryMessage);
|
|
101
|
+
// Broadcast individual provider updates
|
|
102
|
+
for (const [providerId, metrics] of allHealth) {
|
|
103
|
+
const updateMessage = {
|
|
104
|
+
type: 'health_update',
|
|
105
|
+
providerId,
|
|
106
|
+
metrics,
|
|
107
|
+
timestamp: Date.now()
|
|
108
|
+
};
|
|
109
|
+
this.broadcast(updateMessage);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
console.error('Error broadcasting health updates:', error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Broadcast a message to all connected clients
|
|
118
|
+
*/
|
|
119
|
+
broadcast(message) {
|
|
120
|
+
const messageStr = JSON.stringify(message);
|
|
121
|
+
const deadConnections = [];
|
|
122
|
+
for (const connection of this.connections) {
|
|
123
|
+
try {
|
|
124
|
+
if (connection.readyState === 1) { // WebSocket.OPEN
|
|
125
|
+
connection.send(messageStr);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
deadConnections.push(connection);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
console.error('Error sending WebSocket message:', error);
|
|
133
|
+
deadConnections.push(connection);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Clean up dead connections
|
|
137
|
+
for (const deadConnection of deadConnections) {
|
|
138
|
+
this.connections.delete(deadConnection);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Broadcast immediate health update for a specific provider
|
|
143
|
+
*/
|
|
144
|
+
broadcastProviderUpdate(providerId, metrics) {
|
|
145
|
+
const message = {
|
|
146
|
+
type: 'health_update',
|
|
147
|
+
providerId,
|
|
148
|
+
metrics,
|
|
149
|
+
timestamp: Date.now()
|
|
150
|
+
};
|
|
151
|
+
this.broadcast(message);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Get current connection count
|
|
155
|
+
*/
|
|
156
|
+
getConnectionCount() {
|
|
157
|
+
return this.connections.size;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Close all connections and stop broadcasting
|
|
161
|
+
*/
|
|
162
|
+
shutdown() {
|
|
163
|
+
this.stopBroadcasting();
|
|
164
|
+
for (const connection of this.connections) {
|
|
165
|
+
try {
|
|
166
|
+
connection.close();
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
console.error('Error closing WebSocket connection:', error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.connections.clear();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class InMemoryHttpRateLimiter {
|
|
2
|
+
buckets = new Map();
|
|
3
|
+
consume(key, options) {
|
|
4
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
5
|
+
this.prune(nowMs, options.windowMs);
|
|
6
|
+
const existing = this.buckets.get(key);
|
|
7
|
+
if (!existing || nowMs - existing.windowStartedAt >= options.windowMs) {
|
|
8
|
+
this.buckets.set(key, { windowStartedAt: nowMs, hits: 1 });
|
|
9
|
+
return {
|
|
10
|
+
allowed: true,
|
|
11
|
+
remaining: Math.max(options.maxRequests - 1, 0),
|
|
12
|
+
retryAfterMs: 0,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (existing.hits >= options.maxRequests) {
|
|
16
|
+
return {
|
|
17
|
+
allowed: false,
|
|
18
|
+
remaining: 0,
|
|
19
|
+
retryAfterMs: Math.max(options.windowMs - (nowMs - existing.windowStartedAt), 0),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
existing.hits += 1;
|
|
23
|
+
return {
|
|
24
|
+
allowed: true,
|
|
25
|
+
remaining: Math.max(options.maxRequests - existing.hits, 0),
|
|
26
|
+
retryAfterMs: 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
prune(nowMs, windowMs) {
|
|
30
|
+
for (const [key, bucket] of this.buckets) {
|
|
31
|
+
if (nowMs - bucket.windowStartedAt >= windowMs * 2) {
|
|
32
|
+
this.buckets.delete(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { InMemoryHttpRateLimiter } from "./http-rate-limit.js";
|
|
4
|
+
test("rate limiter allows requests until bucket limit is reached", () => {
|
|
5
|
+
const limiter = new InMemoryHttpRateLimiter();
|
|
6
|
+
const first = limiter.consume("responses:token-a", {
|
|
7
|
+
windowMs: 1_000,
|
|
8
|
+
maxRequests: 2,
|
|
9
|
+
nowMs: 10_000,
|
|
10
|
+
});
|
|
11
|
+
assert.equal(first.allowed, true);
|
|
12
|
+
assert.equal(first.remaining, 1);
|
|
13
|
+
assert.equal(first.retryAfterMs, 0);
|
|
14
|
+
const second = limiter.consume("responses:token-a", {
|
|
15
|
+
windowMs: 1_000,
|
|
16
|
+
maxRequests: 2,
|
|
17
|
+
nowMs: 10_100,
|
|
18
|
+
});
|
|
19
|
+
assert.equal(second.allowed, true);
|
|
20
|
+
assert.equal(second.remaining, 0);
|
|
21
|
+
const third = limiter.consume("responses:token-a", {
|
|
22
|
+
windowMs: 1_000,
|
|
23
|
+
maxRequests: 2,
|
|
24
|
+
nowMs: 10_200,
|
|
25
|
+
});
|
|
26
|
+
assert.equal(third.allowed, false);
|
|
27
|
+
assert.equal(third.remaining, 0);
|
|
28
|
+
assert.equal(third.retryAfterMs, 800);
|
|
29
|
+
});
|
|
30
|
+
test("rate limiter resets bucket after window expires", () => {
|
|
31
|
+
const limiter = new InMemoryHttpRateLimiter();
|
|
32
|
+
assert.equal(limiter.consume("health:ip-a", {
|
|
33
|
+
windowMs: 1_000,
|
|
34
|
+
maxRequests: 1,
|
|
35
|
+
nowMs: 5_000,
|
|
36
|
+
}).allowed, true);
|
|
37
|
+
assert.equal(limiter.consume("health:ip-a", {
|
|
38
|
+
windowMs: 1_000,
|
|
39
|
+
maxRequests: 1,
|
|
40
|
+
nowMs: 5_500,
|
|
41
|
+
}).allowed, false);
|
|
42
|
+
const reset = limiter.consume("health:ip-a", {
|
|
43
|
+
windowMs: 1_000,
|
|
44
|
+
maxRequests: 1,
|
|
45
|
+
nowMs: 6_000,
|
|
46
|
+
});
|
|
47
|
+
assert.equal(reset.allowed, true);
|
|
48
|
+
assert.equal(reset.remaining, 0);
|
|
49
|
+
});
|
|
50
|
+
test("rate limiter isolates keys", () => {
|
|
51
|
+
const limiter = new InMemoryHttpRateLimiter();
|
|
52
|
+
assert.equal(limiter.consume("responses:token-a", {
|
|
53
|
+
windowMs: 1_000,
|
|
54
|
+
maxRequests: 1,
|
|
55
|
+
nowMs: 1,
|
|
56
|
+
}).allowed, true);
|
|
57
|
+
assert.equal(limiter.consume("responses:token-b", {
|
|
58
|
+
windowMs: 1_000,
|
|
59
|
+
maxRequests: 1,
|
|
60
|
+
nowMs: 2,
|
|
61
|
+
}).allowed, true);
|
|
62
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export class KiroAuthError extends Error {
|
|
2
|
+
statusCode = 409;
|
|
3
|
+
body;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.body = { type: "authentication_error", code, message };
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const refreshLocks = new Map();
|
|
10
|
+
const accountCursors = new Map();
|
|
11
|
+
/**
|
|
12
|
+
* Refreshes a Kiro/CodeWhisperer token using the AWS SSO-OIDC endpoint for IDC
|
|
13
|
+
* accounts (clientId + clientSecret present), or the Kiro social refresh
|
|
14
|
+
* endpoint otherwise. Mirrors the flow used by the 9router desktop app.
|
|
15
|
+
*/
|
|
16
|
+
export async function refreshKiroToken(account, options = { defaultRegion: "us-east-1" }) {
|
|
17
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
18
|
+
const { clientId, clientSecret, region } = account.providerSpecificData;
|
|
19
|
+
const resolvedRegion = region?.trim() || options.defaultRegion;
|
|
20
|
+
let payload;
|
|
21
|
+
if (clientId && clientSecret) {
|
|
22
|
+
const url = `https://oidc.${resolvedRegion}.amazonaws.com/token`;
|
|
23
|
+
const response = await fetchImpl(url, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify({
|
|
27
|
+
clientId,
|
|
28
|
+
clientSecret,
|
|
29
|
+
refreshToken: account.refreshToken,
|
|
30
|
+
grantType: "refresh_token",
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const text = await response.text().catch(() => "");
|
|
35
|
+
throw new KiroAuthError("KIRO_TOKEN_REFRESH_FAILED", `Token refresh failed (${response.status}): ${text}`);
|
|
36
|
+
}
|
|
37
|
+
payload = (await response.json());
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const response = await fetchImpl("https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify({ refreshToken: account.refreshToken }),
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const text = await response.text().catch(() => "");
|
|
47
|
+
throw new KiroAuthError("KIRO_TOKEN_REFRESH_FAILED", `Token refresh failed (${response.status}): ${text}`);
|
|
48
|
+
}
|
|
49
|
+
payload = (await response.json());
|
|
50
|
+
}
|
|
51
|
+
if (!payload.accessToken) {
|
|
52
|
+
throw new KiroAuthError("KIRO_TOKEN_REFRESH_FAILED", "Token refresh did not return an accessToken");
|
|
53
|
+
}
|
|
54
|
+
const expiresIn = typeof payload.expiresIn === "number" && payload.expiresIn > 0 ? payload.expiresIn : 3600;
|
|
55
|
+
return {
|
|
56
|
+
accessToken: payload.accessToken,
|
|
57
|
+
refreshToken: payload.refreshToken || account.refreshToken,
|
|
58
|
+
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
|
|
59
|
+
expiresIn,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Selects an account (optionally pinned), refreshes its token if it is within
|
|
64
|
+
* the refresh lead window, persists the new token back to 9router's DB, and
|
|
65
|
+
* returns the credentials to use for a CodeWhisperer request.
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveKiroCredentials(args) {
|
|
68
|
+
const account = args.accountId?.trim()
|
|
69
|
+
? args.store.getAccount(args.accountId.trim())
|
|
70
|
+
: selectNextAccount(args.poolKey ?? "kiro", args.store, args.rotationMode ?? "round_robin");
|
|
71
|
+
if (!account || !account.isActive) {
|
|
72
|
+
throw new KiroAuthError("KIRO_ACCOUNT_UNAVAILABLE", "No connected Kiro accounts are available. Sign in through 9router first.");
|
|
73
|
+
}
|
|
74
|
+
const fresh = await ensureFreshAccount(account, args);
|
|
75
|
+
return {
|
|
76
|
+
accountId: fresh.id,
|
|
77
|
+
accessToken: fresh.accessToken,
|
|
78
|
+
profileArn: fresh.providerSpecificData.profileArn ?? null,
|
|
79
|
+
region: fresh.providerSpecificData.region?.trim() || args.defaultRegion,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async function ensureFreshAccount(account, args) {
|
|
83
|
+
const now = args.now ?? new Date();
|
|
84
|
+
const leadMs = args.refreshLeadSeconds * 1000;
|
|
85
|
+
const expiresAtMs = account.expiresAt ? Date.parse(account.expiresAt) : NaN;
|
|
86
|
+
const stillValid = Number.isFinite(expiresAtMs) && expiresAtMs - now.getTime() > leadMs;
|
|
87
|
+
if (stillValid && account.accessToken) {
|
|
88
|
+
return account;
|
|
89
|
+
}
|
|
90
|
+
const existingLock = refreshLocks.get(account.id);
|
|
91
|
+
if (existingLock) {
|
|
92
|
+
return existingLock;
|
|
93
|
+
}
|
|
94
|
+
const refreshPromise = (async () => {
|
|
95
|
+
const update = await refreshKiroToken(account, {
|
|
96
|
+
defaultRegion: args.defaultRegion,
|
|
97
|
+
fetchImpl: args.fetchImpl,
|
|
98
|
+
});
|
|
99
|
+
const persisted = args.store.updateTokens(account.id, update, now);
|
|
100
|
+
// Always overlay the fresh token onto whatever account we have. When write-back
|
|
101
|
+
// is disabled, `persisted` is the stale DB row, so without this overlay the
|
|
102
|
+
// refreshed token would be discarded and an expired token returned.
|
|
103
|
+
const base = persisted ?? account;
|
|
104
|
+
return {
|
|
105
|
+
...base,
|
|
106
|
+
accessToken: update.accessToken,
|
|
107
|
+
refreshToken: update.refreshToken ?? base.refreshToken,
|
|
108
|
+
expiresAt: update.expiresAt,
|
|
109
|
+
expiresIn: update.expiresIn ?? base.expiresIn,
|
|
110
|
+
};
|
|
111
|
+
})().finally(() => {
|
|
112
|
+
refreshLocks.delete(account.id);
|
|
113
|
+
});
|
|
114
|
+
refreshLocks.set(account.id, refreshPromise);
|
|
115
|
+
return refreshPromise;
|
|
116
|
+
}
|
|
117
|
+
function selectNextAccount(poolKey, store, rotationMode) {
|
|
118
|
+
const accounts = store.listAvailableAccounts();
|
|
119
|
+
if (!accounts.length) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (rotationMode === "first_available") {
|
|
123
|
+
return accounts[0];
|
|
124
|
+
}
|
|
125
|
+
if (rotationMode === "random") {
|
|
126
|
+
return accounts[Math.floor(Math.random() * accounts.length)];
|
|
127
|
+
}
|
|
128
|
+
const cursor = accountCursors.get(poolKey) ?? 0;
|
|
129
|
+
const account = accounts[cursor % accounts.length];
|
|
130
|
+
accountCursors.set(poolKey, (cursor + 1) % accounts.length);
|
|
131
|
+
return account;
|
|
132
|
+
}
|
|
133
|
+
export function __resetKiroAuthStateForTests() {
|
|
134
|
+
refreshLocks.clear();
|
|
135
|
+
accountCursors.clear();
|
|
136
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { beforeEach, test } from "node:test";
|
|
3
|
+
import { KiroAuthError, __resetKiroAuthStateForTests, refreshKiroToken, resolveKiroCredentials, } from "./kiro-auth.js";
|
|
4
|
+
function makeAccount(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
id: "acct-a",
|
|
7
|
+
name: "Account A",
|
|
8
|
+
priority: 1,
|
|
9
|
+
isActive: true,
|
|
10
|
+
accessToken: "access-a",
|
|
11
|
+
refreshToken: "refresh-a",
|
|
12
|
+
expiresAt: "2026-06-01T00:00:00.000Z",
|
|
13
|
+
expiresIn: 3600,
|
|
14
|
+
providerSpecificData: {
|
|
15
|
+
profileArn: "arn:keep",
|
|
16
|
+
clientId: null,
|
|
17
|
+
clientSecret: null,
|
|
18
|
+
region: "us-east-1",
|
|
19
|
+
authMethod: null,
|
|
20
|
+
startUrl: null,
|
|
21
|
+
},
|
|
22
|
+
raw: {},
|
|
23
|
+
createdAt: "2026-05-01T00:00:00.000Z",
|
|
24
|
+
updatedAt: "2026-05-01T00:00:00.000Z",
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
class FakeStore {
|
|
29
|
+
accounts = new Map();
|
|
30
|
+
updates = [];
|
|
31
|
+
constructor(accounts) {
|
|
32
|
+
for (const account of accounts) {
|
|
33
|
+
this.accounts.set(account.id, account);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
getAccount(id) {
|
|
37
|
+
return this.accounts.get(id);
|
|
38
|
+
}
|
|
39
|
+
listAvailableAccounts() {
|
|
40
|
+
return [...this.accounts.values()].filter((a) => a.isActive);
|
|
41
|
+
}
|
|
42
|
+
updateTokens(id, update, now = new Date()) {
|
|
43
|
+
this.updates.push({ id, update });
|
|
44
|
+
const current = this.accounts.get(id);
|
|
45
|
+
if (!current) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const next = {
|
|
49
|
+
...current,
|
|
50
|
+
accessToken: update.accessToken,
|
|
51
|
+
refreshToken: update.refreshToken ?? current.refreshToken,
|
|
52
|
+
expiresAt: update.expiresAt,
|
|
53
|
+
expiresIn: update.expiresIn ?? current.expiresIn,
|
|
54
|
+
updatedAt: now.toISOString(),
|
|
55
|
+
};
|
|
56
|
+
this.accounts.set(id, next);
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
asStore() {
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function mockFetch(handler) {
|
|
64
|
+
const calls = [];
|
|
65
|
+
const fetchImpl = (async (input, init) => {
|
|
66
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
67
|
+
calls.push({ url, init });
|
|
68
|
+
const { status = 200, body } = handler(url, init);
|
|
69
|
+
return new Response(JSON.stringify(body), {
|
|
70
|
+
status,
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
return { fetchImpl, calls };
|
|
75
|
+
}
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
__resetKiroAuthStateForTests();
|
|
78
|
+
});
|
|
79
|
+
test("refreshKiroToken uses the social endpoint when no IDC client credentials", async () => {
|
|
80
|
+
const { fetchImpl, calls } = mockFetch(() => ({
|
|
81
|
+
body: { accessToken: "new-access", refreshToken: "new-refresh", expiresIn: 1800 },
|
|
82
|
+
}));
|
|
83
|
+
const update = await refreshKiroToken(makeAccount(), { defaultRegion: "us-east-1", fetchImpl });
|
|
84
|
+
assert.equal(calls.length, 1);
|
|
85
|
+
assert.equal(calls[0].url, "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken");
|
|
86
|
+
assert.equal(JSON.parse(calls[0].init?.body).refreshToken, "refresh-a");
|
|
87
|
+
assert.equal(update.accessToken, "new-access");
|
|
88
|
+
assert.equal(update.refreshToken, "new-refresh");
|
|
89
|
+
assert.equal(update.expiresIn, 1800);
|
|
90
|
+
});
|
|
91
|
+
test("refreshKiroToken uses the regional OIDC endpoint for IDC accounts", async () => {
|
|
92
|
+
const { fetchImpl, calls } = mockFetch(() => ({
|
|
93
|
+
body: { accessToken: "idc-access", refreshToken: "idc-refresh", expiresIn: 3600 },
|
|
94
|
+
}));
|
|
95
|
+
const account = makeAccount({
|
|
96
|
+
providerSpecificData: {
|
|
97
|
+
profileArn: null,
|
|
98
|
+
clientId: "client-x",
|
|
99
|
+
clientSecret: "secret-x",
|
|
100
|
+
region: "eu-west-1",
|
|
101
|
+
authMethod: "idc",
|
|
102
|
+
startUrl: null,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
await refreshKiroToken(account, { defaultRegion: "us-east-1", fetchImpl });
|
|
106
|
+
assert.equal(calls[0].url, "https://oidc.eu-west-1.amazonaws.com/token");
|
|
107
|
+
const body = JSON.parse(calls[0].init?.body);
|
|
108
|
+
assert.equal(body.clientId, "client-x");
|
|
109
|
+
assert.equal(body.clientSecret, "secret-x");
|
|
110
|
+
assert.equal(body.grantType, "refresh_token");
|
|
111
|
+
});
|
|
112
|
+
test("refreshKiroToken throws KiroAuthError on a non-ok response", async () => {
|
|
113
|
+
const { fetchImpl } = mockFetch(() => ({ status: 400, body: { error: "bad" } }));
|
|
114
|
+
await assert.rejects(() => refreshKiroToken(makeAccount(), { defaultRegion: "us-east-1", fetchImpl }), (error) => error instanceof KiroAuthError && error.body.code === "KIRO_TOKEN_REFRESH_FAILED");
|
|
115
|
+
});
|
|
116
|
+
test("refreshKiroToken throws when the response omits an accessToken", async () => {
|
|
117
|
+
const { fetchImpl } = mockFetch(() => ({ body: { refreshToken: "r" } }));
|
|
118
|
+
await assert.rejects(() => refreshKiroToken(makeAccount(), { defaultRegion: "us-east-1", fetchImpl }), (error) => error instanceof KiroAuthError);
|
|
119
|
+
});
|
|
120
|
+
test("resolveKiroCredentials returns a still-valid account without refreshing", async () => {
|
|
121
|
+
const store = new FakeStore([makeAccount()]);
|
|
122
|
+
const { fetchImpl, calls } = mockFetch(() => ({ body: {} }));
|
|
123
|
+
const creds = await resolveKiroCredentials({
|
|
124
|
+
store: store.asStore(),
|
|
125
|
+
accountId: "acct-a",
|
|
126
|
+
defaultRegion: "us-east-1",
|
|
127
|
+
refreshLeadSeconds: 120,
|
|
128
|
+
fetchImpl,
|
|
129
|
+
now: new Date("2026-05-15T00:00:00.000Z"),
|
|
130
|
+
});
|
|
131
|
+
assert.equal(calls.length, 0);
|
|
132
|
+
assert.equal(creds.accessToken, "access-a");
|
|
133
|
+
assert.equal(creds.profileArn, "arn:keep");
|
|
134
|
+
assert.equal(creds.region, "us-east-1");
|
|
135
|
+
});
|
|
136
|
+
test("resolveKiroCredentials refreshes when inside the lead window and persists the new token", async () => {
|
|
137
|
+
const store = new FakeStore([
|
|
138
|
+
makeAccount({ expiresAt: "2026-05-15T00:01:00.000Z" }),
|
|
139
|
+
]);
|
|
140
|
+
const { fetchImpl, calls } = mockFetch(() => ({
|
|
141
|
+
body: { accessToken: "fresh-access", refreshToken: "fresh-refresh", expiresIn: 3600 },
|
|
142
|
+
}));
|
|
143
|
+
const creds = await resolveKiroCredentials({
|
|
144
|
+
store: store.asStore(),
|
|
145
|
+
accountId: "acct-a",
|
|
146
|
+
defaultRegion: "us-east-1",
|
|
147
|
+
refreshLeadSeconds: 120,
|
|
148
|
+
fetchImpl,
|
|
149
|
+
now: new Date("2026-05-15T00:00:00.000Z"),
|
|
150
|
+
});
|
|
151
|
+
assert.equal(calls.length, 1);
|
|
152
|
+
assert.equal(creds.accessToken, "fresh-access");
|
|
153
|
+
assert.equal(store.updates.length, 1);
|
|
154
|
+
assert.equal(store.getAccount("acct-a")?.accessToken, "fresh-access");
|
|
155
|
+
});
|
|
156
|
+
test("resolveKiroCredentials rotates round-robin across available accounts", async () => {
|
|
157
|
+
const store = new FakeStore([
|
|
158
|
+
makeAccount({ id: "acct-a", priority: 1 }),
|
|
159
|
+
makeAccount({ id: "acct-b", priority: 2 }),
|
|
160
|
+
]);
|
|
161
|
+
const { fetchImpl } = mockFetch(() => ({ body: {} }));
|
|
162
|
+
const common = {
|
|
163
|
+
store: store.asStore(),
|
|
164
|
+
defaultRegion: "us-east-1",
|
|
165
|
+
refreshLeadSeconds: 120,
|
|
166
|
+
fetchImpl,
|
|
167
|
+
now: new Date("2026-05-15T00:00:00.000Z"),
|
|
168
|
+
};
|
|
169
|
+
const first = await resolveKiroCredentials(common);
|
|
170
|
+
const second = await resolveKiroCredentials(common);
|
|
171
|
+
const third = await resolveKiroCredentials(common);
|
|
172
|
+
assert.equal(first.accountId, "acct-a");
|
|
173
|
+
assert.equal(second.accountId, "acct-b");
|
|
174
|
+
assert.equal(third.accountId, "acct-a");
|
|
175
|
+
});
|
|
176
|
+
test("resolveKiroCredentials returns the fresh token even when write-back is disabled", async () => {
|
|
177
|
+
// Simulate writeBack:false: updateTokens persists nothing and returns the stale row.
|
|
178
|
+
class NoWriteBackStore extends FakeStore {
|
|
179
|
+
updateTokens(id) {
|
|
180
|
+
return this.getAccount(id);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const store = new NoWriteBackStore([makeAccount({ expiresAt: "2026-05-15T00:01:00.000Z" })]);
|
|
184
|
+
const { fetchImpl, calls } = mockFetch(() => ({
|
|
185
|
+
body: { accessToken: "fresh-access", refreshToken: "fresh-refresh", expiresIn: 3600 },
|
|
186
|
+
}));
|
|
187
|
+
const creds = await resolveKiroCredentials({
|
|
188
|
+
store: store.asStore(),
|
|
189
|
+
accountId: "acct-a",
|
|
190
|
+
defaultRegion: "us-east-1",
|
|
191
|
+
refreshLeadSeconds: 120,
|
|
192
|
+
fetchImpl,
|
|
193
|
+
now: new Date("2026-05-15T00:00:00.000Z"),
|
|
194
|
+
});
|
|
195
|
+
assert.equal(calls.length, 1);
|
|
196
|
+
// The refreshed token must be used, not the stale one the store returned.
|
|
197
|
+
assert.equal(creds.accessToken, "fresh-access");
|
|
198
|
+
// And the store row is intentionally left unchanged (no write-back).
|
|
199
|
+
assert.equal(store.getAccount("acct-a")?.accessToken, "access-a");
|
|
200
|
+
});
|
|
201
|
+
test("resolveKiroCredentials throws KIRO_ACCOUNT_UNAVAILABLE when no accounts are connected", async () => {
|
|
202
|
+
const store = new FakeStore([]);
|
|
203
|
+
const { fetchImpl } = mockFetch(() => ({ body: {} }));
|
|
204
|
+
await assert.rejects(() => resolveKiroCredentials({
|
|
205
|
+
store: store.asStore(),
|
|
206
|
+
defaultRegion: "us-east-1",
|
|
207
|
+
refreshLeadSeconds: 120,
|
|
208
|
+
fetchImpl,
|
|
209
|
+
}), (error) => error instanceof KiroAuthError && error.body.code === "KIRO_ACCOUNT_UNAVAILABLE");
|
|
210
|
+
});
|
|
211
|
+
test("concurrent refreshes for the same account share a single refresh call", async () => {
|
|
212
|
+
const store = new FakeStore([makeAccount({ expiresAt: "2026-05-15T00:01:00.000Z" })]);
|
|
213
|
+
let calls = 0;
|
|
214
|
+
const fetchImpl = (async () => {
|
|
215
|
+
calls += 1;
|
|
216
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
217
|
+
return new Response(JSON.stringify({ accessToken: "fresh", refreshToken: "fresh-r", expiresIn: 3600 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
218
|
+
});
|
|
219
|
+
const args = {
|
|
220
|
+
store: store.asStore(),
|
|
221
|
+
accountId: "acct-a",
|
|
222
|
+
defaultRegion: "us-east-1",
|
|
223
|
+
refreshLeadSeconds: 120,
|
|
224
|
+
fetchImpl,
|
|
225
|
+
now: new Date("2026-05-15T00:00:00.000Z"),
|
|
226
|
+
};
|
|
227
|
+
const [a, b] = await Promise.all([
|
|
228
|
+
resolveKiroCredentials(args),
|
|
229
|
+
resolveKiroCredentials(args),
|
|
230
|
+
]);
|
|
231
|
+
assert.equal(calls, 1);
|
|
232
|
+
assert.equal(a.accessToken, "fresh");
|
|
233
|
+
assert.equal(b.accessToken, "fresh");
|
|
234
|
+
});
|