@nordsym/apiclaw 1.3.5 → 1.3.7

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 (50) hide show
  1. package/README.md +33 -0
  2. package/convex/_generated/api.d.ts +12 -0
  3. package/convex/billing.ts +651 -216
  4. package/convex/crons.ts +17 -0
  5. package/convex/email.ts +135 -82
  6. package/convex/feedback.ts +265 -0
  7. package/convex/http.ts +80 -4
  8. package/convex/logs.ts +287 -0
  9. package/convex/providerKeys.ts +209 -0
  10. package/convex/providers.ts +18 -0
  11. package/convex/schema.ts +115 -0
  12. package/convex/stripeActions.ts +512 -0
  13. package/convex/webhooks.ts +494 -0
  14. package/convex/workspaces.ts +74 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +38 -7
  17. package/dist/cli.js.map +1 -1
  18. package/dist/index.js +178 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/metered.d.ts +62 -0
  21. package/dist/metered.d.ts.map +1 -0
  22. package/dist/metered.js +81 -0
  23. package/dist/metered.js.map +1 -0
  24. package/dist/stripe.d.ts +62 -0
  25. package/dist/stripe.d.ts.map +1 -1
  26. package/dist/stripe.js +212 -0
  27. package/dist/stripe.js.map +1 -1
  28. package/docs/PRD-final-polish.md +117 -0
  29. package/docs/PRD-mobile-responsive.md +56 -0
  30. package/docs/PRD-navigation-expansion.md +295 -0
  31. package/docs/PRD-stripe-billing.md +312 -0
  32. package/docs/PRD-workspace-cleanup.md +200 -0
  33. package/landing/src/app/api/billing/checkout/route.ts +109 -0
  34. package/landing/src/app/api/billing/payment-method/route.ts +118 -0
  35. package/landing/src/app/api/billing/portal/route.ts +64 -0
  36. package/landing/src/app/auth/verify/page.tsx +20 -5
  37. package/landing/src/app/earn/page.tsx +6 -6
  38. package/landing/src/app/login/page.tsx +1 -1
  39. package/landing/src/app/page.tsx +70 -70
  40. package/landing/src/app/providers/dashboard/page.tsx +1 -1
  41. package/landing/src/app/workspace/page.tsx +3497 -535
  42. package/landing/src/components/CheckoutButton.tsx +188 -0
  43. package/landing/src/components/Toast.tsx +84 -0
  44. package/landing/src/lib/stats.json +1 -1
  45. package/landing/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +1 -1
  47. package/src/cli.ts +57 -7
  48. package/src/index.ts +205 -0
  49. package/src/metered.ts +149 -0
  50. package/src/stripe.ts +253 -0
