@fatagnus/remote-cmd-relay-convex 1.0.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.
package/src/public.ts ADDED
@@ -0,0 +1,436 @@
1
+ import { v } from "convex/values";
2
+ import { query, mutation } from "./_generated/server";
3
+ import {
4
+ targetTypeValidator,
5
+ capabilityValidator,
6
+ metricsValidator,
7
+ credentialTypeValidator,
8
+ storageModeValidator,
9
+ } from "./schema";
10
+
11
+ /**
12
+ * Verify a relay's API key and return its assignment details
13
+ * This is called by relays on startup to verify their API key
14
+ */
15
+ export const verifyRelay = query({
16
+ args: {
17
+ apiKeyId: v.string(),
18
+ },
19
+ returns: v.union(
20
+ v.object({
21
+ valid: v.literal(true),
22
+ assignmentId: v.string(),
23
+ machineId: v.string(),
24
+ name: v.string(),
25
+ }),
26
+ v.object({
27
+ valid: v.literal(false),
28
+ error: v.string(),
29
+ })
30
+ ),
31
+ handler: async (ctx, args) => {
32
+ const assignment = await ctx.db
33
+ .query("relayAssignments")
34
+ .withIndex("by_apiKeyId", (q) => q.eq("apiKeyId", args.apiKeyId))
35
+ .first();
36
+
37
+ if (!assignment) {
38
+ return { valid: false as const, error: "No relay assignment found for this API key" };
39
+ }
40
+
41
+ if (!assignment.enabled) {
42
+ return { valid: false as const, error: "Relay assignment is disabled" };
43
+ }
44
+
45
+ return {
46
+ valid: true as const,
47
+ assignmentId: assignment._id,
48
+ machineId: assignment.machineId,
49
+ name: assignment.name,
50
+ };
51
+ },
52
+ });
53
+
54
+ /**
55
+ * Get pending commands for a relay to execute
56
+ */
57
+ export const getPendingCommands = query({
58
+ args: {
59
+ machineId: v.string(),
60
+ },
61
+ returns: v.array(
62
+ v.object({
63
+ _id: v.id("commandQueue"),
64
+ command: v.string(),
65
+ targetType: targetTypeValidator,
66
+ targetHost: v.optional(v.string()),
67
+ targetPort: v.optional(v.number()),
68
+ targetUsername: v.optional(v.string()),
69
+ timeoutMs: v.number(),
70
+ createdAt: v.number(),
71
+ })
72
+ ),
73
+ handler: async (ctx, args) => {
74
+ const commands = await ctx.db
75
+ .query("commandQueue")
76
+ .withIndex("by_machineId_status", (q) =>
77
+ q.eq("machineId", args.machineId).eq("status", "pending")
78
+ )
79
+ .order("asc")
80
+ .take(10);
81
+
82
+ return commands.map((c) => ({
83
+ _id: c._id,
84
+ command: c.command,
85
+ targetType: c.targetType,
86
+ targetHost: c.targetHost,
87
+ targetPort: c.targetPort,
88
+ targetUsername: c.targetUsername,
89
+ timeoutMs: c.timeoutMs,
90
+ createdAt: c.createdAt,
91
+ }));
92
+ },
93
+ });
94
+
95
+ /**
96
+ * Claim a command for execution (atomic operation)
97
+ */
98
+ export const claimCommand = mutation({
99
+ args: {
100
+ commandId: v.id("commandQueue"),
101
+ assignmentId: v.string(),
102
+ },
103
+ returns: v.union(
104
+ v.object({
105
+ success: v.literal(true),
106
+ command: v.object({
107
+ _id: v.id("commandQueue"),
108
+ command: v.string(),
109
+ targetType: targetTypeValidator,
110
+ targetHost: v.optional(v.string()),
111
+ targetPort: v.optional(v.number()),
112
+ targetUsername: v.optional(v.string()),
113
+ timeoutMs: v.number(),
114
+ }),
115
+ }),
116
+ v.object({
117
+ success: v.literal(false),
118
+ error: v.string(),
119
+ })
120
+ ),
121
+ handler: async (ctx, args) => {
122
+ const cmd = await ctx.db.get(args.commandId);
123
+ if (!cmd) {
124
+ return { success: false as const, error: "Command not found" };
125
+ }
126
+
127
+ if (cmd.status !== "pending") {
128
+ return { success: false as const, error: "Command is not pending" };
129
+ }
130
+
131
+ await ctx.db.patch(args.commandId, {
132
+ status: "claimed",
133
+ claimedBy: args.assignmentId,
134
+ claimedAt: Date.now(),
135
+ updatedAt: Date.now(),
136
+ });
137
+
138
+ return {
139
+ success: true as const,
140
+ command: {
141
+ _id: cmd._id,
142
+ command: cmd.command,
143
+ targetType: cmd.targetType,
144
+ targetHost: cmd.targetHost,
145
+ targetPort: cmd.targetPort,
146
+ targetUsername: cmd.targetUsername,
147
+ timeoutMs: cmd.timeoutMs,
148
+ },
149
+ };
150
+ },
151
+ });
152
+
153
+ /**
154
+ * Submit command execution results
155
+ */
156
+ export const submitResult = mutation({
157
+ args: {
158
+ commandId: v.id("commandQueue"),
159
+ success: v.boolean(),
160
+ output: v.optional(v.string()),
161
+ stderr: v.optional(v.string()),
162
+ exitCode: v.optional(v.number()),
163
+ error: v.optional(v.string()),
164
+ durationMs: v.optional(v.number()),
165
+ },
166
+ returns: v.object({
167
+ success: v.boolean(),
168
+ }),
169
+ handler: async (ctx, args) => {
170
+ const cmd = await ctx.db.get(args.commandId);
171
+ if (!cmd) {
172
+ return { success: false };
173
+ }
174
+
175
+ const now = Date.now();
176
+
177
+ await ctx.db.patch(args.commandId, {
178
+ status: args.success ? "completed" : "failed",
179
+ output: args.output,
180
+ stderr: args.stderr,
181
+ exitCode: args.exitCode,
182
+ error: args.error,
183
+ durationMs: args.durationMs,
184
+ completedAt: now,
185
+ updatedAt: now,
186
+ });
187
+
188
+ return { success: true };
189
+ },
190
+ });
191
+
192
+ /**
193
+ * Send heartbeat from relay to update last seen
194
+ */
195
+ export const sendHeartbeat = mutation({
196
+ args: {
197
+ apiKeyId: v.string(),
198
+ },
199
+ returns: v.object({
200
+ success: v.boolean(),
201
+ }),
202
+ handler: async (ctx, args) => {
203
+ const assignment = await ctx.db
204
+ .query("relayAssignments")
205
+ .withIndex("by_apiKeyId", (q) => q.eq("apiKeyId", args.apiKeyId))
206
+ .first();
207
+
208
+ if (!assignment) {
209
+ return { success: false };
210
+ }
211
+
212
+ await ctx.db.patch(assignment._id, {
213
+ lastSeenAt: Date.now(),
214
+ updatedAt: Date.now(),
215
+ });
216
+
217
+ return { success: true };
218
+ },
219
+ });
220
+
221
+ // ===== Status and Capability Reporting =====
222
+
223
+ // Credential inventory item for reporting
224
+ const credentialInventoryItemValidator = v.object({
225
+ credentialName: v.string(),
226
+ credentialType: credentialTypeValidator,
227
+ targetHost: v.optional(v.string()),
228
+ storageMode: storageModeValidator,
229
+ lastUpdatedAt: v.number(),
230
+ });
231
+
232
+ /**
233
+ * Full status report from relay (called on startup and periodically)
234
+ * Includes capabilities, metrics, and credential inventory
235
+ */
236
+ export const reportFullStatus = mutation({
237
+ args: {
238
+ relayId: v.string(),
239
+ capabilities: v.array(capabilityValidator),
240
+ metrics: v.optional(metricsValidator),
241
+ version: v.optional(v.string()),
242
+ hostname: v.optional(v.string()),
243
+ platform: v.optional(v.string()),
244
+ credentials: v.array(credentialInventoryItemValidator),
245
+ },
246
+ returns: v.object({
247
+ success: v.boolean(),
248
+ pendingConfigPushes: v.number(),
249
+ sharedCredentialsCount: v.number(),
250
+ }),
251
+ handler: async (ctx, args) => {
252
+ const now = Date.now();
253
+
254
+ // Update relay status
255
+ const existingStatus = await ctx.db
256
+ .query("relayStatus")
257
+ .withIndex("by_relayId", (q) => q.eq("relayId", args.relayId))
258
+ .first();
259
+
260
+ if (existingStatus) {
261
+ await ctx.db.patch(existingStatus._id, {
262
+ capabilities: args.capabilities,
263
+ metrics: args.metrics,
264
+ version: args.version,
265
+ hostname: args.hostname,
266
+ platform: args.platform,
267
+ lastHeartbeatAt: now,
268
+ updatedAt: now,
269
+ });
270
+ } else {
271
+ await ctx.db.insert("relayStatus", {
272
+ relayId: args.relayId,
273
+ capabilities: args.capabilities,
274
+ metrics: args.metrics,
275
+ version: args.version,
276
+ hostname: args.hostname,
277
+ platform: args.platform,
278
+ lastHeartbeatAt: now,
279
+ createdAt: now,
280
+ updatedAt: now,
281
+ });
282
+ }
283
+
284
+ // Sync credential inventory
285
+ const existingCreds = await ctx.db
286
+ .query("relayCredentialInventory")
287
+ .withIndex("by_relayId", (q) => q.eq("relayId", args.relayId))
288
+ .collect();
289
+
290
+ const existingCredsMap = new Map(existingCreds.map((c) => [c.credentialName, c]));
291
+ const reportedNames = new Set(args.credentials.map((c) => c.credentialName));
292
+
293
+ // Delete credentials no longer reported
294
+ for (const cred of existingCreds) {
295
+ if (!reportedNames.has(cred.credentialName)) {
296
+ await ctx.db.delete(cred._id);
297
+ }
298
+ }
299
+
300
+ // Update or insert reported credentials
301
+ for (const cred of args.credentials) {
302
+ const existingCred = existingCredsMap.get(cred.credentialName);
303
+
304
+ if (existingCred) {
305
+ await ctx.db.patch(existingCred._id, {
306
+ credentialType: cred.credentialType,
307
+ targetHost: cred.targetHost,
308
+ storageMode: cred.storageMode,
309
+ lastUpdatedAt: cred.lastUpdatedAt,
310
+ reportedAt: now,
311
+ });
312
+ } else {
313
+ await ctx.db.insert("relayCredentialInventory", {
314
+ relayId: args.relayId,
315
+ credentialName: cred.credentialName,
316
+ credentialType: cred.credentialType,
317
+ targetHost: cred.targetHost,
318
+ storageMode: cred.storageMode,
319
+ lastUpdatedAt: cred.lastUpdatedAt,
320
+ reportedAt: now,
321
+ });
322
+ }
323
+ }
324
+
325
+ // Count pending config pushes for this relay
326
+ const pendingPushes = await ctx.db
327
+ .query("configPushQueue")
328
+ .withIndex("by_relayId_status", (q) =>
329
+ q.eq("relayId", args.relayId).eq("status", "pending")
330
+ )
331
+ .collect();
332
+
333
+ // Count shared credentials assigned to this relay
334
+ const sharedCreds = await ctx.db.query("sharedCredentials").collect();
335
+ const assignedSharedCreds = sharedCreds.filter((c) =>
336
+ c.assignedRelays.includes(args.relayId)
337
+ );
338
+
339
+ return {
340
+ success: true,
341
+ pendingConfigPushes: pendingPushes.length,
342
+ sharedCredentialsCount: assignedSharedCreds.length,
343
+ };
344
+ },
345
+ });
346
+
347
+ /**
348
+ * Get pending config pushes for relay
349
+ */
350
+ export const getPendingConfigPushes = query({
351
+ args: {
352
+ relayId: v.string(),
353
+ },
354
+ returns: v.array(
355
+ v.object({
356
+ _id: v.id("configPushQueue"),
357
+ pushType: v.string(),
358
+ payload: v.string(),
359
+ createdAt: v.number(),
360
+ })
361
+ ),
362
+ handler: async (ctx, args) => {
363
+ const pushes = await ctx.db
364
+ .query("configPushQueue")
365
+ .withIndex("by_relayId_status", (q) =>
366
+ q.eq("relayId", args.relayId).eq("status", "pending")
367
+ )
368
+ .collect();
369
+
370
+ return pushes.map((p) => ({
371
+ _id: p._id,
372
+ pushType: p.pushType,
373
+ payload: p.payload,
374
+ createdAt: p.createdAt,
375
+ }));
376
+ },
377
+ });
378
+
379
+ /**
380
+ * Acknowledge a config push
381
+ */
382
+ export const acknowledgeConfigPush = mutation({
383
+ args: {
384
+ pushId: v.id("configPushQueue"),
385
+ success: v.boolean(),
386
+ errorMessage: v.optional(v.string()),
387
+ },
388
+ returns: v.object({
389
+ success: v.boolean(),
390
+ }),
391
+ handler: async (ctx, args) => {
392
+ const push = await ctx.db.get(args.pushId);
393
+ if (!push) {
394
+ return { success: false };
395
+ }
396
+
397
+ await ctx.db.patch(args.pushId, {
398
+ status: args.success ? "acked" : "failed",
399
+ ackedAt: Date.now(),
400
+ errorMessage: args.errorMessage,
401
+ });
402
+
403
+ return { success: true };
404
+ },
405
+ });
406
+
407
+ /**
408
+ * Get shared credentials assigned to this relay
409
+ */
410
+ export const getSharedCredentials = query({
411
+ args: {
412
+ relayId: v.string(),
413
+ },
414
+ returns: v.array(
415
+ v.object({
416
+ name: v.string(),
417
+ credentialType: credentialTypeValidator,
418
+ targetHost: v.optional(v.string()),
419
+ encryptedValue: v.string(),
420
+ updatedAt: v.number(),
421
+ })
422
+ ),
423
+ handler: async (ctx, args) => {
424
+ const creds = await ctx.db.query("sharedCredentials").collect();
425
+
426
+ return creds
427
+ .filter((c) => c.assignedRelays.includes(args.relayId))
428
+ .map((c) => ({
429
+ name: c.name,
430
+ credentialType: c.credentialType,
431
+ targetHost: c.targetHost,
432
+ encryptedValue: c.encryptedValue,
433
+ updatedAt: c.updatedAt,
434
+ }));
435
+ },
436
+ });
package/src/schema.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ // Command status
5
+ export const commandStatusValidator = v.union(
6
+ v.literal("pending"),
7
+ v.literal("claimed"),
8
+ v.literal("executing"),
9
+ v.literal("completed"),
10
+ v.literal("failed"),
11
+ v.literal("timeout")
12
+ );
13
+
14
+ // Target type for command execution
15
+ export const targetTypeValidator = v.union(
16
+ v.literal("local"),
17
+ v.literal("ssh")
18
+ );
19
+
20
+ // Relay capabilities
21
+ export const capabilityValidator = v.union(
22
+ v.literal("ssh"),
23
+ v.literal("local_cmd"),
24
+ v.literal("perf_metrics")
25
+ );
26
+
27
+ // Credential types
28
+ export const credentialTypeValidator = v.union(
29
+ v.literal("ssh_key"),
30
+ v.literal("password"),
31
+ v.literal("api_key")
32
+ );
33
+
34
+ // Credential storage mode
35
+ export const storageModeValidator = v.union(
36
+ v.literal("relay_only"),
37
+ v.literal("shared")
38
+ );
39
+
40
+ // Config push types
41
+ export const configPushTypeValidator = v.union(
42
+ v.literal("credential"),
43
+ v.literal("ssh_targets"),
44
+ v.literal("allowed_commands"),
45
+ v.literal("metrics_interval")
46
+ );
47
+
48
+ // Config push status
49
+ export const configPushStatusValidator = v.union(
50
+ v.literal("pending"),
51
+ v.literal("sent"),
52
+ v.literal("acked"),
53
+ v.literal("failed")
54
+ );
55
+
56
+ // Performance metrics object
57
+ export const metricsValidator = v.object({
58
+ cpuPercent: v.optional(v.number()),
59
+ memoryPercent: v.optional(v.number()),
60
+ memoryUsedMb: v.optional(v.number()),
61
+ memoryTotalMb: v.optional(v.number()),
62
+ diskPercent: v.optional(v.number()),
63
+ diskUsedGb: v.optional(v.number()),
64
+ diskTotalGb: v.optional(v.number()),
65
+ loadAvg1m: v.optional(v.number()),
66
+ loadAvg5m: v.optional(v.number()),
67
+ loadAvg15m: v.optional(v.number()),
68
+ });
69
+
70
+ export const tables = {
71
+ // Relay assignments - links API keys to machines
72
+ relayAssignments: defineTable({
73
+ apiKeyId: v.string(), // Better Auth API key ID
74
+ machineId: v.string(), // Reference to machine in main app
75
+ name: v.string(), // Friendly name for the relay
76
+ enabled: v.boolean(),
77
+ lastSeenAt: v.optional(v.number()),
78
+ createdBy: v.string(),
79
+ createdAt: v.number(),
80
+ updatedAt: v.number(),
81
+ })
82
+ .index("by_apiKeyId", ["apiKeyId"])
83
+ .index("by_machineId", ["machineId"])
84
+ .index("by_enabled", ["enabled"]),
85
+
86
+ // Relay status - capabilities, metrics, heartbeat
87
+ relayStatus: defineTable({
88
+ relayId: v.string(), // Reference to relayAssignments._id
89
+ capabilities: v.array(capabilityValidator),
90
+ metrics: v.optional(metricsValidator),
91
+ version: v.optional(v.string()), // Relay binary version
92
+ hostname: v.optional(v.string()), // Relay host machine name
93
+ platform: v.optional(v.string()), // OS platform
94
+ lastHeartbeatAt: v.number(),
95
+ createdAt: v.number(),
96
+ updatedAt: v.number(),
97
+ })
98
+ .index("by_relayId", ["relayId"])
99
+ .index("by_lastHeartbeat", ["lastHeartbeatAt"]),
100
+
101
+ // Relay credential inventory - what credentials each relay reports it has
102
+ relayCredentialInventory: defineTable({
103
+ relayId: v.string(), // Reference to relayAssignments._id
104
+ credentialName: v.string(), // Name/identifier of the credential
105
+ credentialType: credentialTypeValidator,
106
+ targetHost: v.optional(v.string()), // What host/machine this credential is for
107
+ storageMode: storageModeValidator, // relay_only or shared
108
+ lastUpdatedAt: v.number(), // When the credential was last updated on relay
109
+ reportedAt: v.number(), // When relay reported this credential
110
+ })
111
+ .index("by_relayId", ["relayId"])
112
+ .index("by_relayId_name", ["relayId", "credentialName"])
113
+ .index("by_targetHost", ["targetHost"]),
114
+
115
+ // Shared credentials - backup copies for shared mode creds (encrypted)
116
+ sharedCredentials: defineTable({
117
+ name: v.string(), // Credential name
118
+ credentialType: credentialTypeValidator,
119
+ targetHost: v.optional(v.string()), // What host/machine this credential is for
120
+ encryptedValue: v.string(), // Encrypted credential value
121
+ assignedRelays: v.array(v.string()), // List of relay IDs this is assigned to
122
+ createdBy: v.string(),
123
+ createdAt: v.number(),
124
+ updatedAt: v.number(),
125
+ })
126
+ .index("by_name", ["name"])
127
+ .index("by_targetHost", ["targetHost"]),
128
+
129
+ // Config push queue - pending configuration pushes to relays
130
+ configPushQueue: defineTable({
131
+ relayId: v.string(), // Target relay
132
+ pushType: configPushTypeValidator,
133
+ payload: v.string(), // JSON-encoded payload
134
+ status: configPushStatusValidator,
135
+ createdBy: v.string(),
136
+ createdAt: v.number(),
137
+ sentAt: v.optional(v.number()),
138
+ ackedAt: v.optional(v.number()),
139
+ errorMessage: v.optional(v.string()),
140
+ })
141
+ .index("by_relayId", ["relayId"])
142
+ .index("by_relayId_status", ["relayId", "status"])
143
+ .index("by_status", ["status"]),
144
+
145
+ // Command queue - commands waiting to be executed by relays
146
+ commandQueue: defineTable({
147
+ machineId: v.string(), // Target machine (relay assignment)
148
+ targetType: targetTypeValidator, // local or ssh
149
+ // SSH target details (only if targetType is "ssh")
150
+ targetHost: v.optional(v.string()),
151
+ targetPort: v.optional(v.number()),
152
+ targetUsername: v.optional(v.string()),
153
+ // Command details
154
+ command: v.string(),
155
+ timeoutMs: v.number(), // Command timeout
156
+ // Status tracking
157
+ status: commandStatusValidator,
158
+ claimedBy: v.optional(v.string()), // Relay assignment ID that claimed
159
+ claimedAt: v.optional(v.number()),
160
+ // Results
161
+ output: v.optional(v.string()),
162
+ stderr: v.optional(v.string()),
163
+ exitCode: v.optional(v.number()),
164
+ error: v.optional(v.string()),
165
+ durationMs: v.optional(v.number()),
166
+ completedAt: v.optional(v.number()),
167
+ // Metadata
168
+ createdBy: v.string(),
169
+ createdAt: v.number(),
170
+ updatedAt: v.number(),
171
+ })
172
+ .index("by_machineId", ["machineId"])
173
+ .index("by_status", ["status"])
174
+ .index("by_machineId_status", ["machineId", "status"])
175
+ .index("by_createdAt", ["createdAt"]),
176
+ };
177
+
178
+ const schema = defineSchema(tables);
179
+
180
+ export default schema;