@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.
@@ -0,0 +1,377 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query, type MutationCtx } from "./_generated/server.js";
3
+ import { normalizeAppKey } from "./agentBridgeUtils.js";
4
+
5
+ const linkStatusValidator = v.union(
6
+ v.literal("active"),
7
+ v.literal("revoked"),
8
+ v.literal("expired"),
9
+ );
10
+
11
+ const linkRecordValidator = v.object({
12
+ _id: v.id("agentUserLinks"),
13
+ provider: v.string(),
14
+ providerUserId: v.string(),
15
+ appKey: v.string(),
16
+ appUserSubject: v.string(),
17
+ status: linkStatusValidator,
18
+ createdAt: v.number(),
19
+ updatedAt: v.number(),
20
+ lastUsedAt: v.optional(v.number()),
21
+ expiresAt: v.optional(v.number()),
22
+ revokedAt: v.optional(v.number()),
23
+ metadata: v.optional(v.any()),
24
+ refreshTokenCiphertext: v.optional(v.string()),
25
+ refreshTokenExpiresAt: v.optional(v.number()),
26
+ tokenVersion: v.optional(v.number()),
27
+ });
28
+
29
+ const resolveLinkResultValidator = v.union(
30
+ v.object({
31
+ ok: v.literal(true),
32
+ link: linkRecordValidator,
33
+ }),
34
+ v.object({
35
+ ok: v.literal(false),
36
+ errorCode: v.union(
37
+ v.literal("link_not_found"),
38
+ v.literal("link_revoked"),
39
+ v.literal("link_expired"),
40
+ v.literal("link_rate_limited"),
41
+ ),
42
+ statusCode: v.number(),
43
+ retryAfterSeconds: v.optional(v.number()),
44
+ }),
45
+ );
46
+
47
+ function normalizeIdentity(input: string): string {
48
+ return input.trim();
49
+ }
50
+
51
+ function normalizeProvider(provider: string): string {
52
+ return provider.trim().toLowerCase();
53
+ }
54
+
55
+ function resolveExpiresAt(now: number, ttlDays?: number): number | undefined {
56
+ if (ttlDays === undefined) {
57
+ return undefined;
58
+ }
59
+ if (!Number.isFinite(ttlDays) || ttlDays <= 0) {
60
+ throw new Error("expiresInDays must be a positive number");
61
+ }
62
+ return now + Math.floor(ttlDays * 24 * 60 * 60 * 1000);
63
+ }
64
+
65
+ async function checkAndConsumeRateLimit(args: {
66
+ ctx: MutationCtx;
67
+ key: string;
68
+ now: number;
69
+ maxRequests: number;
70
+ windowSeconds: number;
71
+ }) {
72
+ const bucketStartMs =
73
+ Math.floor(args.now / (args.windowSeconds * 1000)) * args.windowSeconds * 1000;
74
+ const existing = await args.ctx.db
75
+ .query("agentLinkRateLimits")
76
+ .withIndex("by_key_bucketStartMs", (q) =>
77
+ q.eq("key", args.key).eq("bucketStartMs", bucketStartMs),
78
+ )
79
+ .unique();
80
+ if (existing && existing.requestCount >= args.maxRequests) {
81
+ const nextBucketStartMs = bucketStartMs + args.windowSeconds * 1000;
82
+ const retryAfterSeconds = Math.max(
83
+ 1,
84
+ Math.ceil((nextBucketStartMs - args.now) / 1000),
85
+ );
86
+ return {
87
+ limited: true as const,
88
+ retryAfterSeconds,
89
+ };
90
+ }
91
+ if (existing) {
92
+ await args.ctx.db.patch(existing._id, {
93
+ requestCount: existing.requestCount + 1,
94
+ updatedAt: args.now,
95
+ });
96
+ } else {
97
+ await args.ctx.db.insert("agentLinkRateLimits", {
98
+ key: args.key,
99
+ bucketStartMs,
100
+ requestCount: 1,
101
+ updatedAt: args.now,
102
+ });
103
+ }
104
+ return { limited: false as const };
105
+ }
106
+
107
+ export const upsertLink = mutation({
108
+ args: {
109
+ provider: v.string(),
110
+ providerUserId: v.string(),
111
+ appKey: v.string(),
112
+ appUserSubject: v.string(),
113
+ expiresInDays: v.optional(v.number()),
114
+ metadata: v.optional(v.any()),
115
+ refreshTokenCiphertext: v.optional(v.string()),
116
+ refreshTokenExpiresAt: v.optional(v.number()),
117
+ tokenVersion: v.optional(v.number()),
118
+ },
119
+ returns: v.object({
120
+ linkId: v.id("agentUserLinks"),
121
+ created: v.boolean(),
122
+ expiresAt: v.optional(v.number()),
123
+ }),
124
+ handler: async (ctx, args) => {
125
+ const now = Date.now();
126
+ const provider = normalizeProvider(args.provider);
127
+ const providerUserId = normalizeIdentity(args.providerUserId);
128
+ const appKey = normalizeAppKey(args.appKey);
129
+ const appUserSubject = normalizeIdentity(args.appUserSubject);
130
+ const expiresAt = resolveExpiresAt(now, args.expiresInDays);
131
+ const existing = await ctx.db
132
+ .query("agentUserLinks")
133
+ .withIndex("by_provider_providerUserId_appKey", (q) =>
134
+ q
135
+ .eq("provider", provider)
136
+ .eq("providerUserId", providerUserId)
137
+ .eq("appKey", appKey),
138
+ )
139
+ .unique();
140
+
141
+ if (existing) {
142
+ await ctx.db.patch(existing._id, {
143
+ appUserSubject,
144
+ status: "active",
145
+ updatedAt: now,
146
+ lastUsedAt: existing.lastUsedAt,
147
+ expiresAt,
148
+ revokedAt: undefined,
149
+ metadata: args.metadata,
150
+ refreshTokenCiphertext: args.refreshTokenCiphertext,
151
+ refreshTokenExpiresAt: args.refreshTokenExpiresAt,
152
+ tokenVersion: args.tokenVersion ?? existing.tokenVersion ?? 1,
153
+ });
154
+ return {
155
+ linkId: existing._id,
156
+ created: false,
157
+ expiresAt,
158
+ };
159
+ }
160
+
161
+ const linkId = await ctx.db.insert("agentUserLinks", {
162
+ provider,
163
+ providerUserId,
164
+ appKey,
165
+ appUserSubject,
166
+ status: "active",
167
+ createdAt: now,
168
+ updatedAt: now,
169
+ expiresAt,
170
+ metadata: args.metadata,
171
+ refreshTokenCiphertext: args.refreshTokenCiphertext,
172
+ refreshTokenExpiresAt: args.refreshTokenExpiresAt,
173
+ tokenVersion: args.tokenVersion ?? 1,
174
+ });
175
+
176
+ return {
177
+ linkId,
178
+ created: true,
179
+ expiresAt,
180
+ };
181
+ },
182
+ });
183
+
184
+ export const resolveLink = mutation({
185
+ args: {
186
+ provider: v.string(),
187
+ providerUserId: v.string(),
188
+ appKey: v.string(),
189
+ maxRequestsPerWindow: v.optional(v.number()),
190
+ windowSeconds: v.optional(v.number()),
191
+ extendExpiryDaysOnUse: v.optional(v.number()),
192
+ },
193
+ returns: resolveLinkResultValidator,
194
+ handler: async (ctx, args) => {
195
+ const now = Date.now();
196
+ const provider = normalizeProvider(args.provider);
197
+ const providerUserId = normalizeIdentity(args.providerUserId);
198
+ const appKey = normalizeAppKey(args.appKey);
199
+
200
+ const maxRequestsPerWindow = args.maxRequestsPerWindow ?? 60;
201
+ const windowSeconds = args.windowSeconds ?? 60;
202
+ if (maxRequestsPerWindow <= 0 || windowSeconds <= 0) {
203
+ throw new Error("maxRequestsPerWindow and windowSeconds must be positive");
204
+ }
205
+ const rateLimitKey = `${provider}:${providerUserId}:${appKey}`;
206
+ const rateLimitResult = await checkAndConsumeRateLimit({
207
+ ctx,
208
+ key: rateLimitKey,
209
+ now,
210
+ maxRequests: maxRequestsPerWindow,
211
+ windowSeconds,
212
+ });
213
+ if (rateLimitResult.limited) {
214
+ return {
215
+ ok: false as const,
216
+ errorCode: "link_rate_limited" as const,
217
+ statusCode: 429,
218
+ retryAfterSeconds: rateLimitResult.retryAfterSeconds,
219
+ };
220
+ }
221
+
222
+ const link = await ctx.db
223
+ .query("agentUserLinks")
224
+ .withIndex("by_provider_providerUserId_appKey", (q) =>
225
+ q
226
+ .eq("provider", provider)
227
+ .eq("providerUserId", providerUserId)
228
+ .eq("appKey", appKey),
229
+ )
230
+ .unique();
231
+ if (!link) {
232
+ return {
233
+ ok: false as const,
234
+ errorCode: "link_not_found" as const,
235
+ statusCode: 404,
236
+ };
237
+ }
238
+ if (link.status === "revoked") {
239
+ return {
240
+ ok: false as const,
241
+ errorCode: "link_revoked" as const,
242
+ statusCode: 410,
243
+ };
244
+ }
245
+ if (link.expiresAt !== undefined && now > link.expiresAt) {
246
+ await ctx.db.patch(link._id, {
247
+ status: "expired",
248
+ updatedAt: now,
249
+ });
250
+ const expiredLink = await ctx.db.get(link._id);
251
+ if (!expiredLink) {
252
+ return {
253
+ ok: false as const,
254
+ errorCode: "link_expired" as const,
255
+ statusCode: 410,
256
+ };
257
+ }
258
+ return {
259
+ ok: false as const,
260
+ errorCode: "link_expired" as const,
261
+ statusCode: 410,
262
+ };
263
+ }
264
+
265
+ const patch: {
266
+ updatedAt: number;
267
+ lastUsedAt: number;
268
+ expiresAt?: number;
269
+ } = {
270
+ updatedAt: now,
271
+ lastUsedAt: now,
272
+ };
273
+ if (args.extendExpiryDaysOnUse !== undefined) {
274
+ patch.expiresAt = resolveExpiresAt(now, args.extendExpiryDaysOnUse);
275
+ }
276
+ await ctx.db.patch(link._id, patch);
277
+ const updated = await ctx.db.get(link._id);
278
+ if (!updated) {
279
+ throw new Error("Link not found after update");
280
+ }
281
+
282
+ return {
283
+ ok: true as const,
284
+ link: updated,
285
+ };
286
+ },
287
+ });
288
+
289
+ export const revokeLink = mutation({
290
+ args: {
291
+ linkId: v.optional(v.id("agentUserLinks")),
292
+ provider: v.optional(v.string()),
293
+ providerUserId: v.optional(v.string()),
294
+ appKey: v.optional(v.string()),
295
+ },
296
+ returns: v.object({
297
+ revoked: v.boolean(),
298
+ linkId: v.optional(v.id("agentUserLinks")),
299
+ }),
300
+ handler: async (ctx, args) => {
301
+ let linkId = args.linkId;
302
+ if (!linkId) {
303
+ const provider = args.provider ? normalizeProvider(args.provider) : undefined;
304
+ const providerUserId = args.providerUserId
305
+ ? normalizeIdentity(args.providerUserId)
306
+ : undefined;
307
+ const appKey = args.appKey ? normalizeAppKey(args.appKey) : undefined;
308
+ if (!provider || !providerUserId || !appKey) {
309
+ throw new Error(
310
+ "Provide either linkId or provider, providerUserId and appKey",
311
+ );
312
+ }
313
+ const existing = await ctx.db
314
+ .query("agentUserLinks")
315
+ .withIndex("by_provider_providerUserId_appKey", (q) =>
316
+ q
317
+ .eq("provider", provider)
318
+ .eq("providerUserId", providerUserId)
319
+ .eq("appKey", appKey),
320
+ )
321
+ .unique();
322
+ if (!existing) {
323
+ return { revoked: false };
324
+ }
325
+ linkId = existing._id;
326
+ }
327
+
328
+ const existing = await ctx.db.get(linkId);
329
+ if (!existing) {
330
+ return { revoked: false };
331
+ }
332
+ await ctx.db.patch(linkId, {
333
+ status: "revoked",
334
+ revokedAt: Date.now(),
335
+ updatedAt: Date.now(),
336
+ });
337
+ return {
338
+ revoked: true,
339
+ linkId,
340
+ };
341
+ },
342
+ });
343
+
344
+ export const listLinks = query({
345
+ args: {
346
+ appKey: v.optional(v.string()),
347
+ provider: v.optional(v.string()),
348
+ status: v.optional(linkStatusValidator),
349
+ limit: v.optional(v.number()),
350
+ },
351
+ returns: v.array(linkRecordValidator),
352
+ handler: async (ctx, args) => {
353
+ const limit = Math.min(args.limit ?? 100, 500);
354
+ if (args.appKey && args.status) {
355
+ return await ctx.db
356
+ .query("agentUserLinks")
357
+ .withIndex("by_appKey_status", (q) =>
358
+ q.eq("appKey", normalizeAppKey(args.appKey!)).eq("status", args.status!),
359
+ )
360
+ .order("desc")
361
+ .take(limit);
362
+ }
363
+ const all = await ctx.db.query("agentUserLinks").order("desc").take(limit);
364
+ return all.filter((link) => {
365
+ if (args.provider && link.provider !== normalizeProvider(args.provider)) {
366
+ return false;
367
+ }
368
+ if (args.appKey && link.appKey !== normalizeAppKey(args.appKey)) {
369
+ return false;
370
+ }
371
+ if (args.status && link.status !== args.status) {
372
+ return false;
373
+ }
374
+ return true;
375
+ });
376
+ },
377
+ });
@@ -45,11 +45,48 @@ export default defineSchema({
45
45
  args: v.any(),
46
46
  result: v.optional(v.any()),
47
47
  error: v.optional(v.string()),
48
+ errorCode: v.optional(v.string()),
48
49
  duration: v.number(),
50
+ linkedProvider: v.optional(v.string()),
51
+ providerUserIdHash: v.optional(v.string()),
52
+ appUserSubjectHash: v.optional(v.string()),
53
+ linkStatus: v.optional(v.string()),
54
+ rateLimited: v.optional(v.boolean()),
49
55
  timestamp: v.number(),
50
56
  })
