Init Commit
quality / commitlint (push) Skipped
CD / update-wiki (push) Failing after 7s
quality / changes (push) Successful in 4s
quality / docker-check (push) Skipped
quality / web (push) Failing after 38s
quality / api (push) Successful in 49s
CD / quality (push) Failing after 1m36s
CD / publish (push) Skipped
quality / commitlint (push) Skipped
CD / update-wiki (push) Failing after 7s
quality / changes (push) Successful in 4s
quality / docker-check (push) Skipped
quality / web (push) Failing after 38s
quality / api (push) Successful in 49s
CD / quality (push) Failing after 1m36s
CD / publish (push) Skipped
This commit is contained in:
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
|
||||
export { }
|
||||
Vendored
+417
@@ -0,0 +1,417 @@
|
||||
// src/server.ts
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { resolve as resolve3 } from "path";
|
||||
|
||||
// src/app.ts
|
||||
import { resolve as resolve2 } from "path";
|
||||
import Fastify from "fastify";
|
||||
import {
|
||||
serializerCompiler,
|
||||
validatorCompiler
|
||||
} from "@fastify/type-provider-zod";
|
||||
|
||||
// src/config.ts
|
||||
import { resolve } from "path";
|
||||
function boolEnv(v, fallback) {
|
||||
if (v === void 0 || v === "") return fallback;
|
||||
return v === "1" || v.toLowerCase() === "true";
|
||||
}
|
||||
function loadConfig() {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const jwtSecret = process.env.AUTH_JWT_SECRET ?? process.env.JWT_SECRET ?? (isProd ? "" : "dev-secret-change-me");
|
||||
return {
|
||||
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
|
||||
jwtSecret: jwtSecret || "dev-secret-change-me",
|
||||
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
|
||||
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
|
||||
adminPasswordHash: process.env.ADMIN_PASSWORD_HASH?.trim() || "devplaceholder",
|
||||
serverPort: Number(process.env.SERVER_PORT ?? "8081") || 8081,
|
||||
staticDir: process.env.STATIC_DIR ? resolve(process.env.STATIC_DIR) : null,
|
||||
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
authIssuer: process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top",
|
||||
authPortalUrl: (process.env.AUTH_PORTAL_URL ?? process.env.VITE_AUTH_PORTAL_URL ?? "http://localhost:5175").replace(/\/$/, ""),
|
||||
authAuditIngestSecret: process.env.AUTH_AUDIT_INGEST_SECRET?.trim() || (!isProd ? "dev-audit-ingest-secret" : null)
|
||||
};
|
||||
}
|
||||
|
||||
// src/plugins/auth.ts
|
||||
import fp from "fastify-plugin";
|
||||
|
||||
// src/errors.ts
|
||||
import { NotFoundError, ConflictError } from "@cdnmanager/db";
|
||||
import { ValidationError } from "@cdnmanager/shared";
|
||||
var AppError = class _AppError extends Error {
|
||||
constructor(code, message, statusCode) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.name = "AppError";
|
||||
}
|
||||
code;
|
||||
statusCode;
|
||||
static notFound(message) {
|
||||
return new _AppError("NOT_FOUND", message, 404);
|
||||
}
|
||||
static validation(message) {
|
||||
return new _AppError("VALIDATION_ERROR", message, 400);
|
||||
}
|
||||
static unauthorized() {
|
||||
return new _AppError("UNAUTHORIZED", "unauthorized", 401);
|
||||
}
|
||||
static forbidden(message = "forbidden") {
|
||||
return new _AppError("FORBIDDEN", message, 403);
|
||||
}
|
||||
static conflict(message) {
|
||||
return new _AppError("CONFLICT", message, 409);
|
||||
}
|
||||
static cloudflare(message) {
|
||||
return new _AppError("CLOUDFLARE_ERROR", message, 502);
|
||||
}
|
||||
static dnsUpdateFailed(message) {
|
||||
return new _AppError(
|
||||
"DNS_UPDATE_FAILED",
|
||||
message,
|
||||
502
|
||||
);
|
||||
}
|
||||
static healthcheckCreateFailed(message) {
|
||||
return new _AppError("HEALTHCHECK_CREATE_FAILED", message, 502);
|
||||
}
|
||||
static zoneNotFound(message = "\u0437\u043E\u043D\u0430 Cloudflare \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430") {
|
||||
return new _AppError("ZONE_NOT_FOUND", message, 404);
|
||||
}
|
||||
static invalidIp(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441") {
|
||||
return new _AppError("INVALID_IP", message, 400);
|
||||
}
|
||||
static invalidHostname(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 \u0438\u043C\u044F \u0445\u043E\u0441\u0442\u0430") {
|
||||
return new _AppError("INVALID_HOSTNAME", message, 400);
|
||||
}
|
||||
static rateLimited(message = "Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043F\u043E\u043F\u044B\u0442\u043A\u0443.") {
|
||||
return new _AppError("RATE_LIMITED", message, 429);
|
||||
}
|
||||
static cloudflareAuthFailed(message = "Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D \u0434\u043E\u0441\u0442\u0443\u043F\u0430") {
|
||||
return new _AppError("CLOUDFLARE_AUTH_FAILED", message, 401);
|
||||
}
|
||||
static syncFailed(message) {
|
||||
return new _AppError("SYNC_FAILED", message, 502);
|
||||
}
|
||||
static internal(message) {
|
||||
return new _AppError("INTERNAL_ERROR", message, 500);
|
||||
}
|
||||
};
|
||||
function toAppError(err) {
|
||||
if (err instanceof AppError) return err;
|
||||
if (err instanceof NotFoundError) return AppError.notFound(err.message);
|
||||
if (err instanceof ConflictError) return AppError.conflict(err.message);
|
||||
if (err instanceof ValidationError) return AppError.validation(err.message);
|
||||
if (err instanceof Error) return AppError.internal(err.message);
|
||||
return AppError.internal(String(err));
|
||||
}
|
||||
function errorBody(err) {
|
||||
return {
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// src/lib/permissions.ts
|
||||
function hasPermission(granted, required) {
|
||||
if (granted.includes(required)) return true;
|
||||
const parts = required.split(":");
|
||||
if (parts.length !== 3) return false;
|
||||
const [app2, section, action] = parts;
|
||||
if (action === "read") {
|
||||
return granted.includes(`${app2}:${section}:write`) || granted.includes(`${app2}:${section}:admin`);
|
||||
}
|
||||
if (action === "write") {
|
||||
return granted.includes(`${app2}:${section}:admin`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var RULES = [
|
||||
{
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/v1/settings"),
|
||||
permission: "cdn:settings:admin"
|
||||
}
|
||||
];
|
||||
function permissionForRequest(method, path) {
|
||||
const m = method.toUpperCase();
|
||||
const pathname = path.split("?")[0] ?? path;
|
||||
for (const rule of RULES) {
|
||||
if (!rule.methods.includes(m)) continue;
|
||||
if (rule.match(pathname)) return rule.permission;
|
||||
}
|
||||
if (pathname.startsWith("/api/v1/")) return "cdn:dashboard:read";
|
||||
return null;
|
||||
}
|
||||
|
||||
// src/plugins/auth.ts
|
||||
async function authPlugin(app2, opts) {
|
||||
const { config: config2 } = opts;
|
||||
if (config2.authRequired && (!config2.jwtSecret || config2.jwtSecret.length < 8)) {
|
||||
throw new Error(
|
||||
"AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true"
|
||||
);
|
||||
}
|
||||
await app2.register(import("@fastify/jwt"), {
|
||||
secret: config2.jwtSecret,
|
||||
...config2.authRequired ? {
|
||||
verify: {
|
||||
allowedIss: [config2.authIssuer]
|
||||
}
|
||||
} : {}
|
||||
});
|
||||
app2.decorate("config", config2);
|
||||
if (config2.authRequired) {
|
||||
app2.log.info(
|
||||
{ issuer: config2.authIssuer, portal: config2.authPortalUrl },
|
||||
"AUTH_REQUIRED=true \u2014 portal JWT middleware enabled"
|
||||
);
|
||||
} else {
|
||||
app2.log.info("AUTH_REQUIRED=false \u2014 local JWT / open protected routes with requireAuth");
|
||||
}
|
||||
}
|
||||
async function requireAuth(request, reply) {
|
||||
const config2 = request.server.config;
|
||||
const authHeader = request.headers.authorization ?? "";
|
||||
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
||||
if (!token) throw AppError.unauthorized();
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
throw AppError.unauthorized();
|
||||
}
|
||||
if (!config2.authRequired) {
|
||||
return;
|
||||
}
|
||||
const payload = request.user;
|
||||
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : [];
|
||||
const permissions = Array.isArray(payload.permissions) ? payload.permissions.map(String) : [];
|
||||
if (!apps.includes("cdn")) {
|
||||
throw AppError.forbidden("\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044E CDN Manager");
|
||||
}
|
||||
request.authUser = {
|
||||
id: String(payload.sub),
|
||||
email: String(payload.email ?? ""),
|
||||
name: String(payload.name ?? ""),
|
||||
apps,
|
||||
permissions,
|
||||
isAdmin: Boolean(payload.is_admin)
|
||||
};
|
||||
const required = permissionForRequest(request.method, request.url);
|
||||
if (required && !hasPermission(permissions, required)) {
|
||||
throw AppError.forbidden(`\u041D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E \u043F\u0440\u0430\u0432: ${required}`);
|
||||
}
|
||||
}
|
||||
var auth_default = fp(authPlugin, { name: "auth" });
|
||||
|
||||
// src/plugins/cors.ts
|
||||
import fp2 from "fastify-plugin";
|
||||
async function corsPlugin(app2) {
|
||||
await app2.register(import("@fastify/cors"), {
|
||||
origin: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"]
|
||||
});
|
||||
}
|
||||
var cors_default = fp2(corsPlugin, { name: "cors" });
|
||||
|
||||
// src/plugins/db.ts
|
||||
import fp3 from "fastify-plugin";
|
||||
import {
|
||||
createDb,
|
||||
createMemoryDb,
|
||||
healthCheck,
|
||||
runMigrations
|
||||
} from "@cdnmanager/db";
|
||||
async function dbPlugin(app2, opts) {
|
||||
const { db, sqlite } = opts.memory ? createMemoryDb() : createDb(opts.config.databaseUrl);
|
||||
runMigrations(sqlite);
|
||||
app2.decorate("db", db);
|
||||
app2.decorate("sqlite", sqlite);
|
||||
app2.addHook("onClose", async () => {
|
||||
sqlite.close();
|
||||
});
|
||||
}
|
||||
var db_default = fp3(dbPlugin, { name: "db" });
|
||||
|
||||
// src/plugins/error-handler.ts
|
||||
import fp4 from "fastify-plugin";
|
||||
async function errorHandlerPlugin(app2) {
|
||||
app2.setErrorHandler((err, _request, reply) => {
|
||||
if (reply.sent) return;
|
||||
const appErr = err.statusCode === 401 ? AppError.unauthorized() : toAppError(err);
|
||||
reply.status(appErr.statusCode).send(errorBody(appErr));
|
||||
});
|
||||
}
|
||||
var error_handler_default = fp4(errorHandlerPlugin, { name: "error-handler" });
|
||||
|
||||
// src/routes/health.ts
|
||||
import { z } from "zod";
|
||||
|
||||
// src/services/auth.ts
|
||||
import { verify } from "@node-rs/argon2";
|
||||
async function verifyPassword(config2, password) {
|
||||
if (config2.adminPasswordHash === "devplaceholder") {
|
||||
if (password === "admin") return;
|
||||
throw AppError.unauthorized();
|
||||
}
|
||||
const ok = await verify(config2.adminPasswordHash, password);
|
||||
if (!ok) throw AppError.unauthorized();
|
||||
}
|
||||
async function login(config2, sign, req) {
|
||||
if (req.username !== config2.adminUsername) {
|
||||
throw AppError.unauthorized();
|
||||
}
|
||||
await verifyPassword(config2, req.password);
|
||||
const expiresAt = new Date(
|
||||
Date.now() + config2.jwtTtlHours * 60 * 60 * 1e3
|
||||
);
|
||||
const token = sign({
|
||||
sub: req.username,
|
||||
exp: Math.floor(expiresAt.getTime() / 1e3)
|
||||
});
|
||||
return {
|
||||
token,
|
||||
expires_at: expiresAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
// src/routes/health.ts
|
||||
async function healthRoutes(app2) {
|
||||
app2.get("/health", async (request) => {
|
||||
healthCheck(request.server.sqlite);
|
||||
return { status: "ok" };
|
||||
});
|
||||
app2.get("/ready", async (request) => {
|
||||
healthCheck(request.server.sqlite);
|
||||
return {
|
||||
status: "ready",
|
||||
database: true
|
||||
};
|
||||
});
|
||||
}
|
||||
async function authRoutes(app2) {
|
||||
app2.get("/auth/config", async (request) => {
|
||||
const { config: config2 } = request.server;
|
||||
return {
|
||||
required: config2.authRequired,
|
||||
portal_url: config2.authPortalUrl
|
||||
};
|
||||
});
|
||||
const loginSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string()
|
||||
});
|
||||
app2.post("/auth/login", async (request, reply) => {
|
||||
if (request.server.config.authRequired) {
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
code: "FORBIDDEN",
|
||||
message: "\u041B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u0432\u0445\u043E\u0434 \u043E\u0442\u043A\u043B\u044E\u0447\u0451\u043D \u2014 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 auth-portal"
|
||||
}
|
||||
});
|
||||
}
|
||||
const body = loginSchema.parse(request.body);
|
||||
const result = await login(
|
||||
request.server.config,
|
||||
(payload) => request.server.jwt.sign(payload),
|
||||
body
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// src/routes/settings.ts
|
||||
import { appSettingsPatchSchema } from "@cdnmanager/shared";
|
||||
import { getAppSettings, updateAppSettings } from "@cdnmanager/db";
|
||||
async function settingsRoutes(app2) {
|
||||
app2.get("/settings", async (request) => {
|
||||
return getAppSettings(request.server.db);
|
||||
});
|
||||
app2.patch("/settings", async (request) => {
|
||||
const parsed = appSettingsPatchSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
throw AppError.validation(
|
||||
parsed.error.issues[0]?.message ?? "\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438"
|
||||
);
|
||||
}
|
||||
return updateAppSettings(request.server.db, parsed.data);
|
||||
});
|
||||
}
|
||||
|
||||
// src/app.ts
|
||||
async function buildApp(opts = {}) {
|
||||
const config2 = opts.config ?? loadConfig();
|
||||
const app2 = Fastify({
|
||||
logger: { level: config2.logLevel }
|
||||
}).withTypeProvider();
|
||||
app2.setValidatorCompiler(validatorCompiler);
|
||||
app2.setSerializerCompiler(serializerCompiler);
|
||||
await app2.register(import("@fastify/sensible"));
|
||||
await app2.register(import("@fastify/helmet"), { contentSecurityPolicy: false });
|
||||
await app2.register(import("@fastify/rate-limit"), {
|
||||
max: 300,
|
||||
timeWindow: "1 minute"
|
||||
});
|
||||
await app2.register(cors_default);
|
||||
await app2.register(error_handler_default);
|
||||
await app2.register(db_default, { config: config2, memory: opts.memory });
|
||||
await app2.register(auth_default, { config: config2 });
|
||||
await app2.register(healthRoutes);
|
||||
await app2.register(authRoutes, { prefix: "/api/v1" });
|
||||
await app2.register(
|
||||
async (protectedApi) => {
|
||||
protectedApi.addHook("onRequest", requireAuth);
|
||||
await protectedApi.register(settingsRoutes);
|
||||
},
|
||||
{ prefix: "/api/v1" }
|
||||
);
|
||||
const staticDir = config2.staticDir ?? resolve2(process.cwd(), "static");
|
||||
if (config2.staticDir !== null) {
|
||||
await app2.register(import("@fastify/static"), {
|
||||
root: staticDir,
|
||||
wildcard: false
|
||||
});
|
||||
app2.setNotFoundHandler(async (_request, reply) => {
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
}
|
||||
return app2;
|
||||
}
|
||||
|
||||
// src/server.ts
|
||||
for (const path of [
|
||||
resolve3(import.meta.dirname, "../../../.env"),
|
||||
".env",
|
||||
"../.env"
|
||||
]) {
|
||||
if (!existsSync(path)) continue;
|
||||
const content = readFileSync(path, "utf-8");
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
var config = loadConfig();
|
||||
var app = await buildApp({ config });
|
||||
try {
|
||||
await app.listen({ port: config.serverPort, host: "0.0.0.0" });
|
||||
app.log.info(`listening on ${config.serverPort}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@cdnmanager/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsup --config tsup.config.ts",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cdnmanager/db": "workspace:*",
|
||||
"@cdnmanager/shared": "workspace:*",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/schedule": "^6.0.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"p-limit": "^6.2.0",
|
||||
"p-queue": "^8.1.0",
|
||||
"toad-scheduler": "^4.0.1",
|
||||
"undici": "^8.5.0",
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.32",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { resolve } from "node:path";
|
||||
import Fastify from "fastify";
|
||||
import {
|
||||
serializerCompiler,
|
||||
validatorCompiler,
|
||||
type ZodTypeProvider,
|
||||
} from "@fastify/type-provider-zod";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import authPlugin from "./plugins/auth.js";
|
||||
import { requireAuth } from "./plugins/auth.js";
|
||||
import corsPlugin from "./plugins/cors.js";
|
||||
import dbPlugin from "./plugins/db.js";
|
||||
import errorHandlerPlugin from "./plugins/error-handler.js";
|
||||
import { authRoutes, healthRoutes } from "./routes/health.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
memory?: boolean;
|
||||
}
|
||||
|
||||
export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
const config = opts.config ?? loadConfig();
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: config.logLevel },
|
||||
}).withTypeProvider<ZodTypeProvider>();
|
||||
|
||||
app.setValidatorCompiler(validatorCompiler);
|
||||
app.setSerializerCompiler(serializerCompiler);
|
||||
|
||||
await app.register(import("@fastify/sensible"));
|
||||
await app.register(import("@fastify/helmet"), { contentSecurityPolicy: false });
|
||||
await app.register(import("@fastify/rate-limit"), {
|
||||
max: 300,
|
||||
timeWindow: "1 minute",
|
||||
});
|
||||
await app.register(corsPlugin);
|
||||
await app.register(errorHandlerPlugin);
|
||||
await app.register(dbPlugin, { config, memory: opts.memory });
|
||||
await app.register(authPlugin, { config });
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes, { prefix: "/api/v1" });
|
||||
|
||||
await app.register(
|
||||
async (protectedApi) => {
|
||||
protectedApi.addHook("onRequest", requireAuth);
|
||||
await protectedApi.register(settingsRoutes);
|
||||
},
|
||||
{ prefix: "/api/v1" },
|
||||
);
|
||||
|
||||
const staticDir = config.staticDir ?? resolve(process.cwd(), "static");
|
||||
if (config.staticDir !== null) {
|
||||
await app.register(import("@fastify/static"), {
|
||||
root: staticDir,
|
||||
wildcard: false,
|
||||
});
|
||||
|
||||
app.setNotFoundHandler(async (_request, reply) => {
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export interface AppConfig {
|
||||
databaseUrl: string;
|
||||
jwtSecret: string;
|
||||
jwtTtlHours: number;
|
||||
adminUsername: string;
|
||||
adminPasswordHash: string;
|
||||
serverPort: number;
|
||||
staticDir: string | null;
|
||||
logLevel: string;
|
||||
/** Portal SSO — when true, require portal JWT with apps includes cdn */
|
||||
authRequired: boolean;
|
||||
authIssuer: string;
|
||||
authPortalUrl: string;
|
||||
/** Bearer secret for POST {authPortalUrl}/api/v1/ingest/audit */
|
||||
authAuditIngestSecret: string | null;
|
||||
}
|
||||
|
||||
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||
if (v === undefined || v === "") return fallback;
|
||||
return v === "1" || v.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const jwtSecret =
|
||||
process.env.AUTH_JWT_SECRET ??
|
||||
process.env.JWT_SECRET ??
|
||||
(isProd ? "" : "dev-secret-change-me");
|
||||
|
||||
return {
|
||||
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
|
||||
jwtSecret: jwtSecret || "dev-secret-change-me",
|
||||
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
|
||||
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
|
||||
adminPasswordHash:
|
||||
process.env.ADMIN_PASSWORD_HASH?.trim() || "devplaceholder",
|
||||
serverPort: Number(process.env.SERVER_PORT ?? "8081") || 8081,
|
||||
staticDir: process.env.STATIC_DIR
|
||||
? resolve(process.env.STATIC_DIR)
|
||||
: null,
|
||||
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
authIssuer:
|
||||
process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top",
|
||||
authPortalUrl: (
|
||||
process.env.AUTH_PORTAL_URL ??
|
||||
process.env.VITE_AUTH_PORTAL_URL ??
|
||||
"http://localhost:5175"
|
||||
).replace(/\/$/, ""),
|
||||
authAuditIngestSecret:
|
||||
process.env.AUTH_AUDIT_INGEST_SECRET?.trim() ||
|
||||
(!isProd ? "dev-audit-ingest-secret" : null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NotFoundError, ConflictError } from "@cdnmanager/db";
|
||||
import { ValidationError } from "@cdnmanager/shared";
|
||||
|
||||
export type ErrorCode =
|
||||
| "NOT_FOUND"
|
||||
| "VALIDATION_ERROR"
|
||||
| "UNAUTHORIZED"
|
||||
| "FORBIDDEN"
|
||||
| "CONFLICT"
|
||||
| "CLOUDFLARE_ERROR"
|
||||
| "DNS_UPDATE_FAILED"
|
||||
| "HEALTHCHECK_CREATE_FAILED"
|
||||
| "ZONE_NOT_FOUND"
|
||||
| "INVALID_IP"
|
||||
| "INVALID_HOSTNAME"
|
||||
| "RATE_LIMITED"
|
||||
| "CLOUDFLARE_AUTH_FAILED"
|
||||
| "SYNC_FAILED"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public readonly code: ErrorCode,
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
}
|
||||
|
||||
static notFound(message: string) {
|
||||
return new AppError("NOT_FOUND", message, 404);
|
||||
}
|
||||
|
||||
static validation(message: string) {
|
||||
return new AppError("VALIDATION_ERROR", message, 400);
|
||||
}
|
||||
|
||||
static unauthorized() {
|
||||
return new AppError("UNAUTHORIZED", "unauthorized", 401);
|
||||
}
|
||||
|
||||
static forbidden(message = "forbidden") {
|
||||
return new AppError("FORBIDDEN", message, 403);
|
||||
}
|
||||
|
||||
static conflict(message: string) {
|
||||
return new AppError("CONFLICT", message, 409);
|
||||
}
|
||||
|
||||
static cloudflare(message: string) {
|
||||
return new AppError("CLOUDFLARE_ERROR", message, 502);
|
||||
}
|
||||
|
||||
static dnsUpdateFailed(message: string) {
|
||||
return new AppError(
|
||||
"DNS_UPDATE_FAILED",
|
||||
message,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
static healthcheckCreateFailed(message: string) {
|
||||
return new AppError("HEALTHCHECK_CREATE_FAILED", message, 502);
|
||||
}
|
||||
|
||||
static zoneNotFound(message = "зона Cloudflare не найдена") {
|
||||
return new AppError("ZONE_NOT_FOUND", message, 404);
|
||||
}
|
||||
|
||||
static invalidIp(message = "Некорректный IP-адрес") {
|
||||
return new AppError("INVALID_IP", message, 400);
|
||||
}
|
||||
|
||||
static invalidHostname(message = "Некорректное имя хоста") {
|
||||
return new AppError("INVALID_HOSTNAME", message, 400);
|
||||
}
|
||||
|
||||
static rateLimited(message = "Cloudflare временно ограничил запросы. Повторите попытку.") {
|
||||
return new AppError("RATE_LIMITED", message, 429);
|
||||
}
|
||||
|
||||
static cloudflareAuthFailed(message = "Cloudflare отклонил токен доступа") {
|
||||
return new AppError("CLOUDFLARE_AUTH_FAILED", message, 401);
|
||||
}
|
||||
|
||||
static syncFailed(message: string) {
|
||||
return new AppError("SYNC_FAILED", message, 502);
|
||||
}
|
||||
|
||||
static internal(message: string) {
|
||||
return new AppError("INTERNAL_ERROR", message, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export function toAppError(err: unknown): AppError {
|
||||
if (err instanceof AppError) return err;
|
||||
if (err instanceof NotFoundError) return AppError.notFound(err.message);
|
||||
if (err instanceof ConflictError) return AppError.conflict(err.message);
|
||||
if (err instanceof ValidationError) return AppError.validation(err.message);
|
||||
if (err instanceof Error) return AppError.internal(err.message);
|
||||
return AppError.internal(String(err));
|
||||
}
|
||||
|
||||
export function errorBody(err: AppError) {
|
||||
return {
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||
* Format: cdn:<section>:<read|write|admin>
|
||||
*/
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
apps: string[];
|
||||
permissions: string[];
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
|
||||
export function hasPermission(
|
||||
granted: readonly string[],
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes(required)) return true;
|
||||
const parts = required.split(":");
|
||||
if (parts.length !== 3) return false;
|
||||
const [app, section, action] = parts;
|
||||
if (action === "read") {
|
||||
return (
|
||||
granted.includes(`${app}:${section}:write`) ||
|
||||
granted.includes(`${app}:${section}:admin`)
|
||||
);
|
||||
}
|
||||
if (action === "write") {
|
||||
return granted.includes(`${app}:${section}:admin`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
methods: string[];
|
||||
match: (path: string) => boolean;
|
||||
permission: string;
|
||||
};
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/v1/settings"),
|
||||
permission: "cdn:settings:admin",
|
||||
},
|
||||
];
|
||||
|
||||
/** Resolve required permission for method+path, or null if public / unknown. */
|
||||
export function permissionForRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
const m = method.toUpperCase();
|
||||
const pathname = path.split("?")[0] ?? path;
|
||||
for (const rule of RULES) {
|
||||
if (!rule.methods.includes(m)) continue;
|
||||
if (rule.match(pathname)) return rule.permission;
|
||||
}
|
||||
if (pathname.startsWith("/api/v1/")) return "cdn:dashboard:read";
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { AppError } from "../errors.js";
|
||||
import {
|
||||
hasPermission,
|
||||
permissionForRequest,
|
||||
type AuthUser,
|
||||
} from "../lib/permissions.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
authUser?: AuthUser;
|
||||
}
|
||||
interface FastifyInstance {
|
||||
config: AppConfig;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
apps?: string[];
|
||||
permissions?: string[];
|
||||
is_admin?: boolean;
|
||||
iss?: string;
|
||||
exp?: number;
|
||||
};
|
||||
user: {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
apps?: string[];
|
||||
permissions?: string[];
|
||||
is_admin?: boolean;
|
||||
iss?: string;
|
||||
exp?: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function authPlugin(
|
||||
app: FastifyInstance,
|
||||
opts: { config: AppConfig },
|
||||
) {
|
||||
const { config } = opts;
|
||||
|
||||
if (config.authRequired && (!config.jwtSecret || config.jwtSecret.length < 8)) {
|
||||
throw new Error(
|
||||
"AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true",
|
||||
);
|
||||
}
|
||||
|
||||
await app.register(import("@fastify/jwt"), {
|
||||
secret: config.jwtSecret,
|
||||
...(config.authRequired
|
||||
? {
|
||||
verify: {
|
||||
allowedIss: [config.authIssuer],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
app.decorate("config", config);
|
||||
|
||||
if (config.authRequired) {
|
||||
app.log.info(
|
||||
{ issuer: config.authIssuer, portal: config.authPortalUrl },
|
||||
"AUTH_REQUIRED=true — portal JWT middleware enabled",
|
||||
);
|
||||
} else {
|
||||
app.log.info("AUTH_REQUIRED=false — local JWT / open protected routes with requireAuth");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect /api/v1 routes.
|
||||
* - AUTH_REQUIRED=false: Bearer JWT from local login (legacy admin).
|
||||
* - AUTH_REQUIRED=true: portal JWT with apps.includes('cdn') + permissions.
|
||||
*/
|
||||
export async function requireAuth(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const config = request.server.config;
|
||||
const authHeader = request.headers.authorization ?? "";
|
||||
const token = authHeader.startsWith("Bearer ")
|
||||
? authHeader.slice(7)
|
||||
: "";
|
||||
if (!token) throw AppError.unauthorized();
|
||||
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
throw AppError.unauthorized();
|
||||
}
|
||||
|
||||
if (!config.authRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = request.user;
|
||||
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : [];
|
||||
const permissions = Array.isArray(payload.permissions)
|
||||
? payload.permissions.map(String)
|
||||
: [];
|
||||
|
||||
if (!apps.includes("cdn")) {
|
||||
throw AppError.forbidden("Нет доступа к приложению CDN Manager");
|
||||
}
|
||||
|
||||
request.authUser = {
|
||||
id: String(payload.sub),
|
||||
email: String(payload.email ?? ""),
|
||||
name: String(payload.name ?? ""),
|
||||
apps,
|
||||
permissions,
|
||||
isAdmin: Boolean(payload.is_admin),
|
||||
};
|
||||
|
||||
const required = permissionForRequest(request.method, request.url);
|
||||
if (required && !hasPermission(permissions, required)) {
|
||||
throw AppError.forbidden(`Недостаточно прав: ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(authPlugin, { name: "auth" });
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
|
||||
async function corsPlugin(app: FastifyInstance) {
|
||||
await app.register(import("@fastify/cors"), {
|
||||
origin: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(corsPlugin, { name: "cors" });
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import {
|
||||
createDb,
|
||||
createMemoryDb,
|
||||
healthCheck,
|
||||
runMigrations,
|
||||
type Db,
|
||||
type Sqlite,
|
||||
} from "@cdnmanager/db";
|
||||
import type { AppConfig } from "../config.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
db: Db;
|
||||
sqlite: Sqlite;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DbPluginOptions {
|
||||
config?: AppConfig;
|
||||
memory?: boolean;
|
||||
}
|
||||
|
||||
async function dbPlugin(
|
||||
app: FastifyInstance,
|
||||
opts: DbPluginOptions,
|
||||
) {
|
||||
const { db, sqlite } = opts.memory
|
||||
? createMemoryDb()
|
||||
: createDb(opts.config!.databaseUrl);
|
||||
|
||||
runMigrations(sqlite);
|
||||
app.decorate("db", db);
|
||||
app.decorate("sqlite", sqlite);
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
sqlite.close();
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(dbPlugin, { name: "db" });
|
||||
|
||||
export { healthCheck };
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import { AppError, errorBody, toAppError } from "../errors.js";
|
||||
|
||||
async function errorHandlerPlugin(app: FastifyInstance) {
|
||||
app.setErrorHandler((err, _request, reply) => {
|
||||
if (reply.sent) return;
|
||||
|
||||
const appErr =
|
||||
err.statusCode === 401
|
||||
? AppError.unauthorized()
|
||||
: toAppError(err);
|
||||
|
||||
reply.status(appErr.statusCode).send(errorBody(appErr));
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(errorHandlerPlugin, { name: "error-handler" });
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { healthCheck } from "../plugins/db.js";
|
||||
import * as authService from "../services/auth.js";
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get("/health", async (request) => {
|
||||
healthCheck(request.server.sqlite);
|
||||
return { status: "ok" };
|
||||
});
|
||||
|
||||
app.get("/ready", async (request) => {
|
||||
healthCheck(request.server.sqlite);
|
||||
return {
|
||||
status: "ready",
|
||||
database: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.get("/auth/config", async (request) => {
|
||||
const { config } = request.server;
|
||||
return {
|
||||
required: config.authRequired,
|
||||
portal_url: config.authPortalUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
app.post("/auth/login", async (request, reply) => {
|
||||
if (request.server.config.authRequired) {
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
code: "FORBIDDEN",
|
||||
message: "Локальный вход отключён — используйте auth-portal",
|
||||
},
|
||||
});
|
||||
}
|
||||
const body = loginSchema.parse(request.body);
|
||||
const result = await authService.login(
|
||||
request.server.config,
|
||||
(payload) => request.server.jwt.sign(payload),
|
||||
body,
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { appSettingsPatchSchema } from "@cdnmanager/shared";
|
||||
import { getAppSettings, updateAppSettings } from "@cdnmanager/db";
|
||||
import { AppError } from "../errors.js";
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
app.get("/settings", async (request) => {
|
||||
return getAppSettings(request.server.db);
|
||||
});
|
||||
|
||||
app.patch("/settings", async (request) => {
|
||||
const parsed = appSettingsPatchSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
throw AppError.validation(
|
||||
parsed.error.issues[0]?.message ?? "некорректные настройки",
|
||||
);
|
||||
}
|
||||
return updateAppSettings(request.server.db, parsed.data);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { buildApp } from "./app.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
|
||||
for (const path of [
|
||||
resolve(import.meta.dirname, "../../../.env"),
|
||||
".env",
|
||||
"../.env",
|
||||
]) {
|
||||
if (!existsSync(path)) continue;
|
||||
const content = readFileSync(path, "utf-8");
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const app = await buildApp({ config });
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.serverPort, host: "0.0.0.0" });
|
||||
app.log.info(`listening on ${config.serverPort}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { verify } from "@node-rs/argon2";
|
||||
import type { JwtClaims, LoginRequest, LoginResponse } from "@cdnmanager/shared";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { AppError } from "../errors.js";
|
||||
|
||||
export async function verifyPassword(
|
||||
config: AppConfig,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
if (config.adminPasswordHash === "devplaceholder") {
|
||||
if (password === "admin") return;
|
||||
throw AppError.unauthorized();
|
||||
}
|
||||
const ok = await verify(config.adminPasswordHash, password);
|
||||
if (!ok) throw AppError.unauthorized();
|
||||
}
|
||||
|
||||
export async function login(
|
||||
config: AppConfig,
|
||||
sign: (payload: JwtClaims) => string,
|
||||
req: LoginRequest,
|
||||
): Promise<LoginResponse> {
|
||||
if (req.username !== config.adminUsername) {
|
||||
throw AppError.unauthorized();
|
||||
}
|
||||
await verifyPassword(config, req.password);
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + config.jwtTtlHours * 60 * 60 * 1000,
|
||||
);
|
||||
const token = sign({
|
||||
sub: req.username,
|
||||
exp: Math.floor(expiresAt.getTime() / 1000),
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
describe("health", () => {
|
||||
it("GET /health returns ok", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/health" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ status: "ok" });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("GET /ready returns database status", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/ready" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.database).toBe(true);
|
||||
expect(["ready", "degraded"]).toContain(body.status);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/server.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["test/**/*.test.ts"],
|
||||
testTimeout: 20_000,
|
||||
typecheck: {
|
||||
tsconfig: "./tsconfig.test.json",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user