@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.
- 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,91 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
3
|
+
import type { RecoveryEvent } from "../../fleet/repository-types.js";
|
|
4
|
+
import type { AdminAuditLogger } from "./admin-audit-helper.js";
|
|
5
|
+
export interface IRecoveryRepository {
|
|
6
|
+
listEvents(limit: number, status?: RecoveryEvent["status"]): Promise<RecoveryEvent[]>;
|
|
7
|
+
}
|
|
8
|
+
export interface IRecoveryOrchestrator {
|
|
9
|
+
getEventDetails(eventId: string): Promise<{
|
|
10
|
+
event: RecoveryEvent | undefined;
|
|
11
|
+
items: unknown[];
|
|
12
|
+
}>;
|
|
13
|
+
retryWaiting(eventId: string): Promise<unknown>;
|
|
14
|
+
triggerRecovery(nodeId: string, trigger: string): Promise<unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface INodeRepository {
|
|
17
|
+
list(): Promise<unknown[]>;
|
|
18
|
+
getById(nodeId: string): Promise<unknown | null>;
|
|
19
|
+
transition(nodeId: string, targetStatus: string, reason: string, actor: string): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
export interface INodeProvisioner {
|
|
22
|
+
provision(opts: {
|
|
23
|
+
region?: string;
|
|
24
|
+
size?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
nodeId: string;
|
|
28
|
+
externalId?: string;
|
|
29
|
+
region?: string;
|
|
30
|
+
size?: string;
|
|
31
|
+
monthlyCostCents?: number;
|
|
32
|
+
}>;
|
|
33
|
+
destroy(nodeId: string): Promise<void>;
|
|
34
|
+
listRegions(): Promise<unknown[]>;
|
|
35
|
+
listSizes(): Promise<unknown[]>;
|
|
36
|
+
}
|
|
37
|
+
export interface INodeDrainer {
|
|
38
|
+
drain(nodeId: string): Promise<{
|
|
39
|
+
migrated: unknown[];
|
|
40
|
+
failed: unknown[];
|
|
41
|
+
}>;
|
|
42
|
+
}
|
|
43
|
+
export interface IBotInstanceRepository {
|
|
44
|
+
listByNode(nodeId: string): Promise<unknown[]>;
|
|
45
|
+
getById(botId: string): Promise<{
|
|
46
|
+
nodeId?: string | null;
|
|
47
|
+
tenantId?: string | null;
|
|
48
|
+
} | null>;
|
|
49
|
+
}
|
|
50
|
+
export interface ICommandBus {
|
|
51
|
+
send(nodeId: string, command: {
|
|
52
|
+
type: string;
|
|
53
|
+
payload: Record<string, unknown>;
|
|
54
|
+
}): Promise<{
|
|
55
|
+
data?: unknown;
|
|
56
|
+
}>;
|
|
57
|
+
}
|
|
58
|
+
export interface IMigrationOrchestrator {
|
|
59
|
+
migrate(botId: string, targetNodeId?: string): Promise<{
|
|
60
|
+
success: boolean;
|
|
61
|
+
error?: string;
|
|
62
|
+
sourceNodeId?: string;
|
|
63
|
+
targetNodeId?: string;
|
|
64
|
+
downtimeMs?: number;
|
|
65
|
+
}>;
|
|
66
|
+
}
|
|
67
|
+
export type CapacityAlertChecker = (nodes: unknown[]) => unknown[];
|
|
68
|
+
export interface AdminRecoveryDeps {
|
|
69
|
+
recoveryRepo: () => IRecoveryRepository;
|
|
70
|
+
recoveryOrchestrator: () => IRecoveryOrchestrator;
|
|
71
|
+
auditLogger?: () => AdminAuditLogger;
|
|
72
|
+
logger?: {
|
|
73
|
+
error(msg: string, meta?: Record<string, unknown>): void;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export declare function createAdminRecoveryRoutes(deps: AdminRecoveryDeps): Hono<AuthEnv>;
|
|
77
|
+
export interface AdminNodeDeps {
|
|
78
|
+
nodeRepo: () => INodeRepository;
|
|
79
|
+
nodeProvisioner: () => INodeProvisioner;
|
|
80
|
+
nodeDrainer: () => INodeDrainer;
|
|
81
|
+
botInstanceRepo: () => IBotInstanceRepository;
|
|
82
|
+
recoveryOrchestrator: () => IRecoveryOrchestrator;
|
|
83
|
+
migrationOrchestrator: () => IMigrationOrchestrator;
|
|
84
|
+
commandBus: () => ICommandBus;
|
|
85
|
+
capacityAlertChecker: CapacityAlertChecker;
|
|
86
|
+
auditLogger?: () => AdminAuditLogger;
|
|
87
|
+
logger?: {
|
|
88
|
+
error(msg: string, meta?: Record<string, unknown>): void;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export declare function createAdminNodeRoutes(deps: AdminNodeDeps): Hono<AuthEnv>;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { safeAuditLog } from "./admin-audit-helper.js";
|
|
4
|
+
export function createAdminRecoveryRoutes(deps) {
|
|
5
|
+
const routes = new Hono();
|
|
6
|
+
routes.get("/", async (c) => {
|
|
7
|
+
const rawLimit = Number.parseInt(c.req.query("limit") ?? "50", 10);
|
|
8
|
+
const limit = Number.isNaN(rawLimit) || rawLimit < 1 ? 50 : Math.min(rawLimit, 500);
|
|
9
|
+
const rawStatus = c.req.query("status");
|
|
10
|
+
const validStatuses = ["in_progress", "partial", "completed"];
|
|
11
|
+
const statusFilter = rawStatus && validStatuses.includes(rawStatus) ? rawStatus : undefined;
|
|
12
|
+
const events = await deps.recoveryRepo().listEvents(limit, statusFilter);
|
|
13
|
+
return c.json({ success: true, events, count: events.length });
|
|
14
|
+
});
|
|
15
|
+
routes.get("/:eventId", async (c) => {
|
|
16
|
+
const eventId = c.req.param("eventId");
|
|
17
|
+
const { event, items } = await deps.recoveryOrchestrator().getEventDetails(eventId);
|
|
18
|
+
if (!event) {
|
|
19
|
+
return c.json({ success: false, error: "Recovery event not found" }, 404);
|
|
20
|
+
}
|
|
21
|
+
return c.json({ success: true, event, items });
|
|
22
|
+
});
|
|
23
|
+
routes.post("/:eventId/retry", async (c) => {
|
|
24
|
+
const eventId = c.req.param("eventId");
|
|
25
|
+
try {
|
|
26
|
+
const report = await deps.recoveryOrchestrator().retryWaiting(eventId);
|
|
27
|
+
return c.json({ success: true, report });
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
deps.logger?.error("Failed to retry waiting tenants", { eventId, err });
|
|
31
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return routes;
|
|
35
|
+
}
|
|
36
|
+
export function createAdminNodeRoutes(deps) {
|
|
37
|
+
const routes = new Hono();
|
|
38
|
+
routes.get("/", async (c) => {
|
|
39
|
+
const nodes = await deps.nodeRepo().list();
|
|
40
|
+
const alerts = deps.capacityAlertChecker(nodes);
|
|
41
|
+
return c.json({ success: true, nodes, count: nodes.length, alerts });
|
|
42
|
+
});
|
|
43
|
+
routes.post("/", async (c) => {
|
|
44
|
+
try {
|
|
45
|
+
const body = await c.req.json();
|
|
46
|
+
const parsed = z
|
|
47
|
+
.object({
|
|
48
|
+
region: z.string().min(1).max(20).optional(),
|
|
49
|
+
size: z.string().min(1).max(50).optional(),
|
|
50
|
+
name: z
|
|
51
|
+
.string()
|
|
52
|
+
.min(1)
|
|
53
|
+
.max(63)
|
|
54
|
+
.regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/)
|
|
55
|
+
.optional(),
|
|
56
|
+
})
|
|
57
|
+
.parse(body);
|
|
58
|
+
const result = await deps.nodeProvisioner().provision(parsed);
|
|
59
|
+
safeAuditLog(deps.auditLogger, {
|
|
60
|
+
adminUser: c.get("user")?.id ?? "unknown",
|
|
61
|
+
action: "node.provision",
|
|
62
|
+
category: "config",
|
|
63
|
+
details: {
|
|
64
|
+
nodeId: result.nodeId,
|
|
65
|
+
externalId: result.externalId,
|
|
66
|
+
region: result.region,
|
|
67
|
+
size: result.size,
|
|
68
|
+
monthlyCostCents: result.monthlyCostCents,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
return c.json({ success: true, node: result }, 201);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err instanceof Error && err.message.includes("DO_API_TOKEN")) {
|
|
75
|
+
return c.json({ success: false, error: "Node provisioning not configured. Set DO_API_TOKEN environment variable." }, 503);
|
|
76
|
+
}
|
|
77
|
+
deps.logger?.error("Node provisioning failed", { err });
|
|
78
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
routes.post("/migrate", async (c) => {
|
|
82
|
+
try {
|
|
83
|
+
const body = await c.req.json();
|
|
84
|
+
const parsed = z
|
|
85
|
+
.object({
|
|
86
|
+
botId: z.string().min(1),
|
|
87
|
+
targetNodeId: z.string().min(1),
|
|
88
|
+
})
|
|
89
|
+
.parse(body);
|
|
90
|
+
const bot = await deps.botInstanceRepo().getById(parsed.botId);
|
|
91
|
+
if (!bot) {
|
|
92
|
+
return c.json({ success: false, error: "Bot not found" }, 404);
|
|
93
|
+
}
|
|
94
|
+
if (!bot.nodeId) {
|
|
95
|
+
return c.json({ success: false, error: "Bot has no node assignment" }, 400);
|
|
96
|
+
}
|
|
97
|
+
if (bot.nodeId === parsed.targetNodeId) {
|
|
98
|
+
return c.json({ success: false, error: "Source and target nodes are the same" }, 400);
|
|
99
|
+
}
|
|
100
|
+
const result = await deps.migrationOrchestrator().migrate(parsed.botId, parsed.targetNodeId);
|
|
101
|
+
if (!result.success) {
|
|
102
|
+
return c.json({ success: false, error: result.error }, 500);
|
|
103
|
+
}
|
|
104
|
+
safeAuditLog(deps.auditLogger, {
|
|
105
|
+
adminUser: c.get("user")?.id ?? "unknown",
|
|
106
|
+
action: "node.migrate",
|
|
107
|
+
category: "config",
|
|
108
|
+
details: {
|
|
109
|
+
botId: parsed.botId,
|
|
110
|
+
tenantId: bot.tenantId,
|
|
111
|
+
sourceNode: result.sourceNodeId,
|
|
112
|
+
targetNode: result.targetNodeId,
|
|
113
|
+
downtimeMs: result.downtimeMs,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
return c.json({
|
|
117
|
+
success: true,
|
|
118
|
+
migration: {
|
|
119
|
+
botId: parsed.botId,
|
|
120
|
+
from: result.sourceNodeId,
|
|
121
|
+
to: result.targetNodeId,
|
|
122
|
+
downtimeMs: result.downtimeMs,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
deps.logger?.error("Manual migration failed", { err });
|
|
128
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
routes.get("/regions", async (c) => {
|
|
132
|
+
try {
|
|
133
|
+
const regions = await deps.nodeProvisioner().listRegions();
|
|
134
|
+
return c.json({ success: true, regions });
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (err instanceof Error && err.message.includes("DO_API_TOKEN")) {
|
|
138
|
+
return c.json({ success: false, error: "Node provisioning not configured. Set DO_API_TOKEN environment variable." }, 503);
|
|
139
|
+
}
|
|
140
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
routes.get("/sizes", async (c) => {
|
|
144
|
+
try {
|
|
145
|
+
const sizes = await deps.nodeProvisioner().listSizes();
|
|
146
|
+
return c.json({ success: true, sizes });
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (err instanceof Error && err.message.includes("DO_API_TOKEN")) {
|
|
150
|
+
return c.json({ success: false, error: "Node provisioning not configured. Set DO_API_TOKEN environment variable." }, 503);
|
|
151
|
+
}
|
|
152
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
routes.get("/:nodeId", async (c) => {
|
|
156
|
+
const nodeId = c.req.param("nodeId");
|
|
157
|
+
const node = await deps.nodeRepo().getById(nodeId);
|
|
158
|
+
if (!node) {
|
|
159
|
+
return c.json({ success: false, error: "Node not found" }, 404);
|
|
160
|
+
}
|
|
161
|
+
const tenants = await deps.botInstanceRepo().listByNode(nodeId);
|
|
162
|
+
return c.json({ success: true, node, tenants, tenantCount: tenants.length });
|
|
163
|
+
});
|
|
164
|
+
routes.delete("/:nodeId", async (c) => {
|
|
165
|
+
const nodeId = c.req.param("nodeId");
|
|
166
|
+
try {
|
|
167
|
+
const node = await deps.nodeRepo().getById(nodeId);
|
|
168
|
+
if (!node) {
|
|
169
|
+
return c.json({ success: false, error: "Node not found" }, 404);
|
|
170
|
+
}
|
|
171
|
+
await deps.nodeProvisioner().destroy(nodeId);
|
|
172
|
+
safeAuditLog(deps.auditLogger, {
|
|
173
|
+
adminUser: c.get("user")?.id ?? "unknown",
|
|
174
|
+
action: "node.destroy",
|
|
175
|
+
category: "config",
|
|
176
|
+
details: { nodeId },
|
|
177
|
+
});
|
|
178
|
+
return c.json({ success: true });
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
deps.logger?.error("Node destruction failed", { nodeId, err });
|
|
182
|
+
const status = err instanceof Error && err.message.includes("must be drained") ? 409 : 500;
|
|
183
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, status);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
routes.get("/:nodeId/tenants", async (c) => {
|
|
187
|
+
const nodeId = c.req.param("nodeId");
|
|
188
|
+
const tenants = await deps.botInstanceRepo().listByNode(nodeId);
|
|
189
|
+
return c.json({ success: true, tenants, count: tenants.length });
|
|
190
|
+
});
|
|
191
|
+
routes.get("/:nodeId/stats", async (c) => {
|
|
192
|
+
const nodeId = c.req.param("nodeId");
|
|
193
|
+
try {
|
|
194
|
+
const result = await deps.commandBus().send(nodeId, { type: "stats.get", payload: {} });
|
|
195
|
+
return c.json({ success: true, stats: result.data });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
routes.post("/:nodeId/drain", async (c) => {
|
|
202
|
+
const nodeId = c.req.param("nodeId");
|
|
203
|
+
try {
|
|
204
|
+
const result = await deps.nodeDrainer().drain(nodeId);
|
|
205
|
+
safeAuditLog(deps.auditLogger, {
|
|
206
|
+
adminUser: c.get("user")?.id ?? "unknown",
|
|
207
|
+
action: "node.drain",
|
|
208
|
+
category: "config",
|
|
209
|
+
details: { nodeId, migrated: result.migrated.length, failed: result.failed.length },
|
|
210
|
+
});
|
|
211
|
+
return c.json({ success: result.failed.length === 0, result });
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
deps.logger?.error("Drain failed", { nodeId, err });
|
|
215
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
routes.post("/:nodeId/cancel-drain", async (c) => {
|
|
219
|
+
const nodeId = c.req.param("nodeId");
|
|
220
|
+
try {
|
|
221
|
+
await deps.nodeRepo().transition(nodeId, "active", "drain_cancelled", "admin");
|
|
222
|
+
safeAuditLog(deps.auditLogger, {
|
|
223
|
+
adminUser: c.get("user")?.id ?? "unknown",
|
|
224
|
+
action: "node.cancelDrain",
|
|
225
|
+
category: "config",
|
|
226
|
+
details: { nodeId },
|
|
227
|
+
});
|
|
228
|
+
return c.json({ success: true, message: `Drain cancelled for node ${nodeId}` });
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
routes.post("/:nodeId/recover", async (c) => {
|
|
235
|
+
const nodeId = c.req.param("nodeId");
|
|
236
|
+
try {
|
|
237
|
+
const report = await deps.recoveryOrchestrator().triggerRecovery(nodeId, "manual");
|
|
238
|
+
return c.json({ success: true, report });
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
deps.logger?.error("Manual recovery failed", { nodeId, err });
|
|
242
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
return routes;
|
|
246
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Context, Next } from "hono";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { RoleStore } from "../../admin/role-store.js";
|
|
4
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
5
|
+
import type { AdminAuditLogger } from "./admin-audit-helper.js";
|
|
6
|
+
export declare function requirePlatformAdmin(storeOrFactory: RoleStore | (() => RoleStore)): (c: Context<AuthEnv>, next: Next) => Promise<void | (Response & import("hono").TypedResponse<{
|
|
7
|
+
error: string;
|
|
8
|
+
}, 401, "json">) | (Response & import("hono").TypedResponse<{
|
|
9
|
+
error: string;
|
|
10
|
+
}, 403, "json">)>;
|
|
11
|
+
export declare function requireTenantAdmin(storeOrFactory: RoleStore | (() => RoleStore), tenantIdKey?: string): (c: Context<AuthEnv>, next: Next) => Promise<void | (Response & import("hono").TypedResponse<{
|
|
12
|
+
error: string;
|
|
13
|
+
}, 401, "json">) | (Response & import("hono").TypedResponse<{
|
|
14
|
+
error: string;
|
|
15
|
+
}, 400, "json">) | (Response & import("hono").TypedResponse<{
|
|
16
|
+
error: string;
|
|
17
|
+
}, 403, "json">)>;
|
|
18
|
+
/**
|
|
19
|
+
* Create admin role management API routes.
|
|
20
|
+
* Takes a RoleStore factory and optional audit logger.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createAdminRolesRoutes(storeFactory: () => RoleStore, auditLogger?: () => AdminAuditLogger): Hono<AuthEnv>;
|
|
23
|
+
/**
|
|
24
|
+
* Create platform admin management routes.
|
|
25
|
+
* Takes a RoleStore factory and optional audit logger.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createPlatformAdminRoutes(storeFactory: () => RoleStore, auditLogger?: () => AdminAuditLogger): Hono<AuthEnv>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { isValidRole, RoleStore } from "../../admin/role-store.js";
|
|
3
|
+
import { safeAuditLog } from "./admin-audit-helper.js";
|
|
4
|
+
// ── Role middleware (generic, no WOPR deps) ──
|
|
5
|
+
function resolveRoleStore(storeOrFactory) {
|
|
6
|
+
return typeof storeOrFactory === "function" ? storeOrFactory() : storeOrFactory;
|
|
7
|
+
}
|
|
8
|
+
export function requirePlatformAdmin(storeOrFactory) {
|
|
9
|
+
return async (c, next) => {
|
|
10
|
+
let user;
|
|
11
|
+
try {
|
|
12
|
+
user = c.get("user");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
16
|
+
}
|
|
17
|
+
if (!user) {
|
|
18
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
19
|
+
}
|
|
20
|
+
if (!user.roles.includes("admin") && !(await resolveRoleStore(storeOrFactory).isPlatformAdmin(user.id))) {
|
|
21
|
+
return c.json({ error: "Platform admin access required" }, 403);
|
|
22
|
+
}
|
|
23
|
+
return next();
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function requireTenantAdmin(storeOrFactory, tenantIdKey = "tenantId") {
|
|
27
|
+
return async (c, next) => {
|
|
28
|
+
let user;
|
|
29
|
+
try {
|
|
30
|
+
user = c.get("user");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
34
|
+
}
|
|
35
|
+
if (!user) {
|
|
36
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
37
|
+
}
|
|
38
|
+
if (user.roles.includes("admin") || (await resolveRoleStore(storeOrFactory).isPlatformAdmin(user.id))) {
|
|
39
|
+
return next();
|
|
40
|
+
}
|
|
41
|
+
const tenantId = c.req.param(tenantIdKey);
|
|
42
|
+
if (!tenantId) {
|
|
43
|
+
return c.json({ error: "Tenant ID required" }, 400);
|
|
44
|
+
}
|
|
45
|
+
const role = await resolveRoleStore(storeOrFactory).getRole(user.id, tenantId);
|
|
46
|
+
if (role !== "tenant_admin") {
|
|
47
|
+
return c.json({ error: "Tenant admin access required" }, 403);
|
|
48
|
+
}
|
|
49
|
+
return next();
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// ── Route factories ──
|
|
53
|
+
/**
|
|
54
|
+
* Create admin role management API routes.
|
|
55
|
+
* Takes a RoleStore factory and optional audit logger.
|
|
56
|
+
*/
|
|
57
|
+
export function createAdminRolesRoutes(storeFactory, auditLogger) {
|
|
58
|
+
const routes = new Hono();
|
|
59
|
+
routes.get("/:tenantId", requireTenantAdmin(storeFactory), async (c) => {
|
|
60
|
+
const tenantId = c.req.param("tenantId");
|
|
61
|
+
const roles = await storeFactory().listByTenant(tenantId);
|
|
62
|
+
return c.json({ roles });
|
|
63
|
+
});
|
|
64
|
+
routes.put("/:tenantId/:userId", requireTenantAdmin(storeFactory), async (c) => {
|
|
65
|
+
const tenantId = c.req.param("tenantId");
|
|
66
|
+
const userId = c.req.param("userId");
|
|
67
|
+
const body = await c.req.json().catch(() => null);
|
|
68
|
+
if (!body?.role || !isValidRole(body.role)) {
|
|
69
|
+
return c.json({ error: "Invalid role. Must be: platform_admin, tenant_admin, or user" }, 400);
|
|
70
|
+
}
|
|
71
|
+
if (body.role === "platform_admin") {
|
|
72
|
+
return c.json({ error: "platform_admin can only be granted via platform admin routes" }, 400);
|
|
73
|
+
}
|
|
74
|
+
const currentUser = c.get("user");
|
|
75
|
+
await storeFactory().setRole(userId, tenantId, body.role, currentUser.id);
|
|
76
|
+
safeAuditLog(auditLogger, {
|
|
77
|
+
adminUser: currentUser.id ?? "unknown",
|
|
78
|
+
action: "role.set",
|
|
79
|
+
category: "roles",
|
|
80
|
+
targetTenant: tenantId,
|
|
81
|
+
targetUser: userId,
|
|
82
|
+
details: { role: body.role },
|
|
83
|
+
outcome: "success",
|
|
84
|
+
});
|
|
85
|
+
return c.json({ ok: true });
|
|
86
|
+
});
|
|
87
|
+
routes.delete("/:tenantId/:userId", requireTenantAdmin(storeFactory), async (c) => {
|
|
88
|
+
const tenantId = c.req.param("tenantId");
|
|
89
|
+
const userId = c.req.param("userId");
|
|
90
|
+
const removed = await storeFactory().removeRole(userId, tenantId);
|
|
91
|
+
if (!removed) {
|
|
92
|
+
return c.json({ error: "Role not found" }, 404);
|
|
93
|
+
}
|
|
94
|
+
const currentUser = c.get("user");
|
|
95
|
+
safeAuditLog(auditLogger, {
|
|
96
|
+
adminUser: currentUser?.id ?? "unknown",
|
|
97
|
+
action: "role.remove",
|
|
98
|
+
category: "roles",
|
|
99
|
+
targetTenant: tenantId,
|
|
100
|
+
targetUser: userId,
|
|
101
|
+
details: {},
|
|
102
|
+
outcome: "success",
|
|
103
|
+
});
|
|
104
|
+
return c.json({ ok: true });
|
|
105
|
+
});
|
|
106
|
+
return routes;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Create platform admin management routes.
|
|
110
|
+
* Takes a RoleStore factory and optional audit logger.
|
|
111
|
+
*/
|
|
112
|
+
export function createPlatformAdminRoutes(storeFactory, auditLogger) {
|
|
113
|
+
const routes = new Hono();
|
|
114
|
+
routes.use("*", requirePlatformAdmin(storeFactory));
|
|
115
|
+
routes.get("/", async (c) => {
|
|
116
|
+
const admins = await storeFactory().listPlatformAdmins();
|
|
117
|
+
return c.json({ admins });
|
|
118
|
+
});
|
|
119
|
+
routes.post("/", async (c) => {
|
|
120
|
+
const body = await c.req.json().catch(() => null);
|
|
121
|
+
if (!body?.userId) {
|
|
122
|
+
return c.json({ error: "userId is required" }, 400);
|
|
123
|
+
}
|
|
124
|
+
const currentUser = c.get("user");
|
|
125
|
+
await storeFactory().setRole(body.userId, RoleStore.PLATFORM_TENANT, "platform_admin", currentUser.id);
|
|
126
|
+
safeAuditLog(auditLogger, {
|
|
127
|
+
adminUser: currentUser.id ?? "unknown",
|
|
128
|
+
action: "platform_admin.add",
|
|
129
|
+
category: "roles",
|
|
130
|
+
targetUser: body.userId,
|
|
131
|
+
details: {},
|
|
132
|
+
outcome: "success",
|
|
133
|
+
});
|
|
134
|
+
return c.json({ ok: true });
|
|
135
|
+
});
|
|
136
|
+
routes.delete("/:userId", async (c) => {
|
|
137
|
+
const userId = c.req.param("userId");
|
|
138
|
+
if ((await storeFactory().countPlatformAdmins()) <= 1 && (await storeFactory().isPlatformAdmin(userId))) {
|
|
139
|
+
return c.json({ error: "Cannot remove the last platform admin" }, 409);
|
|
140
|
+
}
|
|
141
|
+
const removed = await storeFactory().removeRole(userId, RoleStore.PLATFORM_TENANT);
|
|
142
|
+
if (!removed) {
|
|
143
|
+
return c.json({ error: "Platform admin not found" }, 404);
|
|
144
|
+
}
|
|
145
|
+
const currentUser = c.get("user");
|
|
146
|
+
safeAuditLog(auditLogger, {
|
|
147
|
+
adminUser: currentUser?.id ?? "unknown",
|
|
148
|
+
action: "platform_admin.remove",
|
|
149
|
+
category: "roles",
|
|
150
|
+
targetUser: userId,
|
|
151
|
+
details: {},
|
|
152
|
+
outcome: "success",
|
|
153
|
+
});
|
|
154
|
+
return c.json({ ok: true });
|
|
155
|
+
});
|
|
156
|
+
return routes;
|
|
157
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AuditEnv } from "../../audit/types.js";
|
|
3
|
+
import type { DrizzleDb } from "../../db/index.js";
|
|
4
|
+
type DbOrFactory = DrizzleDb | (() => DrizzleDb);
|
|
5
|
+
/**
|
|
6
|
+
* Create audit log API routes.
|
|
7
|
+
*
|
|
8
|
+
* Pass a `DrizzleDb` directly or a `() => DrizzleDb` factory for lazy init.
|
|
9
|
+
* Expects `c.get("user")` to provide `{ id: string }`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createAuditRoutes(db: DbOrFactory): Hono<AuditEnv>;
|
|
12
|
+
/**
|
|
13
|
+
* Create admin audit log API routes.
|
|
14
|
+
*
|
|
15
|
+
* Pass a `DrizzleDb` directly or a `() => DrizzleDb` factory for lazy init.
|
|
16
|
+
* Expects `c.get("user")` to provide `{ id: string, isAdmin: boolean }`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createAdminAuditRoutes(db: DbOrFactory): Hono<AuditEnv>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { DrizzleAuditLogRepository } from "../../audit/audit-log-repository.js";
|
|
3
|
+
import { countAuditLog, queryAuditLog } from "../../audit/query.js";
|
|
4
|
+
import { purgeExpiredEntriesForUser } from "../../audit/retention.js";
|
|
5
|
+
async function handleUserAudit(c, repo) {
|
|
6
|
+
const user = c.get("user");
|
|
7
|
+
if (!user)
|
|
8
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
9
|
+
void purgeExpiredEntriesForUser(repo, user.id).catch(() => {
|
|
10
|
+
/* purge is best-effort — must not break request */
|
|
11
|
+
});
|
|
12
|
+
const sinceRaw = c.req.query("since") ? Number(c.req.query("since")) : undefined;
|
|
13
|
+
const untilRaw = c.req.query("until") ? Number(c.req.query("until")) : undefined;
|
|
14
|
+
const limitRaw = c.req.query("limit") ? Number(c.req.query("limit")) : undefined;
|
|
15
|
+
const offsetRaw = c.req.query("offset") ? Number(c.req.query("offset")) : undefined;
|
|
16
|
+
const filters = {
|
|
17
|
+
userId: user.id,
|
|
18
|
+
action: c.req.query("action") ?? undefined,
|
|
19
|
+
resourceType: c.req.query("resourceType") ?? undefined,
|
|
20
|
+
resourceId: c.req.query("resourceId") ?? undefined,
|
|
21
|
+
since: sinceRaw !== undefined && Number.isFinite(sinceRaw) ? sinceRaw : undefined,
|
|
22
|
+
until: untilRaw !== undefined && Number.isFinite(untilRaw) ? untilRaw : undefined,
|
|
23
|
+
limit: limitRaw !== undefined && Number.isFinite(limitRaw) ? limitRaw : undefined,
|
|
24
|
+
offset: offsetRaw !== undefined && Number.isFinite(offsetRaw) ? offsetRaw : undefined,
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
const [entries, total] = await Promise.all([queryAuditLog(repo, filters), countAuditLog(repo, filters)]);
|
|
28
|
+
return c.json({ entries, total });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function handleAdminAudit(c, repo) {
|
|
35
|
+
const user = c.get("user");
|
|
36
|
+
if (!user?.isAdmin)
|
|
37
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
38
|
+
const sinceRaw = c.req.query("since") ? Number(c.req.query("since")) : undefined;
|
|
39
|
+
const untilRaw = c.req.query("until") ? Number(c.req.query("until")) : undefined;
|
|
40
|
+
const limitRaw = c.req.query("limit") ? Number(c.req.query("limit")) : undefined;
|
|
41
|
+
const offsetRaw = c.req.query("offset") ? Number(c.req.query("offset")) : undefined;
|
|
42
|
+
const filters = {
|
|
43
|
+
userId: c.req.query("userId") ?? undefined,
|
|
44
|
+
action: c.req.query("action") ?? undefined,
|
|
45
|
+
resourceType: c.req.query("resourceType") ?? undefined,
|
|
46
|
+
resourceId: c.req.query("resourceId") ?? undefined,
|
|
47
|
+
since: sinceRaw !== undefined && Number.isFinite(sinceRaw) ? sinceRaw : undefined,
|
|
48
|
+
until: untilRaw !== undefined && Number.isFinite(untilRaw) ? untilRaw : undefined,
|
|
49
|
+
limit: limitRaw !== undefined && Number.isFinite(limitRaw) ? limitRaw : undefined,
|
|
50
|
+
offset: offsetRaw !== undefined && Number.isFinite(offsetRaw) ? offsetRaw : undefined,
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
const [entries, total] = await Promise.all([queryAuditLog(repo, filters), countAuditLog(repo, filters)]);
|
|
54
|
+
return c.json({ entries, total });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function resolveRepo(dbOrFactory) {
|
|
61
|
+
const db = typeof dbOrFactory === "function" ? dbOrFactory() : dbOrFactory;
|
|
62
|
+
return new DrizzleAuditLogRepository(db);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Create audit log API routes.
|
|
66
|
+
*
|
|
67
|
+
* Pass a `DrizzleDb` directly or a `() => DrizzleDb` factory for lazy init.
|
|
68
|
+
* Expects `c.get("user")` to provide `{ id: string }`.
|
|
69
|
+
*/
|
|
70
|
+
export function createAuditRoutes(db) {
|
|
71
|
+
let repo = typeof db === "function" ? null : resolveRepo(db);
|
|
72
|
+
const routes = new Hono();
|
|
73
|
+
routes.get("/", (c) => {
|
|
74
|
+
if (!repo)
|
|
75
|
+
repo = resolveRepo(db);
|
|
76
|
+
return handleUserAudit(c, repo);
|
|
77
|
+
});
|
|
78
|
+
return routes;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Create admin audit log API routes.
|
|
82
|
+
*
|
|
83
|
+
* Pass a `DrizzleDb` directly or a `() => DrizzleDb` factory for lazy init.
|
|
84
|
+
* Expects `c.get("user")` to provide `{ id: string, isAdmin: boolean }`.
|
|
85
|
+
*/
|
|
86
|
+
export function createAdminAuditRoutes(db) {
|
|
87
|
+
let repo = typeof db === "function" ? null : resolveRepo(db);
|
|
88
|
+
const routes = new Hono();
|
|
89
|
+
routes.get("/", (c) => {
|
|
90
|
+
if (!repo)
|
|
91
|
+
repo = resolveRepo(db);
|
|
92
|
+
return handleAdminAudit(c, repo);
|
|
93
|
+
});
|
|
94
|
+
return routes;
|
|
95
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Routes — Mounts better-auth's HTTP handler as a Hono route.
|
|
3
|
+
*
|
|
4
|
+
* All requests to `/api/auth/*` are forwarded to better-auth, which handles:
|
|
5
|
+
* - POST /api/auth/sign-up/email — Register with email + password
|
|
6
|
+
* - POST /api/auth/sign-in/email — Sign in with email + password
|
|
7
|
+
* - POST /api/auth/sign-out — Sign out (invalidate session)
|
|
8
|
+
* - GET /api/auth/get-session — Get current session
|
|
9
|
+
*
|
|
10
|
+
* better-auth manages its own session cookies and CSRF protection.
|
|
11
|
+
*/
|
|
12
|
+
import { Hono } from "hono";
|
|
13
|
+
import type { Auth } from "../../auth/better-auth.js";
|
|
14
|
+
/**
|
|
15
|
+
* Create auth routes that delegate to better-auth's handler.
|
|
16
|
+
*
|
|
17
|
+
* @param auth - The better-auth instance (use `getAuth()`)
|
|
18
|
+
*/
|
|
19
|
+
export declare function createAuthRoutes(auth: Auth): Hono;
|