51
57
  .index("by_agentId_and_timestamp", ["agentId", "timestamp"])
52
58
  .index("by_serviceId_and_timestamp", ["serviceId", "timestamp"])
53
59
  .index("by_functionKey", ["functionKey"])
54
60
  .index("by_timestamp", ["timestamp"]),
61
+
62
+ agentUserLinks: defineTable({
63
+ provider: v.string(),
64
+ providerUserId: v.string(),
65
+ appKey: v.string(),
66
+ appUserSubject: v.string(),
67
+ status: v.union(v.literal("active"), v.literal("revoked"), v.literal("expired")),
68
+ createdAt: v.number(),
69
+ updatedAt: v.number(),
70
+ lastUsedAt: v.optional(v.number()),
71
+ expiresAt: v.optional(v.number()),
72
+ revokedAt: v.optional(v.number()),
73
+ metadata: v.optional(v.any()),
74
+ refreshTokenCiphertext: v.optional(v.string()),
75
+ refreshTokenExpiresAt: v.optional(v.number()),
76
+ tokenVersion: v.optional(v.number()),
77
+ })
78
+ .index("by_provider_providerUserId_appKey", [
79
+ "provider",
80
+ "providerUserId",
81
+ "appKey",
82
+ ])
83
+ .index("by_appKey_status", ["appKey", "status"])
84
+ .index("by_appUserSubject_appKey", ["appUserSubject", "appKey"]),
85
+
86
+ agentLinkRateLimits: defineTable({
87
+ key: v.string(),
88
+ bucketStartMs: v.number(),
89
+ requestCount: v.number(),
90
+ updatedAt: v.number(),
91
+ }).index("by_key_bucketStartMs", ["key", "bucketStartMs"]),
55
92
  });