From 7d51ff433ff1d9816d3312eb14f8bc24f86777b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 02:14:50 +0000 Subject: [PATCH 01/14] feat(workflows): add revision history, trash, UUID naming, and backup - Store up to 50 revisions per workflow in SQLite with normalized SHA dedup - Soft-delete workflows to trash (7-day retention) with restore and permanent purge - Assign UUID filenames for new and duplicated workflows; show name + file in UI - Warn on invalid YAML, unknown scripts, and plaintext secrets with save-anyway option - Add workflow backup zip (workflows + plugins) and merge/replace restore - Trash page, history panel on editor, and smoke test Co-authored-by: Nasyarobby Putra --- packages/server/.gitignore | 1 + .../20260820030000_workflow_history_trash.js | 49 ++ packages/server/package.json | 3 +- packages/server/src/api/workflows.js | 513 +++++++++++++++--- packages/server/start-app.js | 14 +- .../server/test/workflow-history-smoke.js | 121 +++++ packages/server/workflow-backup.js | 134 +++++ packages/server/workflow-duplicate.js | 31 +- packages/server/workflow-history.js | 128 +++++ packages/server/workflow-normalize.js | 62 +++ packages/server/workflow-trash.js | 183 +++++++ packages/server/workflow-validate-warnings.js | 136 +++++ packages/web/src/App.jsx | 2 + packages/web/src/api/hooks.js | 132 ++++- .../components/DuplicateWorkflowDialog.jsx | 56 +- .../workflow/SaveWorkflowWarningsDialog.jsx | 67 +++ .../workflow/WorkflowHistoryPanel.jsx | 113 ++++ packages/web/src/pages/WorkflowEditPage.jsx | 170 ++++-- packages/web/src/pages/WorkflowTrashPage.jsx | 162 ++++++ packages/web/src/pages/WorkflowsPage.jsx | 72 ++- 20 files changed, 1943 insertions(+), 206 deletions(-) create mode 100644 packages/server/.gitignore create mode 100644 packages/server/migrations/20260820030000_workflow_history_trash.js create mode 100644 packages/server/test/workflow-history-smoke.js create mode 100644 packages/server/workflow-backup.js create mode 100644 packages/server/workflow-history.js create mode 100644 packages/server/workflow-normalize.js create mode 100644 packages/server/workflow-trash.js create mode 100644 packages/server/workflow-validate-warnings.js create mode 100644 packages/web/src/components/workflow/SaveWorkflowWarningsDialog.jsx create mode 100644 packages/web/src/components/workflow/WorkflowHistoryPanel.jsx create mode 100644 packages/web/src/pages/WorkflowTrashPage.jsx diff --git a/packages/server/.gitignore b/packages/server/.gitignore new file mode 100644 index 0000000..24ebcf5 --- /dev/null +++ b/packages/server/.gitignore @@ -0,0 +1 @@ +dump.rdb diff --git a/packages/server/migrations/20260820030000_workflow_history_trash.js b/packages/server/migrations/20260820030000_workflow_history_trash.js new file mode 100644 index 0000000..3401fbe --- /dev/null +++ b/packages/server/migrations/20260820030000_workflow_history_trash.js @@ -0,0 +1,49 @@ +/** + * @param {import("knex").Knex} knex + */ +export async function up(knex) { + await knex.schema.createTable("workflow_revisions", (t) => { + t.text("id").primary(); + t.text("workflow_id").notNullable(); + t.text("owner").notNullable(); + t.text("file").notNullable(); + t.integer("revision").notNullable(); + t.text("content_sha").notNullable(); + t.text("content").notNullable(); + t.text("reason"); + t.text("meta"); + t.text("created_at").notNullable(); + }); + + await knex.schema.raw( + "CREATE UNIQUE INDEX workflow_revisions_workflow_id_revision_idx ON workflow_revisions (workflow_id, revision)", + ); + await knex.schema.raw( + "CREATE INDEX workflow_revisions_workflow_id_created_at_idx ON workflow_revisions (workflow_id, created_at DESC)", + ); + + await knex.schema.createTable("workflow_trash", (t) => { + t.text("id").primary(); + t.text("workflow_id").notNullable(); + t.text("owner").notNullable(); + t.text("file").notNullable(); + t.text("name"); + t.text("deleted_at").notNullable(); + t.text("trash_path").notNullable(); + }); + + await knex.schema.raw( + "CREATE INDEX workflow_trash_deleted_at_idx ON workflow_trash (deleted_at ASC)", + ); + await knex.schema.raw( + "CREATE UNIQUE INDEX workflow_trash_owner_file_idx ON workflow_trash (owner, file)", + ); +} + +/** + * @param {import("knex").Knex} knex + */ +export async function down(knex) { + await knex.schema.dropTableIfExists("workflow_trash"); + await knex.schema.dropTableIfExists("workflow_revisions"); +} diff --git a/packages/server/package.json b/packages/server/package.json index 39d66a7..345b80d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,7 +12,8 @@ "start:worker": "node worker.js", "start:control": "node control.js", "migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"", - "test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js" + "test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js", + "test:workflow-history": "node test/workflow-history-smoke.js" }, "dependencies": { "@aws-sdk/client-s3": "^3.1111.0", diff --git a/packages/server/src/api/workflows.js b/packages/server/src/api/workflows.js index 2904c38..d95f151 100644 --- a/packages/server/src/api/workflows.js +++ b/packages/server/src/api/workflows.js @@ -14,9 +14,33 @@ import { validateWorkflowFailureTriggers } from "../../trigger-failure.js"; import { duplicateWorkflowYaml, ensureWorkflowFilename, - suggestCopyFilename, + suggestDuplicateFilename, } from "../../workflow-duplicate.js"; import { publishReload } from "../../control-bus.js"; +import { + workflowIdFromFile, + newWorkflowFilename, +} from "../../workflow-normalize.js"; +import { + collectWorkflowWarnings, + parseWorkflowDocument, +} from "../../workflow-validate-warnings.js"; +import { + recordRevision, + listRevisions, + getRevision, +} from "../../workflow-history.js"; +import { + moveWorkflowToTrash, + listTrash, + restoreFromTrash, + purgeTrashItem, + isInTrash, +} from "../../workflow-trash.js"; +import { + createWorkflowBackupBuffer, + restoreWorkflowBackup, +} from "../../workflow-backup.js"; /** * Reload this process and notify other HTTP/worker processes via Redis. @@ -62,6 +86,92 @@ function scriptNames(workflow) { return names; } +/** + * @param {unknown} parsed + */ +async function validateStrictWorkflow(parsed) { + compileWorkflowScripts(parsed?.scripts); + await validateWorkflowHttpTriggers(parsed); + await validateWorkflowFailureTriggers(parsed); +} + +/** + * @param {{ + * owner: string, + * file: string, + * content: string, + * saveAnyway?: boolean, + * reason?: string | null, + * meta?: Record | null, + * forceRevision?: boolean, + * }} opts + */ +async function saveWorkflowContent(opts) { + const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content); + const saveAnyway = Boolean(opts.saveAnyway); + + if (!saveAnyway) { + if (parseError) { + const err = new Error("workflow has validation warnings"); + err.statusCode = 422; + err.warnings = warnings; + throw err; + } + try { + await validateStrictWorkflow(parsed); + } catch (validationErr) { + const err = new Error("workflow has validation warnings"); + err.statusCode = 422; + err.warnings = [ + ...warnings, + { + code: "validation_error", + message: + validationErr instanceof Error + ? validationErr.message + : String(validationErr), + }, + ]; + throw err; + } + if (warnings.length) { + const err = new Error("workflow has validation warnings"); + err.statusCode = 422; + err.warnings = warnings; + throw err; + } + } + + const workflowId = workflowIdFromFile(opts.file); + const existed = fsStore.readWorkflowYaml(opts.owner, opts.file) != null; + fsStore.writeWorkflowYaml(opts.owner, opts.file, opts.content); + + const registered = fsStore.readRegisters(opts.owner); + if (!registered.includes(opts.file)) { + registered.push(opts.file); + fsStore.writeRegisters(opts.owner, registered); + } + + const revision = await recordRevision({ + workflowId, + owner: opts.owner, + file: opts.file, + content: opts.content, + reason: opts.reason ?? "save", + meta: opts.meta ?? null, + force: opts.forceRevision, + }); + + return { + owner: opts.owner, + file: opts.file, + workflow_id: workflowId, + existed, + warnings, + revision, + }; +} + /** * @param {{ workflows: Map, loadErrors: Map, reregister: () => void }} registry */ @@ -74,6 +184,74 @@ export default function workflowsPluginFactory(registry) { return { owners: fsStore.listOwners() }; }); + fastify.get("/workflows/trash", async () => { + return { items: await listTrash() }; + }); + + fastify.post("/workflows/trash/:id/restore", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + try { + const restored = await restoreFromTrash(id); + await recordRevision({ + workflowId: restored.workflow_id, + owner: restored.owner, + file: restored.file, + content: restored.content, + reason: "restored-from-trash", + force: true, + }); + await reregisterAll(registry); + return { owner: restored.owner, file: restored.file }; + } catch (err) { + return reply.code(err.statusCode ?? 500).send({ + error: err instanceof Error ? err.message : String(err), + }); + } + }); + + fastify.delete("/workflows/trash/:id", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + try { + return await purgeTrashItem(id); + } catch (err) { + return reply.code(err.statusCode ?? 500).send({ + error: err instanceof Error ? err.message : String(err), + }); + } + }); + + fastify.get("/workflows/backup", async (_req, reply) => { + const buffer = await createWorkflowBackupBuffer(); + const stamp = new Date().toISOString().slice(0, 10); + return reply + .header("Content-Type", "application/zip") + .header( + "Content-Disposition", + `attachment; filename="jerapah-flow-backup-${stamp}.zip"`, + ) + .send(buffer); + }); + + fastify.post("/workflows/backup/restore", async (req, reply) => { + const body = /** @type {{ zipBase64?: string, mode?: string }} */ ( + req.body ?? {} + ); + if (typeof body.zipBase64 !== "string" || !body.zipBase64.trim()) { + return reply.code(400).send({ error: "zipBase64 is required" }); + } + const mode = body.mode === "replace" ? "replace" : "merge"; + try { + const buffer = Buffer.from(body.zipBase64, "base64"); + const result = await restoreWorkflowBackup(buffer, { mode }); + await reregisterAll(registry); + return result; + } catch (err) { + return reply.code(400).send({ + error: err instanceof Error ? err.message : String(err), + }); + } + }); + fastify.get("/workflows", async (req) => { const q = /** @type {{ owner?: string }} */ (req.query ?? {}); const stats = await store.workflowStats(); @@ -93,6 +271,7 @@ export default function workflowsPluginFactory(registry) { const files = [...new Set([...registered, ...onDisk])]; for (const file of files) { + if (await isInTrash(owner, file)) continue; const key = `${owner}/${file}`; const loaded = registry.workflows.get(key); const loadError = registry.loadErrors.get(key) ?? null; @@ -102,11 +281,8 @@ export default function workflowsPluginFactory(registry) { if (raw != null) { try { parsed = yaml.parse(raw); - } catch (err) { + } catch { // keep loadError - if (!loadError) { - // file on disk but unparseable and not in registers - } } } } @@ -118,14 +294,13 @@ export default function workflowsPluginFactory(registry) { items.push({ owner, file, + workflow_id: workflowIdFromFile(file), key, name: parsed?.name ?? file, description: parsed?.description ?? null, enabled: parsed ? parsed.enabled !== false : false, registered: registered.includes(file), - loadError: - loadError ?? - (parsed ? null : "unreadable"), + loadError: loadError ?? (parsed ? null : "unreadable"), lastInvokedAt: st.lastInvokedAt, lastStatus: st.lastStatus ?? null, invocationCount: st.invocationCount, @@ -137,6 +312,94 @@ export default function workflowsPluginFactory(registry) { return { workflows: items }; }); + fastify.get("/workflows/:owner/:file/revisions", async (req, reply) => { + const { owner, file } = /** @type {{ owner: string, file: string }} */ ( + req.params + ); + try { + fsStore.assertOwner(owner); + fsStore.assertWorkflowFile(file); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + const workflowId = workflowIdFromFile(file); + return { workflow_id: workflowId, revisions: await listRevisions(workflowId) }; + }); + + fastify.get( + "/workflows/:owner/:file/revisions/:revision", + async (req, reply) => { + const { owner, file, revision } = /** @type {{ owner: string, file: string, revision: string }} */ ( + req.params + ); + try { + fsStore.assertOwner(owner); + fsStore.assertWorkflowFile(file); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + const workflowId = workflowIdFromFile(file); + const rev = await getRevision(workflowId, Number(revision)); + if (!rev) { + return reply.code(404).send({ error: "revision not found" }); + } + return { + workflow_id: workflowId, + revision: rev.revision, + content: rev.content, + reason: rev.reason, + meta: rev.meta, + created_at: rev.created_at, + }; + }, + ); + + fastify.post( + "/workflows/:owner/:file/revisions/:revision/revert", + async (req, reply) => { + const { owner, file, revision } = /** @type {{ owner: string, file: string, revision: string }} */ ( + req.params + ); + try { + fsStore.assertOwner(owner); + fsStore.assertWorkflowFile(file); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + if (fsStore.readWorkflowYaml(owner, file) == null) { + return reply.code(404).send({ error: "workflow not found" }); + } + const workflowId = workflowIdFromFile(file); + const rev = await getRevision(workflowId, Number(revision)); + if (!rev) { + return reply.code(404).send({ error: "revision not found" }); + } + const body = /** @type {{ saveAnyway?: boolean }} */ (req.body ?? {}); + try { + const saved = await saveWorkflowContent({ + owner, + file, + content: rev.content, + saveAnyway: body.saveAnyway, + reason: "revert", + meta: { fromRevision: rev.revision }, + }); + await reregisterAll(registry); + return saved; + } catch (err) { + if (err.statusCode === 422) { + return reply.code(422).send({ + error: err.message, + warnings: err.warnings ?? [], + }); + } + return reply.code(err.statusCode ?? 500).send({ + error: err instanceof Error ? err.message : String(err), + }); + } + }, + ); + fastify.get("/workflows/:owner/:file", async (req, reply) => { const { owner, file } = /** @type {{ owner: string, file: string }} */ ( req.params @@ -167,6 +430,7 @@ export default function workflowsPluginFactory(registry) { return { owner, file, + workflow_id: workflowIdFromFile(file), key, content, parsed, @@ -188,48 +452,95 @@ export default function workflowsPluginFactory(registry) { } catch (err) { return reply.code(err.statusCode ?? 400).send({ error: err.message }); } - const body = /** @type {{ content?: string }} */ (req.body ?? {}); + const body = /** @type {{ content?: string, saveAnyway?: boolean }} */ ( + req.body ?? {} + ); if (typeof body.content !== "string") { return reply.code(400).send({ error: "content is required" }); } - let parsed; try { - parsed = yaml.parse(body.content); - } catch (err) { - return reply.code(400).send({ - error: `invalid yaml: ${err instanceof Error ? err.message : String(err)}`, + const saved = await saveWorkflowContent({ + owner, + file, + content: body.content, + saveAnyway: body.saveAnyway, + reason: "save", + }); + await reregisterAll(registry); + return reply.code(saved.existed ? 200 : 201).send({ + owner: saved.owner, + file: saved.file, + workflow_id: saved.workflow_id, + warnings: saved.warnings, + revision: saved.revision, }); - } - try { - compileWorkflowScripts(parsed?.scripts); } catch (err) { - return reply.code(400).send({ + if (err.statusCode === 422) { + return reply.code(422).send({ + error: err.message, + warnings: err.warnings ?? [], + }); + } + return reply.code(err.statusCode ?? 500).send({ error: err instanceof Error ? err.message : String(err), }); } + }); + + fastify.post("/workflows/:owner", async (req, reply) => { + const { owner } = /** @type {{ owner: string }} */ (req.params); try { - await validateWorkflowHttpTriggers(parsed); + fsStore.assertOwner(owner); } catch (err) { - return reply.code(err.statusCode ?? 400).send({ + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + const body = /** @type {{ content?: string, file?: string, saveAnyway?: boolean }} */ ( + req.body ?? {} + ); + if (typeof body.content !== "string") { + return reply.code(400).send({ error: "content is required" }); + } + let file = body.file?.trim() ? ensureWorkflowFilename(body.file) : ""; + if (!file) { + const existing = fsStore.listOwnerYamlFiles(owner); + file = suggestDuplicateFilename(existing); + } + try { + fsStore.assertWorkflowFile(file); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + if (fsStore.readWorkflowYaml(owner, file) != null) { + return reply.code(409).send({ error: "workflow already exists" }); + } + try { + const saved = await saveWorkflowContent({ + owner, + file, + content: body.content, + saveAnyway: body.saveAnyway, + reason: "create", + forceRevision: true, + }); + await reregisterAll(registry); + return reply.code(201).send({ + owner: saved.owner, + file: saved.file, + workflow_id: saved.workflow_id, + warnings: saved.warnings, + revision: saved.revision, + }); + } catch (err) { + if (err.statusCode === 422) { + return reply.code(422).send({ + error: err.message, + warnings: err.warnings ?? [], + }); + } + return reply.code(err.statusCode ?? 500).send({ error: err instanceof Error ? err.message : String(err), }); } - try { - await validateWorkflowFailureTriggers(parsed); - } catch (err) { - return reply.code(err.statusCode ?? 400).send({ - error: err instanceof Error ? err.message : String(err), - }); - } - const existed = fsStore.readWorkflowYaml(owner, file) != null; - fsStore.writeWorkflowYaml(owner, file, body.content); - const registered = fsStore.readRegisters(owner); - if (!registered.includes(file)) { - registered.push(file); - fsStore.writeRegisters(owner, registered); - } - await reregisterAll(registry); - return reply.code(existed ? 200 : 201).send({ owner, file }); }); fastify.patch("/workflows/:owner/:file", async (req, reply) => { @@ -250,23 +561,39 @@ export default function workflowsPluginFactory(registry) { if (content == null) { return reply.code(404).send({ error: "workflow not found" }); } - const doc = yaml.parseDocument(content); - if (doc.errors?.length) { - const msg = doc.errors[0]?.message ?? "invalid yaml"; - return reply.code(400).send({ error: msg }); - } - const parsed = doc.toJSON(); - if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { - return reply.code(400).send({ error: "workflow yaml must be an object" }); + let doc; + try { + ({ doc } = parseWorkflowDocument(content)); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ + error: err instanceof Error ? err.message : String(err), + }); } if (body.enabled) { doc.delete("enabled"); } else { doc.set("enabled", false); } - fsStore.writeWorkflowYaml(owner, file, String(doc)); - await reregisterAll(registry); - return { owner, file, enabled: body.enabled }; + const nextContent = String(doc); + try { + const saved = await saveWorkflowContent({ + owner, + file, + content: nextContent, + reason: body.enabled ? "enable" : "disable", + }); + await reregisterAll(registry); + return { + owner, + file, + enabled: body.enabled, + revision: saved.revision, + }; + } catch (err) { + return reply.code(err.statusCode ?? 500).send({ + error: err instanceof Error ? err.message : String(err), + }); + } }); fastify.delete("/workflows/:owner/:file", async (req, reply) => { @@ -279,13 +606,31 @@ export default function workflowsPluginFactory(registry) { } catch (err) { return reply.code(err.statusCode ?? 400).send({ error: err.message }); } - if (!fsStore.deleteWorkflowYaml(owner, file)) { + const raw = fsStore.readWorkflowYaml(owner, file); + if (raw == null) { return reply.code(404).send({ error: "workflow not found" }); } - const registered = fsStore.readRegisters(owner).filter((f) => f !== file); - fsStore.writeRegisters(owner, registered); - await reregisterAll(registry); - return { ok: true }; + let name = null; + try { + const parsed = yaml.parse(raw); + name = parsed?.name ?? null; + } catch { + // ignore + } + try { + const item = await moveWorkflowToTrash({ + workflowId: workflowIdFromFile(file), + owner, + file, + name, + }); + await reregisterAll(registry); + return { ok: true, trash: item }; + } catch (err) { + return reply.code(err.statusCode ?? 500).send({ + error: err instanceof Error ? err.message : String(err), + }); + } }); fastify.post("/workflows/:owner/:file/duplicate", async (req, reply) => { @@ -303,7 +648,9 @@ export default function workflowsPluginFactory(registry) { return reply.code(404).send({ error: "workflow not found" }); } - const body = /** @type {{ file?: unknown, owner?: unknown }} */ (req.body ?? {}); + const body = /** @type {{ file?: unknown, owner?: unknown, saveAnyway?: boolean }} */ ( + req.body ?? {} + ); let destOwner = owner; if (body.owner != null && body.owner !== "") { if (typeof body.owner !== "string") { @@ -319,7 +666,9 @@ export default function workflowsPluginFactory(registry) { let destFile; try { if (body.file == null || body.file === "") { - destFile = suggestCopyFilename(file, fsStore.listOwnerYamlFiles(destOwner)); + destFile = suggestDuplicateFilename( + fsStore.listOwnerYamlFiles(destOwner), + ); } else if (typeof body.file !== "string") { return reply.code(400).send({ error: "file must be a string" }); } else { @@ -350,44 +699,34 @@ export default function workflowsPluginFactory(registry) { }); } - let parsed; try { - parsed = yaml.parse(content); - } catch (err) { - return reply.code(400).send({ - error: `invalid yaml: ${err instanceof Error ? err.message : String(err)}`, + const saved = await saveWorkflowContent({ + owner: destOwner, + file: destFile, + content, + saveAnyway: body.saveAnyway, + reason: "duplicated", + meta: { from: `${owner}/${file}` }, + forceRevision: true, + }); + await reregisterAll(registry); + return reply.code(201).send({ + owner: destOwner, + file: destFile, + workflow_id: saved.workflow_id, + revision: saved.revision, }); - } - try { - compileWorkflowScripts(parsed?.scripts); } catch (err) { - return reply.code(400).send({ + if (err.statusCode === 422) { + return reply.code(422).send({ + error: err.message, + warnings: err.warnings ?? [], + }); + } + return reply.code(err.statusCode ?? 500).send({ error: err instanceof Error ? err.message : String(err), }); } - try { - await validateWorkflowHttpTriggers(parsed); - } catch (err) { - return reply.code(err.statusCode ?? 400).send({ - error: err instanceof Error ? err.message : String(err), - }); - } - try { - await validateWorkflowFailureTriggers(parsed); - } catch (err) { - return reply.code(err.statusCode ?? 400).send({ - error: err instanceof Error ? err.message : String(err), - }); - } - - fsStore.writeWorkflowYaml(destOwner, destFile, content); - const registered = fsStore.readRegisters(destOwner); - if (!registered.includes(destFile)) { - registered.push(destFile); - fsStore.writeRegisters(destOwner, registered); - } - await reregisterAll(registry); - return reply.code(201).send({ owner: destOwner, file: destFile }); }); fastify.post("/workflows/:owner/:file/run", async (req, reply) => { @@ -443,3 +782,5 @@ export default function workflowsPluginFactory(registry) { }); }; } + +export { newWorkflowFilename }; diff --git a/packages/server/start-app.js b/packages/server/start-app.js index 6f2ddb7..e38ed79 100644 --- a/packages/server/start-app.js +++ b/packages/server/start-app.js @@ -28,11 +28,7 @@ import { createWorkflowWorker, getRedisUrlForLog, } from "./workflow-queue.js"; -import { - getConfigGeneration, - startHeartbeatLoop, - subscribeReload, -} from "./control-bus.js"; +import { purgeExpiredTrash } from "./workflow-trash.js"; /** * @param {{ @@ -48,6 +44,14 @@ export async function startApp(opts = {}) { if (shouldMigrate) { await migrate(); + try { + const purged = await purgeExpiredTrash(); + if (purged > 0) { + log.info({ purged }, "purged expired workflow trash"); + } + } catch (err) { + log.warn({ err }, "workflow trash purge failed"); + } } enableLogPersistence(); diff --git a/packages/server/test/workflow-history-smoke.js b/packages/server/test/workflow-history-smoke.js new file mode 100644 index 0000000..00978b2 --- /dev/null +++ b/packages/server/test/workflow-history-smoke.js @@ -0,0 +1,121 @@ +/** + * Smoke: workflow revisions, SHA dedup, trash, restore, purge. + * + * Run: pnpm --dir packages/server test:workflow-history + */ +import assert from "node:assert/strict"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { db, migrate } from "../db.js"; +import { WORKFLOWS_DIR } from "../paths.js"; +import * as fsStore from "../fs-store.js"; +import { + workflowContentSha, + workflowIdFromFile, + newWorkflowFilename, +} from "../workflow-normalize.js"; +import { + recordRevision, + listRevisions, + getLatestRevision, + deleteRevisionHistory, +} from "../workflow-history.js"; +import { + moveWorkflowToTrash, + listTrash, + restoreFromTrash, + purgeTrashItem, + TRASH_WORKFLOWS_DIR, +} from "../workflow-trash.js"; +import { collectWorkflowWarnings } from "../workflow-validate-warnings.js"; + +const owner = "__workflow_history_smoke__"; +const file = newWorkflowFilename(); +const workflowId = workflowIdFromFile(file); +const ownerDir = path.join(WORKFLOWS_DIR, owner); +const trashPath = path.join(TRASH_WORKFLOWS_DIR, owner, file); + +function cleanup() { + if (fs.existsSync(trashPath)) fs.unlinkSync(trashPath); + if (fs.existsSync(ownerDir)) fs.rmSync(ownerDir, { recursive: true, force: true }); +} + +cleanup(); +await migrate(); + +const yamlV1 = `name: smoke test +scripts: + - plugin/get-current-time +triggers: + - type: HTTP + method: POST + path: /smoke +`; + +fsStore.writeWorkflowYaml(owner, file, yamlV1); +fsStore.writeRegisters(owner, [file]); + +assert.equal(workflowContentSha(yamlV1), workflowContentSha(`${yamlV1}\n\n`)); + +const rev1 = await recordRevision({ + workflowId, + owner, + file, + content: yamlV1, + reason: "create", + force: true, +}); +assert.equal(rev1.skipped, false); +assert.equal(rev1.revision, 1); + +const revDup = await recordRevision({ + workflowId, + owner, + file, + content: `${yamlV1}\n\n`, + reason: "save", +}); +assert.equal(revDup.skipped, true, "normalized SHA should dedupe blank lines"); + +const yamlV2 = yamlV1.replace("smoke test", "smoke test v2"); +const rev2 = await recordRevision({ + workflowId, + owner, + file, + content: yamlV2, + reason: "save", +}); +assert.equal(rev2.revision, 2); +assert.equal((await listRevisions(workflowId)).length, 2); + +const warnings = collectWorkflowWarnings( + `name: bad\nscripts:\n - unknown-script-xyz\n`, +); +assert.ok(warnings.warnings.some((w) => w.code === "unknown_script")); + +const trashed = await moveWorkflowToTrash({ + workflowId, + owner, + file, + name: "smoke test v2", +}); +assert.ok(trashed.id); +assert.equal(fsStore.readWorkflowYaml(owner, file), null); +assert.ok(fs.existsSync(trashPath)); + +const restored = await restoreFromTrash(trashed.id); +assert.equal(restored.file, file); +assert.ok(fsStore.readWorkflowYaml(owner, file)); + +await moveWorkflowToTrash({ workflowId, owner, file, name: "smoke test v2" }); +const trashAgain = (await listTrash()).find((t) => t.file === file); +assert.ok(trashAgain); +await purgeTrashItem(trashAgain.id); +assert.ok(!(await listTrash()).some((t) => t.file === file)); + +await deleteRevisionHistory(workflowId); +cleanup(); + +console.log("workflow-history-smoke: ok"); +await db.destroy(); diff --git a/packages/server/workflow-backup.js b/packages/server/workflow-backup.js new file mode 100644 index 0000000..f3d20d1 --- /dev/null +++ b/packages/server/workflow-backup.js @@ -0,0 +1,134 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { DATA_DIR, PLUGINS_DIR, WORKFLOWS_DIR } from "./paths.js"; +import { getAppVersion } from "./app-version.js"; +import * as fsStore from "./fs-store.js"; +import { listInstalledPlugins } from "./plugin-store.js"; + +const execFileAsync = promisify(execFile); + +/** + * @param {string} dir + * @param {string} zipPath + */ +async function zipDirectory(dir, zipPath) { + await execFileAsync("zip", ["-r", zipPath, "."], { cwd: dir }); +} + +/** + * @param {string} zipPath + * @param {string} destDir + */ +async function unzipArchive(zipPath, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + await execFileAsync("unzip", ["-o", zipPath, "-d", destDir]); +} + +/** + * Copy directory recursively. + * @param {string} src + * @param {string} dest + */ +function copyDir(src, dest) { + if (!fs.existsSync(src)) return; + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const from = path.join(src, entry.name); + const to = path.join(dest, entry.name); + if (entry.isDirectory()) copyDir(from, to); + else fs.copyFileSync(from, to); + } +} + +/** + * Build a backup zip buffer (workflows + installed plugins + manifest). + */ +export async function createWorkflowBackupBuffer() { + const staging = path.join(DATA_DIR, `.backup-staging-${randomUUID()}`); + fs.mkdirSync(staging, { recursive: true }); + const zipPath = path.join(DATA_DIR, `.backup-${randomUUID()}.zip`); + + try { + const wfDest = path.join(staging, "workflows"); + copyDir(WORKFLOWS_DIR, wfDest); + + const pluginsDest = path.join(staging, "plugins"); + copyDir(PLUGINS_DIR, pluginsDest); + + const manifest = { + version: getAppVersion(), + created_at: new Date().toISOString(), + plugins: listInstalledPlugins().map((p) => p.id), + owners: fsStore.listOwners(), + }; + fs.writeFileSync( + path.join(staging, "manifest.json"), + JSON.stringify(manifest, null, 2), + "utf8", + ); + + await zipDirectory(staging, zipPath); + return fs.readFileSync(zipPath); + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); + } +} + +/** + * @param {Buffer} zipBuffer + * @param {{ mode?: "merge" | "replace" }} [opts] + */ +export async function restoreWorkflowBackup(zipBuffer, opts = {}) { + const mode = opts.mode === "replace" ? "replace" : "merge"; + const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), "jflow-restore-")); + const zipPath = path.join(extractDir, "backup.zip"); + fs.writeFileSync(zipPath, zipBuffer); + + /** @type {string[]} */ + const warnings = []; + + try { + const contentDir = path.join(extractDir, "content"); + await unzipArchive(zipPath, contentDir); + + const manifestPath = path.join(contentDir, "manifest.json"); + if (fs.existsSync(manifestPath)) { + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + for (const pluginId of manifest.plugins ?? []) { + const dir = path.join(PLUGINS_DIR, pluginId); + if (!fs.existsSync(dir)) { + warnings.push(`Plugin "${pluginId}" from backup is not installed`); + } + } + } catch { + warnings.push("Could not read backup manifest.json"); + } + } + + const wfSrc = path.join(contentDir, "workflows"); + if (fs.existsSync(wfSrc)) { + if (mode === "replace" && fs.existsSync(WORKFLOWS_DIR)) { + fs.rmSync(WORKFLOWS_DIR, { recursive: true, force: true }); + } + copyDir(wfSrc, WORKFLOWS_DIR); + } + + const pluginsSrc = path.join(contentDir, "plugins"); + if (fs.existsSync(pluginsSrc)) { + if (mode === "replace" && fs.existsSync(PLUGINS_DIR)) { + fs.rmSync(PLUGINS_DIR, { recursive: true, force: true }); + } + copyDir(pluginsSrc, PLUGINS_DIR); + } + + return { ok: true, mode, warnings }; + } finally { + fs.rmSync(extractDir, { recursive: true, force: true }); + } +} diff --git a/packages/server/workflow-duplicate.js b/packages/server/workflow-duplicate.js index 6e41506..4953cbd 100644 --- a/packages/server/workflow-duplicate.js +++ b/packages/server/workflow-duplicate.js @@ -1,4 +1,10 @@ import yaml from "yaml"; +import { + newWorkflowFilename, + workflowFileStem, +} from "./workflow-normalize.js"; + +export { workflowFileStem }; export function ensureWorkflowFilename(file) { const trimmed = String(file ?? "").trim(); @@ -6,13 +12,8 @@ export function ensureWorkflowFilename(file) { return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`; } -export function workflowFileStem(file) { - return String(file).replace(/\.ya?ml$/i, ""); -} - /** - * Next unused copy filename: `track.yaml` → `track-copy.yaml`, - * `track-copy.yaml` → `track-copy-2.yaml`. + * Legacy human-readable copy name (kept for UI hints). * @param {string} file * @param {string[]} existingFiles */ @@ -33,6 +34,19 @@ export function suggestCopyFilename(file, existingFiles = []) { return candidate(n); } +/** + * UUID-based duplicate filename (default for new duplicates). + * @param {string[]} existingFiles + */ +export function suggestDuplicateFilename(existingFiles = []) { + const existing = new Set(existingFiles); + let file = newWorkflowFilename(); + while (existing.has(file)) { + file = newWorkflowFilename(); + } + return file; +} + export function nextCopyName(name) { const trimmed = String(name ?? "").trim(); if (!trimmed) return "copy"; @@ -55,7 +69,10 @@ export function httpPathCopySuffix(sourceFile, destFile) { function suffixHttpPath(path, suffix) { const trimmed = String(path).replace(/\/+$/, ""); const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - const safe = String(suffix).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "copy"; + const safe = + String(suffix) + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") || "copy"; return `${withSlash}-${safe}`; } diff --git a/packages/server/workflow-history.js b/packages/server/workflow-history.js new file mode 100644 index 0000000..9063aa1 --- /dev/null +++ b/packages/server/workflow-history.js @@ -0,0 +1,128 @@ +import { randomUUID } from "node:crypto"; +import { db } from "./db.js"; +import { workflowContentSha } from "./workflow-normalize.js"; + +const MAX_REVISIONS = 50; + +function nowIso() { + return new Date().toISOString(); +} + +/** + * @param {string | null} meta + */ +function parseMeta(meta) { + if (!meta) return null; + try { + return JSON.parse(meta); + } catch { + return null; + } +} + +/** + * @param {string} workflowId + */ +export async function getLatestRevision(workflowId) { + const row = await db("workflow_revisions") + .where({ workflow_id: workflowId }) + .orderBy("revision", "desc") + .first(); + if (!row) return null; + return { + ...row, + meta: parseMeta(row.meta), + }; +} + +/** + * @param {string} workflowId + */ +export async function listRevisions(workflowId) { + const rows = await db("workflow_revisions") + .where({ workflow_id: workflowId }) + .orderBy("revision", "desc"); + return rows.map((row) => ({ + id: row.id, + workflow_id: row.workflow_id, + owner: row.owner, + file: row.file, + revision: row.revision, + content_sha: row.content_sha, + reason: row.reason ?? null, + meta: parseMeta(row.meta), + created_at: row.created_at, + })); +} + +/** + * @param {string} workflowId + * @param {number} revision + */ +export async function getRevision(workflowId, revision) { + const row = await db("workflow_revisions") + .where({ workflow_id: workflowId, revision }) + .first(); + if (!row) return null; + return { + ...row, + meta: parseMeta(row.meta), + }; +} + +/** + * Insert a revision when content changed (SHA dedup skips identical saves). + * @param {{ + * workflowId: string, + * owner: string, + * file: string, + * content: string, + * reason?: string | null, + * meta?: Record | null, + * force?: boolean, + * }} opts + * @returns {Promise<{ skipped: boolean, revision: number | null, id: string | null }>} + */ +export async function recordRevision(opts) { + const sha = workflowContentSha(opts.content); + const latest = await getLatestRevision(opts.workflowId); + if (!opts.force && latest && latest.content_sha === sha) { + return { skipped: true, revision: latest.revision, id: latest.id }; + } + + const nextRevision = latest ? latest.revision + 1 : 1; + const id = randomUUID(); + const created_at = nowIso(); + + await db("workflow_revisions").insert({ + id, + workflow_id: opts.workflowId, + owner: opts.owner, + file: opts.file, + revision: nextRevision, + content_sha: sha, + content: opts.content, + reason: opts.reason ?? null, + meta: opts.meta ? JSON.stringify(opts.meta) : null, + created_at, + }); + + const overflow = await db("workflow_revisions") + .where({ workflow_id: opts.workflowId }) + .orderBy("revision", "desc") + .offset(MAX_REVISIONS) + .pluck("id"); + + if (overflow.length) { + await db("workflow_revisions").whereIn("id", overflow).del(); + } + + return { skipped: false, revision: nextRevision, id }; +} + +/** + * @param {string} workflowId + */ +export async function deleteRevisionHistory(workflowId) { + return db("workflow_revisions").where({ workflow_id: workflowId }).del(); +} diff --git a/packages/server/workflow-normalize.js b/packages/server/workflow-normalize.js new file mode 100644 index 0000000..3486e85 --- /dev/null +++ b/packages/server/workflow-normalize.js @@ -0,0 +1,62 @@ +import { createHash, randomUUID } from "node:crypto"; +import yaml from "yaml"; + +/** + * Stable key order for canonical JSON (dedup ignores YAML formatting). + * @param {unknown} value + */ +export function canonicalize(value) { + if (value == null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(canonicalize); + const out = {}; + for (const key of Object.keys(value).sort()) { + out[key] = canonicalize(value[key]); + } + return out; +} + +/** + * Parse YAML to a JS object (null when empty/invalid for callers that handle errors). + * @param {string} content + */ +export function parseWorkflowObject(content) { + if (typeof content !== "string" || !content.trim()) return null; + return yaml.parse(content) ?? null; +} + +/** + * SHA256 of normalized workflow content (YAML → object → canonical JSON). + * @param {string} content + */ +export function workflowContentSha(content) { + const parsed = parseWorkflowObject(content); + const canonical = canonicalize(parsed); + const json = JSON.stringify(canonical); + return createHash("sha256").update(json, "utf8").digest("hex"); +} + +/** + * @param {string} file + */ +export function workflowIdFromFile(file) { + return String(file).replace(/\.ya?ml$/i, ""); +} + +/** + * New on-disk workflow filename: `{uuid}.yaml`. + * @param {string} [uuid] + */ +export function newWorkflowFilename(uuid) { + const id = uuid ?? randomUUID(); + return `${id}.yaml`; +} + +const UUID_FILE_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.ya?ml$/i; + +/** + * @param {string} file + */ +export function isUuidWorkflowFile(file) { + return UUID_FILE_RE.test(String(file)); +} diff --git a/packages/server/workflow-trash.js b/packages/server/workflow-trash.js new file mode 100644 index 0000000..fac75e1 --- /dev/null +++ b/packages/server/workflow-trash.js @@ -0,0 +1,183 @@ +import fs from "fs"; +import path from "path"; +import { randomUUID } from "node:crypto"; +import { db } from "./db.js"; +import { deleteRevisionHistory } from "./workflow-history.js"; +import { DATA_DIR, WORKFLOWS_DIR } from "./paths.js"; +import * as fsStore from "./fs-store.js"; + +export const TRASH_WORKFLOWS_DIR = path.join(DATA_DIR, "trash", "workflows"); +export const TRASH_RETENTION_DAYS = 7; + +function nowIso() { + return new Date().toISOString(); +} + +function trashFilePath(owner, file) { + return path.join(TRASH_WORKFLOWS_DIR, owner, file); +} + +/** + * @param {string} deletedAtIso + */ +export function trashAgeMs(deletedAtIso) { + return Date.now() - Date.parse(deletedAtIso); +} + +/** + * @param {string} deletedAtIso + */ +export function trashDaysRemaining(deletedAtIso) { + const purgeAt = + Date.parse(deletedAtIso) + TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000; + return Math.max(0, Math.ceil((purgeAt - Date.now()) / (24 * 60 * 60 * 1000))); +} + +function rowToItem(row) { + return { + id: row.id, + workflow_id: row.workflow_id, + owner: row.owner, + file: row.file, + name: row.name ?? null, + deleted_at: row.deleted_at, + trash_path: row.trash_path, + age_ms: trashAgeMs(row.deleted_at), + days_until_purge: trashDaysRemaining(row.deleted_at), + }; +} + +export async function listTrash() { + const rows = await db("workflow_trash").orderBy("deleted_at", "desc"); + return rows.map(rowToItem); +} + +export async function getTrashItem(id) { + const row = await db("workflow_trash").where({ id }).first(); + return row ? rowToItem(row) : null; +} + +export async function isInTrash(owner, file) { + const row = await db("workflow_trash").where({ owner, file }).first(); + return Boolean(row); +} + +/** + * Soft-delete: move YAML to trash dir, unregister, keep revision history. + * @param {{ + * workflowId: string, + * owner: string, + * file: string, + * name?: string | null, + * }} opts + */ +export async function moveWorkflowToTrash(opts) { + const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file); + if (!fs.existsSync(sourcePath)) { + const err = new Error("workflow not found"); + err.statusCode = 404; + throw err; + } + + const trashPath = trashFilePath(opts.owner, opts.file); + fs.mkdirSync(path.dirname(trashPath), { recursive: true }); + fs.renameSync(sourcePath, trashPath); + + const registered = fsStore.readRegisters(opts.owner).filter((f) => f !== opts.file); + fsStore.writeRegisters(opts.owner, registered); + + const id = randomUUID(); + const deleted_at = nowIso(); + await db("workflow_trash").insert({ + id, + workflow_id: opts.workflowId, + owner: opts.owner, + file: opts.file, + name: opts.name ?? null, + deleted_at, + trash_path: trashPath, + }); + + return rowToItem(await db("workflow_trash").where({ id }).first()); +} + +/** + * Restore workflow from trash. + * @param {string} trashId + */ +export async function restoreFromTrash(trashId) { + const row = await db("workflow_trash").where({ id: trashId }).first(); + if (!row) { + const err = new Error("trash item not found"); + err.statusCode = 404; + throw err; + } + + const destPath = path.join(WORKFLOWS_DIR, row.owner, row.file); + if (fs.existsSync(destPath)) { + const err = new Error("workflow file already exists"); + err.statusCode = 409; + throw err; + } + if (!fs.existsSync(row.trash_path)) { + const err = new Error("trash file missing on disk"); + err.statusCode = 410; + throw err; + } + + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.renameSync(row.trash_path, destPath); + + const registered = fsStore.readRegisters(row.owner); + if (!registered.includes(row.file)) { + registered.push(row.file); + fsStore.writeRegisters(row.owner, registered); + } + + await db("workflow_trash").where({ id: trashId }).del(); + + return { + owner: row.owner, + file: row.file, + workflow_id: row.workflow_id, + content: fs.readFileSync(destPath, "utf8"), + }; +} + +/** + * Permanently delete a trash item and its revision history. + * @param {string} trashId + */ +export async function purgeTrashItem(trashId) { + const row = await db("workflow_trash").where({ id: trashId }).first(); + if (!row) { + const err = new Error("trash item not found"); + err.statusCode = 404; + throw err; + } + + if (fs.existsSync(row.trash_path)) { + fs.unlinkSync(row.trash_path); + } + + await deleteRevisionHistory(row.workflow_id); + await db("workflow_trash").where({ id: trashId }).del(); + return { ok: true }; +} + +/** + * Auto-purge trash older than retention window. + * @returns {Promise} + */ +export async function purgeExpiredTrash() { + const cutoff = new Date( + Date.now() - TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(); + const rows = await db("workflow_trash") + .where("deleted_at", "<", cutoff) + .select("id"); + for (const row of rows) { + await purgeTrashItem(row.id); + } + return rows.length; +} diff --git a/packages/server/workflow-validate-warnings.js b/packages/server/workflow-validate-warnings.js new file mode 100644 index 0000000..91aad42 --- /dev/null +++ b/packages/server/workflow-validate-warnings.js @@ -0,0 +1,136 @@ +import yaml from "yaml"; +import { parseScriptStep } from "./workflow-parse.js"; +import { resolveScriptRef } from "./plugin-store.js"; +import { parseWorkflowObject } from "./workflow-normalize.js"; + +const SECRET_KEY_RE = + /(?:password|passwd|secret|token|api[_-]?key|auth(?:orization)?|credential|private[_-]?key)/i; + +const BEARER_RE = /Bearer\s+[A-Za-z0-9._~+/=-]{8,}/; + +/** + * Walk parsed YAML for suspicious secret-like string values. + * @param {unknown} value + * @param {string} pathKey + * @param {Array<{ code: string, message: string, path?: string }>} warnings + */ +function scanSecrets(value, pathKey, warnings) { + if (value == null) return; + if (typeof value === "string") { + if (BEARER_RE.test(value)) { + warnings.push({ + code: "plaintext_secret", + message: "Possible Bearer token in workflow YAML", + path: pathKey, + }); + } + return; + } + if (Array.isArray(value)) { + value.forEach((item, i) => scanSecrets(item, `${pathKey}[${i}]`, warnings)); + return; + } + if (typeof value === "object") { + for (const [k, v] of Object.entries(value)) { + const childPath = pathKey ? `${pathKey}.${k}` : k; + if (typeof v === "string" && v.trim() && SECRET_KEY_RE.test(k)) { + warnings.push({ + code: "plaintext_secret", + message: `Possible secret in field "${k}"`, + path: childPath, + }); + } + scanSecrets(v, childPath, warnings); + } + } +} + +/** + * Collect non-blocking save warnings for workflow YAML. + * @param {string} content + * @returns {{ warnings: Array<{ code: string, message: string, path?: string }>, parsed: unknown | null, parseError: string | null }} + */ +export function collectWorkflowWarnings(content) { + /** @type {Array<{ code: string, message: string, path?: string }>} */ + const warnings = []; + + let parsed = null; + let parseError = null; + try { + parsed = parseWorkflowObject(content); + if (parsed == null) { + warnings.push({ + code: "invalid_yaml", + message: "Workflow YAML is empty or not an object", + }); + } else if (typeof parsed !== "object" || Array.isArray(parsed)) { + warnings.push({ + code: "invalid_yaml", + message: "Workflow YAML must be a mapping/object", + }); + parsed = null; + } + } catch (err) { + parseError = err instanceof Error ? err.message : String(err); + warnings.push({ + code: "invalid_yaml", + message: `Invalid YAML: ${parseError}`, + }); + } + + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + scanSecrets(parsed, "", warnings); + + for (const [i, raw] of (parsed.scripts ?? []).entries()) { + try { + const step = parseScriptStep(raw); + if (step.kind === "set") continue; + const resolved = resolveScriptRef(step.script); + if (resolved.error) { + warnings.push({ + code: "unknown_script", + message: resolved.error, + path: `scripts[${i}]`, + }); + } + } catch (err) { + warnings.push({ + code: "invalid_script_step", + message: err instanceof Error ? err.message : String(err), + path: `scripts[${i}]`, + }); + } + } + } + + return { warnings, parsed, parseError }; +} + +/** + * Strict validation used when saveAnyway is false. + * @param {unknown} parsed + */ +export function assertStrictWorkflow(parsed) { + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + const err = new Error("workflow yaml must be an object"); + err.statusCode = 400; + throw err; + } + return parsed; +} + +/** + * Parse for PATCH/enable toggles (must be valid YAML document). + * @param {string} content + */ +export function parseWorkflowDocument(content) { + const doc = yaml.parseDocument(content); + if (doc.errors?.length) { + const err = new Error(doc.errors[0]?.message ?? "invalid yaml"); + err.statusCode = 400; + throw err; + } + const parsed = doc.toJSON(); + assertStrictWorkflow(parsed); + return { doc, parsed }; +} diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx index 579c487..ccc3713 100644 --- a/packages/web/src/App.jsx +++ b/packages/web/src/App.jsx @@ -10,6 +10,7 @@ import { ScriptDryRunPage } from "./pages/ScriptDryRunPage.jsx"; import { ScriptEditPage, ScriptNewPage } from "./pages/ScriptEditPage.jsx"; import { WorkflowsPage } from "./pages/WorkflowsPage.jsx"; import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx"; +import { WorkflowTrashPage } from "./pages/WorkflowTrashPage.jsx"; import { EventsPage } from "./pages/EventsPage.jsx"; import { EventDetailPage } from "./pages/EventDetailPage.jsx"; import { FailuresPage } from "./pages/FailuresPage.jsx"; @@ -59,6 +60,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index d7fed6d..302a704 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -191,17 +191,21 @@ export function useOwners() { export function useSaveWorkflow() { const qc = useQueryClient(); return useMutation({ - mutationFn: async ({ owner, file, content }) => + mutationFn: async ({ owner, file, content, saveAnyway }) => ( await api.put( `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, - { content }, + { content, ...(saveAnyway ? { saveAnyway: true } : {}) }, ) ).data, - onSuccess: () => { + 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] }); }, }); } @@ -235,6 +239,7 @@ export function useDeleteWorkflow() { ).data, onSuccess: () => { qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); qc.invalidateQueries({ queryKey: ["dashboard"] }); }, }); @@ -572,3 +577,124 @@ export function useOpsBumpGeneration() { }); } +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 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/DuplicateWorkflowDialog.jsx b/packages/web/src/components/DuplicateWorkflowDialog.jsx index d45cb58..168d004 100644 --- a/packages/web/src/components/DuplicateWorkflowDialog.jsx +++ b/packages/web/src/components/DuplicateWorkflowDialog.jsx @@ -1,44 +1,22 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useState } from "react"; import { errorMessage } from "../api/client.js"; -import { useDuplicateWorkflow, useOwners, useWorkflows } from "../api/hooks.js"; -import { ensureWorkflowFilename, suggestCopyFilename } from "../lib/workflow-doc.js"; +import { useDuplicateWorkflow, useOwners } from "../api/hooks.js"; import { useNotifications } from "../notifications.jsx"; -const EMPTY_WORKFLOWS = []; - export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) { const { notify } = useNotifications(); const { data: owners = [] } = useOwners(); - const { data: workflows = EMPTY_WORKFLOWS } = useWorkflows(); const duplicate = useDuplicateWorkflow(); const [destOwner, setDestOwner] = useState(source.owner); - const [destFile, setDestFile] = useState(() => suggestCopyFilename(source.file)); - const fileTouched = useRef(false); - - const existingFiles = useMemo( - () => workflows.filter((w) => w.owner === destOwner).map((w) => w.file), - [workflows, destOwner], - ); - - useEffect(() => { - if (fileTouched.current) return; - setDestFile(suggestCopyFilename(source.file, existingFiles)); - }, [source.file, existingFiles]); - - const yamlFile = ensureWorkflowFilename(destFile); - const sameAsSource = destOwner === source.owner && yamlFile === source.file; - const exists = existingFiles.includes(yamlFile); - const canSubmit = Boolean(destOwner && yamlFile) && !sameAsSource && !exists && !duplicate.isPending; function onSubmit(e) { e.preventDefault(); - if (!canSubmit) return; + if (destOwner === source.owner && duplicate.isPending) return; duplicate.mutate( { owner: source.owner, file: source.file, destOwner, - destFile: yamlFile, }, { onSuccess: (data) => { @@ -54,11 +32,13 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic

