@acarmisc/backstage-plugin-litellm-backend 0.5.0 → 0.6.1

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/dist/bridge.d.ts CHANGED
@@ -75,13 +75,3 @@ export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClai
75
75
  export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
76
76
  info: (...args: unknown[]) => void;
77
77
  }, request: Partial<GenerateKeyRequest>, userIdDomain?: string): Promise<GenerateKeyResponse>;
78
- /**
79
- * Rotates the caller's key identified by alias, returning a fresh secret. The
80
- * bridge can't recover a usable secret from a listing (it's masked), so a CLI
81
- * that lost its cached key but whose stable alias still exists uses this to
82
- * rotate in place instead of minting a duplicate. Throws 404 when no key with
83
- * that alias exists for the caller — the CLI then mints one.
84
- */
85
- export declare function bridgeRegenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
86
- info: (...args: unknown[]) => void;
87
- }, alias: string, userIdDomain?: string): Promise<GenerateKeyResponse>;
package/dist/bridge.js CHANGED
@@ -7,7 +7,6 @@ exports.resolveBridgeUserId = resolveBridgeUserId;
7
7
  exports.getOrProvisionUserFromClaims = getOrProvisionUserFromClaims;
8
8
  exports.bridgeListKeys = bridgeListKeys;
9
9
  exports.bridgeGenerateKey = bridgeGenerateKey;
10
- exports.bridgeRegenerateKey = bridgeRegenerateKey;
11
10
  /**
12
11
  * Bridge — lets CLI clients (Abby) list/mint LiteLLM virtual keys without ever
13
12
  * holding the LiteLLM master key.
@@ -153,20 +152,3 @@ async function bridgeGenerateKey(client, claims, provisioningEnabled, provisioni
153
152
  };
154
153
  return client.generateKey(enriched);
155
154
  }
156
- /**
157
- * Rotates the caller's key identified by alias, returning a fresh secret. The
158
- * bridge can't recover a usable secret from a listing (it's masked), so a CLI
159
- * that lost its cached key but whose stable alias still exists uses this to
160
- * rotate in place instead of minting a duplicate. Throws 404 when no key with
161
- * that alias exists for the caller — the CLI then mints one.
162
- */
163
- async function bridgeRegenerateKey(client, claims, provisioningEnabled, provisioningDefaults, logger, alias, userIdDomain) {
164
- await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain);
165
- const userId = resolveBridgeUserId(claims, userIdDomain);
166
- const keys = await client.listKeys(userId);
167
- const match = keys.find(k => k.key_alias === alias);
168
- if (!match) {
169
- throw new provisioning_1.ProvisioningError('Key not found', `No key with alias "${alias}" for this identity; mint one first.`, false, 404);
170
- }
171
- return client.regenerateKey(match.token);
172
- }
package/dist/client.d.ts CHANGED
@@ -57,13 +57,6 @@ export declare class LiteLLMClient {
57
57
  unblockKey(key: string): Promise<unknown>;
58
58
  resetKeySpend(key: string): Promise<unknown>;
59
59
  getAuditLogs(params: AuditLogsParams): Promise<PaginatedAuditLogs>;
60
- /**
61
- * Rotates an existing key in place, returning a fresh `sk-` secret while
62
- * keeping the same alias/budget/limits. `token` is the hashed token LiteLLM
63
- * stores (the same value used for delete/update) — passed in the path because
64
- * it isn't an `sk-` prefixed secret, so LiteLLM matches it without re-hashing.
65
- */
66
- regenerateKey(token: string): Promise<GenerateKeyResponse>;
67
60
  /**
68
61
  * Returns the proxy's model catalogue normalised to the ModelInfo shape.
69
62
  *
@@ -98,6 +91,6 @@ export declare class LiteLLMClient {
98
91
  * - usage_by_key → which keys drove cost / traffic (with key_alias + team_id from metadata)
99
92
  */
100
93
  private transformDailyActivity;
101
- getUsage(startDate: string, endDate: string, userId?: string, _groupBy?: string): Promise<UsageMetrics>;
94
+ getUsage(startDate: string, endDate: string, userId?: string): Promise<UsageMetrics>;
102
95
  getTeamUsage(teamId: string, startDate: string, endDate: string): Promise<UsageMetrics>;
103
96
  }
