@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,159 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
4
|
+
import { getCustomerTemplate, getInternalTemplate } from "../../monetization/incident/communication-templates.js";
|
|
5
|
+
import { getEscalationMatrix } from "../../monetization/incident/escalation.js";
|
|
6
|
+
import { generatePostMortemTemplate } from "../../monetization/incident/postmortem.js";
|
|
7
|
+
import { getResponseProcedure } from "../../monetization/incident/response-procedures.js";
|
|
8
|
+
import { classifySeverity } from "../../monetization/incident/severity.js";
|
|
9
|
+
|
|
10
|
+
const severitySchema = z.enum(["SEV1", "SEV2", "SEV3"]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create admin incident response routes.
|
|
14
|
+
*
|
|
15
|
+
* All imports come from platform-core's monetization/incident module.
|
|
16
|
+
* No external dependencies needed.
|
|
17
|
+
*/
|
|
18
|
+
export function createIncidentResponseRoutes(): Hono<AuthEnv> {
|
|
19
|
+
const routes = new Hono<AuthEnv>();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* POST /severity
|
|
23
|
+
* Classify severity from provided signals.
|
|
24
|
+
*/
|
|
25
|
+
routes.post("/severity", async (c) => {
|
|
26
|
+
try {
|
|
27
|
+
const body = await c.req.json();
|
|
28
|
+
const parsed = z
|
|
29
|
+
.object({
|
|
30
|
+
stripeReachable: z.boolean(),
|
|
31
|
+
webhooksReceiving: z.boolean().nullable(),
|
|
32
|
+
gatewayErrorRate: z.number(),
|
|
33
|
+
creditDeductionFailures: z.number(),
|
|
34
|
+
dlqDepth: z.number(),
|
|
35
|
+
tenantsWithNegativeBalance: z.number(),
|
|
36
|
+
autoTopupFailures: z.number(),
|
|
37
|
+
firingAlertCount: z.number(),
|
|
38
|
+
})
|
|
39
|
+
.parse(body);
|
|
40
|
+
|
|
41
|
+
const result = classifySeverity(parsed);
|
|
42
|
+
return c.json({ success: true, ...result });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err instanceof z.ZodError) {
|
|
45
|
+
return c.json({ success: false, error: "Invalid signals payload", details: err.issues }, 400);
|
|
46
|
+
}
|
|
47
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* GET /escalation/:severity
|
|
53
|
+
* Get escalation matrix for a severity level.
|
|
54
|
+
*/
|
|
55
|
+
routes.get("/escalation/:severity", async (c) => {
|
|
56
|
+
const rawSeverity = c.req.param("severity");
|
|
57
|
+
const parsed = severitySchema.safeParse(rawSeverity);
|
|
58
|
+
if (!parsed.success) {
|
|
59
|
+
return c.json({ success: false, error: "Invalid severity. Must be SEV1, SEV2, or SEV3" }, 400);
|
|
60
|
+
}
|
|
61
|
+
const contacts = getEscalationMatrix(parsed.data);
|
|
62
|
+
return c.json({ success: true, severity: parsed.data, contacts });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* GET /procedure/:severity
|
|
67
|
+
* Get response procedure for a severity level.
|
|
68
|
+
*/
|
|
69
|
+
routes.get("/procedure/:severity", async (c) => {
|
|
70
|
+
const rawSeverity = c.req.param("severity");
|
|
71
|
+
const parsed = severitySchema.safeParse(rawSeverity);
|
|
72
|
+
if (!parsed.success) {
|
|
73
|
+
return c.json({ success: false, error: "Invalid severity. Must be SEV1, SEV2, or SEV3" }, 400);
|
|
74
|
+
}
|
|
75
|
+
const procedure = getResponseProcedure(parsed.data);
|
|
76
|
+
return c.json({ success: true, procedure });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* POST /communicate
|
|
81
|
+
* Generate communication templates for an incident.
|
|
82
|
+
*/
|
|
83
|
+
routes.post("/communicate", async (c) => {
|
|
84
|
+
try {
|
|
85
|
+
const body = await c.req.json();
|
|
86
|
+
const parsed = z
|
|
87
|
+
.object({
|
|
88
|
+
severity: severitySchema,
|
|
89
|
+
incidentId: z.string().min(1),
|
|
90
|
+
startedAt: z.string().datetime(),
|
|
91
|
+
affectedSystems: z.array(z.string()),
|
|
92
|
+
customerImpact: z.string(),
|
|
93
|
+
currentStatus: z.string(),
|
|
94
|
+
})
|
|
95
|
+
.parse(body);
|
|
96
|
+
|
|
97
|
+
const context = {
|
|
98
|
+
incidentId: parsed.incidentId,
|
|
99
|
+
startedAt: new Date(parsed.startedAt),
|
|
100
|
+
affectedSystems: parsed.affectedSystems,
|
|
101
|
+
customerImpact: parsed.customerImpact,
|
|
102
|
+
currentStatus: parsed.currentStatus,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const customer = getCustomerTemplate(parsed.severity, context);
|
|
106
|
+
const internal = getInternalTemplate(parsed.severity, context);
|
|
107
|
+
return c.json({ success: true, templates: { customer, internal } });
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err instanceof z.ZodError) {
|
|
110
|
+
return c.json({ success: false, error: "Invalid payload", details: err.issues }, 400);
|
|
111
|
+
}
|
|
112
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* POST /postmortem
|
|
118
|
+
* Generate a post-mortem template for an incident.
|
|
119
|
+
*/
|
|
120
|
+
routes.post("/postmortem", async (c) => {
|
|
121
|
+
try {
|
|
122
|
+
const body = await c.req.json();
|
|
123
|
+
const parsed = z
|
|
124
|
+
.object({
|
|
125
|
+
incidentId: z.string().min(1),
|
|
126
|
+
severity: severitySchema,
|
|
127
|
+
title: z.string().min(1),
|
|
128
|
+
startedAt: z.string().datetime(),
|
|
129
|
+
detectedAt: z.string().datetime(),
|
|
130
|
+
resolvedAt: z.string().datetime().nullable(),
|
|
131
|
+
affectedSystems: z.array(z.string()),
|
|
132
|
+
affectedTenantCount: z.number().int().min(0),
|
|
133
|
+
revenueImpactCents: z.number().int().nullable(),
|
|
134
|
+
})
|
|
135
|
+
.parse(body);
|
|
136
|
+
|
|
137
|
+
const report = generatePostMortemTemplate({
|
|
138
|
+
incidentId: parsed.incidentId,
|
|
139
|
+
severity: parsed.severity,
|
|
140
|
+
title: parsed.title,
|
|
141
|
+
startedAt: new Date(parsed.startedAt),
|
|
142
|
+
detectedAt: new Date(parsed.detectedAt),
|
|
143
|
+
resolvedAt: parsed.resolvedAt ? new Date(parsed.resolvedAt) : null,
|
|
144
|
+
affectedSystems: parsed.affectedSystems,
|
|
145
|
+
affectedTenantCount: parsed.affectedTenantCount,
|
|
146
|
+
revenueImpactCents: parsed.revenueImpactCents,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return c.json({ success: true, report });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err instanceof z.ZodError) {
|
|
152
|
+
return c.json({ success: false, error: "Invalid payload", details: err.issues }, 400);
|
|
153
|
+
}
|
|
154
|
+
return c.json({ success: false, error: err instanceof Error ? err.message : "Unknown error" }, 500);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return routes;
|
|
159
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { logger } from "../../config/logger.js";
|
|
4
|
+
|
|
5
|
+
const VALID_STAGES = [
|
|
6
|
+
"installing_drivers",
|
|
7
|
+
"installing_docker",
|
|
8
|
+
"downloading_models",
|
|
9
|
+
"starting_services",
|
|
10
|
+
"registering",
|
|
11
|
+
"done",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
type ProvisionStage = (typeof VALID_STAGES)[number];
|
|
15
|
+
|
|
16
|
+
export interface GpuNodeStageUpdater {
|
|
17
|
+
updateStage(nodeId: string, stage: string): Promise<void>;
|
|
18
|
+
updateStatus(nodeId: string, status: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create internal GPU routes for node provisioning status updates.
|
|
23
|
+
*
|
|
24
|
+
* @param gpuSecretFactory - returns the GPU_NODE_SECRET (lazy to avoid env read at load time)
|
|
25
|
+
* @param repoFactory - returns the GPU node repository
|
|
26
|
+
*/
|
|
27
|
+
export function createInternalGpuRoutes(
|
|
28
|
+
gpuSecretFactory: () => string | undefined,
|
|
29
|
+
repoFactory: () => GpuNodeStageUpdater,
|
|
30
|
+
): Hono {
|
|
31
|
+
const routes = new Hono();
|
|
32
|
+
|
|
33
|
+
routes.post("/register", async (c) => {
|
|
34
|
+
const gpuSecret = gpuSecretFactory();
|
|
35
|
+
if (!gpuSecret) {
|
|
36
|
+
logger.warn("GPU_NODE_SECRET not configured");
|
|
37
|
+
return c.json({ success: false, error: "Unauthorized" }, 401);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const authHeader = c.req.header("Authorization");
|
|
41
|
+
const bearer = authHeader?.replace(/^Bearer\s+/i, "");
|
|
42
|
+
if (!bearer) {
|
|
43
|
+
return c.json({ success: false, error: "Unauthorized" }, 401);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const a = Buffer.from(bearer);
|
|
47
|
+
const b = Buffer.from(gpuSecret);
|
|
48
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
49
|
+
return c.json({ success: false, error: "Unauthorized" }, 401);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const stage = c.req.query("stage") as ProvisionStage | undefined;
|
|
53
|
+
if (!stage || !VALID_STAGES.includes(stage)) {
|
|
54
|
+
return c.json({ success: false, error: `Invalid or missing stage. Valid: ${VALID_STAGES.join(", ")}` }, 400);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let rawBody: unknown;
|
|
58
|
+
try {
|
|
59
|
+
rawBody = await c.req.json();
|
|
60
|
+
} catch {
|
|
61
|
+
return c.json({ success: false, error: "Invalid JSON body" }, 400);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
typeof rawBody !== "object" ||
|
|
66
|
+
rawBody === null ||
|
|
67
|
+
typeof (rawBody as Record<string, unknown>).nodeId !== "string"
|
|
68
|
+
) {
|
|
69
|
+
return c.json({ success: false, error: "Missing required field: nodeId" }, 400);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { nodeId } = rawBody as { nodeId: string };
|
|
73
|
+
|
|
74
|
+
const repo = repoFactory();
|
|
75
|
+
try {
|
|
76
|
+
await repo.updateStage(nodeId, stage);
|
|
77
|
+
if (stage === "done") {
|
|
78
|
+
await repo.updateStatus(nodeId, "active");
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err instanceof Error && err.message.includes("not found")) {
|
|
82
|
+
return c.json({ success: false, error: `GPU node not found: ${nodeId}` }, 404);
|
|
83
|
+
}
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
logger.info(`GPU node ${nodeId} stage updated to ${stage}`);
|
|
88
|
+
return c.json({ success: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return routes;
|
|
92
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { NodeRegistration } from "../../fleet/repository-types.js";
|
|
5
|
+
|
|
6
|
+
const RegisterNodeSchema = z.object({
|
|
7
|
+
node_id: z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(128)
|
|
11
|
+
.regex(/^[a-zA-Z0-9_-]+$/),
|
|
12
|
+
host: z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(253)
|
|
16
|
+
.regex(/^[a-zA-Z0-9._-]+$/),
|
|
17
|
+
capacity_mb: z.number().int().positive().max(1_048_576),
|
|
18
|
+
agent_version: z.string().min(1).max(32),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// ── Minimal interfaces for injectable deps ──
|
|
22
|
+
|
|
23
|
+
export interface INodeRegistrar {
|
|
24
|
+
register(registration: NodeRegistration): Promise<unknown>;
|
|
25
|
+
registerSelfHosted(
|
|
26
|
+
registration: NodeRegistration & {
|
|
27
|
+
ownerUserId: string;
|
|
28
|
+
label: string | null;
|
|
29
|
+
nodeSecretHash: string;
|
|
30
|
+
},
|
|
31
|
+
): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface INodeRepoForRegistration {
|
|
35
|
+
getBySecret(secret: string): Promise<{ id: string } | null>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IRegistrationTokenStore {
|
|
39
|
+
consume(token: string, nodeId: string): Promise<{ userId: string; label: string | null } | null>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type HostValidator = (host: string) => void;
|
|
43
|
+
|
|
44
|
+
export interface InternalNodeDeps {
|
|
45
|
+
nodeRegistrar: () => INodeRegistrar;
|
|
46
|
+
nodeRepo: () => INodeRepoForRegistration;
|
|
47
|
+
registrationTokenStore: () => IRegistrationTokenStore;
|
|
48
|
+
validateNodeHost: HostValidator;
|
|
49
|
+
logger?: { info(msg: string): void };
|
|
50
|
+
/** Prefix for self-hosted node IDs. Default: "self" */
|
|
51
|
+
nodeIdPrefix?: string;
|
|
52
|
+
/** Prefix for node secrets. Default: "wopr_node_" */
|
|
53
|
+
nodeSecretPrefix?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create internal node registration routes.
|
|
58
|
+
*
|
|
59
|
+
* These are machine-to-machine routes used by node agents, not dashboard UI.
|
|
60
|
+
*/
|
|
61
|
+
export function createInternalNodeRoutes(deps: InternalNodeDeps): Hono {
|
|
62
|
+
const routes = new Hono();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* POST /register
|
|
66
|
+
* Node registration (called on agent boot).
|
|
67
|
+
*
|
|
68
|
+
* Supports 2 auth paths:
|
|
69
|
+
* 1. Per-node persistent secret (returning self-hosted agent)
|
|
70
|
+
* 2. One-time registration token (new self-hosted node, UUID format)
|
|
71
|
+
*/
|
|
72
|
+
routes.post("/register", async (c) => {
|
|
73
|
+
const authHeader = c.req.header("Authorization");
|
|
74
|
+
const bearer = authHeader?.replace(/^Bearer\s+/i, "");
|
|
75
|
+
|
|
76
|
+
if (!bearer) {
|
|
77
|
+
return c.json({ success: false, error: "Unauthorized" }, 401);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let rawBody: unknown;
|
|
81
|
+
try {
|
|
82
|
+
rawBody = await c.req.json();
|
|
83
|
+
} catch {
|
|
84
|
+
return c.json({ success: false, error: "Invalid registration data" }, 400);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parsed = RegisterNodeSchema.safeParse(rawBody);
|
|
88
|
+
if (!parsed.success) {
|
|
89
|
+
return c.json({ success: false, error: "Invalid registration data", details: parsed.error.flatten() }, 400);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const body = parsed.data;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
deps.validateNodeHost(body.host);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return c.json({ success: false, error: (err as Error).message }, 400);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const registrar = deps.nodeRegistrar();
|
|
101
|
+
const nodeRepo = deps.nodeRepo();
|
|
102
|
+
|
|
103
|
+
// Map snake_case HTTP body to camelCase domain type
|
|
104
|
+
const registration: NodeRegistration = {
|
|
105
|
+
nodeId: body.node_id,
|
|
106
|
+
host: body.host,
|
|
107
|
+
capacityMb: body.capacity_mb,
|
|
108
|
+
agentVersion: body.agent_version,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Path 1: Per-node persistent secret (returning agent)
|
|
112
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
113
|
+
if (!uuidPattern.test(bearer)) {
|
|
114
|
+
const existingNode = await nodeRepo.getBySecret(bearer);
|
|
115
|
+
if (existingNode) {
|
|
116
|
+
await registrar.register({ ...registration, nodeId: existingNode.id });
|
|
117
|
+
deps.logger?.info(`Node re-registered via per-node secret: ${existingNode.id}`);
|
|
118
|
+
return c.json({ success: true });
|
|
119
|
+
}
|
|
120
|
+
return c.json({ success: false, error: "Unauthorized" }, 401);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Path 2: One-time registration token (UUID format = registration token)
|
|
124
|
+
const tokenStore = deps.registrationTokenStore();
|
|
125
|
+
const prefix = deps.nodeIdPrefix ?? "self";
|
|
126
|
+
const nodeId = `${prefix}-${randomUUID().slice(0, 8)}`;
|
|
127
|
+
const consumed = await tokenStore.consume(bearer, nodeId);
|
|
128
|
+
|
|
129
|
+
if (!consumed) {
|
|
130
|
+
return c.json({ success: false, error: "Invalid or expired token" }, 401);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Generate persistent per-node secret
|
|
134
|
+
const secretPrefix = deps.nodeSecretPrefix ?? "wopr_node_";
|
|
135
|
+
const nodeSecret = `${secretPrefix}${randomUUID().replace(/-/g, "")}`;
|
|
136
|
+
const hashedSecret = createHash("sha256").update(nodeSecret).digest("hex");
|
|
137
|
+
|
|
138
|
+
// Register self-hosted node via registrar
|
|
139
|
+
await registrar.registerSelfHosted({
|
|
140
|
+
...registration,
|
|
141
|
+
nodeId,
|
|
142
|
+
ownerUserId: consumed.userId,
|
|
143
|
+
label: consumed.label,
|
|
144
|
+
nodeSecretHash: hashedSecret,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
deps.logger?.info(`Self-hosted node registered: ${nodeId} for user ${consumed.userId}`);
|
|
148
|
+
|
|
149
|
+
return c.json({
|
|
150
|
+
success: true,
|
|
151
|
+
node_id: nodeId,
|
|
152
|
+
node_secret: nodeSecret, // Agent saves this — only returned once
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return routes;
|
|
157
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
3
|
+
import type { ILoginHistoryRepository } from "../../auth/login-history-repository.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create login history routes.
|
|
7
|
+
*
|
|
8
|
+
* Returns recent login sessions for the authenticated user.
|
|
9
|
+
* Query params:
|
|
10
|
+
* - limit: max results (default 20, max 100)
|
|
11
|
+
*/
|
|
12
|
+
export function createLoginHistoryRoutes(repoFactory: () => ILoginHistoryRepository): Hono<AuthEnv> {
|
|
13
|
+
const routes = new Hono<AuthEnv>();
|
|
14
|
+
|
|
15
|
+
routes.get("/", async (c) => {
|
|
16
|
+
const user = c.get("user");
|
|
17
|
+
if (!user) return c.json({ error: "Unauthorized" }, 401);
|
|
18
|
+
|
|
19
|
+
const limitRaw = c.req.query("limit");
|
|
20
|
+
const limit = limitRaw ? Math.min(Math.max(1, Number.parseInt(limitRaw, 10) || 20), 100) : 20;
|
|
21
|
+
|
|
22
|
+
const repo = repoFactory();
|
|
23
|
+
const entries = await repo.findByUserId(user.id, limit);
|
|
24
|
+
return c.json(entries);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return routes;
|
|
28
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { RateStore } from "../../admin/rates/rate-store.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create public pricing routes.
|
|
6
|
+
*
|
|
7
|
+
* Public, unauthenticated endpoint returning active sell rates grouped by capability.
|
|
8
|
+
* Used by pricing pages to display current rates.
|
|
9
|
+
*/
|
|
10
|
+
export function createPublicPricingRoutes(storeFactory: () => RateStore): Hono {
|
|
11
|
+
const routes = new Hono();
|
|
12
|
+
|
|
13
|
+
routes.get("/", async (c) => {
|
|
14
|
+
try {
|
|
15
|
+
const store = storeFactory();
|
|
16
|
+
const rates = await store.listPublicRates();
|
|
17
|
+
|
|
18
|
+
// Group by capability for the UI
|
|
19
|
+
const grouped: Record<string, Array<{ name: string; unit: string; price: number }>> = {};
|
|
20
|
+
for (const rate of rates) {
|
|
21
|
+
if (!grouped[rate.capability]) grouped[rate.capability] = [];
|
|
22
|
+
grouped[rate.capability].push({
|
|
23
|
+
name: rate.display_name,
|
|
24
|
+
unit: rate.unit,
|
|
25
|
+
price: rate.price_usd,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return c.json({ rates: grouped });
|
|
30
|
+
} catch {
|
|
31
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return routes;
|
|
36
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { ICreditLedger } from "../../credits/credit-ledger.js";
|
|
3
|
+
import { checkInstanceQuota, DEFAULT_INSTANCE_LIMITS } from "../../monetization/quotas/quota-check.js";
|
|
4
|
+
import { buildResourceLimits, DEFAULT_RESOURCE_CONFIG } from "../../monetization/quotas/resource-limits.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create quota routes.
|
|
8
|
+
*
|
|
9
|
+
* @param ledgerFactory - Factory returning the credit ledger
|
|
10
|
+
*/
|
|
11
|
+
export function createQuotaRoutes(ledgerFactory: () => ICreditLedger): Hono {
|
|
12
|
+
const routes = new Hono();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* GET /
|
|
16
|
+
*
|
|
17
|
+
* Returns the authenticated tenant's credit balance and resource limits.
|
|
18
|
+
*/
|
|
19
|
+
routes.get("/", async (c) => {
|
|
20
|
+
const tenantId = c.req.query("tenant");
|
|
21
|
+
if (!tenantId) {
|
|
22
|
+
return c.json({ error: "tenant query param is required" }, 400);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const activeRaw = c.req.query("activeInstances");
|
|
26
|
+
const activeInstances = activeRaw != null ? Number.parseInt(activeRaw, 10) : 0;
|
|
27
|
+
|
|
28
|
+
if (Number.isNaN(activeInstances) || activeInstances < 0) {
|
|
29
|
+
return c.json({ error: "Invalid activeInstances parameter" }, 400);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const balance = await ledgerFactory().balance(tenantId);
|
|
33
|
+
|
|
34
|
+
return c.json({
|
|
35
|
+
balanceCents: balance.toCentsRounded(),
|
|
36
|
+
instances: {
|
|
37
|
+
current: activeInstances,
|
|
38
|
+
max: DEFAULT_INSTANCE_LIMITS.maxInstances,
|
|
39
|
+
remaining:
|
|
40
|
+
DEFAULT_INSTANCE_LIMITS.maxInstances === 0
|
|
41
|
+
? -1
|
|
42
|
+
: Math.max(0, DEFAULT_INSTANCE_LIMITS.maxInstances - activeInstances),
|
|
43
|
+
},
|
|
44
|
+
resources: DEFAULT_RESOURCE_CONFIG,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* POST /check
|
|
50
|
+
*
|
|
51
|
+
* Check whether an instance creation would be allowed.
|
|
52
|
+
*/
|
|
53
|
+
routes.post("/check", async (c) => {
|
|
54
|
+
let body: Record<string, unknown>;
|
|
55
|
+
try {
|
|
56
|
+
body = (await c.req.json()) as Record<string, unknown>;
|
|
57
|
+
} catch {
|
|
58
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tenantId = body.tenant as string;
|
|
62
|
+
if (!tenantId) {
|
|
63
|
+
return c.json({ error: "tenant is required" }, 400);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const activeInstances = Number(body.activeInstances ?? 0);
|
|
67
|
+
const softCap = Boolean(body.softCap);
|
|
68
|
+
|
|
69
|
+
if (Number.isNaN(activeInstances) || activeInstances < 0) {
|
|
70
|
+
return c.json({ error: "Invalid activeInstances" }, 400);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check credit balance
|
|
74
|
+
const balance = await ledgerFactory().balance(tenantId);
|
|
75
|
+
if (balance.isNegative() || balance.isZero()) {
|
|
76
|
+
return c.json(
|
|
77
|
+
{
|
|
78
|
+
allowed: false,
|
|
79
|
+
reason: "Insufficient credit balance",
|
|
80
|
+
currentBalanceCents: balance.toCentsRounded(),
|
|
81
|
+
purchaseUrl: "/settings/billing",
|
|
82
|
+
},
|
|
83
|
+
402,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = checkInstanceQuota(DEFAULT_INSTANCE_LIMITS, activeInstances, {
|
|
88
|
+
softCapEnabled: softCap,
|
|
89
|
+
gracePeriodMs: 7 * 24 * 60 * 60 * 1000,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const status = result.allowed ? 200 : 403;
|
|
93
|
+
return c.json(result, status);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* GET /balance/:tenant
|
|
98
|
+
*
|
|
99
|
+
* Get a tenant's credit balance.
|
|
100
|
+
*/
|
|
101
|
+
routes.get("/balance/:tenant", async (c) => {
|
|
102
|
+
const tenantId = c.req.param("tenant");
|
|
103
|
+
const balance = await ledgerFactory().balance(tenantId);
|
|
104
|
+
return c.json({ tenantId, balanceCents: balance.toCentsRounded() });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* GET /history/:tenant
|
|
109
|
+
*
|
|
110
|
+
* Get a tenant's credit transaction history.
|
|
111
|
+
*/
|
|
112
|
+
routes.get("/history/:tenant", async (c) => {
|
|
113
|
+
const tenantId = c.req.param("tenant");
|
|
114
|
+
const limitRaw = c.req.query("limit");
|
|
115
|
+
const offsetRaw = c.req.query("offset");
|
|
116
|
+
const type = c.req.query("type");
|
|
117
|
+
|
|
118
|
+
const limit = limitRaw != null ? Number.parseInt(limitRaw, 10) : 50;
|
|
119
|
+
const offset = offsetRaw != null ? Number.parseInt(offsetRaw, 10) : 0;
|
|
120
|
+
|
|
121
|
+
const transactions = await ledgerFactory().history(tenantId, { limit, offset, type: type || undefined });
|
|
122
|
+
return c.json({ transactions });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* GET /resource-limits
|
|
127
|
+
*
|
|
128
|
+
* Get default Docker resource constraints for bot containers.
|
|
129
|
+
*/
|
|
130
|
+
routes.get("/resource-limits", (c) => {
|
|
131
|
+
const limits = buildResourceLimits();
|
|
132
|
+
return c.json(limits);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return routes;
|
|
136
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AuditEnv } from "../../audit/types.js";
|
|
3
|
+
import type { CredentialSummaryRow, ISecretAuditRepository } from "../../security/index.js";
|
|
4
|
+
|
|
5
|
+
type GetCredentialOwner = (id: string) => Promise<CredentialSummaryRow | null>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create secret audit routes.
|
|
9
|
+
*
|
|
10
|
+
* @param getRepo - factory for the secret audit repository (lazy init)
|
|
11
|
+
* @param getCredentialOwner - lookup function to find credential and check ownership
|
|
12
|
+
*/
|
|
13
|
+
export function createSecretAuditRoutes(
|
|
14
|
+
getRepo: () => ISecretAuditRepository,
|
|
15
|
+
getCredentialOwner: GetCredentialOwner,
|
|
16
|
+
): Hono<AuditEnv> {
|
|
17
|
+
const routes = new Hono<AuditEnv>();
|
|
18
|
+
|
|
19
|
+
routes.get("/:id/audit", async (c) => {
|
|
20
|
+
const user = c.get("user");
|
|
21
|
+
if (!user) return c.json({ error: "Unauthorized" }, 401);
|
|
22
|
+
|
|
23
|
+
const credentialId = c.req.param("id");
|
|
24
|
+
|
|
25
|
+
// Verify credential exists and is owned by this user
|
|
26
|
+
const credential = await getCredentialOwner(credentialId);
|
|
27
|
+
if (!credential || credential.createdBy !== user.id) {
|
|
28
|
+
return c.json({ error: "Not found" }, 404);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const limitRaw = c.req.query("limit") ? Number(c.req.query("limit")) : 50;
|
|
32
|
+
const offsetRaw = c.req.query("offset") ? Number(c.req.query("offset")) : 0;
|
|
33
|
+
const limit = Number.isFinite(limitRaw) ? limitRaw : 50;
|
|
34
|
+
const offset = Number.isFinite(offsetRaw) ? offsetRaw : 0;
|
|
35
|
+
|
|
36
|
+
const repo = getRepo();
|
|
37
|
+
const [events, total] = await Promise.all([
|
|
38
|
+
repo.listByCredentialId(credentialId, { limit, offset }),
|
|
39
|
+
repo.countByCredentialId(credentialId),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
return c.json({
|
|
43
|
+
events: events.map((e) => ({
|
|
44
|
+
id: e.id,
|
|
45
|
+
accessedAt: new Date(e.accessedAt).toISOString(),
|
|
46
|
+
accessedBy: e.accessedBy,
|
|
47
|
+
action: e.action,
|
|
48
|
+
...(e.ip !== null ? { ip: e.ip } : {}),
|
|
49
|
+
})),
|
|
50
|
+
total,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return routes;
|
|
55
|
+
}
|