diff --git a/packages/server/http-auths-store.js b/packages/server/http-auths-store.js new file mode 100644 index 0000000..2f0cda8 --- /dev/null +++ b/packages/server/http-auths-store.js @@ -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} config + * @param {string} type + */ +export function publicConfig(config, type) { + /** @type {Record} */ + 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 }} [opts] + */ +export function normalizeAuthConfig(type, config, opts = {}) { + const raw = config && typeof config === "object" && !Array.isArray(config) + ? /** @type {Record} */ (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} */ (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} + */ +export async function deleteHttpAuth(id) { + const n = await db("http_auths").where({ id }).del(); + return n > 0; +} diff --git a/packages/server/http-pages-store.js b/packages/server/http-pages-store.js new file mode 100644 index 0000000..006137a --- /dev/null +++ b/packages/server/http-pages-store.js @@ -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} + */ +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"; +} diff --git a/packages/server/http-trigger-auth.js b/packages/server/http-trigger-auth.js new file mode 100644 index 0000000..7a7e7e1 --- /dev/null +++ b/packages/server/http-trigger-auth.js @@ -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} + */ +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} */ (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, + * 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} */ (authField); + if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) { + return resolveAuthMechanism(obj.name); + } + try { + const type = assertAuthType(obj.type); + /** @type {Record} */ + 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} */ (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 }} mechanism + * @param {{ owner: string, workflowKey: string }} ctx + * @returns {Promise} + */ +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 | 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} */ (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); +} diff --git a/packages/server/migrations/20260815060000_http_auth_pages.js b/packages/server/migrations/20260815060000_http_auth_pages.js new file mode 100644 index 0000000..f3c8fe9 --- /dev/null +++ b/packages/server/migrations/20260815060000_http_auth_pages.js @@ -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"); +} diff --git a/packages/server/registry.js b/packages/server/registry.js index 43c6eee..d25bc4c 100644 --- a/packages/server/registry.js +++ b/packages/server/registry.js @@ -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} */ - const registeredHttpRoutes = new Set(); + /** @type {Map} */ + 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() { diff --git a/packages/server/runner.js b/packages/server/runner.js index 5e4584a..452e380 100644 --- a/packages/server/runner.js +++ b/packages/server/runner.js @@ -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); diff --git a/packages/server/src/api/http-auths.js b/packages/server/src/api/http-auths.js new file mode 100644 index 0000000..99264ec --- /dev/null +++ b/packages/server/src/api/http-auths.js @@ -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 }; + }); +} diff --git a/packages/server/src/api/http-pages.js b/packages/server/src/api/http-pages.js new file mode 100644 index 0000000..8c7785c --- /dev/null +++ b/packages/server/src/api/http-pages.js @@ -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 }; + }); +} diff --git a/packages/server/src/api/workflows.js b/packages/server/src/api/workflows.js index ca41f3d..b4b8da4 100644 --- a/packages/server/src/api/workflows.js +++ b/packages/server/src/api/workflows.js @@ -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); diff --git a/packages/server/test/http-trigger-auth-smoke.js b/packages/server/test/http-trigger-auth-smoke.js new file mode 100644 index 0000000..06e64ea --- /dev/null +++ b/packages/server/test/http-trigger-auth-smoke.js @@ -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: "

denied

", + 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 === "

denied