package/convex/logs.ts ADDED
@@ -0,0 +1,287 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server";
3
+
4
+ // ============================================
5
+ // MUTATIONS
6
+ // ============================================
7
+
8
+ /**
9
+ * Create a log entry for an API call
10
+ * Called after each Direct Call execution
11
+ */
12
+ export const createLog = mutation({
13
+ args: {
14
+ token: v.string(),
15
+ provider: v.string(),
16
+ action: v.string(),
17
+ status: v.union(v.literal("success"), v.literal("error")),
18
+ latencyMs: v.number(),
19
+ errorMessage: v.optional(v.string()),
20
+ },
21
+ handler: async (ctx, args) => {
22
+ // Verify session and get workspace
23
+ const session = await ctx.db
24
+ .query("agentSessions")
25
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
26
+ .first();
27
+
28
+ if (!session) {
29
+ throw new Error("Invalid session token");
30
+ }
31
+
32
+ // Create log entry
33
+ return await ctx.db.insert("apiLogs", {
34
+ workspaceId: session.workspaceId,
35
+ sessionToken: args.token,
36
+ provider: args.provider,
37
+ action: args.action,
38
+ status: args.status,
39
+ latencyMs: args.latencyMs,
40
+ errorMessage: args.errorMessage,
41
+ createdAt: Date.now(),
42
+ });
43
+ },
44
+ });
45
+
46
+ /**
47
+ * Internal log creation (when workspaceId is already known)
48
+ * Used by execute functions that have already verified the session
49
+ */
50
+ export const createLogInternal = mutation({
51
+ args: {
52
+ workspaceId: v.id("workspaces"),
53
+ sessionToken: v.string(),
54
+ provider: v.string(),
55
+ action: v.string(),
56
+ status: v.union(v.literal("success"), v.literal("error")),
57
+ latencyMs: v.number(),
58
+ errorMessage: v.optional(v.string()),
59
+ },
60
+ handler: async (ctx, args) => {
61
+ return await ctx.db.insert("apiLogs", {
62
+ workspaceId: args.workspaceId,
63
+ sessionToken: args.sessionToken,
64
+ provider: args.provider,
65
+ action: args.action,
66
+ status: args.status,
67
+ latencyMs: args.latencyMs,
68
+ errorMessage: args.errorMessage,
69
+ createdAt: Date.now(),
70
+ });
71
+ },
72
+ });
73
+
74
+ // ============================================
75
+ // QUERIES
76
+ // ============================================
77
+
78
+ /**
79
+ * Get logs for a workspace with pagination and filters
80
+ */
81
+ export const getLogs = query({
82
+ args: {
83
+ token: v.string(),
84
+ limit: v.optional(v.number()),
85
+ cursor: v.optional(v.number()), // createdAt timestamp for pagination
86
+ status: v.optional(v.union(v.literal("success"), v.literal("error"), v.literal("all"))),
87
+ provider: v.optional(v.string()),
88
+ },
89
+ handler: async (ctx, args) => {
90
+ const limit = args.limit ?? 50;
91
+ const status = args.status ?? "all";
92
+ const provider = args.provider;
93
+ const cursor = args.cursor;
94
+
95
+ // Verify session
96
+ const session = await ctx.db
97
+ .query("agentSessions")
98
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
99
+ .first();
100
+
101
+ if (!session) {
102
+ return { logs: [], hasMore: false };
103
+ }
104
+
105
+ // Get logs for workspace
106
+ let query = ctx.db
107
+ .query("apiLogs")
108
+ .withIndex("by_workspaceId_createdAt", (q) => q.eq("workspaceId", session.workspaceId))
109
+ .order("desc");
110
+
111
+ // Apply cursor (pagination)
112
+ if (cursor) {
113
+ query = query.filter((q) => q.lt(q.field("createdAt"), cursor));
114
+ }
115
+
116
+ // Collect more than limit to check hasMore
117
+ const allLogs = await query.take(limit + 1);
118
+
119
+ // Apply filters in-memory (Convex doesn't support complex compound filters)
120
+ let filteredLogs = allLogs;
121
+
122
+ if (status !== "all") {
123
+ filteredLogs = filteredLogs.filter((log) => log.status === status);
124
+ }
125
+
126
+ if (provider && provider !== "all") {
127
+ filteredLogs = filteredLogs.filter((log) => log.provider === provider);
128
+ }
129
+
130
+ const hasMore = filteredLogs.length > limit;
131
+ const logs = filteredLogs.slice(0, limit);
132
+
133
+ return {
134
+ logs: logs.map((log) => ({
135
+ id: log._id,
136
+ provider: log.provider,
137
+ action: log.action,
138
+ status: log.status,
139
+ latencyMs: log.latencyMs,
140
+ errorMessage: log.errorMessage,
141
+ createdAt: log.createdAt,
142
+ })),
143
+ hasMore,
144
+ nextCursor: logs.length > 0 ? logs[logs.length - 1].createdAt : undefined,
145
+ };
146
+ },
147
+ });
148
+
149
+ /**
150
+ * Get aggregated log stats for workspace
151
+ */
152
+ export const getLogStats = query({
153
+ args: {
154
+ token: v.string(),
155
+ periodDays: v.optional(v.number()),
156
+ },
157
+ handler: async (ctx, args) => {
158
+ const periodDays = args.periodDays ?? 7;
159
+
160
+ // Verify session
161
+ const session = await ctx.db
162
+ .query("agentSessions")
163
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
164
+ .first();
165
+
166
+ if (!session) {
167
+ return {
168
+ totalCalls: 0,
169
+ successCount: 0,
170
+ errorCount: 0,
171
+ successRate: 0,
172
+ avgLatency: 0,
173
+ byProvider: [],
174
+ byDay: [],
175
+ };
176
+ }
177
+
178
+ const now = Date.now();
179
+ const periodStart = now - periodDays * 24 * 60 * 60 * 1000;
180
+
181
+ // Get all logs for this workspace in the period
182
+ const logs = await ctx.db
183
+ .query("apiLogs")
184
+ .withIndex("by_workspaceId_createdAt", (q) => q.eq("workspaceId", session.workspaceId))
185
+ .filter((q) => q.gte(q.field("createdAt"), periodStart))
186
+ .collect();
187
+
188
+ const totalCalls = logs.length;
189
+ const successCount = logs.filter((l) => l.status === "success").length;
190
+ const errorCount = logs.filter((l) => l.status === "error").length;
191
+ const successRate = totalCalls > 0 ? (successCount / totalCalls) * 100 : 0;
192
+ const totalLatency = logs.reduce((sum, l) => sum + l.latencyMs, 0);
193
+ const avgLatency = totalCalls > 0 ? Math.round(totalLatency / totalCalls) : 0;
194
+
195
+ // Group by provider
196
+ const byProviderMap: Record<string, { calls: number; success: number; error: number; latency: number }> = {};
197
+ for (const log of logs) {
198
+ if (!byProviderMap[log.provider]) {
199
+ byProviderMap[log.provider] = { calls: 0, success: 0, error: 0, latency: 0 };
200
+ }
201
+ byProviderMap[log.provider].calls++;
202
+ byProviderMap[log.provider].latency += log.latencyMs;
203
+ if (log.status === "success") {
204
+ byProviderMap[log.provider].success++;
205
+ } else {
206
+ byProviderMap[log.provider].error++;
207
+ }
208
+ }
209
+
210
+ const byProvider = Object.entries(byProviderMap)
211
+ .map(([provider, data]) => ({
212
+ provider,
213
+ calls: data.calls,
214
+ successRate: data.calls > 0 ? (data.success / data.calls) * 100 : 0,
215
+ avgLatency: data.calls > 0 ? Math.round(data.latency / data.calls) : 0,
216
+ }))
217
+ .sort((a, b) => b.calls - a.calls);
218
+
219
+ // Group by day
220
+ const byDayMap: Record<string, { calls: number; success: number; error: number }> = {};
221
+ for (const log of logs) {
222
+ const day = new Date(log.createdAt).toISOString().split("T")[0];
223
+ if (!byDayMap[day]) {
224
+ byDayMap[day] = { calls: 0, success: 0, error: 0 };
225
+ }
226
+ byDayMap[day].calls++;
227
+ if (log.status === "success") {
228
+ byDayMap[day].success++;
229
+ } else {
230
+ byDayMap[day].error++;
231
+ }
232
+ }
233
+
234
+ const byDay = Object.entries(byDayMap)
235
+ .map(([date, data]) => ({
236
+ date,
237
+ calls: data.calls,
238
+ success: data.success,
239
+ error: data.error,
240
+ }))
241
+ .sort((a, b) => a.date.localeCompare(b.date));
242
+
243
+ // Get unique providers for filter dropdown
244
+ const providers = [...new Set(logs.map((l) => l.provider))].sort();
245
+
246
+ return {
247
+ totalCalls,
248
+ successCount,
249
+ errorCount,
250
+ successRate: Math.round(successRate * 10) / 10,
251
+ avgLatency,
252
+ byProvider,
253
+ byDay,
254
+ providers,
255
+ };
256
+ },
257
+ });
258
+
259
+ /**
260
+ * Get unique providers for filter dropdown
261
+ */
262
+ export const getProviders = query({
263
+ args: {
264
+ token: v.string(),
265
+ },
266
+ handler: async (ctx, args) => {
267
+ // Verify session
268
+ const session = await ctx.db
269
+ .query("agentSessions")
270
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
271
+ .first();
272
+
273
+ if (!session) {
274
+ return [];
275
+ }
276
+
277
+ // Get all logs for this workspace
278
+ const logs = await ctx.db
279
+ .query("apiLogs")
280
+ .withIndex("by_workspaceId", (q) => q.eq("workspaceId", session.workspaceId))
281
+ .collect();
282
+
283
+ // Get unique providers
284
+ const providers = [...new Set(logs.map((l) => l.provider))].sort();
285
+ return providers;
286
+ },
287
+ });
@@ -0,0 +1,209 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query, internalQuery } from "./_generated/server";
3
+
4
+ // ============================================
5
+ // BYOK - Bring Your Own Key
6
+ // ============================================
7
+
8
+ // Supported providers for BYOK
9
+ export const BYOK_PROVIDERS = [
10
+ { id: "brave_search", name: "Brave Search", icon: "🔍" },
11
+ { id: "openrouter", name: "OpenRouter", icon: "🤖" },
12
+ { id: "elevenlabs", name: "ElevenLabs", icon: "🎙️" },
13
+ { id: "twilio", name: "Twilio", icon: "📞" },
14
+ { id: "resend", name: "Resend", icon: "📧" },
15
+ { id: "e2b", name: "E2B", icon: "💻" },
16
+ ] as const;
17
+
18
+ // Simple base64 encoding for MVP (proper encryption in production)
19
+ function encryptKey(key: string): string {
20
+ return Buffer.from(key).toString("base64");
21
+ }
22
+
23
+ function decryptKey(encryptedKey: string): string {
24
+ return Buffer.from(encryptedKey, "base64").toString("utf-8");
25
+ }
26
+
27
+ function getKeyHint(key: string): string {
28
+ if (key.length <= 4) return "••••";
29
+ return key.slice(-4);
30
+ }
31
+
32
+ // ============================================
33
+ // ADD KEY
34
+ // ============================================
35
+
36
+ export const addKey = mutation({
37
+ args: {
38
+ token: v.string(),
39
+ provider: v.string(),
40
+ apiKey: v.string(),
41
+ },
42
+ handler: async (ctx, args) => {
43
+ // Validate session
44
+ const session = await ctx.db
45
+ .query("agentSessions")
46
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
47
+ .first();
48
+
49
+ if (!session) {
50
+ throw new Error("Invalid session");
51
+ }
52
+
53
+ const workspaceId = session.workspaceId;
54
+
55
+ // Check if key already exists for this provider
56
+ const existingKey = await ctx.db
57
+ .query("providerKeys")
58
+ .withIndex("by_provider", (q) =>
59
+ q.eq("workspaceId", workspaceId).eq("provider", args.provider)
60
+ )
61
+ .first();
62
+
63
+ const now = Date.now();
64
+ const encryptedKey = encryptKey(args.apiKey);
65
+ const keyHint = getKeyHint(args.apiKey);
66
+
67
+ if (existingKey) {
68
+ // Update existing key
69
+ await ctx.db.patch(existingKey._id, {
70
+ encryptedKey,
71
+ keyHint,
72
+ updatedAt: now,
73
+ });
74
+ return { success: true, action: "updated" };
75
+ } else {
76
+ // Create new key
77
+ await ctx.db.insert("providerKeys", {
78
+ workspaceId,
79
+ provider: args.provider,
80
+ encryptedKey,
81
+ keyHint,
82
+ isCustom: false,
83
+ createdAt: now,
84
+ updatedAt: now,
85
+ });
86
+ return { success: true, action: "created" };
87
+ }
88
+ },
89
+ });
90
+
91
+ // ============================================
92
+ // REMOVE KEY
93
+ // ============================================
94
+
95
+ export const removeKey = mutation({
96
+ args: {
97
+ token: v.string(),
98
+ provider: v.string(),
99
+ },
100
+ handler: async (ctx, args) => {
101
+ // Validate session
102
+ const session = await ctx.db
103
+ .query("agentSessions")
104
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
105
+ .first();
106
+
107
+ if (!session) {
108
+ throw new Error("Invalid session");
109
+ }
110
+
111
+ const workspaceId = session.workspaceId;
112
+
113
+ // Find and delete the key
114
+ const existingKey = await ctx.db
115
+ .query("providerKeys")
116
+ .withIndex("by_provider", (q) =>
117
+ q.eq("workspaceId", workspaceId).eq("provider", args.provider)
118
+ )
119
+ .first();
120
+
121
+ if (!existingKey) {
122
+ throw new Error("Key not found");
123
+ }
124
+
125
+ await ctx.db.delete(existingKey._id);
126
+ return { success: true };
127
+ },
128
+ });
129
+
130
+ // ============================================
131
+ // GET KEYS (for display - no actual key values)
132
+ // ============================================
133
+
134
+ export const getKeys = query({
135
+ args: {
136
+ token: v.string(),
137
+ },
138
+ handler: async (ctx, args) => {
139
+ // Validate session
140
+ const session = await ctx.db
141
+ .query("agentSessions")
142
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
143
+ .first();
144
+
145
+ if (!session) {
146
+ return { keys: [] };
147
+ }
148
+
149
+ const workspaceId = session.workspaceId;
150
+
151
+ // Get all keys for this workspace
152
+ const keys = await ctx.db
153
+ .query("providerKeys")
154
+ .withIndex("by_workspaceId", (q) => q.eq("workspaceId", workspaceId))
155
+ .collect();
156
+
157
+ // Return without actual key values
158
+ return {
159
+ keys: keys.map((key) => ({
160
+ provider: key.provider,
161
+ keyHint: key.keyHint,
162
+ isCustom: key.isCustom,
163
+ customConfig: key.customConfig,
164
+ createdAt: key.createdAt,
165
+ updatedAt: key.updatedAt,
166
+ })),
167
+ };
168
+ },
169
+ });
170
+
171
+ // ============================================
172
+ // GET KEY FOR EXECUTION (internal use only)
173
+ // ============================================
174
+
175
+ export const getKeyForExecution = internalQuery({
176
+ args: {
177
+ workspaceId: v.id("workspaces"),
178
+ provider: v.string(),
179
+ },
180
+ handler: async (ctx, args) => {
181
+ const key = await ctx.db
182
+ .query("providerKeys")
183
+ .withIndex("by_provider", (q) =>
184
+ q.eq("workspaceId", args.workspaceId).eq("provider", args.provider)
185
+ )
186
+ .first();
187
+
188
+ if (!key) {
189
+ return null;
190
+ }
191
+
192
+ return {
193
+ apiKey: decryptKey(key.encryptedKey),
194
+ isCustom: key.isCustom,
195
+ customConfig: key.customConfig,
196
+ };
197
+ },
198
+ });
199
+
200
+ // ============================================
201
+ // GET SUPPORTED PROVIDERS
202
+ // ============================================
203
+
204
+ export const getSupportedProviders = query({
205
+ args: {},
206
+ handler: async () => {
207
+ return BYOK_PROVIDERS;
208
+ },
209
+ });
@@ -829,3 +829,21 @@ function generateToken(): string {
829
829
  }