package/dist/client.js CHANGED
@@ -12,6 +12,11 @@ class LiteLLMClient {
12
12
  const controller = new AbortController();
13
13
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
14
14
  try {
15
+ // Auth note: the LiteLLM OpenAPI spec declares the security scheme as an
16
+ // `x-litellm-api-key` header, but Bearer + master key is the deliberate,
17
+ // production-tested choice — LiteLLM's proxy accepts both, and Bearer is
18
+ // what every observed deployment actually uses. Do not "correct" this to
19
+ // match the spec literally without testing against a live gateway.
15
20
  const response = await fetch(`${this.baseUrl}${path}`, {
16
21
  ...options,
17
22
  signal: controller.signal,
@@ -204,15 +209,6 @@ class LiteLLMClient {
204
209
  query.set('sort_order', params.sort_order);
205
210
  return this.request(`/audit?${query.toString()}`);
206
211
  }
207
- /**
208
- * Rotates an existing key in place, returning a fresh `sk-` secret while
209
- * keeping the same alias/budget/limits. `token` is the hashed token LiteLLM
210
- * stores (the same value used for delete/update) — passed in the path because
211
- * it isn't an `sk-` prefixed secret, so LiteLLM matches it without re-hashing.
212
- */
213
- async regenerateKey(token) {
214
- return this.request(`/key/${encodeURIComponent(token)}/regenerate`, { method: 'POST', body: JSON.stringify({}) });
215
- }
216
212
  /**
217
213
  * Returns the proxy's model catalogue normalised to the ModelInfo shape.
218
214
  *
@@ -237,6 +233,8 @@ class LiteLLMClient {
237
233
  supports_vision: info.supports_vision ?? m.supports_vision,
238
234
  input_cost_per_token: info.input_cost_per_token ?? params.input_cost_per_token,
239
235
  output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token,
236
+ max_input_tokens: info.max_input_tokens ?? m.max_input_tokens,
237
+ max_output_tokens: info.max_output_tokens ?? m.max_output_tokens,
240
238
  };
241
239
  });
242
240
  const filtered = normalised.filter(m => m.model_name);
@@ -404,7 +402,7 @@ class LiteLLMClient {
404
402
  daily_by_model,
405
403
  };
406
404
  }
407
- async getUsage(startDate, endDate, userId, _groupBy) {
405
+ async getUsage(startDate, endDate, userId) {
408
406
  const params = new URLSearchParams({
409
407
  start_date: startDate,
410
408
  end_date: endDate,
package/dist/index.cjs.js CHANGED
@@ -251,18 +251,6 @@ var LiteLLMClient = class {
251
251
  if (params.sort_order) query.set("sort_order", params.sort_order);
252
252
  return this.request(`/audit?${query.toString()}`);
253
253
  }
254
- /**
255
- * Rotates an existing key in place, returning a fresh `sk-` secret while
256
- * keeping the same alias/budget/limits. `token` is the hashed token LiteLLM
257
- * stores (the same value used for delete/update) — passed in the path because
258
- * it isn't an `sk-` prefixed secret, so LiteLLM matches it without re-hashing.
259
- */
260
- async regenerateKey(token) {
261
- return this.request(
262
- `/key/${encodeURIComponent(token)}/regenerate`,
263
- { method: "POST", body: JSON.stringify({}) }
264
- );
265
- }
266
254
  /**
267
255
  * Returns the proxy's model catalogue normalised to the ModelInfo shape.
268
256
  *
@@ -286,7 +274,9 @@ var LiteLLMClient = class {
286
274
  supports_function_calling: info.supports_function_calling ?? m.supports_function_calling,
287
275
  supports_vision: info.supports_vision ?? m.supports_vision,
288
276
  input_cost_per_token: info.input_cost_per_token ?? params.input_cost_per_token,
289
- output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token
277
+ output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token,
278
+ max_input_tokens: info.max_input_tokens ?? m.max_input_tokens,
279
+ max_output_tokens: info.max_output_tokens ?? m.max_output_tokens
290
280
  };
291
281
  });
292
282
  const filtered = normalised.filter((m) => m.model_name);
@@ -440,7 +430,7 @@ var LiteLLMClient = class {
440
430
  daily_by_model
441
431
  };
442
432
  }
443
- async getUsage(startDate, endDate, userId, _groupBy) {
433
+ async getUsage(startDate, endDate, userId) {
444
434
  const params = new URLSearchParams({
445
435
  start_date: startDate,
446
436
  end_date: endDate,
@@ -480,6 +470,256 @@ var LiteLLMClient = class {
480
470
  }
481
471
  };
482
472
 
473
+ // src/openapi.ts
474
+ var openApiSpec = {
475
+ openapi: "3.1.0",
476
+ info: {
477
+ title: "LiteLLM Governance Plugin API",
478
+ version: "0.1.0",
479
+ description: "Backstage backend surface for the LiteLLM governance plugin. UI routes authenticate via the Backstage identity system; CLI bridge routes authenticate with a Keycloak access token."
480
+ },
481
+ servers: [
482
+ { url: "/api/litellm", description: "Backstage backend plugin mount point" }
483
+ ],
484
+ tags: [
485
+ { name: "System", description: "Health and configuration" },
486
+ { name: "User", description: "Current user info and provisioning" },
487
+ { name: "Keys", description: "Virtual key lifecycle" },
488
+ { name: "Models", description: "Model catalogue" },
489
+ { name: "Teams", description: "Team membership and usage" },
490
+ { name: "Usage", description: "Spend and traffic analytics" },
491
+ { name: "Audit", description: "Audit logs (RBAC-gated)" },
492
+ { name: "Provisioning", description: "Provisioning dry-run (RBAC-gated)" },
493
+ { name: "Bridge", description: "CLI bridge (Keycloak-token auth)" }
494
+ ],
495
+ paths: {
496
+ "/health": {
497
+ get: {
498
+ tags: ["System"],
499
+ summary: "Health check",
500
+ responses: {
501
+ "200": {
502
+ description: "Plugin health",
503
+ content: { "application/json": { schema: { type: "object", properties: {
504
+ status: { type: "string" },
505
+ provisioning: { type: "boolean" }
506
+ } } } }
507
+ }
508
+ }
509
+ }
510
+ },
511
+ "/config": {
512
+ get: {
513
+ tags: ["System"],
514
+ summary: "Public LiteLLM proxy base URL (for snippet generation)",
515
+ responses: {
516
+ "200": {
517
+ description: "Proxy URL",
518
+ content: { "application/json": { schema: { type: "object", properties: {
519
+ baseUrl: { type: "string" }
520
+ } } } }
521
+ }
522
+ }
523
+ }
524
+ },
525
+ "/openapi.json": {
526
+ get: {
527
+ tags: ["System"],
528
+ summary: "This OpenAPI document",
529
+ responses: { "200": { description: "OpenAPI 3.1 JSON" } }
530
+ }
531
+ },
532
+ "/user/info": {
533
+ get: {
534
+ tags: ["User"],
535
+ summary: "Get the current user's info and quotas",
536
+ responses: {
537
+ "200": { description: "User info" },
538
+ "404": { description: "User not found in LiteLLM (provisioning disabled)" }
539
+ }
540
+ }
541
+ },
542
+ "/keys": {
543
+ get: {
544
+ tags: ["Keys"],
545
+ summary: "List the caller's virtual keys",
546
+ responses: { "200": { description: "Array of keys" } }
547
+ }
548
+ },
549
+ "/keys/generate": {
550
+ post: {
551
+ tags: ["Keys"],
552
+ summary: "Generate a new virtual key",
553
+ requestBody: {
554
+ required: true,
555
+ content: { "application/json": { schema: { type: "object", properties: {
556
+ alias: { type: "string" },
557
+ models: { type: "array", items: { type: "string" } },
558
+ team_id: { type: "string" },
559
+ duration: { type: "string" },
560
+ max_budget: { type: "number", nullable: true, description: "Positive number caps spend; null = unlimited" },
561
+ tpm_limit: { type: "number" },
562
+ rpm_limit: { type: "number" }
563
+ } } } }
564
+ },
565
+ responses: {
566
+ "200": { description: "Generated key (includes the raw secret \u2014 shown once)" },
567
+ "400": { description: "Missing required fields" }
568
+ }
569
+ }
570
+ },
571
+ "/keys/{keyId}": {
572
+ delete: {
573
+ tags: ["Keys"],
574
+ summary: "Revoke/delete a virtual key (caller must own it)",
575
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
576
+ responses: { "200": { description: "Deleted" }, "403": { description: "Not the key owner" } }
577
+ }
578
+ },
579
+ "/keys/{keyId}/update": {
580
+ post: {
581
+ tags: ["Keys"],
582
+ summary: "Update alias / models / budget / limits (caller must own it)",
583
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
584
+ requestBody: { content: { "application/json": { schema: { type: "object" } } } },
585
+ responses: { "200": { description: "Updated" }, "403": { description: "Not the key owner" } }
586
+ }
587
+ },
588
+ "/keys/{keyId}/block": {
589
+ post: {
590
+ tags: ["Keys"],
591
+ summary: "Suspend a key without revoking (caller must own it)",
592
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
593
+ responses: { "200": { description: "Blocked" }, "403": { description: "Not the key owner" } }
594
+ }
595
+ },
596
+ "/keys/{keyId}/unblock": {
597
+ post: {
598
+ tags: ["Keys"],
599
+ summary: "Re-enable a blocked key (caller must own it)",
600
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
601
+ responses: { "200": { description: "Unblocked" }, "403": { description: "Not the key owner" } }
602
+ }
603
+ },
604
+ "/keys/{keyId}/reset_spend": {
605
+ post: {
606
+ tags: ["Keys"],
607
+ summary: "Zero out a key spend counter (caller must own it)",
608
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
609
+ responses: { "200": { description: "Spend reset" }, "403": { description: "Not the key owner" } }
610
+ }
611
+ },
612
+ "/models": {
613
+ get: {
614
+ tags: ["Models"],
615
+ summary: "List available LLM models",
616
+ responses: { "200": { description: "Model catalogue" } }
617
+ }
618
+ },
619
+ "/teams": {
620
+ get: {
621
+ tags: ["Teams"],
622
+ summary: "List teams the current user belongs to",
623
+ responses: { "200": { description: "Array of teams" } }
624
+ }
625
+ },
626
+ "/teams/{teamId}/usage": {
627
+ get: {
628
+ tags: ["Teams"],
629
+ summary: "Usage metrics for a team",
630
+ parameters: [
631
+ { name: "teamId", in: "path", required: true, schema: { type: "string" } },
632
+ { name: "start_date", in: "query", required: true, schema: { type: "string" } },
633
+ { name: "end_date", in: "query", required: true, schema: { type: "string" } }
634
+ ],
635
+ responses: { "200": { description: "Team usage" }, "400": { description: "Missing date range" } }
636
+ }
637
+ },
638
+ "/usage": {
639
+ get: {
640
+ tags: ["Usage"],
641
+ summary: "Usage metrics for the current user",
642
+ parameters: [
643
+ { name: "start_date", in: "query", required: true, schema: { type: "string" } },
644
+ { name: "end_date", in: "query", required: true, schema: { type: "string" } }
645
+ ],
646
+ responses: { "200": { description: "Usage metrics" }, "400": { description: "Missing date range" } }
647
+ }
648
+ },
649
+ "/audit": {
650
+ get: {
651
+ tags: ["Audit"],
652
+ summary: "Audit logs (gated by litellm.audit.group membership)",
653
+ parameters: [
654
+ { name: "page", in: "query", schema: { type: "integer" } },
655
+ { name: "page_size", in: "query", schema: { type: "integer" } },
656
+ { name: "start_date", in: "query", schema: { type: "string" } },
657
+ { name: "end_date", in: "query", schema: { type: "string" } },
658
+ { name: "action", in: "query", schema: { type: "string" } },
659
+ { name: "table_name", in: "query", schema: { type: "string" } },
660
+ { name: "changed_by", in: "query", schema: { type: "string" } }
661
+ ],
662
+ responses: { "200": { description: "Paginated audit logs" }, "403": { description: "Not configured or not authorized" } }
663
+ }
664
+ },
665
+ "/provisioning/preview": {
666
+ get: {
667
+ tags: ["Provisioning"],
668
+ summary: "Resolve which role a Backstage group maps to (dry-run)",
669
+ parameters: [
670
+ { name: "group", in: "query", required: true, schema: { type: "string" }, description: "e.g. group:default/ai-platform" }
671
+ ],
672
+ responses: {
673
+ "200": { description: "Resolved role and effective defaults" },
674
+ "400": { description: "Missing group parameter" },
675
+ "403": { description: "Not configured or not authorized" }
676
+ }
677
+ }
678
+ },
679
+ "/bridge/health": {
680
+ get: {
681
+ tags: ["Bridge"],
682
+ summary: "Bridge health + configured clientId (no auth)",
683
+ responses: { "200": { description: "Bridge health" } }
684
+ }
685
+ },
686
+ "/bridge/keys": {
687
+ get: {
688
+ tags: ["Bridge"],
689
+ summary: "List the caller virtual keys (Keycloak token auth)",
690
+ responses: { "200": { description: "Array of keys" }, "401": { description: "Invalid token" } }
691
+ },
692
+ post: {
693
+ tags: ["Bridge"],
694
+ summary: "Mint a virtual key for the caller (Keycloak token auth)",
695
+ requestBody: { content: { "application/json": { schema: { type: "object" } } } },
696
+ responses: { "200": { description: "Generated key" }, "401": { description: "Invalid token" } }
697
+ }
698
+ },
699
+ "/bridge/models": {
700
+ get: {
701
+ tags: ["Bridge"],
702
+ summary: "List available LLM models (Keycloak token auth)",
703
+ responses: { "200": { description: "Model catalogue" }, "401": { description: "Invalid token" } }
704
+ }
705
+ }
706
+ },
707
+ components: {
708
+ securitySchemes: {
709
+ backstageUserToken: {
710
+ type: "http",
711
+ scheme: "bearer",
712
+ description: "Backstage-issued user token (Authorization: Bearer <token>)."
713
+ },
714
+ keycloakAccessToken: {
715
+ type: "http",
716
+ scheme: "bearer",
717
+ description: "Raw Keycloak access token. Bridge routes only; verified against the realm JWKS."
718
+ }
719
+ }
720
+ }
721
+ };
722
+
483
723
  // src/provisioning.ts
484
724
  function toLiteLLMUserId(userEntityRef, userIdDomain) {
485
725
  const name = userEntityRef.split("/").pop() ?? userEntityRef;
@@ -2213,28 +2453,6 @@ async function bridgeGenerateKey(client, claims, provisioningEnabled, provisioni
2213
2453
  };
2214
2454
  return client.generateKey(enriched);
2215
2455
  }
2216
- async function bridgeRegenerateKey(client, claims, provisioningEnabled, provisioningDefaults, logger, alias, userIdDomain) {
2217
- await getOrProvisionUserFromClaims(
2218
- client,
2219
- claims,
2220
- provisioningEnabled,
2221
- provisioningDefaults,
2222
- logger,
2223
- userIdDomain
2224
- );
2225
- const userId = resolveBridgeUserId(claims, userIdDomain);
2226
- const keys = await client.listKeys(userId);
2227
- const match = keys.find((k) => k.key_alias === alias);
2228
- if (!match) {
2229
- throw new ProvisioningError(
2230
- "Key not found",
2231
- `No key with alias "${alias}" for this identity; mint one first.`,
2232
- false,
2233
- 404
2234
- );
2235
- }
2236
- return client.regenerateKey(match.token);
2237
- }
2238
2456
 
2239
2457
  // src/router.ts
2240
2458
  async function createRouter(options) {
@@ -2261,6 +2479,57 @@ async function createRouter(options) {
2261
2479
  router.get("/config", (_req, res) => {
2262
2480
  res.json({ baseUrl: publicBaseUrl });
2263
2481
  });
2482
+ router.get("/openapi.json", (_req, res) => {
2483
+ res.json(openApiSpec);
2484
+ });
2485
+ router.get("/provisioning/preview", async (req, res) => {
2486
+ if (!auditGroup) {
2487
+ res.status(403).json({ error: "Preview is not configured (litellm.audit.group not set)" });
2488
+ return;
2489
+ }
2490
+ const tokenEntityRef = await resolveUserId(req, auth);
2491
+ if (!tokenEntityRef) {
2492
+ res.status(401).json({ error: "Authentication required" });
2493
+ return;
2494
+ }
2495
+ const allowed = await isUserMemberOfGroup(
2496
+ tokenEntityRef,
2497
+ auditGroup,
2498
+ catalogClient,
2499
+ auth,
2500
+ logger
2501
+ );
2502
+ if (!allowed) {
2503
+ res.status(403).json({ error: "Access denied: not a member of the audit group" });
2504
+ return;
2505
+ }
2506
+ const group = req.query.group?.trim();
2507
+ if (!group) {
2508
+ res.status(400).json({ error: "group query parameter is required (e.g. group=group:default/ai-platform)" });
2509
+ return;
2510
+ }
2511
+ if (!roleConfigs.length) {
2512
+ res.json({
2513
+ group,
2514
+ matched_role: null,
2515
+ effective_defaults: provisioningDefaults,
2516
+ note: "No litellm.provisioning.roles configured \u2014 every group receives the base defaults."
2517
+ });
2518
+ return;
2519
+ }
2520
+ try {
2521
+ const matched = roleConfigs.find((rc) => rc.group === group);
2522
+ const effective = matched ? applyRoleOverrides(provisioningDefaults, matched) : provisioningDefaults;
2523
+ res.json({
2524
+ group,
2525
+ matched_role: matched?.group ?? null,
2526
+ effective_defaults: effective
2527
+ });
2528
+ } catch (error) {
2529
+ logger.error("Failed to resolve provisioning preview", error);
2530
+ res.status(500).json({ error: error.message });
2531
+ }
2532
+ });
2264
2533
  router.get("/user/info", async (req, res) => {
2265
2534
  try {
2266
2535
  const tokenEntityRef = await resolveUserId(req, auth);
@@ -2319,22 +2588,26 @@ async function createRouter(options) {
2319
2588
  res.status(500).json({ error: error.message });
2320
2589
  }
2321
2590
  });
2322
- router.post("/keys/:keyId/regenerate", async (req, res) => {
2323
- try {
2324
- const { keyId } = req.params;
2325
- if (!keyId) {
2326
- res.status(400).json({ error: "keyId is required" });
2327
- return;
2328
- }
2329
- const tokenEntityRef = await resolveUserId(req, auth);
2330
- const result = await client.regenerateKey(keyId);
2331
- logger.info({ action: "key.rotate", userId: tokenEntityRef ?? "unknown", keyId });
2332
- res.json(result);
2333
- } catch (error) {
2334
- logger.error("Failed to rotate key", error);
2335
- res.status(500).json({ error: error.message });
2591
+ async function authorizeKeyAction(req, keyId) {
2592
+ const tokenEntityRef = await resolveUserId(req, auth);
2593
+ const userId = tokenEntityRef ? toLiteLLMUserId(tokenEntityRef, userIdDomain) : req.query.user_id;
2594
+ if (!userId) {
2595
+ throw { status: 403, body: { error: "Cannot verify key ownership without an authenticated user" } };
2336
2596
  }
2337
- });
2597
+ const ownKeys = await client.listKeys(userId);
2598
+ const owns = ownKeys.some((k) => (k.token ?? k.key) === keyId);
2599
+ if (!owns) {
2600
+ throw { status: 403, body: { error: "Access denied: key does not belong to the caller" } };
2601
+ }
2602
+ return { tokenEntityRef, userId };
2603
+ }
2604
+ function sendOwnershipError(err, res) {
2605
+ if (err && typeof err.status === "number" && err.body) {
2606
+ res.status(err.status).json(err.body);
2607
+ return true;
2608
+ }
2609
+ return false;
2610
+ }
2338
2611
  router.post("/keys/generate", async (req, res) => {
2339
2612
  try {
2340
2613
  const body = req.body ?? {};
@@ -2400,12 +2673,13 @@ async function createRouter(options) {
2400
2673
  res.status(400).json({ error: "keyId is required" });
2401
2674
  return;
2402
2675
  }
2403
- const tokenEntityRef = await resolveUserId(req, auth);
2676
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2404
2677
  const request = { ...req.body, key: keyId };
2405
2678
  const result = await client.updateKey(request);
2406
2679
  logger.info({ action: "key.update", userId: tokenEntityRef ?? "unknown", keyId });
2407
2680
  res.json(result);
2408
2681
  } catch (error) {
2682
+ if (sendOwnershipError(error, res)) return;
2409
2683
  logger.error("Failed to update key", error);
2410
2684
  res.status(500).json({ error: error.message });
2411
2685
  }
@@ -2417,11 +2691,12 @@ async function createRouter(options) {
2417
2691
  res.status(400).json({ error: "keyId is required" });
2418
2692
  return;
2419
2693
  }
2420
- const deleteEntityRef = await resolveUserId(req, auth);
2694
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2421
2695
  await client.deleteKeys({ keys: [keyId] });
2422
- logger.info({ action: "key.delete", userId: deleteEntityRef ?? "unknown", keyId });
2696
+ logger.info({ action: "key.delete", userId: tokenEntityRef ?? "unknown", keyId });
2423
2697
  res.json({ success: true });
2424
2698
  } catch (error) {
2699
+ if (sendOwnershipError(error, res)) return;
2425
2700
  logger.error("Failed to delete key", error);
2426
2701
  res.status(500).json({ error: error.message });
2427
2702
  }
@@ -2429,11 +2704,12 @@ async function createRouter(options) {
2429
2704
  router.post("/keys/:keyId/block", async (req, res) => {
2430
2705
  try {
2431
2706
  const { keyId } = req.params;
2432
- const tokenEntityRef = await resolveUserId(req, auth);
2707
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2433
2708
  await client.blockKey(keyId);
2434
2709
  logger.info({ action: "key.block", userId: tokenEntityRef ?? "unknown", keyId });
2435
2710
  res.json({ success: true });
2436
2711
  } catch (error) {
2712
+ if (sendOwnershipError(error, res)) return;
2437
2713
  logger.error("Failed to block key", error);
2438
2714
  res.status(500).json({ error: error.message });
2439
2715
  }
@@ -2441,11 +2717,12 @@ async function createRouter(options) {
2441
2717
  router.post("/keys/:keyId/unblock", async (req, res) => {
2442
2718
  try {
2443
2719
  const { keyId } = req.params;
2444
- const tokenEntityRef = await resolveUserId(req, auth);
2720
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2445
2721
  await client.unblockKey(keyId);
2446
2722
  logger.info({ action: "key.unblock", userId: tokenEntityRef ?? "unknown", keyId });
2447
2723
  res.json({ success: true });
2448
2724
  } catch (error) {
2725
+ if (sendOwnershipError(error, res)) return;
2449
2726
  logger.error("Failed to unblock key", error);
2450
2727
  res.status(500).json({ error: error.message });
2451
2728
  }
@@ -2453,11 +2730,12 @@ async function createRouter(options) {
2453
2730
  router.post("/keys/:keyId/reset_spend", async (req, res) => {
2454
2731
  try {
2455
2732
  const { keyId } = req.params;
2456
- const tokenEntityRef = await resolveUserId(req, auth);
2733
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2457
2734
  await client.resetKeySpend(keyId);
2458
2735
  logger.info({ action: "key.reset_spend", userId: tokenEntityRef ?? "unknown", keyId });
2459
2736
  res.json({ success: true });
2460
2737
  } catch (error) {
2738
+ if (sendOwnershipError(error, res)) return;
2461
2739
  logger.error("Failed to reset key spend", error);
2462
2740
  res.status(500).json({ error: error.message });
2463
2741
  }
@@ -2567,7 +2845,7 @@ async function createRouter(options) {
2567
2845
  });
2568
2846
  router.get("/usage", async (req, res) => {
2569
2847
  try {
2570
- const { start_date, end_date, group_by } = req.query;
2848
+ const { start_date, end_date } = req.query;
2571
2849
  if (!start_date || !end_date) {
2572
2850
  res.status(400).json({ error: "start_date and end_date are required" });
2573
2851
  return;
@@ -2590,8 +2868,7 @@ async function createRouter(options) {
2590
2868
  const usage = await client.getUsage(
2591
2869
  start_date,
2592
2870
  end_date,
2593
- userId,
2594
- group_by
2871
+ userId
2595
2872
  );
2596
2873
  res.json(usage);
2597
2874
  } catch (error) {
@@ -2669,28 +2946,6 @@ async function createRouter(options) {
2669
2946
  handleBridgeError(error, res);
2670
2947
  }
2671
2948
  });
2672
- router.post("/bridge/keys/regenerate", async (req, res) => {
2673
- try {
2674
- const claims = await requireClaims(req);
2675
- const alias = (req.body ?? {}).alias?.trim();
2676
- if (!alias) {
2677
- res.status(400).json({ error: "alias is required" });
2678
- return;
2679
- }
2680
- const result = await bridgeRegenerateKey(
2681
- client,
2682
- claims,
2683
- provisioningEnabled,
2684
- provisioningDefaults,
2685
- logger,
2686
- alias,
2687
- userIdDomain
2688
- );
2689
- res.json(result);
2690
- } catch (error) {
2691
- handleBridgeError(error, res);
2692
- }
2693
- });
2694
2949
  router.get("/bridge/models", async (req, res) => {
2695
2950
  try {
2696
2951
  await requireClaims(req);