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,126 @@
|
|
|
1
|
+
import { normalizeClientRouteKey, } from "./runtime-provider-repository.js";
|
|
2
|
+
export function resolveCustomerRoutingAccess(args) {
|
|
3
|
+
const customerKey = args.routingApiKey
|
|
4
|
+
? args.customerKeyRepository.getByApiKey(args.routingApiKey)
|
|
5
|
+
: undefined;
|
|
6
|
+
if (!customerKey) {
|
|
7
|
+
return {
|
|
8
|
+
kind: "operator",
|
|
9
|
+
clientRoute: args.resolvedClientRoute,
|
|
10
|
+
providers: args.providerRepository.findProvidersByAccessKey(args.routingApiKey),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
if (customerKey.status !== "active") {
|
|
14
|
+
return {
|
|
15
|
+
error: {
|
|
16
|
+
statusCode: 403,
|
|
17
|
+
body: buildCustomerKeyError(customerKey.status),
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const workspace = args.workspaceRepository.getById(customerKey.workspaceId);
|
|
22
|
+
if (!workspace) {
|
|
23
|
+
return {
|
|
24
|
+
error: {
|
|
25
|
+
statusCode: 404,
|
|
26
|
+
body: {
|
|
27
|
+
error: {
|
|
28
|
+
type: "not_found_error",
|
|
29
|
+
code: "CUSTOMER_WORKSPACE_NOT_FOUND",
|
|
30
|
+
message: "Customer workspace was not found for this API key.",
|
|
31
|
+
retryable: false,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (workspace.status !== "active") {
|
|
38
|
+
return {
|
|
39
|
+
error: {
|
|
40
|
+
statusCode: 403,
|
|
41
|
+
body: {
|
|
42
|
+
error: {
|
|
43
|
+
type: "authentication_error",
|
|
44
|
+
code: "CUSTOMER_WORKSPACE_SUSPENDED",
|
|
45
|
+
message: "This customer workspace is not active.",
|
|
46
|
+
retryable: false,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const entitlement = args.billingRepository.getActiveEntitlementForWorkspace(workspace.id);
|
|
53
|
+
const usableEntitlement = args.billingRepository.getUsableActiveEntitlementForWorkspace(workspace.id);
|
|
54
|
+
if (!entitlement || !usableEntitlement) {
|
|
55
|
+
return {
|
|
56
|
+
error: {
|
|
57
|
+
statusCode: 403,
|
|
58
|
+
body: {
|
|
59
|
+
error: {
|
|
60
|
+
type: "billing_error",
|
|
61
|
+
code: entitlement ? "TOKEN_LOT_QUOTA_EXHAUSTED" : "SUBSCRIPTION_REQUIRED",
|
|
62
|
+
message: entitlement
|
|
63
|
+
? "No active token lot with remaining quota was found for this customer API key."
|
|
64
|
+
: "No active entitlement was found for this customer API key.",
|
|
65
|
+
retryable: false,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const clientRoute = normalizeClientRouteKey(customerKey.clientRoute);
|
|
72
|
+
const provider = args.providerRepository.getProviderForClient(clientRoute);
|
|
73
|
+
if (!provider) {
|
|
74
|
+
return {
|
|
75
|
+
error: {
|
|
76
|
+
statusCode: 403,
|
|
77
|
+
body: {
|
|
78
|
+
error: {
|
|
79
|
+
type: "authentication_error",
|
|
80
|
+
code: "CUSTOMER_CLIENT_ROUTE_UNBOUND",
|
|
81
|
+
message: "This customer API key is not bound to an active provider route.",
|
|
82
|
+
retryable: false,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
kind: "customer",
|
|
90
|
+
customerKey,
|
|
91
|
+
workspace,
|
|
92
|
+
entitlement: usableEntitlement,
|
|
93
|
+
clientRoute,
|
|
94
|
+
providers: [provider],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function buildCustomerKeyError(status) {
|
|
98
|
+
if (status === "revoked") {
|
|
99
|
+
return {
|
|
100
|
+
error: {
|
|
101
|
+
type: "authentication_error",
|
|
102
|
+
code: "API_KEY_REVOKED",
|
|
103
|
+
message: "This customer API key has been revoked.",
|
|
104
|
+
retryable: false,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (status === "expired") {
|
|
109
|
+
return {
|
|
110
|
+
error: {
|
|
111
|
+
type: "billing_error",
|
|
112
|
+
code: "SUBSCRIPTION_EXPIRED",
|
|
113
|
+
message: "This customer API key has expired.",
|
|
114
|
+
retryable: false,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
error: {
|
|
120
|
+
type: "authentication_error",
|
|
121
|
+
code: "API_KEY_SUSPENDED",
|
|
122
|
+
message: "This customer API key is suspended.",
|
|
123
|
+
retryable: false,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
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 { CustomerKeyRepository } from "./customer-keys.js";
|
|
8
|
+
import { resolveCustomerRoutingAccess } from "./customer-key-access.js";
|
|
9
|
+
import { RuntimeProviderRepository } from "./runtime-provider-repository.js";
|
|
10
|
+
import { CustomerWorkspaceRepository } from "./telegram-bot/customer-workspace-repository.js";
|
|
11
|
+
function createProvider(id) {
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
name: id,
|
|
15
|
+
baseUrl: `https://${id}.example/v1`,
|
|
16
|
+
responsesUrl: `https://${id}.example/v1/responses`,
|
|
17
|
+
providerApiKeys: [`${id}-provider-key`],
|
|
18
|
+
clientApiKeys: [],
|
|
19
|
+
capabilities: {
|
|
20
|
+
usageCheckEnabled: false,
|
|
21
|
+
stripMaxOutputTokens: false,
|
|
22
|
+
requestParameterPolicy: {},
|
|
23
|
+
sanitizeReasoningSummary: false,
|
|
24
|
+
stripModelPrefixes: [],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function createRepositories() {
|
|
29
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "customer-key-access-"));
|
|
30
|
+
const providerDbFile = path.join(dir, "app.sqlite");
|
|
31
|
+
const workspaceDbFile = path.join(dir, "bot.sqlite");
|
|
32
|
+
const legacyStateFile = path.join(dir, "providers.json");
|
|
33
|
+
const providerRepository = await RuntimeProviderRepository.create({
|
|
34
|
+
dbFile: providerDbFile,
|
|
35
|
+
legacyStateFile,
|
|
36
|
+
baseProviders: [createProvider("provider-a"), createProvider("provider-b")],
|
|
37
|
+
});
|
|
38
|
+
providerRepository.setClientRoute("customers", "provider-a");
|
|
39
|
+
return {
|
|
40
|
+
dir,
|
|
41
|
+
providerRepository,
|
|
42
|
+
customerKeys: CustomerKeyRepository.create(workspaceDbFile),
|
|
43
|
+
workspaces: CustomerWorkspaceRepository.create(workspaceDbFile),
|
|
44
|
+
billing: BillingRepository.create(workspaceDbFile),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
test("customer key resolves to its bound client route provider", async () => {
|
|
48
|
+
const repos = await createRepositories();
|
|
49
|
+
try {
|
|
50
|
+
const workspace = repos.workspaces.ensureDefaultWorkspace({
|
|
51
|
+
ownerTelegramUserId: "42",
|
|
52
|
+
defaultClientRoute: "customers",
|
|
53
|
+
status: "active",
|
|
54
|
+
});
|
|
55
|
+
const created = repos.customerKeys.createKey({
|
|
56
|
+
workspaceId: workspace.id,
|
|
57
|
+
telegramUserId: "42",
|
|
58
|
+
clientRoute: "customers",
|
|
59
|
+
});
|
|
60
|
+
repos.billing.grantSubscription({
|
|
61
|
+
workspaceId: workspace.id,
|
|
62
|
+
planId: "basic",
|
|
63
|
+
days: 30,
|
|
64
|
+
now: new Date(),
|
|
65
|
+
});
|
|
66
|
+
const result = resolveCustomerRoutingAccess({
|
|
67
|
+
routingApiKey: created.apiKey,
|
|
68
|
+
resolvedClientRoute: "default",
|
|
69
|
+
providerRepository: repos.providerRepository,
|
|
70
|
+
customerKeyRepository: repos.customerKeys,
|
|
71
|
+
workspaceRepository: repos.workspaces,
|
|
72
|
+
billingRepository: repos.billing,
|
|
73
|
+
});
|
|
74
|
+
assert.equal("kind" in result && result.kind, "customer");
|
|
75
|
+
if ("kind" in result && result.kind === "customer") {
|
|
76
|
+
assert.equal(result.clientRoute, "customers");
|
|
77
|
+
assert.equal(result.providers[0]?.id, "provider-a");
|
|
78
|
+
assert.equal(result.entitlement.workspaceId, workspace.id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
rmSync(repos.dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
test("suspended customer key is rejected before operator routing", async () => {
|
|
86
|
+
const repos = await createRepositories();
|
|
87
|
+
try {
|
|
88
|
+
const workspace = repos.workspaces.ensureDefaultWorkspace({
|
|
89
|
+
ownerTelegramUserId: "42",
|
|
90
|
+
defaultClientRoute: "customers",
|
|
91
|
+
status: "active",
|
|
92
|
+
});
|
|
93
|
+
const created = repos.customerKeys.createKey({
|
|
94
|
+
workspaceId: workspace.id,
|
|
95
|
+
telegramUserId: "42",
|
|
96
|
+
clientRoute: "customers",
|
|
97
|
+
});
|
|
98
|
+
repos.customerKeys.setStatus(created.record.id, "suspended");
|
|
99
|
+
const result = resolveCustomerRoutingAccess({
|
|
100
|
+
routingApiKey: created.apiKey,
|
|
101
|
+
resolvedClientRoute: "default",
|
|
102
|
+
providerRepository: repos.providerRepository,
|
|
103
|
+
customerKeyRepository: repos.customerKeys,
|
|
104
|
+
workspaceRepository: repos.workspaces,
|
|
105
|
+
billingRepository: repos.billing,
|
|
106
|
+
});
|
|
107
|
+
assert.equal("error" in result, true);
|
|
108
|
+
if ("error" in result) {
|
|
109
|
+
assert.equal(result.error.statusCode, 403);
|
|
110
|
+
assert.equal(result.error.body.error.code, "API_KEY_SUSPENDED");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
rmSync(repos.dir, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
test("customer key without active entitlement is rejected", async () => {
|
|
118
|
+
const repos = await createRepositories();
|
|
119
|
+
try {
|
|
120
|
+
const workspace = repos.workspaces.ensureDefaultWorkspace({
|
|
121
|
+
ownerTelegramUserId: "42",
|
|
122
|
+
defaultClientRoute: "customers",
|
|
123
|
+
status: "active",
|
|
124
|
+
});
|
|
125
|
+
const created = repos.customerKeys.createKey({
|
|
126
|
+
workspaceId: workspace.id,
|
|
127
|
+
telegramUserId: "42",
|
|
128
|
+
clientRoute: "customers",
|
|
129
|
+
});
|
|
130
|
+
const result = resolveCustomerRoutingAccess({
|
|
131
|
+
routingApiKey: created.apiKey,
|
|
132
|
+
resolvedClientRoute: "default",
|
|
133
|
+
providerRepository: repos.providerRepository,
|
|
134
|
+
customerKeyRepository: repos.customerKeys,
|
|
135
|
+
workspaceRepository: repos.workspaces,
|
|
136
|
+
billingRepository: repos.billing,
|
|
137
|
+
});
|
|
138
|
+
assert.equal("error" in result, true);
|
|
139
|
+
if ("error" in result) {
|
|
140
|
+
assert.equal(result.error.statusCode, 403);
|
|
141
|
+
assert.equal(result.error.body.error.code, "SUBSCRIPTION_REQUIRED");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
rmSync(repos.dir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
test("operator keys continue to use runtime provider repository access", async () => {
|
|
149
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "customer-key-access-operator-"));
|
|
150
|
+
try {
|
|
151
|
+
const dbFile = path.join(dir, "app.sqlite");
|
|
152
|
+
const legacyStateFile = path.join(dir, "providers.json");
|
|
153
|
+
const botDbFile = path.join(dir, "bot.sqlite");
|
|
154
|
+
const providerRepository = await RuntimeProviderRepository.create({
|
|
155
|
+
dbFile,
|
|
156
|
+
legacyStateFile,
|
|
157
|
+
baseProviders: [createProvider("provider-a")],
|
|
158
|
+
});
|
|
159
|
+
providerRepository.setClientRoute("default", "provider-a");
|
|
160
|
+
providerRepository.setClientRouteApiKeys("default", ["operator-key"]);
|
|
161
|
+
const result = resolveCustomerRoutingAccess({
|
|
162
|
+
routingApiKey: "operator-key",
|
|
163
|
+
resolvedClientRoute: "default",
|
|
164
|
+
providerRepository,
|
|
165
|
+
customerKeyRepository: CustomerKeyRepository.create(botDbFile),
|
|
166
|
+
workspaceRepository: CustomerWorkspaceRepository.create(botDbFile),
|
|
167
|
+
billingRepository: BillingRepository.create(botDbFile),
|
|
168
|
+
});
|
|
169
|
+
assert.equal("kind" in result && result.kind, "operator");
|
|
170
|
+
if ("kind" in result && result.kind === "operator") {
|
|
171
|
+
assert.equal(result.clientRoute, "default");
|
|
172
|
+
assert.equal(result.providers[0]?.id, "provider-a");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
5
|
+
export class CustomerKeyRepository {
|
|
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
|
+
ensureCustomerKeySchema(db);
|
|
14
|
+
return new CustomerKeyRepository(db);
|
|
15
|
+
}
|
|
16
|
+
createKey(input) {
|
|
17
|
+
const apiKey = input.apiKey?.trim() || generateCustomerApiKey();
|
|
18
|
+
const now = (input.now ?? new Date()).toISOString();
|
|
19
|
+
const status = normalizeStatus(input.status ?? "active");
|
|
20
|
+
const id = randomUUID();
|
|
21
|
+
this.db
|
|
22
|
+
.prepare(`INSERT INTO customer_api_keys (
|
|
23
|
+
id,
|
|
24
|
+
workspace_id,
|
|
25
|
+
telegram_user_id,
|
|
26
|
+
telegram_chat_id,
|
|
27
|
+
client_route,
|
|
28
|
+
api_key_hash,
|
|
29
|
+
api_key_preview,
|
|
30
|
+
api_key_secret,
|
|
31
|
+
name,
|
|
32
|
+
status,
|
|
33
|
+
expires_at,
|
|
34
|
+
created_at,
|
|
35
|
+
updated_at
|
|
36
|
+
)
|
|
37
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
38
|
+
.run(id, input.workspaceId, input.telegramUserId ?? null, input.telegramChatId ?? null, normalizeClientRoute(input.clientRoute), hashApiKey(apiKey), previewApiKey(apiKey), apiKey, input.name ?? null, status, input.expiresAt ?? null, now, now);
|
|
39
|
+
return {
|
|
40
|
+
record: this.getById(id),
|
|
41
|
+
apiKey,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
getById(id) {
|
|
45
|
+
const row = this.db
|
|
46
|
+
.prepare(`SELECT *
|
|
47
|
+
FROM customer_api_keys
|
|
48
|
+
WHERE id = ?`)
|
|
49
|
+
.get(id);
|
|
50
|
+
return row ? mapCustomerApiKeyRow(row) : undefined;
|
|
51
|
+
}
|
|
52
|
+
getByApiKey(apiKey) {
|
|
53
|
+
return this.getByHash(hashApiKey(apiKey));
|
|
54
|
+
}
|
|
55
|
+
getByHash(apiKeyHash) {
|
|
56
|
+
const row = this.db
|
|
57
|
+
.prepare(`SELECT *
|
|
58
|
+
FROM customer_api_keys
|
|
59
|
+
WHERE api_key_hash = ?`)
|
|
60
|
+
.get(apiKeyHash);
|
|
61
|
+
return row ? mapCustomerApiKeyRow(row) : undefined;
|
|
62
|
+
}
|
|
63
|
+
getApiKeySecret(id) {
|
|
64
|
+
const row = this.db
|
|
65
|
+
.prepare(`SELECT api_key_secret
|
|
66
|
+
FROM customer_api_keys
|
|
67
|
+
WHERE id = ?`)
|
|
68
|
+
.get(id);
|
|
69
|
+
return row?.api_key_secret ?? undefined;
|
|
70
|
+
}
|
|
71
|
+
getActiveKeyForUser(telegramUserId) {
|
|
72
|
+
const row = this.db
|
|
73
|
+
.prepare(`SELECT *
|
|
74
|
+
FROM customer_api_keys
|
|
75
|
+
WHERE telegram_user_id = ?
|
|
76
|
+
AND status = 'active'
|
|
77
|
+
ORDER BY created_at DESC
|
|
78
|
+
LIMIT 1`)
|
|
79
|
+
.get(telegramUserId);
|
|
80
|
+
return row ? mapCustomerApiKeyRow(row) : undefined;
|
|
81
|
+
}
|
|
82
|
+
getLatestKeyForUser(telegramUserId) {
|
|
83
|
+
const row = this.db
|
|
84
|
+
.prepare(`SELECT *
|
|
85
|
+
FROM customer_api_keys
|
|
86
|
+
WHERE telegram_user_id = ?
|
|
87
|
+
ORDER BY created_at DESC
|
|
88
|
+
LIMIT 1`)
|
|
89
|
+
.get(telegramUserId);
|
|
90
|
+
return row ? mapCustomerApiKeyRow(row) : undefined;
|
|
91
|
+
}
|
|
92
|
+
listKeysByUser(telegramUserId) {
|
|
93
|
+
const rows = this.db
|
|
94
|
+
.prepare(`SELECT *
|
|
95
|
+
FROM customer_api_keys
|
|
96
|
+
WHERE telegram_user_id = ?
|
|
97
|
+
ORDER BY created_at DESC`)
|
|
98
|
+
.all(telegramUserId);
|
|
99
|
+
return rows.map(mapCustomerApiKeyRow);
|
|
100
|
+
}
|
|
101
|
+
listKeysByWorkspace(workspaceId) {
|
|
102
|
+
const rows = this.db
|
|
103
|
+
.prepare(`SELECT *
|
|
104
|
+
FROM customer_api_keys
|
|
105
|
+
WHERE workspace_id = ?
|
|
106
|
+
ORDER BY created_at DESC`)
|
|
107
|
+
.all(workspaceId);
|
|
108
|
+
return rows.map(mapCustomerApiKeyRow);
|
|
109
|
+
}
|
|
110
|
+
listRecentKeys(limit = 10) {
|
|
111
|
+
const rows = this.db
|
|
112
|
+
.prepare(`SELECT *
|
|
113
|
+
FROM customer_api_keys
|
|
114
|
+
ORDER BY created_at DESC
|
|
115
|
+
LIMIT ?`)
|
|
116
|
+
.all(Math.max(1, Math.min(50, Math.floor(limit))));
|
|
117
|
+
return rows.map(mapCustomerApiKeyRow);
|
|
118
|
+
}
|
|
119
|
+
setStatus(id, status, options = {}) {
|
|
120
|
+
const now = (options.now ?? new Date()).toISOString();
|
|
121
|
+
this.db
|
|
122
|
+
.prepare(`UPDATE customer_api_keys
|
|
123
|
+
SET status = ?,
|
|
124
|
+
updated_at = ?,
|
|
125
|
+
revoked_at = CASE WHEN ? = 'revoked' THEN ? ELSE revoked_at END
|
|
126
|
+
WHERE id = ?`)
|
|
127
|
+
.run(status, now, status, now, id);
|
|
128
|
+
return this.getById(id);
|
|
129
|
+
}
|
|
130
|
+
markUsed(id, now = new Date()) {
|
|
131
|
+
this.db
|
|
132
|
+
.prepare(`UPDATE customer_api_keys
|
|
133
|
+
SET last_used_at = ?,
|
|
134
|
+
updated_at = ?
|
|
135
|
+
WHERE id = ?`)
|
|
136
|
+
.run(now.toISOString(), now.toISOString(), id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function hashApiKey(apiKey) {
|
|
140
|
+
return createHash("sha256").update(apiKey).digest("hex");
|
|
141
|
+
}
|
|
142
|
+
function generateCustomerApiKey() {
|
|
143
|
+
return `sk-customer-${randomBytes(24).toString("hex")}`;
|
|
144
|
+
}
|
|
145
|
+
function previewApiKey(apiKey) {
|
|
146
|
+
return `${apiKey.slice(0, 12)}...${apiKey.slice(-6)}`;
|
|
147
|
+
}
|
|
148
|
+
function normalizeClientRoute(value) {
|
|
149
|
+
return (value
|
|
150
|
+
.trim()
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
153
|
+
.replace(/^-+|-+$/g, "") || "customers");
|
|
154
|
+
}
|
|
155
|
+
function ensureCustomerKeySchema(db) {
|
|
156
|
+
db.exec(`
|
|
157
|
+
CREATE TABLE IF NOT EXISTS customer_api_keys (
|
|
158
|
+
id TEXT PRIMARY KEY,
|
|
159
|
+
workspace_id TEXT NOT NULL,
|
|
160
|
+
telegram_user_id TEXT,
|
|
161
|
+
telegram_chat_id TEXT,
|
|
162
|
+
client_route TEXT NOT NULL,
|
|
163
|
+
api_key_hash TEXT NOT NULL UNIQUE,
|
|
164
|
+
api_key_preview TEXT NOT NULL,
|
|
165
|
+
api_key_secret TEXT,
|
|
166
|
+
name TEXT,
|
|
167
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
168
|
+
expires_at TEXT,
|
|
169
|
+
last_used_at TEXT,
|
|
170
|
+
created_at TEXT NOT NULL,
|
|
171
|
+
updated_at TEXT NOT NULL,
|
|
172
|
+
revoked_at TEXT
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
CREATE INDEX IF NOT EXISTS idx_customer_api_keys_user_status
|
|
176
|
+
ON customer_api_keys(telegram_user_id, status, created_at);
|
|
177
|
+
|
|
178
|
+
CREATE INDEX IF NOT EXISTS idx_customer_api_keys_workspace
|
|
179
|
+
ON customer_api_keys(workspace_id, status);
|
|
180
|
+
`);
|
|
181
|
+
const columns = db.prepare("PRAGMA table_info(customer_api_keys)").all();
|
|
182
|
+
if (!columns.some((column) => column.name === "api_key_secret")) {
|
|
183
|
+
db.prepare("ALTER TABLE customer_api_keys ADD COLUMN api_key_secret TEXT").run();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function mapCustomerApiKeyRow(row) {
|
|
187
|
+
return {
|
|
188
|
+
id: row.id,
|
|
189
|
+
workspaceId: row.workspace_id,
|
|
190
|
+
telegramUserId: row.telegram_user_id ?? undefined,
|
|
191
|
+
telegramChatId: row.telegram_chat_id ?? undefined,
|
|
192
|
+
clientRoute: row.client_route,
|
|
193
|
+
apiKeyHash: row.api_key_hash,
|
|
194
|
+
apiKeyPreview: row.api_key_preview,
|
|
195
|
+
apiKeySecret: row.api_key_secret ?? undefined,
|
|
196
|
+
name: row.name ?? undefined,
|
|
197
|
+
status: normalizeStatus(row.status),
|
|
198
|
+
expiresAt: row.expires_at ?? undefined,
|
|
199
|
+
lastUsedAt: row.last_used_at ?? undefined,
|
|
200
|
+
createdAt: row.created_at,
|
|
201
|
+
updatedAt: row.updated_at,
|
|
202
|
+
revokedAt: row.revoked_at ?? undefined,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function normalizeStatus(value) {
|
|
206
|
+
return value === "active" || value === "suspended" || value === "revoked" || value === "expired"
|
|
207
|
+
? value
|
|
208
|
+
: "suspended";
|
|
209
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
7
|
+
import { CustomerKeyRepository, hashApiKey } from "./customer-keys.js";
|
|
8
|
+
function withRepository(fn) {
|
|
9
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "customer-keys-"));
|
|
10
|
+
try {
|
|
11
|
+
const dbFile = path.join(dir, "keys.sqlite");
|
|
12
|
+
fn(CustomerKeyRepository.create(dbFile), dbFile);
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
rmSync(dir, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
test("CustomerKeyRepository creates keys and stores revealable plaintext secrets", () => {
|
|
19
|
+
withRepository((repo, dbFile) => {
|
|
20
|
+
const created = repo.createKey({
|
|
21
|
+
workspaceId: "workspace-1",
|
|
22
|
+
telegramUserId: "1283361952",
|
|
23
|
+
clientRoute: "Paid Customers",
|
|
24
|
+
now: new Date("2026-04-27T00:00:00.000Z"),
|
|
25
|
+
});
|
|
26
|
+
assert.match(created.apiKey, /^sk-customer-/);
|
|
27
|
+
assert.equal(created.record.clientRoute, "paid-customers");
|
|
28
|
+
assert.equal(created.record.apiKeyHash, hashApiKey(created.apiKey));
|
|
29
|
+
assert.equal(created.record.apiKeySecret, created.apiKey);
|
|
30
|
+
assert.equal(repo.getApiKeySecret(created.record.id), created.apiKey);
|
|
31
|
+
assert.equal(created.record.status, "active");
|
|
32
|
+
const db = new BetterSqlite3(dbFile);
|
|
33
|
+
const raw = JSON.stringify(db.prepare("SELECT * FROM customer_api_keys").all());
|
|
34
|
+
db.close();
|
|
35
|
+
assert.equal(raw.includes(created.apiKey), true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
test("CustomerKeyRepository looks up keys by raw key and hash", () => {
|
|
39
|
+
withRepository((repo) => {
|
|
40
|
+
const created = repo.createKey({
|
|
41
|
+
workspaceId: "workspace-1",
|
|
42
|
+
telegramUserId: "42",
|
|
43
|
+
clientRoute: "customers",
|
|
44
|
+
});
|
|
45
|
+
assert.equal(repo.getByApiKey(created.apiKey)?.id, created.record.id);
|
|
46
|
+
assert.equal(repo.getByHash(hashApiKey(created.apiKey))?.id, created.record.id);
|
|
47
|
+
assert.equal(repo.getActiveKeyForUser("42")?.id, created.record.id);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
test("CustomerKeyRepository supports lifecycle status changes", () => {
|
|
51
|
+
withRepository((repo) => {
|
|
52
|
+
const created = repo.createKey({
|
|
53
|
+
workspaceId: "workspace-1",
|
|
54
|
+
telegramUserId: "42",
|
|
55
|
+
clientRoute: "customers",
|
|
56
|
+
});
|
|
57
|
+
const suspended = repo.setStatus(created.record.id, "suspended");
|
|
58
|
+
assert.equal(suspended?.status, "suspended");
|
|
59
|
+
assert.equal(repo.getActiveKeyForUser("42"), undefined);
|
|
60
|
+
const active = repo.setStatus(created.record.id, "active");
|
|
61
|
+
assert.equal(active?.status, "active");
|
|
62
|
+
const revoked = repo.setStatus(created.record.id, "revoked", {
|
|
63
|
+
now: new Date("2026-04-27T00:00:00.000Z"),
|
|
64
|
+
});
|
|
65
|
+
assert.equal(revoked?.status, "revoked");
|
|
66
|
+
assert.equal(revoked?.revokedAt, "2026-04-27T00:00:00.000Z");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { extractUsageTotals } from "./client-token-limits.js";
|
|
2
|
+
export function recordCustomerUsageFromPayload(args) {
|
|
3
|
+
if (args.access.kind !== "customer") {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
const usage = extractUsageTotals(args.usagePayload);
|
|
7
|
+
if (!usage) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const updates = args.billingRepository.consumeWorkspaceUsage({
|
|
11
|
+
workspaceId: args.access.workspace.id,
|
|
12
|
+
customerApiKeyId: args.access.customerKey.id,
|
|
13
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
14
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
15
|
+
totalTokens: usage.totalTokens ?? 0,
|
|
16
|
+
});
|
|
17
|
+
return updates[0];
|
|
18
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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 { recordCustomerUsageFromPayload } from "./customer-usage.js";
|
|
8
|
+
function withRepository(fn) {
|
|
9
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "customer-usage-"));
|
|
10
|
+
try {
|
|
11
|
+
fn(BillingRepository.create(path.join(dir, "billing.sqlite")));
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
rmSync(dir, { recursive: true, force: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
test("recordCustomerUsageFromPayload returns zero change for operator access", () => {
|
|
18
|
+
withRepository((repo) => {
|
|
19
|
+
const usage = recordCustomerUsageFromPayload({
|
|
20
|
+
billingRepository: repo,
|
|
21
|
+
usagePayload: { usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } },
|
|
22
|
+
access: { kind: "operator" },
|
|
23
|
+
});
|
|
24
|
+
assert.equal(usage, undefined);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
test("recordCustomerUsageFromPayload increments customer entitlement usage after a request", () => {
|
|
28
|
+
withRepository((repo) => {
|
|
29
|
+
const granted = repo.grantSubscription({
|
|
30
|
+
workspaceId: "workspace-1",
|
|
31
|
+
planId: "basic",
|
|
32
|
+
days: 30,
|
|
33
|
+
now: new Date(),
|
|
34
|
+
});
|
|
35
|
+
const usage = recordCustomerUsageFromPayload({
|
|
36
|
+
billingRepository: repo,
|
|
37
|
+
usagePayload: {
|
|
38
|
+
usage: {
|
|
39
|
+
input_tokens: 12,
|
|
40
|
+
output_tokens: 8,
|
|
41
|
+
total_tokens: 20,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
access: {
|
|
45
|
+
kind: "customer",
|
|
46
|
+
workspace: { id: "workspace-1" },
|
|
47
|
+
entitlement: { id: granted.entitlement.id },
|
|
48
|
+
customerKey: { id: "key-1" },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
assert.ok(usage);
|
|
52
|
+
assert.equal(usage.totalTokens, 20);
|
|
53
|
+
assert.equal(repo.getEntitlementUsage(granted.entitlement.id)?.totalTokens, 20);
|
|
54
|
+
});
|
|
55
|
+
});
|