830
830
  return result;
831
831
  }
832
+
833
+ // Debug: Update API name/description
834
+ export const debugUpdateAPI = mutation({
835
+ args: {
836
+ apiId: v.string(),
837
+ name: v.optional(v.string()),
838
+ description: v.optional(v.string()),
839
+ category: v.optional(v.string()),
840
+ },
841
+ handler: async (ctx, args) => {
842
+ const updates: any = {};
843
+ if (args.name) updates.name = args.name;
844
+ if (args.description) updates.description = args.description;
845
+ if (args.category) updates.category = args.category;
846
+ await ctx.db.patch(args.apiId as any, updates);
847
+ return { updated: true };
848
+ },
849
+ });
package/convex/schema.ts CHANGED
@@ -49,19 +49,57 @@ export default defineSchema({
49
49
  tier: v.string(), // "free" | "pro" | "enterprise"
50
50
  usageCount: v.number(), // total API calls made
51
51
  usageLimit: v.number(), // max API calls for tier
52
+ // Stripe billing fields
52
53
  stripeCustomerId: v.optional(v.string()),
54
+ stripeSubscriptionId: v.optional(v.string()),
55
+ billingPlan: v.optional(v.string()), // "free" | "usage_based" | "starter" | "pro" | "scale"
56
+ creditBalance: v.optional(v.number()), // prepaid credits in cents
57
+ lastBillingDate: v.optional(v.number()),
53
58
  createdAt: v.number(),
54
59
  updatedAt: v.number(),
55
60
  })
