feat(server): implement HTTP auth and page management functionality

- Introduced new HTTP authentication and page management APIs, allowing for the creation, retrieval, updating, and deletion of HTTP auth profiles and pages.
- Added validation for auth and page fields to ensure proper configuration and error handling.
- Implemented a mechanism for resolving auth credentials from various sources, including inline definitions, KV store, and secrets.
- Enhanced workflow validation to include checks for HTTP triggers, ensuring proper auth and response configurations.
- Updated the web interface to include new routes for managing HTTP auth profiles and pages, improving user experience and accessibility.
This commit is contained in:
2026-08-15 06:41:26 +07:00
parent 951e6f8371
commit d666dc001d
17 changed files with 2197 additions and 38 deletions
+330
View File
@@ -0,0 +1,330 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { assertHttpStatus } from "./http-pages-store.js";
const MAX_NAME_LENGTH = 128;
const NAME_RE = /^[A-Za-z0-9._-]+$/;
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
function nowIso() {
return new Date().toISOString();
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertAuthName(name) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
const err = new Error("invalid auth name");
err.statusCode = 400;
throw err;
}
if (name.length > MAX_NAME_LENGTH) {
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
err.statusCode = 400;
throw err;
}
return name;
}
/**
* @param {unknown} type
* @returns {"bearer" | "basic" | "header"}
*/
export function assertAuthType(type) {
const t = String(type ?? "");
if (!ALLOWED_TYPES.has(t)) {
const err = new Error('auth type must be "bearer", "basic", or "header"');
err.statusCode = 400;
throw err;
}
return /** @type {"bearer" | "basic" | "header"} */ (t);
}
/**
* Detect value source without exposing literal values.
* @param {unknown} value
* @returns {"literal" | "kv" | "secret" | "missing"}
*/
export function valueSourceKind(value) {
if (value == null) return "missing";
if (typeof value === "string") return "literal";
if (typeof value === "object" && !Array.isArray(value)) {
if ("secret" in value) return "secret";
if ("kv" in value) return "kv";
}
return "literal";
}
/**
* Redact config for API responses: replace literal strings with source markers.
* @param {Record<string, unknown>} config
* @param {string} type
*/
export function publicConfig(config, type) {
/** @type {Record<string, unknown>} */
const out = {};
if (type === "bearer") {
out.token = redactField(config.token);
} else if (type === "basic") {
out.user = redactField(config.user);
out.password = redactField(config.password);
} else if (type === "header") {
out.header = typeof config.header === "string" ? config.header : null;
out.value = redactField(config.value);
}
return out;
}
/**
* @param {unknown} value
*/
function redactField(value) {
const kind = valueSourceKind(value);
if (kind === "missing") return { source: "missing" };
if (kind === "kv") {
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
return {
source: "kv",
kv: v.kv,
...(v.namespace != null ? { namespace: v.namespace } : {}),
};
}
if (kind === "secret") {
const v = /** @type {{ secret: string }} */ (value);
return { source: "secret", secret: v.secret };
}
return { source: "literal", set: true };
}
/**
* Validate and normalize auth config for storage.
* @param {string} type
* @param {unknown} config
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
*/
export function normalizeAuthConfig(type, config, opts = {}) {
const raw = config && typeof config === "object" && !Array.isArray(config)
? /** @type {Record<string, unknown>} */ (config)
: {};
const keep = opts.keepLiteralsFrom ?? {};
if (type === "bearer") {
return {
token: normalizeCredentialField(raw.token, keep.token, "token"),
};
}
if (type === "basic") {
return {
user: normalizeCredentialField(raw.user, keep.user, "user"),
password: normalizeCredentialField(raw.password, keep.password, "password", {
allowEmpty: true,
}),
};
}
// header
if (typeof raw.header !== "string" || raw.header.length === 0) {
const err = new Error("header name must be a non-empty string");
err.statusCode = 400;
throw err;
}
return {
header: raw.header,
value: normalizeCredentialField(raw.value, keep.value, "value"),
};
}
/**
* @param {unknown} value
* @param {unknown} previous
* @param {string} label
* @param {{ allowEmpty?: boolean }} [opts]
*/
function normalizeCredentialField(value, previous, label, opts = {}) {
// Explicit "keep previous literal" marker from UI when editing without re-entering
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
/** @type {{ keep?: boolean }} */ (value).keep === true
) {
if (typeof previous === "string") return previous;
if (previous && typeof previous === "object") return previous;
const err = new Error(`${label} was not previously set`);
err.statusCode = 400;
throw err;
}
if (value == null || value === "") {
if (opts.allowEmpty && value === "") return "";
// Allow empty password for basic
if (opts.allowEmpty && (value === "" || value == null)) {
if (typeof previous === "string") return previous;
return "";
}
const err = new Error(`${label} is required`);
err.statusCode = 400;
throw err;
}
if (typeof value === "string") return value;
if (typeof value === "object" && !Array.isArray(value)) {
const v = /** @type {Record<string, unknown>} */ (value);
if (typeof v.secret === "string" && v.secret.length > 0) {
return { secret: v.secret };
}
if (typeof v.kv === "string" && v.kv.length > 0) {
/** @type {{ kv: string, namespace?: string }} */
const out = { kv: v.kv };
if (typeof v.namespace === "string" && v.namespace.length > 0) {
out.namespace = v.namespace;
}
return out;
}
}
const err = new Error(
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
);
err.statusCode = 400;
throw err;
}
function parseConfig(raw) {
if (typeof raw !== "string") return raw ?? {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
function publicAuth(row, { includeConfig = true } = {}) {
const type = row.type;
const config = parseConfig(row.config);
return {
id: row.id,
name: row.name,
type,
...(includeConfig ? { config: publicConfig(config, type) } : {}),
unauthorized_status: row.unauthorized_status ?? null,
unauthorized_response: row.unauthorized_response ?? null,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
/**
* Internal: full config including literals (for runtime auth checks).
* @param {string} name
*/
export async function getHttpAuthInternal(name) {
const row = await db("http_auths").where({ name: assertAuthName(name) }).first();
if (!row) return null;
return {
id: row.id,
name: row.name,
type: row.type,
config: parseConfig(row.config),
unauthorized_status: row.unauthorized_status ?? null,
unauthorized_response: row.unauthorized_response ?? null,
};
}
export async function listHttpAuths() {
const rows = await db("http_auths").select("*").orderBy("name", "asc");
return rows.map((r) => publicAuth(r));
}
/**
* @param {string} name
*/
export async function getHttpAuthByName(name) {
const row = await db("http_auths").where({ name: assertAuthName(name) }).first();
return row ? publicAuth(row) : null;
}
/**
* @param {string} id
*/
export async function getHttpAuthById(id) {
const row = await db("http_auths").where({ id }).first();
return row ? publicAuth(row) : null;
}
/**
* @param {{
* name: string,
* type: string,
* config?: unknown,
* unauthorized_status?: number | null,
* unauthorized_response?: string | null,
* }} opts
*/
export async function upsertHttpAuth({
name,
type,
config,
unauthorized_status,
unauthorized_response,
}) {
const authName = assertAuthName(name);
const authType = assertAuthType(type);
const existing = await db("http_auths").where({ name: authName }).first();
const prevConfig = existing ? parseConfig(existing.config) : {};
const normalized = normalizeAuthConfig(authType, config, {
keepLiteralsFrom: prevConfig,
});
let unauthStatus = null;
if (unauthorized_status != null && unauthorized_status !== "") {
unauthStatus = assertHttpStatus(unauthorized_status, 401);
}
let unauthResponse = null;
if (
unauthorized_response != null &&
String(unauthorized_response).length > 0
) {
unauthResponse = String(unauthorized_response);
}
const now = nowIso();
const configJson = JSON.stringify(normalized);
if (existing) {
await db("http_auths")
.where({ id: existing.id })
.update({
type: authType,
config: configJson,
unauthorized_status: unauthStatus,
unauthorized_response: unauthResponse,
updated_at: now,
});
return getHttpAuthById(existing.id);
}
const id = randomUUID();
await db("http_auths").insert({
id,
name: authName,
type: authType,
config: configJson,
unauthorized_status: unauthStatus,
unauthorized_response: unauthResponse,
created_at: now,
updated_at: now,
});
return getHttpAuthById(id);
}
/**
* @param {string} id
* @returns {Promise<boolean>}
*/
export async function deleteHttpAuth(id) {
const n = await db("http_auths").where({ id }).del();
return n > 0;
}
+150
View File
@@ -0,0 +1,150 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
const MAX_NAME_LENGTH = 128;
const NAME_RE = /^[A-Za-z0-9._-]+$/;
const ALLOWED_MIME = new Set(["html", "json"]);
function nowIso() {
return new Date().toISOString();
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertPageName(name) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
const err = new Error("invalid page name");
err.statusCode = 400;
throw err;
}
if (name.length > MAX_NAME_LENGTH) {
const err = new Error(`page name must be at most ${MAX_NAME_LENGTH} characters`);
err.statusCode = 400;
throw err;
}
return name;
}
/**
* @param {unknown} mime
* @returns {"html" | "json"}
*/
export function assertMime(mime) {
const m = String(mime ?? "");
if (!ALLOWED_MIME.has(m)) {
const err = new Error('mime must be "html" or "json"');
err.statusCode = 400;
throw err;
}
return /** @type {"html" | "json"} */ (m);
}
/**
* @param {unknown} status
* @returns {number}
*/
export function assertHttpStatus(status, fallback = 200) {
if (status == null || status === "") return fallback;
const n = Number(status);
if (!Number.isInteger(n) || n < 100 || n > 599) {
const err = new Error("status must be an HTTP status code (100-599)");
err.statusCode = 400;
throw err;
}
return n;
}
function publicPage(row) {
return {
id: row.id,
name: row.name,
content: row.content,
mime: row.mime,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
export async function listHttpPages() {
const rows = await db("http_pages")
.select("*")
.orderBy("name", "asc");
return rows.map(publicPage);
}
/**
* @param {string} name
*/
export async function getHttpPageByName(name) {
const row = await db("http_pages").where({ name: assertPageName(name) }).first();
return row ? publicPage(row) : null;
}
/**
* @param {string} id
*/
export async function getHttpPageById(id) {
const row = await db("http_pages").where({ id }).first();
return row ? publicPage(row) : null;
}
/**
* @param {{ name: string, content: string, mime: string, status?: number }} opts
*/
export async function upsertHttpPage({ name, content, mime, status }) {
const pageName = assertPageName(name);
const pageMime = assertMime(mime);
const pageStatus = assertHttpStatus(status, 200);
if (typeof content !== "string") {
const err = new Error("content must be a string");
err.statusCode = 400;
throw err;
}
const now = nowIso();
const existing = await db("http_pages").where({ name: pageName }).first();
if (existing) {
await db("http_pages")
.where({ id: existing.id })
.update({
content,
mime: pageMime,
status: pageStatus,
updated_at: now,
});
return getHttpPageById(existing.id);
}
const id = randomUUID();
await db("http_pages").insert({
id,
name: pageName,
content,
mime: pageMime,
status: pageStatus,
created_at: now,
updated_at: now,
});
return getHttpPageById(id);
}
/**
* @param {string} id
* @returns {Promise<boolean>}
*/
export async function deleteHttpPage(id) {
const n = await db("http_pages").where({ id }).del();
return n > 0;
}
/**
* Content-Type for a page mime.
* @param {"html" | "json" | string} mime
*/
export function contentTypeForMime(mime) {
if (mime === "html") return "text/html; charset=utf-8";
return "application/json; charset=utf-8";
}
+277
View File
@@ -0,0 +1,277 @@
import { timingSafeEqual } from "node:crypto";
import { kvGet } from "./kv-store.js";
import { getSecretPlaintext } from "./secrets-store.js";
import { getHttpAuthInternal, assertAuthType } from "./http-auths-store.js";
import { getHttpPageByName, contentTypeForMime } from "./http-pages-store.js";
import { log } from "./logger.js";
/**
* @param {string} a
* @param {string} b
*/
function safeEqualString(a, b) {
const ba = Buffer.from(String(a), "utf8");
const bb = Buffer.from(String(b), "utf8");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/**
* Coerce a KV JSON value to a string credential. Objects fail closed.
* @param {unknown} value
* @returns {string | null}
*/
export function coerceCredentialString(value) {
if (value == null) return null;
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return null;
}
/**
* Resolve a credential field: literal string, { kv }, or { secret }.
* @param {unknown} field
* @param {{ owner: string, workflowKey: string }} ctx
* @returns {Promise<string | null>}
*/
export async function resolveCredentialValue(field, ctx) {
if (field == null) return null;
if (typeof field === "string") return field;
if (typeof field === "object" && !Array.isArray(field)) {
const f = /** @type {Record<string, unknown>} */ (field);
if (typeof f.secret === "string" && f.secret.length > 0) {
try {
return await getSecretPlaintext(ctx.owner, f.secret);
} catch (err) {
log.warn(
{ err, secret: f.secret, owner: ctx.owner },
"http auth: failed to resolve secret",
);
return null;
}
}
if (typeof f.kv === "string" && f.kv.length > 0) {
const namespace =
typeof f.namespace === "string" && f.namespace.length > 0
? f.namespace
: ctx.workflowKey;
try {
const raw = await kvGet(namespace, f.kv);
return coerceCredentialString(raw);
} catch (err) {
log.warn(
{ err, kv: f.kv, namespace },
"http auth: failed to resolve kv",
);
return null;
}
}
}
return null;
}
/**
* Normalize trigger.auth into an inline auth mechanism object.
* @param {unknown} authField
* @returns {Promise<{
* type: string,
* config: Record<string, unknown>,
* unauthorized_status?: number | null,
* unauthorized_response?: string | null,
* label: string,
* } | null>}
*/
export async function resolveAuthMechanism(authField) {
if (authField == null || authField === false) return null;
if (typeof authField === "string") {
const named = await getHttpAuthInternal(authField);
if (!named) {
log.warn({ name: authField }, "http auth: named profile not found");
return null;
}
return {
type: named.type,
config: named.config,
unauthorized_status: named.unauthorized_status,
unauthorized_response: named.unauthorized_response,
label: authField,
};
}
if (typeof authField === "object" && !Array.isArray(authField)) {
const obj = /** @type {Record<string, unknown>} */ (authField);
if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) {
return resolveAuthMechanism(obj.name);
}
try {
const type = assertAuthType(obj.type);
/** @type {Record<string, unknown>} */
const config = { ...obj };
delete config.type;
delete config.name;
return {
type,
config,
unauthorized_status: null,
unauthorized_response: null,
label: type,
};
} catch (err) {
log.warn({ err }, "http auth: invalid inline auth");
return null;
}
}
return null;
}
/**
* Label for mermaid / summary (sync, no DB).
* @param {unknown} authField
*/
export function authLabel(authField) {
if (authField == null) return null;
if (typeof authField === "string") return authField;
if (typeof authField === "object" && !Array.isArray(authField)) {
const o = /** @type {Record<string, unknown>} */ (authField);
if (typeof o.name === "string" && o.name) return o.name;
if (typeof o.type === "string" && o.type) return o.type;
}
return "auth";
}
/**
* @param {import("fastify").FastifyRequest} req
* @param {{ type: string, config: Record<string, unknown> }} mechanism
* @param {{ owner: string, workflowKey: string }} ctx
* @returns {Promise<boolean>}
*/
export async function checkHttpAuth(req, mechanism, ctx) {
const type = mechanism.type;
const config = mechanism.config ?? {};
if (type === "bearer") {
const expected = await resolveCredentialValue(config.token, ctx);
if (expected == null) return false;
const header = req.headers.authorization;
if (typeof header !== "string") return false;
const m = /^Bearer\s+(.+)$/i.exec(header.trim());
if (!m) return false;
return safeEqualString(m[1], expected);
}
if (type === "basic") {
const expectedUser = await resolveCredentialValue(config.user, ctx);
if (expectedUser == null) return false;
const expectedPass =
(await resolveCredentialValue(config.password, ctx)) ?? "";
const header = req.headers.authorization;
if (typeof header !== "string") return false;
const m = /^Basic\s+(.+)$/i.exec(header.trim());
if (!m) return false;
let decoded;
try {
decoded = Buffer.from(m[1], "base64").toString("utf8");
} catch {
return false;
}
const colon = decoded.indexOf(":");
const user = colon === -1 ? decoded : decoded.slice(0, colon);
const pass = colon === -1 ? "" : decoded.slice(colon + 1);
return safeEqualString(user, expectedUser) && safeEqualString(pass, expectedPass);
}
if (type === "header") {
const headerName = config.header;
if (typeof headerName !== "string" || headerName.length === 0) return false;
const expected = await resolveCredentialValue(config.value, ctx);
if (expected == null) return false;
const actual = req.headers[headerName.toLowerCase()];
if (actual == null) return false;
const actualStr = Array.isArray(actual) ? actual[0] : String(actual);
return safeEqualString(actualStr, expected);
}
return false;
}
/**
* Resolve unauthorized response settings from trigger + named profile defaults.
* @param {Record<string, unknown> | undefined} trigger
* @param {{ unauthorized_status?: number | null, unauthorized_response?: string | null } | null} mechanism
*/
export function resolveUnauthorizedSpec(trigger, mechanism) {
const unauth =
trigger?.unauthorized &&
typeof trigger.unauthorized === "object" &&
!Array.isArray(trigger.unauthorized)
? /** @type {Record<string, unknown>} */ (trigger.unauthorized)
: {};
let status = 401;
if (unauth.status != null) {
const n = Number(unauth.status);
if (Number.isInteger(n) && n >= 100 && n <= 599) status = n;
} else if (mechanism?.unauthorized_status != null) {
status = mechanism.unauthorized_status;
}
let pageName = null;
if (typeof unauth.response === "string" && unauth.response.length > 0) {
pageName = unauth.response;
} else if (
typeof mechanism?.unauthorized_response === "string" &&
mechanism.unauthorized_response.length > 0
) {
pageName = mechanism.unauthorized_response;
}
return { status, pageName };
}
/**
* Send a named HTTP page or a default JSON body.
* @param {import("fastify").FastifyReply} reply
* @param {number} status
* @param {string | null} pageName
* @param {unknown} [fallbackBody]
*/
export async function sendHttpPageOrJson(reply, status, pageName, fallbackBody) {
if (pageName) {
const page = await getHttpPageByName(pageName);
if (page) {
const code = status ?? page.status;
return reply
.code(code)
.type(contentTypeForMime(page.mime))
.send(page.content);
}
log.warn({ pageName }, "http page not found; using fallback");
}
return reply.code(status).send(fallbackBody ?? { error: "unauthorized" });
}
/**
* Send a named success response page (uses page's own status by default).
* @param {import("fastify").FastifyReply} reply
* @param {string} pageName
* @param {unknown} [fallbackBody]
*/
export async function sendSuccessPage(reply, pageName, fallbackBody) {
const page = await getHttpPageByName(pageName);
if (page) {
return reply
.code(page.status)
.type(contentTypeForMime(page.mime))
.send(page.content);
}
log.warn({ pageName }, "success page not found; using default JSON");
return reply.send(fallbackBody);
}
@@ -0,0 +1,33 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.createTable("http_pages", (t) => {
t.text("id").primary();
t.text("name").notNullable().unique();
t.text("content").notNullable();
t.text("mime").notNullable(); // html | json
t.integer("status").notNullable().defaultTo(200);
t.text("created_at").notNullable();
t.text("updated_at").notNullable();
});
await knex.schema.createTable("http_auths", (t) => {
t.text("id").primary();
t.text("name").notNullable().unique();
t.text("type").notNullable(); // bearer | basic | header
t.text("config").notNullable(); // JSON
t.integer("unauthorized_status").nullable();
t.text("unauthorized_response").nullable();
t.text("created_at").notNullable();
t.text("updated_at").notNullable();
});
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.dropTableIfExists("http_auths");
await knex.schema.dropTableIfExists("http_pages");
}
+104 -37
View File
@@ -16,12 +16,21 @@ import {
parseScriptStep,
} from "./workflow-parse.js";
import * as fsStore from "./fs-store.js";
import {
checkHttpAuth,
resolveAuthMechanism,
resolveUnauthorizedSpec,
sendHttpPageOrJson,
sendSuccessPage,
} from "./http-trigger-auth.js";
/**
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
* @typedef {{ key: string, owner: string, trigger: any }} HttpRouteEntry
*/
const MAX_WORKFLOW_TRIGGER_DEPTH = 8;
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
/**
* @param {unknown} workflow
@@ -45,8 +54,9 @@ export function createRegistry(server) {
const cronTasks = [];
/** @type {import("node-cron").ScheduledTask | null} */
let pruneTask = null;
/** @type {Set<string>} */
const registeredHttpRoutes = new Set();
/** @type {Map<string, HttpRouteEntry>} */
const httpRoutes = new Map();
let httpDispatcherRegistered = false;
/**
* Resolve a same-owner workflow that opts in with `type: workflow`.
@@ -144,8 +154,12 @@ export function createRegistry(server) {
log.debug({ count: workflows.size }, "workflows loaded");
}
/**
* Rebuild in-memory METHOD+path → workflow map. Registers a single /u/*
* Fastify route once so path/method changes apply on reregister without restart.
*/
function registerHttpTriggers() {
const seen = new Set();
httpRoutes.clear();
for (const [key, { owner, workflow }] of workflows) {
if (workflow.enabled === false) {
@@ -160,45 +174,98 @@ export function createRegistry(server) {
const url = namespacedPath(owner, trigger.path);
const routeKey = `${method} ${url}`;
if (seen.has(routeKey)) {
if (httpRoutes.has(routeKey)) {
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
continue;
}
seen.add(routeKey);
if (registeredHttpRoutes.has(routeKey)) {
continue;
}
registeredHttpRoutes.add(routeKey);
server.route({
method,
url,
handler: async (req, reply) => {
const entry = workflows.get(key);
if (!entry || entry.workflow?.enabled === false) {
return reply.code(404).send({ error: "workflow disabled" });
}
const result = await runWorkflow(
key,
{ data: req.body },
{ type: "http", detail: `${method} ${url}` },
);
if (result.status === "failed") {
return reply.code(500).send({
runId: result.runId,
error: result.error,
});
}
return reply.send({
runId: result.runId,
result: result.result,
});
},
});
log.debug(`Registered HTTP trigger ${routeKey} (${key})`);
httpRoutes.set(routeKey, { key, owner, trigger });
log.debug(`Mapped HTTP trigger ${routeKey} (${key})`);
}
}
if (!httpDispatcherRegistered) {
httpDispatcherRegistered = true;
server.route({
method: HTTP_METHODS,
url: "/u/*",
handler: dispatchHttpTrigger,
});
log.debug("Registered HTTP trigger wildcard dispatcher /u/*");
}
}
/**
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
*/
async function dispatchHttpTrigger(req, reply) {
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
const method = String(req.method ?? "GET").toUpperCase();
const routeKey = `${method} ${url}`;
const mapped = httpRoutes.get(routeKey);
if (!mapped) {
return reply.code(404).send({ error: "not found" });
}
const entry = workflows.get(mapped.key);
if (!entry || entry.workflow?.enabled === false) {
return reply.code(404).send({ error: "workflow disabled" });
}
// Prefer live trigger from current workflow YAML (auth/response edits)
const liveTrigger =
(entry.workflow.triggers ?? []).find((t) => {
if (t?.type !== "HTTP") return false;
const m = String(t.method ?? "POST").toUpperCase();
const p = namespacedPath(entry.owner, t.path);
return m === method && p === url;
}) ?? mapped.trigger;
if (liveTrigger.auth != null && liveTrigger.auth !== false) {
const mechanism = await resolveAuthMechanism(liveTrigger.auth);
if (!mechanism) {
const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null);
return sendHttpPageOrJson(reply, status, pageName, {
error: "unauthorized",
});
}
const ok = await checkHttpAuth(req, mechanism, {
owner: entry.owner,
workflowKey: mapped.key,
});
if (!ok) {
const { status, pageName } = resolveUnauthorizedSpec(
liveTrigger,
mechanism,
);
return sendHttpPageOrJson(reply, status, pageName, {
error: "unauthorized",
});
}
}
const result = await runWorkflow(
mapped.key,
{ data: req.body },
{ type: "http", detail: `${method} ${url}` },
);
if (result.status === "failed") {
return reply.code(500).send({
runId: result.runId,
error: result.error,
});
}
const defaultBody = {
runId: result.runId,
result: result.result,
};
if (typeof liveTrigger.response === "string" && liveTrigger.response) {
return sendSuccessPage(reply, liveTrigger.response, defaultBody);
}
return reply.send(defaultBody);
}
function registerCronTriggers() {
+4
View File
@@ -17,6 +17,8 @@ import runsPlugin from "./src/api/runs.js";
import dashboardPluginFactory from "./src/api/dashboard.js";
import secretsPlugin from "./src/api/secrets.js";
import kvPlugin from "./src/api/kv.js";
import httpPagesPlugin from "./src/api/http-pages.js";
import httpAuthsPlugin from "./src/api/http-auths.js";
import { WEB_DIST } from "./paths.js";
import { resolveSecretsKeyMaterial } from "./secrets.js";
@@ -90,6 +92,8 @@ await server.register(
await api.register(usersPlugin);
await api.register(secretsPlugin);
await api.register(kvPlugin);
await api.register(httpPagesPlugin);
await api.register(httpAuthsPlugin);
await api.register(scriptsPluginFactory(registry));
await api.register(workflowsPluginFactory(registry));
await api.register(runsPlugin);
+78
View File
@@ -0,0 +1,78 @@
import {
assertAuthName,
assertAuthType,
listHttpAuths,
getHttpAuthById,
getHttpAuthByName,
upsertHttpAuth,
deleteHttpAuth,
} from "../../http-auths-store.js";
import { getHttpPageByName } from "../../http-pages-store.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function httpAuthsPlugin(fastify) {
fastify.get("/http-auths", async () => {
return { auths: await listHttpAuths() };
});
fastify.get("/http-auths/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
assertAuthName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const auth = await getHttpAuthByName(name);
if (!auth) {
return reply.code(404).send({ error: "auth not found" });
}
return { auth };
});
fastify.put("/http-auths", async (req, reply) => {
const body = /** @type {{
name?: string,
type?: string,
config?: unknown,
unauthorized_status?: number | null,
unauthorized_response?: string | null,
}} */ (req.body ?? {});
try {
assertAuthName(String(body.name ?? ""));
assertAuthType(body.type);
if (
body.unauthorized_response != null &&
String(body.unauthorized_response).length > 0
) {
const page = await getHttpPageByName(String(body.unauthorized_response));
if (!page) {
return reply
.code(400)
.send({ error: `unknown response page "${body.unauthorized_response}"` });
}
}
const auth = await upsertHttpAuth({
name: String(body.name),
type: String(body.type),
config: body.config,
unauthorized_status: body.unauthorized_status,
unauthorized_response: body.unauthorized_response,
});
return reply.send({ auth });
} catch (err) {
return reply.code(err.statusCode ?? 500).send({ error: err.message });
}
});
fastify.delete("/http-auths/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const existing = await getHttpAuthById(id);
if (!existing) {
return reply.code(404).send({ error: "auth not found" });
}
await deleteHttpAuth(id);
return { ok: true };
});
}
+69
View File
@@ -0,0 +1,69 @@
import {
assertPageName,
assertMime,
assertHttpStatus,
listHttpPages,
getHttpPageById,
getHttpPageByName,
upsertHttpPage,
deleteHttpPage,
} from "../../http-pages-store.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function httpPagesPlugin(fastify) {
fastify.get("/http-pages", async () => {
return { pages: await listHttpPages() };
});
fastify.get("/http-pages/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
assertPageName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const page = await getHttpPageByName(name);
if (!page) {
return reply.code(404).send({ error: "page not found" });
}
return { page };
});
fastify.put("/http-pages", async (req, reply) => {
const body = /** @type {{
name?: string,
content?: string,
mime?: string,
status?: number,
}} */ (req.body ?? {});
try {
assertPageName(String(body.name ?? ""));
assertMime(body.mime);
assertHttpStatus(body.status, 200);
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content must be a string" });
}
const page = await upsertHttpPage({
name: String(body.name),
content: body.content,
mime: String(body.mime),
status: body.status,
});
return reply.send({ page });
} catch (err) {
return reply.code(err.statusCode ?? 500).send({ error: err.message });
}
});
fastify.delete("/http-pages/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const existing = await getHttpPageById(id);
if (!existing) {
return reply.code(404).send({ error: "page not found" });
}
await deleteHttpPage(id);
return { ok: true };
});
}
+12
View File
@@ -6,6 +6,10 @@ import {
namespacedPath,
parseScriptStep,
} from "../../workflow-parse.js";
import {
authLabel,
validateWorkflowHttpTriggers,
} from "../../workflow-http-validate.js";
function triggerSummary(owner, workflow) {
if (!workflow || typeof workflow !== "object") return [];
@@ -17,6 +21,7 @@ function triggerSummary(owner, workflow) {
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,
auth: isHttp ? authLabel(t?.auth) : null,
};
});
}
@@ -180,6 +185,13 @@ export default function workflowsPluginFactory(registry) {
error: err instanceof Error ? err.message : String(err),
});
}
try {
await validateWorkflowHttpTriggers(parsed);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({
error: 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);
@@ -0,0 +1,293 @@
import { migrate, db } from "../db.js";
import { kvSet, kvDelete } from "../kv-store.js";
import { upsertSecret, deleteSecret, listSecrets } from "../secrets-store.js";
import {
upsertHttpPage,
deleteHttpPage,
listHttpPages,
} from "../http-pages-store.js";
import {
upsertHttpAuth,
deleteHttpAuth,
getHttpAuthInternal,
} from "../http-auths-store.js";
import {
checkHttpAuth,
coerceCredentialString,
resolveAuthMechanism,
resolveCredentialValue,
resolveUnauthorizedSpec,
sendHttpPageOrJson,
} from "../http-trigger-auth.js";
import { validateWorkflowHttpTriggers } from "../workflow-http-validate.js";
import { log } from "../logger.js";
await migrate();
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
function mockReq(headers = {}) {
return { headers };
}
const owner = "default";
const workflowKey = "default/auth-smoke.yaml";
const ctx = { owner, workflowKey };
// --- coerce ---
assert(coerceCredentialString("abc") === "abc", "coerce string");
assert(coerceCredentialString(42) === "42", "coerce number");
assert(coerceCredentialString({ a: 1 }) === null, "coerce object fails");
// --- pages ---
const page = await upsertHttpPage({
name: "deny-smoke",
content: "<h1>denied</h1>",
mime: "html",
status: 403,
});
assert(page.name === "deny-smoke", "page upsert");
// --- literal bearer ---
{
const mech = await resolveAuthMechanism({
type: "bearer",
token: "secret-token-ok",
});
assert(mech?.type === "bearer", "inline bearer");
const ok = await checkHttpAuth(
mockReq({ authorization: "Bearer secret-token-ok" }),
mech,
ctx,
);
assert(ok, "bearer match");
const bad = await checkHttpAuth(
mockReq({ authorization: "Bearer wrong" }),
mech,
ctx,
);
assert(!bad, "bearer mismatch");
const missing = await checkHttpAuth(mockReq({}), mech, ctx);
assert(!missing, "bearer missing");
}
// --- basic ---
{
const mech = await resolveAuthMechanism({
type: "basic",
user: "alice",
password: "hunter2",
});
const encoded = Buffer.from("alice:hunter2").toString("base64");
const ok = await checkHttpAuth(
mockReq({ authorization: `Basic ${encoded}` }),
mech,
ctx,
);
assert(ok, "basic match");
const badEnc = Buffer.from("alice:wrong").toString("base64");
const bad = await checkHttpAuth(
mockReq({ authorization: `Basic ${badEnc}` }),
mech,
ctx,
);
assert(!bad, "basic mismatch");
}
// --- header ---
{
const mech = await resolveAuthMechanism({
type: "header",
header: "X-Webhook-Secret",
value: "hdr-secret",
});
const ok = await checkHttpAuth(
mockReq({ "x-webhook-secret": "hdr-secret" }),
mech,
ctx,
);
assert(ok, "header match");
const bad = await checkHttpAuth(
mockReq({ "x-webhook-secret": "nope" }),
mech,
ctx,
);
assert(!bad, "header mismatch");
}
// --- KV ref ---
await kvSet("auth", "webhook-token", "from-kv");
{
const resolved = await resolveCredentialValue(
{ kv: "webhook-token", namespace: "auth" },
ctx,
);
assert(resolved === "from-kv", "kv resolve");
const mech = await resolveAuthMechanism({
type: "bearer",
token: { kv: "webhook-token", namespace: "auth" },
});
const ok = await checkHttpAuth(
mockReq({ authorization: "Bearer from-kv" }),
mech,
ctx,
);
assert(ok, "bearer from kv");
const missingKv = await checkHttpAuth(
mockReq({ authorization: "Bearer from-kv" }),
{
type: "bearer",
config: { token: { kv: "missing-key", namespace: "auth" } },
},
ctx,
);
assert(!missingKv, "missing kv fails closed");
}
await kvDelete("auth", "webhook-token");
// --- secret ref ---
const secret = await upsertSecret({
owner,
name: "http_auth_smoke_token",
value: "encrypted-token-ok",
});
{
const resolved = await resolveCredentialValue(
{ secret: "http_auth_smoke_token" },
ctx,
);
assert(resolved === "encrypted-token-ok", "secret resolve");
const mech = await resolveAuthMechanism({
type: "bearer",
token: { secret: "http_auth_smoke_token" },
});
const ok = await checkHttpAuth(
mockReq({ authorization: "Bearer encrypted-token-ok" }),
mech,
ctx,
);
assert(ok, "bearer from secret");
const missingSec = await checkHttpAuth(
mockReq({ authorization: "Bearer x" }),
{
type: "bearer",
config: { token: { secret: "does_not_exist_xyz" } },
},
ctx,
);
assert(!missingSec, "missing secret fails closed");
}
// --- named profile ---
const profile = await upsertHttpAuth({
name: "webhook-smoke",
type: "bearer",
config: { token: "named-token" },
unauthorized_status: 403,
unauthorized_response: "deny-smoke",
});
{
const mech = await resolveAuthMechanism("webhook-smoke");
assert(mech?.label === "webhook-smoke", "named profile");
const ok = await checkHttpAuth(
mockReq({ authorization: "Bearer named-token" }),
mech,
ctx,
);
assert(ok, "named profile match");
const { status, pageName } = resolveUnauthorizedSpec({}, mech);
assert(status === 403, "profile unauth status");
assert(pageName === "deny-smoke", "profile unauth page");
}
// trigger-level override
{
const mech = await getHttpAuthInternal("webhook-smoke");
const { status, pageName } = resolveUnauthorizedSpec(
{ unauthorized: { status: 401, response: "deny-smoke" } },
mech,
);
assert(status === 401, "trigger override status");
assert(pageName === "deny-smoke", "trigger override page");
}
// default 401
{
const { status, pageName } = resolveUnauthorizedSpec({}, null);
assert(status === 401 && pageName == null, "default 401");
}
// validation
await validateWorkflowHttpTriggers({
triggers: [
{
type: "HTTP",
method: "POST",
path: "/x",
auth: "webhook-smoke",
response: "deny-smoke",
},
],
});
let threw = false;
try {
await validateWorkflowHttpTriggers({
triggers: [{ type: "HTTP", path: "/x", auth: "no-such-profile" }],
});
} catch {
threw = true;
}
assert(threw, "unknown auth fails validation");
threw = false;
try {
await validateWorkflowHttpTriggers({
triggers: [{ type: "HTTP", path: "/x", response: "no-such-page" }],
});
} catch {
threw = true;
}
assert(threw, "unknown page fails validation");
// send page helper (mock reply)
{
/** @type {any} */
const reply = {
_code: 200,
_type: null,
_body: null,
code(c) {
this._code = c;
return this;
},
type(t) {
this._type = t;
return this;
},
send(b) {
this._body = b;
return this;
},
};
await sendHttpPageOrJson(reply, 401, "deny-smoke", { error: "unauthorized" });
assert(reply._code === 401, "page status from arg");
assert(String(reply._type).includes("text/html"), "page mime");
assert(reply._body === "<h1>denied</h1>", "page body");
}
// cleanup
await deleteHttpAuth(profile.id);
await deleteHttpPage(page.id);
await deleteSecret(secret.id);
const leftover = (await listSecrets({ owner })).find(
(s) => s.name === "http_auth_smoke_token",
);
assert(!leftover, "secret cleaned");
assert(!(await listHttpPages()).some((p) => p.name === "deny-smoke"), "page cleaned");
log.info("http-trigger-auth-smoke ok");
await db.destroy();
process.exit(0);
+127
View File
@@ -0,0 +1,127 @@
/**
* Validate HTTP trigger auth / response fields on workflow save.
*/
import { assertAuthType, getHttpAuthByName } from "./http-auths-store.js";
import { getHttpPageByName } from "./http-pages-store.js";
import { authLabel } from "./http-trigger-auth.js";
/**
* @param {unknown} field
* @param {string} label
*/
function assertCredentialFieldShape(field, label) {
if (typeof field === "string") return;
if (field && typeof field === "object" && !Array.isArray(field)) {
const f = /** @type {Record<string, unknown>} */ (field);
if (typeof f.secret === "string" && f.secret.length > 0) return;
if (typeof f.kv === "string" && f.kv.length > 0) return;
}
const err = new Error(
`${label} must be a string, { kv: "..." }, or { secret: "..." }`,
);
err.statusCode = 400;
throw err;
}
/**
* @param {unknown} auth
*/
async function validateAuthField(auth) {
if (auth == null || auth === false) return;
if (typeof auth === "string") {
const named = await getHttpAuthByName(auth);
if (!named) {
const err = new Error(`unknown auth profile "${auth}"`);
err.statusCode = 400;
throw err;
}
return;
}
if (typeof auth === "object" && !Array.isArray(auth)) {
const obj = /** @type {Record<string, unknown>} */ (auth);
if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) {
await validateAuthField(obj.name);
return;
}
const type = assertAuthType(obj.type);
if (type === "bearer") {
assertCredentialFieldShape(obj.token, "auth.token");
} else if (type === "basic") {
assertCredentialFieldShape(obj.user, "auth.user");
if (obj.password != null && obj.password !== "") {
assertCredentialFieldShape(obj.password, "auth.password");
}
} else if (type === "header") {
if (typeof obj.header !== "string" || obj.header.length === 0) {
const err = new Error("auth.header must be a non-empty string");
err.statusCode = 400;
throw err;
}
assertCredentialFieldShape(obj.value, "auth.value");
}
return;
}
const err = new Error("auth must be a profile name or an auth object");
err.statusCode = 400;
throw err;
}
/**
* @param {unknown} pageName
* @param {string} label
*/
async function validatePageRef(pageName, label) {
if (pageName == null || pageName === "") return;
if (typeof pageName !== "string") {
const err = new Error(`${label} must be a string`);
err.statusCode = 400;
throw err;
}
const page = await getHttpPageByName(pageName);
if (!page) {
const err = new Error(`unknown response page "${pageName}"`);
err.statusCode = 400;
throw err;
}
}
/**
* @param {unknown} workflow
*/
export async function validateWorkflowHttpTriggers(workflow) {
if (!workflow || typeof workflow !== "object") return;
const triggers = /** @type {{ triggers?: unknown[] }} */ (workflow).triggers;
if (!Array.isArray(triggers)) return;
for (const t of triggers) {
if (!t || typeof t !== "object") continue;
const trigger = /** @type {Record<string, unknown>} */ (t);
if (String(trigger.type) !== "HTTP") continue;
await validateAuthField(trigger.auth);
if (
trigger.unauthorized &&
typeof trigger.unauthorized === "object" &&
!Array.isArray(trigger.unauthorized)
) {
const u = /** @type {Record<string, unknown>} */ (trigger.unauthorized);
if (u.status != null) {
const n = Number(u.status);
if (!Number.isInteger(n) || n < 100 || n > 599) {
const err = new Error("unauthorized.status must be 100-599");
err.statusCode = 400;
throw err;
}
}
await validatePageRef(u.response, "unauthorized.response");
}
await validatePageRef(trigger.response, "response");
}
}
export { authLabel };
+4
View File
@@ -13,6 +13,8 @@ import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx"
import { EventsPage } from "./pages/EventsPage.jsx";
import { EventDetailPage } from "./pages/EventDetailPage.jsx";
import { KvPage } from "./pages/KvPage.jsx";
import { AuthProfilesPage } from "./pages/AuthProfilesPage.jsx";
import { ResponsesPage } from "./pages/ResponsesPage.jsx";
import { UsersPage } from "./pages/UsersPage.jsx";
import { SecretsPage } from "./pages/SecretsPage.jsx";
@@ -59,6 +61,8 @@ export function App() {
<Route path="/events" element={<EventsPage />} />
<Route path="/events/:id" element={<EventDetailPage />} />
<Route path="/kv" element={<KvPage />} />
<Route path="/auth" element={<AuthProfilesPage />} />
<Route path="/responses" element={<ResponsesPage />} />
{user.role === "admin" ? (
<>
<Route path="/users" element={<UsersPage />} />
+48
View File
@@ -335,3 +335,51 @@ export function useKv(filters = {}) {
},
});
}
export function useHttpPages() {
return useQuery({
queryKey: ["http-pages"],
queryFn: async () => (await api.get("/http-pages")).data.pages,
});
}
export function useUpsertHttpPage() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/http-pages", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
});
}
export function useDeleteHttpPage() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/http-pages/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
});
}
export function useHttpAuths() {
return useQuery({
queryKey: ["http-auths"],
queryFn: async () => (await api.get("/http-auths")).data.auths,
});
}
export function useUpsertHttpAuth() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/http-auths", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
});
}
export function useDeleteHttpAuth() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/http-auths/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
});
}
+4
View File
@@ -3,12 +3,14 @@ import {
LuActivity,
LuCode,
LuDatabase,
LuFileText,
LuGitBranch,
LuHouse,
LuKey,
LuLogOut,
LuMenu,
LuMoon,
LuShield,
LuSun,
LuUsers,
} from "react-icons/lu";
@@ -21,6 +23,8 @@ const links = [
{ to: "/workflows", label: "Workflows", icon: LuGitBranch },
{ to: "/events", label: "Events", icon: LuActivity },
{ to: "/kv", label: "KV", icon: LuDatabase },
{ to: "/auth", label: "Auth", icon: LuShield },
{ to: "/responses", label: "Responses", icon: LuFileText },
];
export function Layout({ user, children }) {
+16 -1
View File
@@ -76,7 +76,22 @@ export function workflowToFlowchart(parsed) {
triggers.forEach((t, i) => {
const id = `t${i}`;
lines.push(` ${id}(["${mermaidLabel(triggerLabel(t))}"])`);
triggerIds.push(id);
let outbound = id;
if (t?.auth != null && t.auth !== false) {
const authId = `${id}auth`;
const label =
typeof t.auth === "string"
? `auth ${t.auth}`
: typeof t.auth === "object" && t.auth?.name
? `auth ${t.auth.name}`
: typeof t.auth === "object" && t.auth?.type
? `auth ${t.auth.type}`
: "auth";
lines.push(` ${authId}(["${mermaidLabel(label)}"])`);
lines.push(` ${id} --> ${authId}`);
outbound = authId;
}
triggerIds.push(outbound);
});
const scripts = Array.isArray(parsed.scripts) ? parsed.scripts : [];
+429
View File
@@ -0,0 +1,429 @@
import { useState } from "react";
import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useDeleteHttpAuth,
useHttpAuths,
useHttpPages,
useUpsertHttpAuth,
} from "../api/hooks.js";
import { formatTime } from "../lib/format.jsx";
function emptyCred(source = "literal") {
return { source, value: "", kv: "", namespace: "", secret: "" };
}
function credFromPublic(field) {
if (!field || field.source === "missing") return emptyCred("literal");
if (field.source === "kv") {
return {
source: "kv",
value: "",
kv: field.kv ?? "",
namespace: field.namespace ?? "",
secret: "",
};
}
if (field.source === "secret") {
return {
source: "secret",
value: "",
kv: "",
namespace: "",
secret: field.secret ?? "",
};
}
// literal — already set; keep marker so we don't require re-entry on edit
return {
source: "literal",
value: "",
kv: "",
namespace: "",
secret: "",
keep: field.set === true,
};
}
function toApiField(cred, { required = true } = {}) {
if (cred.source === "kv") {
const out = { kv: cred.kv };
if (cred.namespace) out.namespace = cred.namespace;
return out;
}
if (cred.source === "secret") {
return { secret: cred.secret };
}
if (cred.value) return cred.value;
if (cred.keep) return { keep: true };
if (!required) return "";
return null;
}
function sourceLabel(field) {
if (!field || field.source === "missing") return "—";
if (field.source === "kv") {
return field.namespace ? `kv:${field.namespace}/${field.kv}` : `kv:${field.kv}`;
}
if (field.source === "secret") return `secret:${field.secret}`;
if (field.source === "literal") return "literal";
return "—";
}
function CredentialFields({ label, cred, onChange, allowEmpty }) {
return (
<div className="space-y-1 border-base-300 border rounded-box p-3">
<label className="label py-0">{label}</label>
<select
className="select select-sm w-full"
value={cred.source}
onChange={(e) => onChange({ ...cred, source: e.target.value, keep: false })}
>
<option value="literal">Plain text</option>
<option value="kv">From KV</option>
<option value="secret">From secret</option>
</select>
{cred.source === "literal" ? (
<>
<input
type="password"
className="input input-sm w-full"
value={cred.value}
onChange={(e) => onChange({ ...cred, value: e.target.value, keep: false })}
placeholder={cred.keep ? "(unchanged — leave blank to keep)" : ""}
required={!allowEmpty && !cred.keep}
autoComplete="new-password"
/>
{cred.keep ? (
<p className="text-xs opacity-60">A value is already set. Enter a new one to replace it.</p>
) : null}
</>
) : null}
{cred.source === "kv" ? (
<div className="flex flex-col sm:flex-row gap-2">
<input
className="input input-sm w-full font-mono"
placeholder="namespace (optional)"
value={cred.namespace}
onChange={(e) => onChange({ ...cred, namespace: e.target.value })}
/>
<input
className="input input-sm w-full font-mono"
placeholder="key"
value={cred.kv}
onChange={(e) => onChange({ ...cred, kv: e.target.value })}
required
/>
</div>
) : null}
{cred.source === "secret" ? (
<input
className="input input-sm w-full font-mono"
placeholder="secret name"
value={cred.secret}
onChange={(e) => onChange({ ...cred, secret: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
/>
) : null}
</div>
);
}
const emptyForm = () => ({
name: "",
type: "bearer",
token: emptyCred(),
user: emptyCred(),
password: emptyCred(),
header: "",
value: emptyCred(),
unauthorized_status: "",
unauthorized_response: "",
});
export function AuthProfilesPage() {
const { data: auths = [], isLoading } = useHttpAuths();
const { data: pages = [] } = useHttpPages();
const upsert = useUpsertHttpAuth();
const del = useDeleteHttpAuth();
const [mode, setMode] = useState(null);
const [form, setForm] = useState(emptyForm);
const [confirmDelete, setConfirmDelete] = useState(null);
function openAdd() {
setMode("add");
setForm(emptyForm());
}
function openEdit(a) {
setMode("edit");
const cfg = a.config ?? {};
setForm({
name: a.name,
type: a.type,
token: credFromPublic(cfg.token),
user: credFromPublic(cfg.user),
password: credFromPublic(cfg.password),
header: cfg.header ?? "",
value: credFromPublic(cfg.value),
unauthorized_status: a.unauthorized_status ?? "",
unauthorized_response: a.unauthorized_response ?? "",
});
}
function closeForm() {
setMode(null);
setForm(emptyForm());
}
function onSubmit(e) {
e.preventDefault();
/** @type {Record<string, unknown>} */
let config = {};
if (form.type === "bearer") {
const token = toApiField(form.token);
if (token == null) return;
config = { token };
} else if (form.type === "basic") {
const user = toApiField(form.user);
if (user == null) return;
const password = toApiField(form.password, { required: false });
config = { user, password: password ?? "" };
} else {
const value = toApiField(form.value);
if (value == null) return;
config = { header: form.header, value };
}
upsert.mutate(
{
name: form.name,
type: form.type,
config,
unauthorized_status:
form.unauthorized_status === "" ? null : Number(form.unauthorized_status),
unauthorized_response: form.unauthorized_response || null,
},
{ onSuccess: closeForm },
);
}
function summarize(a) {
const cfg = a.config ?? {};
if (a.type === "bearer") return sourceLabel(cfg.token);
if (a.type === "basic") {
return `user=${sourceLabel(cfg.user)} pass=${sourceLabel(cfg.password)}`;
}
return `${cfg.header ?? "?"} = ${sourceLabel(cfg.value)}`;
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Auth</h1>
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd}>
<LuPlus className="size-4" />
Add
</button>
</div>
<p className="text-sm opacity-70">
Named HTTP trigger auth profiles. Reference in YAML as{" "}
<code className="font-mono text-xs">auth: name</code>. Secrets are managed on the Secrets
page; KV values stay in KV.
</p>
{isLoading ? (
<span className="loading loading-spinner" />
) : auths.length === 0 ? (
<p className="text-sm opacity-60">No auth profiles yet.</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Credentials</th>
<th>Updated</th>
<th />
</tr>
</thead>
<tbody>
{auths.map((a) => (
<tr key={a.id} className="hover">
<td className="font-mono">{a.name}</td>
<td>{a.type}</td>
<td className="font-mono text-xs">{summarize(a)}</td>
<td className="whitespace-nowrap">{formatTime(a.updated_at)}</td>
<td className="text-right whitespace-nowrap">
<button
type="button"
className="btn btn-ghost btn-xs"
title="Edit"
onClick={() => openEdit(a)}
>
<LuPencil className="size-4" />
</button>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
title="Delete"
onClick={() => setConfirmDelete(a)}
>
<LuTrash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{mode ? (
<form
onSubmit={onSubmit}
className="fieldset bg-base-100 border-base-300 rounded-box max-w-xl border p-4 space-y-2"
>
<div className="flex items-center justify-between">
<legend className="fieldset-legend">
{mode === "add" ? "New auth profile" : `Edit ${form.name}`}
</legend>
<button
type="button"
className="btn btn-ghost btn-sm btn-square"
onClick={closeForm}
aria-label="Close"
>
<LuX className="size-4" />
</button>
</div>
<label className="label">Name</label>
<input
className="input w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
disabled={mode === "edit"}
/>
<label className="label">Type</label>
<select
className="select w-full"
value={form.type}
onChange={(e) => setForm({ ...form, type: e.target.value })}
>
<option value="bearer">bearer</option>
<option value="basic">basic</option>
<option value="header">header</option>
</select>
{form.type === "bearer" ? (
<CredentialFields
label="Token"
cred={form.token}
onChange={(token) => setForm({ ...form, token })}
/>
) : null}
{form.type === "basic" ? (
<>
<CredentialFields
label="User"
cred={form.user}
onChange={(user) => setForm({ ...form, user })}
/>
<CredentialFields
label="Password"
cred={form.password}
onChange={(password) => setForm({ ...form, password })}
allowEmpty
/>
</>
) : null}
{form.type === "header" ? (
<>
<label className="label">Header name</label>
<input
className="input w-full font-mono"
value={form.header}
onChange={(e) => setForm({ ...form, header: e.target.value })}
required
placeholder="X-Webhook-Secret"
/>
<CredentialFields
label="Header value"
cred={form.value}
onChange={(value) => setForm({ ...form, value })}
/>
</>
) : null}
<label className="label">Unauthorized status (optional)</label>
<input
type="number"
className="input w-full"
value={form.unauthorized_status}
onChange={(e) => setForm({ ...form, unauthorized_status: e.target.value })}
min={100}
max={599}
placeholder="401"
/>
<label className="label">Unauthorized response page (optional)</label>
<select
className="select w-full"
value={form.unauthorized_response}
onChange={(e) => setForm({ ...form, unauthorized_response: e.target.value })}
>
<option value="">(default JSON)</option>
{pages.map((p) => (
<option key={p.id} value={p.name}>
{p.name}
</option>
))}
</select>
{upsert.isError ? (
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
) : null}
<button type="submit" className="btn btn-primary mt-2" disabled={upsert.isPending}>
Save
</button>
</form>
) : null}
{confirmDelete ? (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold">Delete {confirmDelete.name}?</h3>
<p className="text-sm mt-2">
Workflows that reference this profile will fail auth until updated.
</p>
{del.isError ? (
<p className="text-error text-sm mt-2">{errorMessage(del.error)}</p>
) : null}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>
Cancel
</button>
<button
type="button"
className="btn btn-error"
disabled={del.isPending}
onClick={() =>
del.mutate(confirmDelete.id, { onSuccess: () => setConfirmDelete(null) })
}
>
Delete
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={() => setConfirmDelete(null)}>
close
</button>
</form>
</dialog>
) : null}
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
import { useState } from "react";
import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useDeleteHttpPage,
useHttpPages,
useUpsertHttpPage,
} from "../api/hooks.js";
import { formatTime } from "../lib/format.jsx";
const emptyForm = {
name: "",
content: "",
mime: "html",
status: 200,
};
export function ResponsesPage() {
const { data: pages = [], isLoading } = useHttpPages();
const upsert = useUpsertHttpPage();
const del = useDeleteHttpPage();
const [mode, setMode] = useState(null);
const [form, setForm] = useState(emptyForm);
const [confirmDelete, setConfirmDelete] = useState(null);
function openAdd() {
setMode("add");
setForm(emptyForm);
}
function openEdit(p) {
setMode("edit");
setForm({
name: p.name,
content: p.content,
mime: p.mime,
status: p.status,
});
}
function closeForm() {
setMode(null);
setForm(emptyForm);
}
function onSubmit(e) {
e.preventDefault();
upsert.mutate(
{
name: form.name,
content: form.content,
mime: form.mime,
status: Number(form.status) || 200,
},
{ onSuccess: closeForm },
);
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Responses</h1>
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd}>
<LuPlus className="size-4" />
Add
</button>
</div>
<p className="text-sm opacity-70">
Named HTML/JSON pages for HTTP trigger success or unauthorized responses. Reference them in
YAML as <code className="font-mono text-xs">response: name</code>.
</p>
{isLoading ? (
<span className="loading loading-spinner" />
) : pages.length === 0 ? (
<p className="text-sm opacity-60">No response pages yet.</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Mime</th>
<th>Status</th>
<th>Updated</th>
<th />
</tr>
</thead>
<tbody>
{pages.map((p) => (
<tr key={p.id} className="hover">
<td className="font-mono">{p.name}</td>
<td>{p.mime}</td>
<td>{p.status}</td>
<td className="whitespace-nowrap">{formatTime(p.updated_at)}</td>
<td className="text-right whitespace-nowrap">
<button
type="button"
className="btn btn-ghost btn-xs"
title="Edit"
onClick={() => openEdit(p)}
>
<LuPencil className="size-4" />
</button>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
title="Delete"
onClick={() => setConfirmDelete(p)}
>
<LuTrash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{mode ? (
<form
onSubmit={onSubmit}
className="fieldset bg-base-100 border-base-300 rounded-box max-w-2xl border p-4"
>
<div className="flex items-center justify-between">
<legend className="fieldset-legend">
{mode === "add" ? "New response page" : `Edit ${form.name}`}
</legend>
<button
type="button"
className="btn btn-ghost btn-sm btn-square"
onClick={closeForm}
aria-label="Close"
>
<LuX className="size-4" />
</button>
</div>
<label className="label">Name</label>
<input
className="input w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens"
disabled={mode === "edit"}
/>
<label className="label">Mime</label>
<select
className="select w-full"
value={form.mime}
onChange={(e) => setForm({ ...form, mime: e.target.value })}
>
<option value="html">html</option>
<option value="json">json</option>
</select>
<label className="label">HTTP status</label>
<input
type="number"
className="input w-full"
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value })}
min={100}
max={599}
required
/>
<label className="label">Content</label>
<textarea
className="textarea w-full font-mono text-sm min-h-40"
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
required
/>
{upsert.isError ? (
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
) : null}
<button type="submit" className="btn btn-primary mt-2" disabled={upsert.isPending}>
Save
</button>
</form>
) : null}
{confirmDelete ? (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold">Delete {confirmDelete.name}?</h3>
<p className="text-sm mt-2">
Workflows that reference this page will fail validation on next save.
</p>
{del.isError ? (
<p className="text-error text-sm mt-2">{errorMessage(del.error)}</p>
) : null}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>
Cancel
</button>
<button
type="button"
className="btn btn-error"
disabled={del.isPending}
onClick={() =>
del.mutate(confirmDelete.id, { onSuccess: () => setConfirmDelete(null) })
}
>
Delete
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={() => setConfirmDelete(null)}>
close
</button>
</form>
</dialog>
) : null}
</div>
);
}