@okrlinkhub/agent-bridge 0.1.0 → 0.2.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 (35) hide show
  1. package/dist/client/index.d.ts +89 -0
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +279 -4
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/api.d.ts +4 -0
  6. package/dist/component/_generated/api.d.ts.map +1 -1
  7. package/dist/component/_generated/api.js.map +1 -1
  8. package/dist/component/_generated/component.d.ts +103 -0
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/channels.d.ts +83 -0
  11. package/dist/component/channels.d.ts.map +1 -0
  12. package/dist/component/channels.js +288 -0
  13. package/dist/component/channels.js.map +1 -0
  14. package/dist/component/circuitBreaker.d.ts +73 -0
  15. package/dist/component/circuitBreaker.d.ts.map +1 -0
  16. package/dist/component/circuitBreaker.js +216 -0
  17. package/dist/component/circuitBreaker.js.map +1 -0
  18. package/dist/component/gateway.d.ts +19 -3
  19. package/dist/component/gateway.d.ts.map +1 -1
  20. package/dist/component/gateway.js +82 -2
  21. package/dist/component/gateway.js.map +1 -1
  22. package/dist/component/permissions.d.ts +2 -2
  23. package/dist/component/registry.d.ts +3 -3
  24. package/dist/component/schema.d.ts +73 -3
  25. package/dist/component/schema.d.ts.map +1 -1
  26. package/dist/component/schema.js +46 -0
  27. package/dist/component/schema.js.map +1 -1
  28. package/package.json +5 -2
  29. package/src/client/index.ts +411 -4
  30. package/src/component/_generated/api.ts +4 -0
  31. package/src/component/_generated/component.ts +142 -1
  32. package/src/component/channels.ts +374 -0
  33. package/src/component/circuitBreaker.ts +250 -0
  34. package/src/component/gateway.ts +98 -2
  35. package/src/component/schema.ts +49 -0
