@wopr-network/platform-core 1.13.0 → 1.13.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.
- package/dist/api/routes/activity.d.ts +9 -0
- package/dist/api/routes/activity.js +68 -0
- package/dist/api/routes/admin-audit-helper.d.ts +7 -0
- package/dist/api/routes/admin-audit-helper.js +13 -0
- package/dist/api/routes/admin-audit.d.ts +13 -0
- package/dist/api/routes/admin-audit.js +61 -0
- package/dist/api/routes/admin-backups.d.ts +19 -0
- package/dist/api/routes/admin-backups.js +116 -0
- package/dist/api/routes/admin-compliance.d.ts +9 -0
- package/dist/api/routes/admin-compliance.js +27 -0
- package/dist/api/routes/admin-credits.d.ts +9 -0
- package/dist/api/routes/admin-credits.js +255 -0
- package/dist/api/routes/admin-gpu.d.ts +46 -0
- package/dist/api/routes/admin-gpu.js +140 -0
- package/dist/api/routes/admin-inference.d.ts +16 -0
- package/dist/api/routes/admin-inference.js +98 -0
- package/dist/api/routes/admin-marketplace.d.ts +36 -0
- package/dist/api/routes/admin-marketplace.js +181 -0
- package/dist/api/routes/admin-migration.d.ts +10 -0
- package/dist/api/routes/admin-migration.js +46 -0
- package/dist/api/routes/admin-notes.d.ts +34 -0
- package/dist/api/routes/admin-notes.js +131 -0
- package/dist/api/routes/admin-onboarding.d.ts +7 -0
- package/dist/api/routes/admin-onboarding.js +49 -0
- package/dist/api/routes/admin-rates.d.ts +9 -0
- package/dist/api/routes/admin-rates.js +427 -0
- package/dist/api/routes/admin-recovery.d.ts +91 -0
- package/dist/api/routes/admin-recovery.js +246 -0
- package/dist/api/routes/admin-roles.d.ts +27 -0
- package/dist/api/routes/admin-roles.js +157 -0
- package/dist/api/routes/audit.d.ts +19 -0
- package/dist/api/routes/audit.js +95 -0
- package/dist/api/routes/auth.d.ts +19 -0
- package/dist/api/routes/auth.js +25 -0
- package/dist/api/routes/channel-validate.d.ts +11 -0
- package/dist/api/routes/channel-validate.js +148 -0
- package/dist/api/routes/fleet-events.d.ts +4 -0
- package/dist/api/routes/fleet-events.js +53 -0
- package/dist/api/routes/friends-proxy.d.ts +28 -0
- package/dist/api/routes/friends-proxy.js +63 -0
- package/dist/api/routes/friends-types.d.ts +34 -0
- package/dist/api/routes/friends-types.js +28 -0
- package/dist/api/routes/health.d.ts +14 -0
- package/dist/api/routes/health.js +32 -0
- package/dist/api/routes/health.test.d.ts +1 -0
- package/dist/api/routes/health.test.js +70 -0
- package/dist/api/routes/incident-response.d.ts +9 -0
- package/dist/api/routes/incident-response.js +148 -0
- package/dist/api/routes/internal-gpu.d.ts +12 -0
- package/dist/api/routes/internal-gpu.js +70 -0
- package/dist/api/routes/internal-nodes.d.ts +41 -0
- package/dist/api/routes/internal-nodes.js +105 -0
- package/dist/api/routes/login-history.d.ts +11 -0
- package/dist/api/routes/login-history.js +22 -0
- package/dist/api/routes/public-pricing.d.ts +9 -0
- package/dist/api/routes/public-pricing.js +32 -0
- package/dist/api/routes/quota.d.ts +8 -0
- package/dist/api/routes/quota.js +113 -0
- package/dist/api/routes/secret-audit.d.ts +12 -0
- package/dist/api/routes/secret-audit.js +41 -0
- package/dist/api/routes/secrets.d.ts +31 -0
- package/dist/api/routes/secrets.js +135 -0
- package/dist/api/routes/tenant-keys.d.ts +16 -0
- package/dist/api/routes/tenant-keys.js +142 -0
- package/dist/api/routes/verify-email.d.ts +19 -0
- package/dist/api/routes/verify-email.js +70 -0
- package/dist/api/routes/ws-auth.d.ts +21 -0
- package/dist/api/routes/ws-auth.js +24 -0
- package/package.json +35 -1
- package/src/api/routes/activity.ts +77 -0
- package/src/api/routes/admin-audit-helper.ts +18 -0
- package/src/api/routes/admin-audit.ts +67 -0
- package/src/api/routes/admin-backups.ts +134 -0
- package/src/api/routes/admin-compliance.ts +35 -0
- package/src/api/routes/admin-credits.ts +280 -0
- package/src/api/routes/admin-gpu.ts +202 -0
- package/src/api/routes/admin-inference.ts +109 -0
- package/src/api/routes/admin-marketplace.ts +233 -0
- package/src/api/routes/admin-migration.ts +61 -0
- package/src/api/routes/admin-notes.ts +145 -0
- package/src/api/routes/admin-onboarding.ts +62 -0
- package/src/api/routes/admin-rates.ts +462 -0
- package/src/api/routes/admin-recovery.ts +376 -0
- package/src/api/routes/admin-roles.ts +205 -0
- package/src/api/routes/audit.ts +106 -0
- package/src/api/routes/auth.ts +30 -0
- package/src/api/routes/channel-validate.ts +182 -0
- package/src/api/routes/fleet-events.ts +66 -0
- package/src/api/routes/friends-proxy.ts +94 -0
- package/src/api/routes/friends-types.ts +37 -0
- package/src/api/routes/health.test.ts +80 -0
- package/src/api/routes/health.ts +48 -0
- package/src/api/routes/incident-response.ts +159 -0
- package/src/api/routes/internal-gpu.ts +92 -0
- package/src/api/routes/internal-nodes.ts +157 -0
- package/src/api/routes/login-history.ts +28 -0
- package/src/api/routes/public-pricing.ts +36 -0
- package/src/api/routes/quota.ts +136 -0
- package/src/api/routes/secret-audit.ts +55 -0
- package/src/api/routes/secrets.ts +178 -0
- package/src/api/routes/tenant-keys.ts +178 -0
- package/src/api/routes/verify-email.ts +102 -0
- package/src/api/routes/ws-auth.ts +44 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
4
|
+
import type { RecoveryEvent } from "../../fleet/repository-types.js";
|
|
5
|
+
import type { AdminAuditLogger } from "./admin-audit-helper.js";
|
|
6
|
+
import { safeAuditLog } from "./admin-audit-helper.js";
|
|
7
|
+
|
|
8
|
+
// ── Minimal interfaces for injectable deps ──
|
|
9
|
+
|
|
10
|
+
export interface IRecoveryRepository {
|
|
11
|
+
listEvents(limit: number, status?: RecoveryEvent["status"]): Promise<RecoveryEvent[]>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IRecoveryOrchestrator {
|
|
15
|
+
getEventDetails(eventId: string): Promise<{ event: RecoveryEvent | undefined; items: unknown[] }>;
|
|
16
|
+
retryWaiting(eventId: string): Promise<unknown>;
|
|
17
|
+
triggerRecovery(nodeId: string, trigger: string): Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface INodeRepository {
|
|
21
|
+
list(): Promise<unknown[]>;
|
|
22
|
+
getById(nodeId: string): Promise<unknown | null>;
|
|
23
|
+
transition(nodeId: string, targetStatus: string, reason: string, actor: string): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface INodeProvisioner {
|
|
27
|
+
provision(opts: { region?: string; size?: string; name?: string }): Promise<{
|
|
28
|
+
nodeId: string;
|
|
29
|
+
externalId?: string;
|
|
30
|
+
region?: string;
|
|
31
|
+
size?: string;
|
|
32
|
+
monthlyCostCents?: number;
|
|
33
|
+
}>;
|
|
34
|
+
destroy(nodeId: string): Promise<void>;
|
|
35
|
+
listRegions(): Promise<unknown[]>;
|
|
36
|
+
listSizes(): Promise<unknown[]>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface INodeDrainer {
|
|
40
|
+
drain(nodeId: string): Promise<{ migrated: unknown[]; failed: unknown[] }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface IBotInstanceRepository {
|
|
44
|
+
listByNode(nodeId: string): Promise<unknown[]>;
|
|
45
|
+
getById(botId: string): Promise<{ nodeId?: string | null; tenantId?: string | null } | null>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ICommandBus {
|
|
49
|
+
send(nodeId: string, command: { type: string; payload: Record<string, unknown> }): Promise<{ data?: unknown }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface IMigrationOrchestrator {
|
|
53
|
+
migrate(
|
|
54
|
+
botId: string,
|
|
55
|
+
targetNodeId?: string,
|
|
56
|
+
): Promise<{
|
|
57
|
+
success: boolean;
|
|
58
|
+
error?: string;
|
|
59
|
+
sourceNodeId?: string;
|
|
60
|
+
targetNodeId?: string;
|
|
61
|
+
downtimeMs?: number;
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type CapacityAlertChecker = (nodes: unknown[]) => unknown[];
|
|
66
|
+
|
|
67
|
+
// ── Recovery routes ──
|
|
68
|
+
|
|
69
|
+
export interface AdminRecoveryDeps {
|
|
70
|
+
recoveryRepo: () => IRecoveryRepository;
|
|
71
|
+
recoveryOrchestrator: () => IRecoveryOrchestrator;
|
|
72
|
+
auditLogger?: () => AdminAuditLogger;
|
|
73
|
+
logger?: { error(msg: string, meta?: Record<string, unknown>): void };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createAdminRecoveryRoutes(deps: AdminRecoveryDeps): Hono<AuthEnv> {
|
|
77
|
+
const routes = new Hono<AuthEnv>();
|
|
78
|
+
|
|
79
|
+
routes.get("/", async (c) => {
|
|
80
|
+
const rawLimit = Number.parseInt(c.req.query("limit") ?? "50", 10);
|
|
81
|
+
const limit = Number.isNaN(rawLimit) || rawLimit < 1 ? 50 : Math.min(rawLimit, 500);
|
|
82
|
+
|
|
83
|
+
const rawStatus = c.req.query("status");
|
|
84
|
+
const validStatuses: RecoveryEvent["status"][] = ["in_progress", "partial", "completed"];
|
|
85
|
+
const statusFilter =
|
|
86
|
+
rawStatus && (validStatuses as string[]).includes(rawStatus) ? (rawStatus as RecoveryEvent["status"]) : undefined;
|
|
87
|
+
const events = await deps.recoveryRepo().listEvents(limit, statusFilter);
|
|
88
|
+
|
|
89
|
+
return c.json({ success: true, events, count: events.length });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
routes.get("/:eventId", async (c) => {
|
|
93
|
+
const eventId = c.req.param("eventId") as string;
|
|
94
|
+
const { event, items } = await deps.recoveryOrchestrator().getEventDetails(eventId);
|
|
95
|
+
|
|
96
|
+
if (!event) {
|
|
97
|
+
return c.json({ success: false, error: "Recovery event not found" }, 404);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return c.json({ success: true, event, items });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
routes.post("/:eventId/retry", async (c) => {
|
|
104
|
+
const eventId = c.req.param("eventId") as string;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const report = await deps.recoveryOrchestrator().retryWaiting(eventId);
|
|
108
|
+
return c.json({ success: true, report });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
deps.logger?.error("Failed to retry waiting tenants", { eventId, err });
|
|
111
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return routes;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Node management routes ──
|
|
119
|
+
|
|
120
|
+
export interface AdminNodeDeps {
|
|
121
|
+
nodeRepo: () => INodeRepository;
|
|
122
|
+
nodeProvisioner: () => INodeProvisioner;
|
|
123
|
+
nodeDrainer: () => INodeDrainer;
|
|
124
|
+
botInstanceRepo: () => IBotInstanceRepository;
|
|
125
|
+
recoveryOrchestrator: () => IRecoveryOrchestrator;
|
|
126
|
+
migrationOrchestrator: () => IMigrationOrchestrator;
|
|
127
|
+
commandBus: () => ICommandBus;
|
|
128
|
+
capacityAlertChecker: CapacityAlertChecker;
|
|
129
|
+
auditLogger?: () => AdminAuditLogger;
|
|
130
|
+
logger?: { error(msg: string, meta?: Record<string, unknown>): void };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createAdminNodeRoutes(deps: AdminNodeDeps): Hono<AuthEnv> {
|
|
134
|
+
const routes = new Hono<AuthEnv>();
|
|
135
|
+
|
|
136
|
+
routes.get("/", async (c) => {
|
|
137
|
+
const nodes = await deps.nodeRepo().list();
|
|
138
|
+
const alerts = deps.capacityAlertChecker(nodes);
|
|
139
|
+
return c.json({ success: true, nodes, count: nodes.length, alerts });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
routes.post("/", async (c) => {
|
|
143
|
+
try {
|
|
144
|
+
const body = await c.req.json();
|
|
145
|
+
const parsed = z
|
|
146
|
+
.object({
|
|
147
|
+
region: z.string().min(1).max(20).optional(),
|
|
148
|
+
size: z.string().min(1).max(50).optional(),
|
|
149
|
+
name: z
|
|
150
|
+
.string()
|
|
151
|
+
.min(1)
|
|
152
|
+
.max(63)
|
|
153
|
+
.regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/)
|
|
154
|
+
.optional(),
|
|
155
|
+
})
|
|
156
|
+
.parse(body);
|
|
157
|
+
|
|
158
|
+
const result = await deps.nodeProvisioner().provision(parsed);
|
|
159
|
+
|
|
160
|
+
safeAuditLog(deps.auditLogger, {
|
|
161
|
+
adminUser: (c.get("user") as { id?: string } | undefined)?.id ?? "unknown",
|
|
162
|
+
action: "node.provision",
|
|
163
|
+
category: "config",
|
|
164
|
+
details: {
|
|
165
|
+
nodeId: result.nodeId,
|
|
166
|
+
externalId: result.externalId,
|
|
167
|
+
region: result.region,
|
|
168
|
+
size: result.size,
|
|
169
|
+
monthlyCostCents: result.monthlyCostCents,
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return c.json({ success: true, node: result }, 201);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err instanceof Error && err.message.includes("DO_API_TOKEN")) {
|
|
176
|
+
return c.json(
|
|
177
|
+
{ success: false, error: "Node provisioning not configured. Set DO_API_TOKEN environment variable." },
|
|
178
|
+
503,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
deps.logger?.error("Node provisioning failed", { err });
|
|
182
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
routes.post("/migrate", async (c) => {
|
|
187
|
+
try {
|
|
188
|
+
const body = await c.req.json();
|
|
189
|
+
const parsed = z
|
|
190
|
+
.object({
|
|
191
|
+
botId: z.string().min(1),
|
|
192
|
+
targetNodeId: z.string().min(1),
|
|
193
|
+
})
|
|
194
|
+
.parse(body);
|
|
195
|
+
|
|
196
|
+
const bot = await deps.botInstanceRepo().getById(parsed.botId);
|
|
197
|
+
if (!bot) {
|
|
198
|
+
return c.json({ success: false, error: "Bot not found" }, 404);
|
|
199
|
+
}
|
|
200
|
+
if (!bot.nodeId) {
|
|
201
|
+
return c.json({ success: false, error: "Bot has no node assignment" }, 400);
|
|
202
|
+
}
|
|
203
|
+
if (bot.nodeId === parsed.targetNodeId) {
|
|
204
|
+
return c.json({ success: false, error: "Source and target nodes are the same" }, 400);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const result = await deps.migrationOrchestrator().migrate(parsed.botId, parsed.targetNodeId);
|
|
208
|
+
|
|
209
|
+
if (!result.success) {
|
|
210
|
+
return c.json({ success: false, error: result.error }, 500);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
safeAuditLog(deps.auditLogger, {
|
|
214
|
+
adminUser: (c.get("user") as { id?: string } | undefined)?.id ?? "unknown",
|
|
215
|
+
action: "node.migrate",
|
|
216
|
+
category: "config",
|
|
217
|
+
details: {
|
|
218
|
+
botId: parsed.botId,
|
|
219
|
+
tenantId: bot.tenantId,
|
|
220
|
+
sourceNode: result.sourceNodeId,
|
|
221
|
+
targetNode: result.targetNodeId,
|
|
222
|
+
downtimeMs: result.downtimeMs,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return c.json({
|
|
227
|
+
success: true,
|
|
228
|
+
migration: {
|
|
229
|
+
botId: parsed.botId,
|
|
230
|
+
from: result.sourceNodeId,
|
|
231
|
+
to: result.targetNodeId,
|
|
232
|
+
downtimeMs: result.downtimeMs,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
} catch (err) {
|
|
236
|
+
deps.logger?.error("Manual migration failed", { err });
|
|
237
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
routes.get("/regions", async (c) => {
|
|
242
|
+
try {
|
|
243
|
+
const regions = await deps.nodeProvisioner().listRegions();
|
|
244
|
+
return c.json({ success: true, regions });
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (err instanceof Error && err.message.includes("DO_API_TOKEN")) {
|
|
247
|
+
return c.json(
|
|
248
|
+
{ success: false, error: "Node provisioning not configured. Set DO_API_TOKEN environment variable." },
|
|
249
|
+
503,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
routes.get("/sizes", async (c) => {
|
|
257
|
+
try {
|
|
258
|
+
const sizes = await deps.nodeProvisioner().listSizes();
|
|
259
|
+
return c.json({ success: true, sizes });
|
|
260
|
+
} catch (err) {
|
|
261
|
+
if (err instanceof Error && err.message.includes("DO_API_TOKEN")) {
|
|
262
|
+
return c.json(
|
|
263
|
+
{ success: false, error: "Node provisioning not configured. Set DO_API_TOKEN environment variable." },
|
|
264
|
+
503,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
routes.get("/:nodeId", async (c) => {
|
|
272
|
+
const nodeId = c.req.param("nodeId") as string;
|
|
273
|
+
const node = await deps.nodeRepo().getById(nodeId);
|
|
274
|
+
if (!node) {
|
|
275
|
+
return c.json({ success: false, error: "Node not found" }, 404);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const tenants = await deps.botInstanceRepo().listByNode(nodeId);
|
|
279
|
+
return c.json({ success: true, node, tenants, tenantCount: tenants.length });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
routes.delete("/:nodeId", async (c) => {
|
|
283
|
+
const nodeId = c.req.param("nodeId") as string;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const node = await deps.nodeRepo().getById(nodeId);
|
|
287
|
+
if (!node) {
|
|
288
|
+
return c.json({ success: false, error: "Node not found" }, 404);
|
|
289
|
+
}
|
|
290
|
+
await deps.nodeProvisioner().destroy(nodeId);
|
|
291
|
+
|
|
292
|
+
safeAuditLog(deps.auditLogger, {
|
|
293
|
+
adminUser: (c.get("user") as { id?: string } | undefined)?.id ?? "unknown",
|
|
294
|
+
action: "node.destroy",
|
|
295
|
+
category: "config",
|
|
296
|
+
details: { nodeId },
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return c.json({ success: true });
|
|
300
|
+
} catch (err) {
|
|
301
|
+
deps.logger?.error("Node destruction failed", { nodeId, err });
|
|
302
|
+
const status = err instanceof Error && err.message.includes("must be drained") ? 409 : 500;
|
|
303
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, status);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
routes.get("/:nodeId/tenants", async (c) => {
|
|
308
|
+
const nodeId = c.req.param("nodeId") as string;
|
|
309
|
+
const tenants = await deps.botInstanceRepo().listByNode(nodeId);
|
|
310
|
+
return c.json({ success: true, tenants, count: tenants.length });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
routes.get("/:nodeId/stats", async (c) => {
|
|
314
|
+
const nodeId = c.req.param("nodeId") as string;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const result = await deps.commandBus().send(nodeId, { type: "stats.get", payload: {} });
|
|
318
|
+
return c.json({ success: true, stats: result.data });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
routes.post("/:nodeId/drain", async (c) => {
|
|
325
|
+
const nodeId = c.req.param("nodeId") as string;
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const result = await deps.nodeDrainer().drain(nodeId);
|
|
329
|
+
|
|
330
|
+
safeAuditLog(deps.auditLogger, {
|
|
331
|
+
adminUser: (c.get("user") as { id?: string } | undefined)?.id ?? "unknown",
|
|
332
|
+
action: "node.drain",
|
|
333
|
+
category: "config",
|
|
334
|
+
details: { nodeId, migrated: result.migrated.length, failed: result.failed.length },
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return c.json({ success: result.failed.length === 0, result });
|
|
338
|
+
} catch (err) {
|
|
339
|
+
deps.logger?.error("Drain failed", { nodeId, err });
|
|
340
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
routes.post("/:nodeId/cancel-drain", async (c) => {
|
|
345
|
+
const nodeId = c.req.param("nodeId") as string;
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
await deps.nodeRepo().transition(nodeId, "active", "drain_cancelled", "admin");
|
|
349
|
+
|
|
350
|
+
safeAuditLog(deps.auditLogger, {
|
|
351
|
+
adminUser: (c.get("user") as { id?: string } | undefined)?.id ?? "unknown",
|
|
352
|
+
action: "node.cancelDrain",
|
|
353
|
+
category: "config",
|
|
354
|
+
details: { nodeId },
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
return c.json({ success: true, message: `Drain cancelled for node ${nodeId}` });
|
|
358
|
+
} catch (err) {
|
|
359
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
routes.post("/:nodeId/recover", async (c) => {
|
|
364
|
+
const nodeId = c.req.param("nodeId") as string;
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const report = await deps.recoveryOrchestrator().triggerRecovery(nodeId, "manual");
|
|
368
|
+
return c.json({ success: true, report });
|
|
369
|
+
} catch (err) {
|
|
370
|
+
deps.logger?.error("Manual recovery failed", { nodeId, err });
|
|
371
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
return routes;
|
|
376
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { Context, Next } from "hono";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { isValidRole, RoleStore } from "../../admin/role-store.js";
|
|
4
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
5
|
+
import type { AdminAuditLogger } from "./admin-audit-helper.js";
|
|
6
|
+
import { safeAuditLog } from "./admin-audit-helper.js";
|
|
7
|
+
|
|
8
|
+
// ── Role middleware (generic, no WOPR deps) ──
|
|
9
|
+
|
|
10
|
+
function resolveRoleStore(storeOrFactory: RoleStore | (() => RoleStore)): RoleStore {
|
|
11
|
+
return typeof storeOrFactory === "function" ? storeOrFactory() : storeOrFactory;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function requirePlatformAdmin(storeOrFactory: RoleStore | (() => RoleStore)) {
|
|
15
|
+
return async (c: Context<AuthEnv>, next: Next) => {
|
|
16
|
+
let user: { id: string; roles: string[] } | undefined;
|
|
17
|
+
try {
|
|
18
|
+
user = c.get("user");
|
|
19
|
+
} catch {
|
|
20
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!user) {
|
|
24
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!user.roles.includes("admin") && !(await resolveRoleStore(storeOrFactory).isPlatformAdmin(user.id))) {
|
|
28
|
+
return c.json({ error: "Platform admin access required" }, 403);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return next();
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function requireTenantAdmin(storeOrFactory: RoleStore | (() => RoleStore), tenantIdKey = "tenantId") {
|
|
36
|
+
return async (c: Context<AuthEnv>, next: Next) => {
|
|
37
|
+
let user: { id: string; roles: string[] } | undefined;
|
|
38
|
+
try {
|
|
39
|
+
user = c.get("user");
|
|
40
|
+
} catch {
|
|
41
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!user) {
|
|
45
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (user.roles.includes("admin") || (await resolveRoleStore(storeOrFactory).isPlatformAdmin(user.id))) {
|
|
49
|
+
return next();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tenantId = c.req.param(tenantIdKey);
|
|
53
|
+
if (!tenantId) {
|
|
54
|
+
return c.json({ error: "Tenant ID required" }, 400);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const role = await resolveRoleStore(storeOrFactory).getRole(user.id, tenantId);
|
|
58
|
+
if (role !== "tenant_admin") {
|
|
59
|
+
return c.json({ error: "Tenant admin access required" }, 403);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return next();
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Route factories ──
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create admin role management API routes.
|
|
70
|
+
* Takes a RoleStore factory and optional audit logger.
|
|
71
|
+
*/
|
|
72
|
+
export function createAdminRolesRoutes(
|
|
73
|
+
storeFactory: () => RoleStore,
|
|
74
|
+
auditLogger?: () => AdminAuditLogger,
|
|
75
|
+
): Hono<AuthEnv> {
|
|
76
|
+
const routes = new Hono<AuthEnv>();
|
|
77
|
+
|
|
78
|
+
routes.get("/:tenantId", requireTenantAdmin(storeFactory), async (c) => {
|
|
79
|
+
const tenantId = c.req.param("tenantId") as string;
|
|
80
|
+
const roles = await storeFactory().listByTenant(tenantId);
|
|
81
|
+
return c.json({ roles });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
routes.put("/:tenantId/:userId", requireTenantAdmin(storeFactory), async (c) => {
|
|
85
|
+
const tenantId = c.req.param("tenantId") as string;
|
|
86
|
+
const userId = c.req.param("userId") as string;
|
|
87
|
+
const body = await c.req.json<{ role: string }>().catch(() => null);
|
|
88
|
+
|
|
89
|
+
if (!body?.role || !isValidRole(body.role)) {
|
|
90
|
+
return c.json({ error: "Invalid role. Must be: platform_admin, tenant_admin, or user" }, 400);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (body.role === "platform_admin") {
|
|
94
|
+
return c.json({ error: "platform_admin can only be granted via platform admin routes" }, 400);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const currentUser = c.get("user");
|
|
98
|
+
|
|
99
|
+
await storeFactory().setRole(userId, tenantId, body.role, currentUser.id);
|
|
100
|
+
|
|
101
|
+
safeAuditLog(auditLogger, {
|
|
102
|
+
adminUser: currentUser.id ?? "unknown",
|
|
103
|
+
action: "role.set",
|
|
104
|
+
category: "roles",
|
|
105
|
+
targetTenant: tenantId,
|
|
106
|
+
targetUser: userId,
|
|
107
|
+
details: { role: body.role },
|
|
108
|
+
outcome: "success",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return c.json({ ok: true });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
routes.delete("/:tenantId/:userId", requireTenantAdmin(storeFactory), async (c) => {
|
|
115
|
+
const tenantId = c.req.param("tenantId") as string;
|
|
116
|
+
const userId = c.req.param("userId") as string;
|
|
117
|
+
|
|
118
|
+
const removed = await storeFactory().removeRole(userId, tenantId);
|
|
119
|
+
if (!removed) {
|
|
120
|
+
return c.json({ error: "Role not found" }, 404);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const currentUser = c.get("user");
|
|
124
|
+
safeAuditLog(auditLogger, {
|
|
125
|
+
adminUser: currentUser?.id ?? "unknown",
|
|
126
|
+
action: "role.remove",
|
|
127
|
+
category: "roles",
|
|
128
|
+
targetTenant: tenantId,
|
|
129
|
+
targetUser: userId,
|
|
130
|
+
details: {},
|
|
131
|
+
outcome: "success",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return c.json({ ok: true });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return routes;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create platform admin management routes.
|
|
142
|
+
* Takes a RoleStore factory and optional audit logger.
|
|
143
|
+
*/
|
|
144
|
+
export function createPlatformAdminRoutes(
|
|
145
|
+
storeFactory: () => RoleStore,
|
|
146
|
+
auditLogger?: () => AdminAuditLogger,
|
|
147
|
+
): Hono<AuthEnv> {
|
|
148
|
+
const routes = new Hono<AuthEnv>();
|
|
149
|
+
|
|
150
|
+
routes.use("*", requirePlatformAdmin(storeFactory));
|
|
151
|
+
|
|
152
|
+
routes.get("/", async (c) => {
|
|
153
|
+
const admins = await storeFactory().listPlatformAdmins();
|
|
154
|
+
return c.json({ admins });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
routes.post("/", async (c) => {
|
|
158
|
+
const body = await c.req.json<{ userId: string }>().catch(() => null);
|
|
159
|
+
|
|
160
|
+
if (!body?.userId) {
|
|
161
|
+
return c.json({ error: "userId is required" }, 400);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const currentUser = c.get("user");
|
|
165
|
+
await storeFactory().setRole(body.userId, RoleStore.PLATFORM_TENANT, "platform_admin", currentUser.id);
|
|
166
|
+
|
|
167
|
+
safeAuditLog(auditLogger, {
|
|
168
|
+
adminUser: currentUser.id ?? "unknown",
|
|
169
|
+
action: "platform_admin.add",
|
|
170
|
+
category: "roles",
|
|
171
|
+
targetUser: body.userId,
|
|
172
|
+
details: {},
|
|
173
|
+
outcome: "success",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return c.json({ ok: true });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
routes.delete("/:userId", async (c) => {
|
|
180
|
+
const userId = c.req.param("userId") as string;
|
|
181
|
+
|
|
182
|
+
if ((await storeFactory().countPlatformAdmins()) <= 1 && (await storeFactory().isPlatformAdmin(userId))) {
|
|
183
|
+
return c.json({ error: "Cannot remove the last platform admin" }, 409);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const removed = await storeFactory().removeRole(userId, RoleStore.PLATFORM_TENANT);
|
|
187
|
+
if (!removed) {
|
|
188
|
+
return c.json({ error: "Platform admin not found" }, 404);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const currentUser = c.get("user");
|
|
192
|
+
safeAuditLog(auditLogger, {
|
|
193
|
+
adminUser: currentUser?.id ?? "unknown",
|
|
194
|
+
action: "platform_admin.remove",
|
|
195
|
+
category: "roles",
|
|
196
|
+
targetUser: userId,
|
|
197
|
+
details: {},
|
|
198
|
+
outcome: "success",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return c.json({ ok: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return routes;
|
|
205
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { DrizzleAuditLogRepository, type IAuditLogRepository } from "../../audit/audit-log-repository.js";
|
|
4
|
+
import { countAuditLog, queryAuditLog } from "../../audit/query.js";
|
|
5
|
+
import { purgeExpiredEntriesForUser } from "../../audit/retention.js";
|
|
6
|
+
import type { AuditEnv } from "../../audit/types.js";
|
|
7
|
+
import type { DrizzleDb } from "../../db/index.js";
|
|
8
|
+
|
|
9
|
+
async function handleUserAudit(c: Context<AuditEnv>, repo: IAuditLogRepository) {
|
|
10
|
+
const user = c.get("user");
|
|
11
|
+
if (!user) return c.json({ error: "Unauthorized" }, 401);
|
|
12
|
+
|
|
13
|
+
void purgeExpiredEntriesForUser(repo, user.id).catch(() => {
|
|
14
|
+
/* purge is best-effort — must not break request */
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const sinceRaw = c.req.query("since") ? Number(c.req.query("since")) : undefined;
|
|
18
|
+
const untilRaw = c.req.query("until") ? Number(c.req.query("until")) : undefined;
|
|
19
|
+
const limitRaw = c.req.query("limit") ? Number(c.req.query("limit")) : undefined;
|
|
20
|
+
const offsetRaw = c.req.query("offset") ? Number(c.req.query("offset")) : undefined;
|
|
21
|
+
|
|
22
|
+
const filters = {
|
|
23
|
+
userId: user.id,
|
|
24
|
+
action: c.req.query("action") ?? undefined,
|
|
25
|
+
resourceType: c.req.query("resourceType") ?? undefined,
|
|
26
|
+
resourceId: c.req.query("resourceId") ?? undefined,
|
|
27
|
+
since: sinceRaw !== undefined && Number.isFinite(sinceRaw) ? sinceRaw : undefined,
|
|
28
|
+
until: untilRaw !== undefined && Number.isFinite(untilRaw) ? untilRaw : undefined,
|
|
29
|
+
limit: limitRaw !== undefined && Number.isFinite(limitRaw) ? limitRaw : undefined,
|
|
30
|
+
offset: offsetRaw !== undefined && Number.isFinite(offsetRaw) ? offsetRaw : undefined,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const [entries, total] = await Promise.all([queryAuditLog(repo, filters), countAuditLog(repo, filters)]);
|
|
35
|
+
return c.json({ entries, total });
|
|
36
|
+
} catch {
|
|
37
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function handleAdminAudit(c: Context<AuditEnv>, repo: IAuditLogRepository) {
|
|
42
|
+
const user = c.get("user");
|
|
43
|
+
if (!user?.isAdmin) return c.json({ error: "Forbidden" }, 403);
|
|
44
|
+
|
|
45
|
+
const sinceRaw = c.req.query("since") ? Number(c.req.query("since")) : undefined;
|
|
46
|
+
const untilRaw = c.req.query("until") ? Number(c.req.query("until")) : undefined;
|
|
47
|
+
const limitRaw = c.req.query("limit") ? Number(c.req.query("limit")) : undefined;
|
|
48
|
+
const offsetRaw = c.req.query("offset") ? Number(c.req.query("offset")) : undefined;
|
|
49
|
+
|
|
50
|
+
const filters = {
|
|
51
|
+
userId: c.req.query("userId") ?? undefined,
|
|
52
|
+
action: c.req.query("action") ?? undefined,
|
|
53
|
+
resourceType: c.req.query("resourceType") ?? undefined,
|
|
54
|
+
resourceId: c.req.query("resourceId") ?? undefined,
|
|
55
|
+
since: sinceRaw !== undefined && Number.isFinite(sinceRaw) ? sinceRaw : undefined,
|
|
56
|
+
until: untilRaw !== undefined && Number.isFinite(untilRaw) ? untilRaw : undefined,
|
|
57
|
+
limit: limitRaw !== undefined && Number.isFinite(limitRaw) ? limitRaw : undefined,
|
|
58
|
+
offset: offsetRaw !== undefined && Number.isFinite(offsetRaw) ? offsetRaw : undefined,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const [entries, total] = await Promise.all([queryAuditLog(repo, filters), countAuditLog(repo, filters)]);
|
|
63
|
+
return c.json({ entries, total });
|
|
64
|
+
} catch {
|
|
65
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type DbOrFactory = DrizzleDb | (() => DrizzleDb);
|
|
70
|
+
|
|
71
|
+
function resolveRepo(dbOrFactory: DbOrFactory): IAuditLogRepository {
|
|
72
|
+
const db = typeof dbOrFactory === "function" ? dbOrFactory() : dbOrFactory;
|
|
73
|
+
return new DrizzleAuditLogRepository(db);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create audit log API routes.
|
|
78
|
+
*
|
|
79
|
+
* Pass a `DrizzleDb` directly or a `() => DrizzleDb` factory for lazy init.
|
|
80
|
+
* Expects `c.get("user")` to provide `{ id: string }`.
|
|
81
|
+
*/
|
|
82
|
+
export function createAuditRoutes(db: DbOrFactory): Hono<AuditEnv> {
|
|
83
|
+
let repo: IAuditLogRepository | null = typeof db === "function" ? null : resolveRepo(db);
|
|
84
|
+
const routes = new Hono<AuditEnv>();
|
|
85
|
+
routes.get("/", (c) => {
|
|
86
|
+
if (!repo) repo = resolveRepo(db);
|
|
87
|
+
return handleUserAudit(c, repo);
|
|
88
|
+
});
|
|
89
|
+
return routes;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create admin audit log API routes.
|
|
94
|
+
*
|
|
95
|
+
* Pass a `DrizzleDb` directly or a `() => DrizzleDb` factory for lazy init.
|
|
96
|
+
* Expects `c.get("user")` to provide `{ id: string, isAdmin: boolean }`.
|
|
97
|
+
*/
|
|
98
|
+
export function createAdminAuditRoutes(db: DbOrFactory): Hono<AuditEnv> {
|
|
99
|
+
let repo: IAuditLogRepository | null = typeof db === "function" ? null : resolveRepo(db);
|
|
100
|
+
const routes = new Hono<AuditEnv>();
|
|
101
|
+
routes.get("/", (c) => {
|
|
102
|
+
if (!repo) repo = resolveRepo(db);
|
|
103
|
+
return handleAdminAudit(c, repo);
|
|
104
|
+
});
|
|
105
|
+
return routes;
|
|
106
|
+
}
|