@okrlinkhub/agent-bridge 3.0.1 → 3.0.2

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.
@@ -38,6 +38,7 @@ export interface AgentBridgeFunctionMetadata {
38
38
  description?: string;
39
39
  riskLevel?: "low" | "medium" | "high";
40
40
  category?: string;
41
+ authMode?: "service" | "user";
41
42
  }
42
43
 
43
44
  export interface AgentBridgeConfig {
@@ -156,6 +157,8 @@ type RegisterRoutesOptions = {
156
157
  pathPrefix?: string;
157
158
  serviceKeys?: Record<string, string>;
158
159
  serviceKeysEnvVar?: string;
160
+ auditHashSaltEnvVar?: string;
161
+ linkingMode?: "component_api_only";
159
162
  };
160
163
 
161
164
  export function registerRoutes(
@@ -165,10 +168,17 @@ export function registerRoutes(
165
168
  options?: RegisterRoutesOptions,
166
169
  ) {
167
170
  const prefix = options?.pathPrefix ?? "/agent";
171
+ const linkingMode = options?.linkingMode ?? "component_api_only";
172
+ if (linkingMode !== "component_api_only") {
173
+ throw new Error(`Unsupported linkingMode: ${linkingMode}`);
174
+ }
168
175
  const configuredServiceKeys = resolveConfiguredServiceKeys({
169
176
  serviceKeys: options?.serviceKeys,
170
177
  serviceKeysEnvVar: options?.serviceKeysEnvVar ?? "AGENT_BRIDGE_SERVICE_KEYS_JSON",
171
178
  });
179
+ const auditHashSalt =
180
+ readRuntimeEnv(options?.auditHashSaltEnvVar ?? "AGENT_BRIDGE_AUDIT_HASH_SALT") ??
181
+ "";
172
182
  const normalizedConfig = normalizeAgentBridgeConfig(bridgeConfig);
173
183
  const availableFunctionKeys = Object.keys(normalizedConfig.functions);
174
184
 
@@ -234,6 +244,10 @@ export function registerRoutes(
234
244
  }
235
245
 
236
246
  const startTime = Date.now();
247
+ const linkAuditContext = await extractLinkAuditContextFromRequest({
248
+ request,
249
+ auditHashSalt,
250
+ });
237
251
  try {
238
252
  const args = body.args ?? {};
239
253
  let result: unknown;
@@ -262,6 +276,7 @@ export function registerRoutes(
262
276
  result,
263
277
  duration: Date.now() - startTime,
264
278
  timestamp: Date.now(),
279
+ ...linkAuditContext,
265
280
  });
266
281
 
267
282
  return jsonResponse({ success: true, result }, 200);
@@ -277,8 +292,10 @@ export function registerRoutes(
277
292
  functionKey,
278
293
  args: body.args ?? {},
279
294
  error: errorMessage,
295
+ errorCode: "bridge_execution_error",
280
296
  duration: Date.now() - startTime,
281
297
  timestamp: Date.now(),
298
+ ...linkAuditContext,
282
299
  });
283
300
 
284
301
  return jsonResponse({ success: false, error: errorMessage }, 500);
@@ -443,6 +460,58 @@ function readRuntimeEnv(name: string): string | undefined {
443
460
  return trimmed.length > 0 ? trimmed : undefined;
444
461
  }
445
462
 
463
+ async function extractLinkAuditContextFromRequest(args: {
464
+ request: Request;
465
+ auditHashSalt: string;
466
+ }): Promise<{
467
+ linkedProvider?: string;
468
+ providerUserIdHash?: string;
469
+ appUserSubjectHash?: string;
470
+ linkStatus?: string;
471
+ }> {
472
+ const linkedProvider =
473
+ args.request.headers.get("X-Agent-Link-Provider")?.trim().toLowerCase() || undefined;
474
+ const providerUserIdRaw =
475
+ args.request.headers.get("X-Agent-Link-Provider-User-Id")?.trim() || undefined;
476
+ const appUserSubjectRaw =
477
+ args.request.headers.get("X-Agent-Link-User-Subject")?.trim() || undefined;
478
+ const linkStatus = args.request.headers.get("X-Agent-Link-Status")?.trim() || undefined;
479
+ const providerUserIdHash = providerUserIdRaw
480
+ ? await hashAuditIdentifier(providerUserIdRaw, args.auditHashSalt)
481
+ : undefined;
482
+ const appUserSubjectHash = appUserSubjectRaw
483
+ ? await hashAuditIdentifier(appUserSubjectRaw, args.auditHashSalt)
484
+ : undefined;
485
+ const auditContext: {
486
+ linkedProvider?: string;
487
+ providerUserIdHash?: string;
488
+ appUserSubjectHash?: string;
489
+ linkStatus?: string;
490
+ } = {};
491
+ if (linkedProvider) {
492
+ auditContext.linkedProvider = linkedProvider;
493
+ }
494
+ if (providerUserIdHash) {
495
+ auditContext.providerUserIdHash = providerUserIdHash;
496
+ }
497
+ if (appUserSubjectHash) {
498
+ auditContext.appUserSubjectHash = appUserSubjectHash;
499
+ }
500
+ if (linkStatus) {
501
+ auditContext.linkStatus = linkStatus;
502
+ }
503
+ return auditContext;
504
+ }
505
+
506
+ async function hashAuditIdentifier(value: string, salt: string): Promise<string> {
507
+ const encoder = new TextEncoder();
508
+ const input = encoder.encode(`${salt}:${value}`);
509
+ const digest = await crypto.subtle.digest("SHA-256", input);
510
+ return Array.from(new Uint8Array(digest))
511
+ .map((byte) => byte.toString(16).padStart(2, "0"))
512
+ .join("");
513
+ }
514
+
446
515
  function resolveConfiguredServiceKeys(args: {
447
516
  serviceKeys?: Record<string, string>;
448
517
  serviceKeysEnvVar: string;
@@ -0,0 +1,145 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { components, initConvexTest } from "./setup.test.js";
3
+
4
+ describe("component linking lifecycle", () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(new Date("2026-01-01T10:00:00.000Z"));
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ test("upsert + resolve are idempotent and appKey scoped", async () => {
15
+ const t = initConvexTest();
16
+
17
+ const first = await t.mutation(components.agentBridge.linking.upsertLink, {
18
+ provider: "discord",
19
+ providerUserId: "user-123",
20
+ appKey: "CRM",
21
+ appUserSubject: "convex-user-a",
22
+ expiresInDays: 30,
23
+ metadata: { source: "discord-command" },
24
+ });
25
+ expect(first.created).toBe(true);
26
+
27
+ const second = await t.mutation(components.agentBridge.linking.upsertLink, {
28
+ provider: "discord",
29
+ providerUserId: "user-123",
30
+ appKey: "crm",
31
+ appUserSubject: "convex-user-b",
32
+ expiresInDays: 30,
33
+ });
34
+ expect(second.created).toBe(false);
35
+ expect(second.linkId).toBe(first.linkId);
36
+
37
+ const resolved = await t.mutation(components.agentBridge.linking.resolveLink, {
38
+ provider: "discord",
39
+ providerUserId: "user-123",
40
+ appKey: "crm",
41
+ });
42
+ expect(resolved.ok).toBe(true);
43
+ if (!resolved.ok) {
44
+ throw new Error("Expected resolve to succeed");
45
+ }
46
+ expect(resolved.link.appUserSubject).toBe("convex-user-b");
47
+
48
+ const wrongApp = await t.mutation(components.agentBridge.linking.resolveLink, {
49
+ provider: "discord",
50
+ providerUserId: "user-123",
51
+ appKey: "billing",
52
+ });
53
+ expect(wrongApp.ok).toBe(false);
54
+ if (wrongApp.ok) {
55
+ throw new Error("Expected app scoped lookup to fail");
56
+ }
57
+ expect(wrongApp.errorCode).toBe("link_not_found");
58
+ });
59
+
60
+ test("revoke marks link as revoked", async () => {
61
+ const t = initConvexTest();
62
+ const created = await t.mutation(components.agentBridge.linking.upsertLink, {
63
+ provider: "telegram",
64
+ providerUserId: "tg-77",
65
+ appKey: "crm",
66
+ appUserSubject: "convex-user-77",
67
+ });
68
+
69
+ const revokeResult = await t.mutation(components.agentBridge.linking.revokeLink, {
70
+ linkId: created.linkId,
71
+ });
72
+ expect(revokeResult.revoked).toBe(true);
73
+
74
+ const resolved = await t.mutation(components.agentBridge.linking.resolveLink, {
75
+ provider: "telegram",
76
+ providerUserId: "tg-77",
77
+ appKey: "crm",
78
+ });
79
+ expect(resolved.ok).toBe(false);
80
+ if (resolved.ok) {
81
+ throw new Error("Expected revoked link to fail");
82
+ }
83
+ expect(resolved.errorCode).toBe("link_revoked");
84
+ expect(resolved.statusCode).toBe(410);
85
+ });
86
+
87
+ test("expired links are detected and converted to expired status", async () => {
88
+ const t = initConvexTest();
89
+ await t.mutation(components.agentBridge.linking.upsertLink, {
90
+ provider: "discord",
91
+ providerUserId: "exp-1",
92
+ appKey: "crm",
93
+ appUserSubject: "convex-user-exp",
94
+ expiresInDays: 1,
95
+ });
96
+
97
+ vi.setSystemTime(new Date("2026-01-03T10:00:00.000Z"));
98
+ const resolved = await t.mutation(components.agentBridge.linking.resolveLink, {
99
+ provider: "discord",
100
+ providerUserId: "exp-1",
101
+ appKey: "crm",
102
+ });
103
+
104
+ expect(resolved.ok).toBe(false);
105
+ if (resolved.ok) {
106
+ throw new Error("Expected expired link to fail");
107
+ }
108
+ expect(resolved.errorCode).toBe("link_expired");
109
+ expect(resolved.statusCode).toBe(410);
110
+ });
111
+
112
+ test("resolveLink applies per-link rate limiting", async () => {
113
+ const t = initConvexTest();
114
+ await t.mutation(components.agentBridge.linking.upsertLink, {
115
+ provider: "discord",
116
+ providerUserId: "rl-1",
117
+ appKey: "crm",
118
+ appUserSubject: "convex-user-rl",
119
+ });
120
+
121
+ const first = await t.mutation(components.agentBridge.linking.resolveLink, {
122
+ provider: "discord",
123
+ providerUserId: "rl-1",
124
+ appKey: "crm",
125
+ maxRequestsPerWindow: 1,
126
+ windowSeconds: 60,
127
+ });
128
+ expect(first.ok).toBe(true);
129
+
130
+ const second = await t.mutation(components.agentBridge.linking.resolveLink, {
131
+ provider: "discord",
132
+ providerUserId: "rl-1",
133
+ appKey: "crm",
134
+ maxRequestsPerWindow: 1,
135
+ windowSeconds: 60,
136
+ });
137
+ expect(second.ok).toBe(false);
138
+ if (second.ok) {
139
+ throw new Error("Expected second resolve to be rate limited");
140
+ }
141
+ expect(second.errorCode).toBe("link_rate_limited");
142
+ expect(second.statusCode).toBe(429);
143
+ expect(second.retryAfterSeconds).toBeGreaterThanOrEqual(1);
144
+ });
145
+ });
@@ -11,6 +11,7 @@
11
11
  import type * as agentBridgeUtils from "../agentBridgeUtils.js";
12
12
  import type * as agents from "../agents.js";
13
13
  import type * as gateway from "../gateway.js";
14
+ import type * as linking from "../linking.js";
14
15
  import type * as permissions from "../permissions.js";
15
16
 
16
17
  import type {
@@ -24,6 +25,7 @@ const fullApi: ApiFromModules<{
24
25
  agentBridgeUtils: typeof agentBridgeUtils;
25
26
  agents: typeof agents;
26
27
  gateway: typeof gateway;
28
+ linking: typeof linking;
27
29
  permissions: typeof permissions;
28
30
  }> = anyApi as any;
29
31
 
@@ -107,10 +107,16 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
107
107
  "internal",
108
108
  {
109
109
  agentId: string;
110
+ appUserSubjectHash?: string;
110
111
  args: any;
111
112
  duration: number;
112
113
  error?: string;
114
+ errorCode?: string;
113
115
  functionKey: string;
116
+ linkStatus?: string;
117
+ linkedProvider?: string;
118
+ providerUserIdHash?: string;
119
+ rateLimited?: boolean;
114
120
  result?: any;
115
121
  serviceId?: string;
116
122
  timestamp: number;
@@ -130,10 +136,16 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
130
136
  Array<{
131
137
  _id: string;
132
138
  agentId: string;
139
+ appUserSubjectHash?: string;
133
140
  args: any;
134
141
  duration: number;
135
142
  error?: string;
143
+ errorCode?: string;
136
144
  functionKey: string;
145
+ linkStatus?: string;
146
+ linkedProvider?: string;
147
+ providerUserIdHash?: string;
148
+ rateLimited?: boolean;
137
149
  result?: any;
138
150
  serviceId?: string;
139
151
  timestamp: number;
@@ -141,6 +153,108 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
141
153
  Name
142
154
  >;
143
155
  };
156
+ linking: {
157
+ listLinks: FunctionReference<
158
+ "query",
159
+ "internal",
160
+ {
161
+ appKey?: string;
162
+ limit?: number;
163
+ provider?: string;
164
+ status?: "active" | "revoked" | "expired";
165
+ },
166
+ Array<{
167
+ _id: string;
168
+ appKey: string;
169
+ appUserSubject: string;
170
+ createdAt: number;
171
+ expiresAt?: number;
172
+ lastUsedAt?: number;
173
+ metadata?: any;
174
+ provider: string;
175
+ providerUserId: string;
176
+ refreshTokenCiphertext?: string;
177
+ refreshTokenExpiresAt?: number;
178
+ revokedAt?: number;
179
+ status: "active" | "revoked" | "expired";
180
+ tokenVersion?: number;
181
+ updatedAt: number;
182
+ }>,
183
+ Name
184
+ >;
185
+ resolveLink: FunctionReference<
186
+ "mutation",
187
+ "internal",
188
+ {
189
+ appKey: string;
190
+ extendExpiryDaysOnUse?: number;
191
+ maxRequestsPerWindow?: number;
192
+ provider: string;
193
+ providerUserId: string;
194
+ windowSeconds?: number;
195
+ },
196
+ | {
197
+ link: {
198
+ _id: string;
199
+ appKey: string;
200
+ appUserSubject: string;
201
+ createdAt: number;
202
+ expiresAt?: number;
203
+ lastUsedAt?: number;
204
+ metadata?: any;
205
+ provider: string;
206
+ providerUserId: string;
207
+ refreshTokenCiphertext?: string;
208
+ refreshTokenExpiresAt?: number;
209
+ revokedAt?: number;
210
+ status: "active" | "revoked" | "expired";
211
+ tokenVersion?: number;
212
+ updatedAt: number;
213
+ };
214
+ ok: true;
215
+ }
216
+ | {
217
+ errorCode:
218
+ | "link_not_found"
219
+ | "link_revoked"
220
+ | "link_expired"
221
+ | "link_rate_limited";
222
+ ok: false;
223
+ retryAfterSeconds?: number;
224
+ statusCode: number;
225
+ },
226
+ Name
227
+ >;
228
+ revokeLink: FunctionReference<
229
+ "mutation",
230
+ "internal",
231
+ {
232
+ appKey?: string;
233
+ linkId?: string;
234
+ provider?: string;
235
+ providerUserId?: string;
236
+ },
237
+ { linkId?: string; revoked: boolean },
238
+ Name
239
+ >;
240
+ upsertLink: FunctionReference<
241
+ "mutation",
242
+ "internal",
243
+ {
244
+ appKey: string;
245
+ appUserSubject: string;
246
+ expiresInDays?: number;
247
+ metadata?: any;
248
+ provider: string;
249
+ providerUserId: string;
250
+ refreshTokenCiphertext?: string;
251
+ refreshTokenExpiresAt?: number;
252
+ tokenVersion?: number;
253
+ },
254
+ { created: boolean; expiresAt?: number; linkId: string },
255
+ Name
256
+ >;
257
+ };
144
258
  permissions: {
145
259
  listAgentPermissions: FunctionReference<
146
260
  "query",
@@ -105,7 +105,13 @@ export const logAccess = mutation({
105
105
  args: v.any(),
106
106
  result: v.optional(v.any()),
107
107
  error: v.optional(v.string()),
108
+ errorCode: v.optional(v.string()),
108
109
  duration: v.number(),
110
+ linkedProvider: v.optional(v.string()),
111
+ providerUserIdHash: v.optional(v.string()),
112
+ appUserSubjectHash: v.optional(v.string()),
113
+ linkStatus: v.optional(v.string()),
114
+ rateLimited: v.optional(v.boolean()),
109
115
  timestamp: v.number(),
110
116
  },
111
117
  returns: v.null(),
@@ -118,7 +124,13 @@ export const logAccess = mutation({
118
124
  args: args.args,
119
125
  result: args.result,
120
126
  error: args.error,
127
+ errorCode: args.errorCode,
121
128
  duration: args.duration,
129
+ linkedProvider: args.linkedProvider,
130
+ providerUserIdHash: args.providerUserIdHash,
131
+ appUserSubjectHash: args.appUserSubjectHash,
132
+ linkStatus: args.linkStatus,
133
+ rateLimited: args.rateLimited,
122
134
  });
123
135
  return null;
124
136
  },
@@ -144,7 +156,13 @@ export const queryAccessLog = query({
144
156
  args: v.any(),
145
157
  result: v.optional(v.any()),
146
158
  error: v.optional(v.string()),
159
+ errorCode: v.optional(v.string()),
147
160
  duration: v.number(),
161
+ linkedProvider: v.optional(v.string()),
162
+ providerUserIdHash: v.optional(v.string()),
163
+ appUserSubjectHash: v.optional(v.string()),
164
+ linkStatus: v.optional(v.string()),
165
+ rateLimited: v.optional(v.boolean()),
148
166
  }),
149
167
  ),
150
168
  handler: async (ctx, args) => {
@@ -173,7 +191,13 @@ export const queryAccessLog = query({
173
191
  args: l.args,
174
192
  result: l.result,
175
193
  error: l.error,
194
+ errorCode: l.errorCode,
176
195
  duration: l.duration,
196
+ linkedProvider: l.linkedProvider,
197
+ providerUserIdHash: l.providerUserIdHash,
198
+ appUserSubjectHash: l.appUserSubjectHash,
199
+ linkStatus: l.linkStatus,
200
+ rateLimited: l.rateLimited,
177
201
  }));
178
202
  }
179
203
 
@@ -196,7 +220,13 @@ export const queryAccessLog = query({
196
220
  args: l.args,
197
221
  result: l.result,
198
222
  error: l.error,
223
+ errorCode: l.errorCode,
199
224
  duration: l.duration,
225
+ linkedProvider: l.linkedProvider,
226
+ providerUserIdHash: l.providerUserIdHash,
227
+ appUserSubjectHash: l.appUserSubjectHash,
228
+ linkStatus: l.linkStatus,
229
+ rateLimited: l.rateLimited,
200
230
  }));
201
231
  }
202
232
 
@@ -218,7 +248,13 @@ export const queryAccessLog = query({
218
248
  args: l.args,
219
249
  result: l.result,
220
250
  error: l.error,
251
+ errorCode: l.errorCode,
221
252
  duration: l.duration,
253
+ linkedProvider: l.linkedProvider,
254
+ providerUserIdHash: l.providerUserIdHash,
255
+ appUserSubjectHash: l.appUserSubjectHash,
256
+ linkStatus: l.linkStatus,
257
+ rateLimited: l.rateLimited,
222
258
  }));
223
259
  },
224
260
  });