@@ -0,0 +1,374 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server.js";
3
+ import type { MutationCtx, QueryCtx } from "./_generated/server.js";
4
+
5
+ // --- Token hashing utility (shared with gateway.ts) ---
6
+
7
+ async function hashToken(token: string): Promise<string> {
8
+ const encoder = new TextEncoder();
9
+ const data = encoder.encode(token);
10
+ const hash = await crypto.subtle.digest("SHA-256", data);
11
+ return Array.from(new Uint8Array(hash))
12
+ .map((b) => b.toString(16).padStart(2, "0"))
13
+ .join("");
14
+ }
15
+
16
+ // --- Instance verification helper ---
17
+
18
+ /**
19
+ * Verify an instance token and return the agent info.
20
+ * Throws if the token is invalid, expired, or doesn't match the expected app.
21
+ */
22
+ async function verifyInstanceToken(
23
+ ctx: QueryCtx | MutationCtx,
24
+ instanceToken: string,
25
+ appName: string,
26
+ ): Promise<{ agentId: string; appName: string }> {
27
+ const tokenHash = await hashToken(instanceToken);
28
+ const instance = await ctx.db
29
+ .query("agentAppInstances")
30
+ .withIndex("by_instance_token_hash", (q) =>
31
+ q.eq("instanceTokenHash", tokenHash),
32
+ )
33
+ .unique();
34
+
35
+ if (!instance) {
36
+ throw new Error("Invalid instance token");
37
+ }
38
+
39
+ if (instance.expiresAt < Date.now()) {
40
+ throw new Error("Instance token has expired");
41
+ }
42
+
43
+ if (instance.appName !== appName) {
44
+ throw new Error("Token does not match this app");
45
+ }
46
+
47
+ // Verify agent is active
48
+ const agent = await ctx.db
49
+ .query("registeredAgents")
50
+ .withIndex("by_agent_id", (q) => q.eq("agentId", instance.agentId))
51
+ .unique();
52
+
53
+ if (!agent || !agent.isActive) {
54
+ throw new Error("Agent has been revoked");
55
+ }
56
+
57
+ return { agentId: instance.agentId, appName: instance.appName };
58
+ }
59
+
60
+ // --- Channel Management ---
61
+
62
+ /**
63
+ * Create a channel for an app.
64
+ * Idempotent: if the channel already exists, returns the existing ID.
65
+ */
66
+ export const createChannel = mutation({
67
+ args: {
68
+ appName: v.string(),
69
+ channelName: v.string(),
70
+ description: v.optional(v.string()),
71
+ },
72
+ returns: v.string(),
73
+ handler: async (ctx, args) => {
74
+ // Check if channel already exists
75
+ const existing = await ctx.db
76
+ .query("appChannels")
77
+ .withIndex("by_appName_and_channelName", (q) =>
78
+ q.eq("appName", args.appName).eq("channelName", args.channelName),
79
+ )
80
+ .unique();
81
+
82
+ if (existing) {
83
+ return existing._id;
84
+ }
85
+
86
+ const id = await ctx.db.insert("appChannels", {
87
+ appName: args.appName,
88
+ channelName: args.channelName,
89
+ description: args.description,
90
+ createdAt: Date.now(),
91
+ isActive: true,
92
+ });
93
+
94
+ return id;
95
+ },
96
+ });
97
+
98
+ /**
99
+ * List active channels for an app.
100
+ */
101
+ export const listChannels = query({
102
+ args: {
103
+ appName: v.string(),
104
+ },
105
+ returns: v.array(
106
+ v.object({
107
+ channelName: v.string(),
108
+ description: v.optional(v.string()),
109
+ createdAt: v.number(),
110
+ isActive: v.boolean(),
111
+ }),
112
+ ),
113
+ handler: async (ctx, args) => {
114
+ const channels = await ctx.db
115
+ .query("appChannels")
116
+ .withIndex("by_appName_and_channelName", (q) =>
117
+ q.eq("appName", args.appName),
118
+ )
119
+ .collect();
120
+
121
+ return channels
122
+ .filter((c) => c.isActive)
123
+ .map((c) => ({
124
+ channelName: c.channelName,
125
+ description: c.description,
126
+ createdAt: c.createdAt,
127
+ isActive: c.isActive,
128
+ }));
129
+ },
130
+ });
131
+
132
+ /**
133
+ * Deactivate a channel (soft delete).
134
+ */
135
+ export const deactivateChannel = mutation({
136
+ args: {
137
+ appName: v.string(),
138
+ channelName: v.string(),
139
+ },
140
+ returns: v.boolean(),
141
+ handler: async (ctx, args) => {
142
+ const channel = await ctx.db
143
+ .query("appChannels")
144
+ .withIndex("by_appName_and_channelName", (q) =>
145
+ q.eq("appName", args.appName).eq("channelName", args.channelName),
146
+ )
147
+ .unique();
148
+
149
+ if (!channel) {
150
+ return false;
151
+ }
152
+
153
+ await ctx.db.patch(channel._id, { isActive: false });
154
+ return true;
155
+ },
156
+ });
157
+
158
+ // --- Message Operations ---
159
+
160
+ /**
161
+ * Post a message to a channel.
162
+ * Requires a valid instanceToken for authentication.
163
+ */
164
+ export const postMessage = mutation({
165
+ args: {
166
+ instanceToken: v.string(),
167
+ appName: v.string(),
168
+ channelName: v.string(),
169
+ payload: v.string(),
170
+ priority: v.optional(v.number()),
171
+ ttlMinutes: v.optional(v.number()),
172
+ },
173
+ returns: v.object({
174
+ success: v.boolean(),
175
+ messageId: v.string(),
176
+ }),
177
+ handler: async (ctx, args) => {
178
+ // Verify the agent's identity
179
+ const verified = await verifyInstanceToken(
180
+ ctx,
181
+ args.instanceToken,
182
+ args.appName,
183
+ );
184
+
185
+ // Verify channel exists and is active
186
+ const channel = await ctx.db
187
+ .query("appChannels")
188
+ .withIndex("by_appName_and_channelName", (q) =>
189
+ q.eq("appName", args.appName).eq("channelName", args.channelName),
190
+ )
191
+ .unique();
192
+
193
+ if (!channel || !channel.isActive) {
194
+ throw new Error(
195
+ `Channel "${args.channelName}" not found or inactive in app "${args.appName}"`,
196
+ );
197
+ }
198
+
199
+ const ttlMs = (args.ttlMinutes ?? 60) * 60 * 1000;
200
+ const messageId = crypto.randomUUID();
201
+ const now = Date.now();
202
+
203
+ await ctx.db.insert("channelMessages", {
204
+ appName: args.appName,
205
+ channelName: args.channelName,
206
+ messageId,
207
+ fromAgentId: verified.agentId,
208
+ payload: args.payload,
209
+ metadata: {
210
+ priority: args.priority ?? 5,
211
+ ttl: ttlMs,
212
+ },
213
+ sentAt: now,
214
+ expiresAt: now + ttlMs,
215
+ readBy: [],
216
+ });
217
+
218
+ return { success: true, messageId };
219
+ },
220
+ });
221
+
222
+ /**
223
+ * Read messages from a channel with cursor-based pagination.
224
+ * Filters out expired messages. Returns messages in descending order (newest first).
225
+ */
226
+ export const readMessages = query({
227
+ args: {
228
+ instanceToken: v.string(),
229
+ appName: v.string(),
230
+ channelName: v.string(),
231
+ limit: v.optional(v.number()),
232
+ after: v.optional(v.number()),
233
+ },
234
+ returns: v.array(
235
+ v.object({
236
+ messageId: v.string(),
237
+ fromAgentId: v.string(),
238
+ payload: v.string(),
239
+ metadata: v.object({
240
+ priority: v.number(),
241
+ ttl: v.number(),
242
+ }),
243
+ sentAt: v.number(),
244
+ expiresAt: v.number(),
245
+ readBy: v.array(v.string()),
246
+ }),
247
+ ),
248
+ handler: async (ctx, args) => {
249
+ // Verify the agent's identity
250
+ await verifyInstanceToken(ctx, args.instanceToken, args.appName);
251
+
252
+ const now = Date.now();
253
+ const limit = args.limit ?? 20;
254
+
255
+ let messagesQuery;
256
+ if (args.after !== undefined) {
257
+ messagesQuery = ctx.db
258
+ .query("channelMessages")
259
+ .withIndex("by_appName_and_channelName_and_sentAt", (q) =>
260
+ q
261
+ .eq("appName", args.appName)
262
+ .eq("channelName", args.channelName)
263
+ .gt("sentAt", args.after!),
264
+ );
265
+ } else {
266
+ messagesQuery = ctx.db
267
+ .query("channelMessages")
268
+ .withIndex("by_appName_and_channelName_and_sentAt", (q) =>
269
+ q
270
+ .eq("appName", args.appName)
271
+ .eq("channelName", args.channelName),
272
+ );
273
+ }
274
+
275
+ const messages = await messagesQuery.order("desc").take(limit * 2);
276
+
277
+ // Filter out expired messages and take the requested limit
278
+ const validMessages = messages
279
+ .filter((m) => m.expiresAt > now)
280
+ .slice(0, limit);
281
+
282
+ return validMessages.map((m) => ({
283
+ messageId: m.messageId,
284
+ fromAgentId: m.fromAgentId,
285
+ payload: m.payload,
286
+ metadata: m.metadata,
287
+ sentAt: m.sentAt,
288
+ expiresAt: m.expiresAt,
289
+ readBy: m.readBy,
290
+ }));
291
+ },
292
+ });
293
+
294
+ /**
295
+ * Mark a message as read by an agent.
296
+ * Adds the agentId to the readBy array if not already present.
297
+ */
298
+ export const markAsRead = mutation({
299
+ args: {
300
+ instanceToken: v.string(),
301
+ appName: v.string(),
302
+ messageId: v.string(),
303
+ },
304
+ returns: v.boolean(),
305
+ handler: async (ctx, args) => {
306
+ const verified = await verifyInstanceToken(
307
+ ctx,
308
+ args.instanceToken,
309
+ args.appName,
310
+ );
311
+
312
+ // Find the message by messageId
313
+ const messages = await ctx.db
314
+ .query("channelMessages")
315
+ .withIndex("by_appName_and_channelName_and_sentAt", (q) =>
316
+ q.eq("appName", args.appName),
317
+ )
318
+ .collect();
319
+
320
+ const message = messages.find((m) => m.messageId === args.messageId);
321
+
322
+ if (!message) {
323
+ return false;
324
+ }
325
+
326
+ if (message.readBy.includes(verified.agentId)) {
327
+ // Already marked as read
328
+ return true;
329
+ }
330
+
331
+ await ctx.db.patch(message._id, {
332
+ readBy: [...message.readBy, verified.agentId],
333
+ });
334
+
335
+ return true;
336
+ },
337
+ });
338
+
339
+ /**
340
+ * Get count of unread messages for an agent on a channel.
341
+ * Only counts non-expired messages.
342
+ */
343
+ export const getUnreadCount = query({
344
+ args: {
345
+ instanceToken: v.string(),
346
+ appName: v.string(),
347
+ channelName: v.string(),
348
+ },
349
+ returns: v.number(),
350
+ handler: async (ctx, args) => {
351
+ const verified = await verifyInstanceToken(
352
+ ctx,
353
+ args.instanceToken,
354
+ args.appName,
355
+ );
356
+
357
+ const now = Date.now();
358
+
359
+ const messages = await ctx.db
360
+ .query("channelMessages")
361
+ .withIndex("by_appName_and_channelName_and_sentAt", (q) =>
362
+ q
363
+ .eq("appName", args.appName)
364
+ .eq("channelName", args.channelName),
365
+ )
366
+ .collect();
367
+
368
+ const unread = messages.filter(
369
+ (m) => m.expiresAt > now && !m.readBy.includes(verified.agentId),
370
+ );
371
+
372
+ return unread.length;
373
+ },
374
+ });
@@ -0,0 +1,250 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server.js";
3
+
4
+ // --- Helpers ---
5
+
6
+ /**
7
+ * Get the current hour window string (e.g. "2026-02-08T14").
8
+ */
9
+ function getCurrentWindowHour(): string {
10
+ return new Date().toISOString().slice(0, 13);
11
+ }
12
+
13
+ /**
14
+ * Calculate seconds remaining until the current hour window expires.
15
+ */
16
+ export function secondsUntilWindowExpires(): number {
17
+ const now = new Date();
18
+ const nextHour = new Date(now);
19
+ nextHour.setMinutes(0, 0, 0);
20
+ nextHour.setHours(nextHour.getHours() + 1);
21
+ return Math.ceil((nextHour.getTime() - now.getTime()) / 1000);
22
+ }
23
+
24
+ // --- Validators ---
25
+
26
+ const checkResultValidator = v.object({
27
+ allowed: v.boolean(),
28
+ reason: v.optional(v.string()),
29
+ currentCount: v.number(),
30
+ currentTokens: v.number(),
31
+ });
32
+
33
+ const statusValidator = v.object({
34
+ agentId: v.string(),
35
+ appName: v.string(),
36
+ windowHour: v.string(),
37
+ requestCount: v.number(),
38
+ tokenEstimate: v.number(),
39
+ isBlocked: v.boolean(),
40
+ blockedReason: v.optional(v.string()),
41
+ blockedAt: v.optional(v.number()),
42
+ });
43
+
44
+ // --- Public functions ---
45
+
46
+ /**
47
+ * Check the circuit breaker and increment counters if allowed.
48
+ * This is designed to be called from within the gateway's authorizeRequest
49
+ * mutation, but is also exposed for direct use.
50
+ *
51
+ * Looks up or creates the counter for the current hour window, checks
52
+ * if the agent is already blocked, then increments request count and
53
+ * token estimate. If limits are exceeded, blocks the agent for the
54
+ * remainder of the hour window.
55
+ */
56
+ export const checkAndIncrement = mutation({
57
+ args: {
58
+ agentId: v.string(),
59
+ appName: v.string(),
60
+ estimatedCost: v.number(),
61
+ limits: v.object({
62
+ requestsPerHour: v.number(),
63
+ tokenBudget: v.number(),
64
+ }),
65
+ },
66
+ returns: checkResultValidator,
67
+ handler: async (ctx, args) => {
68
+ const windowHour = getCurrentWindowHour();
69
+
70
+ // Find or create counter for this window
71
+ let counter = await ctx.db
72
+ .query("circuitCounters")
73
+ .withIndex("by_agentId_and_appName_and_windowHour", (q) =>
74
+ q
75
+ .eq("agentId", args.agentId)
76
+ .eq("appName", args.appName)
77
+ .eq("windowHour", windowHour),
78
+ )
79
+ .unique();
80
+
81
+ if (!counter) {
82
+ // Create new counter for this window
83
+ const id = await ctx.db.insert("circuitCounters", {
84
+ agentId: args.agentId,
85
+ appName: args.appName,
86
+ windowHour,
87
+ requestCount: 0,
88
+ tokenEstimate: 0,
89
+ isBlocked: false,
90
+ });
91
+ counter = (await ctx.db.get(id))!;
92
+ }
93
+
94
+ // If already blocked, deny immediately
95
+ if (counter.isBlocked) {
96
+ return {
97
+ allowed: false,
98
+ reason: counter.blockedReason ?? "Circuit breaker is open",
99
+ currentCount: counter.requestCount,
100
+ currentTokens: counter.tokenEstimate,
101
+ };
102
+ }
103
+
104
+ // Check if this request would exceed limits
105
+ const newRequestCount = counter.requestCount + 1;
106
+ const newTokenEstimate = counter.tokenEstimate + args.estimatedCost;
107
+
108
+ const requestsExceeded = newRequestCount > args.limits.requestsPerHour;
109
+ const tokensExceeded = newTokenEstimate > args.limits.tokenBudget;
110
+
111
+ if (requestsExceeded || tokensExceeded) {
112
+ // Block the circuit breaker
113
+ const reason = requestsExceeded
114
+ ? `Requests per hour exceeded (${newRequestCount}/${args.limits.requestsPerHour})`
115
+ : `Token budget exceeded (${newTokenEstimate}/${args.limits.tokenBudget})`;
116
+
117
+ await ctx.db.patch(counter._id, {
118
+ requestCount: newRequestCount,
119
+ tokenEstimate: newTokenEstimate,
120
+ isBlocked: true,
121
+ blockedReason: reason,
122
+ blockedAt: Date.now(),
123
+ });
124
+
125
+ return {
126
+ allowed: false,
127
+ reason,
128
+ currentCount: newRequestCount,
129
+ currentTokens: newTokenEstimate,
130
+ };
131
+ }
132
+
133
+ // Increment counters
134
+ await ctx.db.patch(counter._id, {
135
+ requestCount: newRequestCount,
136
+ tokenEstimate: newTokenEstimate,
137
+ });
138
+
139
+ return {
140
+ allowed: true,
141
+ currentCount: newRequestCount,
142
+ currentTokens: newTokenEstimate,
143
+ };
144
+ },
145
+ });
146
+
147
+ /**
148
+ * Get the current circuit breaker status for an agent on an app.
149
+ * Returns the counter for the current hour window.
150
+ */
151
+ export const getStatus = query({
152
+ args: {
153
+ agentId: v.string(),
154
+ appName: v.string(),
155
+ },
156
+ returns: v.union(statusValidator, v.null()),
157
+ handler: async (ctx, args) => {
158
+ const windowHour = getCurrentWindowHour();
159
+
160
+ const counter = await ctx.db
161
+ .query("circuitCounters")
162
+ .withIndex("by_agentId_and_appName_and_windowHour", (q) =>
163
+ q
164
+ .eq("agentId", args.agentId)
165
+ .eq("appName", args.appName)
166
+ .eq("windowHour", windowHour),
167
+ )
168
+ .unique();
169
+
170
+ if (!counter) {
171
+ return null;
172
+ }
173
+
174
+ return {
175
+ agentId: counter.agentId,
176
+ appName: counter.appName,
177
+ windowHour: counter.windowHour,
178
+ requestCount: counter.requestCount,
179
+ tokenEstimate: counter.tokenEstimate,
180
+ isBlocked: counter.isBlocked,
181
+ blockedReason: counter.blockedReason,
182
+ blockedAt: counter.blockedAt,
183
+ };
184
+ },
185
+ });
186
+
187
+ /**
188
+ * Admin function to manually reset a blocked circuit breaker.
189
+ * Creates a fresh counter for the current window with zeroed values.
190
+ */
191
+ export const resetCounter = mutation({
192
+ args: {
193
+ agentId: v.string(),
194
+ appName: v.string(),
195
+ },
196
+ returns: v.boolean(),
197
+ handler: async (ctx, args) => {
198
+ const windowHour = getCurrentWindowHour();
199
+
200
+ const counter = await ctx.db
201
+ .query("circuitCounters")
202
+ .withIndex("by_agentId_and_appName_and_windowHour", (q) =>
203
+ q
204
+ .eq("agentId", args.agentId)
205
+ .eq("appName", args.appName)
206
+ .eq("windowHour", windowHour),
207
+ )
208
+ .unique();
209
+
210
+ if (!counter) {
211
+ return false;
212
+ }
213
+
214
+ await ctx.db.patch(counter._id, {
215
+ requestCount: 0,
216
+ tokenEstimate: 0,
217
+ isBlocked: false,
218
+ blockedReason: undefined,
219
+ blockedAt: undefined,
220
+ });
221
+
222
+ return true;
223
+ },
224
+ });
225
+
226
+ /**
227
+ * List all currently blocked circuit breakers.
228
+ * Useful for admin dashboards.
229
+ */
230
+ export const listBlocked = query({
231
+ args: {},
232
+ returns: v.array(statusValidator),
233
+ handler: async (ctx) => {
234
+ const blocked = await ctx.db
235
+ .query("circuitCounters")
236
+ .withIndex("by_isBlocked", (q) => q.eq("isBlocked", true))
237
+ .collect();
238
+
239
+ return blocked.map((c) => ({
240
+ agentId: c.agentId,
241
+ appName: c.appName,
242
+ windowHour: c.windowHour,
243
+ requestCount: c.requestCount,
244
+ tokenEstimate: c.tokenEstimate,
245
+ isBlocked: c.isBlocked,
246
+ blockedReason: c.blockedReason,
247
+ blockedAt: c.blockedAt,
248
+ }));
249
+ },
250
+ });