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,480 @@
|
|
|
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 { AuditLogRepository } from "./audit-log.js";
|
|
7
|
+
import { BillingRepository } from "./billing.js";
|
|
8
|
+
import { CustomerKeyRepository } from "./customer-keys.js";
|
|
9
|
+
import { registerApiKeyCommand } from "./telegram-bot/commands/apikey.js";
|
|
10
|
+
import { registerGrantCommand } from "./telegram-bot/commands/grant.js";
|
|
11
|
+
import { registerRenewUserCommand } from "./telegram-bot/commands/renew-user.js";
|
|
12
|
+
import { BotIdentityRepository } from "./telegram-bot/bot-identity-repository.js";
|
|
13
|
+
import { CustomerWorkspaceRepository } from "./telegram-bot/customer-workspace-repository.js";
|
|
14
|
+
import { grantCustomerAccess } from "./telegram-bot/grants.js";
|
|
15
|
+
function createConfig(overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
telegramBotToken: "token",
|
|
18
|
+
allowedUserIds: new Set(),
|
|
19
|
+
allowedChatIds: new Set(),
|
|
20
|
+
ownerUserIds: new Set(["1"]),
|
|
21
|
+
adminUserIds: new Set(),
|
|
22
|
+
botMode: "polling",
|
|
23
|
+
proxyAdminBaseUrl: "http://127.0.0.1:8318",
|
|
24
|
+
defaultModel: "gpt-5.5",
|
|
25
|
+
publicSignupEnabled: true,
|
|
26
|
+
requireAdminApproval: false,
|
|
27
|
+
defaultCustomerRoute: "customers",
|
|
28
|
+
publicResponsesBaseUrl: "http://127.0.0.1:8318/v1",
|
|
29
|
+
proxyRequestTimeoutMs: 30_000,
|
|
30
|
+
sessionDbPath: ":memory:",
|
|
31
|
+
sessionTtlMs: 900_000,
|
|
32
|
+
rateLimitWindowMs: 60_000,
|
|
33
|
+
rateLimitMaxRequests: 12,
|
|
34
|
+
logLevel: "info",
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function createMockProxyClient() {
|
|
39
|
+
let routeKeys = [];
|
|
40
|
+
return {
|
|
41
|
+
client: {
|
|
42
|
+
async getClientConfigs() {
|
|
43
|
+
return {
|
|
44
|
+
clientRoutes: [{ key: "customers", apiKeys: [...routeKeys] }],
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
async setClientRouteApiKeys(input) {
|
|
48
|
+
if (input.client === "customers") {
|
|
49
|
+
routeKeys = [...input.apiKeys];
|
|
50
|
+
}
|
|
51
|
+
return { ok: true };
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function createBotHarness() {
|
|
57
|
+
const handlers = new Map();
|
|
58
|
+
const messageTextHandlers = [];
|
|
59
|
+
const callbackHandlers = [];
|
|
60
|
+
return {
|
|
61
|
+
bot: {
|
|
62
|
+
command(name, handler) {
|
|
63
|
+
handlers.set(name, handler);
|
|
64
|
+
},
|
|
65
|
+
callbackQuery(pattern, handler) {
|
|
66
|
+
callbackHandlers.push({ pattern, handler });
|
|
67
|
+
},
|
|
68
|
+
on(filter, handler) {
|
|
69
|
+
if (filter === "message:text") {
|
|
70
|
+
messageTextHandlers.push(handler);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
handler(name) {
|
|
75
|
+
const handler = handlers.get(name);
|
|
76
|
+
assert.ok(handler);
|
|
77
|
+
return handler;
|
|
78
|
+
},
|
|
79
|
+
callbackHandler(data) {
|
|
80
|
+
for (const entry of callbackHandlers) {
|
|
81
|
+
if (typeof entry.pattern === "string") {
|
|
82
|
+
if (entry.pattern === data) {
|
|
83
|
+
return { handler: entry.handler, match: [data] };
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const match = data.match(entry.pattern);
|
|
88
|
+
if (match) {
|
|
89
|
+
return { handler: entry.handler, match };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
assert.fail(`No callback handler for ${data}`);
|
|
93
|
+
},
|
|
94
|
+
async runText(ctx) {
|
|
95
|
+
let index = 0;
|
|
96
|
+
const next = async () => {
|
|
97
|
+
const handler = messageTextHandlers[index++];
|
|
98
|
+
if (handler) {
|
|
99
|
+
await handler(ctx, next);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
await next();
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function createContext(input) {
|
|
107
|
+
const replies = [];
|
|
108
|
+
const replyMarkups = [];
|
|
109
|
+
const answeredCallbacks = [];
|
|
110
|
+
const sentMessages = [];
|
|
111
|
+
const sentDocuments = [];
|
|
112
|
+
return {
|
|
113
|
+
from: { id: input.fromId, is_bot: false, first_name: "User" },
|
|
114
|
+
chat: input.chatType === "private"
|
|
115
|
+
? { id: input.chatId, type: "private", first_name: "User" }
|
|
116
|
+
: { id: input.chatId, type: "group", title: "Ops" },
|
|
117
|
+
message: {
|
|
118
|
+
message_id: 1,
|
|
119
|
+
date: 0,
|
|
120
|
+
chat: input.chatType === "private"
|
|
121
|
+
? { id: input.chatId, type: "private", first_name: "User" }
|
|
122
|
+
: { id: input.chatId, type: "group", title: "Ops" },
|
|
123
|
+
text: `/${input.command} ${input.match}`.trim(),
|
|
124
|
+
},
|
|
125
|
+
match: input.match,
|
|
126
|
+
callbackQuery: {
|
|
127
|
+
id: "callback-id",
|
|
128
|
+
from: { id: input.fromId, is_bot: false, first_name: "User" },
|
|
129
|
+
data: input.match,
|
|
130
|
+
chat_instance: "chat-instance",
|
|
131
|
+
},
|
|
132
|
+
replies,
|
|
133
|
+
replyMarkups,
|
|
134
|
+
answeredCallbacks,
|
|
135
|
+
sentMessages,
|
|
136
|
+
sentDocuments,
|
|
137
|
+
reply(text, options) {
|
|
138
|
+
replies.push(text);
|
|
139
|
+
replyMarkups.push(options?.reply_markup);
|
|
140
|
+
return Promise.resolve({});
|
|
141
|
+
},
|
|
142
|
+
answerCallbackQuery(payload) {
|
|
143
|
+
answeredCallbacks.push(payload ?? {});
|
|
144
|
+
return Promise.resolve(true);
|
|
145
|
+
},
|
|
146
|
+
api: {
|
|
147
|
+
async sendMessage(_chatId, text) {
|
|
148
|
+
sentMessages.push(text);
|
|
149
|
+
return {};
|
|
150
|
+
},
|
|
151
|
+
async sendDocument(chatId, document) {
|
|
152
|
+
sentDocuments.push({
|
|
153
|
+
chatId,
|
|
154
|
+
filename: document.filename,
|
|
155
|
+
content: document.fileData ? Buffer.from(document.fileData).toString("utf8") : "",
|
|
156
|
+
});
|
|
157
|
+
return {};
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async function withRepos(fn) {
|
|
163
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "audit-log-"));
|
|
164
|
+
try {
|
|
165
|
+
const dbFile = path.join(dir, "bot.sqlite");
|
|
166
|
+
const proxy = createMockProxyClient();
|
|
167
|
+
await fn({
|
|
168
|
+
identities: BotIdentityRepository.create(dbFile),
|
|
169
|
+
workspaces: CustomerWorkspaceRepository.create(dbFile),
|
|
170
|
+
customerKeys: CustomerKeyRepository.create(dbFile),
|
|
171
|
+
billing: BillingRepository.create(dbFile),
|
|
172
|
+
auditLog: AuditLogRepository.create(dbFile),
|
|
173
|
+
deps: {
|
|
174
|
+
config: createConfig({ sessionDbPath: dbFile }),
|
|
175
|
+
proxyClient: proxy.client,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
rmSync(dir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
test("AuditLogRepository redacts full API keys in metadata", () => {
|
|
184
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "audit-redaction-"));
|
|
185
|
+
try {
|
|
186
|
+
const repo = AuditLogRepository.create(path.join(dir, "audit.sqlite"));
|
|
187
|
+
repo.record({
|
|
188
|
+
event: "api_key.revealed",
|
|
189
|
+
actor: { type: "admin", id: "1" },
|
|
190
|
+
subjectType: "customer_api_key",
|
|
191
|
+
subjectId: "key-1",
|
|
192
|
+
metadata: {
|
|
193
|
+
apiKey: "sk-customer-secret-value",
|
|
194
|
+
keyPreview: "sk-customer-...value",
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
const event = repo.listEvents({ event: "api_key.revealed", limit: 1 })[0];
|
|
198
|
+
assert.ok(event);
|
|
199
|
+
assert.equal(event.metadata.apiKey, "[redacted]");
|
|
200
|
+
assert.equal(event.metadata.keyPreview, "sk-customer-...value");
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
rmSync(dir, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
test("grantCustomerAccess writes audit events for lifecycle mutations", async () => {
|
|
207
|
+
await withRepos(async ({ identities, workspaces, customerKeys, billing, auditLog, deps }) => {
|
|
208
|
+
await grantCustomerAccess({
|
|
209
|
+
telegramUserId: "1283361952",
|
|
210
|
+
planId: "basic",
|
|
211
|
+
days: 30,
|
|
212
|
+
defaultClientRoute: "customers",
|
|
213
|
+
identities,
|
|
214
|
+
workspaces,
|
|
215
|
+
customerKeys,
|
|
216
|
+
billing,
|
|
217
|
+
proxyClient: deps.proxyClient,
|
|
218
|
+
auditLog,
|
|
219
|
+
actor: { type: "admin", id: "1" },
|
|
220
|
+
});
|
|
221
|
+
const events = auditLog.listEvents();
|
|
222
|
+
const names = new Set(events.map((event) => event.event));
|
|
223
|
+
assert.equal(names.has("user.created"), true);
|
|
224
|
+
assert.equal(names.has("workspace.created"), true);
|
|
225
|
+
assert.equal(names.has("api_key.created"), true);
|
|
226
|
+
assert.equal(names.has("subscription.granted"), true);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
test("apikey issue writes redacted reveal audit metadata", async () => {
|
|
230
|
+
await withRepos(async ({ workspaces, customerKeys, billing, auditLog, deps }) => {
|
|
231
|
+
const workspace = workspaces.ensureDefaultWorkspace({
|
|
232
|
+
ownerTelegramUserId: "42",
|
|
233
|
+
defaultClientRoute: "customers",
|
|
234
|
+
status: "active",
|
|
235
|
+
});
|
|
236
|
+
billing.grantSubscription({
|
|
237
|
+
workspaceId: workspace.id,
|
|
238
|
+
planId: "basic",
|
|
239
|
+
days: 30,
|
|
240
|
+
});
|
|
241
|
+
const harness = createBotHarness();
|
|
242
|
+
registerApiKeyCommand(harness.bot, deps, customerKeys, workspaces, billing, auditLog);
|
|
243
|
+
const ctx = createContext({
|
|
244
|
+
command: "apikey",
|
|
245
|
+
fromId: 1,
|
|
246
|
+
chatId: 1,
|
|
247
|
+
chatType: "private",
|
|
248
|
+
match: "issue 42 customers",
|
|
249
|
+
});
|
|
250
|
+
await harness.handler("apikey")(ctx);
|
|
251
|
+
const event = auditLog.listEvents({ event: "api_key.revealed", limit: 1 })[0];
|
|
252
|
+
assert.ok(event);
|
|
253
|
+
assert.equal(event.metadata.apiKey, "[redacted]");
|
|
254
|
+
assert.equal(customerKeys.getActiveKeyForUser("42")?.status, "active");
|
|
255
|
+
assert.equal(ctx.replies[0]?.includes("api_key:"), true);
|
|
256
|
+
const customerCtx = createContext({
|
|
257
|
+
command: "apikey",
|
|
258
|
+
fromId: 42,
|
|
259
|
+
chatId: 42,
|
|
260
|
+
chatType: "private",
|
|
261
|
+
match: "",
|
|
262
|
+
});
|
|
263
|
+
await harness.handler("apikey")(customerCtx);
|
|
264
|
+
assert.equal(customerCtx.sentMessages[0]?.includes("api_key:"), false);
|
|
265
|
+
assert.equal(customerCtx.sentMessages[0]?.includes("curl -fsSL"), true);
|
|
266
|
+
assert.deepEqual(customerCtx.sentDocuments.map((document) => document.filename), ["config.toml", "auth.json"]);
|
|
267
|
+
assert.match(customerCtx.sentDocuments[0]?.content ?? "", /base_url = "http:\/\/127\.0\.0\.1:8318\/v1"/);
|
|
268
|
+
assert.match(customerCtx.sentDocuments[0]?.content ?? "", /api_key = "sk-/);
|
|
269
|
+
assert.equal(customerCtx.sentMessages[0]?.includes("full_key: unavailable_for_legacy_key"), false);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
test("admin can list, show, suspend, activate, and rotate customer API keys", async () => {
|
|
273
|
+
await withRepos(async ({ workspaces, customerKeys, billing, auditLog, deps }) => {
|
|
274
|
+
const workspace = workspaces.ensureDefaultWorkspace({
|
|
275
|
+
ownerTelegramUserId: "42",
|
|
276
|
+
defaultClientRoute: "customers",
|
|
277
|
+
status: "active",
|
|
278
|
+
});
|
|
279
|
+
billing.grantSubscription({
|
|
280
|
+
workspaceId: workspace.id,
|
|
281
|
+
planId: "basic",
|
|
282
|
+
days: 30,
|
|
283
|
+
});
|
|
284
|
+
const harness = createBotHarness();
|
|
285
|
+
registerApiKeyCommand(harness.bot, deps, customerKeys, workspaces, billing, auditLog);
|
|
286
|
+
const issueCtx = createContext({
|
|
287
|
+
command: "apikey",
|
|
288
|
+
fromId: 1,
|
|
289
|
+
chatId: 1,
|
|
290
|
+
chatType: "private",
|
|
291
|
+
match: "issue 42 customers",
|
|
292
|
+
});
|
|
293
|
+
await harness.handler("apikey")(issueCtx);
|
|
294
|
+
const firstKey = customerKeys.getActiveKeyForUser("42");
|
|
295
|
+
assert.ok(firstKey);
|
|
296
|
+
const listCtx = createContext({
|
|
297
|
+
command: "apikey",
|
|
298
|
+
fromId: 1,
|
|
299
|
+
chatId: 1,
|
|
300
|
+
chatType: "private",
|
|
301
|
+
match: "list 42",
|
|
302
|
+
});
|
|
303
|
+
await harness.handler("apikey")(listCtx);
|
|
304
|
+
assert.equal(listCtx.replies[0]?.includes(firstKey.id), true);
|
|
305
|
+
assert.equal(listCtx.replies[0]?.includes("status=active"), true);
|
|
306
|
+
assert.ok(listCtx.replyMarkups[0]);
|
|
307
|
+
const listKeyboard = JSON.parse(JSON.stringify(listCtx.replyMarkups[0]));
|
|
308
|
+
assert.equal(listKeyboard.inline_keyboard?.[0]?.[0]?.copy_text?.text, firstKey.id);
|
|
309
|
+
const pasteCtx = createContext({
|
|
310
|
+
command: "message",
|
|
311
|
+
fromId: 1,
|
|
312
|
+
chatId: 1,
|
|
313
|
+
chatType: "private",
|
|
314
|
+
match: firstKey.id,
|
|
315
|
+
});
|
|
316
|
+
pasteCtx.message.text = firstKey.id;
|
|
317
|
+
await harness.runText(pasteCtx);
|
|
318
|
+
assert.equal(pasteCtx.replies[0]?.includes("Customer API key"), true);
|
|
319
|
+
assert.equal(pasteCtx.replies[0]?.includes(`key_id: ${firstKey.id}`), true);
|
|
320
|
+
assert.ok(pasteCtx.replyMarkups[0]);
|
|
321
|
+
const callbackShow = harness.callbackHandler(`v1:apikey:show:${firstKey.id}`);
|
|
322
|
+
const callbackShowCtx = createContext({
|
|
323
|
+
command: "callback",
|
|
324
|
+
fromId: 1,
|
|
325
|
+
chatId: 1,
|
|
326
|
+
chatType: "private",
|
|
327
|
+
match: `v1:apikey:show:${firstKey.id}`,
|
|
328
|
+
});
|
|
329
|
+
callbackShowCtx.match = callbackShow.match;
|
|
330
|
+
await callbackShow.handler(callbackShowCtx);
|
|
331
|
+
assert.equal(callbackShowCtx.answeredCallbacks[0]?.text, "Key details loaded");
|
|
332
|
+
assert.equal(callbackShowCtx.replies[0]?.includes("api_key:"), true);
|
|
333
|
+
assert.ok(callbackShowCtx.replyMarkups[0]);
|
|
334
|
+
const showCtx = createContext({
|
|
335
|
+
command: "apikey",
|
|
336
|
+
fromId: 1,
|
|
337
|
+
chatId: 1,
|
|
338
|
+
chatType: "private",
|
|
339
|
+
match: `show ${firstKey.id}`,
|
|
340
|
+
});
|
|
341
|
+
await harness.handler("apikey")(showCtx);
|
|
342
|
+
assert.equal(showCtx.replies[0]?.includes("api_key:"), true);
|
|
343
|
+
assert.equal(auditLog.listEvents({ event: "api_key.revealed", limit: 1 })[0]?.metadata.apiKey, "[redacted]");
|
|
344
|
+
const suspendFound = harness.callbackHandler(`v1:apikey:suspend:${firstKey.id}`);
|
|
345
|
+
const suspendCtx = createContext({
|
|
346
|
+
command: "callback",
|
|
347
|
+
fromId: 1,
|
|
348
|
+
chatId: 1,
|
|
349
|
+
chatType: "private",
|
|
350
|
+
match: `v1:apikey:suspend:${firstKey.id}`,
|
|
351
|
+
});
|
|
352
|
+
suspendCtx.match = suspendFound.match;
|
|
353
|
+
await suspendFound.handler(suspendCtx);
|
|
354
|
+
assert.equal(customerKeys.getById(firstKey.id)?.status, "suspended");
|
|
355
|
+
assert.equal(suspendCtx.answeredCallbacks[0]?.text, "Key suspended");
|
|
356
|
+
assert.equal(suspendCtx.replies[0]?.includes("proxy_sync: removed_from_route"), true);
|
|
357
|
+
const activateFound = harness.callbackHandler(`v1:apikey:activate:${firstKey.id}`);
|
|
358
|
+
const activateCtx = createContext({
|
|
359
|
+
command: "callback",
|
|
360
|
+
fromId: 1,
|
|
361
|
+
chatId: 1,
|
|
362
|
+
chatType: "private",
|
|
363
|
+
match: `v1:apikey:activate:${firstKey.id}`,
|
|
364
|
+
});
|
|
365
|
+
activateCtx.match = activateFound.match;
|
|
366
|
+
await activateFound.handler(activateCtx);
|
|
367
|
+
assert.equal(customerKeys.getById(firstKey.id)?.status, "active");
|
|
368
|
+
assert.equal(activateCtx.answeredCallbacks[0]?.text, "Key activated");
|
|
369
|
+
assert.equal(activateCtx.replies[0]?.includes("proxy_sync: added_to_route"), true);
|
|
370
|
+
const rotateFound = harness.callbackHandler(`v1:apikey:rotate:${firstKey.id}`);
|
|
371
|
+
const rotateCtx = createContext({
|
|
372
|
+
command: "callback",
|
|
373
|
+
fromId: 1,
|
|
374
|
+
chatId: 1,
|
|
375
|
+
chatType: "private",
|
|
376
|
+
match: `v1:apikey:rotate:${firstKey.id}`,
|
|
377
|
+
});
|
|
378
|
+
rotateCtx.match = rotateFound.match;
|
|
379
|
+
await rotateFound.handler(rotateCtx);
|
|
380
|
+
const nextKey = customerKeys.getActiveKeyForUser("42");
|
|
381
|
+
assert.ok(nextKey);
|
|
382
|
+
assert.notEqual(nextKey.id, firstKey.id);
|
|
383
|
+
assert.equal(customerKeys.getById(firstKey.id)?.status, "revoked");
|
|
384
|
+
assert.equal(rotateCtx.answeredCallbacks[0]?.text, "Key rotated");
|
|
385
|
+
assert.equal(rotateCtx.replies[0]?.includes(`new_key_id: ${nextKey.id}`), true);
|
|
386
|
+
assert.equal(rotateCtx.replies[0]?.includes("api_key:"), true);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
test("grant in admin group does not print the full key in the group", async () => {
|
|
390
|
+
await withRepos(async ({ identities, workspaces, customerKeys, billing, auditLog, deps }) => {
|
|
391
|
+
const harness = createBotHarness();
|
|
392
|
+
registerGrantCommand(harness.bot, deps, identities, workspaces, customerKeys, billing, auditLog);
|
|
393
|
+
const ctx = createContext({
|
|
394
|
+
command: "grant",
|
|
395
|
+
fromId: 1,
|
|
396
|
+
chatId: -1001,
|
|
397
|
+
chatType: "group",
|
|
398
|
+
match: "42 basic 30",
|
|
399
|
+
});
|
|
400
|
+
await harness.handler("grant")(ctx);
|
|
401
|
+
assert.equal(ctx.replies[0]?.includes("api_key:"), false);
|
|
402
|
+
assert.equal(ctx.replies[0]?.includes("Full key: shown only in private admin chat"), true);
|
|
403
|
+
const revealEvent = auditLog.listEvents({ event: "api_key.revealed", limit: 1 })[0];
|
|
404
|
+
assert.ok(revealEvent);
|
|
405
|
+
assert.equal(revealEvent.metadata.audience, "customer_private_chat");
|
|
406
|
+
assert.equal(revealEvent.metadata.apiKey, "[redacted]");
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
test("apikey issue in admin group without explicit key is rejected before creating a secret", async () => {
|
|
410
|
+
await withRepos(async ({ workspaces, customerKeys, billing, auditLog, deps }) => {
|
|
411
|
+
const workspace = workspaces.ensureDefaultWorkspace({
|
|
412
|
+
ownerTelegramUserId: "42",
|
|
413
|
+
defaultClientRoute: "customers",
|
|
414
|
+
status: "active",
|
|
415
|
+
});
|
|
416
|
+
billing.grantSubscription({
|
|
417
|
+
workspaceId: workspace.id,
|
|
418
|
+
planId: "basic",
|
|
419
|
+
days: 30,
|
|
420
|
+
});
|
|
421
|
+
const harness = createBotHarness();
|
|
422
|
+
registerApiKeyCommand(harness.bot, deps, customerKeys, workspaces, billing, auditLog);
|
|
423
|
+
const ctx = createContext({
|
|
424
|
+
command: "apikey",
|
|
425
|
+
fromId: 1,
|
|
426
|
+
chatId: -1001,
|
|
427
|
+
chatType: "group",
|
|
428
|
+
match: "issue 42 customers",
|
|
429
|
+
});
|
|
430
|
+
await harness.handler("apikey")(ctx);
|
|
431
|
+
assert.equal(ctx.replies[0], "Run /apikey issue in a private admin chat when generating a new key, or provide an explicit apiKey.");
|
|
432
|
+
assert.equal(customerKeys.listKeysByWorkspace(workspace.id).length, 0);
|
|
433
|
+
assert.equal(auditLog.listEvents({ event: "api_key.revealed", limit: 1 }).length, 0);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
test("apikey issue respects the workspace maxApiKeys limit", async () => {
|
|
437
|
+
await withRepos(async ({ identities, workspaces, customerKeys, billing, auditLog, deps }) => {
|
|
438
|
+
await grantCustomerAccess({
|
|
439
|
+
telegramUserId: "42",
|
|
440
|
+
planId: "basic",
|
|
441
|
+
days: 30,
|
|
442
|
+
defaultClientRoute: "customers",
|
|
443
|
+
identities,
|
|
444
|
+
workspaces,
|
|
445
|
+
customerKeys,
|
|
446
|
+
billing,
|
|
447
|
+
proxyClient: deps.proxyClient,
|
|
448
|
+
auditLog,
|
|
449
|
+
actor: { type: "admin", id: "1" },
|
|
450
|
+
});
|
|
451
|
+
const harness = createBotHarness();
|
|
452
|
+
registerApiKeyCommand(harness.bot, deps, customerKeys, workspaces, billing, auditLog);
|
|
453
|
+
const ctx = createContext({
|
|
454
|
+
command: "apikey",
|
|
455
|
+
fromId: 1,
|
|
456
|
+
chatId: 1,
|
|
457
|
+
chatType: "private",
|
|
458
|
+
match: "issue 42 customers",
|
|
459
|
+
});
|
|
460
|
+
await harness.handler("apikey")(ctx);
|
|
461
|
+
assert.equal(ctx.replies[0], "API key limit reached for this workspace (1/1). Revoke or rotate an existing key first.");
|
|
462
|
+
assert.equal(customerKeys.listKeysByWorkspace(workspaces.getDefaultWorkspace("42").id).length, 1);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
test("failed authorization does not write success audit events", async () => {
|
|
466
|
+
await withRepos(async ({ identities, workspaces, customerKeys, billing, auditLog, deps }) => {
|
|
467
|
+
const harness = createBotHarness();
|
|
468
|
+
registerRenewUserCommand(harness.bot, deps, identities, workspaces, customerKeys, billing, auditLog);
|
|
469
|
+
const ctx = createContext({
|
|
470
|
+
command: "renewuser",
|
|
471
|
+
fromId: 2,
|
|
472
|
+
chatId: 2,
|
|
473
|
+
chatType: "private",
|
|
474
|
+
match: "42 basic 30",
|
|
475
|
+
});
|
|
476
|
+
await harness.handler("renewuser")(ctx);
|
|
477
|
+
assert.equal(ctx.replies[0], "Only admins can renew customer access.");
|
|
478
|
+
assert.equal(auditLog.listEvents().length, 0);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export async function runBillingExpiration(args) {
|
|
2
|
+
const now = args.now ?? new Date();
|
|
3
|
+
const expiredEntitlements = args.billing.expireEntitlements(now);
|
|
4
|
+
let suspendedWorkspaces = 0;
|
|
5
|
+
let suspendedKeys = 0;
|
|
6
|
+
let notificationsSent = 0;
|
|
7
|
+
let notificationsFailed = 0;
|
|
8
|
+
for (const workspace of args.workspaces.listWorkspaces()) {
|
|
9
|
+
const activeEntitlement = args.billing.getActiveEntitlementForWorkspace(workspace.id, now);
|
|
10
|
+
if (activeEntitlement) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const latestEntitlement = args.billing.getLatestEntitlementForWorkspace(workspace.id);
|
|
14
|
+
if (!latestEntitlement) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
let workspaceChanged = false;
|
|
18
|
+
if (workspace.status !== "suspended") {
|
|
19
|
+
args.workspaces.setStatus(workspace.id, "suspended", now);
|
|
20
|
+
suspendedWorkspaces += 1;
|
|
21
|
+
workspaceChanged = true;
|
|
22
|
+
}
|
|
23
|
+
let workspaceSuspendedKeys = 0;
|
|
24
|
+
for (const key of args.customerKeys.listKeysByWorkspace(workspace.id)) {
|
|
25
|
+
if (key.status !== "active") {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
args.customerKeys.setStatus(key.id, "suspended", { now });
|
|
29
|
+
args.auditLog?.record({
|
|
30
|
+
event: "api_key.suspended",
|
|
31
|
+
actor: { type: "system", id: "billing-expiration-worker" },
|
|
32
|
+
subjectType: "customer_api_key",
|
|
33
|
+
subjectId: key.id,
|
|
34
|
+
metadata: {
|
|
35
|
+
workspaceId: workspace.id,
|
|
36
|
+
telegramUserId: workspace.ownerTelegramUserId,
|
|
37
|
+
keyPreview: key.apiKeyPreview,
|
|
38
|
+
reason: "no_active_entitlement",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
suspendedKeys += 1;
|
|
42
|
+
workspaceSuspendedKeys += 1;
|
|
43
|
+
}
|
|
44
|
+
if ((workspaceChanged || workspaceSuspendedKeys > 0) && args.notifyCustomer) {
|
|
45
|
+
try {
|
|
46
|
+
await args.notifyCustomer({
|
|
47
|
+
telegramUserId: workspace.ownerTelegramUserId,
|
|
48
|
+
text: [
|
|
49
|
+
"Your Responses access has expired.",
|
|
50
|
+
`workspace_id: ${workspace.id}`,
|
|
51
|
+
`client_route: ${workspace.defaultClientRoute}`,
|
|
52
|
+
`expired_at: ${latestEntitlement.validUntil}`,
|
|
53
|
+
"Contact admin to renew your plan.",
|
|
54
|
+
].join("\n"),
|
|
55
|
+
});
|
|
56
|
+
notificationsSent += 1;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
notificationsFailed += 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
expiredEntitlements,
|
|
65
|
+
suspendedWorkspaces,
|
|
66
|
+
suspendedKeys,
|
|
67
|
+
notificationsSent,
|
|
68
|
+
notificationsFailed,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
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 { BillingRepository } from "./billing.js";
|
|
7
|
+
import { runBillingExpiration } from "./billing-expiration.js";
|
|
8
|
+
import { CustomerKeyRepository } from "./customer-keys.js";
|
|
9
|
+
import { CustomerWorkspaceRepository } from "./telegram-bot/customer-workspace-repository.js";
|
|
10
|
+
async function withRepos(fn) {
|
|
11
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "billing-expiration-"));
|
|
12
|
+
try {
|
|
13
|
+
const dbFile = path.join(dir, "bot.sqlite");
|
|
14
|
+
await fn({
|
|
15
|
+
billing: BillingRepository.create(dbFile),
|
|
16
|
+
customerKeys: CustomerKeyRepository.create(dbFile),
|
|
17
|
+
workspaces: CustomerWorkspaceRepository.create(dbFile),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
test("expired subscription becomes expired and active key becomes suspended", async () => {
|
|
25
|
+
await withRepos(async ({ billing, customerKeys, workspaces }) => {
|
|
26
|
+
const workspace = workspaces.ensureDefaultWorkspace({
|
|
27
|
+
ownerTelegramUserId: "42",
|
|
28
|
+
defaultClientRoute: "customers",
|
|
29
|
+
status: "active",
|
|
30
|
+
now: new Date("2026-04-20T00:00:00.000Z"),
|
|
31
|
+
});
|
|
32
|
+
const granted = billing.grantSubscription({
|
|
33
|
+
workspaceId: workspace.id,
|
|
34
|
+
planId: "basic",
|
|
35
|
+
days: 1,
|
|
36
|
+
now: new Date("2026-04-20T00:00:00.000Z"),
|
|
37
|
+
});
|
|
38
|
+
const created = customerKeys.createKey({
|
|
39
|
+
workspaceId: workspace.id,
|
|
40
|
+
telegramUserId: "42",
|
|
41
|
+
clientRoute: "customers",
|
|
42
|
+
now: new Date("2026-04-20T00:00:00.000Z"),
|
|
43
|
+
});
|
|
44
|
+
const notifications = [];
|
|
45
|
+
const summary = await runBillingExpiration({
|
|
46
|
+
billing,
|
|
47
|
+
customerKeys,
|
|
48
|
+
workspaces,
|
|
49
|
+
now: new Date("2026-04-22T00:00:00.000Z"),
|
|
50
|
+
notifyCustomer: async (input) => {
|
|
51
|
+
notifications.push(input);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
assert.equal(summary.expiredEntitlements, 1);
|
|
55
|
+
assert.equal(summary.suspendedWorkspaces, 1);
|
|
56
|
+
assert.equal(summary.suspendedKeys, 1);
|
|
57
|
+
assert.equal(summary.notificationsSent, 1);
|
|
58
|
+
assert.equal(billing.getLatestSubscriptionForWorkspace(workspace.id)?.status, "expired");
|
|
59
|
+
assert.equal(billing.getLatestEntitlementForWorkspace(workspace.id)?.status, "expired");
|
|
60
|
+
assert.equal(workspaces.getById(workspace.id)?.status, "suspended");
|
|
61
|
+
assert.equal(customerKeys.getById(created.record.id)?.status, "suspended");
|
|
62
|
+
assert.equal(notifications[0]?.telegramUserId, "42");
|
|
63
|
+
assert.equal(notifications[0]?.text.includes(granted.entitlement.validUntil), true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
test("running billing expiration twice is idempotent", async () => {
|
|
67
|
+
await withRepos(async ({ billing, customerKeys, workspaces }) => {
|
|
68
|
+
const workspace = workspaces.ensureDefaultWorkspace({
|
|
69
|
+
ownerTelegramUserId: "77",
|
|
70
|
+
defaultClientRoute: "customers",
|
|
71
|
+
status: "active",
|
|
72
|
+
now: new Date("2026-04-20T00:00:00.000Z"),
|
|
73
|
+
});
|
|
74
|
+
customerKeys.createKey({
|
|
75
|
+
workspaceId: workspace.id,
|
|
76
|
+
telegramUserId: "77",
|
|
77
|
+
clientRoute: "customers",
|
|
78
|
+
now: new Date("2026-04-20T00:00:00.000Z"),
|
|
79
|
+
});
|
|
80
|
+
billing.grantSubscription({
|
|
81
|
+
workspaceId: workspace.id,
|
|
82
|
+
planId: "basic",
|
|
83
|
+
days: 1,
|
|
84
|
+
now: new Date("2026-04-20T00:00:00.000Z"),
|
|
85
|
+
});
|
|
86
|
+
const sent = [];
|
|
87
|
+
const first = await runBillingExpiration({
|
|
88
|
+
billing,
|
|
89
|
+
customerKeys,
|
|
90
|
+
workspaces,
|
|
91
|
+
now: new Date("2026-04-22T00:00:00.000Z"),
|
|
92
|
+
notifyCustomer: async ({ telegramUserId }) => {
|
|
93
|
+
sent.push(telegramUserId);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const second = await runBillingExpiration({
|
|
97
|
+
billing,
|
|
98
|
+
customerKeys,
|
|
99
|
+
workspaces,
|
|
100
|
+
now: new Date("2026-04-22T00:00:00.000Z"),
|
|
101
|
+
notifyCustomer: async ({ telegramUserId }) => {
|
|
102
|
+
sent.push(telegramUserId);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
assert.equal(first.suspendedWorkspaces, 1);
|
|
106
|
+
assert.equal(first.suspendedKeys, 1);
|
|
107
|
+
assert.equal(first.notificationsSent, 1);
|
|
108
|
+
assert.equal(second.expiredEntitlements, 0);
|
|
109
|
+
assert.equal(second.suspendedWorkspaces, 0);
|
|
110
|
+
assert.equal(second.suspendedKeys, 0);
|
|
111
|
+
assert.equal(second.notificationsSent, 0);
|
|
112
|
+
assert.deepEqual(sent, ["77"]);
|
|
113
|
+
});
|
|
114
|
+
});
|