", "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); diff --git a/packages/server/workflow-http-validate.js b/packages/server/workflow-http-validate.js new file mode 100644 index 0000000..7e5c93e --- /dev/null +++ b/packages/server/workflow-http-validate.js @@ -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} */ (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} */ (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} */ (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} */ (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 }; diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx index c0674b9..e067c3f 100644 --- a/packages/web/src/App.jsx +++ b/packages/web/src/App.jsx @@ -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() { } /> } /> } /> + } /> + } /> {user.role === "admin" ? ( <> } /> diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index 18e8949..eefdb69 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -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"] }), + }); +} diff --git a/packages/web/src/components/Layout.jsx b/packages/web/src/components/Layout.jsx index dc82807..5ec3262 100644 --- a/packages/web/src/components/Layout.jsx +++ b/packages/web/src/components/Layout.jsx @@ -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 }) { diff --git a/packages/web/src/lib/workflow-mermaid.js b/packages/web/src/lib/workflow-mermaid.js index 5fc3b84..39d2027 100644 --- a/packages/web/src/lib/workflow-mermaid.js +++ b/packages/web/src/lib/workflow-mermaid.js @@ -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 : []; diff --git a/packages/web/src/pages/AuthProfilesPage.jsx b/packages/web/src/pages/AuthProfilesPage.jsx new file mode 100644 index 0000000..b8761a3 --- /dev/null +++ b/packages/web/src/pages/AuthProfilesPage.jsx @@ -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 ( +
+ + + {cred.source === "literal" ? ( + <> + 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 ? ( +

A value is already set. Enter a new one to replace it.

+ ) : null} + + ) : null} + {cred.source === "kv" ? ( +
+ onChange({ ...cred, namespace: e.target.value })} + /> + onChange({ ...cred, kv: e.target.value })} + required + /> +
+ ) : null} + {cred.source === "secret" ? ( + onChange({ ...cred, secret: e.target.value })} + required + pattern="[A-Za-z0-9._-]+" + /> + ) : null} +
+ ); +} + +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} */ + 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 ( +
+
+

Auth

+ +
+

+ Named HTTP trigger auth profiles. Reference in YAML as{" "} + auth: name. Secrets are managed on the Secrets + page; KV values stay in KV. +

+ + {isLoading ? ( + + ) : auths.length === 0 ? ( +

No auth profiles yet.

+ ) : ( +
+ + + + + + + + + + + {auths.map((a) => ( + + + + + + + + ))} + +
NameTypeCredentialsUpdated +
{a.name}{a.type}{summarize(a)}{formatTime(a.updated_at)} + + +
+
+ )} + + {mode ? ( +
+
+ + {mode === "add" ? "New auth profile" : `Edit ${form.name}`} + + +
+ + setForm({ ...form, name: e.target.value })} + required + pattern="[A-Za-z0-9._-]+" + disabled={mode === "edit"} + /> + + + + {form.type === "bearer" ? ( + setForm({ ...form, token })} + /> + ) : null} + {form.type === "basic" ? ( + <> + setForm({ ...form, user })} + /> + setForm({ ...form, password })} + allowEmpty + /> + + ) : null} + {form.type === "header" ? ( + <> + + setForm({ ...form, header: e.target.value })} + required + placeholder="X-Webhook-Secret" + /> + setForm({ ...form, value })} + /> + + ) : null} + + + setForm({ ...form, unauthorized_status: e.target.value })} + min={100} + max={599} + placeholder="401" + /> + + + + {upsert.isError ? ( +

{errorMessage(upsert.error)}

+ ) : null} + + + ) : null} + + {confirmDelete ? ( + +
+

Delete {confirmDelete.name}?

+

+ Workflows that reference this profile will fail auth until updated. +

+ {del.isError ? ( +

{errorMessage(del.error)}

+ ) : null} +
+ + +
+
+
+ +
+
+ ) : null} +
+ ); +} diff --git a/packages/web/src/pages/ResponsesPage.jsx b/packages/web/src/pages/ResponsesPage.jsx new file mode 100644 index 0000000..96600af --- /dev/null +++ b/packages/web/src/pages/ResponsesPage.jsx @@ -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 ( +
+
+

Responses

+ +
+

+ Named HTML/JSON pages for HTTP trigger success or unauthorized responses. Reference them in + YAML as response: name. +

+ + {isLoading ? ( + + ) : pages.length === 0 ? ( +

No response pages yet.

+ ) : ( +
+ + + + + + + + + + + {pages.map((p) => ( + + + + + + + + ))} + +
NameMimeStatusUpdated +
{p.name}{p.mime}{p.status}{formatTime(p.updated_at)} + + +
+
+ )} + + {mode ? ( +
+
+ + {mode === "add" ? "New response page" : `Edit ${form.name}`} + + +
+ + setForm({ ...form, name: e.target.value })} + required + pattern="[A-Za-z0-9._-]+" + title="Letters, numbers, dots, underscores, hyphens" + disabled={mode === "edit"} + /> + + + + setForm({ ...form, status: e.target.value })} + min={100} + max={599} + required + /> + +