Duplicate {source.key}?

- The copy starts disabled. HTTP paths are rewritten when staying under the same owner so - triggers do not collide. + A new UUID filename is assigned automatically. The copy starts disabled. HTTP paths are + rewritten when staying under the same owner so triggers do not collide.

{warnUnsaved ? ( -

The copy uses the last saved YAML, not unsaved edits.

+

+ The copy uses the last saved YAML, not unsaved edits. +

) : null}
- - {sameAsSource ? ( -

Choose a different owner or filename.

- ) : exists ? ( -

{destOwner}/{yamlFile} already exists.

- ) : null} {duplicate.isError ? (

{errorMessage(duplicate.error)}

) : null} @@ -101,7 +63,7 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic - + +
+ + + + + + ); +} + +/** + * Extract validation warnings from a failed save mutation error. + * @param {unknown} error + */ +export function saveWarningsFromError(error) { + const warnings = error?.response?.data?.warnings; + return Array.isArray(warnings) ? warnings : null; +} + +export function isSaveWarningsError(error) { + return error?.response?.status === 422 && saveWarningsFromError(error); +} + +export function saveErrorMessage(error) { + if (isSaveWarningsError(error)) { + return "Workflow has validation warnings"; + } + return errorMessage(error); +} diff --git a/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx b/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx new file mode 100644 index 0000000..fcdae63 --- /dev/null +++ b/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { LuHistory, LuRotateCcw } from "react-icons/lu"; +import { errorMessage } from "../../api/client.js"; +import { + useRevertWorkflowRevision, + useWorkflowRevisions, +} from "../../api/hooks.js"; +import { formatTime } from "../../lib/format.jsx"; +import { useNotifications } from "../../notifications.jsx"; +import { + SaveWorkflowWarningsDialog, + isSaveWarningsError, + saveWarningsFromError, +} from "./SaveWorkflowWarningsDialog.jsx"; + +function reasonLabel(reason, meta) { + if (reason === "duplicated" && meta?.from) return `duplicated from ${meta.from}`; + if (reason === "revert" && meta?.fromRevision != null) { + return `reverted from #${meta.fromRevision}`; + } + if (reason === "restored-from-trash") return "restored from trash"; + return reason ?? "save"; +} + +export function WorkflowHistoryPanel({ owner, file, onReverted }) { + const { notify } = useNotifications(); + const revisions = useWorkflowRevisions(owner, file); + const revert = useRevertWorkflowRevision(); + const [pendingRevision, setPendingRevision] = useState(null); + const [warnings, setWarnings] = useState(null); + + function doRevert(revision, saveAnyway = false) { + setPendingRevision(revision); + revert.mutate( + { owner, file, revision, saveAnyway }, + { + onSuccess: (data) => { + setWarnings(null); + setPendingRevision(null); + notify.success(`Restored revision #${revision}`); + onReverted?.(data); + }, + onError: (err) => { + setPendingRevision(null); + if (isSaveWarningsError(err)) { + setWarnings({ revision, items: saveWarningsFromError(err) }); + return; + } + notify.error(errorMessage(err)); + }, + }, + ); + } + + const items = revisions.data?.revisions ?? []; + + return ( +
+
+ + History + ({items.length} / 50) +
+
+ {revisions.isLoading ? ( +
+ +
+ ) : !items.length ? ( +

No revisions yet.

+ ) : ( +
    + {items.map((rev) => ( +
  • +
    +
    #{rev.revision}
    +
    {formatTime(rev.created_at)}
    +
    + {reasonLabel(rev.reason, rev.meta)} +
    +
    + +
  • + ))} +
+ )} +
+ {warnings ? ( + setWarnings(null)} + onSaveAnyway={() => doRevert(warnings.revision, true)} + /> + ) : null} +
+ ); +} diff --git a/packages/web/src/pages/WorkflowEditPage.jsx b/packages/web/src/pages/WorkflowEditPage.jsx index 3f8ae0d..74d4061 100644 --- a/packages/web/src/pages/WorkflowEditPage.jsx +++ b/packages/web/src/pages/WorkflowEditPage.jsx @@ -3,6 +3,7 @@ import { Link, useNavigate, useParams } from "react-router-dom"; import { LuArrowLeft, LuCopy, LuPause, LuPlay, LuSave } from "react-icons/lu"; import { errorMessage } from "../api/client.js"; import { + useCreateWorkflow, useOwners, useSaveWorkflow, useSetWorkflowEnabled, @@ -11,6 +12,13 @@ import { import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx"; import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx"; import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx"; +import { WorkflowHistoryPanel } from "../components/workflow/WorkflowHistoryPanel.jsx"; +import { + SaveWorkflowWarningsDialog, + isSaveWarningsError, + saveErrorMessage, + saveWarningsFromError, +} from "../components/workflow/SaveWorkflowWarningsDialog.jsx"; import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js"; import { useNotifications } from "../notifications.jsx"; @@ -87,48 +95,63 @@ function WorkflowEditorLayout({ export function WorkflowNewPage() { const navigate = useNavigate(); + const { notify } = useNotifications(); const { data: owners = [] } = useOwners(); const [owner, setOwner] = useState(""); - const [file, setFile] = useState(""); const [content, setContent] = useState(NEW_WORKFLOW_YAML); const [savedYaml] = useState(NEW_WORKFLOW_YAML); - const save = useSaveWorkflow(); + const [saveWarnings, setSaveWarnings] = useState(null); + const create = useCreateWorkflow(); useEffect(() => { if (!owner && owners[0]) setOwner(owners[0]); }, [owner, owners]); - function onSave() { - const yamlFile = file.endsWith(".yaml") || file.endsWith(".yml") ? file : `${file}.yaml`; - save.mutate( - { owner, file: yamlFile, content }, + function onSave(saveAnyway = false) { + create.mutate( + { owner, content, saveAnyway }, { - onSuccess: () => + onSuccess: (data) => { + setSaveWarnings(null); + notify.success(`Created ${data.file}`); navigate( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(yamlFile)}/edit`, - ), + `/workflows/${encodeURIComponent(data.owner)}/${encodeURIComponent(data.file)}/edit`, + ); + }, + onError: (err) => { + if (isSaveWarningsError(err)) { + setSaveWarnings(saveWarningsFromError(err)); + return; + } + notify.error(errorMessage(err)); + }, }, ); } return ( - -
- + <> + onSave(false)} + savePending={create.isPending} + saveDisabled={!owner} + saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null} + > +
+

+ A UUID filename is assigned on save (for example{" "} + a1b2c3d4-….yaml). Edit the{" "} + name: field for the display name. +

+ setOwner(e.target.value)} required /> - setFile(e.target.value)} - required - /> - - } + } + /> +
+
+ {saveWarnings ? ( + setSaveWarnings(null)} + onSaveAnyway={() => onSave(true)} /> -
-
+ ) : null} + ); } @@ -165,6 +189,7 @@ export function WorkflowEditPage() { const [contentReady, setContentReady] = useState(false); const [testOpen, setTestOpen] = useState(false); const [duplicateOpen, setDuplicateOpen] = useState(false); + const [saveWarnings, setSaveWarnings] = useState(null); const routeKey = `${owner}/${file}`; const [activeKey, setActiveKey] = useState(routeKey); @@ -186,14 +211,21 @@ export function WorkflowEditPage() { } }, [existing.data, existing.isLoading, contentReady]); - function onSave() { + function onSave(saveAnyway = false) { save.mutate( - { owner, file, content }, + { owner, file, content, saveAnyway }, { onSuccess: () => { setSavedYaml(content); + setSaveWarnings(null); notify.success("Workflow saved"); }, + onError: (err) => { + if (isSaveWarningsError(err)) { + setSaveWarnings(saveWarningsFromError(err)); + return; + } + }, }, ); } @@ -221,16 +253,21 @@ export function WorkflowEditPage() { const parsedDoc = parseWorkflowYaml(content); const yamlOk = !parsedDoc.parseError; const workflowName = parsedDoc.doc?.name?.trim(); - const pageTitle = workflowName ? `${workflowName} (${file})` : file; + const pageTitle = workflowName ? `${workflowName}` : file; return ( <> + {pageTitle} + {file} + + } savePending={save.isPending} saveDisabled={!contentReady} - saveError={save.isError ? errorMessage(save.error) : null} - onSave={onSave} + saveError={save.isError && !saveWarnings ? saveErrorMessage(save.error) : null} + onSave={() => onSave(false)} onTest={() => setTestOpen(true)} onDuplicate={() => setDuplicateOpen(true)} onToggleEnabled={() => @@ -245,17 +282,34 @@ export function WorkflowEditPage() { enableDisabled={!contentReady || Boolean(existing.data?.parseError) || !yamlOk} enableError={setEnabled.isError ? errorMessage(setEnabled.error) : null} > -
- setTestOpen(false)} - /> +
+
+ setTestOpen(false)} + /> +
+
+ { + existing.refetch().then((result) => { + const next = result.data?.content; + if (next != null) { + setContent(next); + setSavedYaml(next); + } + }); + }} + /> +
{duplicateOpen ? ( @@ -271,6 +325,14 @@ export function WorkflowEditPage() { }} /> ) : null} + {saveWarnings ? ( + setSaveWarnings(null)} + onSaveAnyway={() => onSave(true)} + /> + ) : null} ); } diff --git a/packages/web/src/pages/WorkflowTrashPage.jsx b/packages/web/src/pages/WorkflowTrashPage.jsx new file mode 100644 index 0000000..f56d1a4 --- /dev/null +++ b/packages/web/src/pages/WorkflowTrashPage.jsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { LuArrowLeft, LuRotateCcw, LuTrash2 } from "react-icons/lu"; +import { errorMessage } from "../api/client.js"; +import { + usePurgeWorkflowTrash, + useRestoreWorkflowTrash, + useWorkflowTrash, +} from "../api/hooks.js"; +import { formatTime } from "../lib/format.jsx"; +import { useNotifications } from "../notifications.jsx"; + +function formatAge(ms) { + if (ms == null || ms < 0) return "—"; + const mins = Math.floor(ms / 60_000); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 48) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function WorkflowTrashPage() { + const navigate = useNavigate(); + const { notify } = useNotifications(); + const { data: items = [], isLoading } = useWorkflowTrash(); + const restore = useRestoreWorkflowTrash(); + const purge = usePurgeWorkflowTrash(); + const [confirmPurge, setConfirmPurge] = useState(null); + + return ( +
+
+ + + +

Workflow trash

+
+ +

+ Deleted workflows are kept for 7 days. Restore to bring them back, or delete permanently + to remove the file and revision history. +

+ + {restore.isError ? ( +

{errorMessage(restore.error)}

+ ) : null} + {purge.isError ? ( +

{errorMessage(purge.error)}

+ ) : null} + + {isLoading ? ( + + ) : !items.length ? ( +

Trash is empty.

+ ) : ( +
+ + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
NameFileOwnerDeletedAgePurge in +
{item.name ?? "—"}{item.file}{item.owner}{formatTime(item.deleted_at)}{formatAge(item.age_ms)} + {item.days_until_purge <= 0 ? ( + soon + ) : ( + `${item.days_until_purge}d` + )} + + + +
+
+ )} + + {confirmPurge ? ( + +
+

Delete permanently?

+

+ {confirmPurge.name ?? confirmPurge.file} ({confirmPurge.owner}/{confirmPurge.file}) + will be removed forever, including revision history. +

+
+ + +
+
+
+ +
+
+ ) : null} +
+ ); +} diff --git a/packages/web/src/pages/WorkflowsPage.jsx b/packages/web/src/pages/WorkflowsPage.jsx index 14252e8..47dedf5 100644 --- a/packages/web/src/pages/WorkflowsPage.jsx +++ b/packages/web/src/pages/WorkflowsPage.jsx @@ -4,6 +4,8 @@ import { LuActivity, LuCopy, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2, Lu import { errorMessage } from "../api/client.js"; import { useDeleteWorkflow, + useDownloadWorkflowBackup, + useRestoreWorkflowBackup, useReregisterWorkflows, useRunWorkflow, useSetWorkflowEnabled, @@ -12,6 +14,7 @@ import { import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx"; import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx"; import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx"; +import { useNotifications } from "../notifications.jsx"; export function WorkflowsPage() { const navigate = useNavigate(); @@ -25,6 +28,10 @@ export function WorkflowsPage() { const run = useRunWorkflow(); const setEnabled = useSetWorkflowEnabled(); const reregister = useReregisterWorkflows(); + const backup = useDownloadWorkflowBackup(); + const restoreBackup = useRestoreWorkflowBackup(); + const { notify } = useNotifications(); + const [restoreMode, setRestoreMode] = useState("merge"); if (editParam) { const slash = editParam.indexOf("/"); @@ -72,6 +79,10 @@ export function WorkflowsPage() {

Workflows

+ + + Trash + + + +
+ {isLoading ? ( ) : ( @@ -128,6 +189,7 @@ export function WorkflowsPage() { > {w.name} + {w.file} {!w.registered ? ( setConfirmDelete(w)} > @@ -231,7 +293,11 @@ export function WorkflowsPage() { {confirmDelete ? (
-

Delete {confirmDelete.key}?

+

Move {confirmDelete.key} to trash?

+

+ The workflow is removed from the list but kept in trash for 7 days. Revision history + is preserved. +

{del.isError ? (

{errorMessage(del.error)}

) : null} @@ -250,7 +316,7 @@ export function WorkflowsPage() { ) } > - Delete + Move to trash
From d74923c7e1728aee4077e0efea68002ea925798d Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Thu, 20 Aug 2026 11:00:09 +0700 Subject: [PATCH 02/14] fix(workflows): export workflowFileStem from workflow-normalize Restore the missing export so workflow duplication resolves YAML stems without a broken import from workflow-duplicate.js. Co-authored-by: Cursor --- packages/server/workflow-duplicate.js | 2 -- packages/server/workflow-normalize.js | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/server/workflow-duplicate.js b/packages/server/workflow-duplicate.js index 4953cbd..d540b40 100644 --- a/packages/server/workflow-duplicate.js +++ b/packages/server/workflow-duplicate.js @@ -4,8 +4,6 @@ import { workflowFileStem, } from "./workflow-normalize.js"; -export { workflowFileStem }; - export function ensureWorkflowFilename(file) { const trimmed = String(file ?? "").trim(); if (!trimmed) return ""; diff --git a/packages/server/workflow-normalize.js b/packages/server/workflow-normalize.js index 3486e85..a1c8ef5 100644 --- a/packages/server/workflow-normalize.js +++ b/packages/server/workflow-normalize.js @@ -42,6 +42,11 @@ export function workflowIdFromFile(file) { return String(file).replace(/\.ya?ml$/i, ""); } +/** @param {string} file */ +export function workflowFileStem(file) { + return workflowIdFromFile(file); +} + /** * New on-disk workflow filename: `{uuid}.yaml`. * @param {string} [uuid] From d953f8113ba4b7250cdbf80bb6129657b8168b51 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Thu, 20 Aug 2026 11:00:23 +0700 Subject: [PATCH 03/14] fix(scripts): import installPluginFromDirectory from plugin-store The helper is exported from plugin-store, not plugin-install. Co-authored-by: Cursor --- packages/server/src/api/scripts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/api/scripts.js b/packages/server/src/api/scripts.js index 989f0b1..8858255 100644 --- a/packages/server/src/api/scripts.js +++ b/packages/server/src/api/scripts.js @@ -14,10 +14,10 @@ import { resolveScriptRef, uninstallPlugin, createBlankPlugin, + installPluginFromDirectory, } from "../../plugin-store.js"; import { installExamplePlugin, - installPluginFromDirectory, installPluginFromGit, installPluginFromZipBuffer, } from "../../plugin-install.js"; From 21a31b0b54d9ef88bb033fc84575280e4425515d Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Thu, 20 Aug 2026 11:00:23 +0700 Subject: [PATCH 04/14] fix(start-app): add missing control-bus imports Import getConfigGeneration, startHeartbeatLoop, and subscribeReload so the monolith runner starts without ReferenceError crashes. Co-authored-by: Cursor --- packages/server/start-app.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/server/start-app.js b/packages/server/start-app.js index e38ed79..a5469f7 100644 --- a/packages/server/start-app.js +++ b/packages/server/start-app.js @@ -29,6 +29,11 @@ import { getRedisUrlForLog, } from "./workflow-queue.js"; import { purgeExpiredTrash } from "./workflow-trash.js"; +import { + getConfigGeneration, + startHeartbeatLoop, + subscribeReload, +} from "./control-bus.js"; /** * @param {{ From eb94a2f66970e2c52023e6d4dc2509213db0b2fc Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Thu, 20 Aug 2026 11:00:47 +0700 Subject: [PATCH 05/14] fix(control): allow login when HTTP server is stopped Mount auth routes on the control plane and proxy /api/auth to :8600 in dev:pm2 so the UI can authenticate while the HTTP API process is down. Co-authored-by: Cursor --- packages/server/control.js | 10 +++++++++- packages/server/dev-pm2.mjs | 2 +- packages/server/src/api/auth.js | 18 ++++++++++++++++++ packages/server/start-app.js | 13 ++----------- packages/web/vite.config.js | 5 +++++ 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/server/control.js b/packages/server/control.js index b89c38a..ac1fd94 100644 --- a/packages/server/control.js +++ b/packages/server/control.js @@ -4,7 +4,7 @@ import cors from "@fastify/cors"; import jwt from "@fastify/jwt"; import { migrate, db } from "./db.js"; import { log, enableLogPersistence, flushLogs } from "./logger.js"; -import { COOKIE } from "./src/api/auth.js"; +import authPlugin, { addApiAuthGuard, COOKIE } from "./src/api/auth.js"; import { clearRestartNeeded, bumpGeneration, @@ -124,6 +124,14 @@ server.decorate("requireAdmin", async function requireAdmin(req, reply) { } }); +await server.register( + async (api) => { + addApiAuthGuard(api, server); + await api.register(authPlugin); + }, + { prefix: "/api" }, +); + /** * @param {number} timeoutMs * @param {string} lockToken diff --git a/packages/server/dev-pm2.mjs b/packages/server/dev-pm2.mjs index 92e71d9..fd9c1da 100644 --- a/packages/server/dev-pm2.mjs +++ b/packages/server/dev-pm2.mjs @@ -108,7 +108,7 @@ run( ["--filter", "@jerapah-flow/web", "dev"], { env: { - // vite.config reads nothing; port is set in vite.config.js + JFLOW_AUTH_PROXY: "http://127.0.0.1:8600", }, }, ); diff --git a/packages/server/src/api/auth.js b/packages/server/src/api/auth.js index 8683230..576d2c8 100644 --- a/packages/server/src/api/auth.js +++ b/packages/server/src/api/auth.js @@ -18,6 +18,24 @@ export function cookieOpts() { }; } +/** + * Require JWT for /api routes except bootstrap, login, and register. + * @param {import("fastify").FastifyInstance} api + * @param {import("fastify").FastifyInstance} root + */ +export function addApiAuthGuard(api, root) { + api.addHook("onRequest", async (req, reply) => { + const raw = (req.url || "").split("?")[0]; + const stripped = raw.replace(/^\/api/, "") || "/"; + const routeUrl = req.routeOptions?.url || stripped; + const open = + OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) || + OPEN_API_ROUTES.has(`${req.method} ${stripped}`); + if (open) return; + await root.authenticate(req, reply); + }); +} + export function validateCredentials(username, password) { if (!/^[A-Za-z0-9_]{3,32}$/.test(username)) { return "username must be 3-32 letters, numbers, or underscore"; diff --git a/packages/server/start-app.js b/packages/server/start-app.js index a5469f7..5b6a65e 100644 --- a/packages/server/start-app.js +++ b/packages/server/start-app.js @@ -8,7 +8,7 @@ import { migrate, db } from "./db.js"; import { log, enableLogPersistence, flushLogs } from "./logger.js"; import * as store from "./store.js"; import { createRegistry } from "./registry.js"; -import { COOKIE, OPEN_API_ROUTES } from "./src/api/auth.js"; +import { addApiAuthGuard, COOKIE } from "./src/api/auth.js"; import authPlugin from "./src/api/auth.js"; import usersPlugin from "./src/api/users.js"; import scriptsPluginFactory from "./src/api/scripts.js"; @@ -159,16 +159,7 @@ export async function startApp(opts = {}) { if (runApi) { await server.register( async (api) => { - api.addHook("onRequest", async (req, reply) => { - const raw = (req.url || "").split("?")[0]; - const stripped = raw.replace(/^\/api/, "") || "/"; - const routeUrl = req.routeOptions?.url || stripped; - const open = - OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) || - OPEN_API_ROUTES.has(`${req.method} ${stripped}`); - if (open) return; - await server.authenticate(req, reply); - }); + addApiAuthGuard(api, server); await api.register(authPlugin); await api.register(usersPlugin); await api.register(secretsPlugin); diff --git a/packages/web/vite.config.js b/packages/web/vite.config.js index 8397d27..3288278 100644 --- a/packages/web/vite.config.js +++ b/packages/web/vite.config.js @@ -2,11 +2,16 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +/** In dev:pm2, control (:8600) serves auth so login works when HTTP is stopped. */ +const authProxyTarget = + process.env.JFLOW_AUTH_PROXY ?? "http://127.0.0.1:8700"; + export default defineConfig({ plugins: [react(), tailwindcss()], server: { port: 8500, proxy: { + "/api/auth": { target: authProxyTarget, changeOrigin: true }, "/api": { target: "http://127.0.0.1:8700", changeOrigin: true }, "/admin": { target: "http://127.0.0.1:8700", changeOrigin: true }, "/u": { target: "http://127.0.0.1:8700", changeOrigin: true }, From f74b0defc6df97a1be4ef3192a428e161510e908 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Thu, 20 Aug 2026 11:00:59 +0700 Subject: [PATCH 06/14] feat(web): add backup page and reorganize admin navigation Move workflow backup/restore to a dedicated page, rename Ops to Manage, and group sidebar links into Automate, Platform, and Admin sections. Co-authored-by: Cursor --- packages/web/src/App.jsx | 7 +- packages/web/src/components/Layout.jsx | 89 ++++++++----- packages/web/src/pages/BackupPage.jsx | 159 +++++++++++++++++++++++ packages/web/src/pages/OpsPage.jsx | 10 +- packages/web/src/pages/WorkflowsPage.jsx | 57 -------- 5 files changed, 228 insertions(+), 94 deletions(-) create mode 100644 packages/web/src/pages/BackupPage.jsx diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx index ccc3713..c191915 100644 --- a/packages/web/src/App.jsx +++ b/packages/web/src/App.jsx @@ -21,6 +21,7 @@ import { UsersPage } from "./pages/UsersPage.jsx"; import { SecretsPage } from "./pages/SecretsPage.jsx"; import { VariablesPage } from "./pages/VariablesPage.jsx"; import { OpsPage } from "./pages/OpsPage.jsx"; +import { BackupPage } from "./pages/BackupPage.jsx"; export function App() { const qc = useQueryClient(); @@ -74,13 +75,17 @@ export function App() { <> } /> } /> - } /> + } /> + } /> + } /> ) : ( <> } /> } /> + } /> } /> + } /> )} } /> diff --git a/packages/web/src/components/Layout.jsx b/packages/web/src/components/Layout.jsx index 737fded..45fd436 100644 --- a/packages/web/src/components/Layout.jsx +++ b/packages/web/src/components/Layout.jsx @@ -1,6 +1,8 @@ +import { Fragment } from "react"; import { Link, NavLink, useNavigate } from "react-router-dom"; import { LuActivity, + LuArchive, LuCode, LuDatabase, LuFileText, @@ -20,17 +22,50 @@ import { useLogout, useOpsStatus } from "../api/hooks.js"; import { brandMark } from "../theme/brand.js"; import { useTheme } from "../theme.jsx"; -const links = [ - { to: "/", label: "Home", icon: LuHouse, end: true }, - { to: "/scripts", label: "Scripts", icon: LuCode }, - { to: "/workflows", label: "Workflows", icon: LuGitBranch }, - { to: "/events", label: "Events", icon: LuActivity }, - { to: "/kv", label: "KV", icon: LuDatabase }, - { to: "/variables", label: "Variables", icon: LuTags }, - { to: "/auth", label: "Auth", icon: LuShield }, - { to: "/responses", label: "Responses", icon: LuFileText }, +const navSections = [ + { + items: [{ to: "/", label: "Home", icon: LuHouse, end: true }], + }, + { + title: "Automate", + items: [ + { to: "/workflows", label: "Workflows", icon: LuGitBranch }, + { to: "/scripts", label: "Scripts", icon: LuCode }, + { to: "/events", label: "Events", icon: LuActivity }, + ], + }, + { + title: "Platform", + items: [ + { to: "/variables", label: "Variables", icon: LuTags }, + { to: "/kv", label: "KV", icon: LuDatabase }, + { to: "/auth", label: "Auth", icon: LuShield }, + { to: "/responses", label: "Responses", icon: LuFileText }, + { to: "/secrets", label: "Secrets", icon: LuKey, admin: true }, + ], + }, + { + title: "Admin", + admin: true, + items: [ + { to: "/manage", label: "Manage", icon: LuServer }, + { to: "/backup", label: "Backup", icon: LuArchive }, + { to: "/users", label: "Users", icon: LuUsers }, + ], + }, ]; +function visibleSections(role) { + const isAdmin = role === "admin"; + return navSections + .filter((s) => !s.admin || isAdmin) + .map((s) => ({ + ...s, + items: s.items.filter((item) => !item.admin || isAdmin), + })) + .filter((s) => s.items.length > 0); +} + export function Layout({ user, children }) { const { theme, toggle } = useTheme(); const logout = useLogout(); @@ -38,17 +73,6 @@ export function Layout({ user, children }) { const ops = useOpsStatus(user.role === "admin"); const restartNeeded = Boolean(ops.data?.desired?.restartNeeded); - const navItems = [ - ...links, - ...(user.role === "admin" - ? [ - { to: "/secrets", label: "Secrets", icon: LuKey }, - { to: "/users", label: "Users", icon: LuUsers }, - { to: "/ops", label: "Ops", icon: LuServer }, - ] - : []), - ]; - function closeDrawer() { const el = document.getElementById("nav-drawer"); if (el) el.checked = false; @@ -101,8 +125,8 @@ export function Layout({ user, children }) { ? ` (${ops.data.desired.restartReason})` : ""} .{" "} - - Drain restart from Ops + + Drain restart from Manage {" "} to apply on HTTP + workers. @@ -120,13 +144,20 @@ export function Layout({ user, children }) { JerapahFlow
- {navItems.map(({ to, label, icon: Icon, end }) => ( -
  • - - - {label} - -
  • + {visibleSections(user.role).map((section) => ( + + {section.title ? ( +
  • {section.title}
  • + ) : null} + {section.items.map(({ to, label, icon: Icon, end }) => ( +
  • + + + {label} + +
  • + ))} +
    ))} diff --git a/packages/web/src/pages/BackupPage.jsx b/packages/web/src/pages/BackupPage.jsx new file mode 100644 index 0000000..0921ba4 --- /dev/null +++ b/packages/web/src/pages/BackupPage.jsx @@ -0,0 +1,159 @@ +import { useState } from "react"; +import { LuDownload, LuUpload } from "react-icons/lu"; +import { errorMessage } from "../api/client.js"; +import { + useDownloadWorkflowBackup, + useRestoreWorkflowBackup, +} from "../api/hooks.js"; +import { useNotifications } from "../notifications.jsx"; + +export function BackupPage() { + const backup = useDownloadWorkflowBackup(); + const restoreBackup = useRestoreWorkflowBackup(); + const { notify } = useNotifications(); + const [restoreMode, setRestoreMode] = useState("merge"); + const [pendingFile, setPendingFile] = useState(null); + + function download() { + backup.mutate(undefined, { + onSuccess: () => notify.success("Backup downloaded"), + onError: (err) => notify.error(errorMessage(err)), + }); + } + + function restore(file, mode) { + restoreBackup.mutate( + { file, mode }, + { + onSuccess: (data) => { + notify.success("Backup restored"); + if (data.warnings?.length) { + notify.warning(data.warnings.join("; ")); + } + }, + onError: (err) => notify.error(errorMessage(err)), + onSettled: () => setPendingFile(null), + }, + ); + } + + function onPickFile(e) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + if (restoreMode === "replace") { + setPendingFile(file); + return; + } + restore(file, restoreMode); + } + + return ( +
    +
    +

    Backup

    +

    + Download or restore workflows and installed plugins as a zip archive. +

    +
    + +
    +
    +

    Download

    +

    + Includes workflow YAML and installed plugins. Use this to copy the + setup to another machine or keep a snapshot before a restore. +

    +
    + +
    +
    +
    + +
    +
    +

    Restore

    +

    + Merge keeps existing files and overwrites matches from the zip. + Replace deletes current workflows and plugins first. +

    +
    + + +
    +
    +
    + + {pendingFile ? ( + +
    +

    Replace with this backup?

    +

    + Current workflows and plugins will be deleted, then{" "} + {pendingFile.name} will be + restored. This cannot be undone from this page. +

    +
    + + +
    +
    +
    + +
    +
    + ) : null} +
    + ); +} diff --git a/packages/web/src/pages/OpsPage.jsx b/packages/web/src/pages/OpsPage.jsx index 7fc559e..72ca3a6 100644 --- a/packages/web/src/pages/OpsPage.jsx +++ b/packages/web/src/pages/OpsPage.jsx @@ -55,7 +55,7 @@ export function OpsPage() { if (unavailable) { return (
    -

    Ops

    +

    Manage

    Control plane is not reachable on /ops. Use{" "} @@ -74,14 +74,10 @@ export function OpsPage() { const children = data?.children; return ( -
    +
    -

    Ops

    -

    - Process control via PM2. Pause stops workers from opening jobs; - cron/HTTP can still enqueue. Restart drains active jobs unless forced. -

    +

    Manage

    - - -
    - {isLoading ? ( ) : ( From 69106ab805fac3eca21d0bb3269fc4d2b014274c Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Thu, 20 Aug 2026 22:30:47 +0700 Subject: [PATCH 07/14] feat(plugins): add get-current-time example plugin Co-authored-by: Cursor --- plugins/get-current-time/jerapah-plugin.json | 8 +++++ plugins/get-current-time/package.json | 7 ++++ plugins/get-current-time/script.js | 34 ++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 plugins/get-current-time/jerapah-plugin.json create mode 100644 plugins/get-current-time/package.json create mode 100644 plugins/get-current-time/script.js diff --git a/plugins/get-current-time/jerapah-plugin.json b/plugins/get-current-time/jerapah-plugin.json new file mode 100644 index 0000000..35294d2 --- /dev/null +++ b/plugins/get-current-time/jerapah-plugin.json @@ -0,0 +1,8 @@ +{ + "id": "get-current-time", + "name": "Get current time", + "version": "0.1.0", + "jerapah": ">=0.1.0 <1.0.0", + "main": "script.js", + "description": "Example user plugin: return the current time as output.datetime" +} diff --git a/plugins/get-current-time/package.json b/plugins/get-current-time/package.json new file mode 100644 index 0000000..2ca2114 --- /dev/null +++ b/plugins/get-current-time/package.json @@ -0,0 +1,7 @@ +{ + "name": "jflow-plugin-get-current-time", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Example JerapahFlow plugin (no npm dependencies)" +} diff --git a/plugins/get-current-time/script.js b/plugins/get-current-time/script.js new file mode 100644 index 0000000..ad34931 --- /dev/null +++ b/plugins/get-current-time/script.js @@ -0,0 +1,34 @@ +function passContext(ctx) { + if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) { + return { ...ctx.context }; + } + return {}; +} + +function getCurrentTime(ctx) { + const output = { + datetime: new Date().toISOString(), + processId: "1234", + }; + return { output, context: { ...passContext(ctx), ...output } }; +} + +getCurrentTime.meta = { + description: "Return the current time as output.datetime", + config: {}, + input: {}, + output: { + datetime: { type: "string", description: "ISO timestamp" }, + processId: { type: "string" }, + }, + context: { + datetime: { type: "string", description: "ISO timestamp" }, + processId: { type: "string" }, + }, + example: { + data: {}, + config: {}, + }, +}; + +export default getCurrentTime; From f68376587e6120a4e9d921ee3b8316eb5d97b5ef Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Thu, 20 Aug 2026 22:31:02 +0700 Subject: [PATCH 08/14] feat(http-auth): resolve profiles by UUID and allow multi-auth triggers Co-authored-by: Cursor --- packages/server/http-auths-store.js | 88 +++- packages/server/http-trigger-auth.js | 116 +++-- packages/server/src/api/http-auths.js | 28 +- .../server/test/http-trigger-auth-smoke.js | 106 ++++- packages/server/workflow-http-validate.js | 69 ++- .../server/workflows/default/dev-zte-sms.yaml | 3 +- .../server/workflows/default/send-gmail.yaml | 3 +- .../default/time-to-ntfy-example.yaml | 3 + .../web/src/components/AuthEditorModal.jsx | 422 +++++++++++++++++ .../src/components/workflow/TriggerCard.jsx | 302 +++++++++--- packages/web/src/lib/workflow-doc.js | 4 +- packages/web/src/pages/AuthProfilesPage.jsx | 436 +++--------------- 12 files changed, 1056 insertions(+), 524 deletions(-) create mode 100644 packages/web/src/components/AuthEditorModal.jsx diff --git a/packages/server/http-auths-store.js b/packages/server/http-auths-store.js index afb805c..45e4661 100644 --- a/packages/server/http-auths-store.js +++ b/packages/server/http-auths-store.js @@ -4,12 +4,27 @@ 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} @@ -218,10 +233,11 @@ function publicAuth(row, { includeConfig = true } = {}) { /** * Internal: full config including literals (for runtime auth checks). - * @param {string} name + * @param {string} id */ -export async function getHttpAuthInternal(name) { - const row = await db("http_auths").where({ name: assertAuthName(name) }).first(); +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, @@ -235,11 +251,11 @@ export async function getHttpAuthInternal(name) { /** * Return only plaintext literal credential fields (not KV refs or encrypted secrets). - * @param {string} name - * @returns {Promise<{ name: string, type: string, literals: Record } | null>} + * @param {string} id + * @returns {Promise<{ id: string, name: string, type: string, literals: Record } | null>} */ -export async function revealHttpAuthLiterals(name) { - const internal = await getHttpAuthInternal(name); +export async function revealHttpAuthLiterals(id) { + const internal = await getHttpAuthInternal(id); if (!internal) return null; /** @type {Record} */ const literals = {}; @@ -248,7 +264,12 @@ export async function revealHttpAuthLiterals(name) { const v = cfg[key]; if (typeof v === "string") literals[key] = v; } - return { name: internal.name, type: internal.type, literals }; + return { + id: internal.id, + name: internal.name, + type: internal.type, + literals, + }; } export async function listHttpAuths() { @@ -256,24 +277,23 @@ export async function listHttpAuths() { return rows.map((r) => publicAuth(r)); } -/** - * @param {string} name - */ -export async function getHttpAuthByName(name) { - const row = await db("http_auths").where({ name: assertAuthName(name) }).first(); - return row ? publicAuth(row) : null; -} - /** * @param {string} id */ export async function getHttpAuthById(id) { - const row = await db("http_auths").where({ id }).first(); + 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, @@ -282,6 +302,7 @@ export async function getHttpAuthById(id) { * }} opts */ export async function upsertHttpAuth({ + id, name, type, config, @@ -290,7 +311,26 @@ export async function upsertHttpAuth({ }) { const authName = assertAuthName(name); const authType = assertAuthType(type); - const existing = await db("http_auths").where({ name: authName }).first(); + + /** @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, @@ -315,18 +355,19 @@ export async function upsertHttpAuth({ 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(existing.id); + return getHttpAuthById(/** @type {string} */ (existing.id)); } - const id = randomUUID(); + const newId = randomUUID(); await db("http_auths").insert({ - id, + id: newId, name: authName, type: authType, config: configJson, @@ -335,7 +376,7 @@ export async function upsertHttpAuth({ created_at: now, updated_at: now, }); - return getHttpAuthById(id); + return getHttpAuthById(newId); } /** @@ -343,6 +384,7 @@ export async function upsertHttpAuth({ * @returns {Promise} */ export async function deleteHttpAuth(id) { - const n = await db("http_auths").where({ id }).del(); + const authId = assertAuthId(id); + const n = await db("http_auths").where({ id: authId }).del(); return n > 0; } diff --git a/packages/server/http-trigger-auth.js b/packages/server/http-trigger-auth.js index c5d1e86..24b0eff 100644 --- a/packages/server/http-trigger-auth.js +++ b/packages/server/http-trigger-auth.js @@ -77,38 +77,47 @@ export async function resolveCredentialValue(field, ctx) { } /** - * Normalize trigger.auth into an inline auth mechanism object. - * @param {unknown} authField - * @returns {Promise<{ + * @typedef {{ * type: string, * config: Record, * unauthorized_status?: number | null, * unauthorized_response?: string | null, * label: string, - * } | null>} + * }} AuthMechanism */ -export async function resolveAuthMechanism(authField) { - if (authField == null || authField === false) return null; - if (typeof authField === "string") { - const named = await getHttpAuthInternal(authField); - if (!named) { - log.warn({ name: authField }, "http auth: named profile not found"); +/** + * Resolve one auth entry (auth profile id UUID, or inline object). + * @param {unknown} entry + * @returns {Promise} + */ +export async function resolveAuthMechanism(entry) { + if (entry == null || entry === false) return null; + + if (typeof entry === "string") { + try { + const named = await getHttpAuthInternal(entry); + if (!named) { + log.warn({ id: entry }, "http auth: profile id not found"); + return null; + } + return { + type: named.type, + config: named.config, + unauthorized_status: named.unauthorized_status, + unauthorized_response: named.unauthorized_response, + label: named.name, + }; + } catch (err) { + log.warn({ err, id: entry }, "http auth: invalid profile id"); return null; } - return { - type: named.type, - config: named.config, - unauthorized_status: named.unauthorized_status, - unauthorized_response: named.unauthorized_response, - label: authField, - }; } - if (typeof authField === "object" && !Array.isArray(authField)) { - const obj = /** @type {Record} */ (authField); - if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) { - return resolveAuthMechanism(obj.name); + if (typeof entry === "object" && !Array.isArray(entry)) { + const obj = /** @type {Record} */ (entry); + if (typeof obj.id === "string" && obj.id.length > 0 && !obj.type) { + return resolveAuthMechanism(obj.id); } try { const type = assertAuthType(obj.type); @@ -116,6 +125,7 @@ export async function resolveAuthMechanism(authField) { const config = { ...obj }; delete config.type; delete config.name; + delete config.id; return { type, config, @@ -133,18 +143,64 @@ export async function resolveAuthMechanism(authField) { } /** - * Label for mermaid / summary (sync, no DB). + * Normalize trigger.auth (array of auth ids / inline objects) into mechanisms. + * Empty / null / false → no auth. Any entry that fails to resolve is skipped; + * if the field was non-empty but nothing resolves, returns [] (caller treats as unauthorized). * @param {unknown} authField + * @returns {Promise} */ -export function authLabel(authField) { - if (authField == null) return null; - if (typeof authField === "string") return authField; - if (typeof authField === "object" && !Array.isArray(authField)) { - const o = /** @type {Record} */ (authField); - if (typeof o.name === "string" && o.name) return o.name; - if (typeof o.type === "string" && o.type) return o.type; +export async function resolveAuthMechanisms(authField) { + if (authField == null || authField === false) return []; + if (!Array.isArray(authField) || authField.length === 0) return []; + + /** @type {AuthMechanism[]} */ + const out = []; + for (const entry of authField) { + const mech = await resolveAuthMechanism(entry); + if (mech) out.push(mech); } - return "auth"; + return out; +} + +/** + * True if any mechanism accepts the request (OR). + * @param {import("fastify").FastifyRequest} req + * @param {AuthMechanism[]} mechanisms + * @param {{ owner: string, workflowKey: string }} ctx + */ +export async function checkAnyHttpAuth(req, mechanisms, ctx) { + for (const mechanism of mechanisms) { + if (await checkHttpAuth(req, mechanism, ctx)) return true; + } + return false; +} + +/** + * Label for mermaid / summary (sync). Prefer resolved display names when provided. + * @param {unknown} authField + * @param {Map | Record} [nameById] + */ +export function authLabel(authField, nameById) { + if (authField == null || authField === false) return null; + if (!Array.isArray(authField) || authField.length === 0) return null; + const lookup = + nameById instanceof Map + ? (id) => nameById.get(id) + : nameById + ? (id) => nameById[id] + : () => undefined; + const parts = authField.map((entry) => { + if (typeof entry === "string") return lookup(entry) ?? entry; + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const o = /** @type {Record} */ (entry); + if (typeof o.id === "string" && o.id && !o.type) { + return lookup(o.id) ?? o.id; + } + if (typeof o.type === "string" && o.type) return o.type; + } + return "auth"; + }); + return parts.join("|"); } /** diff --git a/packages/server/src/api/http-auths.js b/packages/server/src/api/http-auths.js index c7d85f0..b729953 100644 --- a/packages/server/src/api/http-auths.js +++ b/packages/server/src/api/http-auths.js @@ -1,9 +1,9 @@ import { + assertAuthId, assertAuthName, assertAuthType, listHttpAuths, getHttpAuthById, - getHttpAuthByName, upsertHttpAuth, deleteHttpAuth, revealHttpAuthLiterals, @@ -18,28 +18,28 @@ export default async function httpAuthsPlugin(fastify) { return { auths: await listHttpAuths() }; }); - fastify.get("/http-auths/:name/reveal", async (req, reply) => { - const { name } = /** @type {{ name: string }} */ (req.params); + fastify.get("/http-auths/:id/reveal", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); try { - assertAuthName(name); + assertAuthId(id); } catch (err) { return reply.code(err.statusCode ?? 400).send({ error: err.message }); } - const revealed = await revealHttpAuthLiterals(name); + const revealed = await revealHttpAuthLiterals(id); if (!revealed) { return reply.code(404).send({ error: "auth not found" }); } return revealed; }); - fastify.get("/http-auths/:name", async (req, reply) => { - const { name } = /** @type {{ name: string }} */ (req.params); + fastify.get("/http-auths/:id", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); try { - assertAuthName(name); + assertAuthId(id); } catch (err) { return reply.code(err.statusCode ?? 400).send({ error: err.message }); } - const auth = await getHttpAuthByName(name); + const auth = await getHttpAuthById(id); if (!auth) { return reply.code(404).send({ error: "auth not found" }); } @@ -48,6 +48,7 @@ export default async function httpAuthsPlugin(fastify) { fastify.put("/http-auths", async (req, reply) => { const body = /** @type {{ + id?: string | null, name?: string, type?: string, config?: unknown, @@ -57,6 +58,9 @@ export default async function httpAuthsPlugin(fastify) { try { assertAuthName(String(body.name ?? "")); assertAuthType(body.type); + if (body.id != null && String(body.id).length > 0) { + assertAuthId(String(body.id)); + } if ( body.unauthorized_response != null && String(body.unauthorized_response).length > 0 @@ -69,6 +73,7 @@ export default async function httpAuthsPlugin(fastify) { ); } const auth = await upsertHttpAuth({ + id: body.id != null && String(body.id).length > 0 ? String(body.id) : null, name: String(body.name), type: String(body.type), config: body.config, @@ -83,6 +88,11 @@ export default async function httpAuthsPlugin(fastify) { fastify.delete("/http-auths/:id", async (req, reply) => { const { id } = /** @type {{ id: string }} */ (req.params); + try { + assertAuthId(id); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } const existing = await getHttpAuthById(id); if (!existing) { return reply.code(404).send({ error: "auth not found" }); diff --git a/packages/server/test/http-trigger-auth-smoke.js b/packages/server/test/http-trigger-auth-smoke.js index 06e64ea..7d300e5 100644 --- a/packages/server/test/http-trigger-auth-smoke.js +++ b/packages/server/test/http-trigger-auth-smoke.js @@ -12,9 +12,12 @@ import { getHttpAuthInternal, } from "../http-auths-store.js"; import { + authLabel, + checkAnyHttpAuth, checkHttpAuth, coerceCredentialString, resolveAuthMechanism, + resolveAuthMechanisms, resolveCredentialValue, resolveUnauthorizedSpec, sendHttpPageOrJson, @@ -176,11 +179,11 @@ const secret = await upsertSecret({ config: { token: { secret: "does_not_exist_xyz" } }, }, ctx, - ); + ); assert(!missingSec, "missing secret fails closed"); } -// --- named profile --- +// --- named profile (by id) --- const profile = await upsertHttpAuth({ name: "webhook-smoke", type: "bearer", @@ -188,9 +191,10 @@ const profile = await upsertHttpAuth({ unauthorized_status: 403, unauthorized_response: "deny-smoke", }); +assert(typeof profile.id === "string" && profile.id.length > 0, "profile has id"); { - const mech = await resolveAuthMechanism("webhook-smoke"); - assert(mech?.label === "webhook-smoke", "named profile"); + const mech = await resolveAuthMechanism(profile.id); + assert(mech?.label === "webhook-smoke", "profile by id"); const ok = await checkHttpAuth( mockReq({ authorization: "Bearer named-token" }), mech, @@ -202,9 +206,68 @@ const profile = await upsertHttpAuth({ assert(pageName === "deny-smoke", "profile unauth page"); } +// --- rename keeps id --- +{ + const renamed = await upsertHttpAuth({ + id: profile.id, + name: "webhook-renamed", + type: "bearer", + config: { token: { keep: true } }, + unauthorized_status: 403, + unauthorized_response: "deny-smoke", + }); + assert(renamed.id === profile.id, "rename keeps id"); + assert(renamed.name === "webhook-renamed", "rename updates name"); + const mech = await resolveAuthMechanism(profile.id); + assert(mech?.label === "webhook-renamed", "resolve uses new name label"); + const ok = await checkHttpAuth( + mockReq({ authorization: "Bearer named-token" }), + mech, + ctx, + ); + assert(ok, "credentials survive rename"); +} + +// --- multi-auth OR --- +const basicProfile = await upsertHttpAuth({ + name: "basic-smoke", + type: "basic", + config: { user: "bob", password: "p@ss" }, +}); +{ + const mechs = await resolveAuthMechanisms([profile.id, basicProfile.id]); + assert(mechs.length === 2, "resolve two mechanisms"); + assert( + authLabel([profile.id, basicProfile.id], { + [profile.id]: "webhook-renamed", + [basicProfile.id]: "basic-smoke", + }) === "webhook-renamed|basic-smoke", + "authLabel", + ); + const viaBearer = await checkAnyHttpAuth( + mockReq({ authorization: "Bearer named-token" }), + mechs, + ctx, + ); + assert(viaBearer, "OR accepts bearer"); + const encoded = Buffer.from("bob:p@ss").toString("base64"); + const viaBasic = await checkAnyHttpAuth( + mockReq({ authorization: `Basic ${encoded}` }), + mechs, + ctx, + ); + assert(viaBasic, "OR accepts basic"); + const neither = await checkAnyHttpAuth( + mockReq({ authorization: "Bearer wrong" }), + mechs, + ctx, + ); + assert(!neither, "OR rejects when none match"); +} + // trigger-level override { - const mech = await getHttpAuthInternal("webhook-smoke"); + const mech = await getHttpAuthInternal(profile.id); const { status, pageName } = resolveUnauthorizedSpec( { unauthorized: { status: 401, response: "deny-smoke" } }, mech, @@ -226,7 +289,7 @@ await validateWorkflowHttpTriggers({ type: "HTTP", method: "POST", path: "/x", - auth: "webhook-smoke", + auth: [profile.id, basicProfile.id], response: "deny-smoke", }, ], @@ -235,12 +298,38 @@ await validateWorkflowHttpTriggers({ let threw = false; try { await validateWorkflowHttpTriggers({ - triggers: [{ type: "HTTP", path: "/x", auth: "no-such-profile" }], + triggers: [{ type: "HTTP", path: "/x", auth: profile.id }], }); } catch { threw = true; } -assert(threw, "unknown auth fails validation"); +assert(threw, "non-array auth fails validation"); + +threw = false; +try { + await validateWorkflowHttpTriggers({ + triggers: [{ type: "HTTP", path: "/x", auth: ["webhook-renamed"] }], + }); +} catch { + threw = true; +} +assert(threw, "name string fails validation"); + +threw = false; +try { + await validateWorkflowHttpTriggers({ + triggers: [ + { + type: "HTTP", + path: "/x", + auth: ["00000000-0000-4000-8000-000000000000"], + }, + ], + }); +} catch { + threw = true; +} +assert(threw, "unknown auth id fails validation"); threw = false; try { @@ -280,6 +369,7 @@ assert(threw, "unknown page fails validation"); // cleanup await deleteHttpAuth(profile.id); +await deleteHttpAuth(basicProfile.id); await deleteHttpPage(page.id); await deleteSecret(secret.id); const leftover = (await listSecrets({ owner })).find( diff --git a/packages/server/workflow-http-validate.js b/packages/server/workflow-http-validate.js index 58a0b06..2f44ce7 100644 --- a/packages/server/workflow-http-validate.js +++ b/packages/server/workflow-http-validate.js @@ -1,7 +1,11 @@ /** * Validate HTTP trigger auth / response fields on workflow save. */ -import { assertAuthType, getHttpAuthByName } from "./http-auths-store.js"; +import { + assertAuthId, + assertAuthType, + getHttpAuthById, +} from "./http-auths-store.js"; import { getHttpPageByName, assertHttpResponsePage } from "./http-pages-store.js"; import { authLabel } from "./http-trigger-auth.js"; @@ -24,51 +28,80 @@ function assertCredentialFieldShape(field, label) { } /** - * @param {unknown} auth + * @param {unknown} entry + * @param {string} path */ -async function validateAuthField(auth) { - if (auth == null || auth === false) return; - - if (typeof auth === "string") { - const named = await getHttpAuthByName(auth); +async function validateAuthEntry(entry, path) { + if (typeof entry === "string") { + try { + assertAuthId(entry); + } catch { + const err = new Error(`${path} must be an auth profile UUID`); + err.statusCode = 400; + throw err; + } + const named = await getHttpAuthById(entry); if (!named) { - const err = new Error(`unknown auth profile "${auth}"`); + const err = new Error(`unknown auth profile id "${entry}"`); err.statusCode = 400; throw err; } return; } - if (typeof auth === "object" && !Array.isArray(auth)) { - const obj = /** @type {Record} */ (auth); - if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) { - await validateAuthField(obj.name); + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const obj = /** @type {Record} */ (entry); + if (typeof obj.id === "string" && obj.id.length > 0 && !obj.type) { + await validateAuthEntry(obj.id, path); return; } const type = assertAuthType(obj.type); if (type === "bearer") { - assertCredentialFieldShape(obj.token, "auth.token"); + assertCredentialFieldShape(obj.token, `${path}.token`); } else if (type === "basic") { - assertCredentialFieldShape(obj.user, "auth.user"); + assertCredentialFieldShape(obj.user, `${path}.user`); if (obj.password != null && obj.password !== "") { - assertCredentialFieldShape(obj.password, "auth.password"); + assertCredentialFieldShape(obj.password, `${path}.password`); } } else if (type === "header") { if (typeof obj.header !== "string" || obj.header.length === 0) { - const err = new Error("auth.header must be a non-empty string"); + const err = new Error(`${path}.header must be a non-empty string`); err.statusCode = 400; throw err; } - assertCredentialFieldShape(obj.value, "auth.value"); + assertCredentialFieldShape(obj.value, `${path}.value`); } return; } - const err = new Error("auth must be a profile name or an auth object"); + const err = new Error( + `${path} must be an auth profile UUID or an inline auth object`, + ); err.statusCode = 400; throw err; } +/** + * auth is an array of auth profile UUIDs and/or inline auth objects (OR). + * null / false / [] = no auth. + * @param {unknown} auth + */ +async function validateAuthField(auth) { + if (auth == null || auth === false) return; + + if (!Array.isArray(auth)) { + const err = new Error( + "auth must be an array of auth profile UUIDs and/or inline auth objects", + ); + err.statusCode = 400; + throw err; + } + + for (let i = 0; i < auth.length; i++) { + await validateAuthEntry(auth[i], `auth[${i}]`); + } +} + /** * @param {unknown} pageName * @param {string} label diff --git a/packages/server/workflows/default/dev-zte-sms.yaml b/packages/server/workflows/default/dev-zte-sms.yaml index e351541..1a85a56 100644 --- a/packages/server/workflows/default/dev-zte-sms.yaml +++ b/packages/server/workflows/default/dev-zte-sms.yaml @@ -13,4 +13,5 @@ triggers: - type: HTTP method: POST path: /dev-zte-sms - auth: basic-auth + auth: + - 0f78d6d7-bd44-45d7-a826-f51c027b767f diff --git a/packages/server/workflows/default/send-gmail.yaml b/packages/server/workflows/default/send-gmail.yaml index d6b42cf..e0d5889 100644 --- a/packages/server/workflows/default/send-gmail.yaml +++ b/packages/server/workflows/default/send-gmail.yaml @@ -36,4 +36,5 @@ triggers: - type: HTTP method: POST path: /send-gmail - auth: basic-auth + auth: + - 0f78d6d7-bd44-45d7-a826-f51c027b767f diff --git a/packages/server/workflows/default/time-to-ntfy-example.yaml b/packages/server/workflows/default/time-to-ntfy-example.yaml index 34404f9..67dcdf3 100644 --- a/packages/server/workflows/default/time-to-ntfy-example.yaml +++ b/packages/server/workflows/default/time-to-ntfy-example.yaml @@ -1,6 +1,7 @@ name: time to ntfy example description: | this workflow will send a message to ntfy with the current time +enabled: false scripts: - script: plugin/get-current-time config: @@ -12,3 +13,5 @@ triggers: - type: HTTP method: POST path: /time-to-ntfy + auth: + - 0f78d6d7-bd44-45d7-a826-f51c027b767f diff --git a/packages/web/src/components/AuthEditorModal.jsx b/packages/web/src/components/AuthEditorModal.jsx new file mode 100644 index 0000000..8b01e7d --- /dev/null +++ b/packages/web/src/components/AuthEditorModal.jsx @@ -0,0 +1,422 @@ +import { useEffect, useState } from "react"; +import { LuEye, LuEyeOff } from "react-icons/lu"; +import { errorMessage } from "../api/client.js"; +import { + fetchHttpAuthLiterals, + useHttpPages, + useUpsertHttpAuth, +} from "../api/hooks.js"; + +function emptyCred(source = "literal") { + return { source, value: "", kv: "", namespace: "", secret: "" }; +} + +function credFromPublic(field, literalValue) { + if (!field || field.source === "missing") return emptyCred("literal"); + if (field.source === "kv") { + return { + source: "kv", + value: "", + kv: field.kv ?? "", + namespace: field.namespace ?? "", + secret: "", + }; + } + if (field.source === "secret") { + return { + source: "secret", + value: "", + kv: "", + namespace: "", + secret: field.secret ?? "", + }; + } + if (typeof literalValue === "string") { + return { + source: "literal", + value: literalValue, + kv: "", + namespace: "", + secret: "", + keep: true, + }; + } + return { + source: "literal", + value: "", + kv: "", + namespace: "", + secret: "", + keep: field.set === true, + }; +} + +function toApiField(cred, { required = true } = {}) { + if (cred.source === "kv") { + const out = { kv: cred.kv }; + if (cred.namespace) out.namespace = cred.namespace; + return out; + } + if (cred.source === "secret") { + return { secret: cred.secret }; + } + if (cred.value) return cred.value; + if (cred.keep) return { keep: true }; + if (!required) return ""; + return null; +} + +function emptyForm() { + return { + id: null, + name: "", + type: "bearer", + token: emptyCred(), + user: emptyCred(), + password: emptyCred(), + header: "", + value: emptyCred(), + unauthorized_status: "", + unauthorized_response: "", + }; +} + +function formFromAuth(auth, literals = {}) { + const cfg = auth.config ?? {}; + return { + id: auth.id, + name: auth.name, + type: auth.type, + token: credFromPublic(cfg.token, literals.token), + user: credFromPublic(cfg.user, literals.user), + password: credFromPublic(cfg.password, literals.password), + header: cfg.header ?? "", + value: credFromPublic(cfg.value, literals.value), + unauthorized_status: auth.unauthorized_status ?? "", + unauthorized_response: auth.unauthorized_response ?? "", + }; +} + +function Field({ label, children, hint }) { + return ( +
    +
    + {label} +
    + {children} + {hint ? ( +
    + {hint} +
    + ) : null} +
    + ); +} + +function CredentialFields({ label, cred, onChange, allowEmpty, masked }) { + const [show, setShow] = useState(false); + + return ( +
    +

    {label}

    + + + + {cred.source === "literal" ? ( + +
    + onChange({ ...cred, value: e.target.value, keep: false })} + placeholder={ + cred.keep && !cred.value ? "(unchanged — leave blank to keep)" : "" + } + required={!allowEmpty && !cred.keep && !cred.value} + autoComplete="off" + /> + {masked ? ( + + ) : null} +
    +
    + ) : null} + {cred.source === "kv" ? ( + <> + + onChange({ ...cred, namespace: e.target.value })} + /> + + + onChange({ ...cred, kv: e.target.value })} + required + /> + + + ) : null} + {cred.source === "secret" ? ( + + onChange({ ...cred, secret: e.target.value })} + required + pattern="[A-Za-z0-9._-]+" + /> + + ) : null} +
    + ); +} + +/** + * Add / edit an HTTP trigger auth profile. + * Reusable: mount when open; pass `auth` for edit (literals loaded inside). + * + * @param {"add" | "edit"} mode + * @param {object} [auth] Public auth row when mode is "edit" + * @param {() => void} onClose + * @param {(saved: unknown) => void} [onSaved] + */ +export function AuthEditorModal({ mode, auth, onClose, onSaved }) { + const { data: pages = [] } = useHttpPages(); + const upsert = useUpsertHttpAuth(); + const [form, setForm] = useState(emptyForm); + const [loading, setLoading] = useState(mode === "edit"); + + useEffect(() => { + if (mode !== "edit" || !auth?.id) { + setForm(emptyForm()); + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + (async () => { + /** @type {Record} */ + let literals = {}; + try { + const data = await fetchHttpAuthLiterals(auth.id); + literals = data.literals ?? {}; + } catch { + // Form still works with keep markers + } + if (cancelled) return; + setForm(formFromAuth(auth, literals)); + setLoading(false); + })(); + return () => { + cancelled = true; + }; + }, [mode, auth]); + + function onSubmit(e) { + e.preventDefault(); + /** @type {Record} */ + let config = {}; + if (form.type === "bearer") { + const token = toApiField(form.token); + if (token == null) return; + config = { token }; + } else if (form.type === "basic") { + const user = toApiField(form.user); + if (user == null) return; + const password = toApiField(form.password, { required: false }); + config = { user, password: password ?? "" }; + } else { + const value = toApiField(form.value); + if (value == null) return; + config = { header: form.header, value }; + } + + upsert.mutate( + { + id: form.id, + name: form.name, + type: form.type, + config, + unauthorized_status: + form.unauthorized_status === "" ? null : Number(form.unauthorized_status), + unauthorized_response: form.unauthorized_response || null, + }, + { + onSuccess: (data) => { + onSaved?.(data?.auth ?? data); + onClose(); + }, + }, + ); + } + + const title = mode === "add" ? "New auth profile" : `Edit ${form.name || auth?.name || ""}`; + + return ( + +
    +

    {title}

    + {loading ? ( +
    + +
    + ) : ( +
    + + setForm({ ...form, name: e.target.value })} + required + pattern="[A-Za-z0-9._-]+" + autoComplete="off" + /> + + + + + + + {form.type === "bearer" ? ( + setForm({ ...form, token })} + /> + ) : null} + {form.type === "basic" ? ( + <> + setForm({ ...form, user })} + /> + setForm({ ...form, password })} + allowEmpty + masked + /> + + ) : null} + {form.type === "header" ? ( + <> + + setForm({ ...form, header: e.target.value })} + required + placeholder="X-Webhook-Secret" + /> + + setForm({ ...form, value })} + /> + + ) : null} + + + setForm({ ...form, unauthorized_status: e.target.value })} + min={100} + max={599} + placeholder="401" + /> + + + + + + + {upsert.isError ? ( +

    {errorMessage(upsert.error)}

    + ) : null} +
    + + +
    + + )} + {loading ? ( +
    + +
    + ) : null} +
    +
    + +
    +
    + ); +} diff --git a/packages/web/src/components/workflow/TriggerCard.jsx b/packages/web/src/components/workflow/TriggerCard.jsx index 4e735d2..a7df469 100644 --- a/packages/web/src/components/workflow/TriggerCard.jsx +++ b/packages/web/src/components/workflow/TriggerCard.jsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2, LuX } from "react-icons/lu"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import cronstrue from "cronstrue"; @@ -138,15 +138,27 @@ function typeLabel(type) { return type || "Trigger"; } +function Field({ label, children, hint }) { + return ( +
    + {label ? {label} : null} + {children} + {hint ? {hint} : null} +
    + ); +} + function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDestinations }) { const path = trigger.path || "/"; const url = namespacedPath(owner || "owner", path); - const authIsInline = trigger.auth != null && typeof trigger.auth === "object"; - const authSelect = authIsInline - ? "__inline__" - : typeof trigger.auth === "string" && trigger.auth - ? trigger.auth - : ""; + const authEntries = Array.isArray(trigger.auth) ? trigger.auth : []; + const selectedIds = authEntries.filter((e) => typeof e === "string" && e).map(String); + const inlineEntries = authEntries.filter((e) => e && typeof e === "object"); + + function setAuthIds(nextIds) { + const next = [...nextIds, ...inlineEntries]; + onChange({ ...trigger, auth: next.length ? next : null }); + } function copyUrl() { if (typeof navigator?.clipboard?.writeText === "function") { @@ -155,11 +167,10 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes } return ( -
    -