@tokenbuddy/tb-admin 1.0.35 → 1.0.37
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/src/cli.js +92 -19
- package/dist/src/config.d.ts +7 -1
- package/dist/src/config.js +16 -4
- package/dist/src/display-format.js +6 -14
- package/dist/src/init-command.d.ts +50 -0
- package/dist/src/init-command.js +347 -0
- package/dist/src/providers/fly-io.d.ts +3 -0
- package/dist/src/providers/fly-io.js +137 -0
- package/dist/src/providers/provider-definition.d.ts +38 -0
- package/dist/src/providers/provider-definition.js +2 -0
- package/dist/src/seller.d.ts +2 -0
- package/dist/src/seller.js +30 -13
- package/dist/src/server-cmd.d.ts +1 -0
- package/dist/src/server-cmd.js +9 -2
- package/dist/src/ui-actions.d.ts +3 -0
- package/dist/src/ui-actions.js +199 -27
- package/dist/src/ui-command.js +3 -2
- package/dist/src/ui-state.d.ts +1 -3
- package/dist/src/ui-state.js +4 -8
- package/dist/src/ui-static.js +43 -15
- package/dist/src/workdir.d.ts +21 -0
- package/dist/src/workdir.js +50 -0
- package/package.json +8 -2
- package/templates/providers/fly.io/admin.toml.example +18 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/README.md +18 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/admin-web.example.env +3 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/cloudflare-r2.example.env +6 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/registry-signing-key.example.json +6 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/registry.example.json +14 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/tb-registry.example.yaml +14 -0
- package/templates/providers/fly.io/deploy-secrets/seller-configs/README.md +13 -0
- package/templates/providers/fly.io/deploy-secrets/seller-configs/seller.example.yaml +35 -0
- package/templates/providers/fly.io/env/deploy.env.example +12 -0
- package/templates/providers/fly.io/fly/fly.tb-registry.toml +31 -0
- package/templates/providers/fly.io/fly/fly.tb-seller.toml +25 -0
- package/templates/providers/fly.io/provider.toml.example +10 -0
- package/dist/src/bootstrap-registry.d.ts.map +0 -1
- package/dist/src/bootstrap-registry.js.map +0 -1
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js.map +0 -1
- package/dist/src/client.d.ts.map +0 -1
- package/dist/src/client.js.map +0 -1
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js.map +0 -1
- package/dist/src/display-format.d.ts.map +0 -1
- package/dist/src/display-format.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js.map +0 -1
- package/dist/src/provider.d.ts.map +0 -1
- package/dist/src/provider.js.map +0 -1
- package/dist/src/seller.d.ts.map +0 -1
- package/dist/src/seller.js.map +0 -1
- package/dist/src/server-cmd.d.ts.map +0 -1
- package/dist/src/server-cmd.js.map +0 -1
- package/dist/src/ui-actions.d.ts.map +0 -1
- package/dist/src/ui-actions.js.map +0 -1
- package/dist/src/ui-command.d.ts.map +0 -1
- package/dist/src/ui-command.js.map +0 -1
- package/dist/src/ui-server.d.ts.map +0 -1
- package/dist/src/ui-server.js.map +0 -1
- package/dist/src/ui-state.d.ts.map +0 -1
- package/dist/src/ui-state.js.map +0 -1
- package/dist/src/ui-static.d.ts.map +0 -1
- package/dist/src/ui-static.js.map +0 -1
- package/dist/src/upstream-balance-probe.d.ts.map +0 -1
- package/dist/src/upstream-balance-probe.js.map +0 -1
- package/dist/src/vendor-client.d.ts.map +0 -1
- package/dist/src/vendor-client.js.map +0 -1
- package/dist/src/vendor-commands.d.ts.map +0 -1
- package/dist/src/vendor-commands.js.map +0 -1
- package/src/bootstrap-registry.ts +0 -90
- package/src/cli.ts +0 -1614
- package/src/client.ts +0 -179
- package/src/config.ts +0 -194
- package/src/display-format.ts +0 -411
- package/src/index.ts +0 -11
- package/src/provider.ts +0 -150
- package/src/seller.ts +0 -538
- package/src/server-cmd.ts +0 -362
- package/src/ui-actions.ts +0 -1040
- package/src/ui-command.ts +0 -44
- package/src/ui-server.ts +0 -353
- package/src/ui-state.ts +0 -1318
- package/src/ui-static.ts +0 -673
- package/src/upstream-balance-probe.ts +0 -13
- package/src/vendor-client.ts +0 -23
- package/src/vendor-commands.ts +0 -65
- package/tests/admin.test.ts +0 -2162
- package/tests/seller.test.ts +0 -388
- package/tests/ui-state-fleet.test.ts +0 -526
- package/tests/ui-static-row.test.ts +0 -467
- package/tests/vendor-cli.test.ts +0 -241
- package/tsconfig.json +0 -8
package/src/ui-command.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
2
|
-
import { ConfigManager } from "./config.js";
|
|
3
|
-
import { startAdminUiServer } from "./ui-server.js";
|
|
4
|
-
|
|
5
|
-
export function bindAdminUiCommand(program: Command, configManager: ConfigManager): void {
|
|
6
|
-
program
|
|
7
|
-
.command("ui")
|
|
8
|
-
.description("Start the local TokenBuddy admin UI")
|
|
9
|
-
.option("--host <host>", "Local bind host", "127.0.0.1")
|
|
10
|
-
.option("--port <port>", "Local bind port", parsePort, 17822)
|
|
11
|
-
.option("--no-open", "Do not open a browser")
|
|
12
|
-
.action(async (options) => {
|
|
13
|
-
try {
|
|
14
|
-
const rootOptions = program.opts();
|
|
15
|
-
const mgr = rootOptions.config ? new ConfigManager(rootOptions.config) : configManager;
|
|
16
|
-
const started = await startAdminUiServer({
|
|
17
|
-
host: options.host,
|
|
18
|
-
port: options.port,
|
|
19
|
-
openBrowser: Boolean(options.open),
|
|
20
|
-
configManager: mgr,
|
|
21
|
-
configPath: rootOptions.config,
|
|
22
|
-
profile: rootOptions.profile || process.env.TOKENBUDDY_ADMIN_PROFILE || defaultUiProfile(mgr),
|
|
23
|
-
url: rootOptions.url || process.env.TOKENBUDDY_ADMIN_URL,
|
|
24
|
-
token: rootOptions.token || process.env.TOKENBUDDY_ADMIN_TOKEN
|
|
25
|
-
});
|
|
26
|
-
console.log(`TokenBuddy admin UI listening on ${started.url}`);
|
|
27
|
-
} catch (err: any) {
|
|
28
|
-
console.error("Error:", err.message);
|
|
29
|
-
process.exit(1);
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function parsePort(value: string): number {
|
|
35
|
-
const parsed = Number(value);
|
|
36
|
-
if (!Number.isInteger(parsed)) {
|
|
37
|
-
throw new Error("port must be an integer");
|
|
38
|
-
}
|
|
39
|
-
return parsed;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function defaultUiProfile(configManager: ConfigManager): string {
|
|
43
|
-
return configManager.getProfile("bootstrap-vendor") ? "bootstrap-vendor" : "bootstrap";
|
|
44
|
-
}
|
package/src/ui-server.ts
DELETED
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
import * as http from "http";
|
|
2
|
-
import { randomBytes } from "crypto";
|
|
3
|
-
import { URL } from "url";
|
|
4
|
-
import { ConfigManager } from "./config.js";
|
|
5
|
-
import { RegistryVendorClient } from "./client.js";
|
|
6
|
-
import { AdminUiState, type SellerRow } from "./ui-state.js";
|
|
7
|
-
import { UiActions, type CreateSellerRequest, type UiActionProgressEvent, type UiActionResult } from "./ui-actions.js";
|
|
8
|
-
import { adminUiHtml } from "./ui-static.js";
|
|
9
|
-
|
|
10
|
-
export interface AdminUiServerOptions {
|
|
11
|
-
host: string;
|
|
12
|
-
port: number;
|
|
13
|
-
openBrowser: boolean;
|
|
14
|
-
configManager: ConfigManager;
|
|
15
|
-
configPath?: string;
|
|
16
|
-
profile?: string;
|
|
17
|
-
url?: string;
|
|
18
|
-
token?: string;
|
|
19
|
-
fetchJson?: (url: string, init?: RequestInit) => Promise<unknown>;
|
|
20
|
-
balanceFetch?: typeof fetch;
|
|
21
|
-
commandRunner?: (args: string[], timeoutMs: number) => Promise<UiActionResult>;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface StartedAdminUiServer {
|
|
25
|
-
server: http.Server;
|
|
26
|
-
url: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
type UiJobStatus = "running" | "succeeded" | "failed";
|
|
30
|
-
|
|
31
|
-
interface UiJob {
|
|
32
|
-
id: string;
|
|
33
|
-
kind: "create_seller";
|
|
34
|
-
status: UiJobStatus;
|
|
35
|
-
startedAt: string;
|
|
36
|
-
updatedAt: string;
|
|
37
|
-
title: string;
|
|
38
|
-
events: UiActionProgressEvent[];
|
|
39
|
-
result?: unknown;
|
|
40
|
-
error?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function startAdminUiServer(options: AdminUiServerOptions): Promise<StartedAdminUiServer> {
|
|
44
|
-
assertSafeHost(options.host);
|
|
45
|
-
assertSafePort(options.port);
|
|
46
|
-
|
|
47
|
-
const state = new AdminUiState(options);
|
|
48
|
-
const actions = new UiActions(options);
|
|
49
|
-
const jobs = new Map<string, UiJob>();
|
|
50
|
-
const server = http.createServer(async (req, res) => {
|
|
51
|
-
try {
|
|
52
|
-
await routeRequest(req, res, options, state, actions, jobs);
|
|
53
|
-
} catch (err: any) {
|
|
54
|
-
sendJson(res, 500, { error: err.message || "internal error" });
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
await new Promise<void>((resolve, reject) => {
|
|
59
|
-
server.once("error", reject);
|
|
60
|
-
server.listen(options.port, options.host, () => {
|
|
61
|
-
server.off("error", reject);
|
|
62
|
-
resolve();
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
const address = server.address();
|
|
67
|
-
const actualPort = typeof address === "object" && address ? address.port : options.port;
|
|
68
|
-
const url = `http://${options.host}:${actualPort}/`;
|
|
69
|
-
if (options.openBrowser) {
|
|
70
|
-
openLocalBrowser(url).catch(() => undefined);
|
|
71
|
-
}
|
|
72
|
-
return { server, url };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function routeRequest(
|
|
76
|
-
req: http.IncomingMessage,
|
|
77
|
-
res: http.ServerResponse,
|
|
78
|
-
options: AdminUiServerOptions,
|
|
79
|
-
state: AdminUiState,
|
|
80
|
-
actions: UiActions,
|
|
81
|
-
jobs: Map<string, UiJob>
|
|
82
|
-
): Promise<void> {
|
|
83
|
-
const parsed = new URL(req.url || "/", "http://127.0.0.1");
|
|
84
|
-
if (req.method === "GET" && parsed.pathname === "/") {
|
|
85
|
-
sendHtml(res, adminUiHtml());
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
if (req.method === "GET" && parsed.pathname === "/favicon.ico") {
|
|
89
|
-
res.writeHead(204, { "Cache-Control": "no-store" });
|
|
90
|
-
res.end();
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if (parsed.pathname.startsWith("/api/") && !isValidUiOrigin(req)) {
|
|
94
|
-
sendJson(res, 403, { error: "invalid UI origin" });
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (req.method === "GET" && parsed.pathname === "/api/bootstrap") {
|
|
99
|
-
sendJson(res, 200, await state.bootstrap());
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
if (req.method === "GET" && parsed.pathname === "/api/bootstrap/config") {
|
|
103
|
-
sendJson(res, 200, await state.bootstrapConfig());
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (req.method === "GET" && parsed.pathname === "/api/vendor/release-requests") {
|
|
107
|
-
const profile = state.activeBootstrapProfile();
|
|
108
|
-
const baseUrl = options.url || profile.profile?.url;
|
|
109
|
-
const token = options.token || profile.profile?.token;
|
|
110
|
-
if (!baseUrl || !token) {
|
|
111
|
-
throw new Error("No bootstrap profile found. Configure an admin profile or pass --url and --token.");
|
|
112
|
-
}
|
|
113
|
-
const limit = parsed.searchParams.get("limit") || undefined;
|
|
114
|
-
const client = new RegistryVendorClient(baseUrl, token);
|
|
115
|
-
sendJson(res, 200, await client.listReleaseRequests(limit ? Number(limit) : 20));
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
if (req.method === "GET" && parsed.pathname === "/api/sellers/registry") {
|
|
119
|
-
sendJson(res, 200, await state.sellerRegistryRows());
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
if (req.method === "GET" && parsed.pathname === "/api/sellers/inventory") {
|
|
123
|
-
sendJson(res, 200, await state.sellerInventory());
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
if (req.method === "GET" && parsed.pathname === "/api/sellers") {
|
|
127
|
-
sendJson(res, 200, await state.sellers());
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
if (parsed.pathname === "/api/sellers/status" && req.method !== "POST") {
|
|
131
|
-
sendJson(res, 405, { error: "method not allowed" });
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
if (req.method === "POST" && parsed.pathname === "/api/sellers/status") {
|
|
135
|
-
const body = await readJson(req) as { rows?: SellerRow[] };
|
|
136
|
-
sendJson(res, 200, await state.refreshSellerRows(Array.isArray(body.rows) ? body.rows : []));
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (req.method === "POST" && parsed.pathname === "/api/sellers") {
|
|
140
|
-
const body = await readJson(req) as CreateSellerRequest;
|
|
141
|
-
const job = startCreateSellerJob(jobs, actions, body);
|
|
142
|
-
sendJson(res, 202, { jobId: job.id, job });
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const jobMatch = parsed.pathname.match(/^\/api\/jobs\/([^/]+)$/);
|
|
147
|
-
if (req.method === "GET" && jobMatch) {
|
|
148
|
-
const job = jobs.get(decodeURIComponent(jobMatch[1]));
|
|
149
|
-
if (!job) {
|
|
150
|
-
sendJson(res, 404, { error: "job not found" });
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
sendJson(res, 200, job);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const sellerMatch = parsed.pathname.match(/^\/api\/sellers\/([^/]+)(?:\/([^/]+))?$/);
|
|
158
|
-
if (sellerMatch) {
|
|
159
|
-
const id = decodeURIComponent(sellerMatch[1]);
|
|
160
|
-
const subroute = sellerMatch[2];
|
|
161
|
-
if (req.method === "GET" && !subroute) {
|
|
162
|
-
sendJson(res, 200, await state.sellerDetail(id));
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
if (req.method === "GET" && subroute === "models") {
|
|
166
|
-
const detail = await state.sellerDetail(id);
|
|
167
|
-
sendJson(res, 200, detail.models);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
if (req.method === "PUT" && subroute === "config") {
|
|
171
|
-
sendJson(res, 200, await actions.updateSellerConfig(id, await readJson(req) as Record<string, unknown>));
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
if (req.method === "POST" && (subroute === "offline" || subroute === "drain" || subroute === "activate")) {
|
|
175
|
-
const status = subroute === "drain" ? "draining" : subroute === "activate" ? "active" : "offline";
|
|
176
|
-
sendJson(res, 200, await actions.setRegistryStatus(id, status));
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
if (req.method === "DELETE" && subroute === "deployment") {
|
|
180
|
-
const body = await readJson(req).catch(() => ({})) as { confirm?: boolean };
|
|
181
|
-
sendJson(res, 200, await actions.deleteDeployment(id, Boolean(body.confirm)));
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
sendJson(res, 404, { error: "not found" });
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function startCreateSellerJob(jobs: Map<string, UiJob>, actions: UiActions, request: CreateSellerRequest): UiJob {
|
|
190
|
-
const now = new Date().toISOString();
|
|
191
|
-
const job: UiJob = {
|
|
192
|
-
id: randomBytes(12).toString("hex"),
|
|
193
|
-
kind: "create_seller",
|
|
194
|
-
status: "running",
|
|
195
|
-
startedAt: now,
|
|
196
|
-
updatedAt: now,
|
|
197
|
-
title: `Create seller ${request.sellerName || ""}`.trim(),
|
|
198
|
-
events: []
|
|
199
|
-
};
|
|
200
|
-
jobs.set(job.id, job);
|
|
201
|
-
actions.createSeller(request, (event) => {
|
|
202
|
-
const safeEvent = sanitizeProgressEvent(event);
|
|
203
|
-
job.events = [...job.events.filter((item) => item.stepId !== safeEvent.stepId), safeEvent];
|
|
204
|
-
job.updatedAt = new Date().toISOString();
|
|
205
|
-
}).then((result) => {
|
|
206
|
-
job.status = result.result.ok
|
|
207
|
-
&& (!result.readiness || result.readiness.ok)
|
|
208
|
-
&& (!result.configPut || result.configPut.ok)
|
|
209
|
-
&& (!result.modelsRefresh || result.modelsRefresh.ok)
|
|
210
|
-
&& (!result.registryPublish || result.registryPublish.ok)
|
|
211
|
-
? "succeeded"
|
|
212
|
-
: "failed";
|
|
213
|
-
job.result = sanitizeJobResult(result);
|
|
214
|
-
job.updatedAt = new Date().toISOString();
|
|
215
|
-
}).catch((err: any) => {
|
|
216
|
-
job.status = "failed";
|
|
217
|
-
job.error = redactSensitive(err.message || "create seller failed");
|
|
218
|
-
job.updatedAt = new Date().toISOString();
|
|
219
|
-
});
|
|
220
|
-
pruneJobs(jobs);
|
|
221
|
-
return job;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function sanitizeProgressEvent(event: UiActionProgressEvent): UiActionProgressEvent {
|
|
225
|
-
return {
|
|
226
|
-
...event,
|
|
227
|
-
message: event.message ? redactSensitive(event.message) : undefined,
|
|
228
|
-
result: event.result ? sanitizeActionResult(event.result) : undefined
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function sanitizeJobResult(value: unknown): unknown {
|
|
233
|
-
if (!value || typeof value !== "object") {
|
|
234
|
-
return value;
|
|
235
|
-
}
|
|
236
|
-
const input = value as Record<string, unknown>;
|
|
237
|
-
return Object.fromEntries(Object.entries(input).map(([key, entry]) => {
|
|
238
|
-
if (entry && typeof entry === "object" && "command" in entry) {
|
|
239
|
-
return [key, sanitizeActionResult(entry as unknown as UiActionResult)];
|
|
240
|
-
}
|
|
241
|
-
if (entry && typeof entry === "object") {
|
|
242
|
-
return [key, sanitizeJobResult(entry)];
|
|
243
|
-
}
|
|
244
|
-
return [key, typeof entry === "string" ? redactSensitive(entry) : entry];
|
|
245
|
-
}));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function sanitizeActionResult(result: UiActionResult): UiActionResult {
|
|
249
|
-
return {
|
|
250
|
-
ok: result.ok,
|
|
251
|
-
stdout: redactSensitive(result.stdout),
|
|
252
|
-
stderr: redactSensitive(result.stderr),
|
|
253
|
-
command: redactCommand(result.command)
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function redactCommand(command: string[]): string[] {
|
|
258
|
-
return command.map((part, index) => {
|
|
259
|
-
const prev = command[index - 1];
|
|
260
|
-
if (prev === "--operator-secret" || prev === "--token" || prev === "--api-key") {
|
|
261
|
-
return "********";
|
|
262
|
-
}
|
|
263
|
-
return redactSensitive(part);
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function redactSensitive(value: string): string {
|
|
268
|
-
return value.replace(/(sk-[A-Za-z0-9_-]+)/g, "********")
|
|
269
|
-
.replace(/(operatorSecret|upstreamApiKey|token|apiKey|sm4KeyBase64|clawtipSm4KeyBase64)["'=:\s]+([^"',\s]+)/gi, "$1=********");
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function pruneJobs(jobs: Map<string, UiJob>): void {
|
|
273
|
-
const maxJobs = 20;
|
|
274
|
-
if (jobs.size <= maxJobs) {
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
const stale = Array.from(jobs.values()).sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
|
|
278
|
-
for (const job of stale.slice(0, jobs.size - maxJobs)) {
|
|
279
|
-
jobs.delete(job.id);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function isValidUiOrigin(req: http.IncomingMessage): boolean {
|
|
284
|
-
const origin = headerValue(req.headers.origin);
|
|
285
|
-
if (!origin) {
|
|
286
|
-
return true;
|
|
287
|
-
}
|
|
288
|
-
const host = headerValue(req.headers.host);
|
|
289
|
-
if (!host) {
|
|
290
|
-
return false;
|
|
291
|
-
}
|
|
292
|
-
try {
|
|
293
|
-
const parsed = new URL(origin);
|
|
294
|
-
return parsed.protocol === "http:" && parsed.host === host;
|
|
295
|
-
} catch {
|
|
296
|
-
return false;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function headerValue(value: string | string[] | undefined): string | undefined {
|
|
301
|
-
return Array.isArray(value) ? value[0] : value;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function sendHtml(res: http.ServerResponse, html: string): void {
|
|
305
|
-
res.writeHead(200, {
|
|
306
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
307
|
-
"Cache-Control": "no-store"
|
|
308
|
-
});
|
|
309
|
-
res.end(html);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
|
|
313
|
-
res.writeHead(status, {
|
|
314
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
315
|
-
"Cache-Control": "no-store"
|
|
316
|
-
});
|
|
317
|
-
res.end(JSON.stringify(body));
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
async function readJson(req: http.IncomingMessage): Promise<unknown> {
|
|
321
|
-
const chunks: Buffer[] = [];
|
|
322
|
-
for await (const chunk of req) {
|
|
323
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
324
|
-
}
|
|
325
|
-
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
326
|
-
return raw ? JSON.parse(raw) : {};
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function assertSafeHost(host: string): void {
|
|
330
|
-
if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
|
|
331
|
-
throw new Error("tb-admin ui only supports loopback hosts");
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function assertSafePort(port: number): void {
|
|
336
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
337
|
-
throw new Error("port must be between 0 and 65535");
|
|
338
|
-
}
|
|
339
|
-
if (port === 12571 || port === 15721 || port === 17820 || port === 17821) {
|
|
340
|
-
throw new Error(`port ${port} is reserved`);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
async function openLocalBrowser(url: string): Promise<void> {
|
|
345
|
-
const { spawn } = await import("child_process");
|
|
346
|
-
const command = process.platform === "darwin"
|
|
347
|
-
? "open"
|
|
348
|
-
: process.platform === "win32"
|
|
349
|
-
? "cmd"
|
|
350
|
-
: "xdg-open";
|
|
351
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
352
|
-
spawn(command, args, { shell: false, stdio: "ignore", detached: true }).unref();
|
|
353
|
-
}
|