feat: enhance project structure with server and workflow management
- Introduced a new server package with Fastify for handling API requests and workflows. - Implemented user authentication and authorization with JWT and cookie management. - Added endpoints for managing workflows, runs, and user accounts. - Established a dashboard for monitoring workflow status and statistics. - Included a script sandbox for executing user-defined scripts securely. - Updated README with setup instructions and API documentation. - Configured database migrations for user management. - Enhanced logging capabilities for better traceability.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import * as store from "../../store.js";
|
||||
|
||||
export const COOKIE = "scrunner_token";
|
||||
export const OPEN_API_ROUTES = new Set([
|
||||
"GET /auth/bootstrap",
|
||||
"POST /auth/register",
|
||||
"POST /auth/login",
|
||||
]);
|
||||
|
||||
export function cookieOpts() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCredentials(username, password) {
|
||||
if (!/^[A-Za-z0-9_]{3,32}$/.test(username)) {
|
||||
return "username must be 3-32 letters, numbers, or underscore";
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return "password must be at least 8 characters";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function authPlugin(fastify) {
|
||||
fastify.get("/auth/bootstrap", async () => {
|
||||
const count = await store.countUsers();
|
||||
return { needsSetup: count === 0 };
|
||||
});
|
||||
|
||||
fastify.post("/auth/register", async (req, reply) => {
|
||||
const count = await store.countUsers();
|
||||
if (count > 0) {
|
||||
return reply.code(403).send({ error: "setup already complete" });
|
||||
}
|
||||
const body = /** @type {{ username?: string, password?: string }} */ (req.body ?? {});
|
||||
const username = String(body.username ?? "").trim();
|
||||
const password = String(body.password ?? "");
|
||||
const err = validateCredentials(username, password);
|
||||
if (err) return reply.code(400).send({ error: err });
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await store.createUser({
|
||||
username,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
});
|
||||
const token = await reply.jwtSign({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
reply.setCookie(COOKIE, token, cookieOpts());
|
||||
return { user };
|
||||
});
|
||||
|
||||
fastify.post("/auth/login", async (req, reply) => {
|
||||
const body = /** @type {{ username?: string, password?: string }} */ (req.body ?? {});
|
||||
const username = String(body.username ?? "").trim();
|
||||
const password = String(body.password ?? "");
|
||||
const row = await store.getUserAuthByUsername(username);
|
||||
if (!row || !(await bcrypt.compare(password, row.password_hash))) {
|
||||
return reply.code(401).send({ error: "invalid credentials" });
|
||||
}
|
||||
const token = await reply.jwtSign({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
role: row.role,
|
||||
});
|
||||
reply.setCookie(COOKIE, token, cookieOpts());
|
||||
return {
|
||||
user: {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
role: row.role,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post("/auth/logout", async (_req, reply) => {
|
||||
reply.clearCookie(COOKIE, { path: "/" });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
fastify.get("/auth/me", async (req, reply) => {
|
||||
const user = await store.getUserById(req.user.id);
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: "unauthorized" });
|
||||
}
|
||||
return { user };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import yaml from "yaml";
|
||||
import * as store from "../../store.js";
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
|
||||
/**
|
||||
* @param {{ workflows: Map<string, any>, loadErrors: Map<string, string> }} registry
|
||||
*/
|
||||
export default function dashboardPluginFactory(registry) {
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
return async function dashboardPlugin(fastify) {
|
||||
fastify.get("/dashboard", async () => {
|
||||
const owners = fsStore.listOwners();
|
||||
let workflowCount = 0;
|
||||
let enabledCount = 0;
|
||||
let brokenCount = registry.loadErrors.size;
|
||||
const brokenWorkflows = [];
|
||||
|
||||
for (const [key, message] of registry.loadErrors) {
|
||||
const [owner, ...rest] = key.split("/");
|
||||
brokenWorkflows.push({
|
||||
key,
|
||||
owner,
|
||||
file: rest.join("/"),
|
||||
loadError: message,
|
||||
});
|
||||
}
|
||||
|
||||
for (const owner of owners) {
|
||||
let registered = [];
|
||||
try {
|
||||
registered = fsStore.readRegisters(owner);
|
||||
} catch {
|
||||
registered = [];
|
||||
}
|
||||
const files = [
|
||||
...new Set([...registered, ...fsStore.listOwnerYamlFiles(owner)]),
|
||||
];
|
||||
for (const file of files) {
|
||||
workflowCount += 1;
|
||||
const key = `${owner}/${file}`;
|
||||
const loaded = registry.workflows.get(key);
|
||||
if (loaded?.workflow && loaded.workflow.enabled !== false) {
|
||||
enabledCount += 1;
|
||||
} else if (!loaded && !registry.loadErrors.has(key)) {
|
||||
const raw = fsStore.readWorkflowYaml(owner, file);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = yaml.parse(raw);
|
||||
if (parsed?.enabled !== false) enabledCount += 1;
|
||||
} catch (err) {
|
||||
brokenCount += 1;
|
||||
brokenWorkflows.push({
|
||||
key,
|
||||
owner,
|
||||
file,
|
||||
loadError: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [running, failed, recent] = await Promise.all([
|
||||
store.listRuns({ status: "running", limit: 10 }),
|
||||
store.listRuns({ status: "failed", limit: 20 }),
|
||||
store.listRuns({ limit: 10 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
workflowCount,
|
||||
scriptCount: fsStore.listScriptFiles().length,
|
||||
enabledCount,
|
||||
brokenCount,
|
||||
running,
|
||||
needsAttention: {
|
||||
failed,
|
||||
brokenWorkflows,
|
||||
},
|
||||
recent,
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as store from "../../store.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function runsPlugin(fastify) {
|
||||
fastify.get("/runs", async (req) => {
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||
const limit = q.limit ? Number(q.limit) : undefined;
|
||||
const runs = await store.listRuns({
|
||||
owner: q.owner,
|
||||
workflow: q.workflow,
|
||||
status: q.status,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
before: q.before,
|
||||
});
|
||||
return { runs };
|
||||
});
|
||||
|
||||
fastify.get("/runs/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const run = await store.getRun(id);
|
||||
if (!run) return reply.code(404).send({ error: "run not found" });
|
||||
return run;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { clearScriptCache } from "../../script-sandbox.js";
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
|
||||
/**
|
||||
* @param {{ referencedScripts: () => Set<string> }} registry
|
||||
*/
|
||||
export default function scriptsPluginFactory(registry) {
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
return async function scriptsPlugin(fastify) {
|
||||
fastify.get("/scripts", async () => {
|
||||
return { scripts: fsStore.listScriptFiles() };
|
||||
});
|
||||
|
||||
fastify.get("/scripts/:name", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
try {
|
||||
fsStore.assertScriptName(name);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const content = fsStore.readScript(name);
|
||||
if (content == null) return reply.code(404).send({ error: "script not found" });
|
||||
return { name, content };
|
||||
});
|
||||
|
||||
fastify.put("/scripts/:name", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
try {
|
||||
fsStore.assertScriptName(name);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const body = /** @type {{ content?: string }} */ (req.body ?? {});
|
||||
if (typeof body.content !== "string") {
|
||||
return reply.code(400).send({ error: "content is required" });
|
||||
}
|
||||
const existed = fsStore.readScript(name) != null;
|
||||
fsStore.writeScript(name, body.content);
|
||||
clearScriptCache();
|
||||
return reply.code(existed ? 200 : 201).send({ name });
|
||||
});
|
||||
|
||||
fastify.delete("/scripts/:name", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
try {
|
||||
fsStore.assertScriptName(name);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
if (registry.referencedScripts().has(name)) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: "script is referenced by a workflow" });
|
||||
}
|
||||
if (!fsStore.deleteScript(name)) {
|
||||
return reply.code(404).send({ error: "script not found" });
|
||||
}
|
||||
clearScriptCache();
|
||||
return { ok: true };
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import * as store from "../../store.js";
|
||||
import { validateCredentials } from "./auth.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function usersPlugin(fastify) {
|
||||
fastify.addHook("onRequest", fastify.requireAdmin);
|
||||
|
||||
fastify.get("/users", async () => {
|
||||
const users = await store.listUsers();
|
||||
return { users };
|
||||
});
|
||||
|
||||
fastify.post("/users", async (req, reply) => {
|
||||
const body = /** @type {{ username?: string, password?: string, role?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
const username = String(body.username ?? "").trim();
|
||||
const password = String(body.password ?? "");
|
||||
const role = body.role === "admin" ? "admin" : "operator";
|
||||
const err = validateCredentials(username, password);
|
||||
if (err) return reply.code(400).send({ error: err });
|
||||
|
||||
const existing = await store.getUserAuthByUsername(username);
|
||||
if (existing) {
|
||||
return reply.code(409).send({ error: "username taken" });
|
||||
}
|
||||
|
||||
const user = await store.createUser({
|
||||
username,
|
||||
passwordHash: await bcrypt.hash(password, 10),
|
||||
role,
|
||||
});
|
||||
return reply.code(201).send({ user });
|
||||
});
|
||||
|
||||
fastify.patch("/users/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const existing = await store.getUserAuthById(id);
|
||||
if (!existing) return reply.code(404).send({ error: "user not found" });
|
||||
|
||||
const body = /** @type {{ password?: string, role?: string }} */ (req.body ?? {});
|
||||
/** @type {{ passwordHash?: string, role?: string }} */
|
||||
const patch = {};
|
||||
|
||||
if (body.role) {
|
||||
if (body.role !== "admin" && body.role !== "operator") {
|
||||
return reply.code(400).send({ error: "invalid role" });
|
||||
}
|
||||
if (existing.role === "admin" && body.role !== "admin") {
|
||||
const admins = await store.countAdmins();
|
||||
if (admins <= 1) {
|
||||
return reply.code(400).send({ error: "cannot demote last admin" });
|
||||
}
|
||||
}
|
||||
patch.role = body.role;
|
||||
}
|
||||
|
||||
if (body.password) {
|
||||
if (body.password.length < 8) {
|
||||
return reply.code(400).send({ error: "password must be at least 8 characters" });
|
||||
}
|
||||
patch.passwordHash = await bcrypt.hash(body.password, 10);
|
||||
}
|
||||
|
||||
const user = await store.updateUser(id, patch);
|
||||
return { user };
|
||||
});
|
||||
|
||||
fastify.delete("/users/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const existing = await store.getUserAuthById(id);
|
||||
if (!existing) return reply.code(404).send({ error: "user not found" });
|
||||
if (existing.role === "admin") {
|
||||
const admins = await store.countAdmins();
|
||||
if (admins <= 1) {
|
||||
return reply.code(400).send({ error: "cannot delete last admin" });
|
||||
}
|
||||
}
|
||||
await store.deleteUser(id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import yaml from "yaml";
|
||||
import * as store from "../../store.js";
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import { namespacedPath, parseScriptStep } from "../../workflow-parse.js";
|
||||
|
||||
function triggerSummary(owner, workflow) {
|
||||
if (!workflow || typeof workflow !== "object") return [];
|
||||
return (workflow.triggers ?? []).map((t) => {
|
||||
const type = t?.type ?? "unknown";
|
||||
const isHttp = String(type).toLowerCase() === "http";
|
||||
return {
|
||||
type,
|
||||
method: isHttp ? String(t?.method ?? "POST").toUpperCase() : t?.method ?? null,
|
||||
path: isHttp && t?.path != null ? namespacedPath(owner, t.path) : t?.path ?? null,
|
||||
schedule: t?.schedule ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function scriptNames(workflow) {
|
||||
if (!workflow || typeof workflow !== "object") return [];
|
||||
const names = [];
|
||||
for (const raw of workflow.scripts ?? []) {
|
||||
try {
|
||||
names.push(parseScriptStep(raw).script);
|
||||
} catch {
|
||||
names.push(null);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ workflows: Map<string, any>, loadErrors: Map<string, string>, reregister: () => void }} registry
|
||||
*/
|
||||
export default function workflowsPluginFactory(registry) {
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
return async function workflowsPlugin(fastify) {
|
||||
fastify.get("/owners", async () => {
|
||||
return { owners: fsStore.listOwners() };
|
||||
});
|
||||
|
||||
fastify.get("/workflows", async (req) => {
|
||||
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
|
||||
const stats = await store.workflowStats();
|
||||
const owners = q.owner
|
||||
? [fsStore.assertOwner(q.owner)]
|
||||
: fsStore.listOwners();
|
||||
|
||||
const items = [];
|
||||
for (const owner of owners) {
|
||||
let registered = [];
|
||||
try {
|
||||
registered = fsStore.readRegisters(owner);
|
||||
} catch {
|
||||
registered = [];
|
||||
}
|
||||
const onDisk = fsStore.listOwnerYamlFiles(owner);
|
||||
const files = [...new Set([...registered, ...onDisk])];
|
||||
|
||||
for (const file of files) {
|
||||
const key = `${owner}/${file}`;
|
||||
const loaded = registry.workflows.get(key);
|
||||
const loadError = registry.loadErrors.get(key) ?? null;
|
||||
let parsed = loaded?.workflow ?? null;
|
||||
if (!parsed) {
|
||||
const raw = fsStore.readWorkflowYaml(owner, file);
|
||||
if (raw != null) {
|
||||
try {
|
||||
parsed = yaml.parse(raw);
|
||||
} catch (err) {
|
||||
// keep loadError
|
||||
if (!loadError) {
|
||||
// file on disk but unparseable and not in registers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const st = stats[key] ?? {
|
||||
invocationCount: 0,
|
||||
lastInvokedAt: null,
|
||||
lastStatus: null,
|
||||
};
|
||||
items.push({
|
||||
owner,
|
||||
file,
|
||||
key,
|
||||
name: parsed?.name ?? file,
|
||||
description: parsed?.description ?? null,
|
||||
enabled: parsed ? parsed.enabled !== false : false,
|
||||
registered: registered.includes(file),
|
||||
loadError:
|
||||
loadError ??
|
||||
(parsed ? null : "unreadable"),
|
||||
lastInvokedAt: st.lastInvokedAt,
|
||||
lastStatus: st.lastStatus ?? null,
|
||||
invocationCount: st.invocationCount,
|
||||
triggers: triggerSummary(owner, parsed),
|
||||
scripts: scriptNames(parsed),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { workflows: items };
|
||||
});
|
||||
|
||||
fastify.get("/workflows/:owner/:file", async (req, reply) => {
|
||||
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
||||
req.params
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(owner);
|
||||
fsStore.assertWorkflowFile(file);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const content = fsStore.readWorkflowYaml(owner, file);
|
||||
if (content == null) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
const key = `${owner}/${file}`;
|
||||
let parsed = null;
|
||||
let parseError = null;
|
||||
try {
|
||||
parsed = yaml.parse(content);
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
const stats = (await store.workflowStats())[key] ?? {
|
||||
invocationCount: 0,
|
||||
lastInvokedAt: null,
|
||||
lastStatus: null,
|
||||
};
|
||||
return {
|
||||
owner,
|
||||
file,
|
||||
key,
|
||||
content,
|
||||
parsed,
|
||||
parseError,
|
||||
loadError: registry.loadErrors.get(key) ?? parseError,
|
||||
lastInvokedAt: stats.lastInvokedAt,
|
||||
lastStatus: stats.lastStatus ?? null,
|
||||
invocationCount: stats.invocationCount,
|
||||
};
|
||||
});
|
||||
|
||||
fastify.put("/workflows/:owner/:file", async (req, reply) => {
|
||||
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
||||
req.params
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(owner);
|
||||
fsStore.assertWorkflowFile(file);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const body = /** @type {{ content?: string }} */ (req.body ?? {});
|
||||
if (typeof body.content !== "string") {
|
||||
return reply.code(400).send({ error: "content is required" });
|
||||
}
|
||||
try {
|
||||
yaml.parse(body.content);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: `invalid yaml: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
const existed = fsStore.readWorkflowYaml(owner, file) != null;
|
||||
fsStore.writeWorkflowYaml(owner, file, body.content);
|
||||
const registered = fsStore.readRegisters(owner);
|
||||
if (!registered.includes(file)) {
|
||||
registered.push(file);
|
||||
fsStore.writeRegisters(owner, registered);
|
||||
}
|
||||
registry.reregister();
|
||||
return reply.code(existed ? 200 : 201).send({ owner, file });
|
||||
});
|
||||
|
||||
fastify.delete("/workflows/:owner/:file", async (req, reply) => {
|
||||
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
||||
req.params
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(owner);
|
||||
fsStore.assertWorkflowFile(file);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
if (!fsStore.deleteWorkflowYaml(owner, file)) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
const registered = fsStore.readRegisters(owner).filter((f) => f !== file);
|
||||
fsStore.writeRegisters(owner, registered);
|
||||
registry.reregister();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
fastify.post("/workflows/:owner/:file/run", async (req, reply) => {
|
||||
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
||||
req.params
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(owner);
|
||||
fsStore.assertWorkflowFile(file);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const key = `${owner}/${file}`;
|
||||
if (!registry.workflows.has(key)) {
|
||||
return reply.code(404).send({
|
||||
error: registry.loadErrors.get(key) ?? "workflow not loaded",
|
||||
});
|
||||
}
|
||||
const body = /** @type {{ data?: unknown }} */ (req.body ?? {});
|
||||
const result = await registry.runWorkflow(
|
||||
key,
|
||||
{ data: body.data ?? null },
|
||||
{ type: "manual", detail: "ui" },
|
||||
);
|
||||
if (result.status === "failed") {
|
||||
return reply.code(result.runId ? 500 : 404).send({
|
||||
runId: result.runId,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
return {
|
||||
runId: result.runId,
|
||||
status: result.status,
|
||||
result: result.result,
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post("/workflows/reregister", async () => {
|
||||
registry.reregister();
|
||||
return { message: "Workflows refreshed" };
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user