56
61
  .index("by_email", ["email"])
57
62
  .index("by_stripeCustomerId", ["stripeCustomerId"])
63
+ .index("by_stripeSubscriptionId", ["stripeSubscriptionId"])
58
64
  .index("by_status", ["status"]),
59
65
 
66
+ // Invoices (Stripe invoice records)
67
+ invoices: defineTable({
68
+ workspaceId: v.id("workspaces"),
69
+ stripeInvoiceId: v.string(),
70
+ amount: v.number(), // in cents
71
+ status: v.string(), // "paid" | "pending" | "failed" | "void"
72
+ periodStart: v.number(),
73
+ periodEnd: v.number(),
74
+ callCount: v.number(),
75
+ pdfUrl: v.optional(v.string()),
76
+ createdAt: v.number(),
77
+ })
78
+ .index("by_workspaceId", ["workspaceId"])
79
+ .index("by_stripeInvoiceId", ["stripeInvoiceId"])
80
+ .index("by_workspaceId_createdAt", ["workspaceId", "createdAt"]),
81
+
82
+ // Usage records (daily aggregation for billing)
83
+ usageRecords: defineTable({
84
+ workspaceId: v.id("workspaces"),
85
+ date: v.string(), // "2026-02-28" format
86
+ callCount: v.number(),
87
+ reportedToStripe: v.boolean(),
88
+ stripeUsageRecordId: v.optional(v.string()),
89
+ createdAt: v.number(),
90
+ updatedAt: v.number(),
91
+ })
92
+ .index("by_workspaceId", ["workspaceId"])
93
+ .index("by_date", ["date"])
94
+ .index("by_workspaceId_date", ["workspaceId", "date"])
95
+ .index("by_reportedToStripe", ["reportedToStripe"]),
96
+
60
97
  // Agent sessions (for MCP server authentication)
