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,318 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
5
|
+
export class DashboardAuthRepository {
|
|
6
|
+
db;
|
|
7
|
+
constructor(db) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
}
|
|
10
|
+
static create(dbFile) {
|
|
11
|
+
mkdirSync(path.dirname(dbFile), { recursive: true });
|
|
12
|
+
const db = new BetterSqlite3(dbFile);
|
|
13
|
+
ensureDashboardAuthSchema(db);
|
|
14
|
+
return new DashboardAuthRepository(db);
|
|
15
|
+
}
|
|
16
|
+
createChallenge(input) {
|
|
17
|
+
const now = input.now ?? new Date();
|
|
18
|
+
const createdAt = now.toISOString();
|
|
19
|
+
const expiresAt = new Date(now.getTime() + input.ttlMs).toISOString();
|
|
20
|
+
const id = randomBytes(16).toString("hex");
|
|
21
|
+
const otp = randomInt(0, 1_000_000).toString().padStart(6, "0");
|
|
22
|
+
this.db
|
|
23
|
+
.prepare(`INSERT INTO dashboard_auth_challenges (
|
|
24
|
+
id,
|
|
25
|
+
telegram_user_id,
|
|
26
|
+
otp_hash,
|
|
27
|
+
expires_at,
|
|
28
|
+
created_at
|
|
29
|
+
) VALUES (?, ?, ?, ?, ?)`)
|
|
30
|
+
.run(id, input.telegramUserId, hashOtp(id, otp), expiresAt, createdAt);
|
|
31
|
+
return { id, telegramUserId: input.telegramUserId, otp, expiresAt };
|
|
32
|
+
}
|
|
33
|
+
createApprovalChallenge(input) {
|
|
34
|
+
const now = input.now ?? new Date();
|
|
35
|
+
const expiresAt = new Date(now.getTime() + input.ttlMs).toISOString();
|
|
36
|
+
const id = randomBytes(16).toString("hex");
|
|
37
|
+
const displayCode = randomInt(10, 100).toString().padStart(2, "0");
|
|
38
|
+
const pollToken = randomBytes(24).toString("base64url");
|
|
39
|
+
const telegramUserIds = Array.from(new Set(input.telegramUserIds.filter(Boolean)));
|
|
40
|
+
if (telegramUserIds.length === 0) {
|
|
41
|
+
throw new Error("Cannot create dashboard approval challenge without Telegram admins.");
|
|
42
|
+
}
|
|
43
|
+
const insert = this.db.prepare(`INSERT INTO dashboard_auth_challenges (
|
|
44
|
+
id,
|
|
45
|
+
challenge_group_id,
|
|
46
|
+
telegram_user_id,
|
|
47
|
+
otp_hash,
|
|
48
|
+
poll_token_hash,
|
|
49
|
+
expires_at,
|
|
50
|
+
created_at
|
|
51
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
52
|
+
const createdAt = now.toISOString();
|
|
53
|
+
for (const telegramUserId of telegramUserIds) {
|
|
54
|
+
const rowId = randomBytes(16).toString("hex");
|
|
55
|
+
insert.run(rowId, id, telegramUserId, hashOtp(rowId, displayCode), hashPollToken(pollToken), expiresAt, createdAt);
|
|
56
|
+
}
|
|
57
|
+
return { id, displayCode, pollToken, expiresAt };
|
|
58
|
+
}
|
|
59
|
+
getApprovalChallengeStatus(input) {
|
|
60
|
+
const now = input.now ?? new Date();
|
|
61
|
+
const row = this.getApprovalChallengeRow(input.challengeId, input.pollToken);
|
|
62
|
+
if (!row) {
|
|
63
|
+
return { ok: false, reason: "invalid" };
|
|
64
|
+
}
|
|
65
|
+
if (row.approved_at) {
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
status: "approved",
|
|
69
|
+
challengeId: input.challengeId,
|
|
70
|
+
expiresAt: row.expires_at,
|
|
71
|
+
telegramUserId: row.approved_by_telegram_user_id ?? row.telegram_user_id,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (row.rejected_at) {
|
|
75
|
+
return { ok: true, status: "rejected", challengeId: input.challengeId, expiresAt: row.expires_at };
|
|
76
|
+
}
|
|
77
|
+
if (new Date(row.expires_at).getTime() < now.getTime()) {
|
|
78
|
+
return { ok: true, status: "expired", challengeId: input.challengeId, expiresAt: row.expires_at };
|
|
79
|
+
}
|
|
80
|
+
if (row.consumed_at) {
|
|
81
|
+
return { ok: true, status: "consumed", challengeId: input.challengeId, expiresAt: row.expires_at };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, status: "pending", challengeId: input.challengeId, expiresAt: row.expires_at };
|
|
84
|
+
}
|
|
85
|
+
resolveApprovalChoice(input) {
|
|
86
|
+
const now = input.now ?? new Date();
|
|
87
|
+
const row = this.getAdminChallengeRow(input.challengeId, input.telegramUserId);
|
|
88
|
+
if (!row) {
|
|
89
|
+
return { ok: false, reason: "invalid" };
|
|
90
|
+
}
|
|
91
|
+
if (row.consumed_at) {
|
|
92
|
+
return { ok: false, reason: "consumed" };
|
|
93
|
+
}
|
|
94
|
+
if (new Date(row.expires_at).getTime() < now.getTime()) {
|
|
95
|
+
return { ok: false, reason: "expired" };
|
|
96
|
+
}
|
|
97
|
+
if (row.approved_at || row.rejected_at) {
|
|
98
|
+
return { ok: false, reason: "consumed" };
|
|
99
|
+
}
|
|
100
|
+
if (hashOtp(row.id, input.selectedCode) === row.otp_hash) {
|
|
101
|
+
this.db
|
|
102
|
+
.prepare(`UPDATE dashboard_auth_challenges
|
|
103
|
+
SET approved_at = ?, approved_by_telegram_user_id = ?
|
|
104
|
+
WHERE challenge_group_id = ?`)
|
|
105
|
+
.run(now.toISOString(), input.telegramUserId, input.challengeId);
|
|
106
|
+
return { ok: true, status: "approved" };
|
|
107
|
+
}
|
|
108
|
+
this.db
|
|
109
|
+
.prepare(`UPDATE dashboard_auth_challenges
|
|
110
|
+
SET rejected_at = ?, rejected_by_telegram_user_id = ?
|
|
111
|
+
WHERE challenge_group_id = ?`)
|
|
112
|
+
.run(now.toISOString(), input.telegramUserId, input.challengeId);
|
|
113
|
+
return { ok: true, status: "rejected" };
|
|
114
|
+
}
|
|
115
|
+
consumeApprovedChallenge(input) {
|
|
116
|
+
const now = input.now ?? new Date();
|
|
117
|
+
const row = this.getApprovalChallengeRow(input.challengeId, input.pollToken);
|
|
118
|
+
if (!row) {
|
|
119
|
+
return { ok: false, reason: "invalid" };
|
|
120
|
+
}
|
|
121
|
+
if (row.rejected_at) {
|
|
122
|
+
return { ok: false, reason: "rejected" };
|
|
123
|
+
}
|
|
124
|
+
if (new Date(row.expires_at).getTime() < now.getTime()) {
|
|
125
|
+
return { ok: false, reason: "expired" };
|
|
126
|
+
}
|
|
127
|
+
if (!row.approved_at) {
|
|
128
|
+
return { ok: false, reason: "pending" };
|
|
129
|
+
}
|
|
130
|
+
if (row.consumed_at) {
|
|
131
|
+
return { ok: false, reason: "consumed" };
|
|
132
|
+
}
|
|
133
|
+
this.db
|
|
134
|
+
.prepare(`UPDATE dashboard_auth_challenges
|
|
135
|
+
SET consumed_at = ?
|
|
136
|
+
WHERE challenge_group_id = ?`)
|
|
137
|
+
.run(now.toISOString(), input.challengeId);
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
telegramUserId: row.approved_by_telegram_user_id ?? row.telegram_user_id,
|
|
141
|
+
expiresAt: row.expires_at,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
consumeChallenge(input) {
|
|
145
|
+
return this.consumeChallengeForUsers({ telegramUserIds: [input.telegramUserId], otp: input.otp, now: input.now });
|
|
146
|
+
}
|
|
147
|
+
consumeChallengeForUsers(input) {
|
|
148
|
+
const now = input.now ?? new Date();
|
|
149
|
+
const userIds = Array.from(new Set(input.telegramUserIds.filter(Boolean)));
|
|
150
|
+
if (userIds.length === 0) {
|
|
151
|
+
return { ok: false, reason: "invalid" };
|
|
152
|
+
}
|
|
153
|
+
const placeholders = userIds.map(() => "?").join(", ");
|
|
154
|
+
const rows = this.db
|
|
155
|
+
.prepare(`SELECT * FROM dashboard_auth_challenges
|
|
156
|
+
WHERE telegram_user_id IN (${placeholders})
|
|
157
|
+
AND consumed_at IS NULL
|
|
158
|
+
ORDER BY created_at DESC
|
|
159
|
+
LIMIT 20`)
|
|
160
|
+
.all(...userIds);
|
|
161
|
+
for (const row of rows) {
|
|
162
|
+
if (hashOtp(row.id, input.otp) !== row.otp_hash) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (new Date(row.expires_at).getTime() < now.getTime()) {
|
|
166
|
+
return { ok: false, reason: "expired" };
|
|
167
|
+
}
|
|
168
|
+
this.db
|
|
169
|
+
.prepare(`UPDATE dashboard_auth_challenges
|
|
170
|
+
SET consumed_at = ?
|
|
171
|
+
WHERE id = ?`)
|
|
172
|
+
.run(now.toISOString(), row.id);
|
|
173
|
+
return { ok: true, challengeId: row.id, telegramUserId: row.telegram_user_id };
|
|
174
|
+
}
|
|
175
|
+
return { ok: false, reason: "invalid" };
|
|
176
|
+
}
|
|
177
|
+
createSession(input) {
|
|
178
|
+
const now = input.now ?? new Date();
|
|
179
|
+
const timestamp = now.toISOString();
|
|
180
|
+
const expiresAt = new Date(now.getTime() + input.ttlMs).toISOString();
|
|
181
|
+
const id = randomBytes(16).toString("hex");
|
|
182
|
+
const token = randomBytes(32).toString("base64url");
|
|
183
|
+
this.db
|
|
184
|
+
.prepare(`INSERT INTO dashboard_auth_sessions (
|
|
185
|
+
id,
|
|
186
|
+
session_hash,
|
|
187
|
+
telegram_user_id,
|
|
188
|
+
role,
|
|
189
|
+
expires_at,
|
|
190
|
+
created_at,
|
|
191
|
+
updated_at
|
|
192
|
+
) VALUES (?, ?, ?, 'admin', ?, ?, ?)`)
|
|
193
|
+
.run(id, hashSessionToken(token), input.telegramUserId, expiresAt, timestamp, timestamp);
|
|
194
|
+
return {
|
|
195
|
+
token,
|
|
196
|
+
session: {
|
|
197
|
+
id,
|
|
198
|
+
telegramUserId: input.telegramUserId,
|
|
199
|
+
role: "admin",
|
|
200
|
+
expiresAt,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
getSessionByToken(token, now = new Date()) {
|
|
205
|
+
if (!token) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
const row = this.db
|
|
209
|
+
.prepare(`SELECT * FROM dashboard_auth_sessions
|
|
210
|
+
WHERE session_hash = ?
|
|
211
|
+
AND revoked_at IS NULL
|
|
212
|
+
LIMIT 1`)
|
|
213
|
+
.get(hashSessionToken(token));
|
|
214
|
+
if (!row || new Date(row.expires_at).getTime() < now.getTime()) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
id: row.id,
|
|
219
|
+
telegramUserId: row.telegram_user_id,
|
|
220
|
+
role: "admin",
|
|
221
|
+
expiresAt: row.expires_at,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
revokeSessionByToken(token, now = new Date()) {
|
|
225
|
+
if (!token) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const timestamp = now.toISOString();
|
|
229
|
+
const result = this.db
|
|
230
|
+
.prepare(`UPDATE dashboard_auth_sessions
|
|
231
|
+
SET revoked_at = ?, updated_at = ?
|
|
232
|
+
WHERE session_hash = ?
|
|
233
|
+
AND revoked_at IS NULL`)
|
|
234
|
+
.run(timestamp, timestamp, hashSessionToken(token));
|
|
235
|
+
return result.changes > 0;
|
|
236
|
+
}
|
|
237
|
+
getApprovalChallengeRow(challengeId, pollToken) {
|
|
238
|
+
return this.db
|
|
239
|
+
.prepare(`SELECT * FROM dashboard_auth_challenges
|
|
240
|
+
WHERE challenge_group_id = ?
|
|
241
|
+
AND poll_token_hash = ?
|
|
242
|
+
ORDER BY created_at ASC
|
|
243
|
+
LIMIT 1`)
|
|
244
|
+
.get(challengeId, hashPollToken(pollToken));
|
|
245
|
+
}
|
|
246
|
+
getAdminChallengeRow(challengeId, telegramUserId) {
|
|
247
|
+
return this.db
|
|
248
|
+
.prepare(`SELECT * FROM dashboard_auth_challenges
|
|
249
|
+
WHERE challenge_group_id = ?
|
|
250
|
+
AND telegram_user_id = ?
|
|
251
|
+
ORDER BY created_at ASC
|
|
252
|
+
LIMIT 1`)
|
|
253
|
+
.get(challengeId, telegramUserId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function ensureDashboardAuthSchema(db) {
|
|
257
|
+
db.exec(`
|
|
258
|
+
CREATE TABLE IF NOT EXISTS dashboard_auth_challenges (
|
|
259
|
+
id TEXT PRIMARY KEY,
|
|
260
|
+
challenge_group_id TEXT,
|
|
261
|
+
telegram_user_id TEXT NOT NULL,
|
|
262
|
+
otp_hash TEXT NOT NULL,
|
|
263
|
+
poll_token_hash TEXT,
|
|
264
|
+
expires_at TEXT NOT NULL,
|
|
265
|
+
consumed_at TEXT,
|
|
266
|
+
approved_at TEXT,
|
|
267
|
+
approved_by_telegram_user_id TEXT,
|
|
268
|
+
rejected_at TEXT,
|
|
269
|
+
rejected_by_telegram_user_id TEXT,
|
|
270
|
+
created_at TEXT NOT NULL
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
CREATE INDEX IF NOT EXISTS idx_dashboard_auth_challenges_user
|
|
274
|
+
ON dashboard_auth_challenges(telegram_user_id, consumed_at, created_at);
|
|
275
|
+
|
|
276
|
+
CREATE TABLE IF NOT EXISTS dashboard_auth_sessions (
|
|
277
|
+
id TEXT PRIMARY KEY,
|
|
278
|
+
session_hash TEXT NOT NULL UNIQUE,
|
|
279
|
+
telegram_user_id TEXT NOT NULL,
|
|
280
|
+
role TEXT NOT NULL,
|
|
281
|
+
expires_at TEXT NOT NULL,
|
|
282
|
+
revoked_at TEXT,
|
|
283
|
+
created_at TEXT NOT NULL,
|
|
284
|
+
updated_at TEXT NOT NULL
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
CREATE INDEX IF NOT EXISTS idx_dashboard_auth_sessions_hash
|
|
288
|
+
ON dashboard_auth_sessions(session_hash, revoked_at, expires_at);
|
|
289
|
+
`);
|
|
290
|
+
ensureColumn(db, "dashboard_auth_challenges", "challenge_group_id", "TEXT");
|
|
291
|
+
ensureColumn(db, "dashboard_auth_challenges", "poll_token_hash", "TEXT");
|
|
292
|
+
ensureColumn(db, "dashboard_auth_challenges", "approved_at", "TEXT");
|
|
293
|
+
ensureColumn(db, "dashboard_auth_challenges", "approved_by_telegram_user_id", "TEXT");
|
|
294
|
+
ensureColumn(db, "dashboard_auth_challenges", "rejected_at", "TEXT");
|
|
295
|
+
ensureColumn(db, "dashboard_auth_challenges", "rejected_by_telegram_user_id", "TEXT");
|
|
296
|
+
db.exec(`
|
|
297
|
+
CREATE INDEX IF NOT EXISTS idx_dashboard_auth_challenges_group
|
|
298
|
+
ON dashboard_auth_challenges(challenge_group_id, telegram_user_id, created_at);
|
|
299
|
+
`);
|
|
300
|
+
}
|
|
301
|
+
function hashOtp(challengeId, otp) {
|
|
302
|
+
return createHash("sha256").update(`${challengeId}:${otp}`).digest("hex");
|
|
303
|
+
}
|
|
304
|
+
function hashSessionToken(token) {
|
|
305
|
+
return createHash("sha256").update(token).digest("hex");
|
|
306
|
+
}
|
|
307
|
+
function hashPollToken(token) {
|
|
308
|
+
return createHash("sha256").update(token).digest("hex");
|
|
309
|
+
}
|
|
310
|
+
function ensureColumn(db, tableName, columnName, columnDefinition) {
|
|
311
|
+
const columns = db
|
|
312
|
+
.prepare(`PRAGMA table_info(${tableName})`)
|
|
313
|
+
.all();
|
|
314
|
+
if (columns.some((column) => column.name === columnName)) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDefinition}`);
|
|
318
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { DashboardAuthRepository } from "./dashboard-auth.js";
|
|
7
|
+
import { registerDashboardLoginCallbacks } from "./telegram-bot/dashboard-login.js";
|
|
8
|
+
test("dashboard approval challenge resolves and consumes once", async () => {
|
|
9
|
+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "responses-proxy-dashboard-auth-"));
|
|
10
|
+
const dbFile = path.join(tempDir, "auth.sqlite");
|
|
11
|
+
try {
|
|
12
|
+
const repo = DashboardAuthRepository.create(dbFile);
|
|
13
|
+
const challenge = repo.createApprovalChallenge({ telegramUserIds: ["1", "2"], ttlMs: 60_000 });
|
|
14
|
+
assert.match(challenge.displayCode, /^\d{2}$/);
|
|
15
|
+
const initial = repo.getApprovalChallengeStatus({ challengeId: challenge.id, pollToken: challenge.pollToken });
|
|
16
|
+
assert.equal(initial.ok, true);
|
|
17
|
+
assert.equal(initial.status, "pending");
|
|
18
|
+
const resolved = repo.resolveApprovalChoice({
|
|
19
|
+
challengeId: challenge.id,
|
|
20
|
+
telegramUserId: "1",
|
|
21
|
+
selectedCode: challenge.displayCode,
|
|
22
|
+
});
|
|
23
|
+
assert.equal(resolved.ok, true);
|
|
24
|
+
assert.equal(resolved.status, "approved");
|
|
25
|
+
const approved = repo.getApprovalChallengeStatus({ challengeId: challenge.id, pollToken: challenge.pollToken });
|
|
26
|
+
assert.equal(approved.ok, true);
|
|
27
|
+
assert.equal(approved.status, "approved");
|
|
28
|
+
const consumed = repo.consumeApprovedChallenge({ challengeId: challenge.id, pollToken: challenge.pollToken });
|
|
29
|
+
assert.equal(consumed.ok, true);
|
|
30
|
+
assert.equal(consumed.telegramUserId, "1");
|
|
31
|
+
const second = repo.consumeApprovedChallenge({ challengeId: challenge.id, pollToken: challenge.pollToken });
|
|
32
|
+
assert.equal(second.ok, false);
|
|
33
|
+
if (!second.ok) {
|
|
34
|
+
assert.equal(second.reason, "consumed");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
test("dashboard login callback approves or rejects selected code", async () => {
|
|
42
|
+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "responses-proxy-dashboard-login-"));
|
|
43
|
+
const dbFile = path.join(tempDir, "auth.sqlite");
|
|
44
|
+
try {
|
|
45
|
+
const repo = DashboardAuthRepository.create(dbFile);
|
|
46
|
+
const challenge = repo.createApprovalChallenge({ telegramUserIds: ["1"], ttlMs: 60_000 });
|
|
47
|
+
const handler = createCallbackHarness();
|
|
48
|
+
registerDashboardLoginCallbacks(handler.bot, {
|
|
49
|
+
telegramBotToken: "token",
|
|
50
|
+
allowedUserIds: new Set(),
|
|
51
|
+
allowedChatIds: new Set(),
|
|
52
|
+
ownerUserIds: new Set(["1"]),
|
|
53
|
+
adminUserIds: new Set(["1"]),
|
|
54
|
+
botMode: "polling",
|
|
55
|
+
proxyAdminBaseUrl: "http://127.0.0.1:8318",
|
|
56
|
+
defaultModel: "gpt-5.5",
|
|
57
|
+
publicSignupEnabled: false,
|
|
58
|
+
requireAdminApproval: true,
|
|
59
|
+
defaultCustomerRoute: "customers",
|
|
60
|
+
publicResponsesBaseUrl: "http://127.0.0.1:8318/v1",
|
|
61
|
+
proxyRequestTimeoutMs: 30_000,
|
|
62
|
+
sessionDbPath: dbFile,
|
|
63
|
+
sessionTtlMs: 900_000,
|
|
64
|
+
rateLimitWindowMs: 60_000,
|
|
65
|
+
rateLimitMaxRequests: 12,
|
|
66
|
+
logLevel: "info",
|
|
67
|
+
}, repo);
|
|
68
|
+
const approved = handler.find(`v1:dashauth:${challenge.id}:${challenge.displayCode}`);
|
|
69
|
+
const approveCtx = createCallbackContext({ fromId: 1, chatId: 1, data: `v1:dashauth:${challenge.id}:${challenge.displayCode}` });
|
|
70
|
+
approveCtx.match = approved.match;
|
|
71
|
+
await approved.handler(approveCtx);
|
|
72
|
+
assert.equal(approveCtx.answeredCallbacks[0]?.text, "Dashboard login approved.");
|
|
73
|
+
assert.match(approveCtx.editedReplies[0]?.text ?? "", /approved/i);
|
|
74
|
+
const rejectedChallenge = repo.createApprovalChallenge({ telegramUserIds: ["1"], ttlMs: 60_000 });
|
|
75
|
+
const rejected = handler.find(`v1:dashauth:${rejectedChallenge.id}:00`);
|
|
76
|
+
const rejectCtx = createCallbackContext({ fromId: 1, chatId: 1, data: `v1:dashauth:${rejectedChallenge.id}:00` });
|
|
77
|
+
rejectCtx.match = rejected.match;
|
|
78
|
+
await rejected.handler(rejectCtx);
|
|
79
|
+
assert.equal(rejectCtx.answeredCallbacks[0]?.show_alert, true);
|
|
80
|
+
assert.match(rejectCtx.answeredCallbacks[0]?.text ?? "", /Wrong code/i);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
function createCallbackHarness() {
|
|
87
|
+
const callbackHandlers = [];
|
|
88
|
+
return {
|
|
89
|
+
bot: {
|
|
90
|
+
callbackQuery(pattern, handler) {
|
|
91
|
+
callbackHandlers.push({ pattern, handler });
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
find(data) {
|
|
95
|
+
for (const entry of callbackHandlers) {
|
|
96
|
+
if (typeof entry.pattern === "string") {
|
|
97
|
+
if (entry.pattern === data) {
|
|
98
|
+
return { handler: entry.handler, match: [data] };
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const match = data.match(entry.pattern);
|
|
103
|
+
if (match) {
|
|
104
|
+
return { handler: entry.handler, match };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
assert.fail(`No callback handler for ${data}`);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function createCallbackContext(input) {
|
|
112
|
+
const answeredCallbacks = [];
|
|
113
|
+
const editedReplies = [];
|
|
114
|
+
return {
|
|
115
|
+
from: { id: input.fromId },
|
|
116
|
+
chat: { id: input.chatId, type: "private" },
|
|
117
|
+
callbackQuery: { data: input.data, message: { message_id: 1, chat: { id: input.chatId, type: "private" } } },
|
|
118
|
+
answeredCallbacks,
|
|
119
|
+
editedReplies,
|
|
120
|
+
answerCallbackQuery(payload) {
|
|
121
|
+
answeredCallbacks.push(payload ?? {});
|
|
122
|
+
return Promise.resolve();
|
|
123
|
+
},
|
|
124
|
+
editMessageText(text) {
|
|
125
|
+
editedReplies.push({ text });
|
|
126
|
+
return Promise.resolve();
|
|
127
|
+
},
|
|
128
|
+
reply(text) {
|
|
129
|
+
editedReplies.push({ text });
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { DashboardAuthRepository } from "./dashboard-auth.js";
|
|
9
|
+
const repoRoot = process.cwd();
|
|
10
|
+
const distClientIndexPath = path.join(repoRoot, "dist", "client", "index.html");
|
|
11
|
+
const distClientIndexHtml = readFileSync(distClientIndexPath, "utf8");
|
|
12
|
+
const builtReactAssetPath = extractBuiltReactAssetPath(distClientIndexHtml);
|
|
13
|
+
test("dashboard serving smoke coverage", { concurrency: false }, async (t) => {
|
|
14
|
+
await t.test("react mode serves dashboard, preserves backend routes, and logs mode", async () => {
|
|
15
|
+
const server = await startDashboardServer();
|
|
16
|
+
try {
|
|
17
|
+
assert.match(server.output, /Dashboard UI: react \(serving .*dist\/client\)/);
|
|
18
|
+
const root = await fetchText(`${server.baseUrl}/`);
|
|
19
|
+
assert.equal(root.response.status, 200);
|
|
20
|
+
assert.equal(root.response.headers.get("cache-control"), "no-cache");
|
|
21
|
+
assert.match(root.text, /Responses Proxy React Shell|<div id="root"><\/div>/);
|
|
22
|
+
const favicon = await fetchText(`${server.baseUrl}/favicon.svg`);
|
|
23
|
+
assert.equal(favicon.response.status, 200);
|
|
24
|
+
assert.equal(favicon.response.headers.get("cache-control"), "no-cache");
|
|
25
|
+
assert.match(favicon.response.headers.get("content-type") ?? "", /image\/svg\+xml/);
|
|
26
|
+
const health = await fetchJson(`${server.baseUrl}/health`);
|
|
27
|
+
assert.equal(health.response.status, 200);
|
|
28
|
+
assert.match(health.response.headers.get("content-type") ?? "", /application\/json/);
|
|
29
|
+
assert.equal(health.body.ok, true);
|
|
30
|
+
const protectedBeforeLogin = await fetchJson(`${server.baseUrl}/api/providers`);
|
|
31
|
+
assert.equal(protectedBeforeLogin.response.status, 401);
|
|
32
|
+
const approvalRequest = await fetchJson(`${server.baseUrl}/api/dashboard-auth/request-approval`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
});
|
|
35
|
+
assert.equal(approvalRequest.response.status, 200);
|
|
36
|
+
assert.equal(typeof approvalRequest.body.debugApprovalCode, "string");
|
|
37
|
+
assert.match(String(approvalRequest.body.displayCode), /^\d{2}$/);
|
|
38
|
+
const dashboardAuth = DashboardAuthRepository.create(server.customerDbFile);
|
|
39
|
+
const resolved = dashboardAuth.resolveApprovalChoice({
|
|
40
|
+
challengeId: approvalRequest.body.challengeId,
|
|
41
|
+
telegramUserId: "1",
|
|
42
|
+
selectedCode: approvalRequest.body.debugApprovalCode,
|
|
43
|
+
});
|
|
44
|
+
assert.equal(resolved.ok, true);
|
|
45
|
+
const poll = await fetchJson(`${server.baseUrl}/api/dashboard-auth/approval-status?challengeId=${encodeURIComponent(approvalRequest.body.challengeId)}&pollToken=${encodeURIComponent(approvalRequest.body.pollToken)}`);
|
|
46
|
+
assert.equal(poll.response.status, 200);
|
|
47
|
+
assert.equal(poll.body.status, "approved");
|
|
48
|
+
const approvedSession = poll.body.session;
|
|
49
|
+
assert.equal(typeof approvedSession?.telegramUserId, "string");
|
|
50
|
+
const session = DashboardAuthRepository.create(server.customerDbFile).createSession({
|
|
51
|
+
telegramUserId: approvedSession?.telegramUserId,
|
|
52
|
+
ttlMs: 60_000,
|
|
53
|
+
});
|
|
54
|
+
const sessionCookie = `responses_proxy_dashboard_session=${encodeURIComponent(session.token)}`;
|
|
55
|
+
const providers = await fetchJson(`${server.baseUrl}/api/providers`, {
|
|
56
|
+
headers: { Cookie: sessionCookie },
|
|
57
|
+
});
|
|
58
|
+
assert.equal(providers.response.status, 200);
|
|
59
|
+
assert.match(providers.response.headers.get("content-type") ?? "", /application\/json/);
|
|
60
|
+
assert.equal(typeof providers.body, "object");
|
|
61
|
+
assert.doesNotMatch(JSON.stringify(providers.body), /Responses Proxy React Shell/);
|
|
62
|
+
const authCallback = await fetchText(`${server.baseUrl}/auth/chatgpt/callback`);
|
|
63
|
+
assert.notEqual(authCallback.response.status, 404);
|
|
64
|
+
assert.doesNotMatch(authCallback.text, /Responses Proxy React Shell/);
|
|
65
|
+
assert.match(authCallback.text, /ChatGPT OAuth/);
|
|
66
|
+
const v1Models = await fetchText(`${server.baseUrl}/v1/models`);
|
|
67
|
+
assert.notEqual(v1Models.response.status, 200);
|
|
68
|
+
assert.doesNotMatch(v1Models.text, /Responses Proxy React Shell/);
|
|
69
|
+
assert.match(v1Models.response.headers.get("content-type") ?? "", /application\/json/);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await server.stop();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
await t.test("legacy dashboard routes are removed", async () => {
|
|
76
|
+
const server = await startDashboardServer();
|
|
77
|
+
try {
|
|
78
|
+
const legacy = await fetchText(`${server.baseUrl}/legacy`);
|
|
79
|
+
assert.equal(legacy.response.status, 404);
|
|
80
|
+
assert.match(legacy.response.headers.get("content-type") ?? "", /application\/json/);
|
|
81
|
+
const legacyAsset = await fetchText(`${server.baseUrl}/legacy/app.js`);
|
|
82
|
+
assert.equal(legacyAsset.response.status, 404);
|
|
83
|
+
assert.match(legacyAsset.response.headers.get("content-type") ?? "", /application\/json/);
|
|
84
|
+
const rootAsset = await fetchText(`${server.baseUrl}/app.js`);
|
|
85
|
+
assert.equal(rootAsset.response.status, 404);
|
|
86
|
+
assert.match(rootAsset.response.headers.get("content-type") ?? "", /application\/json/);
|
|
87
|
+
const health = await fetchJson(`${server.baseUrl}/health`);
|
|
88
|
+
assert.equal(health.response.status, 200);
|
|
89
|
+
assert.equal(health.body.ok, true);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await server.stop();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
await t.test("static assets use expected cache policy and block traversal", async () => {
|
|
96
|
+
const server = await startDashboardServer();
|
|
97
|
+
try {
|
|
98
|
+
const reactAsset = await fetchText(`${server.baseUrl}${builtReactAssetPath}`);
|
|
99
|
+
assert.equal(reactAsset.response.status, 200);
|
|
100
|
+
assert.equal(reactAsset.response.headers.get("cache-control"), "public, max-age=31536000, immutable");
|
|
101
|
+
assert.match(reactAsset.response.headers.get("content-type") ?? "", /javascript|text\/css/);
|
|
102
|
+
const legacyAsset = await fetchText(`${server.baseUrl}/legacy/app.js`);
|
|
103
|
+
assert.equal(legacyAsset.response.status, 404);
|
|
104
|
+
const traversal = await fetchText(`${server.baseUrl}/assets/..%2F..%2Fserver.js`);
|
|
105
|
+
assert.equal(traversal.response.status, 404);
|
|
106
|
+
assert.match(traversal.response.headers.get("content-type") ?? "", /application\/json/);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await server.stop();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
function extractBuiltReactAssetPath(indexHtml) {
|
|
114
|
+
const match = indexHtml.match(/"(\/assets\/[^"]+\.(?:js|css))"/);
|
|
115
|
+
assert.ok(match?.[1], "Could not find built React asset reference in dist/client/index.html");
|
|
116
|
+
return match[1];
|
|
117
|
+
}
|
|
118
|
+
async function fetchText(url, init) {
|
|
119
|
+
const response = await fetch(url, init);
|
|
120
|
+
const text = await response.text();
|
|
121
|
+
return { response, text };
|
|
122
|
+
}
|
|
123
|
+
async function fetchJson(url, init) {
|
|
124
|
+
const response = await fetch(url, init);
|
|
125
|
+
const body = (await response.json().catch(() => ({})));
|
|
126
|
+
return { response, body };
|
|
127
|
+
}
|
|
128
|
+
async function startDashboardServer(extraEnv = {}) {
|
|
129
|
+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "responses-proxy-dashboard-serving-"));
|
|
130
|
+
const port = await getFreePort();
|
|
131
|
+
const child = spawn(process.execPath, ["--import", "tsx", "src/server.ts"], {
|
|
132
|
+
cwd: repoRoot,
|
|
133
|
+
env: {
|
|
134
|
+
...process.env,
|
|
135
|
+
PORT: String(port),
|
|
136
|
+
HOST: "127.0.0.1",
|
|
137
|
+
UPSTREAM_BASE_URL: "https://api.openai.com",
|
|
138
|
+
APP_DB_PATH: path.join(tempDir, "app.sqlite"),
|
|
139
|
+
CUSTOMER_KEY_DB_PATH: path.join(tempDir, "customer.sqlite"),
|
|
140
|
+
SESSION_LOG_DIR: path.join(tempDir, "sessions"),
|
|
141
|
+
PROVIDER_USAGE_CHECK_ENABLED: "false",
|
|
142
|
+
CHATGPT_OAUTH_ENABLED: "false",
|
|
143
|
+
TELEGRAM_BOT_TOKEN: "test-dashboard-bot-token",
|
|
144
|
+
TELEGRAM_OWNER_USER_IDS: "1",
|
|
145
|
+
TELEGRAM_ADMIN_USER_IDS: "1",
|
|
146
|
+
LOG_LEVEL: "info",
|
|
147
|
+
...extraEnv,
|
|
148
|
+
},
|
|
149
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
150
|
+
});
|
|
151
|
+
let output = "";
|
|
152
|
+
child.stdout.on("data", (chunk) => {
|
|
153
|
+
output += chunk.toString();
|
|
154
|
+
});
|
|
155
|
+
child.stderr.on("data", (chunk) => {
|
|
156
|
+
output += chunk.toString();
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
await waitForServer(`http://127.0.0.1:${port}/health`, child);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
child.kill("SIGTERM");
|
|
163
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
164
|
+
throw new Error(`Server failed to start.\n${output}\n${String(error)}`);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
168
|
+
customerDbFile: path.join(tempDir, "customer.sqlite"),
|
|
169
|
+
get output() {
|
|
170
|
+
return output;
|
|
171
|
+
},
|
|
172
|
+
async stop() {
|
|
173
|
+
await stopChild(child);
|
|
174
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function waitForServer(url, child, timeoutMs = 15_000) {
|
|
179
|
+
const startedAt = Date.now();
|
|
180
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
181
|
+
if (child.exitCode !== null) {
|
|
182
|
+
throw new Error(`server exited early with code ${child.exitCode}`);
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const response = await fetch(url);
|
|
186
|
+
if (response.ok) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Retry until the timeout expires.
|
|
192
|
+
}
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
194
|
+
}
|
|
195
|
+
throw new Error(`server did not become ready within ${timeoutMs}ms`);
|
|
196
|
+
}
|
|
197
|
+
async function stopChild(child) {
|
|
198
|
+
if (child.exitCode !== null) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
child.kill("SIGTERM");
|
|
202
|
+
await new Promise((resolve) => {
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
if (child.exitCode === null) {
|
|
205
|
+
child.kill("SIGKILL");
|
|
206
|
+
}
|
|
207
|
+
}, 3_000);
|
|
208
|
+
child.once("exit", () => {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
resolve();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
async function getFreePort() {
|
|
215
|
+
return await new Promise((resolve, reject) => {
|
|
216
|
+
const server = createServer();
|
|
217
|
+
server.listen(0, "127.0.0.1", () => {
|
|
218
|
+
const address = server.address();
|
|
219
|
+
if (!address || typeof address === "string") {
|
|
220
|
+
server.close();
|
|
221
|
+
reject(new Error("Could not resolve a free port"));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const { port } = address;
|
|
225
|
+
server.close((error) => {
|
|
226
|
+
if (error) {
|
|
227
|
+
reject(error);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
resolve(port);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
server.on("error", reject);
|
|
234
|
+
});
|
|
235
|
+
}
|