@wopr-network/platform-core 1.13.0 → 1.13.1

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 (103) hide show
  1. package/dist/api/routes/activity.d.ts +9 -0
  2. package/dist/api/routes/activity.js +68 -0
  3. package/dist/api/routes/admin-audit-helper.d.ts +7 -0
  4. package/dist/api/routes/admin-audit-helper.js +13 -0
  5. package/dist/api/routes/admin-audit.d.ts +13 -0
  6. package/dist/api/routes/admin-audit.js +61 -0
  7. package/dist/api/routes/admin-backups.d.ts +19 -0
  8. package/dist/api/routes/admin-backups.js +116 -0
  9. package/dist/api/routes/admin-compliance.d.ts +9 -0
  10. package/dist/api/routes/admin-compliance.js +27 -0
  11. package/dist/api/routes/admin-credits.d.ts +9 -0
  12. package/dist/api/routes/admin-credits.js +255 -0
  13. package/dist/api/routes/admin-gpu.d.ts +46 -0
  14. package/dist/api/routes/admin-gpu.js +140 -0
  15. package/dist/api/routes/admin-inference.d.ts +16 -0
  16. package/dist/api/routes/admin-inference.js +98 -0
  17. package/dist/api/routes/admin-marketplace.d.ts +36 -0
  18. package/dist/api/routes/admin-marketplace.js +181 -0
  19. package/dist/api/routes/admin-migration.d.ts +10 -0
  20. package/dist/api/routes/admin-migration.js +46 -0
  21. package/dist/api/routes/admin-notes.d.ts +34 -0
  22. package/dist/api/routes/admin-notes.js +131 -0
  23. package/dist/api/routes/admin-onboarding.d.ts +7 -0
  24. package/dist/api/routes/admin-onboarding.js +49 -0
  25. package/dist/api/routes/admin-rates.d.ts +9 -0
  26. package/dist/api/routes/admin-rates.js +427 -0
  27. package/dist/api/routes/admin-recovery.d.ts +91 -0
  28. package/dist/api/routes/admin-recovery.js +246 -0
  29. package/dist/api/routes/admin-roles.d.ts +27 -0
  30. package/dist/api/routes/admin-roles.js +157 -0
  31. package/dist/api/routes/audit.d.ts +19 -0
  32. package/dist/api/routes/audit.js +95 -0
  33. package/dist/api/routes/auth.d.ts +19 -0
  34. package/dist/api/routes/auth.js +25 -0
  35. package/dist/api/routes/channel-validate.d.ts +11 -0
  36. package/dist/api/routes/channel-validate.js +148 -0
  37. package/dist/api/routes/fleet-events.d.ts +4 -0
  38. package/dist/api/routes/fleet-events.js +53 -0
  39. package/dist/api/routes/friends-proxy.d.ts +28 -0
  40. package/dist/api/routes/friends-proxy.js +63 -0
  41. package/dist/api/routes/friends-types.d.ts +34 -0
  42. package/dist/api/routes/friends-types.js +28 -0
  43. package/dist/api/routes/health.d.ts +14 -0
  44. package/dist/api/routes/health.js +32 -0
  45. package/dist/api/routes/health.test.d.ts +1 -0
  46. package/dist/api/routes/health.test.js +70 -0
  47. package/dist/api/routes/incident-response.d.ts +9 -0
  48. package/dist/api/routes/incident-response.js +148 -0
  49. package/dist/api/routes/internal-gpu.d.ts +12 -0
  50. package/dist/api/routes/internal-gpu.js +70 -0
  51. package/dist/api/routes/internal-nodes.d.ts +41 -0
  52. package/dist/api/routes/internal-nodes.js +105 -0
  53. package/dist/api/routes/login-history.d.ts +11 -0
  54. package/dist/api/routes/login-history.js +22 -0
  55. package/dist/api/routes/public-pricing.d.ts +9 -0
  56. package/dist/api/routes/public-pricing.js +32 -0
  57. package/dist/api/routes/quota.d.ts +8 -0
  58. package/dist/api/routes/quota.js +113 -0
  59. package/dist/api/routes/secret-audit.d.ts +12 -0
  60. package/dist/api/routes/secret-audit.js +41 -0
  61. package/dist/api/routes/secrets.d.ts +31 -0
  62. package/dist/api/routes/secrets.js +135 -0
  63. package/dist/api/routes/tenant-keys.d.ts +16 -0
  64. package/dist/api/routes/tenant-keys.js +142 -0
  65. package/dist/api/routes/verify-email.d.ts +19 -0
  66. package/dist/api/routes/verify-email.js +70 -0
  67. package/dist/api/routes/ws-auth.d.ts +21 -0
  68. package/dist/api/routes/ws-auth.js +24 -0
  69. package/package.json +35 -1
  70. package/src/api/routes/activity.ts +77 -0
  71. package/src/api/routes/admin-audit-helper.ts +18 -0
  72. package/src/api/routes/admin-audit.ts +67 -0
  73. package/src/api/routes/admin-backups.ts +134 -0
  74. package/src/api/routes/admin-compliance.ts +35 -0
  75. package/src/api/routes/admin-credits.ts +280 -0
  76. package/src/api/routes/admin-gpu.ts +202 -0
  77. package/src/api/routes/admin-inference.ts +109 -0
  78. package/src/api/routes/admin-marketplace.ts +233 -0
  79. package/src/api/routes/admin-migration.ts +61 -0
  80. package/src/api/routes/admin-notes.ts +145 -0
  81. package/src/api/routes/admin-onboarding.ts +62 -0
  82. package/src/api/routes/admin-rates.ts +462 -0
  83. package/src/api/routes/admin-recovery.ts +376 -0
  84. package/src/api/routes/admin-roles.ts +205 -0
  85. package/src/api/routes/audit.ts +106 -0
  86. package/src/api/routes/auth.ts +30 -0
  87. package/src/api/routes/channel-validate.ts +182 -0
  88. package/src/api/routes/fleet-events.ts +66 -0
  89. package/src/api/routes/friends-proxy.ts +94 -0
  90. package/src/api/routes/friends-types.ts +37 -0
  91. package/src/api/routes/health.test.ts +80 -0
  92. package/src/api/routes/health.ts +48 -0
  93. package/src/api/routes/incident-response.ts +159 -0
  94. package/src/api/routes/internal-gpu.ts +92 -0
  95. package/src/api/routes/internal-nodes.ts +157 -0
  96. package/src/api/routes/login-history.ts +28 -0
  97. package/src/api/routes/public-pricing.ts +36 -0
  98. package/src/api/routes/quota.ts +136 -0
  99. package/src/api/routes/secret-audit.ts +55 -0
  100. package/src/api/routes/secrets.ts +178 -0
  101. package/src/api/routes/tenant-keys.ts +178 -0
  102. package/src/api/routes/verify-email.ts +102 -0
  103. package/src/api/routes/ws-auth.ts +44 -0
