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.
Files changed (161) hide show
  1. package/README.md +56 -0
  2. package/cli.js +118 -0
  3. package/dist/anthropic-messages.js +383 -0
  4. package/dist/anthropic-messages.test.js +209 -0
  5. package/dist/audit-log.js +138 -0
  6. package/dist/audit-log.test.js +480 -0
  7. package/dist/billing-expiration.js +70 -0
  8. package/dist/billing-expiration.test.js +114 -0
  9. package/dist/billing.js +716 -0
  10. package/dist/billing.test.js +228 -0
  11. package/dist/chatgpt-oauth-store.js +240 -0
  12. package/dist/chatgpt-oauth-store.test.js +88 -0
  13. package/dist/chatgpt-oauth.js +118 -0
  14. package/dist/chatgpt-oauth.test.js +63 -0
  15. package/dist/chatgpt-provider-auth.js +60 -0
  16. package/dist/chatgpt-provider-auth.test.js +101 -0
  17. package/dist/client/app-icon.svg +17 -0
  18. package/dist/client/assets/index-C7Vvhst8.js +14 -0
  19. package/dist/client/assets/index-DpqgYK3L.css +1 -0
  20. package/dist/client/favicon.svg +17 -0
  21. package/dist/client/index.html +31 -0
  22. package/dist/client-config-apply.js +345 -0
  23. package/dist/client-config-apply.test.js +185 -0
  24. package/dist/client-token-limits.js +111 -0
  25. package/dist/client-token-limits.test.js +129 -0
  26. package/dist/codex-config.js +47 -0
  27. package/dist/codex-setup.js +87 -0
  28. package/dist/codex-setup.test.js +30 -0
  29. package/dist/config.js +314 -0
  30. package/dist/cost-analytics.js +31 -0
  31. package/dist/cost-analytics.test.js +38 -0
  32. package/dist/customer-key-access.js +126 -0
  33. package/dist/customer-key-access.test.js +178 -0
  34. package/dist/customer-keys.js +209 -0
  35. package/dist/customer-keys.test.js +68 -0
  36. package/dist/customer-usage.js +18 -0
  37. package/dist/customer-usage.test.js +55 -0
  38. package/dist/dashboard-auth.js +318 -0
  39. package/dist/dashboard-auth.test.js +133 -0
  40. package/dist/dashboard-serving.test.js +235 -0
  41. package/dist/error-response.js +174 -0
  42. package/dist/error-response.test.js +88 -0
  43. package/dist/forward.js +357 -0
  44. package/dist/health-websocket-manager.js +174 -0
  45. package/dist/http-rate-limit.js +36 -0
  46. package/dist/http-rate-limit.test.js +62 -0
  47. package/dist/kiro-auth.js +136 -0
  48. package/dist/kiro-auth.test.js +234 -0
  49. package/dist/kiro-codewhisperer.js +646 -0
  50. package/dist/kiro-codewhisperer.test.js +219 -0
  51. package/dist/kiro-device-login.js +338 -0
  52. package/dist/kiro-eventstream.js +219 -0
  53. package/dist/kiro-eventstream.test.js +79 -0
  54. package/dist/kiro-forward.js +401 -0
  55. package/dist/kiro-import-cli.js +69 -0
  56. package/dist/kiro-import.js +94 -0
  57. package/dist/kiro-import.test.js +125 -0
  58. package/dist/kiro-token-store.js +196 -0
  59. package/dist/kiro-token-store.test.js +207 -0
  60. package/dist/krouter-usage.js +243 -0
  61. package/dist/model-combo-repository.js +147 -0
  62. package/dist/model-routing.js +69 -0
  63. package/dist/model-routing.test.js +41 -0
  64. package/dist/normalize-request.js +531 -0
  65. package/dist/normalize-request.test.js +277 -0
  66. package/dist/omv-public-firewall.test.js +11 -0
  67. package/dist/package.json +17 -0
  68. package/dist/prompt-cache-state.js +146 -0
  69. package/dist/prompt-cache-state.test.js +71 -0
  70. package/dist/prompt-cache.js +229 -0
  71. package/dist/provider-health-service.js +404 -0
  72. package/dist/provider-request-parameters.js +107 -0
  73. package/dist/provider-request-parameters.test.js +26 -0
  74. package/dist/provider-routing.js +114 -0
  75. package/dist/provider-routing.test.js +64 -0
  76. package/dist/provider-usage.js +314 -0
  77. package/dist/request-timeout-policy.js +61 -0
  78. package/dist/request-timeout-policy.test.js +40 -0
  79. package/dist/response-cache.js +69 -0
  80. package/dist/response-cache.test.js +28 -0
  81. package/dist/routing-combo-repository.js +300 -0
  82. package/dist/routing-engine.js +377 -0
  83. package/dist/routing-integration.js +155 -0
  84. package/dist/routing-simulation-engine.js +326 -0
  85. package/dist/rtk-layer.js +483 -0
  86. package/dist/rtk-layer.test.js +198 -0
  87. package/dist/runtime-provider-repository.js +1742 -0
  88. package/dist/runtime-provider-repository.test.js +1177 -0
  89. package/dist/schema.js +118 -0
  90. package/dist/schema.test.js +16 -0
  91. package/dist/sepay-webhook.js +87 -0
  92. package/dist/sepay-webhook.test.js +142 -0
  93. package/dist/server-body-limit.test.js +35 -0
  94. package/dist/server-client-token-limits.test.js +161 -0
  95. package/dist/server-codex-config-setup.test.js +76 -0
  96. package/dist/server-http-rate-limit.test.js +80 -0
  97. package/dist/server-response-cache.test.js +105 -0
  98. package/dist/server-routes-alias.test.js +39 -0
  99. package/dist/server-sepay-webhook-security.test.js +59 -0
  100. package/dist/server.js +5906 -0
  101. package/dist/session-log.js +178 -0
  102. package/dist/tailnet-funnel-script.test.js +33 -0
  103. package/dist/telegram-bot/actions.js +118 -0
  104. package/dist/telegram-bot/admin-actions.js +103 -0
  105. package/dist/telegram-bot/auth.js +46 -0
  106. package/dist/telegram-bot/auth.test.js +1 -0
  107. package/dist/telegram-bot/bot-identity-repository.js +189 -0
  108. package/dist/telegram-bot/bot-identity-repository.test.js +78 -0
  109. package/dist/telegram-bot/callbacks.js +30 -0
  110. package/dist/telegram-bot/codex-config-delivery.js +38 -0
  111. package/dist/telegram-bot/codex-config-delivery.test.js +75 -0
  112. package/dist/telegram-bot/commands/accounts.js +140 -0
  113. package/dist/telegram-bot/commands/apikey.js +737 -0
  114. package/dist/telegram-bot/commands/apply.js +265 -0
  115. package/dist/telegram-bot/commands/clients.js +13 -0
  116. package/dist/telegram-bot/commands/customer-billing.test.js +271 -0
  117. package/dist/telegram-bot/commands/grant.js +138 -0
  118. package/dist/telegram-bot/commands/grant.test.js +217 -0
  119. package/dist/telegram-bot/commands/help.js +52 -0
  120. package/dist/telegram-bot/commands/me.js +53 -0
  121. package/dist/telegram-bot/commands/models.js +6 -0
  122. package/dist/telegram-bot/commands/oauth.js +64 -0
  123. package/dist/telegram-bot/commands/plans.js +96 -0
  124. package/dist/telegram-bot/commands/providers.js +27 -0
  125. package/dist/telegram-bot/commands/quota.js +10 -0
  126. package/dist/telegram-bot/commands/renew-user.js +139 -0
  127. package/dist/telegram-bot/commands/renew-user.test.js +184 -0
  128. package/dist/telegram-bot/commands/renew.js +1369 -0
  129. package/dist/telegram-bot/commands/renew.test.js +1633 -0
  130. package/dist/telegram-bot/commands/start.js +212 -0
  131. package/dist/telegram-bot/commands/start.test.js +280 -0
  132. package/dist/telegram-bot/commands/status.js +6 -0
  133. package/dist/telegram-bot/commands/tailscale.js +15 -0
  134. package/dist/telegram-bot/commands/tailscale.test.js +76 -0
  135. package/dist/telegram-bot/commands/test.js +51 -0
  136. package/dist/telegram-bot/commands/test.test.js +14 -0
  137. package/dist/telegram-bot/commands/usage.js +10 -0
  138. package/dist/telegram-bot/config.js +98 -0
  139. package/dist/telegram-bot/config.test.js +42 -0
  140. package/dist/telegram-bot/customer-actions.js +160 -0
  141. package/dist/telegram-bot/customer-api-keys.js +68 -0
  142. package/dist/telegram-bot/customer-billing.js +72 -0
  143. package/dist/telegram-bot/customer-workspace-repository.js +134 -0
  144. package/dist/telegram-bot/customer-workspace-repository.test.js +47 -0
  145. package/dist/telegram-bot/dashboard-login.js +39 -0
  146. package/dist/telegram-bot/format.js +140 -0
  147. package/dist/telegram-bot/grants.js +370 -0
  148. package/dist/telegram-bot/grants.test.js +290 -0
  149. package/dist/telegram-bot/index.js +85 -0
  150. package/dist/telegram-bot/message-cleanup.js +55 -0
  151. package/dist/telegram-bot/message-cleanup.test.js +77 -0
  152. package/dist/telegram-bot/message-format.js +45 -0
  153. package/dist/telegram-bot/message-format.test.js +10 -0
  154. package/dist/telegram-bot/proxy-client.js +174 -0
  155. package/dist/telegram-bot/rate-limit.js +95 -0
  156. package/dist/telegram-bot/rate-limit.test.js +58 -0
  157. package/dist/telegram-bot/sessions.js +171 -0
  158. package/dist/telegram-bot/sessions.test.js +107 -0
  159. package/dist/telegram-bot/telegram-adapter.js +126 -0
  160. package/dist/telegram-bot/worker.js +63 -0
  161. 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
+ });