61
98
  agentSessions: defineTable({
62
99
  workspaceId: v.id("workspaces"),
63
100
  sessionToken: v.string(),
64
101
  fingerprint: v.optional(v.string()), // machine fingerprint
102
+ customName: v.optional(v.string()), // user-defined name
65
103
  lastUsedAt: v.number(),
66
104
  createdAt: v.number(),
67
105
  })
@@ -338,6 +376,24 @@ export default defineSchema({
338
376
  .index("by_userId_providerId", ["userId", "providerId"])
339
377
  .index("by_userId_timestamp", ["userId", "timestamp"]),
340
378
 
379
+ // ============================================
380
+ // API LOGS (workspace/consumer view)
381
+ // ============================================
382
+
383
+ apiLogs: defineTable({
384
+ workspaceId: v.id("workspaces"),
385
+ sessionToken: v.string(),
386
+ provider: v.string(),
387
+ action: v.string(),
388
+ status: v.union(v.literal("success"), v.literal("error")),
389
+ latencyMs: v.number(),
390
+ errorMessage: v.optional(v.string()),
391
+ createdAt: v.number(),
392
+ })
393
+ .index("by_workspaceId", ["workspaceId"])
394
+ .index("by_createdAt", ["createdAt"])
395
+ .index("by_workspaceId_createdAt", ["workspaceId", "createdAt"]),
396
+
341
397
  // ============================================
342
398
  // WAITLIST (for Direct Call provider leads)
343
399
  // ============================================
@@ -413,4 +469,63 @@ export default defineSchema({
413
469
  .index("by_providerId", ["providerId"])
414
470
  .index("by_userId", ["userId"])
415
471
  .index("by_timestamp", ["timestamp"]),
472
+
473
+ // ============================================
474
+ // WEBHOOKS
475
+ // ============================================
476
+
477
+ webhooks: defineTable({
478
+ workspaceId: v.id("workspaces"),
479
+ url: v.string(),
480
+ events: v.array(v.string()),
481
+ secret: v.string(), // For signature verification
482
+ enabled: v.boolean(),
483
+ lastTriggeredAt: v.optional(v.number()),
484
+ lastStatus: v.optional(v.string()), // "success" | "failed"
485
+ failCount: v.number(),
486
+ createdAt: v.number(),
487
+ })
488
+ .index("by_workspaceId", ["workspaceId"]),
489
+
490
+ // ============================================
491
+ // BYOK - BRING YOUR OWN KEY
492
+ // ============================================
493
+
494
+ // User-provided API keys for providers
495
+ providerKeys: defineTable({
496
+ workspaceId: v.id("workspaces"),
497
+ provider: v.string(), // "brave_search", "openrouter", etc.
498
+ encryptedKey: v.string(), // Base64 encoded for MVP
499
+ keyHint: v.string(), // Last 4 chars for display
500
+ isCustom: v.boolean(), // true if custom provider (not built-in)
501
+ customConfig: v.optional(v.object({
502
+ baseUrl: v.string(),
503
+ authType: v.string(), // "bearer", "api_key", "basic"
504
+ authHeader: v.optional(v.string()), // e.g. "X-API-Key"
505
+ })),
506
+ createdAt: v.number(),
507
+ updatedAt: v.number(),
508
+ })
509
+ .index("by_workspaceId", ["workspaceId"])
510
+ .index("by_provider", ["workspaceId", "provider"]),
511
+
512
+ // ============================================
513
+ // FEEDBACK SYSTEM
514
+ // ============================================
515
+
516
+ // User feedback with voting
517
+ feedback: defineTable({
518
+ workspaceId: v.id("workspaces"),
519
+ type: v.union(v.literal("bug"), v.literal("feature"), v.literal("general")),
520
+ content: v.string(),
521
+ votes: v.number(),
522
+ votedBy: v.array(v.string()), // workspace IDs that voted
523
+ status: v.union(v.literal("new"), v.literal("reviewing"), v.literal("planned"), v.literal("shipped")),
524
+ createdAt: v.number(),
525
+ })
526
+ .index("by_workspaceId", ["workspaceId"])
527
+ .index("by_type", ["type"])
528
+ .index("by_status", ["status"])
529
+ .index("by_votes", ["votes"])
530
+ .index("by_createdAt", ["createdAt"]),
416
531
  });