@@ -0,0 +1,134 @@
1
+ import { Hono } from "hono";
2
+ import type { AuthEnv } from "../../auth/index.js";
3
+ import type { IBackupStatusStore } from "../../backup/backup-status-store.js";
4
+ import type { SpacesClient } from "../../backup/spaces-client.js";
5
+ import { logger } from "../../config/logger.js";
6
+ import type { AdminAuditLogger } from "./admin-audit-helper.js";
7
+ import { safeAuditLog } from "./admin-audit-helper.js";
8
+
9
+ /**
10
+ * Validate that a remotePath belongs to the given container.
11
+ * Normalizes the path, rejects traversal segments, and checks that the containerId
12
+ * appears as the leading non-empty segment.
13
+ */
14
+ export function isRemotePathOwnedBy(remotePath: string, containerId: string): boolean {
15
+ const normalized = remotePath.replace(/\\/g, "/");
16
+ const segments = normalized.split("/").filter(Boolean);
17
+ if (segments.length === 0) return false;
18
+ if (segments.some((s) => s === ".." || s === ".")) return false;
19
+ return segments[0] === containerId;
20
+ }
21
+
22
+ /**
23
+ * Create admin backup routes.
24
+ *
25
+ * @param storeFactory - factory for the backup status store
26
+ * @param spacesFactory - factory for the S3/Spaces client
27
+ * @param auditLogger - optional admin audit logger
28
+ */
29
+ export function createAdminBackupRoutes(
30
+ storeFactory: () => IBackupStatusStore,
31
+ spacesFactory: () => SpacesClient,
32
+ auditLogger?: () => AdminAuditLogger,
33
+ ): Hono<AuthEnv> {
34
+ const routes = new Hono<AuthEnv>();
35
+
36
+ routes.get("/", async (c) => {
37
+ const store = storeFactory();
38
+ const staleOnly = c.req.query("stale") === "true";
39
+ const entries = staleOnly ? await store.listStale() : await store.listAll();
40
+ return c.json({
41
+ backups: entries,
42
+ total: entries.length,
43
+ staleCount: entries.filter((e) => e.isStale).length,
44
+ });
45
+ });
46
+
47
+ routes.get("/alerts/stale", async (c) => {
48
+ const store = storeFactory();
49
+ const stale = await store.listStale();
50
+ return c.json({
51
+ alerts: stale.map((e) => ({
52
+ containerId: e.containerId,
53
+ nodeId: e.nodeId,
54
+ lastBackupAt: e.lastBackupAt,
55
+ lastBackupSuccess: e.lastBackupSuccess,
56
+ lastBackupError: e.lastBackupError,
57
+ })),
58
+ count: stale.length,
59
+ });
60
+ });
61
+
62
+ routes.get("/:containerId", async (c) => {
63
+ const store = storeFactory();
64
+ const containerId = c.req.param("containerId");
65
+ const entry = await store.get(containerId);
66
+ if (!entry) {
67
+ return c.json({ error: "No backup status found for this container" }, 404);
68
+ }
69
+ return c.json(entry);
70
+ });
71
+
72
+ routes.get("/:containerId/snapshots", async (c) => {
73
+ const store = storeFactory();
74
+ const containerId = c.req.param("containerId");
75
+ const entry = await store.get(containerId);
76
+ if (!entry) {
77
+ return c.json({ error: "No backup status found for this container" }, 404);
78
+ }
79
+
80
+ try {
81
+ const spaces = spacesFactory();
82
+ const prefix = `nightly/${entry.nodeId}/${containerId}/`;
83
+ const objects = await spaces.list(prefix);
84
+ return c.json({
85
+ containerId,
86
+ snapshots: objects.map((o) => ({
87
+ path: o.path,
88
+ date: o.date,
89
+ sizeMb: Math.round((o.size / (1024 * 1024)) * 100) / 100,
90
+ })),
91
+ });
92
+ } catch (err) {
93
+ logger.error(`Failed to list snapshots for ${containerId}`, { err });
94
+ return c.json({ error: "Failed to list backup snapshots" }, 500);
95
+ }
96
+ });
97
+
98
+ routes.post("/:containerId/restore", async (c) => {
99
+ const containerId = c.req.param("containerId");
100
+
101
+ let body: { remotePath?: string; targetNodeId?: string };
102
+ try {
103
+ body = await c.req.json();
104
+ } catch {
105
+ return c.json({ error: "Invalid JSON body" }, 400);
106
+ }
107
+
108
+ if (!body.remotePath) {
109
+ return c.json({ error: "remotePath is required" }, 400);
110
+ }
111
+
112
+ if (!isRemotePathOwnedBy(body.remotePath, containerId)) {
113
+ return c.json({ error: "remotePath does not belong to this container" }, 403);
114
+ }
115
+
116
+ safeAuditLog(auditLogger, {
117
+ adminUser: (c.get("user") as { id?: string } | undefined)?.id ?? "unknown",
118
+ action: "backup.restore",
119
+ category: "config",
120
+ details: { containerId, remotePath: body.remotePath, targetNodeId: body.targetNodeId ?? "auto" },
121
+ outcome: "success",
122
+ });
123
+
124
+ return c.json({
125
+ ok: true,
126
+ message: `Restore initiated for ${containerId} from ${body.remotePath}`,
127
+ containerId,
128
+ remotePath: body.remotePath,
129
+ targetNodeId: body.targetNodeId ?? "auto",
130
+ });
131
+ });
132
+
133
+ return routes;
134
+ }
@@ -0,0 +1,35 @@
1
+ import { Hono } from "hono";
2
+ import type { EvidenceCollector } from "../../compliance/evidence-collector.js";
3
+
4
+ /**
5
+ * Create admin compliance routes.
6
+ *
7
+ * GET /evidence?from=ISO&to=ISO — Generate SOC 2 evidence report for the given period.
8
+ * Defaults to last 90 days if no params provided.
9
+ */
10
+ export function createAdminComplianceRoutes(getCollector: () => EvidenceCollector): Hono {
11
+ const routes = new Hono();
12
+
13
+ routes.get("/evidence", async (c) => {
14
+ const now = new Date();
15
+ const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
16
+
17
+ const fromParam = c.req.query("from");
18
+ const toParam = c.req.query("to");
19
+
20
+ if (fromParam !== undefined && Number.isNaN(new Date(fromParam).getTime())) {
21
+ return c.json({ error: "Invalid 'from' date" }, 400);
22
+ }
23
+ if (toParam !== undefined && Number.isNaN(new Date(toParam).getTime())) {
24
+ return c.json({ error: "Invalid 'to' date" }, 400);
25
+ }
26
+
27
+ const from = fromParam ?? ninetyDaysAgo.toISOString();
28
+ const to = toParam ?? now.toISOString();
29
+
30
+ const report = await getCollector().collect({ from, to });
31
+ return c.json(report);
32
+ });
33
+
34
+ return routes;
35
+ }
@@ -0,0 +1,280 @@
1
+ import { Hono } from "hono";
2
+ import { z } from "zod";
3
+ import type { AuthEnv } from "../../auth/index.js";
4
+ import type { ICreditLedger } from "../../credits/index.js";
5
+ import { Credit, InsufficientBalanceError } from "../../credits/index.js";
6
+ import type { AdminAuditLogger } from "./admin-audit-helper.js";
7
+ import { safeAuditLog } from "./admin-audit-helper.js";
8
+
9
+ const tenantIdSchema = z
10
+ .string()
11
+ .min(1)
12
+ .max(128)
13
+ .regex(/^[a-zA-Z0-9_-]+$/);
14
+
15
+ const TENANT_ID_ERROR = "tenantId must be 1-128 alphanumeric characters, hyphens, or underscores";
16
+
17
+ function parseTenantId(c: { req: { param: (k: string) => string } }): { ok: true; tenant: string } | { ok: false } {
18
+ const result = tenantIdSchema.safeParse(c.req.param("tenantId"));
19
+ if (!result.success) return { ok: false };
20
+ return { ok: true, tenant: result.data };
21
+ }
22
+
23
+ function parseIntParam(value: string | undefined): number | undefined {
24
+ if (value == null || value === "") return undefined;
25
+ const n = Number.parseInt(value, 10);
26
+ return Number.isFinite(n) ? n : undefined;
27
+ }
28
+
29
+ /**
30
+ * Create admin credit API routes.
31
+ * Pass a ledger directly or a factory for lazy init.
32
+ */
33
+ export function createAdminCreditApiRoutes(
34
+ ledgerOrFactory: ICreditLedger | (() => ICreditLedger),
35
+ auditLogger?: () => AdminAuditLogger,
36
+ ): Hono<AuthEnv> {
37
+ const ledgerFactory = typeof ledgerOrFactory === "function" ? ledgerOrFactory : () => ledgerOrFactory;
38
+ const routes = new Hono<AuthEnv>();
39
+
40
+ routes.post("/:tenantId/grant", async (c) => {
41
+ const ledger = ledgerFactory();
42
+ const parsed = parseTenantId(c);
43
+ if (!parsed.ok) return c.json({ error: TENANT_ID_ERROR }, 400);
44
+ const tenant = parsed.tenant;
45
+
46
+ let body: Record<string, unknown>;
47
+ try {
48
+ body = (await c.req.json()) as Record<string, unknown>;
49
+ } catch {
50
+ return c.json({ error: "Invalid JSON body" }, 400);
51
+ }
52
+
53
+ const amountCents = body.amount_cents;
54
+ const reason = body.reason;
55
+
56
+ if (typeof amountCents !== "number" || !Number.isInteger(amountCents) || amountCents <= 0) {
57
+ return c.json({ error: "amount_cents must be a positive integer" }, 400);
58
+ }
59
+
60
+ if (typeof reason !== "string" || !reason.trim()) {
61
+ return c.json({ error: "reason is required and must be non-empty" }, 400);
62
+ }
63
+
64
+ try {
65
+ const user = c.get("user");
66
+ const adminUser = user?.id ?? "unknown";
67
+ let result: Awaited<ReturnType<typeof ledger.credit>>;
68
+ try {
69
+ result = await ledger.credit(
70
+ tenant,
71
+ Credit.fromCents(amountCents),
72
+ "admin_grant",
73
+ reason,
74
+ undefined,
75
+ undefined,
76
+ adminUser,
77
+ );
78
+ } catch (err) {
79
+ safeAuditLog(auditLogger, {
80
+ adminUser,
81
+ action: "credits.grant",
82
+ category: "credits",
83
+ targetTenant: tenant,
84
+ details: { amount_cents: amountCents, reason, error: String(err) },
85
+ outcome: "failure",
86
+ });
87
+ throw err;
88
+ }
89
+ safeAuditLog(auditLogger, {
90
+ adminUser,
91
+ action: "credits.grant",
92
+ category: "credits",
93
+ targetTenant: tenant,
94
+ details: { amount_cents: amountCents, reason },
95
+ outcome: "success",
96
+ });
97
+ return c.json(result, 201);
98
+ } catch (err) {
99
+ return c.json({ error: err instanceof Error ? err.message : "Internal server error" }, 500);
100
+ }
101
+ });
102
+
103
+ routes.post("/:tenantId/refund", async (c) => {
104
+ const ledger = ledgerFactory();
105
+ const parsed = parseTenantId(c);
106
+ if (!parsed.ok) return c.json({ error: TENANT_ID_ERROR }, 400);
107
+ const tenant = parsed.tenant;
108
+
109
+ let body: Record<string, unknown>;
110
+ try {
111
+ body = (await c.req.json()) as Record<string, unknown>;
112
+ } catch {
113
+ return c.json({ error: "Invalid JSON body" }, 400);
114
+ }
115
+
116
+ const amountCents = body.amount_cents;
117
+ const reason = body.reason;
118
+
119
+ if (typeof amountCents !== "number" || !Number.isInteger(amountCents) || amountCents <= 0) {
120
+ return c.json({ error: "amount_cents must be a positive integer" }, 400);
121
+ }
122
+
123
+ if (typeof reason !== "string" || !reason.trim()) {
124
+ return c.json({ error: "reason is required and must be non-empty" }, 400);
125
+ }
126
+
127
+ try {
128
+ const user = c.get("user");
129
+ const adminUser = user?.id ?? "unknown";
130
+ let result: Awaited<ReturnType<typeof ledger.credit>>;
131
+ try {
132
+ result = await ledger.credit(tenant, Credit.fromCents(amountCents), "admin_grant", reason);
133
+ } catch (err) {
134
+ safeAuditLog(auditLogger, {
135
+ adminUser,
136
+ action: "credits.refund",
137
+ category: "credits",
138
+ targetTenant: tenant,
139
+ details: { amount_cents: amountCents, reason, error: String(err) },
140
+ outcome: "failure",
141
+ });
142
+ throw err;
143
+ }
144
+ safeAuditLog(auditLogger, {
145
+ adminUser,
146
+ action: "credits.refund",
147
+ category: "credits",
148
+ targetTenant: tenant,
149
+ details: { amount_cents: amountCents, reason },
150
+ outcome: "success",
151
+ });
152
+ return c.json(result, 201);
153
+ } catch (err) {
154
+ if (err instanceof InsufficientBalanceError) {
155
+ return c.json({ error: err.message, current_balance: err.currentBalance }, 400);
156
+ }
157
+ return c.json({ error: err instanceof Error ? err.message : "Internal server error" }, 500);
158
+ }
159
+ });
160
+
161
+ routes.post("/:tenantId/correction", async (c) => {
162
+ const ledger = ledgerFactory();
163
+ const parsed = parseTenantId(c);
164
+ if (!parsed.ok) return c.json({ error: TENANT_ID_ERROR }, 400);
165
+ const tenant = parsed.tenant;
166
+
167
+ let body: Record<string, unknown>;
168
+ try {
169
+ body = (await c.req.json()) as Record<string, unknown>;
170
+ } catch {
171
+ return c.json({ error: "Invalid JSON body" }, 400);
172
+ }
173
+
174
+ const amountCents = body.amount_cents;
175
+ const reason = body.reason;
176
+
177
+ if (typeof amountCents !== "number" || !Number.isInteger(amountCents) || amountCents === 0) {
178
+ return c.json({ error: "amount_cents must be a non-zero integer" }, 400);
179
+ }
180
+
181
+ if (typeof reason !== "string" || !reason.trim()) {
182
+ return c.json({ error: "reason is required and must be non-empty" }, 400);
183
+ }
184
+
185
+ try {
186
+ const user = c.get("user");
187
+ const adminUser = user?.id ?? "unknown";
188
+ let result: Awaited<ReturnType<typeof ledger.credit>>;
189
+ try {
190
+ if (amountCents >= 0) {
191
+ result = await ledger.credit(tenant, Credit.fromCents(amountCents), "promo", reason);
192
+ } else {
193
+ result = await ledger.debit(tenant, Credit.fromCents(Math.abs(amountCents)), "correction", reason);
194
+ }
195
+ } catch (err) {
196
+ safeAuditLog(auditLogger, {
197
+ adminUser,
198
+ action: "credits.correction",
199
+ category: "credits",
200
+ targetTenant: tenant,
201
+ details: { amount_cents: amountCents, reason, error: String(err) },
202
+ outcome: "failure",
203
+ });
204
+ throw err;
205
+ }
206
+ safeAuditLog(auditLogger, {
207
+ adminUser,
208
+ action: "credits.correction",
209
+ category: "credits",
210
+ targetTenant: tenant,
211
+ details: { amount_cents: amountCents, reason },
212
+ outcome: "success",
213
+ });
214
+ return c.json(result, 201);
215
+ } catch (err) {
216
+ if (err instanceof InsufficientBalanceError) {
217
+ return c.json({ error: err.message, current_balance: err.currentBalance }, 400);
218
+ }
219
+ return c.json({ error: err instanceof Error ? err.message : "Internal server error" }, 500);
220
+ }
221
+ });
222
+
223
+ routes.get("/:tenantId/balance", async (c) => {
224
+ const ledger = ledgerFactory();
225
+ const parsed = parseTenantId(c);
226
+ if (!parsed.ok) return c.json({ error: TENANT_ID_ERROR }, 400);
227
+ const tenant = parsed.tenant;
228
+
229
+ try {
230
+ const balance = await ledger.balance(tenant);
231
+ return c.json({ tenant, balance_credits: balance });
232
+ } catch {
233
+ return c.json({ error: "Internal server error" }, 500);
234
+ }
235
+ });
236
+
237
+ routes.get("/:tenantId/transactions", async (c) => {
238
+ const ledger = ledgerFactory();
239
+ const parsed = parseTenantId(c);
240
+ if (!parsed.ok) return c.json({ error: TENANT_ID_ERROR }, 400);
241
+ const tenant = parsed.tenant;
242
+ const typeParam = c.req.query("type");
243
+
244
+ const filters = {
245
+ type: typeParam,
246
+ limit: parseIntParam(c.req.query("limit")),
247
+ offset: parseIntParam(c.req.query("offset")),
248
+ };
249
+
250
+ try {
251
+ const entries = await ledger.history(tenant, filters);
252
+ return c.json({ entries, total: entries.length });
253
+ } catch {
254
+ return c.json({ error: "Internal server error" }, 500);
255
+ }
256
+ });
257
+
258
+ routes.get("/:tenantId/adjustments", async (c) => {
259
+ const ledger = ledgerFactory();
260
+ const parsed = parseTenantId(c);
261
+ if (!parsed.ok) return c.json({ error: TENANT_ID_ERROR }, 400);
262
+ const tenant = parsed.tenant;
263
+ const typeParam = c.req.query("type");
264
+
265
+ const filters = {
266
+ type: typeParam,
267
+ limit: parseIntParam(c.req.query("limit")),
268
+ offset: parseIntParam(c.req.query("offset")),
269
+ };
270
+
271
+ try {
272
+ const entries = await ledger.history(tenant, filters);
273
+ return c.json({ entries, total: entries.length });
274
+ } catch {
275
+ return c.json({ error: "Internal server error" }, 500);
276
+ }
277
+ });
278
+
279
+ return routes;
280
+ }
@@ -0,0 +1,202 @@
1
+ import { Hono } from "hono";
2
+ import { z } from "zod";
3
+ import type { AuthEnv } from "../../auth/index.js";
4
+ import type { AdminAuditLogger } from "./admin-audit-helper.js";
5
+ import { safeAuditLog } from "./admin-audit-helper.js";
6
+
7
+ /** Minimal interface for GPU node repository. */
8
+ export interface IGpuNodeRepository {
9
+ list(): Promise<unknown[]>;
10
+ getById(nodeId: string): Promise<{ dropletId?: string | number | null; status?: string } | null>;
11
+ }
12
+
13
+ /** Minimal interface for GPU node provisioner. */
14
+ export interface IGpuNodeProvisioner {
15
+ provision(opts: { region?: string; size?: string; name?: string }): Promise<{
16
+ nodeId: string;
17
+ dropletId?: string | number | null;
18
+ region?: string;
19
+ size?: string;
20
+ monthlyCostCents?: number;
21
+ }>;
22
+ destroy(nodeId: string): Promise<void>;
23
+ }
24
+
25
+ /** Minimal interface for DO API client. */
26
+ export interface IDOClient {
27
+ listRegions(): Promise<unknown[]>;
28
+ listSizes(): Promise<unknown[]>;
29
+ rebootDroplet(dropletId: number): Promise<void>;
30
+ }
31
+
32
+ export interface AdminGpuDeps {
33
+ gpuNodeRepo: () => IGpuNodeRepository;
34
+ gpuNodeProvisioner: () => IGpuNodeProvisioner;
35
+ doClient: () => IDOClient;
36
+ auditLogger?: () => AdminAuditLogger;
37
+ logger?: { error(msg: string, meta?: Record<string, unknown>): void };
38
+ }
39
+
40
+ /**
41
+ * Create admin GPU node management routes.
42
+ * Static routes (/regions, /sizes) are registered BEFORE parameterized routes (/:nodeId).
43
+ */
44
+ export function createAdminGpuRoutes(deps: AdminGpuDeps): Hono<AuthEnv> {
45
+ const routes = new Hono<AuthEnv>();
46
+
47
+ routes.get("/", async (c) => {
48
+ const nodes = await deps.gpuNodeRepo().list();
49
+ return c.json({ success: true, nodes, count: nodes.length });
50
+ });
51
+
52
+ routes.post("/", async (c) => {
53
+ try {
54
+ const body = await c.req.json();
55
+ const parsed = z
56
+ .object({
57
+ region: z.string().min(1).max(20).optional(),
58
+ size: z.string().min(1).max(50).optional(),
59
+ name: z
60
+ .string()
61
+ .min(1)
62
+ .max(63)
63
+ .regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/)
64
+ .optional(),
65
+ })
66
+ .parse(body);
67
+
68
+ const provisioner = deps.gpuNodeProvisioner();
69
+ const result = await provisioner.provision(parsed);
70
+
71
+ safeAuditLog(deps.auditLogger, {
72
+ adminUser: (c.get("user") as { id?: string } | undefined)?.id ?? "unknown",
73
+ action: "gpu.provision",
74
+ category: "config",
75
+ details: {
76
+ nodeId: result.nodeId,
77
+ dropletId: result.dropletId,
78
+ region: result.region,
79
+ size: result.size,
80
+ monthlyCostCents: result.monthlyCostCents,
81
+ },
82
+ });
83
+
84
+ return c.json({ success: true, node: result }, 201);
85
+ } catch (err) {
86
+ if (err instanceof z.ZodError) {
87
+ return c.json({ success: false, error: err.issues }, 400);
88
+ }
89
+ if (err instanceof Error && err.message.includes("DO_API_TOKEN")) {
90
+ return c.json(
91
+ { success: false, error: "GPU provisioning not configured. Set DO_API_TOKEN environment variable." },
92
+ 503,
93
+ );
94
+ }
95
+ deps.logger?.error("GPU node provisioning failed", { err });
96
+ return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
97
+ }
98
+ });
99
+
100
+ routes.get("/regions", async (c) => {
101
+ try {
102
+ const regions = await deps.doClient().listRegions();
103
+ return c.json({ success: true, regions });
104
+ } catch (err) {
105
+ if (err instanceof Error && err.message.includes("DO_API_TOKEN")) {
106
+ return c.json(
107
+ { success: false, error: "GPU provisioning not configured. Set DO_API_TOKEN environment variable." },
108
+ 503,
109
+ );
110
+ }
111
+ return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
112
+ }
113
+ });
114
+
115
+ routes.get("/sizes", async (c) => {
116
+ try {
117
+ const sizes = await deps.doClient().listSizes();
118
+ return c.json({ success: true, sizes });
119
+ } catch (err) {
120
+ if (err instanceof Error && err.message.includes("DO_API_TOKEN")) {
121
+ return c.json(
122
+ { success: false, error: "GPU provisioning not configured. Set DO_API_TOKEN environment variable." },
123
+ 503,
124
+ );
125
+ }
126
+ return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
127
+ }
128
+ });
129
+
130
+ routes.get("/:nodeId", async (c) => {
131
+ const nodeId = c.req.param("nodeId") as string;
132
+ const node = await deps.gpuNodeRepo().getById(nodeId);
133
+ if (!node) {
134
+ return c.json({ success: false, error: "GPU node not found" }, 404);
135
+ }
136
+ return c.json({ success: true, node });
137
+ });
138
+
139
+ routes.delete("/:nodeId", async (c) => {
140
+ const nodeId = c.req.param("nodeId") as string;
141
+
142
+ try {
143
+ const node = await deps.gpuNodeRepo().getById(nodeId);
144
+ if (!node) {
145
+ return c.json({ success: false, error: "GPU node not found" }, 404);
146
+ }
147
+ if (node.status === "provisioning" || node.status === "bootstrapping") {
148
+ return c.json(
149
+ {
150
+ success: false,
151
+ error: `Cannot destroy GPU node in ${node.status} state — wait until provisioning/bootstrapping completes`,
152
+ },
153
+ 409,
154
+ );
155
+ }
156
+
157
+ await deps.gpuNodeProvisioner().destroy(nodeId);
158
+
159
+ safeAuditLog(deps.auditLogger, {
160
+ adminUser: (c.get("user") as { id?: string } | undefined)?.id ?? "unknown",
161
+ action: "gpu.destroy",
162
+ category: "config",
163
+ details: { nodeId },
164
+ });
165
+
166
+ return c.json({ success: true });
167
+ } catch (err) {
168
+ deps.logger?.error("GPU node destruction failed", { nodeId, err });
169
+ return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
170
+ }
171
+ });
172
+
173
+ routes.post("/:nodeId/reboot", async (c) => {
174
+ const nodeId = c.req.param("nodeId") as string;
175
+
176
+ try {
177
+ const node = await deps.gpuNodeRepo().getById(nodeId);
178
+ if (!node) {
179
+ return c.json({ success: false, error: "GPU node not found" }, 404);
180
+ }
181
+ if (!node.dropletId) {
182
+ return c.json({ success: false, error: "GPU node has no droplet assigned" }, 400);
183
+ }
184
+
185
+ await deps.doClient().rebootDroplet(Number(node.dropletId));
186
+
187
+ safeAuditLog(deps.auditLogger, {
188
+ adminUser: (c.get("user") as { id?: string } | undefined)?.id ?? "unknown",
189
+ action: "gpu.reboot",
190
+ category: "config",
191
+ details: { nodeId, dropletId: node.dropletId },
192
+ });
193
+
194
+ return c.json({ success: true, message: `Reboot initiated for GPU node ${nodeId}` });
195
+ } catch (err) {
196
+ deps.logger?.error("GPU node reboot failed", { nodeId, err });
197
+ return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
198
+ }
199
+ });
200
+
201
+ return routes;
202
+ }