@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,25 @@
|
|
|
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
|
+
/**
|
|
14
|
+
* Create auth routes that delegate to better-auth's handler.
|
|
15
|
+
*
|
|
16
|
+
* @param auth - The better-auth instance (use `getAuth()`)
|
|
17
|
+
*/
|
|
18
|
+
export function createAuthRoutes(auth) {
|
|
19
|
+
const routes = new Hono();
|
|
20
|
+
routes.all("/*", async (c) => {
|
|
21
|
+
const response = await auth.handler(c.req.raw);
|
|
22
|
+
return response;
|
|
23
|
+
});
|
|
24
|
+
return routes;
|
|
25
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
3
|
+
export interface ChannelValidateLogger {
|
|
4
|
+
info(msg: string, meta?: Record<string, unknown>): void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Create channel validation routes.
|
|
8
|
+
*
|
|
9
|
+
* @param logger - Optional logger for validation events
|
|
10
|
+
*/
|
|
11
|
+
export declare function createChannelValidateRoutes(logger?: ChannelValidateLogger): Hono<AuthEnv>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const channelValidateSchema = z.object({
|
|
4
|
+
credentials: z.record(z.string(), z.string()),
|
|
5
|
+
});
|
|
6
|
+
/**
|
|
7
|
+
* Create channel validation routes.
|
|
8
|
+
*
|
|
9
|
+
* @param logger - Optional logger for validation events
|
|
10
|
+
*/
|
|
11
|
+
export function createChannelValidateRoutes(logger) {
|
|
12
|
+
const routes = new Hono();
|
|
13
|
+
/**
|
|
14
|
+
* POST /:pluginId/test
|
|
15
|
+
*
|
|
16
|
+
* Validates channel credentials by calling the channel's API.
|
|
17
|
+
* Returns { success: boolean, error?: string }.
|
|
18
|
+
*
|
|
19
|
+
* SECURITY: Credentials are used for a single API probe and then discarded.
|
|
20
|
+
* They are NEVER logged, NEVER persisted, NEVER returned in the response.
|
|
21
|
+
*/
|
|
22
|
+
routes.post("/:pluginId/test", async (c) => {
|
|
23
|
+
let user;
|
|
24
|
+
try {
|
|
25
|
+
user = c.get("user");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// not set
|
|
29
|
+
}
|
|
30
|
+
if (!user) {
|
|
31
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
32
|
+
}
|
|
33
|
+
const pluginId = c.req.param("pluginId");
|
|
34
|
+
let body;
|
|
35
|
+
try {
|
|
36
|
+
body = await c.req.json();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
40
|
+
}
|
|
41
|
+
const parsed = channelValidateSchema.safeParse(body);
|
|
42
|
+
if (!parsed.success) {
|
|
43
|
+
return c.json({ error: "Invalid input", details: parsed.error.flatten().fieldErrors }, 400);
|
|
44
|
+
}
|
|
45
|
+
const { credentials } = parsed.data;
|
|
46
|
+
const result = await validateChannel(pluginId, credentials);
|
|
47
|
+
logger?.info("Channel credential validation", {
|
|
48
|
+
pluginId,
|
|
49
|
+
userId: user.id,
|
|
50
|
+
success: result.success,
|
|
51
|
+
// NEVER log credential values
|
|
52
|
+
});
|
|
53
|
+
return c.json(result);
|
|
54
|
+
});
|
|
55
|
+
return routes;
|
|
56
|
+
}
|
|
57
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
58
|
+
async function validateChannel(pluginId, credentials) {
|
|
59
|
+
switch (pluginId) {
|
|
60
|
+
case "discord":
|
|
61
|
+
return validateDiscord(credentials);
|
|
62
|
+
case "telegram":
|
|
63
|
+
return validateTelegram(credentials);
|
|
64
|
+
case "slack":
|
|
65
|
+
return validateSlack(credentials);
|
|
66
|
+
// Signal, WhatsApp, MS Teams: no simple API probe available.
|
|
67
|
+
// Format validation is handled client-side; server returns success.
|
|
68
|
+
default:
|
|
69
|
+
return { success: true };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function validateDiscord(credentials) {
|
|
73
|
+
const token = credentials.discord_bot_token;
|
|
74
|
+
if (!token) {
|
|
75
|
+
return { success: false, error: "Discord bot token is required" };
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch("https://discord.com/api/v10/users/@me", {
|
|
79
|
+
headers: { Authorization: `Bot ${token}` },
|
|
80
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
81
|
+
});
|
|
82
|
+
if (res.ok) {
|
|
83
|
+
return { success: true };
|
|
84
|
+
}
|
|
85
|
+
if (res.status === 401) {
|
|
86
|
+
return { success: false, error: "Invalid Discord bot token" };
|
|
87
|
+
}
|
|
88
|
+
return { success: false, error: `Discord API returned ${res.status}` };
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
return handleFetchError(err, "Discord");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function validateTelegram(credentials) {
|
|
95
|
+
const token = credentials.telegram_bot_token;
|
|
96
|
+
if (!token) {
|
|
97
|
+
return { success: false, error: "Telegram bot token is required" };
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, {
|
|
101
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
return { success: false, error: "Invalid Telegram bot token" };
|
|
105
|
+
}
|
|
106
|
+
const data = (await res.json());
|
|
107
|
+
if (data.ok) {
|
|
108
|
+
return { success: true };
|
|
109
|
+
}
|
|
110
|
+
return { success: false, error: "Invalid Telegram bot token" };
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
return handleFetchError(err, "Telegram");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function validateSlack(credentials) {
|
|
117
|
+
const token = credentials.slack_bot_token;
|
|
118
|
+
if (!token) {
|
|
119
|
+
return { success: false, error: "Slack bot token is required" };
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch("https://slack.com/api/auth.test", {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: {
|
|
125
|
+
Authorization: `Bearer ${token}`,
|
|
126
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
127
|
+
},
|
|
128
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
129
|
+
});
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
return { success: false, error: `Slack API returned ${res.status}` };
|
|
132
|
+
}
|
|
133
|
+
const data = (await res.json());
|
|
134
|
+
if (data.ok) {
|
|
135
|
+
return { success: true };
|
|
136
|
+
}
|
|
137
|
+
return { success: false, error: `Slack error: ${data.error || "invalid_auth"}` };
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
return handleFetchError(err, "Slack");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function handleFetchError(err, provider) {
|
|
144
|
+
if (err instanceof DOMException && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
145
|
+
return { success: false, error: `${provider} API request timed out. Please try again.` };
|
|
146
|
+
}
|
|
147
|
+
return { success: false, error: `Could not reach ${provider}. Check your connection.` };
|
|
148
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { logger } from "../../config/logger.js";
|
|
3
|
+
export function createFleetEventsRoute(emitter) {
|
|
4
|
+
const routes = new Hono();
|
|
5
|
+
routes.get("/", (c) => {
|
|
6
|
+
const user = c.get("user");
|
|
7
|
+
if (!user) {
|
|
8
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
9
|
+
}
|
|
10
|
+
// Derive tenantId exclusively from the authenticated session — never from
|
|
11
|
+
// caller-supplied query params, which would allow IDOR (any user subscribing
|
|
12
|
+
// to another tenant's events).
|
|
13
|
+
const tenantId = user.id;
|
|
14
|
+
const { readable, writable } = new TransformStream();
|
|
15
|
+
const writer = writable.getWriter();
|
|
16
|
+
const unsub = emitter.subscribe((event) => {
|
|
17
|
+
// BotFleetEvents are tenant-scoped; NodeFleetEvents are forwarded to all authenticated subscribers.
|
|
18
|
+
if ("tenantId" in event && event.tenantId !== tenantId)
|
|
19
|
+
return;
|
|
20
|
+
const payload = "tenantId" in event
|
|
21
|
+
? JSON.stringify({ type: event.type, botId: event.botId, timestamp: event.timestamp })
|
|
22
|
+
: JSON.stringify({ type: event.type, nodeId: event.nodeId, timestamp: event.timestamp });
|
|
23
|
+
writer.write(`event: fleet\ndata: ${payload}\n\n`).catch(() => {
|
|
24
|
+
unsub();
|
|
25
|
+
clearInterval(heartbeatTimer);
|
|
26
|
+
logger.debug("Fleet SSE write error (client disconnected)");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
const heartbeatTimer = setInterval(() => {
|
|
30
|
+
writer.write(": heartbeat\n\n").catch(() => {
|
|
31
|
+
clearInterval(heartbeatTimer);
|
|
32
|
+
unsub();
|
|
33
|
+
});
|
|
34
|
+
}, 30_000);
|
|
35
|
+
const signal = c.req.raw.signal;
|
|
36
|
+
if (signal) {
|
|
37
|
+
signal.addEventListener("abort", () => {
|
|
38
|
+
clearInterval(heartbeatTimer);
|
|
39
|
+
unsub();
|
|
40
|
+
writer.close().catch(() => { });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return new Response(readable, {
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": "text/event-stream",
|
|
46
|
+
"Cache-Control": "no-cache",
|
|
47
|
+
Connection: "keep-alive",
|
|
48
|
+
"X-Accel-Buffering": "no",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
return routes;
|
|
53
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result from proxying a request to a bot instance's P2P friend API.
|
|
3
|
+
*/
|
|
4
|
+
export interface ProxyResult {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
status: number;
|
|
7
|
+
data?: unknown;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ProxyLogger {
|
|
11
|
+
error(msg: string, meta?: Record<string, unknown>): void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Proxy a request to a bot instance's internal friend management API.
|
|
15
|
+
*
|
|
16
|
+
* The platform never parses friend data itself -- it acts as a pass-through
|
|
17
|
+
* to the instance's P2P plugin HTTP API running inside the container.
|
|
18
|
+
*
|
|
19
|
+
* @param instanceId - The bot instance identifier (used to build the container hostname)
|
|
20
|
+
* @param method - HTTP method (GET, POST, PUT, PATCH, DELETE)
|
|
21
|
+
* @param path - The API path on the instance (e.g., "/p2p/friends")
|
|
22
|
+
* @param body - Optional request body (will be JSON-serialized)
|
|
23
|
+
* @param opts - Optional configuration (logger, hostname builder)
|
|
24
|
+
*/
|
|
25
|
+
export declare function proxyToInstance(instanceId: string, method: string, path: string, body?: unknown, opts?: {
|
|
26
|
+
logger?: ProxyLogger;
|
|
27
|
+
buildHostname?: (id: string) => string;
|
|
28
|
+
}): Promise<ProxyResult>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Allowed path prefixes — proxy targets must live under /p2p/ or /plugins/. */
|
|
2
|
+
const ALLOWED_PATH_RE = /^\/(?:p2p|plugins)\/[a-zA-Z0-9/_-]*$|^\/plugins\/install$/;
|
|
3
|
+
/** Allowed HTTP methods for proxying. */
|
|
4
|
+
const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]);
|
|
5
|
+
/**
|
|
6
|
+
* Proxy a request to a bot instance's internal friend management API.
|
|
7
|
+
*
|
|
8
|
+
* The platform never parses friend data itself -- it acts as a pass-through
|
|
9
|
+
* to the instance's P2P plugin HTTP API running inside the container.
|
|
10
|
+
*
|
|
11
|
+
* @param instanceId - The bot instance identifier (used to build the container hostname)
|
|
12
|
+
* @param method - HTTP method (GET, POST, PUT, PATCH, DELETE)
|
|
13
|
+
* @param path - The API path on the instance (e.g., "/p2p/friends")
|
|
14
|
+
* @param body - Optional request body (will be JSON-serialized)
|
|
15
|
+
* @param opts - Optional configuration (logger, hostname builder)
|
|
16
|
+
*/
|
|
17
|
+
export async function proxyToInstance(instanceId, method, path, body, opts) {
|
|
18
|
+
// --- SSRF defense (WOP-1288) ---
|
|
19
|
+
if (!ALLOWED_METHODS.has(method)) {
|
|
20
|
+
throw new Error(`proxyToInstance: disallowed method ${method}`);
|
|
21
|
+
}
|
|
22
|
+
// Decode percent-encoded characters before checking, to prevent %2e%2e bypass
|
|
23
|
+
const decodedPath = decodeURIComponent(path);
|
|
24
|
+
if (!ALLOWED_PATH_RE.test(decodedPath) ||
|
|
25
|
+
decodedPath.includes("..") ||
|
|
26
|
+
decodedPath.includes("?") ||
|
|
27
|
+
decodedPath.includes("#") ||
|
|
28
|
+
decodedPath.includes("://") ||
|
|
29
|
+
/[\r\n]/.test(path)) {
|
|
30
|
+
throw new Error(`proxyToInstance: disallowed path ${path}`);
|
|
31
|
+
}
|
|
32
|
+
const hostname = opts?.buildHostname?.(instanceId) ?? `wopr-${instanceId}`;
|
|
33
|
+
const instanceUrl = `http://${hostname}:3000${path}`;
|
|
34
|
+
try {
|
|
35
|
+
const init = {
|
|
36
|
+
method,
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
};
|
|
39
|
+
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
|
40
|
+
init.body = JSON.stringify(body);
|
|
41
|
+
}
|
|
42
|
+
const response = await fetch(instanceUrl, init);
|
|
43
|
+
const contentType = response.headers.get("content-type") || "";
|
|
44
|
+
if (contentType.includes("application/json")) {
|
|
45
|
+
const data = await response.json();
|
|
46
|
+
return { ok: response.ok, status: response.status, data };
|
|
47
|
+
}
|
|
48
|
+
const text = await response.text();
|
|
49
|
+
if (response.ok) {
|
|
50
|
+
return { ok: true, status: response.status, data: text || null };
|
|
51
|
+
}
|
|
52
|
+
return { ok: false, status: response.status, error: text || "Request failed" };
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
56
|
+
opts?.logger?.error(`Failed to proxy to instance ${instanceId}`, { path, error: message });
|
|
57
|
+
// Distinguish between connection errors (instance down) and other failures
|
|
58
|
+
if (message.includes("ECONNREFUSED") || message.includes("ENOTFOUND")) {
|
|
59
|
+
return { ok: false, status: 503, error: "Instance unavailable" };
|
|
60
|
+
}
|
|
61
|
+
return { ok: false, status: 502, error: "Failed to reach instance" };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const instanceIdSchema: z.ZodString;
|
|
3
|
+
/** Friend capability levels. */
|
|
4
|
+
export declare const friendCapabilitySchema: z.ZodEnum<{
|
|
5
|
+
"message-only": "message-only";
|
|
6
|
+
inject: "inject";
|
|
7
|
+
"ai-access": "ai-access";
|
|
8
|
+
}>;
|
|
9
|
+
export type FriendCapability = z.infer<typeof friendCapabilitySchema>;
|
|
10
|
+
/** Schema for updating a friend's capabilities. */
|
|
11
|
+
export declare const updateCapabilitiesSchema: z.ZodObject<{
|
|
12
|
+
capabilities: z.ZodArray<z.ZodEnum<{
|
|
13
|
+
"message-only": "message-only";
|
|
14
|
+
inject: "inject";
|
|
15
|
+
"ai-access": "ai-access";
|
|
16
|
+
}>>;
|
|
17
|
+
}, z.core.$strip>;
|
|
18
|
+
/** Schema for sending a friend request. */
|
|
19
|
+
export declare const sendFriendRequestSchema: z.ZodObject<{
|
|
20
|
+
peerId: z.ZodString;
|
|
21
|
+
message: z.ZodOptional<z.ZodString>;
|
|
22
|
+
}, z.core.$strip>;
|
|
23
|
+
/** Schema for auto-accept rule configuration. */
|
|
24
|
+
export declare const autoAcceptRuleSchema: z.ZodObject<{
|
|
25
|
+
enabled: z.ZodBoolean;
|
|
26
|
+
sameTopicOnly: z.ZodDefault<z.ZodBoolean>;
|
|
27
|
+
defaultCapabilities: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
28
|
+
"message-only": "message-only";
|
|
29
|
+
inject: "inject";
|
|
30
|
+
"ai-access": "ai-access";
|
|
31
|
+
}>>>;
|
|
32
|
+
allowlist: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
33
|
+
}, z.core.$strip>;
|
|
34
|
+
export type AutoAcceptRule = z.infer<typeof autoAcceptRuleSchema>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Allowlist: only alphanumeric, hyphens, and underscores. */
|
|
3
|
+
const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
4
|
+
export const instanceIdSchema = z.string().regex(SAFE_ID_RE, "Invalid instance ID");
|
|
5
|
+
/** Friend capability levels. */
|
|
6
|
+
export const friendCapabilitySchema = z.enum(["message-only", "inject", "ai-access"]);
|
|
7
|
+
/** Schema for updating a friend's capabilities. */
|
|
8
|
+
export const updateCapabilitiesSchema = z.object({
|
|
9
|
+
capabilities: z.array(friendCapabilitySchema).min(1, "At least one capability required"),
|
|
10
|
+
});
|
|
11
|
+
/** Schema for sending a friend request. */
|
|
12
|
+
export const sendFriendRequestSchema = z.object({
|
|
13
|
+
/** The peer ID or discovery ID of the target bot. */
|
|
14
|
+
peerId: z.string().min(1, "peerId is required"),
|
|
15
|
+
/** Optional message to send with the request. */
|
|
16
|
+
message: z.string().max(256).optional(),
|
|
17
|
+
});
|
|
18
|
+
/** Schema for auto-accept rule configuration. */
|
|
19
|
+
export const autoAcceptRuleSchema = z.object({
|
|
20
|
+
/** Whether auto-accept is enabled. */
|
|
21
|
+
enabled: z.boolean(),
|
|
22
|
+
/** Only auto-accept from peers on the same discovery topic. */
|
|
23
|
+
sameTopicOnly: z.boolean().default(false),
|
|
24
|
+
/** Default capabilities granted to auto-accepted friends. */
|
|
25
|
+
defaultCapabilities: z.array(friendCapabilitySchema).default(["message-only"]),
|
|
26
|
+
/** Allowlist of peer IDs that are always accepted. Empty = accept all (when enabled). */
|
|
27
|
+
allowlist: z.array(z.string().min(1)).default([]),
|
|
28
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { IBackupStatusStore } from "../../backup/backup-status-store.js";
|
|
3
|
+
export interface HealthRouteConfig {
|
|
4
|
+
/** Service name returned in health responses (e.g., "wopr-platform", "silo") */
|
|
5
|
+
serviceName: string;
|
|
6
|
+
/** Factory to resolve the backup status store. Return null if unavailable. */
|
|
7
|
+
storeFactory?: () => IBackupStatusStore | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Create health check routes.
|
|
11
|
+
*
|
|
12
|
+
* Public, unauthenticated, used by load balancers and monitoring.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createHealthRoutes(config: HealthRouteConfig): Hono;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
/**
|
|
3
|
+
* Create health check routes.
|
|
4
|
+
*
|
|
5
|
+
* Public, unauthenticated, used by load balancers and monitoring.
|
|
6
|
+
*/
|
|
7
|
+
export function createHealthRoutes(config) {
|
|
8
|
+
const routes = new Hono();
|
|
9
|
+
const resolveStore = config.storeFactory ?? (() => null);
|
|
10
|
+
routes.get("/", async (c) => {
|
|
11
|
+
const health = {
|
|
12
|
+
status: "ok",
|
|
13
|
+
service: config.serviceName,
|
|
14
|
+
};
|
|
15
|
+
const store = resolveStore();
|
|
16
|
+
if (store) {
|
|
17
|
+
try {
|
|
18
|
+
const stale = await store.listStale();
|
|
19
|
+
const total = await store.count();
|
|
20
|
+
health.backups = { staleCount: stale.length, totalTracked: total };
|
|
21
|
+
if (stale.length > 0) {
|
|
22
|
+
health.status = "degraded";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Backup DB query failed — don't crash the health endpoint
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return c.json(health);
|
|
30
|
+
});
|
|
31
|
+
return routes;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createHealthRoutes } from "./health.js";
|
|
3
|
+
function createMockStore(staleCount, totalCount) {
|
|
4
|
+
return {
|
|
5
|
+
listStale: vi.fn().mockReturnValue(Array(staleCount).fill({ isStale: true })),
|
|
6
|
+
count: vi.fn().mockReturnValue(totalCount),
|
|
7
|
+
listAll: vi.fn().mockReturnValue([]),
|
|
8
|
+
get: vi.fn().mockReturnValue(null),
|
|
9
|
+
recordSuccess: vi.fn(),
|
|
10
|
+
recordFailure: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
describe("createHealthRoutes", () => {
|
|
14
|
+
it("returns ok with configured service name", async () => {
|
|
15
|
+
const routes = createHealthRoutes({ serviceName: "test-service" });
|
|
16
|
+
const res = await routes.request("/");
|
|
17
|
+
expect(res.status).toBe(200);
|
|
18
|
+
const body = await res.json();
|
|
19
|
+
expect(body.status).toBe("ok");
|
|
20
|
+
expect(body.service).toBe("test-service");
|
|
21
|
+
});
|
|
22
|
+
it("returns ok when no backup store is available", async () => {
|
|
23
|
+
const routes = createHealthRoutes({ serviceName: "my-platform" });
|
|
24
|
+
const res = await routes.request("/");
|
|
25
|
+
expect(res.status).toBe(200);
|
|
26
|
+
const body = await res.json();
|
|
27
|
+
expect(body.status).toBe("ok");
|
|
28
|
+
expect(body.backups).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
it("returns ok with backup info when all backups fresh", async () => {
|
|
31
|
+
const store = createMockStore(0, 5);
|
|
32
|
+
const routes = createHealthRoutes({ serviceName: "my-platform", storeFactory: () => store });
|
|
33
|
+
const res = await routes.request("/");
|
|
34
|
+
expect(res.status).toBe(200);
|
|
35
|
+
const body = await res.json();
|
|
36
|
+
expect(body.status).toBe("ok");
|
|
37
|
+
expect(body.backups).toEqual({ staleCount: 0, totalTracked: 5 });
|
|
38
|
+
});
|
|
39
|
+
it("returns degraded when stale backups exist", async () => {
|
|
40
|
+
const store = createMockStore(2, 5);
|
|
41
|
+
const routes = createHealthRoutes({ serviceName: "my-platform", storeFactory: () => store });
|
|
42
|
+
const res = await routes.request("/");
|
|
43
|
+
expect(res.status).toBe(200);
|
|
44
|
+
const body = await res.json();
|
|
45
|
+
expect(body.status).toBe("degraded");
|
|
46
|
+
expect(body.backups).toEqual({ staleCount: 2, totalTracked: 5 });
|
|
47
|
+
});
|
|
48
|
+
it("does not crash when backup store throws", async () => {
|
|
49
|
+
const store = {
|
|
50
|
+
listStale: vi.fn().mockImplementation(() => {
|
|
51
|
+
throw new Error("DB locked");
|
|
52
|
+
}),
|
|
53
|
+
count: vi.fn().mockReturnValue(0),
|
|
54
|
+
};
|
|
55
|
+
const routes = createHealthRoutes({ serviceName: "my-platform", storeFactory: () => store });
|
|
56
|
+
const res = await routes.request("/");
|
|
57
|
+
expect(res.status).toBe(200);
|
|
58
|
+
const body = await res.json();
|
|
59
|
+
expect(body.status).toBe("ok");
|
|
60
|
+
expect(body.backups).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
it("uses different service names per brand", async () => {
|
|
63
|
+
const wopr = createHealthRoutes({ serviceName: "wopr-platform" });
|
|
64
|
+
const silo = createHealthRoutes({ serviceName: "silo" });
|
|
65
|
+
const woprBody = await (await wopr.request("/")).json();
|
|
66
|
+
const siloBody = await (await silo.request("/")).json();
|
|
67
|
+
expect(woprBody.service).toBe("wopr-platform");
|
|
68
|
+
expect(siloBody.service).toBe("silo");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AuthEnv } from "../../auth/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Create admin incident response routes.
|
|
5
|
+
*
|
|
6
|
+
* All imports come from platform-core's monetization/incident module.
|
|
7
|
+
* No external dependencies needed.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createIncidentResponseRoutes(): Hono<AuthEnv>;
|