@wopr-network/platform-core 1.12.2 → 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/dist/monetization/adapters/bootstrap.d.ts +2 -2
- package/dist/monetization/adapters/bootstrap.js +3 -2
- package/dist/monetization/adapters/bootstrap.test.js +11 -7
- package/dist/monetization/adapters/embeddings-factory.d.ts +10 -5
- package/dist/monetization/adapters/embeddings-factory.js +17 -4
- package/dist/monetization/adapters/embeddings-factory.test.js +85 -31
- package/dist/monetization/adapters/ollama-embeddings.d.ts +40 -0
- package/dist/monetization/adapters/ollama-embeddings.js +76 -0
- package/dist/monetization/adapters/ollama-embeddings.test.d.ts +1 -0
- package/dist/monetization/adapters/ollama-embeddings.test.js +178 -0
- package/dist/monetization/adapters/rate-table.js +9 -3
- package/dist/monetization/adapters/rate-table.test.js +22 -1
- 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
- package/src/monetization/adapters/bootstrap.test.ts +11 -7
- package/src/monetization/adapters/bootstrap.ts +3 -2
- package/src/monetization/adapters/embeddings-factory.test.ts +102 -33
- package/src/monetization/adapters/embeddings-factory.ts +24 -7
- package/src/monetization/adapters/ollama-embeddings.test.ts +235 -0
- package/src/monetization/adapters/ollama-embeddings.ts +120 -0
- package/src/monetization/adapters/rate-table.test.ts +32 -1
- package/src/monetization/adapters/rate-table.ts +9 -3
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AuditEnv } from "../../audit/types.js";
|
|
3
|
+
import type { DrizzleDb } from "../../db/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Create activity feed routes.
|
|
6
|
+
*
|
|
7
|
+
* @param dbFactory - Factory returning the audit DB instance
|
|
8
|
+
*/
|
|
9
|
+
export declare function createActivityRoutes(dbFactory: () => DrizzleDb): Hono<AuditEnv>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { DrizzleAuditLogRepository } from "../../audit/audit-log-repository.js";
|
|
3
|
+
import { queryAuditLog } from "../../audit/query.js";
|
|
4
|
+
/**
|
|
5
|
+
* Create activity feed routes.
|
|
6
|
+
*
|
|
7
|
+
* @param dbFactory - Factory returning the audit DB instance
|
|
8
|
+
*/
|
|
9
|
+
export function createActivityRoutes(dbFactory) {
|
|
10
|
+
const routes = new Hono();
|
|
11
|
+
/**
|
|
12
|
+
* GET /
|
|
13
|
+
*
|
|
14
|
+
* Returns recent activity events for the authenticated user.
|
|
15
|
+
* Query params:
|
|
16
|
+
* - limit: max results (default 20, max 100)
|
|
17
|
+
*/
|
|
18
|
+
routes.get("/", async (c) => {
|
|
19
|
+
const user = c.get("user");
|
|
20
|
+
if (!user)
|
|
21
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
22
|
+
const limitRaw = c.req.query("limit");
|
|
23
|
+
const limit = Math.min(Math.max(1, limitRaw ? Number.parseInt(limitRaw, 10) : 20), 100);
|
|
24
|
+
const db = dbFactory();
|
|
25
|
+
const rows = await queryAuditLog(new DrizzleAuditLogRepository(db), { userId: user.id, limit });
|
|
26
|
+
const events = rows.map((row) => ({
|
|
27
|
+
id: row.id,
|
|
28
|
+
timestamp: new Date(row.timestamp).toISOString(),
|
|
29
|
+
actor: row.user_id,
|
|
30
|
+
action: formatAction(row.action),
|
|
31
|
+
target: row.resource_id ?? row.resource_type,
|
|
32
|
+
targetHref: buildTargetHref(row.resource_type, row.resource_id ?? null),
|
|
33
|
+
}));
|
|
34
|
+
return c.json(events);
|
|
35
|
+
});
|
|
36
|
+
return routes;
|
|
37
|
+
}
|
|
38
|
+
function formatAction(action) {
|
|
39
|
+
const parts = action.split(".");
|
|
40
|
+
if (parts.length === 2) {
|
|
41
|
+
const [resource, verb] = parts;
|
|
42
|
+
const pastTense = {
|
|
43
|
+
start: "Started",
|
|
44
|
+
stop: "Stopped",
|
|
45
|
+
create: "Created",
|
|
46
|
+
delete: "Deleted",
|
|
47
|
+
update: "Updated",
|
|
48
|
+
restart: "Restarted",
|
|
49
|
+
};
|
|
50
|
+
return `${pastTense[verb] ?? verb} ${resource}`;
|
|
51
|
+
}
|
|
52
|
+
return action;
|
|
53
|
+
}
|
|
54
|
+
function buildTargetHref(resourceType, resourceId) {
|
|
55
|
+
if (!resourceId)
|
|
56
|
+
return "/dashboard";
|
|
57
|
+
switch (resourceType) {
|
|
58
|
+
case "instance":
|
|
59
|
+
case "bot":
|
|
60
|
+
return `/instances/${resourceId}`;
|
|
61
|
+
case "snapshot":
|
|
62
|
+
return `/instances/${resourceId}`;
|
|
63
|
+
case "key":
|
|
64
|
+
return "/settings";
|
|
65
|
+
default:
|
|
66
|
+
return "/dashboard";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AuditEntry } from "../../admin/audit-log.js";
|
|
2
|
+
/** Minimal interface for admin audit logging in route factories. */
|
|
3
|
+
export interface AdminAuditLogger {
|
|
4
|
+
log(entry: AuditEntry): void | Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
/** Safely log an admin audit entry — never throws. */
|
|
7
|
+
export declare function safeAuditLog(logger: (() => AdminAuditLogger) | undefined, entry: AuditEntry): void;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Safely log an admin audit entry — never throws. */
|
|
2
|
+
export function safeAuditLog(logger, entry) {
|
|
3
|
+
if (!logger)
|
|
4
|
+
return;
|
|
5
|
+
try {
|
|
6
|
+
void Promise.resolve(logger().log(entry)).catch(() => {
|
|
7
|
+
/* audit must not break request */
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
/* audit must not break request */
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AdminAuditLog } from "../../admin/index.js";
|
|
3
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Create admin audit API routes with an injectable audit log service.
|
|
6
|
+
*
|
|
7
|
+
* Routes:
|
|
8
|
+
* GET / — query admin audit log entries with filters
|
|
9
|
+
* GET /export — export filtered entries as CSV
|
|
10
|
+
*
|
|
11
|
+
* @param auditLogFactory - factory returning an AdminAuditLog instance
|
|
12
|
+
*/
|
|
13
|
+
export declare function createAdminAuditApiRoutes(auditLogFactory: () => AdminAuditLog): Hono<AuthEnv>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
function parseIntParam(value) {
|
|
3
|
+
if (value == null || value === "")
|
|
4
|
+
return undefined;
|
|
5
|
+
const n = Number.parseInt(value, 10);
|
|
6
|
+
return Number.isFinite(n) ? n : undefined;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Create admin audit API routes with an injectable audit log service.
|
|
10
|
+
*
|
|
11
|
+
* Routes:
|
|
12
|
+
* GET / — query admin audit log entries with filters
|
|
13
|
+
* GET /export — export filtered entries as CSV
|
|
14
|
+
*
|
|
15
|
+
* @param auditLogFactory - factory returning an AdminAuditLog instance
|
|
16
|
+
*/
|
|
17
|
+
export function createAdminAuditApiRoutes(auditLogFactory) {
|
|
18
|
+
const routes = new Hono();
|
|
19
|
+
routes.get("/", async (c) => {
|
|
20
|
+
const filters = {
|
|
21
|
+
admin: c.req.query("admin") ?? undefined,
|
|
22
|
+
action: c.req.query("action") ?? undefined,
|
|
23
|
+
category: c.req.query("category") ?? undefined,
|
|
24
|
+
tenant: c.req.query("tenant") ?? undefined,
|
|
25
|
+
from: parseIntParam(c.req.query("from")),
|
|
26
|
+
to: parseIntParam(c.req.query("to")),
|
|
27
|
+
limit: parseIntParam(c.req.query("limit")),
|
|
28
|
+
offset: parseIntParam(c.req.query("offset")),
|
|
29
|
+
};
|
|
30
|
+
try {
|
|
31
|
+
const result = await auditLogFactory().query(filters);
|
|
32
|
+
return c.json(result);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
routes.get("/export", async (c) => {
|
|
39
|
+
const filters = {
|
|
40
|
+
admin: c.req.query("admin") ?? undefined,
|
|
41
|
+
action: c.req.query("action") ?? undefined,
|
|
42
|
+
category: c.req.query("category") ?? undefined,
|
|
43
|
+
tenant: c.req.query("tenant") ?? undefined,
|
|
44
|
+
from: parseIntParam(c.req.query("from")),
|
|
45
|
+
to: parseIntParam(c.req.query("to")),
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
const csv = await auditLogFactory().exportCsv(filters);
|
|
49
|
+
return new Response(csv, {
|
|
50
|
+
headers: {
|
|
51
|
+
"Content-Type": "text/csv",
|
|
52
|
+
"Content-Disposition": 'attachment; filename="audit-log.csv"',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return routes;
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
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 type { AdminAuditLogger } from "./admin-audit-helper.js";
|
|
6
|
+
/**
|
|
7
|
+
* Validate that a remotePath belongs to the given container.
|
|
8
|
+
* Normalizes the path, rejects traversal segments, and checks that the containerId
|
|
9
|
+
* appears as the leading non-empty segment.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isRemotePathOwnedBy(remotePath: string, containerId: string): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Create admin backup routes.
|
|
14
|
+
*
|
|
15
|
+
* @param storeFactory - factory for the backup status store
|
|
16
|
+
* @param spacesFactory - factory for the S3/Spaces client
|
|
17
|
+
* @param auditLogger - optional admin audit logger
|
|
18
|
+
*/
|
|
19
|
+
export declare function createAdminBackupRoutes(storeFactory: () => IBackupStatusStore, spacesFactory: () => SpacesClient, auditLogger?: () => AdminAuditLogger): Hono<AuthEnv>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { logger } from "../../config/logger.js";
|
|
3
|
+
import { safeAuditLog } from "./admin-audit-helper.js";
|
|
4
|
+
/**
|
|
5
|
+
* Validate that a remotePath belongs to the given container.
|
|
6
|
+
* Normalizes the path, rejects traversal segments, and checks that the containerId
|
|
7
|
+
* appears as the leading non-empty segment.
|
|
8
|
+
*/
|
|
9
|
+
export function isRemotePathOwnedBy(remotePath, containerId) {
|
|
10
|
+
const normalized = remotePath.replace(/\\/g, "/");
|
|
11
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
12
|
+
if (segments.length === 0)
|
|
13
|
+
return false;
|
|
14
|
+
if (segments.some((s) => s === ".." || s === "."))
|
|
15
|
+
return false;
|
|
16
|
+
return segments[0] === containerId;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create admin backup routes.
|
|
20
|
+
*
|
|
21
|
+
* @param storeFactory - factory for the backup status store
|
|
22
|
+
* @param spacesFactory - factory for the S3/Spaces client
|
|
23
|
+
* @param auditLogger - optional admin audit logger
|
|
24
|
+
*/
|
|
25
|
+
export function createAdminBackupRoutes(storeFactory, spacesFactory, auditLogger) {
|
|
26
|
+
const routes = new Hono();
|
|
27
|
+
routes.get("/", async (c) => {
|
|
28
|
+
const store = storeFactory();
|
|
29
|
+
const staleOnly = c.req.query("stale") === "true";
|
|
30
|
+
const entries = staleOnly ? await store.listStale() : await store.listAll();
|
|
31
|
+
return c.json({
|
|
32
|
+
backups: entries,
|
|
33
|
+
total: entries.length,
|
|
34
|
+
staleCount: entries.filter((e) => e.isStale).length,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
routes.get("/alerts/stale", async (c) => {
|
|
38
|
+
const store = storeFactory();
|
|
39
|
+
const stale = await store.listStale();
|
|
40
|
+
return c.json({
|
|
41
|
+
alerts: stale.map((e) => ({
|
|
42
|
+
containerId: e.containerId,
|
|
43
|
+
nodeId: e.nodeId,
|
|
44
|
+
lastBackupAt: e.lastBackupAt,
|
|
45
|
+
lastBackupSuccess: e.lastBackupSuccess,
|
|
46
|
+
lastBackupError: e.lastBackupError,
|
|
47
|
+
})),
|
|
48
|
+
count: stale.length,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
routes.get("/:containerId", async (c) => {
|
|
52
|
+
const store = storeFactory();
|
|
53
|
+
const containerId = c.req.param("containerId");
|
|
54
|
+
const entry = await store.get(containerId);
|
|
55
|
+
if (!entry) {
|
|
56
|
+
return c.json({ error: "No backup status found for this container" }, 404);
|
|
57
|
+
}
|
|
58
|
+
return c.json(entry);
|
|
59
|
+
});
|
|
60
|
+
routes.get("/:containerId/snapshots", async (c) => {
|
|
61
|
+
const store = storeFactory();
|
|
62
|
+
const containerId = c.req.param("containerId");
|
|
63
|
+
const entry = await store.get(containerId);
|
|
64
|
+
if (!entry) {
|
|
65
|
+
return c.json({ error: "No backup status found for this container" }, 404);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const spaces = spacesFactory();
|
|
69
|
+
const prefix = `nightly/${entry.nodeId}/${containerId}/`;
|
|
70
|
+
const objects = await spaces.list(prefix);
|
|
71
|
+
return c.json({
|
|
72
|
+
containerId,
|
|
73
|
+
snapshots: objects.map((o) => ({
|
|
74
|
+
path: o.path,
|
|
75
|
+
date: o.date,
|
|
76
|
+
sizeMb: Math.round((o.size / (1024 * 1024)) * 100) / 100,
|
|
77
|
+
})),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
logger.error(`Failed to list snapshots for ${containerId}`, { err });
|
|
82
|
+
return c.json({ error: "Failed to list backup snapshots" }, 500);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
routes.post("/:containerId/restore", async (c) => {
|
|
86
|
+
const containerId = c.req.param("containerId");
|
|
87
|
+
let body;
|
|
88
|
+
try {
|
|
89
|
+
body = await c.req.json();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
93
|
+
}
|
|
94
|
+
if (!body.remotePath) {
|
|
95
|
+
return c.json({ error: "remotePath is required" }, 400);
|
|
96
|
+
}
|
|
97
|
+
if (!isRemotePathOwnedBy(body.remotePath, containerId)) {
|
|
98
|
+
return c.json({ error: "remotePath does not belong to this container" }, 403);
|
|
99
|
+
}
|
|
100
|
+
safeAuditLog(auditLogger, {
|
|
101
|
+
adminUser: c.get("user")?.id ?? "unknown",
|
|
102
|
+
action: "backup.restore",
|
|
103
|
+
category: "config",
|
|
104
|
+
details: { containerId, remotePath: body.remotePath, targetNodeId: body.targetNodeId ?? "auto" },
|
|
105
|
+
outcome: "success",
|
|
106
|
+
});
|
|
107
|
+
return c.json({
|
|
108
|
+
ok: true,
|
|
109
|
+
message: `Restore initiated for ${containerId} from ${body.remotePath}`,
|
|
110
|
+
containerId,
|
|
111
|
+
remotePath: body.remotePath,
|
|
112
|
+
targetNodeId: body.targetNodeId ?? "auto",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
return routes;
|
|
116
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { EvidenceCollector } from "../../compliance/evidence-collector.js";
|
|
3
|
+
/**
|
|
4
|
+
* Create admin compliance routes.
|
|
5
|
+
*
|
|
6
|
+
* GET /evidence?from=ISO&to=ISO — Generate SOC 2 evidence report for the given period.
|
|
7
|
+
* Defaults to last 90 days if no params provided.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createAdminComplianceRoutes(getCollector: () => EvidenceCollector): Hono;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
/**
|
|
3
|
+
* Create admin compliance routes.
|
|
4
|
+
*
|
|
5
|
+
* GET /evidence?from=ISO&to=ISO — Generate SOC 2 evidence report for the given period.
|
|
6
|
+
* Defaults to last 90 days if no params provided.
|
|
7
|
+
*/
|
|
8
|
+
export function createAdminComplianceRoutes(getCollector) {
|
|
9
|
+
const routes = new Hono();
|
|
10
|
+
routes.get("/evidence", async (c) => {
|
|
11
|
+
const now = new Date();
|
|
12
|
+
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
|
13
|
+
const fromParam = c.req.query("from");
|
|
14
|
+
const toParam = c.req.query("to");
|
|
15
|
+
if (fromParam !== undefined && Number.isNaN(new Date(fromParam).getTime())) {
|
|
16
|
+
return c.json({ error: "Invalid 'from' date" }, 400);
|
|
17
|
+
}
|
|
18
|
+
if (toParam !== undefined && Number.isNaN(new Date(toParam).getTime())) {
|
|
19
|
+
return c.json({ error: "Invalid 'to' date" }, 400);
|
|
20
|
+
}
|
|
21
|
+
const from = fromParam ?? ninetyDaysAgo.toISOString();
|
|
22
|
+
const to = toParam ?? now.toISOString();
|
|
23
|
+
const report = await getCollector().collect({ from, to });
|
|
24
|
+
return c.json(report);
|
|
25
|
+
});
|
|
26
|
+
return routes;
|
|
27
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
3
|
+
import type { ICreditLedger } from "../../credits/index.js";
|
|
4
|
+
import type { AdminAuditLogger } from "./admin-audit-helper.js";
|
|
5
|
+
/**
|
|
6
|
+
* Create admin credit API routes.
|
|
7
|
+
* Pass a ledger directly or a factory for lazy init.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createAdminCreditApiRoutes(ledgerOrFactory: ICreditLedger | (() => ICreditLedger), auditLogger?: () => AdminAuditLogger): Hono<AuthEnv>;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Credit, InsufficientBalanceError } from "../../credits/index.js";
|
|
4
|
+
import { safeAuditLog } from "./admin-audit-helper.js";
|
|
5
|
+
const tenantIdSchema = z
|
|
6
|
+
.string()
|
|
7
|
+
.min(1)
|
|
8
|
+
.max(128)
|
|
9
|
+
.regex(/^[a-zA-Z0-9_-]+$/);
|
|
10
|
+
const TENANT_ID_ERROR = "tenantId must be 1-128 alphanumeric characters, hyphens, or underscores";
|
|
11
|
+
function parseTenantId(c) {
|
|
12
|
+
const result = tenantIdSchema.safeParse(c.req.param("tenantId"));
|
|
13
|
+
if (!result.success)
|
|
14
|
+
return { ok: false };
|
|
15
|
+
return { ok: true, tenant: result.data };
|
|
16
|
+
}
|
|
17
|
+
function parseIntParam(value) {
|
|
18
|
+
if (value == null || value === "")
|
|
19
|
+
return undefined;
|
|
20
|
+
const n = Number.parseInt(value, 10);
|
|
21
|
+
return Number.isFinite(n) ? n : undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create admin credit API routes.
|
|
25
|
+
* Pass a ledger directly or a factory for lazy init.
|
|
26
|
+
*/
|
|
27
|
+
export function createAdminCreditApiRoutes(ledgerOrFactory, auditLogger) {
|
|
28
|
+
const ledgerFactory = typeof ledgerOrFactory === "function" ? ledgerOrFactory : () => ledgerOrFactory;
|
|
29
|
+
const routes = new Hono();
|
|
30
|
+
routes.post("/:tenantId/grant", async (c) => {
|
|
31
|
+
const ledger = ledgerFactory();
|
|
32
|
+
const parsed = parseTenantId(c);
|
|
33
|
+
if (!parsed.ok)
|
|
34
|
+
return c.json({ error: TENANT_ID_ERROR }, 400);
|
|
35
|
+
const tenant = parsed.tenant;
|
|
36
|
+
let body;
|
|
37
|
+
try {
|
|
38
|
+
body = (await c.req.json());
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
42
|
+
}
|
|
43
|
+
const amountCents = body.amount_cents;
|
|
44
|
+
const reason = body.reason;
|
|
45
|
+
if (typeof amountCents !== "number" || !Number.isInteger(amountCents) || amountCents <= 0) {
|
|
46
|
+
return c.json({ error: "amount_cents must be a positive integer" }, 400);
|
|
47
|
+
}
|
|
48
|
+
if (typeof reason !== "string" || !reason.trim()) {
|
|
49
|
+
return c.json({ error: "reason is required and must be non-empty" }, 400);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const user = c.get("user");
|
|
53
|
+
const adminUser = user?.id ?? "unknown";
|
|
54
|
+
let result;
|
|
55
|
+
try {
|
|
56
|
+
result = await ledger.credit(tenant, Credit.fromCents(amountCents), "admin_grant", reason, undefined, undefined, adminUser);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
safeAuditLog(auditLogger, {
|
|
60
|
+
adminUser,
|
|
61
|
+
action: "credits.grant",
|
|
62
|
+
category: "credits",
|
|
63
|
+
targetTenant: tenant,
|
|
64
|
+
details: { amount_cents: amountCents, reason, error: String(err) },
|
|
65
|
+
outcome: "failure",
|
|
66
|
+
});
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
safeAuditLog(auditLogger, {
|
|
70
|
+
adminUser,
|
|
71
|
+
action: "credits.grant",
|
|
72
|
+
category: "credits",
|
|
73
|
+
targetTenant: tenant,
|
|
74
|
+
details: { amount_cents: amountCents, reason },
|
|
75
|
+
outcome: "success",
|
|
76
|
+
});
|
|
77
|
+
return c.json(result, 201);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return c.json({ error: err instanceof Error ? err.message : "Internal server error" }, 500);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
routes.post("/:tenantId/refund", async (c) => {
|
|
84
|
+
const ledger = ledgerFactory();
|
|
85
|
+
const parsed = parseTenantId(c);
|
|
86
|
+
if (!parsed.ok)
|
|
87
|
+
return c.json({ error: TENANT_ID_ERROR }, 400);
|
|
88
|
+
const tenant = parsed.tenant;
|
|
89
|
+
let body;
|
|
90
|
+
try {
|
|
91
|
+
body = (await c.req.json());
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
95
|
+
}
|
|
96
|
+
const amountCents = body.amount_cents;
|
|
97
|
+
const reason = body.reason;
|
|
98
|
+
if (typeof amountCents !== "number" || !Number.isInteger(amountCents) || amountCents <= 0) {
|
|
99
|
+
return c.json({ error: "amount_cents must be a positive integer" }, 400);
|
|
100
|
+
}
|
|
101
|
+
if (typeof reason !== "string" || !reason.trim()) {
|
|
102
|
+
return c.json({ error: "reason is required and must be non-empty" }, 400);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const user = c.get("user");
|
|
106
|
+
const adminUser = user?.id ?? "unknown";
|
|
107
|
+
let result;
|
|
108
|
+
try {
|
|
109
|
+
result = await ledger.credit(tenant, Credit.fromCents(amountCents), "admin_grant", reason);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
safeAuditLog(auditLogger, {
|
|
113
|
+
adminUser,
|
|
114
|
+
action: "credits.refund",
|
|
115
|
+
category: "credits",
|
|
116
|
+
targetTenant: tenant,
|
|
117
|
+
details: { amount_cents: amountCents, reason, error: String(err) },
|
|
118
|
+
outcome: "failure",
|
|
119
|
+
});
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
safeAuditLog(auditLogger, {
|
|
123
|
+
adminUser,
|
|
124
|
+
action: "credits.refund",
|
|
125
|
+
category: "credits",
|
|
126
|
+
targetTenant: tenant,
|
|
127
|
+
details: { amount_cents: amountCents, reason },
|
|
128
|
+
outcome: "success",
|
|
129
|
+
});
|
|
130
|
+
return c.json(result, 201);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err instanceof InsufficientBalanceError) {
|
|
134
|
+
return c.json({ error: err.message, current_balance: err.currentBalance }, 400);
|
|
135
|
+
}
|
|
136
|
+
return c.json({ error: err instanceof Error ? err.message : "Internal server error" }, 500);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
routes.post("/:tenantId/correction", async (c) => {
|
|
140
|
+
const ledger = ledgerFactory();
|
|
141
|
+
const parsed = parseTenantId(c);
|
|
142
|
+
if (!parsed.ok)
|
|
143
|
+
return c.json({ error: TENANT_ID_ERROR }, 400);
|
|
144
|
+
const tenant = parsed.tenant;
|
|
145
|
+
let body;
|
|
146
|
+
try {
|
|
147
|
+
body = (await c.req.json());
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
151
|
+
}
|
|
152
|
+
const amountCents = body.amount_cents;
|
|
153
|
+
const reason = body.reason;
|
|
154
|
+
if (typeof amountCents !== "number" || !Number.isInteger(amountCents) || amountCents === 0) {
|
|
155
|
+
return c.json({ error: "amount_cents must be a non-zero integer" }, 400);
|
|
156
|
+
}
|
|
157
|
+
if (typeof reason !== "string" || !reason.trim()) {
|
|
158
|
+
return c.json({ error: "reason is required and must be non-empty" }, 400);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const user = c.get("user");
|
|
162
|
+
const adminUser = user?.id ?? "unknown";
|
|
163
|
+
let result;
|
|
164
|
+
try {
|
|
165
|
+
if (amountCents >= 0) {
|
|
166
|
+
result = await ledger.credit(tenant, Credit.fromCents(amountCents), "promo", reason);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
result = await ledger.debit(tenant, Credit.fromCents(Math.abs(amountCents)), "correction", reason);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
safeAuditLog(auditLogger, {
|
|
174
|
+
adminUser,
|
|
175
|
+
action: "credits.correction",
|
|
176
|
+
category: "credits",
|
|
177
|
+
targetTenant: tenant,
|
|
178
|
+
details: { amount_cents: amountCents, reason, error: String(err) },
|
|
179
|
+
outcome: "failure",
|
|
180
|
+
});
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
safeAuditLog(auditLogger, {
|
|
184
|
+
adminUser,
|
|
185
|
+
action: "credits.correction",
|
|
186
|
+
category: "credits",
|
|
187
|
+
targetTenant: tenant,
|
|
188
|
+
details: { amount_cents: amountCents, reason },
|
|
189
|
+
outcome: "success",
|
|
190
|
+
});
|
|
191
|
+
return c.json(result, 201);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
if (err instanceof InsufficientBalanceError) {
|
|
195
|
+
return c.json({ error: err.message, current_balance: err.currentBalance }, 400);
|
|
196
|
+
}
|
|
197
|
+
return c.json({ error: err instanceof Error ? err.message : "Internal server error" }, 500);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
routes.get("/:tenantId/balance", async (c) => {
|
|
201
|
+
const ledger = ledgerFactory();
|
|
202
|
+
const parsed = parseTenantId(c);
|
|
203
|
+
if (!parsed.ok)
|
|
204
|
+
return c.json({ error: TENANT_ID_ERROR }, 400);
|
|
205
|
+
const tenant = parsed.tenant;
|
|
206
|
+
try {
|
|
207
|
+
const balance = await ledger.balance(tenant);
|
|
208
|
+
return c.json({ tenant, balance_credits: balance });
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
routes.get("/:tenantId/transactions", async (c) => {
|
|
215
|
+
const ledger = ledgerFactory();
|
|
216
|
+
const parsed = parseTenantId(c);
|
|
217
|
+
if (!parsed.ok)
|
|
218
|
+
return c.json({ error: TENANT_ID_ERROR }, 400);
|
|
219
|
+
const tenant = parsed.tenant;
|
|
220
|
+
const typeParam = c.req.query("type");
|
|
221
|
+
const filters = {
|
|
222
|
+
type: typeParam,
|
|
223
|
+
limit: parseIntParam(c.req.query("limit")),
|
|
224
|
+
offset: parseIntParam(c.req.query("offset")),
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
const entries = await ledger.history(tenant, filters);
|
|
228
|
+
return c.json({ entries, total: entries.length });
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
routes.get("/:tenantId/adjustments", async (c) => {
|
|
235
|
+
const ledger = ledgerFactory();
|
|
236
|
+
const parsed = parseTenantId(c);
|
|
237
|
+
if (!parsed.ok)
|
|
238
|
+
return c.json({ error: TENANT_ID_ERROR }, 400);
|
|
239
|
+
const tenant = parsed.tenant;
|
|
240
|
+
const typeParam = c.req.query("type");
|
|
241
|
+
const filters = {
|
|
242
|
+
type: typeParam,
|
|
243
|
+
limit: parseIntParam(c.req.query("limit")),
|
|
244
|
+
offset: parseIntParam(c.req.query("offset")),
|
|
245
|
+
};
|
|
246
|
+
try {
|
|
247
|
+
const entries = await ledger.history(tenant, filters);
|
|
248
|
+
return c.json({ entries, total: entries.length });
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
return routes;
|
|
255
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
3
|
+
import type { AdminAuditLogger } from "./admin-audit-helper.js";
|
|
4
|
+
/** Minimal interface for GPU node repository. */
|
|
5
|
+
export interface IGpuNodeRepository {
|
|
6
|
+
list(): Promise<unknown[]>;
|
|
7
|
+
getById(nodeId: string): Promise<{
|
|
8
|
+
dropletId?: string | number | null;
|
|
9
|
+
status?: string;
|
|
10
|
+
} | null>;
|
|
11
|
+
}
|
|
12
|
+
/** Minimal interface for GPU node provisioner. */
|
|
13
|
+
export interface IGpuNodeProvisioner {
|
|
14
|
+
provision(opts: {
|
|
15
|
+
region?: string;
|
|
16
|
+
size?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
nodeId: string;
|
|
20
|
+
dropletId?: string | number | null;
|
|
21
|
+
region?: string;
|
|
22
|
+
size?: string;
|
|
23
|
+
monthlyCostCents?: number;
|
|
24
|
+
}>;
|
|
25
|
+
destroy(nodeId: string): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/** Minimal interface for DO API client. */
|
|
28
|
+
export interface IDOClient {
|
|
29
|
+
listRegions(): Promise<unknown[]>;
|
|
30
|
+
listSizes(): Promise<unknown[]>;
|
|
31
|
+
rebootDroplet(dropletId: number): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export interface AdminGpuDeps {
|
|
34
|
+
gpuNodeRepo: () => IGpuNodeRepository;
|
|
35
|
+
gpuNodeProvisioner: () => IGpuNodeProvisioner;
|
|
36
|
+
doClient: () => IDOClient;
|
|
37
|
+
auditLogger?: () => AdminAuditLogger;
|
|
38
|
+
logger?: {
|
|
39
|
+
error(msg: string, meta?: Record<string, unknown>): void;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Create admin GPU node management routes.
|
|
44
|
+
* Static routes (/regions, /sizes) are registered BEFORE parameterized routes (/:nodeId).
|
|
45
|
+
*/
|
|
46
|
+
export declare function createAdminGpuRoutes(deps: AdminGpuDeps): Hono<AuthEnv>;
|