From db884808f2888951225db59101e5c1a0632b5078 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sat, 22 Aug 2026 06:16:38 +0700 Subject: [PATCH] refactor: split oversized modules and start server src/stores layout Consolidate format helpers under lib/format, split React Query hooks by domain, extract AuthPicker/ConfigRefHint and HTTP trigger routing, demote file-private exports, and move KV/secrets/variables/profiles/http-auths stores under src/stores with root re-exports. Co-authored-by: Cursor --- packages/server/control-state.js | 2 +- packages/server/http-auths-store.js | 391 +-------- packages/server/kv-store.js | 400 +-------- packages/server/profiles-store.js | 245 +----- packages/server/registry.js | 139 +--- packages/server/script-sandbox.js | 2 +- packages/server/secrets-store.js | 145 +--- packages/server/secrets.js | 2 +- .../server/src/stores/http-auths-store.js | 390 +++++++++ packages/server/src/stores/kv-store.js | 399 +++++++++ packages/server/src/stores/profiles-store.js | 244 ++++++ packages/server/src/stores/secrets-store.js | 144 ++++ packages/server/src/stores/variables-store.js | 190 +++++ packages/server/variables-store.js | 191 +---- packages/server/workflow-http-routes.js | 159 ++++ packages/server/workflow-trash.js | 2 +- packages/web/src/api/hooks.js | 762 +----------------- packages/web/src/api/hooks/auth.js | 91 +++ packages/web/src/api/hooks/http.js | 58 ++ packages/web/src/api/hooks/index.js | 10 + packages/web/src/api/hooks/kv.js | 25 + packages/web/src/api/hooks/ops.js | 83 ++ packages/web/src/api/hooks/profiles.js | 44 + packages/web/src/api/hooks/runs.js | 56 ++ packages/web/src/api/hooks/scripts.js | 99 +++ packages/web/src/api/hooks/secrets.js | 30 + packages/web/src/api/hooks/variables.js | 31 + packages/web/src/api/hooks/workflows.js | 273 +++++++ .../src/components/HeartbeatsAndPm2Card.jsx | 2 +- packages/web/src/components/LogViewer.jsx | 2 +- .../src/components/ProcessResourcesCard.jsx | 2 +- .../src/components/workflow/AuthPicker.jsx | 162 ++++ .../src/components/workflow/ConfigFields.jsx | 145 +--- .../src/components/workflow/ConfigRefHint.jsx | 145 ++++ .../src/components/workflow/ScriptCard.jsx | 4 +- .../src/components/workflow/TriggerCard.jsx | 163 +--- .../workflow/WorkflowHistoryPanel.jsx | 2 +- .../workflow/WorkflowRevisionBanner.jsx | 2 +- .../components/workflow/WorkflowTestPanel.jsx | 2 +- .../workflow/WorkflowVisualEditor.jsx | 2 +- .../src/lib/{format.jsx => format/badges.jsx} | 0 packages/web/src/lib/format/index.js | 2 + .../src/lib/{format.js => format/numbers.js} | 0 packages/web/src/pages/AuthProfilesPage.jsx | 2 +- packages/web/src/pages/EventDetailPage.jsx | 2 +- packages/web/src/pages/EventsPage.jsx | 2 +- packages/web/src/pages/FailuresPage.jsx | 2 +- packages/web/src/pages/HomePage.jsx | 2 +- packages/web/src/pages/KvPage.jsx | 2 +- packages/web/src/pages/ProfilesPage.jsx | 2 +- packages/web/src/pages/ResponsesPage.jsx | 2 +- packages/web/src/pages/ScriptDryRunPage.jsx | 2 +- packages/web/src/pages/SecretsPage.jsx | 2 +- packages/web/src/pages/VariablesPage.jsx | 2 +- packages/web/src/pages/WorkflowTrashPage.jsx | 2 +- packages/web/src/pages/WorkflowsPage.jsx | 2 +- 56 files changed, 2690 insertions(+), 2578 deletions(-) create mode 100644 packages/server/src/stores/http-auths-store.js create mode 100644 packages/server/src/stores/kv-store.js create mode 100644 packages/server/src/stores/profiles-store.js create mode 100644 packages/server/src/stores/secrets-store.js create mode 100644 packages/server/src/stores/variables-store.js create mode 100644 packages/server/workflow-http-routes.js create mode 100644 packages/web/src/api/hooks/auth.js create mode 100644 packages/web/src/api/hooks/http.js create mode 100644 packages/web/src/api/hooks/index.js create mode 100644 packages/web/src/api/hooks/kv.js create mode 100644 packages/web/src/api/hooks/ops.js create mode 100644 packages/web/src/api/hooks/profiles.js create mode 100644 packages/web/src/api/hooks/runs.js create mode 100644 packages/web/src/api/hooks/scripts.js create mode 100644 packages/web/src/api/hooks/secrets.js create mode 100644 packages/web/src/api/hooks/variables.js create mode 100644 packages/web/src/api/hooks/workflows.js create mode 100644 packages/web/src/components/workflow/AuthPicker.jsx create mode 100644 packages/web/src/components/workflow/ConfigRefHint.jsx rename packages/web/src/lib/{format.jsx => format/badges.jsx} (100%) create mode 100644 packages/web/src/lib/format/index.js rename packages/web/src/lib/{format.js => format/numbers.js} (100%) diff --git a/packages/server/control-state.js b/packages/server/control-state.js index 52ab475..b16e1d2 100644 --- a/packages/server/control-state.js +++ b/packages/server/control-state.js @@ -17,7 +17,7 @@ const LOCK_PATH = path.join(DATA_DIR, "ops.lock"); */ /** @returns {ControlState} */ -export function defaultControlState() { +function defaultControlState() { return { http: "running", workers: 1, diff --git a/packages/server/http-auths-store.js b/packages/server/http-auths-store.js index 45e4661..69193e2 100644 --- a/packages/server/http-auths-store.js +++ b/packages/server/http-auths-store.js @@ -1,390 +1 @@ -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 UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]); - -function nowIso() { - return new Date().toISOString(); -} - -/** - * @param {unknown} id - * @returns {string} - */ -export function assertAuthId(id) { - if (typeof id !== "string" || !UUID_RE.test(id)) { - const err = new Error("invalid auth id"); - err.statusCode = 400; - throw err; - } - return id.toLowerCase(); -} - -/** - * @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} id - */ -export async function getHttpAuthInternal(id) { - const authId = assertAuthId(id); - const row = await db("http_auths").where({ id: authId }).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, - }; -} - -/** - * Return only plaintext literal credential fields (not KV refs or encrypted secrets). - * @param {string} id - * @returns {Promise<{ id: string, name: string, type: string, literals: Record } | null>} - */ -export async function revealHttpAuthLiterals(id) { - const internal = await getHttpAuthInternal(id); - if (!internal) return null; - /** @type {Record} */ - const literals = {}; - const cfg = internal.config ?? {}; - for (const key of ["token", "user", "password", "value"]) { - const v = cfg[key]; - if (typeof v === "string") literals[key] = v; - } - return { - id: internal.id, - name: internal.name, - type: internal.type, - literals, - }; -} - -export async function listHttpAuths() { - const rows = await db("http_auths").select("*").orderBy("name", "asc"); - return rows.map((r) => publicAuth(r)); -} - -/** - * @param {string} id - */ -export async function getHttpAuthById(id) { - let authId; - try { - authId = assertAuthId(id); - } catch { - return null; - } - const row = await db("http_auths").where({ id: authId }).first(); - return row ? publicAuth(row) : null; -} - -/** - * @param {{ - * id?: string | null, - * name: string, - * type: string, - * config?: unknown, - * unauthorized_status?: number | null, - * unauthorized_response?: string | null, - * }} opts - */ -export async function upsertHttpAuth({ - id, - name, - type, - config, - unauthorized_status, - unauthorized_response, -}) { - const authName = assertAuthName(name); - const authType = assertAuthType(type); - - /** @type {Record | null} */ - let existing = null; - if (id != null && String(id).length > 0) { - const authId = assertAuthId(id); - existing = await db("http_auths").where({ id: authId }).first(); - if (!existing) { - const err = new Error("auth not found"); - err.statusCode = 404; - throw err; - } - } - - const nameClash = await db("http_auths").where({ name: authName }).first(); - if (nameClash && (!existing || nameClash.id !== existing.id)) { - const err = new Error(`auth name "${authName}" already exists`); - err.statusCode = 409; - throw err; - } - - 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({ - name: authName, - type: authType, - config: configJson, - unauthorized_status: unauthStatus, - unauthorized_response: unauthResponse, - updated_at: now, - }); - return getHttpAuthById(/** @type {string} */ (existing.id)); - } - - const newId = randomUUID(); - await db("http_auths").insert({ - id: newId, - name: authName, - type: authType, - config: configJson, - unauthorized_status: unauthStatus, - unauthorized_response: unauthResponse, - created_at: now, - updated_at: now, - }); - return getHttpAuthById(newId); -} - -/** - * @param {string} id - * @returns {Promise} - */ -export async function deleteHttpAuth(id) { - const authId = assertAuthId(id); - const n = await db("http_auths").where({ id: authId }).del(); - return n > 0; -} +export * from "./src/stores/http-auths-store.js"; diff --git a/packages/server/kv-store.js b/packages/server/kv-store.js index f1dfcb3..00302b3 100644 --- a/packages/server/kv-store.js +++ b/packages/server/kv-store.js @@ -1,399 +1 @@ -import { db } from "./db.js"; - -const MAX_KEY_LENGTH = 512; -const MAX_NAMESPACE_LENGTH = 512; -const MAX_VALUE_BYTES = 256 * 1024; -const DEFAULT_LIST_LIMIT = 100; -const MAX_LIST_LIMIT = 500; - -function nowIso() { - return new Date().toISOString(); -} - -/** - * @param {unknown} value - */ -function valuesEqual(a, b) { - if (a === b) return true; - if (a == null || b == null) return a === b; - try { - return JSON.stringify(a) === JSON.stringify(b); - } catch { - return false; - } -} - -/** - * @param {string} label - * @param {unknown} value - */ -function assertString(label, value) { - if (typeof value !== "string" || value.length === 0) { - throw new Error(`${label} must be a non-empty string`); - } -} - -/** - * @param {string} label - * @param {string} value - * @param {number} max - */ -function assertMaxLength(label, value, max) { - if (value.length > max) { - throw new Error(`${label} must be at most ${max} characters`); - } -} - -/** - * @param {string} namespace - */ -function assertNamespace(namespace) { - assertString("namespace", namespace); - assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH); -} - -/** - * @param {string} key - */ -function assertKey(key) { - assertString("key", key); - assertMaxLength("key", key, MAX_KEY_LENGTH); -} - -/** - * @param {unknown} value - * @returns {string} - */ -export function serializeKvValue(value) { - let json; - try { - json = JSON.stringify(value); - } catch { - throw new Error("value must be JSON-serializable"); - } - if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) { - throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`); - } - return json; -} - -/** - * @param {string | null | undefined} value - * @returns {unknown} - */ -function deserializeKvValue(value) { - if (value == null) return null; - try { - return JSON.parse(value); - } catch { - return value; - } -} - -/** - * @param {{ expires_at?: string | null }} row - */ -function isExpired(row) { - if (!row.expires_at) return false; - return Date.parse(row.expires_at) <= Date.now(); -} - -/** - * @param {string} namespace - * @param {string} key - * @returns {Promise} - */ -export async function kvGet(namespace, key) { - assertNamespace(namespace); - assertKey(key); - - const row = await db("script_state").where({ namespace, key }).first(); - if (!row) return null; - - if (isExpired(row)) { - await db("script_state").where({ namespace, key }).del(); - return null; - } - - return deserializeKvValue(row.value); -} - -/** - * @param {string} namespace - * @param {string} key - * @param {unknown} value - * @param {{ expiresAt?: string | Date | null }} [opts] - */ -export async function kvSet(namespace, key, value, opts = {}) { - assertNamespace(namespace); - assertKey(key); - - const json = serializeKvValue(value); - const updated_at = nowIso(); - let expires_at = null; - if (opts.expiresAt != null) { - expires_at = - opts.expiresAt instanceof Date - ? opts.expiresAt.toISOString() - : String(opts.expiresAt); - } - - await db("script_state") - .insert({ - namespace, - key, - value: json, - updated_at, - expires_at, - }) - .onConflict(["namespace", "key"]) - .merge({ - value: json, - updated_at, - expires_at, - }); -} - -/** - * @param {string} namespace - * @param {string} key - * @returns {Promise} - */ -export async function kvDelete(namespace, key) { - assertNamespace(namespace); - assertKey(key); - const deleted = await db("script_state").where({ namespace, key }).del(); - return deleted > 0; -} - -/** - * @param {string} namespace - * @param {string} key - * @param {unknown} expected - * @param {unknown} next - * @param {{ expiresAt?: string | Date | null }} [opts] - * @returns {Promise<{ ok: boolean, previous: unknown }>} - */ -export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) { - assertNamespace(namespace); - assertKey(key); - - return db.transaction(async (trx) => { - const row = await trx("script_state").where({ namespace, key }).first(); - - if (row && isExpired(row)) { - await trx("script_state").where({ namespace, key }).del(); - } - - const currentRow = - row && !isExpired(row) - ? row - : await trx("script_state").where({ namespace, key }).first(); - const previous = currentRow ? deserializeKvValue(currentRow.value) : null; - - if (!valuesEqual(previous, expected)) { - return { ok: false, previous }; - } - - const json = serializeKvValue(next); - const updated_at = nowIso(); - let expires_at = null; - if (opts.expiresAt != null) { - expires_at = - opts.expiresAt instanceof Date - ? opts.expiresAt.toISOString() - : String(opts.expiresAt); - } - - if (currentRow) { - await trx("script_state").where({ namespace, key }).update({ - value: json, - updated_at, - expires_at, - }); - } else { - await trx("script_state").insert({ - namespace, - key, - value: json, - updated_at, - expires_at, - }); - } - - return { ok: true, previous }; - }); -} - -/** - * @param {string} namespace - * @param {{ limit?: number }} [opts] - */ -export async function kvList(namespace, opts = {}) { - assertNamespace(namespace); - const limit = Math.min( - Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1), - MAX_LIST_LIMIT, - ); - - const rows = await db("script_state") - .where({ namespace }) - .orderBy("updated_at", "desc") - .limit(limit); - - const items = []; - for (const row of rows) { - if (isExpired(row)) { - await db("script_state").where({ namespace, key: row.key }).del(); - continue; - } - items.push({ - key: row.key, - value: deserializeKvValue(row.value), - updatedAt: row.updated_at, - expiresAt: row.expires_at ?? null, - }); - } - return items; -} - -function escapeLike(value) { - return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); -} - -async function pruneExpiredKv() { - await db("script_state") - .whereNotNull("expires_at") - .andWhere("expires_at", "<=", nowIso()) - .del(); -} - -/** - * @param {{ - * namespace?: string, - * q?: string, - * limit?: number, - * offset?: number, - * }} [opts] - */ -export async function kvQuery(opts = {}) { - await pruneExpiredKv(); - - const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100); - const offset = Math.max(Number(opts.offset) || 0, 0); - - let q = db("script_state"); - if (opts.namespace) { - assertNamespace(opts.namespace); - q = q.where({ namespace: opts.namespace }); - } - if (typeof opts.q === "string" && opts.q.length > 0) { - const like = `%${escapeLike(opts.q)}%`; - q = q.where(function likeSearch() { - this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw( - "value LIKE ? ESCAPE '\\'", - [like], - ); - }); - } - - const countRow = await q.clone().count({ count: "*" }).first(); - const total = Number(countRow?.count ?? 0); - - const rows = await q - .clone() - .orderBy("updated_at", "desc") - .orderBy("namespace", "asc") - .orderBy("key", "asc") - .limit(limit) - .offset(offset); - - return { - items: rows.map((row) => ({ - namespace: row.namespace, - key: row.key, - value: deserializeKvValue(row.value), - updatedAt: row.updated_at, - expiresAt: row.expires_at ?? null, - })), - total, - limit, - offset, - }; -} - -/** - * @returns {Promise} - */ -export async function kvNamespaces() { - await pruneExpiredKv(); - const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc"); - return rows.map((row) => row.namespace); -} - -/** - * @param {string} defaultNamespace - */ -export function createKvApi(defaultNamespace) { - assertNamespace(defaultNamespace); - - /** - * @param {{ namespace?: string }} [opts] - */ - function resolveNamespace(opts = {}) { - const namespace = opts.namespace ?? defaultNamespace; - assertNamespace(namespace); - return namespace; - } - - return { - namespace: defaultNamespace, - - /** - * @param {string} key - * @param {{ namespace?: string }} [opts] - */ - get(key, opts) { - return kvGet(resolveNamespace(opts), key); - }, - - /** - * @param {string} key - * @param {unknown} value - * @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts] - */ - set(key, value, opts) { - const { namespace, expiresAt } = opts ?? {}; - return kvSet(resolveNamespace(opts), key, value, { expiresAt }); - }, - - /** - * @param {string} key - * @param {{ namespace?: string }} [opts] - */ - delete(key, opts) { - return kvDelete(resolveNamespace(opts), key); - }, - - /** - * @param {string} key - * @param {unknown} expected - * @param {unknown} next - * @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts] - */ - compareAndSet(key, expected, next, opts) { - const { expiresAt } = opts ?? {}; - return kvCompareAndSet(resolveNamespace(opts), key, expected, next, { - expiresAt, - }); - }, - - /** - * @param {{ namespace?: string, limit?: number }} [opts] - */ - list(opts) { - const { namespace, limit } = opts ?? {}; - return kvList(resolveNamespace(opts), { limit }); - }, - }; -} +export * from "./src/stores/kv-store.js"; diff --git a/packages/server/profiles-store.js b/packages/server/profiles-store.js index da6d981..60e282b 100644 --- a/packages/server/profiles-store.js +++ b/packages/server/profiles-store.js @@ -1,244 +1 @@ -import { randomUUID } from "node:crypto"; -import yaml from "yaml"; -import { db } from "./db.js"; -import { assertOwner, listOwnerYamlFiles, readWorkflowYaml } from "./fs-store.js"; - -const MAX_NAME_LENGTH = 128; -const MAX_DESCRIPTION_LENGTH = 500; -const MAX_CONFIG_BYTES = 64 * 1024; -const PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/; - -function nowIso() { - return new Date().toISOString(); -} - -function httpError(message, statusCode = 400) { - const err = new Error(message); - err.statusCode = statusCode; - return err; -} - -/** - * @param {unknown} name - * @returns {string} - */ -export function assertProfileName(name) { - if (typeof name !== "string" || !PROFILE_NAME_RE.test(name)) { - throw httpError("invalid profile name"); - } - if (name.length > MAX_NAME_LENGTH) { - throw httpError(`profile name must be at most ${MAX_NAME_LENGTH} characters`); - } - return name; -} - -/** - * @param {unknown} script - * @returns {string} - */ -export function assertProfileScript(script) { - if (typeof script !== "string" || script.trim().length === 0) { - throw httpError("script is required"); - } - const trimmed = script.trim(); - if (trimmed.length > 256) { - throw httpError("script name is too long"); - } - return trimmed; -} - -/** - * @param {unknown} description - * @returns {string} - */ -export function assertProfileDescription(description) { - if (description == null) return ""; - if (typeof description !== "string") { - throw httpError("description must be a string"); - } - if (description.length > MAX_DESCRIPTION_LENGTH) { - throw httpError(`description must be at most ${MAX_DESCRIPTION_LENGTH} characters`); - } - return description; -} - -/** - * @param {unknown} config - * @returns {string} - */ -export function encodeProfileConfig(config) { - if (config == null) return "{}"; - if (typeof config !== "object" || Array.isArray(config)) { - throw httpError("config must be an object"); - } - let encoded; - try { - encoded = JSON.stringify(config); - } catch { - throw httpError("config must be JSON-serializable"); - } - if (Buffer.byteLength(encoded, "utf8") > MAX_CONFIG_BYTES) { - throw httpError(`config exceeds ${MAX_CONFIG_BYTES} byte limit`); - } - return encoded; -} - -/** - * @param {string} stored - * @returns {Record} - */ -export function decodeProfileConfig(stored) { - if (stored == null || stored === "") return {}; - try { - const parsed = JSON.parse(stored); - if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed; - } - } catch { - throw new Error(`corrupt profile config: ${JSON.stringify(stored).slice(0, 80)}`); - } - throw new Error("corrupt profile config: not an object"); -} - -/** - * @param {Record} row - */ -function publicProfile(row) { - return { - id: row.id, - owner: row.owner, - name: row.name, - script: row.script, - config: decodeProfileConfig(String(row.config ?? "{}")), - description: row.description == null ? "" : String(row.description), - created_at: row.created_at, - updated_at: row.updated_at, - }; -} - -/** - * @param {{ owner?: string }} [filters] - */ -export async function listProfiles(filters = {}) { - let q = db("profiles") - .select("id", "owner", "name", "script", "config", "description", "created_at", "updated_at") - .orderBy("owner", "asc") - .orderBy("name", "asc"); - if (filters.owner) { - q = q.where("owner", assertOwner(filters.owner)); - } - const rows = await q; - return rows.map((row) => publicProfile(row)); -} - -/** - * @param {string} id - */ -export async function getProfileById(id) { - const row = await db("profiles").where({ id }).first(); - return row ? publicProfile(row) : null; -} - -/** - * @param {string} owner - * @param {string} name - */ -export async function getProfilePlain(owner, name) { - const ownerName = assertOwner(owner); - const profileName = assertProfileName(name); - const row = await db("profiles").where({ owner: ownerName, name: profileName }).first(); - return row ? publicProfile(row) : null; -} - -/** - * @param {{ - * owner: string, - * name: string, - * script: unknown, - * config?: unknown, - * description?: unknown, - * }} opts - */ -export async function upsertProfile({ owner, name, script, config, description }) { - const ownerName = assertOwner(owner); - const profileName = assertProfileName(name); - const scriptName = assertProfileScript(script); - const encoded = encodeProfileConfig(config ?? {}); - const desc = assertProfileDescription(description); - const now = nowIso(); - const existing = await db("profiles").where({ owner: ownerName, name: profileName }).first(); - - if (existing) { - await db("profiles") - .where({ id: existing.id }) - .update({ - script: scriptName, - config: encoded, - description: desc, - updated_at: now, - }); - return getProfileById(existing.id); - } - - const id = randomUUID(); - await db("profiles").insert({ - id, - owner: ownerName, - name: profileName, - script: scriptName, - config: encoded, - description: desc, - created_at: now, - updated_at: now, - }); - return getProfileById(id); -} - -/** - * @param {string} id - * @returns {Promise} - */ -export async function deleteProfile(id) { - const n = await db("profiles").where({ id }).del(); - return n > 0; -} - -/** - * Workflows (same owner) whose YAML steps reference this profile name. - * @param {string} owner - * @param {string} name - * @returns {{ file: string, name: string, steps: number }[]} - */ -export function listProfileUsages(owner, name) { - const ownerName = assertOwner(owner); - const profileName = assertProfileName(name); - /** @type {{ file: string, name: string, steps: number }[]} */ - const usages = []; - for (const file of listOwnerYamlFiles(ownerName)) { - const content = readWorkflowYaml(ownerName, file); - if (content == null) continue; - let parsed; - try { - parsed = yaml.parse(content); - } catch { - continue; - } - if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) continue; - const scripts = parsed.scripts; - if (!Array.isArray(scripts)) continue; - let steps = 0; - for (const step of scripts) { - if (step != null && typeof step === "object" && !Array.isArray(step) && step.profile === profileName) { - steps += 1; - } - } - if (steps > 0) { - usages.push({ - file, - name: typeof parsed.name === "string" && parsed.name ? parsed.name : file, - steps, - }); - } - } - return usages; -} +export * from "./src/stores/profiles-store.js"; diff --git a/packages/server/registry.js b/packages/server/registry.js index 24c81d0..d2871df 100644 --- a/packages/server/registry.js +++ b/packages/server/registry.js @@ -23,15 +23,13 @@ import { storedEnvelope, } from "./step-result.js"; import * as fsStore from "./fs-store.js"; -import { - checkAnyHttpAuth, - resolveAuthMechanisms, - resolveUnauthorizedSpec, - sendHttpPageOrJson, - sendSuccessPage, -} from "./http-trigger-auth.js"; import { resolveConfigRefs } from "./config-refs.js"; -import { HTTP_METHODS, hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared"; +import { hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared"; +import { + createHttpTriggerHandler, + ensureHttpWildcardRoute, + rebuildHttpRoutes, +} from "./workflow-http-routes.js"; import { getProfilePlain } from "./profiles-store.js"; import { buildFailureAlertData, @@ -69,7 +67,14 @@ export function createRegistry(server, opts = {}) { let pruneTask = null; /** @type {Map} */ const httpRoutes = new Map(); - let httpDispatcherRegistered = false; + const httpDispatcherState = { registered: false }; + const dispatchHttpTrigger = createHttpTriggerHandler({ + httpRoutes, + workflows, + namespacedPath, + enqueueWorkflow: (...args) => enqueueWorkflow(...args), + }); + /** * Resolve a same-owner workflow that opts in with `type: workflow`. @@ -175,118 +180,10 @@ export function createRegistry(server, opts = {}) { * Fastify route once so path/method changes apply on reregister without restart. */ function registerHttpTriggers() { - httpRoutes.clear(); - - for (const [key, { owner, workflow }] of workflows) { - if (workflow.enabled === false) { - log.debug(`Skipping disabled workflow HTTP triggers (${key})`); - continue; - } - - for (const trigger of workflow.triggers ?? []) { - if (trigger.type !== "HTTP") continue; - - const method = String(trigger.method ?? "POST").toUpperCase(); - const url = namespacedPath(owner, trigger.path); - const routeKey = `${method} ${url}`; - - if (httpRoutes.has(routeKey)) { - log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`); - continue; - } - 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 && - !(Array.isArray(liveTrigger.auth) && liveTrigger.auth.length === 0) - ) { - const mechanisms = await resolveAuthMechanisms(liveTrigger.auth); - if (mechanisms.length === 0) { - const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null); - return sendHttpPageOrJson(reply, status, pageName, { - error: "unauthorized", - }); - } - const ok = await checkAnyHttpAuth(req, mechanisms, { - owner: entry.owner, - workflowKey: mapped.key, - }); - if (!ok) { - const { status, pageName } = resolveUnauthorizedSpec( - liveTrigger, - mechanisms[0], - ); - return sendHttpPageOrJson(reply, status, pageName, { - error: "unauthorized", - }); - } - } - - const result = await enqueueWorkflow( - mapped.key, - { data: req.body }, - { type: "http", detail: `${method} ${url}` }, - ); - if (result.status === "failed") { - return reply.code(result.runId ? 500 : 404).send({ - runId: result.runId, - status: result.status, - error: result.error, - }); - } - - const defaultBody = { - runId: result.runId, - status: result.status, - }; - if (typeof liveTrigger.response === "string" && liveTrigger.response) { - return sendSuccessPage(reply, liveTrigger.response, defaultBody); - } - return reply.code(202).send(defaultBody); + rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log }); + ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, { + log, + }); } function registerCronTriggers() { diff --git a/packages/server/script-sandbox.js b/packages/server/script-sandbox.js index 7996f7a..6f6ea86 100644 --- a/packages/server/script-sandbox.js +++ b/packages/server/script-sandbox.js @@ -486,7 +486,7 @@ const inspectLog = pino({ level: "silent" }); * @param {unknown} fn * @returns {{ meta: Record | null, metaError: string | null }} */ -export function extractScriptMeta(fn) { +function extractScriptMeta(fn) { if (typeof fn !== "function") { return { meta: null, metaError: "default export must be a function" }; } diff --git a/packages/server/secrets-store.js b/packages/server/secrets-store.js index a23e949..5376b42 100644 --- a/packages/server/secrets-store.js +++ b/packages/server/secrets-store.js @@ -1,144 +1 @@ -import { randomUUID } from "node:crypto"; -import { db } from "./db.js"; -import { assertOwner } from "./fs-store.js"; -import { decryptSecret, encryptSecret } from "./secrets.js"; -import { registerPlaintext } from "./secret-value.js"; - -const MAX_NAME_LENGTH = 128; -const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/; - -function nowIso() { - return new Date().toISOString(); -} - -/** - * @param {unknown} name - * @returns {string} - */ -export function assertSecretName(name) { - if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) { - const err = new Error("invalid secret name"); - err.statusCode = 400; - throw err; - } - if (name.length > MAX_NAME_LENGTH) { - const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`); - err.statusCode = 400; - throw err; - } - return name; -} - -function publicSecret(row) { - return { - id: row.id, - owner: row.owner, - name: row.name, - created_at: row.created_at, - updated_at: row.updated_at, - }; -} - -/** - * @param {{ owner?: string }} [filters] - */ -export async function listSecrets(filters = {}) { - let q = db("secrets") - .select("id", "owner", "name", "created_at", "updated_at") - .orderBy("owner", "asc") - .orderBy("name", "asc"); - if (filters.owner) { - q = q.where("owner", assertOwner(filters.owner)); - } - return q; -} - -/** - * @param {string} id - */ -export async function getSecretById(id) { - const row = await db("secrets") - .select("id", "owner", "name", "created_at", "updated_at") - .where({ id }) - .first(); - return row ?? null; -} - -/** - * @param {{ owner: string, name: string, value: string }} opts - */ -export async function upsertSecret({ owner, name, value }) { - if (typeof value !== "string" || value.length === 0) { - const err = new Error("value is required"); - err.statusCode = 400; - throw err; - } - const ownerName = assertOwner(owner); - const secretName = assertSecretName(name); - registerPlaintext(value); - const { ciphertext, iv, authTag } = encryptSecret(value); - const now = nowIso(); - const existing = await db("secrets") - .where({ owner: ownerName, name: secretName }) - .first(); - - if (existing) { - await db("secrets") - .where({ id: existing.id }) - .update({ - ciphertext, - iv, - auth_tag: authTag, - updated_at: now, - }); - return getSecretById(existing.id); - } - - const id = randomUUID(); - await db("secrets").insert({ - id, - owner: ownerName, - name: secretName, - ciphertext, - iv, - auth_tag: authTag, - created_at: now, - updated_at: now, - }); - return getSecretById(id); -} - -/** - * @param {string} id - * @returns {Promise} - */ -export async function deleteSecret(id) { - const n = await db("secrets").where({ id }).del(); - return n > 0; -} - -/** - * Decrypt a named secret for an owner. Returns null if missing. - * @param {string} owner - * @param {string} name - * @returns {Promise} - */ -export async function getSecretPlaintext(owner, name) { - const ownerName = assertOwner(owner); - const secretName = assertSecretName(name); - const row = await db("secrets") - .where({ owner: ownerName, name: secretName }) - .first(); - if (!row) return null; - try { - const plaintext = decryptSecret({ - ciphertext: row.ciphertext, - iv: row.iv, - authTag: row.auth_tag, - }); - registerPlaintext(plaintext); - return plaintext; - } catch { - throw new Error(`failed to decrypt secret "${secretName}"`); - } -} +export * from "./src/stores/secrets-store.js"; diff --git a/packages/server/secrets.js b/packages/server/secrets.js index 4a8492d..b9239ae 100644 --- a/packages/server/secrets.js +++ b/packages/server/secrets.js @@ -25,7 +25,7 @@ let cachedKey = null; /** * @returns {Buffer} */ -export function getMasterKey() { +function getMasterKey() { if (cachedKey) return cachedKey; const raw = resolveSecretsKeyMaterial(); cachedKey = /^[0-9a-fA-F]{64}$/.test(raw) diff --git a/packages/server/src/stores/http-auths-store.js b/packages/server/src/stores/http-auths-store.js new file mode 100644 index 0000000..11ab057 --- /dev/null +++ b/packages/server/src/stores/http-auths-store.js @@ -0,0 +1,390 @@ +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 UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]); + +function nowIso() { + return new Date().toISOString(); +} + +/** + * @param {unknown} id + * @returns {string} + */ +export function assertAuthId(id) { + if (typeof id !== "string" || !UUID_RE.test(id)) { + const err = new Error("invalid auth id"); + err.statusCode = 400; + throw err; + } + return id.toLowerCase(); +} + +/** + * @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} id + */ +export async function getHttpAuthInternal(id) { + const authId = assertAuthId(id); + const row = await db("http_auths").where({ id: authId }).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, + }; +} + +/** + * Return only plaintext literal credential fields (not KV refs or encrypted secrets). + * @param {string} id + * @returns {Promise<{ id: string, name: string, type: string, literals: Record } | null>} + */ +export async function revealHttpAuthLiterals(id) { + const internal = await getHttpAuthInternal(id); + if (!internal) return null; + /** @type {Record} */ + const literals = {}; + const cfg = internal.config ?? {}; + for (const key of ["token", "user", "password", "value"]) { + const v = cfg[key]; + if (typeof v === "string") literals[key] = v; + } + return { + id: internal.id, + name: internal.name, + type: internal.type, + literals, + }; +} + +export async function listHttpAuths() { + const rows = await db("http_auths").select("*").orderBy("name", "asc"); + return rows.map((r) => publicAuth(r)); +} + +/** + * @param {string} id + */ +export async function getHttpAuthById(id) { + let authId; + try { + authId = assertAuthId(id); + } catch { + return null; + } + const row = await db("http_auths").where({ id: authId }).first(); + return row ? publicAuth(row) : null; +} + +/** + * @param {{ + * id?: string | null, + * name: string, + * type: string, + * config?: unknown, + * unauthorized_status?: number | null, + * unauthorized_response?: string | null, + * }} opts + */ +export async function upsertHttpAuth({ + id, + name, + type, + config, + unauthorized_status, + unauthorized_response, +}) { + const authName = assertAuthName(name); + const authType = assertAuthType(type); + + /** @type {Record | null} */ + let existing = null; + if (id != null && String(id).length > 0) { + const authId = assertAuthId(id); + existing = await db("http_auths").where({ id: authId }).first(); + if (!existing) { + const err = new Error("auth not found"); + err.statusCode = 404; + throw err; + } + } + + const nameClash = await db("http_auths").where({ name: authName }).first(); + if (nameClash && (!existing || nameClash.id !== existing.id)) { + const err = new Error(`auth name "${authName}" already exists`); + err.statusCode = 409; + throw err; + } + + 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({ + name: authName, + type: authType, + config: configJson, + unauthorized_status: unauthStatus, + unauthorized_response: unauthResponse, + updated_at: now, + }); + return getHttpAuthById(/** @type {string} */ (existing.id)); + } + + const newId = randomUUID(); + await db("http_auths").insert({ + id: newId, + name: authName, + type: authType, + config: configJson, + unauthorized_status: unauthStatus, + unauthorized_response: unauthResponse, + created_at: now, + updated_at: now, + }); + return getHttpAuthById(newId); +} + +/** + * @param {string} id + * @returns {Promise} + */ +export async function deleteHttpAuth(id) { + const authId = assertAuthId(id); + const n = await db("http_auths").where({ id: authId }).del(); + return n > 0; +} diff --git a/packages/server/src/stores/kv-store.js b/packages/server/src/stores/kv-store.js new file mode 100644 index 0000000..ad54c28 --- /dev/null +++ b/packages/server/src/stores/kv-store.js @@ -0,0 +1,399 @@ +import { db } from "../../db.js"; + +const MAX_KEY_LENGTH = 512; +const MAX_NAMESPACE_LENGTH = 512; +const MAX_VALUE_BYTES = 256 * 1024; +const DEFAULT_LIST_LIMIT = 100; +const MAX_LIST_LIMIT = 500; + +function nowIso() { + return new Date().toISOString(); +} + +/** + * @param {unknown} value + */ +function valuesEqual(a, b) { + if (a === b) return true; + if (a == null || b == null) return a === b; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + +/** + * @param {string} label + * @param {unknown} value + */ +function assertString(label, value) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } +} + +/** + * @param {string} label + * @param {string} value + * @param {number} max + */ +function assertMaxLength(label, value, max) { + if (value.length > max) { + throw new Error(`${label} must be at most ${max} characters`); + } +} + +/** + * @param {string} namespace + */ +function assertNamespace(namespace) { + assertString("namespace", namespace); + assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH); +} + +/** + * @param {string} key + */ +function assertKey(key) { + assertString("key", key); + assertMaxLength("key", key, MAX_KEY_LENGTH); +} + +/** + * @param {unknown} value + * @returns {string} + */ +function serializeKvValue(value) { + let json; + try { + json = JSON.stringify(value); + } catch { + throw new Error("value must be JSON-serializable"); + } + if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) { + throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`); + } + return json; +} + +/** + * @param {string | null | undefined} value + * @returns {unknown} + */ +function deserializeKvValue(value) { + if (value == null) return null; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +/** + * @param {{ expires_at?: string | null }} row + */ +function isExpired(row) { + if (!row.expires_at) return false; + return Date.parse(row.expires_at) <= Date.now(); +} + +/** + * @param {string} namespace + * @param {string} key + * @returns {Promise} + */ +export async function kvGet(namespace, key) { + assertNamespace(namespace); + assertKey(key); + + const row = await db("script_state").where({ namespace, key }).first(); + if (!row) return null; + + if (isExpired(row)) { + await db("script_state").where({ namespace, key }).del(); + return null; + } + + return deserializeKvValue(row.value); +} + +/** + * @param {string} namespace + * @param {string} key + * @param {unknown} value + * @param {{ expiresAt?: string | Date | null }} [opts] + */ +export async function kvSet(namespace, key, value, opts = {}) { + assertNamespace(namespace); + assertKey(key); + + const json = serializeKvValue(value); + const updated_at = nowIso(); + let expires_at = null; + if (opts.expiresAt != null) { + expires_at = + opts.expiresAt instanceof Date + ? opts.expiresAt.toISOString() + : String(opts.expiresAt); + } + + await db("script_state") + .insert({ + namespace, + key, + value: json, + updated_at, + expires_at, + }) + .onConflict(["namespace", "key"]) + .merge({ + value: json, + updated_at, + expires_at, + }); +} + +/** + * @param {string} namespace + * @param {string} key + * @returns {Promise} + */ +export async function kvDelete(namespace, key) { + assertNamespace(namespace); + assertKey(key); + const deleted = await db("script_state").where({ namespace, key }).del(); + return deleted > 0; +} + +/** + * @param {string} namespace + * @param {string} key + * @param {unknown} expected + * @param {unknown} next + * @param {{ expiresAt?: string | Date | null }} [opts] + * @returns {Promise<{ ok: boolean, previous: unknown }>} + */ +export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) { + assertNamespace(namespace); + assertKey(key); + + return db.transaction(async (trx) => { + const row = await trx("script_state").where({ namespace, key }).first(); + + if (row && isExpired(row)) { + await trx("script_state").where({ namespace, key }).del(); + } + + const currentRow = + row && !isExpired(row) + ? row + : await trx("script_state").where({ namespace, key }).first(); + const previous = currentRow ? deserializeKvValue(currentRow.value) : null; + + if (!valuesEqual(previous, expected)) { + return { ok: false, previous }; + } + + const json = serializeKvValue(next); + const updated_at = nowIso(); + let expires_at = null; + if (opts.expiresAt != null) { + expires_at = + opts.expiresAt instanceof Date + ? opts.expiresAt.toISOString() + : String(opts.expiresAt); + } + + if (currentRow) { + await trx("script_state").where({ namespace, key }).update({ + value: json, + updated_at, + expires_at, + }); + } else { + await trx("script_state").insert({ + namespace, + key, + value: json, + updated_at, + expires_at, + }); + } + + return { ok: true, previous }; + }); +} + +/** + * @param {string} namespace + * @param {{ limit?: number }} [opts] + */ +export async function kvList(namespace, opts = {}) { + assertNamespace(namespace); + const limit = Math.min( + Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1), + MAX_LIST_LIMIT, + ); + + const rows = await db("script_state") + .where({ namespace }) + .orderBy("updated_at", "desc") + .limit(limit); + + const items = []; + for (const row of rows) { + if (isExpired(row)) { + await db("script_state").where({ namespace, key: row.key }).del(); + continue; + } + items.push({ + key: row.key, + value: deserializeKvValue(row.value), + updatedAt: row.updated_at, + expiresAt: row.expires_at ?? null, + }); + } + return items; +} + +function escapeLike(value) { + return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); +} + +async function pruneExpiredKv() { + await db("script_state") + .whereNotNull("expires_at") + .andWhere("expires_at", "<=", nowIso()) + .del(); +} + +/** + * @param {{ + * namespace?: string, + * q?: string, + * limit?: number, + * offset?: number, + * }} [opts] + */ +export async function kvQuery(opts = {}) { + await pruneExpiredKv(); + + const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100); + const offset = Math.max(Number(opts.offset) || 0, 0); + + let q = db("script_state"); + if (opts.namespace) { + assertNamespace(opts.namespace); + q = q.where({ namespace: opts.namespace }); + } + if (typeof opts.q === "string" && opts.q.length > 0) { + const like = `%${escapeLike(opts.q)}%`; + q = q.where(function likeSearch() { + this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw( + "value LIKE ? ESCAPE '\\'", + [like], + ); + }); + } + + const countRow = await q.clone().count({ count: "*" }).first(); + const total = Number(countRow?.count ?? 0); + + const rows = await q + .clone() + .orderBy("updated_at", "desc") + .orderBy("namespace", "asc") + .orderBy("key", "asc") + .limit(limit) + .offset(offset); + + return { + items: rows.map((row) => ({ + namespace: row.namespace, + key: row.key, + value: deserializeKvValue(row.value), + updatedAt: row.updated_at, + expiresAt: row.expires_at ?? null, + })), + total, + limit, + offset, + }; +} + +/** + * @returns {Promise} + */ +export async function kvNamespaces() { + await pruneExpiredKv(); + const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc"); + return rows.map((row) => row.namespace); +} + +/** + * @param {string} defaultNamespace + */ +export function createKvApi(defaultNamespace) { + assertNamespace(defaultNamespace); + + /** + * @param {{ namespace?: string }} [opts] + */ + function resolveNamespace(opts = {}) { + const namespace = opts.namespace ?? defaultNamespace; + assertNamespace(namespace); + return namespace; + } + + return { + namespace: defaultNamespace, + + /** + * @param {string} key + * @param {{ namespace?: string }} [opts] + */ + get(key, opts) { + return kvGet(resolveNamespace(opts), key); + }, + + /** + * @param {string} key + * @param {unknown} value + * @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts] + */ + set(key, value, opts) { + const { namespace, expiresAt } = opts ?? {}; + return kvSet(resolveNamespace(opts), key, value, { expiresAt }); + }, + + /** + * @param {string} key + * @param {{ namespace?: string }} [opts] + */ + delete(key, opts) { + return kvDelete(resolveNamespace(opts), key); + }, + + /** + * @param {string} key + * @param {unknown} expected + * @param {unknown} next + * @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts] + */ + compareAndSet(key, expected, next, opts) { + const { expiresAt } = opts ?? {}; + return kvCompareAndSet(resolveNamespace(opts), key, expected, next, { + expiresAt, + }); + }, + + /** + * @param {{ namespace?: string, limit?: number }} [opts] + */ + list(opts) { + const { namespace, limit } = opts ?? {}; + return kvList(resolveNamespace(opts), { limit }); + }, + }; +} diff --git a/packages/server/src/stores/profiles-store.js b/packages/server/src/stores/profiles-store.js new file mode 100644 index 0000000..92b73ca --- /dev/null +++ b/packages/server/src/stores/profiles-store.js @@ -0,0 +1,244 @@ +import { randomUUID } from "node:crypto"; +import yaml from "yaml"; +import { db } from "../../db.js"; +import { assertOwner, listOwnerYamlFiles, readWorkflowYaml } from "../../fs-store.js"; + +const MAX_NAME_LENGTH = 128; +const MAX_DESCRIPTION_LENGTH = 500; +const MAX_CONFIG_BYTES = 64 * 1024; +const PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/; + +function nowIso() { + return new Date().toISOString(); +} + +function httpError(message, statusCode = 400) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +/** + * @param {unknown} name + * @returns {string} + */ +export function assertProfileName(name) { + if (typeof name !== "string" || !PROFILE_NAME_RE.test(name)) { + throw httpError("invalid profile name"); + } + if (name.length > MAX_NAME_LENGTH) { + throw httpError(`profile name must be at most ${MAX_NAME_LENGTH} characters`); + } + return name; +} + +/** + * @param {unknown} script + * @returns {string} + */ +export function assertProfileScript(script) { + if (typeof script !== "string" || script.trim().length === 0) { + throw httpError("script is required"); + } + const trimmed = script.trim(); + if (trimmed.length > 256) { + throw httpError("script name is too long"); + } + return trimmed; +} + +/** + * @param {unknown} description + * @returns {string} + */ +export function assertProfileDescription(description) { + if (description == null) return ""; + if (typeof description !== "string") { + throw httpError("description must be a string"); + } + if (description.length > MAX_DESCRIPTION_LENGTH) { + throw httpError(`description must be at most ${MAX_DESCRIPTION_LENGTH} characters`); + } + return description; +} + +/** + * @param {unknown} config + * @returns {string} + */ +export function encodeProfileConfig(config) { + if (config == null) return "{}"; + if (typeof config !== "object" || Array.isArray(config)) { + throw httpError("config must be an object"); + } + let encoded; + try { + encoded = JSON.stringify(config); + } catch { + throw httpError("config must be JSON-serializable"); + } + if (Buffer.byteLength(encoded, "utf8") > MAX_CONFIG_BYTES) { + throw httpError(`config exceeds ${MAX_CONFIG_BYTES} byte limit`); + } + return encoded; +} + +/** + * @param {string} stored + * @returns {Record} + */ +export function decodeProfileConfig(stored) { + if (stored == null || stored === "") return {}; + try { + const parsed = JSON.parse(stored); + if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; + } + } catch { + throw new Error(`corrupt profile config: ${JSON.stringify(stored).slice(0, 80)}`); + } + throw new Error("corrupt profile config: not an object"); +} + +/** + * @param {Record} row + */ +function publicProfile(row) { + return { + id: row.id, + owner: row.owner, + name: row.name, + script: row.script, + config: decodeProfileConfig(String(row.config ?? "{}")), + description: row.description == null ? "" : String(row.description), + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** + * @param {{ owner?: string }} [filters] + */ +export async function listProfiles(filters = {}) { + let q = db("profiles") + .select("id", "owner", "name", "script", "config", "description", "created_at", "updated_at") + .orderBy("owner", "asc") + .orderBy("name", "asc"); + if (filters.owner) { + q = q.where("owner", assertOwner(filters.owner)); + } + const rows = await q; + return rows.map((row) => publicProfile(row)); +} + +/** + * @param {string} id + */ +export async function getProfileById(id) { + const row = await db("profiles").where({ id }).first(); + return row ? publicProfile(row) : null; +} + +/** + * @param {string} owner + * @param {string} name + */ +export async function getProfilePlain(owner, name) { + const ownerName = assertOwner(owner); + const profileName = assertProfileName(name); + const row = await db("profiles").where({ owner: ownerName, name: profileName }).first(); + return row ? publicProfile(row) : null; +} + +/** + * @param {{ + * owner: string, + * name: string, + * script: unknown, + * config?: unknown, + * description?: unknown, + * }} opts + */ +export async function upsertProfile({ owner, name, script, config, description }) { + const ownerName = assertOwner(owner); + const profileName = assertProfileName(name); + const scriptName = assertProfileScript(script); + const encoded = encodeProfileConfig(config ?? {}); + const desc = assertProfileDescription(description); + const now = nowIso(); + const existing = await db("profiles").where({ owner: ownerName, name: profileName }).first(); + + if (existing) { + await db("profiles") + .where({ id: existing.id }) + .update({ + script: scriptName, + config: encoded, + description: desc, + updated_at: now, + }); + return getProfileById(existing.id); + } + + const id = randomUUID(); + await db("profiles").insert({ + id, + owner: ownerName, + name: profileName, + script: scriptName, + config: encoded, + description: desc, + created_at: now, + updated_at: now, + }); + return getProfileById(id); +} + +/** + * @param {string} id + * @returns {Promise} + */ +export async function deleteProfile(id) { + const n = await db("profiles").where({ id }).del(); + return n > 0; +} + +/** + * Workflows (same owner) whose YAML steps reference this profile name. + * @param {string} owner + * @param {string} name + * @returns {{ file: string, name: string, steps: number }[]} + */ +export function listProfileUsages(owner, name) { + const ownerName = assertOwner(owner); + const profileName = assertProfileName(name); + /** @type {{ file: string, name: string, steps: number }[]} */ + const usages = []; + for (const file of listOwnerYamlFiles(ownerName)) { + const content = readWorkflowYaml(ownerName, file); + if (content == null) continue; + let parsed; + try { + parsed = yaml.parse(content); + } catch { + continue; + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) continue; + const scripts = parsed.scripts; + if (!Array.isArray(scripts)) continue; + let steps = 0; + for (const step of scripts) { + if (step != null && typeof step === "object" && !Array.isArray(step) && step.profile === profileName) { + steps += 1; + } + } + if (steps > 0) { + usages.push({ + file, + name: typeof parsed.name === "string" && parsed.name ? parsed.name : file, + steps, + }); + } + } + return usages; +} diff --git a/packages/server/src/stores/secrets-store.js b/packages/server/src/stores/secrets-store.js new file mode 100644 index 0000000..cd4f491 --- /dev/null +++ b/packages/server/src/stores/secrets-store.js @@ -0,0 +1,144 @@ +import { randomUUID } from "node:crypto"; +import { db } from "../../db.js"; +import { assertOwner } from "../../fs-store.js"; +import { decryptSecret, encryptSecret } from "../../secrets.js"; +import { registerPlaintext } from "../../secret-value.js"; + +const MAX_NAME_LENGTH = 128; +const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/; + +function nowIso() { + return new Date().toISOString(); +} + +/** + * @param {unknown} name + * @returns {string} + */ +export function assertSecretName(name) { + if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) { + const err = new Error("invalid secret name"); + err.statusCode = 400; + throw err; + } + if (name.length > MAX_NAME_LENGTH) { + const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`); + err.statusCode = 400; + throw err; + } + return name; +} + +function publicSecret(row) { + return { + id: row.id, + owner: row.owner, + name: row.name, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** + * @param {{ owner?: string }} [filters] + */ +export async function listSecrets(filters = {}) { + let q = db("secrets") + .select("id", "owner", "name", "created_at", "updated_at") + .orderBy("owner", "asc") + .orderBy("name", "asc"); + if (filters.owner) { + q = q.where("owner", assertOwner(filters.owner)); + } + return q; +} + +/** + * @param {string} id + */ +export async function getSecretById(id) { + const row = await db("secrets") + .select("id", "owner", "name", "created_at", "updated_at") + .where({ id }) + .first(); + return row ?? null; +} + +/** + * @param {{ owner: string, name: string, value: string }} opts + */ +export async function upsertSecret({ owner, name, value }) { + if (typeof value !== "string" || value.length === 0) { + const err = new Error("value is required"); + err.statusCode = 400; + throw err; + } + const ownerName = assertOwner(owner); + const secretName = assertSecretName(name); + registerPlaintext(value); + const { ciphertext, iv, authTag } = encryptSecret(value); + const now = nowIso(); + const existing = await db("secrets") + .where({ owner: ownerName, name: secretName }) + .first(); + + if (existing) { + await db("secrets") + .where({ id: existing.id }) + .update({ + ciphertext, + iv, + auth_tag: authTag, + updated_at: now, + }); + return getSecretById(existing.id); + } + + const id = randomUUID(); + await db("secrets").insert({ + id, + owner: ownerName, + name: secretName, + ciphertext, + iv, + auth_tag: authTag, + created_at: now, + updated_at: now, + }); + return getSecretById(id); +} + +/** + * @param {string} id + * @returns {Promise} + */ +export async function deleteSecret(id) { + const n = await db("secrets").where({ id }).del(); + return n > 0; +} + +/** + * Decrypt a named secret for an owner. Returns null if missing. + * @param {string} owner + * @param {string} name + * @returns {Promise} + */ +export async function getSecretPlaintext(owner, name) { + const ownerName = assertOwner(owner); + const secretName = assertSecretName(name); + const row = await db("secrets") + .where({ owner: ownerName, name: secretName }) + .first(); + if (!row) return null; + try { + const plaintext = decryptSecret({ + ciphertext: row.ciphertext, + iv: row.iv, + authTag: row.auth_tag, + }); + registerPlaintext(plaintext); + return plaintext; + } catch { + throw new Error(`failed to decrypt secret "${secretName}"`); + } +} diff --git a/packages/server/src/stores/variables-store.js b/packages/server/src/stores/variables-store.js new file mode 100644 index 0000000..d6cc37c --- /dev/null +++ b/packages/server/src/stores/variables-store.js @@ -0,0 +1,190 @@ +import { randomUUID } from "node:crypto"; +import { db } from "../../db.js"; +import { assertOwner } from "../../fs-store.js"; + +const MAX_NAME_LENGTH = 128; +const MAX_STRING_BYTES = 64 * 1024; +const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/; +export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]); + +function nowIso() { + return new Date().toISOString(); +} + +function httpError(message, statusCode = 400) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +/** + * @param {unknown} name + * @returns {string} + */ +export function assertVariableName(name) { + if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) { + throw httpError("invalid variable name"); + } + if (name.length > MAX_NAME_LENGTH) { + throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`); + } + return name; +} + +/** + * @param {unknown} type + * @returns {"string" | "number" | "boolean"} + */ +export function assertVariableType(type) { + if (type !== "string" && type !== "number" && type !== "boolean") { + throw httpError("type must be string, number, or boolean"); + } + return type; +} + +/** + * @param {"string" | "number" | "boolean"} type + * @param {unknown} value + * @returns {string} + */ +export function encodeVariableValue(type, value) { + if (type === "string") { + if (typeof value !== "string") { + throw httpError("value must be a string"); + } + if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) { + throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`); + } + return value; + } + if (type === "number") { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw httpError("value must be a finite number"); + } + return String(value); + } + if (typeof value !== "boolean") { + throw httpError("value must be a boolean"); + } + return value ? "true" : "false"; +} + +/** + * @param {"string" | "number" | "boolean"} type + * @param {string} stored + * @returns {string | number | boolean} + */ +export function decodeVariableValue(type, stored) { + if (type === "string") return stored; + if (type === "number") { + const n = Number(stored); + if (!Number.isFinite(n)) { + throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`); + } + return n; + } + if (stored === "true") return true; + if (stored === "false") return false; + throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`); +} + +/** + * @param {Record} row + */ +function publicVariable(row) { + const type = assertVariableType(row.type); + return { + id: row.id, + owner: row.owner, + name: row.name, + type, + value: decodeVariableValue(type, String(row.value ?? "")), + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** + * @param {{ owner?: string }} [filters] + */ +export async function listVariables(filters = {}) { + let q = db("variables") + .select("id", "owner", "name", "type", "value", "created_at", "updated_at") + .orderBy("owner", "asc") + .orderBy("name", "asc"); + if (filters.owner) { + q = q.where("owner", assertOwner(filters.owner)); + } + const rows = await q; + return rows.map((row) => publicVariable(row)); +} + +/** + * @param {string} id + */ +export async function getVariableById(id) { + const row = await db("variables").where({ id }).first(); + return row ? publicVariable(row) : null; +} + +/** + * @param {{ owner: string, name: string, type: unknown, value: unknown }} opts + */ +export async function upsertVariable({ owner, name, type, value }) { + const ownerName = assertOwner(owner); + const variableName = assertVariableName(name); + const variableType = assertVariableType(type); + const encoded = encodeVariableValue(variableType, value); + const now = nowIso(); + const existing = await db("variables") + .where({ owner: ownerName, name: variableName }) + .first(); + + if (existing) { + await db("variables") + .where({ id: existing.id }) + .update({ + type: variableType, + value: encoded, + updated_at: now, + }); + return getVariableById(existing.id); + } + + const id = randomUUID(); + await db("variables").insert({ + id, + owner: ownerName, + name: variableName, + type: variableType, + value: encoded, + created_at: now, + updated_at: now, + }); + return getVariableById(id); +} + +/** + * @param {string} id + * @returns {Promise} + */ +export async function deleteVariable(id) { + const n = await db("variables").where({ id }).del(); + return n > 0; +} + +/** + * Typed primitive for an owner/name. Returns null if missing. + * @param {string} owner + * @param {string} name + * @returns {Promise} + */ +export async function getVariablePlain(owner, name) { + const ownerName = assertOwner(owner); + const variableName = assertVariableName(name); + const row = await db("variables") + .where({ owner: ownerName, name: variableName }) + .first(); + if (!row) return null; + return decodeVariableValue(assertVariableType(row.type), String(row.value ?? "")); +} diff --git a/packages/server/variables-store.js b/packages/server/variables-store.js index 2ed9f64..35ee535 100644 --- a/packages/server/variables-store.js +++ b/packages/server/variables-store.js @@ -1,190 +1 @@ -import { randomUUID } from "node:crypto"; -import { db } from "./db.js"; -import { assertOwner } from "./fs-store.js"; - -const MAX_NAME_LENGTH = 128; -const MAX_STRING_BYTES = 64 * 1024; -const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/; -export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]); - -function nowIso() { - return new Date().toISOString(); -} - -function httpError(message, statusCode = 400) { - const err = new Error(message); - err.statusCode = statusCode; - return err; -} - -/** - * @param {unknown} name - * @returns {string} - */ -export function assertVariableName(name) { - if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) { - throw httpError("invalid variable name"); - } - if (name.length > MAX_NAME_LENGTH) { - throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`); - } - return name; -} - -/** - * @param {unknown} type - * @returns {"string" | "number" | "boolean"} - */ -export function assertVariableType(type) { - if (type !== "string" && type !== "number" && type !== "boolean") { - throw httpError("type must be string, number, or boolean"); - } - return type; -} - -/** - * @param {"string" | "number" | "boolean"} type - * @param {unknown} value - * @returns {string} - */ -export function encodeVariableValue(type, value) { - if (type === "string") { - if (typeof value !== "string") { - throw httpError("value must be a string"); - } - if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) { - throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`); - } - return value; - } - if (type === "number") { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw httpError("value must be a finite number"); - } - return String(value); - } - if (typeof value !== "boolean") { - throw httpError("value must be a boolean"); - } - return value ? "true" : "false"; -} - -/** - * @param {"string" | "number" | "boolean"} type - * @param {string} stored - * @returns {string | number | boolean} - */ -export function decodeVariableValue(type, stored) { - if (type === "string") return stored; - if (type === "number") { - const n = Number(stored); - if (!Number.isFinite(n)) { - throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`); - } - return n; - } - if (stored === "true") return true; - if (stored === "false") return false; - throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`); -} - -/** - * @param {Record} row - */ -function publicVariable(row) { - const type = assertVariableType(row.type); - return { - id: row.id, - owner: row.owner, - name: row.name, - type, - value: decodeVariableValue(type, String(row.value ?? "")), - created_at: row.created_at, - updated_at: row.updated_at, - }; -} - -/** - * @param {{ owner?: string }} [filters] - */ -export async function listVariables(filters = {}) { - let q = db("variables") - .select("id", "owner", "name", "type", "value", "created_at", "updated_at") - .orderBy("owner", "asc") - .orderBy("name", "asc"); - if (filters.owner) { - q = q.where("owner", assertOwner(filters.owner)); - } - const rows = await q; - return rows.map((row) => publicVariable(row)); -} - -/** - * @param {string} id - */ -export async function getVariableById(id) { - const row = await db("variables").where({ id }).first(); - return row ? publicVariable(row) : null; -} - -/** - * @param {{ owner: string, name: string, type: unknown, value: unknown }} opts - */ -export async function upsertVariable({ owner, name, type, value }) { - const ownerName = assertOwner(owner); - const variableName = assertVariableName(name); - const variableType = assertVariableType(type); - const encoded = encodeVariableValue(variableType, value); - const now = nowIso(); - const existing = await db("variables") - .where({ owner: ownerName, name: variableName }) - .first(); - - if (existing) { - await db("variables") - .where({ id: existing.id }) - .update({ - type: variableType, - value: encoded, - updated_at: now, - }); - return getVariableById(existing.id); - } - - const id = randomUUID(); - await db("variables").insert({ - id, - owner: ownerName, - name: variableName, - type: variableType, - value: encoded, - created_at: now, - updated_at: now, - }); - return getVariableById(id); -} - -/** - * @param {string} id - * @returns {Promise} - */ -export async function deleteVariable(id) { - const n = await db("variables").where({ id }).del(); - return n > 0; -} - -/** - * Typed primitive for an owner/name. Returns null if missing. - * @param {string} owner - * @param {string} name - * @returns {Promise} - */ -export async function getVariablePlain(owner, name) { - const ownerName = assertOwner(owner); - const variableName = assertVariableName(name); - const row = await db("variables") - .where({ owner: ownerName, name: variableName }) - .first(); - if (!row) return null; - return decodeVariableValue(assertVariableType(row.type), String(row.value ?? "")); -} +export * from "./src/stores/variables-store.js"; diff --git a/packages/server/workflow-http-routes.js b/packages/server/workflow-http-routes.js new file mode 100644 index 0000000..4537cb0 --- /dev/null +++ b/packages/server/workflow-http-routes.js @@ -0,0 +1,159 @@ +import { HTTP_METHODS } from "@jerapah-flow/shared"; +import { + checkAnyHttpAuth, + resolveAuthMechanisms, + resolveUnauthorizedSpec, + sendHttpPageOrJson, + sendSuccessPage, +} from "./http-trigger-auth.js"; + +/** + * Rebuild METHOD+path → workflow map from loaded workflows. + * + * @param {Map} workflows + * @param {Map} httpRoutes + * @param {{ + * namespacedPath: (owner: string, path: unknown) => string, + * log: { debug: Function, warn: Function }, + * }} deps + */ +export function rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log }) { + httpRoutes.clear(); + + for (const [key, { owner, workflow }] of workflows) { + if (workflow.enabled === false) { + log.debug(`Skipping disabled workflow HTTP triggers (${key})`); + continue; + } + + for (const trigger of workflow.triggers ?? []) { + if (trigger.type !== "HTTP") continue; + + const method = String(trigger.method ?? "POST").toUpperCase(); + const url = namespacedPath(owner, trigger.path); + const routeKey = `${method} ${url}`; + + if (httpRoutes.has(routeKey)) { + log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`); + continue; + } + httpRoutes.set(routeKey, { key, owner, trigger }); + log.debug(`Mapped HTTP trigger ${routeKey} (${key})`); + } + } +} + +/** + * Register the /u/* Fastify wildcard once; subsequent rebuilds only refresh the map. + * + * @param {import("fastify").FastifyInstance} server + * @param {(req: import("fastify").FastifyRequest, reply: import("fastify").FastifyReply) => any} handler + * @param {{ registered: boolean }} state + * @param {{ log: { debug: Function } }} deps + */ +export function ensureHttpWildcardRoute(server, handler, state, { log }) { + if (state.registered) return; + state.registered = true; + server.route({ + method: HTTP_METHODS, + url: "/u/*", + handler, + }); + log.debug("Registered HTTP trigger wildcard dispatcher /u/*"); +} + +/** + * Build the HTTP trigger request handler bound to registry state. + * + * @param {{ + * httpRoutes: Map, + * workflows: Map, + * namespacedPath: (owner: string, path: unknown) => string, + * enqueueWorkflow: (key: string, ctx: any, trigger: any) => Promise, + * }} deps + */ +export function createHttpTriggerHandler({ + httpRoutes, + workflows, + namespacedPath, + enqueueWorkflow, +}) { + /** + * @param {import("fastify").FastifyRequest} req + * @param {import("fastify").FastifyReply} reply + */ + return 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 && + !(Array.isArray(liveTrigger.auth) && liveTrigger.auth.length === 0) + ) { + const mechanisms = await resolveAuthMechanisms(liveTrigger.auth); + if (mechanisms.length === 0) { + const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null); + return sendHttpPageOrJson(reply, status, pageName, { + error: "unauthorized", + }); + } + const ok = await checkAnyHttpAuth(req, mechanisms, { + owner: entry.owner, + workflowKey: mapped.key, + }); + if (!ok) { + const { status, pageName } = resolveUnauthorizedSpec( + liveTrigger, + mechanisms[0], + ); + return sendHttpPageOrJson(reply, status, pageName, { + error: "unauthorized", + }); + } + } + + const result = await enqueueWorkflow( + mapped.key, + { data: req.body }, + { type: "http", detail: `${method} ${url}` }, + ); + if (result.status === "failed") { + return reply.code(result.runId ? 500 : 404).send({ + runId: result.runId, + status: result.status, + error: result.error, + }); + } + + const defaultBody = { + runId: result.runId, + status: result.status, + }; + if (typeof liveTrigger.response === "string" && liveTrigger.response) { + return sendSuccessPage(reply, liveTrigger.response, defaultBody); + } + return reply.code(202).send(defaultBody); + }; +} diff --git a/packages/server/workflow-trash.js b/packages/server/workflow-trash.js index fac75e1..f0cb464 100644 --- a/packages/server/workflow-trash.js +++ b/packages/server/workflow-trash.js @@ -20,7 +20,7 @@ function trashFilePath(owner, file) { /** * @param {string} deletedAtIso */ -export function trashAgeMs(deletedAtIso) { +function trashAgeMs(deletedAtIso) { return Date.now() - Date.parse(deletedAtIso); } diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index cce8f82..746d179 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -1,761 +1 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, opsApi } from "./client.js"; - -export function useBootstrap() { - return useQuery({ - queryKey: ["bootstrap"], - queryFn: async () => (await api.get("/auth/bootstrap")).data, - }); -} - -export function useMe() { - return useQuery({ - queryKey: ["me"], - queryFn: async () => { - try { - return (await api.get("/auth/me")).data; - } catch (err) { - if (err.response?.status === 401) return { user: null }; - throw err; - } - }, - retry: false, - }); -} - -export function useLogin() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.post("/auth/login", body)).data, - onSuccess: (data) => { - qc.setQueryData(["me"], data); - qc.invalidateQueries({ queryKey: ["bootstrap"] }); - }, - }); -} - -export function useRegister() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.post("/auth/register", body)).data, - onSuccess: (data) => { - qc.setQueryData(["me"], data); - qc.invalidateQueries({ queryKey: ["bootstrap"] }); - }, - }); -} - -export function useLogout() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await api.post("/auth/logout")).data, - onSuccess: () => { - qc.setQueryData(["me"], null); - qc.clear(); - }, - }); -} - -export function useDashboard() { - return useQuery({ - queryKey: ["dashboard"], - queryFn: async () => (await api.get("/dashboard")).data, - refetchInterval: (query) => - query.state.data?.running?.length ? 4000 : 15000, - }); -} - -export function useScripts() { - return useQuery({ - queryKey: ["scripts"], - queryFn: async () => (await api.get("/scripts")).data.scripts, - }); -} - -export function useScript(name, enabled = true) { - return useQuery({ - queryKey: ["scripts", name], - queryFn: async () => (await api.get(`/scripts/${encodeURIComponent(name)}`)).data, - enabled: Boolean(name) && enabled, - }); -} - -export function useSaveScript() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ name, content }) => - (await api.put(`/scripts/${encodeURIComponent(name)}`, { content })).data, - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["scripts", vars.name] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - qc.invalidateQueries({ queryKey: ["ops-status"] }); - }, - }); -} - -export function useCreatePlugin() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ id, content, description }) => - (await api.post("/plugins/create", { id, content, description })).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["ops-status"] }); - }, - }); -} - -export function useForkScript() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ name, id, description }) => - ( - await api.post(`/scripts/${encodeURIComponent(name)}/fork`, { - id, - description, - }) - ).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["ops-status"] }); - }, - }); -} - -export function useInstallPlugin() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.post("/plugins/install", body)).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["ops-status"] }); - }, - }); -} - -export function useDeleteScript() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (name) => - (await api.delete(`/scripts/${encodeURIComponent(name)}`)).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useDryRunScript() { - return useMutation({ - mutationFn: async ({ name, content, data, context, config, owner }) => - ( - await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, { - content, - data, - context, - config, - owner, - }) - ).data, - }); -} - -export function useWorkflows(owner) { - return useQuery({ - queryKey: ["workflows", owner ?? "all"], - queryFn: async () => { - const params = owner ? { owner } : {}; - return (await api.get("/workflows", { params })).data.workflows; - }, - }); -} - -export function useWorkflow(owner, file, enabled = true) { - return useQuery({ - queryKey: ["workflows", owner, file], - queryFn: async () => - (await api.get(`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`)) - .data, - enabled: Boolean(owner && file) && enabled, - }); -} - -export function useOwners() { - return useQuery({ - queryKey: ["owners"], - queryFn: async () => (await api.get("/owners")).data.owners, - }); -} - -export function useSaveWorkflow() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file, content, saveAnyway }) => - ( - await api.put( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, - { content, ...(saveAnyway ? { saveAnyway: true } : {}) }, - ) - ).data, - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - qc.invalidateQueries({ - queryKey: ["workflows", vars.owner, vars.file, "revisions"], - }); - qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); - }, - }); -} - -export function useSetWorkflowEnabled() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file, enabled }) => - ( - await api.patch( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, - { enabled }, - ) - ).data, - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useDeleteWorkflow() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file }) => - ( - await api.delete( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, - ) - ).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useDuplicateWorkflow() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file, destOwner, destFile }) => - ( - await api.post( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`, - { - ...(destOwner ? { owner: destOwner } : {}), - ...(destFile ? { file: destFile } : {}), - }, - ) - ).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useRunWorkflow() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file, data }) => - ( - await api.post( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/run`, - data !== undefined ? { data } : {}, - ) - ).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["runs"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useReregisterWorkflows() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await api.post("/workflows/reregister")).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useRuns(filters = {}) { - const { owner, workflow, status, trigger, after, before, limit, offset, sort, order } = filters; - return useQuery({ - queryKey: ["runs", { owner, workflow, status, trigger, after, before, limit, offset, sort, order }], - queryFn: async () => { - const params = {}; - if (owner) params.owner = owner; - if (workflow) params.workflow = workflow; - if (status) params.status = status; - if (trigger) params.trigger = trigger; - if (after) params.after = after; - if (before) params.before = before; - if (limit != null) params.limit = limit; - if (offset != null) params.offset = offset; - if (sort) params.sort = sort; - if (order) params.order = order; - return (await api.get("/runs", { params })).data; - }, - }); -} - -export function useConsecutiveFailures(limit) { - return useQuery({ - queryKey: ["consecutive-failures", limit ?? "all"], - queryFn: async () => { - const params = {}; - if (limit) params.limit = limit; - return (await api.get("/consecutive-failures", { params })).data; - }, - }); -} - -export function useRun(id) { - return useQuery({ - queryKey: ["runs", id], - queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data, - enabled: Boolean(id), - refetchInterval: (query) => { - const status = query.state.data?.status; - return status === "running" || status === "queued" ? 1500 : false; - }, - }); -} - -export function useUsers() { - return useQuery({ - queryKey: ["users"], - queryFn: async () => (await api.get("/users")).data.users, - }); -} - -export function useCreateUser() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.post("/users", body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), - }); -} - -export function useUpdateUser() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ id, ...body }) => - (await api.patch(`/users/${encodeURIComponent(id)}`, body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), - }); -} - -export function useDeleteUser() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.delete(`/users/${encodeURIComponent(id)}`)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), - }); -} - -export function useSecrets(owner) { - return useQuery({ - queryKey: ["secrets", owner ?? "all"], - queryFn: async () => { - const params = owner ? { owner } : {}; - return (await api.get("/secrets", { params })).data.secrets; - }, - }); -} - -export function useUpsertSecret() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.put("/secrets", body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), - }); -} - -export function useDeleteSecret() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.delete(`/secrets/${encodeURIComponent(id)}`)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), - }); -} - -export function useVariables(owner, options = {}) { - return useQuery({ - queryKey: ["variables", owner ?? "all"], - queryFn: async () => { - const params = owner ? { owner } : {}; - return (await api.get("/variables", { params })).data.variables; - }, - ...options, - }); -} - -export function useUpsertVariable() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.put("/variables", body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }), - }); -} - -export function useDeleteVariable() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.delete(`/variables/${encodeURIComponent(id)}`)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }), - }); -} - -export function useProfiles(owner, options = {}) { - return useQuery({ - queryKey: ["profiles", owner ?? "all"], - queryFn: async () => { - const params = owner ? { owner } : {}; - return (await api.get("/profiles", { params })).data.profiles; - }, - ...options, - }); -} - -export function useProfileUsage(id, enabled = true) { - return useQuery({ - queryKey: ["profiles", "usage", id], - queryFn: async () => - (await api.get(`/profiles/${encodeURIComponent(id)}/usage`)).data.usages, - enabled: Boolean(id) && enabled, - }); -} - -export function useUpsertProfile() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.put("/profiles", body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }), - }); -} - -export function useDeleteProfile() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ id, force }) => - ( - await api.delete(`/profiles/${encodeURIComponent(id)}`, { - params: force ? { force: "1" } : {}, - }) - ).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }), - }); -} - -export function useKvNamespaces() { - return useQuery({ - queryKey: ["kv", "namespaces"], - queryFn: async () => (await api.get("/kv/namespaces")).data.namespaces, - }); -} - -export function useKv(filters = {}) { - const { namespace, q, limit, offset } = filters; - return useQuery({ - queryKey: ["kv", { namespace, q, limit, offset }], - queryFn: async () => { - const params = {}; - if (namespace) params.namespace = namespace; - if (q) params.q = q; - if (limit != null) params.limit = limit; - if (offset != null) params.offset = offset; - return (await api.get("/kv", { params })).data; - }, - }); -} - -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"] }), - }); -} - -/** Fetch plaintext literals only (not encrypted secrets). */ -export async function fetchHttpAuthLiterals(id) { - return (await api.get(`/http-auths/${encodeURIComponent(id)}/reveal`)).data; -} - -export function useOpsStatus(enabled = true) { - return useQuery({ - queryKey: ["ops-status"], - queryFn: async () => (await opsApi.get("/status")).data, - enabled, - retry: false, - refetchInterval: enabled ? 3000 : false, - }); -} - -export function useOpsPause() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await opsApi.post("/pause")).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsResume() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await opsApi.post("/resume")).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsReload() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await opsApi.post("/reload")).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["ops-status"] }); - qc.invalidateQueries({ queryKey: ["workflows"] }); - }, - }); -} - -export function useOpsRestart() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ force = false } = {}) => - (await opsApi.post("/restart", { force })).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsScale() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ workers, force = false }) => - (await opsApi.post("/scale", { workers, force })).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsHttpStart() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await opsApi.post("/http/start")).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsHttpStop() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await opsApi.post("/http/stop")).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsProcessRestart() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ pmId }) => - (await opsApi.post("/restart", { pmId: Number(pmId) })).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useWorkflowTrash() { - return useQuery({ - queryKey: ["workflows", "trash"], - queryFn: async () => (await api.get("/workflows/trash")).data.items, - }); -} - -export function useRestoreWorkflowTrash() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.post(`/workflows/trash/${encodeURIComponent(id)}/restore`)).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function usePurgeWorkflowTrash() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.delete(`/workflows/trash/${encodeURIComponent(id)}`)).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); - }, - }); -} - -export function useWorkflowRevisions(owner, file, enabled = true) { - return useQuery({ - queryKey: ["workflows", owner, file, "revisions"], - queryFn: async () => - ( - await api.get( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions`, - ) - ).data, - enabled: Boolean(owner && file) && enabled, - }); -} - -export function useWorkflowRevision(owner, file, revision) { - return useQuery({ - queryKey: ["workflows", owner, file, "revisions", revision], - queryFn: async () => - ( - await api.get( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}`, - ) - ).data, - enabled: Boolean(owner && file && revision != null), - }); -} - -export function useRevertWorkflowRevision() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file, revision, saveAnyway }) => - ( - await api.post( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}/revert`, - saveAnyway ? { saveAnyway: true } : {}, - ) - ).data, - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); - qc.invalidateQueries({ - queryKey: ["workflows", vars.owner, vars.file, "revisions"], - }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useCreateWorkflow() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, content, file, saveAnyway }) => - ( - await api.post(`/workflows/${encodeURIComponent(owner)}`, { - content, - ...(file ? { file } : {}), - ...(saveAnyway ? { saveAnyway: true } : {}), - }) - ).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useDownloadWorkflowBackup() { - return useMutation({ - mutationFn: async () => { - const res = await api.get("/workflows/backup", { responseType: "blob" }); - const disposition = res.headers["content-disposition"] ?? ""; - const match = disposition.match(/filename="([^"]+)"/); - const filename = match?.[1] ?? "jerapah-flow-backup.zip"; - const url = URL.createObjectURL(res.data); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - return { ok: true }; - }, - }); -} - -export function useRestoreWorkflowBackup() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ file, mode }) => { - const buffer = await file.arrayBuffer(); - const zipBase64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); - return (await api.post("/workflows/backup/restore", { zipBase64, mode })).data; - }, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - qc.invalidateQueries({ queryKey: ["ops-status"] }); - }, - }); -} - +export * from "./hooks/index.js"; diff --git a/packages/web/src/api/hooks/auth.js b/packages/web/src/api/hooks/auth.js new file mode 100644 index 0000000..2152e5d --- /dev/null +++ b/packages/web/src/api/hooks/auth.js @@ -0,0 +1,91 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useBootstrap() { + return useQuery({ + queryKey: ["bootstrap"], + queryFn: async () => (await api.get("/auth/bootstrap")).data, + }); +} + +export function useMe() { + return useQuery({ + queryKey: ["me"], + queryFn: async () => { + try { + return (await api.get("/auth/me")).data; + } catch (err) { + if (err.response?.status === 401) return { user: null }; + throw err; + } + }, + retry: false, + }); +} + +export function useLogin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.post("/auth/login", body)).data, + onSuccess: (data) => { + qc.setQueryData(["me"], data); + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + }, + }); +} + +export function useRegister() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.post("/auth/register", body)).data, + onSuccess: (data) => { + qc.setQueryData(["me"], data); + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + }, + }); +} + +export function useLogout() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await api.post("/auth/logout")).data, + onSuccess: () => { + qc.setQueryData(["me"], null); + qc.clear(); + }, + }); +} + +export function useUsers() { + return useQuery({ + queryKey: ["users"], + queryFn: async () => (await api.get("/users")).data.users, + }); +} + +export function useCreateUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.post("/users", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), + }); +} + +export function useUpdateUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, ...body }) => + (await api.patch(`/users/${encodeURIComponent(id)}`, body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), + }); +} + +export function useDeleteUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/users/${encodeURIComponent(id)}`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), + }); +} + diff --git a/packages/web/src/api/hooks/http.js b/packages/web/src/api/hooks/http.js new file mode 100644 index 0000000..0d7654a --- /dev/null +++ b/packages/web/src/api/hooks/http.js @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +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"] }), + }); +} + +/** Fetch plaintext literals only (not encrypted secrets). */ + +/** Fetch plaintext literals only (not encrypted secrets). */ +export async function fetchHttpAuthLiterals(id) { + return (await api.get(`/http-auths/${encodeURIComponent(id)}/reveal`)).data; +} + diff --git a/packages/web/src/api/hooks/index.js b/packages/web/src/api/hooks/index.js new file mode 100644 index 0000000..33ed695 --- /dev/null +++ b/packages/web/src/api/hooks/index.js @@ -0,0 +1,10 @@ +export * from "./auth.js"; +export * from "./scripts.js"; +export * from "./workflows.js"; +export * from "./runs.js"; +export * from "./secrets.js"; +export * from "./variables.js"; +export * from "./profiles.js"; +export * from "./kv.js"; +export * from "./http.js"; +export * from "./ops.js"; diff --git a/packages/web/src/api/hooks/kv.js b/packages/web/src/api/hooks/kv.js new file mode 100644 index 0000000..71ac369 --- /dev/null +++ b/packages/web/src/api/hooks/kv.js @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useKvNamespaces() { + return useQuery({ + queryKey: ["kv", "namespaces"], + queryFn: async () => (await api.get("/kv/namespaces")).data.namespaces, + }); +} + +export function useKv(filters = {}) { + const { namespace, q, limit, offset } = filters; + return useQuery({ + queryKey: ["kv", { namespace, q, limit, offset }], + queryFn: async () => { + const params = {}; + if (namespace) params.namespace = namespace; + if (q) params.q = q; + if (limit != null) params.limit = limit; + if (offset != null) params.offset = offset; + return (await api.get("/kv", { params })).data; + }, + }); +} + diff --git a/packages/web/src/api/hooks/ops.js b/packages/web/src/api/hooks/ops.js new file mode 100644 index 0000000..db9a9ea --- /dev/null +++ b/packages/web/src/api/hooks/ops.js @@ -0,0 +1,83 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { opsApi } from "../client.js"; + +export function useOpsStatus(enabled = true) { + return useQuery({ + queryKey: ["ops-status"], + queryFn: async () => (await opsApi.get("/status")).data, + enabled, + retry: false, + refetchInterval: enabled ? 3000 : false, + }); +} + +export function useOpsPause() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await opsApi.post("/pause")).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsResume() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await opsApi.post("/resume")).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsReload() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await opsApi.post("/reload")).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["ops-status"] }); + qc.invalidateQueries({ queryKey: ["workflows"] }); + }, + }); +} + +export function useOpsRestart() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ force = false } = {}) => + (await opsApi.post("/restart", { force })).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsScale() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ workers, force = false }) => + (await opsApi.post("/scale", { workers, force })).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsHttpStart() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await opsApi.post("/http/start")).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsHttpStop() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await opsApi.post("/http/stop")).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsProcessRestart() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ pmId }) => + (await opsApi.post("/restart", { pmId: Number(pmId) })).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + diff --git a/packages/web/src/api/hooks/profiles.js b/packages/web/src/api/hooks/profiles.js new file mode 100644 index 0000000..be5874f --- /dev/null +++ b/packages/web/src/api/hooks/profiles.js @@ -0,0 +1,44 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useProfiles(owner, options = {}) { + return useQuery({ + queryKey: ["profiles", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/profiles", { params })).data.profiles; + }, + ...options, + }); +} + +export function useProfileUsage(id, enabled = true) { + return useQuery({ + queryKey: ["profiles", "usage", id], + queryFn: async () => + (await api.get(`/profiles/${encodeURIComponent(id)}/usage`)).data.usages, + enabled: Boolean(id) && enabled, + }); +} + +export function useUpsertProfile() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/profiles", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }), + }); +} + +export function useDeleteProfile() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, force }) => + ( + await api.delete(`/profiles/${encodeURIComponent(id)}`, { + params: force ? { force: "1" } : {}, + }) + ).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }), + }); +} + diff --git a/packages/web/src/api/hooks/runs.js b/packages/web/src/api/hooks/runs.js new file mode 100644 index 0000000..429642c --- /dev/null +++ b/packages/web/src/api/hooks/runs.js @@ -0,0 +1,56 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useDashboard() { + return useQuery({ + queryKey: ["dashboard"], + queryFn: async () => (await api.get("/dashboard")).data, + refetchInterval: (query) => + query.state.data?.running?.length ? 4000 : 15000, + }); +} + +export function useRuns(filters = {}) { + const { owner, workflow, status, trigger, after, before, limit, offset, sort, order } = filters; + return useQuery({ + queryKey: ["runs", { owner, workflow, status, trigger, after, before, limit, offset, sort, order }], + queryFn: async () => { + const params = {}; + if (owner) params.owner = owner; + if (workflow) params.workflow = workflow; + if (status) params.status = status; + if (trigger) params.trigger = trigger; + if (after) params.after = after; + if (before) params.before = before; + if (limit != null) params.limit = limit; + if (offset != null) params.offset = offset; + if (sort) params.sort = sort; + if (order) params.order = order; + return (await api.get("/runs", { params })).data; + }, + }); +} + +export function useConsecutiveFailures(limit) { + return useQuery({ + queryKey: ["consecutive-failures", limit ?? "all"], + queryFn: async () => { + const params = {}; + if (limit) params.limit = limit; + return (await api.get("/consecutive-failures", { params })).data; + }, + }); +} + +export function useRun(id) { + return useQuery({ + queryKey: ["runs", id], + queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data, + enabled: Boolean(id), + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === "running" || status === "queued" ? 1500 : false; + }, + }); +} + diff --git a/packages/web/src/api/hooks/scripts.js b/packages/web/src/api/hooks/scripts.js new file mode 100644 index 0000000..96bb319 --- /dev/null +++ b/packages/web/src/api/hooks/scripts.js @@ -0,0 +1,99 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useScripts() { + return useQuery({ + queryKey: ["scripts"], + queryFn: async () => (await api.get("/scripts")).data.scripts, + }); +} + +export function useScript(name, enabled = true) { + return useQuery({ + queryKey: ["scripts", name], + queryFn: async () => (await api.get(`/scripts/${encodeURIComponent(name)}`)).data, + enabled: Boolean(name) && enabled, + }); +} + +export function useSaveScript() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ name, content }) => + (await api.put(`/scripts/${encodeURIComponent(name)}`, { content })).data, + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["scripts", vars.name] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + qc.invalidateQueries({ queryKey: ["ops-status"] }); + }, + }); +} + +export function useCreatePlugin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, content, description }) => + (await api.post("/plugins/create", { id, content, description })).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["ops-status"] }); + }, + }); +} + +export function useForkScript() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ name, id, description }) => + ( + await api.post(`/scripts/${encodeURIComponent(name)}/fork`, { + id, + description, + }) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["ops-status"] }); + }, + }); +} + +export function useInstallPlugin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.post("/plugins/install", body)).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["ops-status"] }); + }, + }); +} + +export function useDeleteScript() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (name) => + (await api.delete(`/scripts/${encodeURIComponent(name)}`)).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useDryRunScript() { + return useMutation({ + mutationFn: async ({ name, content, data, context, config, owner }) => + ( + await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, { + content, + data, + context, + config, + owner, + }) + ).data, + }); +} + diff --git a/packages/web/src/api/hooks/secrets.js b/packages/web/src/api/hooks/secrets.js new file mode 100644 index 0000000..b2a9aa4 --- /dev/null +++ b/packages/web/src/api/hooks/secrets.js @@ -0,0 +1,30 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useSecrets(owner) { + return useQuery({ + queryKey: ["secrets", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/secrets", { params })).data.secrets; + }, + }); +} + +export function useUpsertSecret() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/secrets", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), + }); +} + +export function useDeleteSecret() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/secrets/${encodeURIComponent(id)}`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), + }); +} + diff --git a/packages/web/src/api/hooks/variables.js b/packages/web/src/api/hooks/variables.js new file mode 100644 index 0000000..1a03c2e --- /dev/null +++ b/packages/web/src/api/hooks/variables.js @@ -0,0 +1,31 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useVariables(owner, options = {}) { + return useQuery({ + queryKey: ["variables", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/variables", { params })).data.variables; + }, + ...options, + }); +} + +export function useUpsertVariable() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/variables", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }), + }); +} + +export function useDeleteVariable() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/variables/${encodeURIComponent(id)}`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }), + }); +} + diff --git a/packages/web/src/api/hooks/workflows.js b/packages/web/src/api/hooks/workflows.js new file mode 100644 index 0000000..3f45102 --- /dev/null +++ b/packages/web/src/api/hooks/workflows.js @@ -0,0 +1,273 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useWorkflows(owner) { + return useQuery({ + queryKey: ["workflows", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/workflows", { params })).data.workflows; + }, + }); +} + +export function useWorkflow(owner, file, enabled = true) { + return useQuery({ + queryKey: ["workflows", owner, file], + queryFn: async () => + (await api.get(`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`)) + .data, + enabled: Boolean(owner && file) && enabled, + }); +} + +export function useOwners() { + return useQuery({ + queryKey: ["owners"], + queryFn: async () => (await api.get("/owners")).data.owners, + }); +} + +export function useSaveWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, content, saveAnyway }) => + ( + await api.put( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, + { content, ...(saveAnyway ? { saveAnyway: true } : {}) }, + ) + ).data, + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + qc.invalidateQueries({ + queryKey: ["workflows", vars.owner, vars.file, "revisions"], + }); + qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); + }, + }); +} + +export function useSetWorkflowEnabled() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, enabled }) => + ( + await api.patch( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, + { enabled }, + ) + ).data, + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useDeleteWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file }) => + ( + await api.delete( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, + ) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useDuplicateWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, destOwner, destFile }) => + ( + await api.post( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`, + { + ...(destOwner ? { owner: destOwner } : {}), + ...(destFile ? { file: destFile } : {}), + }, + ) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useRunWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, data }) => + ( + await api.post( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/run`, + data !== undefined ? { data } : {}, + ) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["runs"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useReregisterWorkflows() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await api.post("/workflows/reregister")).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useWorkflowTrash() { + return useQuery({ + queryKey: ["workflows", "trash"], + queryFn: async () => (await api.get("/workflows/trash")).data.items, + }); +} + +export function useRestoreWorkflowTrash() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.post(`/workflows/trash/${encodeURIComponent(id)}/restore`)).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function usePurgeWorkflowTrash() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/workflows/trash/${encodeURIComponent(id)}`)).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); + }, + }); +} + +export function useWorkflowRevisions(owner, file, enabled = true) { + return useQuery({ + queryKey: ["workflows", owner, file, "revisions"], + queryFn: async () => + ( + await api.get( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions`, + ) + ).data, + enabled: Boolean(owner && file) && enabled, + }); +} + +export function useWorkflowRevision(owner, file, revision) { + return useQuery({ + queryKey: ["workflows", owner, file, "revisions", revision], + queryFn: async () => + ( + await api.get( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}`, + ) + ).data, + enabled: Boolean(owner && file && revision != null), + }); +} + +export function useRevertWorkflowRevision() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, revision, saveAnyway }) => + ( + await api.post( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}/revert`, + saveAnyway ? { saveAnyway: true } : {}, + ) + ).data, + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); + qc.invalidateQueries({ + queryKey: ["workflows", vars.owner, vars.file, "revisions"], + }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useCreateWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, content, file, saveAnyway }) => + ( + await api.post(`/workflows/${encodeURIComponent(owner)}`, { + content, + ...(file ? { file } : {}), + ...(saveAnyway ? { saveAnyway: true } : {}), + }) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useDownloadWorkflowBackup() { + return useMutation({ + mutationFn: async () => { + const res = await api.get("/workflows/backup", { responseType: "blob" }); + const disposition = res.headers["content-disposition"] ?? ""; + const match = disposition.match(/filename="([^"]+)"/); + const filename = match?.[1] ?? "jerapah-flow-backup.zip"; + const url = URL.createObjectURL(res.data); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + return { ok: true }; + }, + }); +} + +export function useRestoreWorkflowBackup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ file, mode }) => { + const buffer = await file.arrayBuffer(); + const zipBase64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); + return (await api.post("/workflows/backup/restore", { zipBase64, mode })).data; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + qc.invalidateQueries({ queryKey: ["ops-status"] }); + }, + }); +} + diff --git a/packages/web/src/components/HeartbeatsAndPm2Card.jsx b/packages/web/src/components/HeartbeatsAndPm2Card.jsx index 4e3c220..bc8a60a 100644 --- a/packages/web/src/components/HeartbeatsAndPm2Card.jsx +++ b/packages/web/src/components/HeartbeatsAndPm2Card.jsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { errorMessage } from "../api/client.js"; import { HeartbeatsToolbar } from "./HeartbeatsToolbar.jsx"; import { useNotifications } from "../notifications.jsx"; -import { formatBytes, formatCpu, formatDuration } from "../lib/format.js"; +import { formatBytes, formatCpu, formatDuration } from "../lib/format"; const MAX_WORKERS = 32; diff --git a/packages/web/src/components/LogViewer.jsx b/packages/web/src/components/LogViewer.jsx index ddd8ac5..65c9f1d 100644 --- a/packages/web/src/components/LogViewer.jsx +++ b/packages/web/src/components/LogViewer.jsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { LuX } from "react-icons/lu"; -import { levelName } from "../lib/format.jsx"; +import { levelName } from "../lib/format"; export function LogViewer({ logs = [], filters = [], onRemoveFilter, className = "" }) { const [wordWrap, setWordWrap] = useState(true); diff --git a/packages/web/src/components/ProcessResourcesCard.jsx b/packages/web/src/components/ProcessResourcesCard.jsx index 6296c37..a6703b5 100644 --- a/packages/web/src/components/ProcessResourcesCard.jsx +++ b/packages/web/src/components/ProcessResourcesCard.jsx @@ -1,4 +1,4 @@ -import { formatBytes, formatCpu } from "../lib/format.js"; +import { formatBytes, formatCpu } from "../lib/format"; export function ProcessResourcesCard({ children }) { const totals = children?.totals ?? { memory: 0, cpu: 0 }; diff --git a/packages/web/src/components/workflow/AuthPicker.jsx b/packages/web/src/components/workflow/AuthPicker.jsx new file mode 100644 index 0000000..2f4a4c6 --- /dev/null +++ b/packages/web/src/components/workflow/AuthPicker.jsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { LuChevronDown, LuX } from "react-icons/lu"; +import { FormInput } from "../FormControls.jsx"; + +/** + * Searchable dropdown: pick an auth to add; chips with X to remove. + * YAML stores profile UUIDs; UI shows names. + */ +export function AuthPicker({ auths, selectedIds, inlineCount, disabled, onChange }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const rootRef = useRef(null); + const inputRef = useRef(null); + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const byId = useMemo(() => new Map(auths.map((a) => [a.id, a])), [auths]); + + const available = useMemo(() => { + const q = query.trim().toLowerCase(); + return auths + .filter((a) => !selectedSet.has(a.id)) + .filter((a) => { + if (!q) return true; + return ( + a.name.toLowerCase().includes(q) || + String(a.type).toLowerCase().includes(q) || + a.id.toLowerCase().includes(q) + ); + }); + }, [auths, selectedSet, query]); + + useEffect(() => { + if (!open) return; + function onDoc(e) { + if (rootRef.current && !rootRef.current.contains(e.target)) { + setOpen(false); + setQuery(""); + } + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + function addAuth(id) { + if (selectedSet.has(id)) return; + onChange([...selectedIds, id]); + setQuery(""); + setOpen(false); + } + + function removeAuth(id) { + onChange(selectedIds.filter((x) => x !== id)); + } + + return ( +
+ Auth (any of) + + {selectedIds.length > 0 ? ( +
+ {selectedIds.map((id) => { + const auth = byId.get(id); + return ( + + + {auth ? ( + <> + {auth.name} + · {auth.type} + + ) : ( + missing + )} + + + + ); + })} +
+ ) : null} + +
+
+ { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={(e) => { + if (e.key === "Escape") { + setOpen(false); + setQuery(""); + inputRef.current?.blur(); + } + if (e.key === "Enter") { + e.preventDefault(); + if (available[0]) addAuth(available[0].id); + } + }} + /> + +
+ {open && !disabled && auths.length > 0 ? ( +
    + {available.length === 0 ? ( +
  • + + {query.trim() ? "No matches" : "All profiles selected"} + +
  • + ) : ( + available.map((a) => ( +
  • + +
  • + )) + )} +
+ ) : null} +
+ + {inlineCount > 0 ? ( + + + {inlineCount} inline auth{inlineCount === 1 ? "" : "s"} (edit in YAML) + + ) : null} +
+ ); +} diff --git a/packages/web/src/components/workflow/ConfigFields.jsx b/packages/web/src/components/workflow/ConfigFields.jsx index 44221ec..b56c044 100644 --- a/packages/web/src/components/workflow/ConfigFields.jsx +++ b/packages/web/src/components/workflow/ConfigFields.jsx @@ -1,153 +1,12 @@ import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; -import { LuEye, LuEyeOff, LuExternalLink, LuPlus, LuTrash2 } from "react-icons/lu"; -import { useVariables } from "../../api/hooks.js"; +import { LuPlus, LuTrash2 } from "react-icons/lu"; import { triggerDestinations } from "../../lib/workflow-doc.js"; import { prettyJson } from "../../lib/script.js"; import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx"; import { FieldLabel } from "./FieldHelp.jsx"; +import { ConfigRefHint } from "./ConfigRefHint.jsx"; const MULTILINE_KEYS = new Set(["expression", "jsonata"]); -const VAR_PEEK_MAX = 48; - -const CONFIG_REF_PREFIXES = [ - { prefix: "$SECRET_", label: "secret" }, - { prefix: "$CONTEXT_", label: "context" }, - { prefix: "$VAR_", label: "variable" }, -]; - -function describeConfigRef(value) { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - for (const { prefix, label } of CONFIG_REF_PREFIXES) { - if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) { - return { label, name: trimmed.slice(prefix.length), kind: prefix }; - } - } - return null; -} - -function formatVarDisplay(value) { - if (typeof value === "string") return value === "" ? '""' : value; - return String(value); -} - -function truncatePeek(text, maxLen = VAR_PEEK_MAX) { - if (!text) return ""; - if (text.length <= maxLen) return text; - return `${text.slice(0, Math.max(0, maxLen - 1))}…`; -} - -/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */ -function lookupVariable(variables, owner, name) { - const list = Array.isArray(variables) ? variables : []; - const match = list.find((v) => v.owner === owner && v.name === name); - if (match) { - return { status: "found", value: match.value, type: match.type }; - } - const otherOwners = [ - ...new Set(list.filter((v) => v.name === name && v.owner !== owner).map((v) => v.owner)), - ]; - if (otherOwners.length > 0) { - return { status: "other_owner", otherOwners }; - } - return { status: "missing" }; -} - -function variablesDeepLink({ owner, name, missing }) { - if (missing) { - const params = new URLSearchParams(); - if (owner) params.set("owner", owner); - if (name) params.set("name", name); - const q = params.toString(); - return q ? `/variables/new?${q}` : "/variables/new"; - } - return `/variables/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/edit`; -} - -function ConfigRefHint({ value, owner }) { - const ref = describeConfigRef(value); - const isVar = ref?.kind === "$VAR_"; - const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar }); - const [revealed, setRevealed] = useState(false); - - useEffect(() => { - setRevealed(false); - }, [value, owner]); - - if (!ref) return null; - - if (!isVar) { - return ( -

- from {ref.label} {ref.name} -

- ); - } - - const lookup = !owner - ? { status: "missing" } - : isPending - ? { status: "loading" } - : lookupVariable(variables, owner, ref.name); - const linkTo = variablesDeepLink({ - owner: owner || "", - name: ref.name, - missing: lookup.status !== "found", - }); - - let statusNote = null; - if (lookup.status === "missing") { - statusNote = (missing); - } else if (lookup.status === "other_owner") { - statusNote = ( - - (missing · other owner: {lookup.otherOwners.join(", ")}) - - ); - } - - const fullText = lookup.status === "found" ? formatVarDisplay(lookup.value) : ""; - const peek = truncatePeek(fullText); - const masked = lookup.status === "found" ? "******" : null; - - return ( -
- - from variable {ref.name} - {lookup.status === "found" ? ":" : null} - - {lookup.status === "found" ? ( - <> - - {revealed ? peek : masked} - - - - ) : null} - {statusNote} - {lookup.status === "loading" ? null : ( - - {lookup.status === "found" ? "Open Variables" : "Create on Variables"} - - - )} -
- ); -} function fieldSpec(meta, key) { const spec = meta?.config?.[key]; diff --git a/packages/web/src/components/workflow/ConfigRefHint.jsx b/packages/web/src/components/workflow/ConfigRefHint.jsx new file mode 100644 index 0000000..af38300 --- /dev/null +++ b/packages/web/src/components/workflow/ConfigRefHint.jsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu"; +import { useVariables } from "../../api/hooks.js"; + +const VAR_PEEK_MAX = 48; + +const CONFIG_REF_PREFIXES = [ + { prefix: "$SECRET_", label: "secret" }, + { prefix: "$CONTEXT_", label: "context" }, + { prefix: "$VAR_", label: "variable" }, +]; + +function describeConfigRef(value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + for (const { prefix, label } of CONFIG_REF_PREFIXES) { + if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) { + return { label, name: trimmed.slice(prefix.length), kind: prefix }; + } + } + return null; +} + +function formatVarDisplay(value) { + if (typeof value === "string") return value === "" ? '""' : value; + return String(value); +} + +function truncatePeek(text, maxLen = VAR_PEEK_MAX) { + if (!text) return ""; + if (text.length <= maxLen) return text; + return `${text.slice(0, Math.max(0, maxLen - 1))}…`; +} + +/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */ +function lookupVariable(variables, owner, name) { + const list = Array.isArray(variables) ? variables : []; + const match = list.find((v) => v.owner === owner && v.name === name); + if (match) { + return { status: "found", value: match.value, type: match.type }; + } + const otherOwners = [ + ...new Set(list.filter((v) => v.name === name && v.owner !== owner).map((v) => v.owner)), + ]; + if (otherOwners.length > 0) { + return { status: "other_owner", otherOwners }; + } + return { status: "missing" }; +} + +function variablesDeepLink({ owner, name, missing }) { + if (missing) { + const params = new URLSearchParams(); + if (owner) params.set("owner", owner); + if (name) params.set("name", name); + const q = params.toString(); + return q ? `/variables/new?${q}` : "/variables/new"; + } + return `/variables/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/edit`; +} + +export function ConfigRefHint({ value, owner }) { + const ref = describeConfigRef(value); + const isVar = ref?.kind === "$VAR_"; + const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar }); + const [revealed, setRevealed] = useState(false); + + useEffect(() => { + setRevealed(false); + }, [value, owner]); + + if (!ref) return null; + + if (!isVar) { + return ( +

+ from {ref.label} {ref.name} +

+ ); + } + + const lookup = !owner + ? { status: "missing" } + : isPending + ? { status: "loading" } + : lookupVariable(variables, owner, ref.name); + const linkTo = variablesDeepLink({ + owner: owner || "", + name: ref.name, + missing: lookup.status !== "found", + }); + + let statusNote = null; + if (lookup.status === "missing") { + statusNote = (missing); + } else if (lookup.status === "other_owner") { + statusNote = ( + + (missing · other owner: {lookup.otherOwners.join(", ")}) + + ); + } + + const fullText = lookup.status === "found" ? formatVarDisplay(lookup.value) : ""; + const peek = truncatePeek(fullText); + const masked = lookup.status === "found" ? "******" : null; + + return ( +
+ + from variable {ref.name} + {lookup.status === "found" ? ":" : null} + + {lookup.status === "found" ? ( + <> + + {revealed ? peek : masked} + + + + ) : null} + {statusNote} + {lookup.status === "loading" ? null : ( + + {lookup.status === "found" ? "Open Variables" : "Create on Variables"} + + + )} +
+ ); +} diff --git a/packages/web/src/components/workflow/ScriptCard.jsx b/packages/web/src/components/workflow/ScriptCard.jsx index 386a297..264d55e 100644 --- a/packages/web/src/components/workflow/ScriptCard.jsx +++ b/packages/web/src/components/workflow/ScriptCard.jsx @@ -7,7 +7,7 @@ import { errorMessage } from "../../api/client.js"; import { CodeEditor } from "../CodeEditor.jsx"; import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx"; import { LogViewer } from "../LogViewer.jsx"; -import { StatusBadge } from "../../lib/format.jsx"; +import { StatusBadge } from "../../lib/format"; import { contextFromMeta, prettyJson } from "../../lib/script.js"; import { ConfigFields } from "./ConfigFields.jsx"; import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx"; @@ -390,7 +390,7 @@ function needsMode(needs) { return "map"; } -export function ScriptTryDialog({ script, config, meta, owner, onClose }) { +function ScriptTryDialog({ script, config, meta, owner, onClose }) { const existing = useScript(script); const dryRun = useDryRunScript(); const defaults = useMemo(() => contextFromMeta(meta), [meta]); diff --git a/packages/web/src/components/workflow/TriggerCard.jsx b/packages/web/src/components/workflow/TriggerCard.jsx index 581f274..15aa3cb 100644 --- a/packages/web/src/components/workflow/TriggerCard.jsx +++ b/packages/web/src/components/workflow/TriggerCard.jsx @@ -1,11 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2, LuX } from "react-icons/lu"; +import { useState } from "react"; +import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import cronstrue from "cronstrue"; import { namespacedPath, HTTP_METHODS, triggerDestinations } from "../../lib/workflow-doc.js"; import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from "../../lib/cron-presets.js"; import { FormInput, FormSelect } from "../FormControls.jsx"; +import { AuthPicker } from "./AuthPicker.jsx"; export function TriggerCard({ trigger, @@ -253,164 +254,6 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes ); } -/** - * Searchable dropdown: pick an auth to add; chips with X to remove. - * YAML stores profile UUIDs; UI shows names. - */ -function AuthPicker({ auths, selectedIds, inlineCount, disabled, onChange }) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const rootRef = useRef(null); - const inputRef = useRef(null); - const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); - const byId = useMemo(() => new Map(auths.map((a) => [a.id, a])), [auths]); - - const available = useMemo(() => { - const q = query.trim().toLowerCase(); - return auths - .filter((a) => !selectedSet.has(a.id)) - .filter((a) => { - if (!q) return true; - return ( - a.name.toLowerCase().includes(q) || - String(a.type).toLowerCase().includes(q) || - a.id.toLowerCase().includes(q) - ); - }); - }, [auths, selectedSet, query]); - - useEffect(() => { - if (!open) return; - function onDoc(e) { - if (rootRef.current && !rootRef.current.contains(e.target)) { - setOpen(false); - setQuery(""); - } - } - document.addEventListener("mousedown", onDoc); - return () => document.removeEventListener("mousedown", onDoc); - }, [open]); - - function addAuth(id) { - if (selectedSet.has(id)) return; - onChange([...selectedIds, id]); - setQuery(""); - setOpen(false); - } - - function removeAuth(id) { - onChange(selectedIds.filter((x) => x !== id)); - } - - return ( -
- Auth (any of) - - {selectedIds.length > 0 ? ( -
- {selectedIds.map((id) => { - const auth = byId.get(id); - return ( - - - {auth ? ( - <> - {auth.name} - · {auth.type} - - ) : ( - missing - )} - - - - ); - })} -
- ) : null} - -
-
- { - setQuery(e.target.value); - setOpen(true); - }} - onFocus={() => setOpen(true)} - onKeyDown={(e) => { - if (e.key === "Escape") { - setOpen(false); - setQuery(""); - inputRef.current?.blur(); - } - if (e.key === "Enter") { - e.preventDefault(); - if (available[0]) addAuth(available[0].id); - } - }} - /> - -
- {open && !disabled && auths.length > 0 ? ( -
    - {available.length === 0 ? ( -
  • - - {query.trim() ? "No matches" : "All profiles selected"} - -
  • - ) : ( - available.map((a) => ( -
  • - -
  • - )) - )} -
- ) : null} -
- - {inlineCount > 0 ? ( - - + {inlineCount} inline auth{inlineCount === 1 ? "" : "s"} (edit in YAML) - - ) : null} -
- ); -} function CronFields({ trigger, disabled, onChange, alertDestinations }) { const preset = matchCronPreset(trigger.schedule); diff --git a/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx b/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx index 8e2b333..9cb18e5 100644 --- a/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx +++ b/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx @@ -5,7 +5,7 @@ import { useRevertWorkflowRevision, useWorkflowRevisions, } from "../../api/hooks.js"; -import { formatTime } from "../../lib/format.jsx"; +import { formatTime } from "../../lib/format"; import { useNotifications } from "../../notifications.jsx"; import { SaveWorkflowWarningsDialog, diff --git a/packages/web/src/components/workflow/WorkflowRevisionBanner.jsx b/packages/web/src/components/workflow/WorkflowRevisionBanner.jsx index ebcd973..10934d0 100644 --- a/packages/web/src/components/workflow/WorkflowRevisionBanner.jsx +++ b/packages/web/src/components/workflow/WorkflowRevisionBanner.jsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { LuHistory } from "react-icons/lu"; import { errorMessage } from "../../api/client.js"; import { useRevertWorkflowRevision } from "../../api/hooks.js"; -import { formatTime } from "../../lib/format.jsx"; +import { formatTime } from "../../lib/format"; import { useNotifications } from "../../notifications.jsx"; import { ConfirmDialog } from "../ConfirmDialog.jsx"; import { diff --git a/packages/web/src/components/workflow/WorkflowTestPanel.jsx b/packages/web/src/components/workflow/WorkflowTestPanel.jsx index 4ac649c..2cd4056 100644 --- a/packages/web/src/components/workflow/WorkflowTestPanel.jsx +++ b/packages/web/src/components/workflow/WorkflowTestPanel.jsx @@ -4,7 +4,7 @@ import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu"; import { api, errorMessage } from "../../api/client.js"; import { useRunWorkflow } from "../../api/hooks.js"; import { CodeEditor } from "../CodeEditor.jsx"; -import { StatusBadge } from "../../lib/format.jsx"; +import { StatusBadge } from "../../lib/format"; import { prettyJson } from "../../lib/script.js"; import { overlayWorkflowTestData, diff --git a/packages/web/src/components/workflow/WorkflowVisualEditor.jsx b/packages/web/src/components/workflow/WorkflowVisualEditor.jsx index 6a6254c..28aaf7a 100644 --- a/packages/web/src/components/workflow/WorkflowVisualEditor.jsx +++ b/packages/web/src/components/workflow/WorkflowVisualEditor.jsx @@ -10,7 +10,7 @@ import { TriggersTab } from "./TriggersTab.jsx"; import { YamlTab } from "./YamlTab.jsx"; import { WorkflowTestPanel } from "./WorkflowTestPanel.jsx"; -export function useYamlPreview(content) { +function useYamlPreview(content) { return useMemo(() => { try { const parsedYaml = parseYaml(content); diff --git a/packages/web/src/lib/format.jsx b/packages/web/src/lib/format/badges.jsx similarity index 100% rename from packages/web/src/lib/format.jsx rename to packages/web/src/lib/format/badges.jsx diff --git a/packages/web/src/lib/format/index.js b/packages/web/src/lib/format/index.js new file mode 100644 index 0000000..165d380 --- /dev/null +++ b/packages/web/src/lib/format/index.js @@ -0,0 +1,2 @@ +export { formatBytes, formatCpu, formatDuration } from "./numbers.js"; +export { formatTime, StatusBadge, WorkflowStatusBadge, levelName } from "./badges.jsx"; diff --git a/packages/web/src/lib/format.js b/packages/web/src/lib/format/numbers.js similarity index 100% rename from packages/web/src/lib/format.js rename to packages/web/src/lib/format/numbers.js diff --git a/packages/web/src/pages/AuthProfilesPage.jsx b/packages/web/src/pages/AuthProfilesPage.jsx index 446d9a3..e156417 100644 --- a/packages/web/src/pages/AuthProfilesPage.jsx +++ b/packages/web/src/pages/AuthProfilesPage.jsx @@ -10,7 +10,7 @@ import { import { AuthEditorModal } from "../components/AuthEditorModal.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js"; -import { formatTime } from "../lib/format.jsx"; +import { formatTime } from "../lib/format"; function CredDisplay({ field, fieldKey, authId, cache, onRevealed }) { const [open, setOpen] = useState(false); diff --git a/packages/web/src/pages/EventDetailPage.jsx b/packages/web/src/pages/EventDetailPage.jsx index bf5a3dd..6ca9a5f 100644 --- a/packages/web/src/pages/EventDetailPage.jsx +++ b/packages/web/src/pages/EventDetailPage.jsx @@ -4,7 +4,7 @@ import { LuArrowLeft, LuFilter } from "react-icons/lu"; import { useRun } from "../api/hooks.js"; import { JsonViewBlock } from "../components/JsonViewBlock.jsx"; import { LogViewer } from "../components/LogViewer.jsx"; -import { formatTime, StatusBadge } from "../lib/format.jsx"; +import { formatTime, StatusBadge } from "../lib/format"; function stepLabel(s) { if (s.script === "set") return "set"; diff --git a/packages/web/src/pages/EventsPage.jsx b/packages/web/src/pages/EventsPage.jsx index f1ec475..4126df0 100644 --- a/packages/web/src/pages/EventsPage.jsx +++ b/packages/web/src/pages/EventsPage.jsx @@ -1,6 +1,6 @@ import { Link, useSearchParams } from "react-router-dom"; import { useOwners, useRuns } from "../api/hooks.js"; -import { formatTime, StatusBadge } from "../lib/format.jsx"; +import { formatTime, StatusBadge } from "../lib/format"; const PAGE_SIZES = [25, 50, 100]; const DEFAULT_LIMIT = 50; diff --git a/packages/web/src/pages/FailuresPage.jsx b/packages/web/src/pages/FailuresPage.jsx index b7836a9..85bae5e 100644 --- a/packages/web/src/pages/FailuresPage.jsx +++ b/packages/web/src/pages/FailuresPage.jsx @@ -1,6 +1,6 @@ import { Link } from "react-router-dom"; import { useConsecutiveFailures } from "../api/hooks.js"; -import { formatTime } from "../lib/format.jsx"; +import { formatTime } from "../lib/format"; export function FailuresPage() { const { data, isLoading, error } = useConsecutiveFailures(); diff --git a/packages/web/src/pages/HomePage.jsx b/packages/web/src/pages/HomePage.jsx index 8402d2a..fada1aa 100644 --- a/packages/web/src/pages/HomePage.jsx +++ b/packages/web/src/pages/HomePage.jsx @@ -6,7 +6,7 @@ import { LuTriangleAlert, } from "react-icons/lu"; import { useDashboard } from "../api/hooks.js"; -import { formatTime, StatusBadge } from "../lib/format.jsx"; +import { formatTime, StatusBadge } from "../lib/format"; export function HomePage() { const { data, isLoading, error } = useDashboard(); diff --git a/packages/web/src/pages/KvPage.jsx b/packages/web/src/pages/KvPage.jsx index f419800..0e36583 100644 --- a/packages/web/src/pages/KvPage.jsx +++ b/packages/web/src/pages/KvPage.jsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useKv, useKvNamespaces } from "../api/hooks.js"; import { FormSelect } from "../components/FormControls.jsx"; -import { formatTime } from "../lib/format.jsx"; +import { formatTime } from "../lib/format"; const PAGE_SIZE = 50; diff --git a/packages/web/src/pages/ProfilesPage.jsx b/packages/web/src/pages/ProfilesPage.jsx index 12f5224..c3040ce 100644 --- a/packages/web/src/pages/ProfilesPage.jsx +++ b/packages/web/src/pages/ProfilesPage.jsx @@ -8,7 +8,7 @@ import { Modal } from "../components/Modal.jsx"; import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx"; import { ScriptIcon } from "../components/ScriptIcon.jsx"; import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js"; -import { formatTime } from "../lib/format.jsx"; +import { formatTime } from "../lib/format"; function previewConfig(config) { if (!config || typeof config !== "object") return ""; diff --git a/packages/web/src/pages/ResponsesPage.jsx b/packages/web/src/pages/ResponsesPage.jsx index 13b4fab..7ade866 100644 --- a/packages/web/src/pages/ResponsesPage.jsx +++ b/packages/web/src/pages/ResponsesPage.jsx @@ -7,7 +7,7 @@ import { useUpsertHttpPage, } from "../api/hooks.js"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; -import { formatTime } from "../lib/format.jsx"; +import { formatTime } from "../lib/format"; const emptyForm = { name: "", diff --git a/packages/web/src/pages/ScriptDryRunPage.jsx b/packages/web/src/pages/ScriptDryRunPage.jsx index 48e3d0f..6d43c9f 100644 --- a/packages/web/src/pages/ScriptDryRunPage.jsx +++ b/packages/web/src/pages/ScriptDryRunPage.jsx @@ -12,7 +12,7 @@ import { CodeEditor } from "../components/CodeEditor.jsx"; import { LogViewer } from "../components/LogViewer.jsx"; import { ScriptIcon } from "../components/ScriptIcon.jsx"; import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx"; -import { StatusBadge } from "../lib/format.jsx"; +import { StatusBadge } from "../lib/format"; import { DEFAULT_INPUT_CONTEXT, NEW_SCRIPT_TEMPLATE, diff --git a/packages/web/src/pages/SecretsPage.jsx b/packages/web/src/pages/SecretsPage.jsx index 7bc5325..2022a46 100644 --- a/packages/web/src/pages/SecretsPage.jsx +++ b/packages/web/src/pages/SecretsPage.jsx @@ -7,7 +7,7 @@ import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { FormSelect } from "../components/FormControls.jsx"; import { SecretEditorModal } from "../components/SecretEditorModal.jsx"; import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js"; -import { formatTime } from "../lib/format.jsx"; +import { formatTime } from "../lib/format"; export function SecretsPage() { const navigate = useNavigate(); diff --git a/packages/web/src/pages/VariablesPage.jsx b/packages/web/src/pages/VariablesPage.jsx index 7266ee4..9aaed2a 100644 --- a/packages/web/src/pages/VariablesPage.jsx +++ b/packages/web/src/pages/VariablesPage.jsx @@ -7,7 +7,7 @@ import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { FormSelect } from "../components/FormControls.jsx"; import { VariableEditorModal } from "../components/VariableEditorModal.jsx"; import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js"; -import { formatTime } from "../lib/format.jsx"; +import { formatTime } from "../lib/format"; function displayValue(value) { if (typeof value === "string") return value === "" ? '""' : value; diff --git a/packages/web/src/pages/WorkflowTrashPage.jsx b/packages/web/src/pages/WorkflowTrashPage.jsx index 2d83ebb..c1810d1 100644 --- a/packages/web/src/pages/WorkflowTrashPage.jsx +++ b/packages/web/src/pages/WorkflowTrashPage.jsx @@ -8,7 +8,7 @@ import { useWorkflowTrash, } from "../api/hooks.js"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; -import { formatTime } from "../lib/format.jsx"; +import { formatTime } from "../lib/format"; import { useNotifications } from "../notifications.jsx"; function formatAge(ms) { diff --git a/packages/web/src/pages/WorkflowsPage.jsx b/packages/web/src/pages/WorkflowsPage.jsx index 6c3f01c..2f2f82a 100644 --- a/packages/web/src/pages/WorkflowsPage.jsx +++ b/packages/web/src/pages/WorkflowsPage.jsx @@ -12,7 +12,7 @@ import { import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx"; -import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx"; +import { formatTime, WorkflowStatusBadge } from "../lib/format"; export function WorkflowsPage() { const navigate = useNavigate();