diff --git a/.gitignore b/.gitignore index 7d09a63..a71135d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.pnpm-store/ data/ logs/ *.db @@ -9,8 +10,11 @@ packages/web/dist # Personal/local scripts and workflows (not for the repo) debug-*.js +# Legacy live workflow tree (migrated to packages/server/data/workflows/) +packages/server/workflows/ + # Plugin install staging and per-plugin deps plugins/.staging-* plugins/*/node_modules/ -.gitea/workflows/ \ No newline at end of file +.gitea/workflows/ diff --git a/AGENTS.md b/AGENTS.md index 62d0cb9..5182884 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,17 @@ This file tells agents how to add a **user plugin**. Do not put personal or site-specific scripts in `packages/server/scripts/` (core, read-only). Do not put them in `examples/plugins/` (shipped examples only). +## Workflows (instance data) + +Live workflows are **not** product source. They live under `packages/server/data/workflows//` (gitignored; override with `JFLOW_WORKFLOWS_DIR`). + +| Kind | In git? | Path | +|---|---|---| +| Live / personal YAML | No | `packages/server/data/workflows//` | +| Example presets | Yes | `examples/workflows/*.yaml` (copy into editor only; runner does not load them) | + +Do **not** add personal YAML under `packages/server/`, `examples/workflows/`, or the legacy `packages/server/workflows/` tree. Prefer owner `local`. Example presets must use **core** scripts only (no `plugin/…` that requires install). + ## Where things live | Kind | YAML `script` | Editable | Path | @@ -127,13 +138,14 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger ```yaml scripts: - - script: plugin/my-plugin + - name: Notify to channel + script: plugin/my-plugin config: url: http://10.8.0.6:3030/notes token: $SECRET_joplin_api_token ``` -Canonical ref is `plugin/` (`.js` suffix is optional). +Optional `name` is the display title in the editor and graph (falls back to the script filename). Canonical ref is `plugin/` (`.js` suffix is optional). ## Do not diff --git a/README.md b/README.md index 5efc0b1..cc7e9ac 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,13 @@ pnpm dev - UI (dev): http://localhost:8500 - API: http://localhost:8700 -The first account created becomes **admin**. Later accounts are created from Users. +The first account created becomes **admin**. JerapahFlow is a **single-machine, single-user** automation app: the Users page is not linked in the nav (still available at `/users` if typed). New workflows, secrets, variables, and profiles default to the internal namespace `local`. + +Reset or create the admin login from the host: + +```bash +pnpm --dir packages/server reset-admin -- --username admin --password 'your-password' +``` ### Process modes @@ -36,13 +42,26 @@ The first account created becomes **admin**. Later accounts are created from Use | Kind | Name in YAML | Editable | Location | |---|---|---|---| | **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` | -| **Plugin** | `plugin/` | Yes | `plugins//` | +| **User plugin** | `plugin/` | Yes | `plugins//` | +| **Example source** | install → `plugin/` | After install | `examples/plugins//` | - App version is **`0.1.0`** (root `package.json`). Plugin manifests declare `jerapah: ">=0.1.0 <1.0.0"`. - Install plugins via admin API: zip (base64), HTTPS git URL, example, or fork a core script. - Install/update/uninstall sets **restart-needed** — drain-restart HTTP + workers under `pnpm dev:pm2`. -- Example plugin: `examples/plugins/get-current-time` → `plugin/get-current-time`. -- User plugins in this repo: `plugins/joplin-api` → `plugin/joplin-api`, `plugins/send-sms` → `plugin/send-sms`. +- Shipped example source (install from UI/API): `examples/plugins/get-current-time` → runtime `plugin/get-current-time`. +- `plugins/joplin-api` and `plugins/send-sms` are **personal/user plugins** appropriate for a fork — not shipped examples. See **AGENTS.md** for creating user plugins under `plugins//`. + +## Workflows (instance data vs examples) + +| Kind | Loaded by runner? | Location | +|---|---|---| +| **Live workflows** | Yes | `packages/server/data/workflows//` (gitignored) | +| **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow | + +- Live YAML is **instance data**, same as SQLite and secrets — not product source. New resources use owner `local` (owner remains in storage/URLs for a possible future multi-tenant mode; the UI hides it). +- On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`. +- New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save). +- Override the live store in tests with `JFLOW_WORKFLOWS_DIR`. ```bash # Smoke @@ -113,6 +132,7 @@ Desired state is stored in `packages/server/data/control-state.json` (generation | `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. | | `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. | | `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. | +| `JFLOW_WORKFLOWS_DIR` | `packages/server/data/workflows` | Live workflow YAML (instance data). | | `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. | | `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. | | `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. | diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index f8ef770..ece8164 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,3 +1,8 @@ +/** + * Production PM2 ecosystem (monolith runner). + * Use for deployed/single-process starts. For local multi-process Ops UI, use + * `pnpm dev:pm2` → packages/server/ecosystem.dev.cjs / control.js instead. + */ const fs = require("fs"); const path = require("path"); diff --git a/examples/workflows/comic-monkeyuser-to-ntfy.yaml b/examples/workflows/comic-monkeyuser-to-ntfy.yaml new file mode 100644 index 0000000..ac6c20f --- /dev/null +++ b/examples/workflows/comic-monkeyuser-to-ntfy.yaml @@ -0,0 +1,27 @@ +name: Comic - monkeyuser to ntfy +description: | + Scrape the latest MonkeyUser comic and send it to ntfy (requires $VAR_ntfy_channel). +scripts: + - script: fetch-html.js + config: + url: https://www.monkeyuser.com/ + outputVar: comic + selector: .comic img + jsonata: | + {"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]} + - script: jsonata.js + config: + expression: | + { + "title": data.comic.title, + "message": data.comic.title, + "attach": data.comic.url + } + - script: fetch-binary.js + - script: ntfy.js + config: + url: $VAR_ntfy_channel +triggers: + - type: HTTP + method: POST + path: /comic-monkeyuser diff --git a/examples/workflows/detect-example-changes.yaml b/examples/workflows/detect-example-changes.yaml new file mode 100644 index 0000000..e9d3dc6 --- /dev/null +++ b/examples/workflows/detect-example-changes.yaml @@ -0,0 +1,22 @@ +name: detect example.com changes +description: | + Fetch example.com, fingerprint the response, and report whether it changed + since the previous run. Uses detect-url-changes with a transform that builds + a human-readable message into ctx.data.message. +scripts: + - script: detect-url-changes.js + config: + url: https://example.com/ + outputVar: message + transform: | + data.hasChanges + ? "example.com changed (fingerprint " & data.fingerprint & ")" + : "example.com unchanged since " & data.fingerprintAt +triggers: + - type: HTTP + method: POST + path: /detect-example + - type: cron + schedule: "* * * * *" + onConsecutiveFailures: 3 +enabled: false diff --git a/package.json b/package.json index 8cc5674..27ab9b3 100644 --- a/package.json +++ b/package.json @@ -14,16 +14,9 @@ "start:api": "pnpm --filter @jerapah-flow/server start:api", "start:worker": "pnpm --filter @jerapah-flow/server start:worker", "start:control": "pnpm --filter @jerapah-flow/server start:control", - "migrate": "pnpm --filter @jerapah-flow/server migrate" + "migrate": "pnpm --filter @jerapah-flow/server migrate", + "test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test", + "lint": "pnpm --filter @jerapah-flow/web lint" }, - "packageManager": "pnpm@10.25.0", - "pnpm": { - "onlyBuiltDependencies": [ - "better-sqlite3", - "esbuild" - ] - }, - "dependencies": { - "rss-parser": "^3.13.0" - } + "packageManager": "pnpm@11.22.0" } diff --git a/packages/server/app-version.js b/packages/server/app-version.js index c81629f..ae46a37 100644 --- a/packages/server/app-version.js +++ b/packages/server/app-version.js @@ -1,6 +1,5 @@ import fs from "fs"; import path from "path"; -import { fileURLToPath } from "url"; import { SERVER_ROOT } from "./paths.js"; const ROOT_PKG = path.resolve(SERVER_ROOT, "../../package.json"); @@ -75,5 +74,3 @@ export function satisfiesRange(version, range) { } return true; } - -void fileURLToPath; diff --git a/packages/server/control-state.js b/packages/server/control-state.js index 52ab475..b16e1d2 100644 --- a/packages/server/control-state.js +++ b/packages/server/control-state.js @@ -17,7 +17,7 @@ const LOCK_PATH = path.join(DATA_DIR, "ops.lock"); */ /** @returns {ControlState} */ -export function defaultControlState() { +function defaultControlState() { return { http: "running", workers: 1, diff --git a/packages/server/ecosystem.dev.cjs b/packages/server/ecosystem.dev.cjs index c832870..bf8ce77 100644 --- a/packages/server/ecosystem.dev.cjs +++ b/packages/server/ecosystem.dev.cjs @@ -1,7 +1,8 @@ /** - * Dev ecosystem for `pnpm dev:pm2`. + * Local multi-process Ops UI ecosystem (`pnpm dev:pm2`). * Prefer starting children via control.js (desired state) rather than this file. * Kept as a reference / fallback: `pm2 start packages/server/ecosystem.dev.cjs` + * For production monolith, use root ecosystem.config.cjs instead. */ const path = require("path"); diff --git a/packages/server/fs-store.js b/packages/server/fs-store.js index 2f2c842..2e3e6d5 100644 --- a/packages/server/fs-store.js +++ b/packages/server/fs-store.js @@ -49,14 +49,8 @@ export function readScript(name) { return fs.readFileSync(filePath, "utf8"); } -export function writeScript(name, content) { - assertScriptName(name); - fs.mkdirSync(SCRIPTS_DIR, { recursive: true }); - fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8"); -} - /** - * Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`. + * Icon next to the script: `fetch-html.js` → `fetch-html.png`, `.jpg`, or `.jpeg`. * @returns {{ filePath: string, contentType: string } | null} */ export function resolveScriptIcon(name) { @@ -65,6 +59,7 @@ export function resolveScriptIcon(name) { for (const { ext, contentType } of [ { ext: "png", contentType: "image/png" }, { ext: "jpg", contentType: "image/jpeg" }, + { ext: "jpeg", contentType: "image/jpeg" }, ]) { const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`); if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { @@ -78,22 +73,6 @@ export function scriptHasIcon(name) { return resolveScriptIcon(name) != null; } -export function deleteScript(name) { - assertScriptName(name); - const filePath = path.join(SCRIPTS_DIR, name); - if (!fs.existsSync(filePath)) return false; - const icon = resolveScriptIcon(name); - fs.unlinkSync(filePath); - if (icon) { - try { - fs.unlinkSync(icon.filePath); - } catch { - // ignore missing icon - } - } - return true; -} - export function listOwners() { if (!fs.existsSync(WORKFLOWS_DIR)) return []; return fs @@ -132,6 +111,31 @@ export function readWorkflowYaml(owner, file) { return fs.readFileSync(filePath, "utf8"); } +/** + * Last content change time for a workflow YAML file. + * Uses birthtime (creation) when the file has not been modified since it was created. + * @returns {string | null} ISO timestamp + */ +export function workflowLastModifiedAt(owner, file) { + try { + assertOwner(owner); + assertWorkflowFile(file); + } catch { + return null; + } + const filePath = path.join(WORKFLOWS_DIR, owner, file); + try { + const st = fs.statSync(filePath); + const birthMs = Number.isFinite(st.birthtimeMs) && st.birthtimeMs > 0 ? st.birthtimeMs : null; + const mtimeMs = Number.isFinite(st.mtimeMs) && st.mtimeMs > 0 ? st.mtimeMs : null; + const unmodified = birthMs != null && (mtimeMs == null || mtimeMs <= birthMs + 1000); + const ms = unmodified ? birthMs : (mtimeMs ?? birthMs); + return ms != null ? new Date(ms).toISOString() : null; + } catch { + return null; + } +} + export function writeWorkflowYaml(owner, file, content) { assertOwner(owner); assertWorkflowFile(file); @@ -140,15 +144,6 @@ export function writeWorkflowYaml(owner, file, content) { fs.writeFileSync(path.join(ownerDir, file), content, "utf8"); } -export function deleteWorkflowYaml(owner, file) { - assertOwner(owner); - assertWorkflowFile(file); - const filePath = path.join(WORKFLOWS_DIR, owner, file); - if (!fs.existsSync(filePath)) return false; - fs.unlinkSync(filePath); - return true; -} - export function listOwnerYamlFiles(owner) { const ownerDir = path.join(WORKFLOWS_DIR, owner); if (!fs.existsSync(ownerDir)) return []; diff --git a/packages/server/http-auths-store.js b/packages/server/http-auths-store.js index 45e4661..69193e2 100644 --- a/packages/server/http-auths-store.js +++ b/packages/server/http-auths-store.js @@ -1,390 +1 @@ -import { randomUUID } from "node:crypto"; -import { db } from "./db.js"; -import { assertHttpStatus } from "./http-pages-store.js"; - -const MAX_NAME_LENGTH = 128; -const NAME_RE = /^[A-Za-z0-9._-]+$/; -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]); - -function nowIso() { - return new Date().toISOString(); -} - -/** - * @param {unknown} id - * @returns {string} - */ -export function assertAuthId(id) { - if (typeof id !== "string" || !UUID_RE.test(id)) { - const err = new Error("invalid auth id"); - err.statusCode = 400; - throw err; - } - return id.toLowerCase(); -} - -/** - * @param {unknown} name - * @returns {string} - */ -export function assertAuthName(name) { - if (typeof name !== "string" || !NAME_RE.test(name)) { - const err = new Error("invalid auth name"); - err.statusCode = 400; - throw err; - } - if (name.length > MAX_NAME_LENGTH) { - const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`); - err.statusCode = 400; - throw err; - } - return name; -} - -/** - * @param {unknown} type - * @returns {"bearer" | "basic" | "header"} - */ -export function assertAuthType(type) { - const t = String(type ?? ""); - if (!ALLOWED_TYPES.has(t)) { - const err = new Error('auth type must be "bearer", "basic", or "header"'); - err.statusCode = 400; - throw err; - } - return /** @type {"bearer" | "basic" | "header"} */ (t); -} - -/** - * Detect value source without exposing literal values. - * @param {unknown} value - * @returns {"literal" | "kv" | "secret" | "missing"} - */ -export function valueSourceKind(value) { - if (value == null) return "missing"; - if (typeof value === "string") return "literal"; - if (typeof value === "object" && !Array.isArray(value)) { - if ("secret" in value) return "secret"; - if ("kv" in value) return "kv"; - } - return "literal"; -} - -/** - * Redact config for API responses: replace literal strings with source markers. - * @param {Record} config - * @param {string} type - */ -export function publicConfig(config, type) { - /** @type {Record} */ - const out = {}; - if (type === "bearer") { - out.token = redactField(config.token); - } else if (type === "basic") { - out.user = redactField(config.user); - out.password = redactField(config.password); - } else if (type === "header") { - out.header = typeof config.header === "string" ? config.header : null; - out.value = redactField(config.value); - } - return out; -} - -/** - * @param {unknown} value - */ -function redactField(value) { - const kind = valueSourceKind(value); - if (kind === "missing") return { source: "missing" }; - if (kind === "kv") { - const v = /** @type {{ kv: string, namespace?: string }} */ (value); - return { - source: "kv", - kv: v.kv, - ...(v.namespace != null ? { namespace: v.namespace } : {}), - }; - } - if (kind === "secret") { - const v = /** @type {{ secret: string }} */ (value); - return { source: "secret", secret: v.secret }; - } - return { source: "literal", set: true }; -} - -/** - * Validate and normalize auth config for storage. - * @param {string} type - * @param {unknown} config - * @param {{ keepLiteralsFrom?: Record }} [opts] - */ -export function normalizeAuthConfig(type, config, opts = {}) { - const raw = config && typeof config === "object" && !Array.isArray(config) - ? /** @type {Record} */ (config) - : {}; - const keep = opts.keepLiteralsFrom ?? {}; - - if (type === "bearer") { - return { - token: normalizeCredentialField(raw.token, keep.token, "token"), - }; - } - if (type === "basic") { - return { - user: normalizeCredentialField(raw.user, keep.user, "user"), - password: normalizeCredentialField(raw.password, keep.password, "password", { - allowEmpty: true, - }), - }; - } - // header - if (typeof raw.header !== "string" || raw.header.length === 0) { - const err = new Error("header name must be a non-empty string"); - err.statusCode = 400; - throw err; - } - return { - header: raw.header, - value: normalizeCredentialField(raw.value, keep.value, "value"), - }; -} - -/** - * @param {unknown} value - * @param {unknown} previous - * @param {string} label - * @param {{ allowEmpty?: boolean }} [opts] - */ -function normalizeCredentialField(value, previous, label, opts = {}) { - // Explicit "keep previous literal" marker from UI when editing without re-entering - if ( - value && - typeof value === "object" && - !Array.isArray(value) && - /** @type {{ keep?: boolean }} */ (value).keep === true - ) { - if (typeof previous === "string") return previous; - if (previous && typeof previous === "object") return previous; - const err = new Error(`${label} was not previously set`); - err.statusCode = 400; - throw err; - } - - if (value == null || value === "") { - if (opts.allowEmpty && value === "") return ""; - // Allow empty password for basic - if (opts.allowEmpty && (value === "" || value == null)) { - if (typeof previous === "string") return previous; - return ""; - } - const err = new Error(`${label} is required`); - err.statusCode = 400; - throw err; - } - - if (typeof value === "string") return value; - - if (typeof value === "object" && !Array.isArray(value)) { - const v = /** @type {Record} */ (value); - if (typeof v.secret === "string" && v.secret.length > 0) { - return { secret: v.secret }; - } - if (typeof v.kv === "string" && v.kv.length > 0) { - /** @type {{ kv: string, namespace?: string }} */ - const out = { kv: v.kv }; - if (typeof v.namespace === "string" && v.namespace.length > 0) { - out.namespace = v.namespace; - } - return out; - } - } - - const err = new Error( - `${label} must be a string, { kv }, { secret }, or { keep: true }`, - ); - err.statusCode = 400; - throw err; -} - -function parseConfig(raw) { - if (typeof raw !== "string") return raw ?? {}; - try { - return JSON.parse(raw); - } catch { - return {}; - } -} - -function publicAuth(row, { includeConfig = true } = {}) { - const type = row.type; - const config = parseConfig(row.config); - return { - id: row.id, - name: row.name, - type, - ...(includeConfig ? { config: publicConfig(config, type) } : {}), - unauthorized_status: row.unauthorized_status ?? null, - unauthorized_response: row.unauthorized_response ?? null, - created_at: row.created_at, - updated_at: row.updated_at, - }; -} - -/** - * Internal: full config including literals (for runtime auth checks). - * @param {string} id - */ -export async function getHttpAuthInternal(id) { - const authId = assertAuthId(id); - const row = await db("http_auths").where({ id: authId }).first(); - if (!row) return null; - return { - id: row.id, - name: row.name, - type: row.type, - config: parseConfig(row.config), - unauthorized_status: row.unauthorized_status ?? null, - unauthorized_response: row.unauthorized_response ?? null, - }; -} - -/** - * Return only plaintext literal credential fields (not KV refs or encrypted secrets). - * @param {string} id - * @returns {Promise<{ id: string, name: string, type: string, literals: Record } | null>} - */ -export async function revealHttpAuthLiterals(id) { - const internal = await getHttpAuthInternal(id); - if (!internal) return null; - /** @type {Record} */ - const literals = {}; - const cfg = internal.config ?? {}; - for (const key of ["token", "user", "password", "value"]) { - const v = cfg[key]; - if (typeof v === "string") literals[key] = v; - } - return { - id: internal.id, - name: internal.name, - type: internal.type, - literals, - }; -} - -export async function listHttpAuths() { - const rows = await db("http_auths").select("*").orderBy("name", "asc"); - return rows.map((r) => publicAuth(r)); -} - -/** - * @param {string} id - */ -export async function getHttpAuthById(id) { - let authId; - try { - authId = assertAuthId(id); - } catch { - return null; - } - const row = await db("http_auths").where({ id: authId }).first(); - return row ? publicAuth(row) : null; -} - -/** - * @param {{ - * id?: string | null, - * name: string, - * type: string, - * config?: unknown, - * unauthorized_status?: number | null, - * unauthorized_response?: string | null, - * }} opts - */ -export async function upsertHttpAuth({ - id, - name, - type, - config, - unauthorized_status, - unauthorized_response, -}) { - const authName = assertAuthName(name); - const authType = assertAuthType(type); - - /** @type {Record | null} */ - let existing = null; - if (id != null && String(id).length > 0) { - const authId = assertAuthId(id); - existing = await db("http_auths").where({ id: authId }).first(); - if (!existing) { - const err = new Error("auth not found"); - err.statusCode = 404; - throw err; - } - } - - const nameClash = await db("http_auths").where({ name: authName }).first(); - if (nameClash && (!existing || nameClash.id !== existing.id)) { - const err = new Error(`auth name "${authName}" already exists`); - err.statusCode = 409; - throw err; - } - - const prevConfig = existing ? parseConfig(existing.config) : {}; - const normalized = normalizeAuthConfig(authType, config, { - keepLiteralsFrom: prevConfig, - }); - - let unauthStatus = null; - if (unauthorized_status != null && unauthorized_status !== "") { - unauthStatus = assertHttpStatus(unauthorized_status, 401); - } - let unauthResponse = null; - if ( - unauthorized_response != null && - String(unauthorized_response).length > 0 - ) { - unauthResponse = String(unauthorized_response); - } - - const now = nowIso(); - const configJson = JSON.stringify(normalized); - - if (existing) { - await db("http_auths") - .where({ id: existing.id }) - .update({ - name: authName, - type: authType, - config: configJson, - unauthorized_status: unauthStatus, - unauthorized_response: unauthResponse, - updated_at: now, - }); - return getHttpAuthById(/** @type {string} */ (existing.id)); - } - - const newId = randomUUID(); - await db("http_auths").insert({ - id: newId, - name: authName, - type: authType, - config: configJson, - unauthorized_status: unauthStatus, - unauthorized_response: unauthResponse, - created_at: now, - updated_at: now, - }); - return getHttpAuthById(newId); -} - -/** - * @param {string} id - * @returns {Promise} - */ -export async function deleteHttpAuth(id) { - const authId = assertAuthId(id); - const n = await db("http_auths").where({ id: authId }).del(); - return n > 0; -} +export * from "./src/stores/http-auths-store.js"; diff --git a/packages/server/json-preview.js b/packages/server/json-preview.js index c730632..ad68640 100644 --- a/packages/server/json-preview.js +++ b/packages/server/json-preview.js @@ -11,16 +11,21 @@ export function isBinary(value) { ); } +/** + * @param {Buffer | ArrayBufferView | ArrayBuffer} value + */ +export function toBuffer(value) { + if (Buffer.isBuffer(value)) return value; + if (value instanceof ArrayBuffer) return Buffer.from(value); + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); +} + /** * Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number). * @param {Buffer | ArrayBufferView | ArrayBuffer} value */ export function summarizeBinary(value) { - const buf = Buffer.isBuffer(value) - ? value - : value instanceof ArrayBuffer - ? Buffer.from(value) - : Buffer.from(value.buffer, value.byteOffset, value.byteLength); + const buf = toBuffer(value); const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES); return { type: "Buffer", @@ -30,6 +35,84 @@ export function summarizeBinary(value) { }; } +/** + * JSON-safe Buffer that can be revived (dry-run / Try chaining). + * @param {Buffer | ArrayBufferView | ArrayBuffer} value + */ +export function encodeBinary(value) { + const buf = toBuffer(value); + return { + type: "Buffer", + encoding: "base64", + data: buf.toString("base64"), + length: buf.length, + }; +} + +/** + * @param {unknown} value + */ +export function isWireBuffer(value) { + if (value == null || typeof value !== "object" || Array.isArray(value)) return false; + const obj = /** @type {{ type?: unknown, encoding?: unknown, data?: unknown }} */ (value); + if (obj.type !== "Buffer") return false; + if (obj.encoding === "base64" && typeof obj.data === "string") return true; + return Array.isArray(obj.data); +} + +/** + * Replace live Buffers with reconstructable JSON (for dry-run responses). + * Display still uses summarizeBinary / safeSerialize. + * @param {unknown} value + */ +export function encodeBinaryForWire(value) { + const seen = new WeakSet(); + /** @param {unknown} v */ + function walk(v) { + if (isBinary(v)) return encodeBinary(v); + if (typeof v === "bigint") return v.toString(); + if (v == null || typeof v !== "object") return v; + if (seen.has(v)) return "[Circular]"; + seen.add(v); + if (Array.isArray(v)) return v.map(walk); + /** @type {Record} */ + const out = {}; + for (const [k, val] of Object.entries(v)) out[k] = walk(val); + return out; + } + return walk(value); +} + +/** + * Revive `{ type: "Buffer", encoding: "base64", data }` or Node `{ type, data: number[] }`. + * Preview-only summaries (`preview` / `truncated`, no payload) are left as-is. + * @param {unknown} value + */ +export function reviveBinaryFromWire(value) { + const seen = new WeakSet(); + /** @param {unknown} v */ + function walk(v) { + if (v == null || typeof v !== "object") return v; + if (isWireBuffer(v)) { + const obj = /** @type {{ encoding?: unknown, data: string | number[] }} */ (v); + if (obj.encoding === "base64" && typeof obj.data === "string") { + return Buffer.from(obj.data, "base64"); + } + return Buffer.from(/** @type {number[]} */ (obj.data)); + } + if (seen.has(v)) return v; + seen.add(v); + if (Array.isArray(v)) { + for (let i = 0; i < v.length; i++) v[i] = walk(v[i]); + return v; + } + const obj = /** @type {Record} */ (v); + for (const k of Object.keys(obj)) obj[k] = walk(obj[k]); + return obj; + } + return walk(value); +} + /** * JSON.stringify replacer. Must be a real function so `this` is the holder: * Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer. diff --git a/packages/server/kv-store.js b/packages/server/kv-store.js index f1dfcb3..00302b3 100644 --- a/packages/server/kv-store.js +++ b/packages/server/kv-store.js @@ -1,399 +1 @@ -import { db } from "./db.js"; - -const MAX_KEY_LENGTH = 512; -const MAX_NAMESPACE_LENGTH = 512; -const MAX_VALUE_BYTES = 256 * 1024; -const DEFAULT_LIST_LIMIT = 100; -const MAX_LIST_LIMIT = 500; - -function nowIso() { - return new Date().toISOString(); -} - -/** - * @param {unknown} value - */ -function valuesEqual(a, b) { - if (a === b) return true; - if (a == null || b == null) return a === b; - try { - return JSON.stringify(a) === JSON.stringify(b); - } catch { - return false; - } -} - -/** - * @param {string} label - * @param {unknown} value - */ -function assertString(label, value) { - if (typeof value !== "string" || value.length === 0) { - throw new Error(`${label} must be a non-empty string`); - } -} - -/** - * @param {string} label - * @param {string} value - * @param {number} max - */ -function assertMaxLength(label, value, max) { - if (value.length > max) { - throw new Error(`${label} must be at most ${max} characters`); - } -} - -/** - * @param {string} namespace - */ -function assertNamespace(namespace) { - assertString("namespace", namespace); - assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH); -} - -/** - * @param {string} key - */ -function assertKey(key) { - assertString("key", key); - assertMaxLength("key", key, MAX_KEY_LENGTH); -} - -/** - * @param {unknown} value - * @returns {string} - */ -export function serializeKvValue(value) { - let json; - try { - json = JSON.stringify(value); - } catch { - throw new Error("value must be JSON-serializable"); - } - if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) { - throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`); - } - return json; -} - -/** - * @param {string | null | undefined} value - * @returns {unknown} - */ -function deserializeKvValue(value) { - if (value == null) return null; - try { - return JSON.parse(value); - } catch { - return value; - } -} - -/** - * @param {{ expires_at?: string | null }} row - */ -function isExpired(row) { - if (!row.expires_at) return false; - return Date.parse(row.expires_at) <= Date.now(); -} - -/** - * @param {string} namespace - * @param {string} key - * @returns {Promise} - */ -export async function kvGet(namespace, key) { - assertNamespace(namespace); - assertKey(key); - - const row = await db("script_state").where({ namespace, key }).first(); - if (!row) return null; - - if (isExpired(row)) { - await db("script_state").where({ namespace, key }).del(); - return null; - } - - return deserializeKvValue(row.value); -} - -/** - * @param {string} namespace - * @param {string} key - * @param {unknown} value - * @param {{ expiresAt?: string | Date | null }} [opts] - */ -export async function kvSet(namespace, key, value, opts = {}) { - assertNamespace(namespace); - assertKey(key); - - const json = serializeKvValue(value); - const updated_at = nowIso(); - let expires_at = null; - if (opts.expiresAt != null) { - expires_at = - opts.expiresAt instanceof Date - ? opts.expiresAt.toISOString() - : String(opts.expiresAt); - } - - await db("script_state") - .insert({ - namespace, - key, - value: json, - updated_at, - expires_at, - }) - .onConflict(["namespace", "key"]) - .merge({ - value: json, - updated_at, - expires_at, - }); -} - -/** - * @param {string} namespace - * @param {string} key - * @returns {Promise} - */ -export async function kvDelete(namespace, key) { - assertNamespace(namespace); - assertKey(key); - const deleted = await db("script_state").where({ namespace, key }).del(); - return deleted > 0; -} - -/** - * @param {string} namespace - * @param {string} key - * @param {unknown} expected - * @param {unknown} next - * @param {{ expiresAt?: string | Date | null }} [opts] - * @returns {Promise<{ ok: boolean, previous: unknown }>} - */ -export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) { - assertNamespace(namespace); - assertKey(key); - - return db.transaction(async (trx) => { - const row = await trx("script_state").where({ namespace, key }).first(); - - if (row && isExpired(row)) { - await trx("script_state").where({ namespace, key }).del(); - } - - const currentRow = - row && !isExpired(row) - ? row - : await trx("script_state").where({ namespace, key }).first(); - const previous = currentRow ? deserializeKvValue(currentRow.value) : null; - - if (!valuesEqual(previous, expected)) { - return { ok: false, previous }; - } - - const json = serializeKvValue(next); - const updated_at = nowIso(); - let expires_at = null; - if (opts.expiresAt != null) { - expires_at = - opts.expiresAt instanceof Date - ? opts.expiresAt.toISOString() - : String(opts.expiresAt); - } - - if (currentRow) { - await trx("script_state").where({ namespace, key }).update({ - value: json, - updated_at, - expires_at, - }); - } else { - await trx("script_state").insert({ - namespace, - key, - value: json, - updated_at, - expires_at, - }); - } - - return { ok: true, previous }; - }); -} - -/** - * @param {string} namespace - * @param {{ limit?: number }} [opts] - */ -export async function kvList(namespace, opts = {}) { - assertNamespace(namespace); - const limit = Math.min( - Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1), - MAX_LIST_LIMIT, - ); - - const rows = await db("script_state") - .where({ namespace }) - .orderBy("updated_at", "desc") - .limit(limit); - - const items = []; - for (const row of rows) { - if (isExpired(row)) { - await db("script_state").where({ namespace, key: row.key }).del(); - continue; - } - items.push({ - key: row.key, - value: deserializeKvValue(row.value), - updatedAt: row.updated_at, - expiresAt: row.expires_at ?? null, - }); - } - return items; -} - -function escapeLike(value) { - return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); -} - -async function pruneExpiredKv() { - await db("script_state") - .whereNotNull("expires_at") - .andWhere("expires_at", "<=", nowIso()) - .del(); -} - -/** - * @param {{ - * namespace?: string, - * q?: string, - * limit?: number, - * offset?: number, - * }} [opts] - */ -export async function kvQuery(opts = {}) { - await pruneExpiredKv(); - - const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100); - const offset = Math.max(Number(opts.offset) || 0, 0); - - let q = db("script_state"); - if (opts.namespace) { - assertNamespace(opts.namespace); - q = q.where({ namespace: opts.namespace }); - } - if (typeof opts.q === "string" && opts.q.length > 0) { - const like = `%${escapeLike(opts.q)}%`; - q = q.where(function likeSearch() { - this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw( - "value LIKE ? ESCAPE '\\'", - [like], - ); - }); - } - - const countRow = await q.clone().count({ count: "*" }).first(); - const total = Number(countRow?.count ?? 0); - - const rows = await q - .clone() - .orderBy("updated_at", "desc") - .orderBy("namespace", "asc") - .orderBy("key", "asc") - .limit(limit) - .offset(offset); - - return { - items: rows.map((row) => ({ - namespace: row.namespace, - key: row.key, - value: deserializeKvValue(row.value), - updatedAt: row.updated_at, - expiresAt: row.expires_at ?? null, - })), - total, - limit, - offset, - }; -} - -/** - * @returns {Promise} - */ -export async function kvNamespaces() { - await pruneExpiredKv(); - const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc"); - return rows.map((row) => row.namespace); -} - -/** - * @param {string} defaultNamespace - */ -export function createKvApi(defaultNamespace) { - assertNamespace(defaultNamespace); - - /** - * @param {{ namespace?: string }} [opts] - */ - function resolveNamespace(opts = {}) { - const namespace = opts.namespace ?? defaultNamespace; - assertNamespace(namespace); - return namespace; - } - - return { - namespace: defaultNamespace, - - /** - * @param {string} key - * @param {{ namespace?: string }} [opts] - */ - get(key, opts) { - return kvGet(resolveNamespace(opts), key); - }, - - /** - * @param {string} key - * @param {unknown} value - * @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts] - */ - set(key, value, opts) { - const { namespace, expiresAt } = opts ?? {}; - return kvSet(resolveNamespace(opts), key, value, { expiresAt }); - }, - - /** - * @param {string} key - * @param {{ namespace?: string }} [opts] - */ - delete(key, opts) { - return kvDelete(resolveNamespace(opts), key); - }, - - /** - * @param {string} key - * @param {unknown} expected - * @param {unknown} next - * @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts] - */ - compareAndSet(key, expected, next, opts) { - const { expiresAt } = opts ?? {}; - return kvCompareAndSet(resolveNamespace(opts), key, expected, next, { - expiresAt, - }); - }, - - /** - * @param {{ namespace?: string, limit?: number }} [opts] - */ - list(opts) { - const { namespace, limit } = opts ?? {}; - return kvList(resolveNamespace(opts), { limit }); - }, - }; -} +export * from "./src/stores/kv-store.js"; diff --git a/packages/server/migrations/20260821060000_profiles.js b/packages/server/migrations/20260821060000_profiles.js new file mode 100644 index 0000000..a9552ca --- /dev/null +++ b/packages/server/migrations/20260821060000_profiles.js @@ -0,0 +1,25 @@ +/** + * @param {import("knex").Knex} knex + */ +export async function up(knex) { + await knex.schema.createTable("profiles", (t) => { + t.text("id").primary(); + t.text("owner").notNullable(); + t.text("name").notNullable(); + t.text("script").notNullable(); + t.text("config").notNullable(); + t.text("description").notNullable().defaultTo(""); + t.text("created_at").notNullable(); + t.text("updated_at").notNullable(); + t.unique(["owner", "name"]); + }); + + await knex.schema.raw("CREATE INDEX profiles_owner_name_idx ON profiles (owner, name)"); +} + +/** + * @param {import("knex").Knex} knex + */ +export async function down(knex) { + await knex.schema.dropTableIfExists("profiles"); +} diff --git a/packages/server/package.json b/packages/server/package.json index 345b80d..ee83215 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,9 +13,13 @@ "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:workflow-history": "node test/workflow-history-smoke.js" + "test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js", + "reset-admin": "node reset-admin.js", + "test:profiles": "node test/profiles-smoke.js", + "test:set-dry-run": "node test/set-dry-run-smoke.js" }, "dependencies": { + "@jerapah-flow/shared": "workspace:*", "@aws-sdk/client-s3": "^3.1111.0", "@aws-sdk/s3-request-presigner": "^3.1111.0", "@fastify/cookie": "^11.0.2", @@ -38,6 +42,7 @@ "pino": "^10.3.1", "pino-roll": "^4.0.0", "pm2": "^6.0.13", + "rss-parser": "^3.13.0", "ssh2-sftp-client": "^12.1.1", "webdav": "^5.10.0", "yaml": "^2.9.0" diff --git a/packages/server/paths.js b/packages/server/paths.js index f591164..3e8fd9c 100644 --- a/packages/server/paths.js +++ b/packages/server/paths.js @@ -3,8 +3,12 @@ import { fileURLToPath } from "url"; export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url)); export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts"); -export const WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows"); export const DATA_DIR = path.join(SERVER_ROOT, "data"); +/** Live instance workflows (not shipped in git). Override for tests. */ +export const WORKFLOWS_DIR = + process.env.JFLOW_WORKFLOWS_DIR ?? path.join(DATA_DIR, "workflows"); +/** Pre-0.1 layout; used only for one-shot migrate into WORKFLOWS_DIR. */ +export const LEGACY_WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows"); /** User plugins (repo-root /plugins, outside the pnpm workspace). */ export const PLUGINS_DIR = process.env.JFLOW_PLUGINS_DIR ?? @@ -14,5 +18,10 @@ export const EXAMPLE_PLUGINS_DIR = path.resolve( SERVER_ROOT, "../../examples/plugins", ); +/** Example workflow YAML presets (not loaded by the runner). */ +export const EXAMPLE_WORKFLOWS_DIR = path.resolve( + SERVER_ROOT, + "../../examples/workflows", +); export const LOGS_DIR = path.join(SERVER_ROOT, "logs"); export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist"); diff --git a/packages/server/plugin-install.js b/packages/server/plugin-install.js index 06c1f05..2a49484 100644 --- a/packages/server/plugin-install.js +++ b/packages/server/plugin-install.js @@ -8,7 +8,6 @@ import { installPluginFromDirectory, pluginDir, } from "./plugin-store.js"; -import { readManifestFile } from "./plugin-manifest.js"; const execFileAsync = promisify(execFile); @@ -214,5 +213,3 @@ export function ensureExampleInstalled(exampleId) { }); return { ...installed, already: false }; } - -void readManifestFile; diff --git a/packages/server/profile-config.js b/packages/server/profile-config.js new file mode 100644 index 0000000..c4776ef --- /dev/null +++ b/packages/server/profile-config.js @@ -0,0 +1 @@ +export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared"; diff --git a/packages/server/profiles-store.js b/packages/server/profiles-store.js new file mode 100644 index 0000000..60e282b --- /dev/null +++ b/packages/server/profiles-store.js @@ -0,0 +1 @@ +export * from "./src/stores/profiles-store.js"; diff --git a/packages/server/registry.js b/packages/server/registry.js index 008ff04..c538a1e 100644 --- a/packages/server/registry.js +++ b/packages/server/registry.js @@ -23,14 +23,14 @@ import { storedEnvelope, } from "./step-result.js"; import * as fsStore from "./fs-store.js"; -import { - checkAnyHttpAuth, - resolveAuthMechanisms, - resolveUnauthorizedSpec, - sendHttpPageOrJson, - sendSuccessPage, -} from "./http-trigger-auth.js"; import { resolveConfigRefs } from "./config-refs.js"; +import { hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared"; +import { + createHttpTriggerHandler, + ensureHttpWildcardRoute, + rebuildHttpRoutes, +} from "./workflow-http-routes.js"; +import { getProfilePlain } from "./profiles-store.js"; import { buildFailureAlertData, resolveFailureTriggerConfig, @@ -46,17 +46,6 @@ import { publishReload } from "./control-bus.js"; */ const MAX_WORKFLOW_TRIGGER_DEPTH = 8; -const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]; - -/** - * @param {unknown} workflow - */ -function hasWorkflowTrigger(workflow) { - if (!workflow || typeof workflow !== "object") return false; - const triggers = /** @type {{ triggers?: Array<{ type?: string }> }} */ (workflow) - .triggers; - return (triggers ?? []).some((t) => t?.type === "workflow"); -} /** * @param {import("fastify").FastifyInstance} server @@ -80,7 +69,14 @@ export function createRegistry(server, opts = {}) { let pruneTask = null; /** @type {Map} */ const httpRoutes = new Map(); - let httpDispatcherRegistered = false; + const httpDispatcherState = { registered: false }; + const dispatchHttpTrigger = createHttpTriggerHandler({ + httpRoutes, + workflows, + namespacedPath, + enqueueWorkflow: (...args) => enqueueWorkflow(...args), + }); + /** * Resolve a same-owner workflow that opts in with `type: workflow`. @@ -186,118 +182,10 @@ export function createRegistry(server, opts = {}) { * Fastify route once so path/method changes apply on reregister without restart. */ function registerHttpTriggers() { - httpRoutes.clear(); - - for (const [key, { owner, workflow }] of workflows) { - if (workflow.enabled === false) { - log.debug(`Skipping disabled workflow HTTP triggers (${key})`); - continue; - } - - for (const trigger of workflow.triggers ?? []) { - if (trigger.type !== "HTTP") continue; - - const method = String(trigger.method ?? "POST").toUpperCase(); - const url = namespacedPath(owner, trigger.path); - const routeKey = `${method} ${url}`; - - if (httpRoutes.has(routeKey)) { - log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`); - continue; - } - httpRoutes.set(routeKey, { key, owner, trigger }); - log.debug(`Mapped HTTP trigger ${routeKey} (${key})`); - } - } - - if (!httpDispatcherRegistered) { - httpDispatcherRegistered = true; - server.route({ - method: HTTP_METHODS, - url: "/u/*", - handler: dispatchHttpTrigger, - }); - log.debug("Registered HTTP trigger wildcard dispatcher /u/*"); - } - } - - /** - * @param {import("fastify").FastifyRequest} req - * @param {import("fastify").FastifyReply} reply - */ - async function dispatchHttpTrigger(req, reply) { - const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? ""; - const url = `/u/${String(wildcard).replace(/^\/+/, "")}`; - const method = String(req.method ?? "GET").toUpperCase(); - const routeKey = `${method} ${url}`; - const mapped = httpRoutes.get(routeKey); - - if (!mapped) { - return reply.code(404).send({ error: "not found" }); - } - - const entry = workflows.get(mapped.key); - if (!entry || entry.workflow?.enabled === false) { - return reply.code(404).send({ error: "workflow disabled" }); - } - - // Prefer live trigger from current workflow YAML (auth/response edits) - const liveTrigger = - (entry.workflow.triggers ?? []).find((t) => { - if (t?.type !== "HTTP") return false; - const m = String(t.method ?? "POST").toUpperCase(); - const p = namespacedPath(entry.owner, t.path); - return m === method && p === url; - }) ?? mapped.trigger; - - if ( - liveTrigger.auth != null && - liveTrigger.auth !== false && - !(Array.isArray(liveTrigger.auth) && liveTrigger.auth.length === 0) - ) { - const mechanisms = await resolveAuthMechanisms(liveTrigger.auth); - if (mechanisms.length === 0) { - const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null); - return sendHttpPageOrJson(reply, status, pageName, { - error: "unauthorized", - }); - } - const ok = await checkAnyHttpAuth(req, mechanisms, { - owner: entry.owner, - workflowKey: mapped.key, - }); - if (!ok) { - const { status, pageName } = resolveUnauthorizedSpec( - liveTrigger, - mechanisms[0], - ); - return sendHttpPageOrJson(reply, status, pageName, { - error: "unauthorized", - }); - } - } - - const result = await enqueueWorkflow( - mapped.key, - { data: req.body }, - { type: "http", detail: `${method} ${url}` }, - ); - if (result.status === "failed") { - return reply.code(result.runId ? 500 : 404).send({ - runId: result.runId, - status: result.status, - error: result.error, - }); - } - - const defaultBody = { - runId: result.runId, - status: result.status, - }; - if (typeof liveTrigger.response === "string" && liveTrigger.response) { - return sendSuccessPage(reply, liveTrigger.response, defaultBody); - } - return reply.code(202).send(defaultBody); + rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log }); + ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, { + log, + }); } function registerCronTriggers() { @@ -595,8 +483,21 @@ export function createRegistry(server, opts = {}) { owner, depth, ) { - const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script; - const unresolvedConfig = parsed.config; + let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script; + let unresolvedConfig = parsed.config; + if (parsed.kind === "script" && parsed.profile) { + const profile = await getProfilePlain(owner, parsed.profile); + if (!profile) { + throw new Error(`profile "${parsed.profile}" not found`); + } + if (parsed.script && parsed.script !== profile.script) { + throw new Error( + `step script "${parsed.script}" does not match profile "${parsed.profile}" script "${profile.script}"`, + ); + } + script = profile.script; + unresolvedConfig = mergeProfileConfig(profile.config, parsed.config); + } const incomingContext = normalizeContext(ctx.context); const step = await store.startStep({ runId, @@ -956,7 +857,10 @@ export function createRegistry(server, opts = {}) { for (const raw of workflow.scripts ?? []) { try { const parsed = parseScriptStep(raw); - if (parsed.kind === "script") refs.add(parsed.script); + if (parsed.kind === "script") { + if (parsed.script) refs.add(parsed.script); + if (parsed.profile) refs.add(`profile:${parsed.profile}`); + } } catch { // skip invalid steps } diff --git a/packages/server/reset-admin.js b/packages/server/reset-admin.js new file mode 100644 index 0000000..49fc475 --- /dev/null +++ b/packages/server/reset-admin.js @@ -0,0 +1,105 @@ +/** + * Reset (or create) the admin username and password. + * + * Usage: + * pnpm --dir packages/server reset-admin -- --username admin --password 'your-password' + * + * Uses JFLOW_DB_PATH like the app. Never prints the password. + */ +import bcrypt from "bcryptjs"; +import { db, migrate } from "./db.js"; +import * as store from "./store.js"; +import { validateCredentials } from "./src/api/auth.js"; + +function parseArgs(argv) { + /** @type {{ username?: string, password?: string }} */ + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--username" || arg === "-u") { + out.username = argv[++i]; + continue; + } + if (arg === "--password" || arg === "-p") { + out.password = argv[++i]; + continue; + } + if (arg === "--help" || arg === "-h") { + out.help = true; + } + } + return out; +} + +function usage() { + console.log(`Usage: + pnpm --dir packages/server reset-admin -- --username --password + +Creates an admin if none exist; otherwise updates the oldest admin's +username and password. Credentials must match login rules +(username 3-32 [A-Za-z0-9_], password at least 8 characters).`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + usage(); + process.exit(0); + } + + const username = typeof args.username === "string" ? args.username.trim() : ""; + const password = typeof args.password === "string" ? args.password : ""; + if (!username || !password) { + usage(); + process.exit(1); + } + + const credErr = validateCredentials(username, password); + if (credErr) { + console.error(credErr); + process.exit(1); + } + + await migrate(); + + const passwordHash = await bcrypt.hash(password, 10); + const admins = await db("users") + .where({ role: "admin" }) + .orderBy("created_at", "asc") + .select("id", "username"); + + if (admins.length === 0) { + const user = await store.createUser({ + username, + passwordHash, + role: "admin", + }); + console.log(`Created admin user "${user.username}" (${user.id})`); + return; + } + + const admin = admins[0]; + const taken = await store.getUserAuthByUsername(username); + if (taken && taken.id !== admin.id) { + console.error(`username "${username}" is already taken by another user`); + process.exit(1); + } + + const updated = await store.updateUser(admin.id, { + username, + passwordHash, + role: "admin", + }); + console.log( + `Updated admin "${admin.username}" → "${updated.username}" (${updated.id})`, + ); +} + +try { + await main(); +} catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; +} finally { + await db.destroy(); +} diff --git a/packages/server/script-sandbox.js b/packages/server/script-sandbox.js index 7996f7a..6f6ea86 100644 --- a/packages/server/script-sandbox.js +++ b/packages/server/script-sandbox.js @@ -486,7 +486,7 @@ const inspectLog = pino({ level: "silent" }); * @param {unknown} fn * @returns {{ meta: Record | null, metaError: string | null }} */ -export function extractScriptMeta(fn) { +function extractScriptMeta(fn) { if (typeof fn !== "function") { return { meta: null, metaError: "default export must be a function" }; } diff --git a/packages/server/scripts/detect-url-changes.js b/packages/server/scripts/detect-url-changes.js index a6e2059..47ad1e6 100644 --- a/packages/server/scripts/detect-url-changes.js +++ b/packages/server/scripts/detect-url-changes.js @@ -1,9 +1,17 @@ import jsonata from "jsonata"; -function ensureDataObject(ctx) { - if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) { - ctx.data = {}; +function passContext(ctx) { + if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) { + return { ...ctx.context }; } + return {}; +} + +function mergeData(data) { + if (data != null && typeof data === "object" && !Array.isArray(data)) { + return { ...data }; + } + return {}; } const ALLOWED_METHODS = new Set([ @@ -35,6 +43,19 @@ function previewValue(value) { return { preview: `${json.slice(0, 500)}...`, truncated: true }; } +/** + * Eval context for fingerprint/transform JSONata (reads `data.*`). + * @param {unknown} ctx + * @param {Record} data + */ +function evalCtx(ctx, data) { + return { + data, + context: passContext(ctx), + config: ctx?.config ?? {}, + }; +} + async function detectUrlChanges(ctx) { const url = ctx.config?.url; if (typeof url !== "string" || url.length === 0) { @@ -63,7 +84,7 @@ async function detectUrlChanges(ctx) { ); } - ensureDataObject(ctx); + const data = mergeData(ctx.data); log.info({ url, method, key }, "detect-url-changes: fetching url"); const response = await $axios.request({ @@ -77,28 +98,31 @@ async function detectUrlChanges(ctx) { "detect-url-changes: fetch complete", ); - ctx.data.httpResponse = response.data; + data.httpResponse = response.data; - let fingerprintSource = ctx.data.httpResponse; + let fingerprintSource = data.httpResponse; if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) { log.info( { jsonata: fingerprintExpr }, "detect-url-changes: evaluating fingerprint jsonata", ); - fingerprintSource = await jsonata(fingerprintExpr).evaluate(ctx); + fingerprintSource = await jsonata(fingerprintExpr).evaluate(evalCtx(ctx, data)); } const result = await $fingerprint.claim(key, fingerprintSource, { maxAge: ctx.config?.maxAge, }); - ctx.data.hasChanges = result.changed; - ctx.data.fingerprint = result.hash; - ctx.data.fingerprintChanged = result.changed; - ctx.data.fingerprintPrevious = result.previous; - ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt; - ctx.data.fingerprintAge = result.ageMs; - ctx.data.fingerprintExpired = result.expired; + const extra = { + hasChanges: result.changed, + fingerprint: result.hash, + fingerprintChanged: result.changed, + fingerprintPrevious: result.previous, + fingerprintAt: result.changed ? result.at : result.previousAt, + fingerprintAge: result.ageMs, + fingerprintExpired: result.expired, + }; + Object.assign(data, extra); log.info( { @@ -116,28 +140,35 @@ async function detectUrlChanges(ctx) { { outputVar, jsonata: transformExpr }, "detect-url-changes: evaluating transform jsonata", ); - const transformed = await jsonata(transformExpr).evaluate(ctx); - ctx.data[outputVar] = transformed; + const transformed = await jsonata(transformExpr).evaluate(evalCtx(ctx, data)); + data[outputVar] = transformed; log.info( { outputVar, value: previewValue(transformed) }, "detect-url-changes: saved transform result", ); } else { - ctx.data[outputVar] = ctx.data.httpResponse; + data[outputVar] = data.httpResponse; log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar"); } } + /** @type {{ output: Record, context: Record, skipRemaining?: true }} */ + const envelope = { + output: data, + context: { ...passContext(ctx), ...extra }, + }; if (skipRemainingWhenUnchanged && !result.changed) { - ctx.skipRemaining = true; + envelope.skipRemaining = true; } - - return ctx; + return envelope; } detectUrlChanges.meta = { description: "Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run", + previewConfigKey: "url", + tags: ["HTTP"], + reads: "ctx", config: { url: { type: "string", required: true, description: "URL to fetch" }, method: { @@ -203,6 +234,15 @@ detectUrlChanges.meta = { fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" }, fingerprintExpired: { type: "boolean" }, }, + context: { + hasChanges: { type: "boolean" }, + fingerprint: { type: "string" }, + fingerprintChanged: { type: "boolean" }, + fingerprintPrevious: { type: "string", required: false }, + fingerprintAt: { type: "string", required: false }, + fingerprintAge: { type: "number", required: false }, + fingerprintExpired: { type: "boolean" }, + }, example: { data: {}, config: { diff --git a/packages/server/scripts/fetch-binary.png b/packages/server/scripts/fetch-binary.png new file mode 100644 index 0000000..3ab9b42 Binary files /dev/null and b/packages/server/scripts/fetch-binary.png differ diff --git a/packages/server/scripts/fetch-html.png b/packages/server/scripts/fetch-html.png new file mode 100644 index 0000000..3ab9b42 Binary files /dev/null and b/packages/server/scripts/fetch-html.png differ diff --git a/packages/server/scripts/fetch-http.png b/packages/server/scripts/fetch-http.png new file mode 100644 index 0000000..3ab9b42 Binary files /dev/null and b/packages/server/scripts/fetch-http.png differ diff --git a/packages/server/scripts/fetch-rss-feed.png b/packages/server/scripts/fetch-rss-feed.png new file mode 100644 index 0000000..b5a4d40 Binary files /dev/null and b/packages/server/scripts/fetch-rss-feed.png differ diff --git a/packages/server/scripts/jsonata.png b/packages/server/scripts/jsonata.png new file mode 100644 index 0000000..9715ca3 Binary files /dev/null and b/packages/server/scripts/jsonata.png differ diff --git a/packages/server/scripts/ntfy.png b/packages/server/scripts/ntfy.png new file mode 100644 index 0000000..c1c653e Binary files /dev/null and b/packages/server/scripts/ntfy.png differ diff --git a/packages/server/secrets-store.js b/packages/server/secrets-store.js index a23e949..5376b42 100644 --- a/packages/server/secrets-store.js +++ b/packages/server/secrets-store.js @@ -1,144 +1 @@ -import { randomUUID } from "node:crypto"; -import { db } from "./db.js"; -import { assertOwner } from "./fs-store.js"; -import { decryptSecret, encryptSecret } from "./secrets.js"; -import { registerPlaintext } from "./secret-value.js"; - -const MAX_NAME_LENGTH = 128; -const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/; - -function nowIso() { - return new Date().toISOString(); -} - -/** - * @param {unknown} name - * @returns {string} - */ -export function assertSecretName(name) { - if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) { - const err = new Error("invalid secret name"); - err.statusCode = 400; - throw err; - } - if (name.length > MAX_NAME_LENGTH) { - const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`); - err.statusCode = 400; - throw err; - } - return name; -} - -function publicSecret(row) { - return { - id: row.id, - owner: row.owner, - name: row.name, - created_at: row.created_at, - updated_at: row.updated_at, - }; -} - -/** - * @param {{ owner?: string }} [filters] - */ -export async function listSecrets(filters = {}) { - let q = db("secrets") - .select("id", "owner", "name", "created_at", "updated_at") - .orderBy("owner", "asc") - .orderBy("name", "asc"); - if (filters.owner) { - q = q.where("owner", assertOwner(filters.owner)); - } - return q; -} - -/** - * @param {string} id - */ -export async function getSecretById(id) { - const row = await db("secrets") - .select("id", "owner", "name", "created_at", "updated_at") - .where({ id }) - .first(); - return row ?? null; -} - -/** - * @param {{ owner: string, name: string, value: string }} opts - */ -export async function upsertSecret({ owner, name, value }) { - if (typeof value !== "string" || value.length === 0) { - const err = new Error("value is required"); - err.statusCode = 400; - throw err; - } - const ownerName = assertOwner(owner); - const secretName = assertSecretName(name); - registerPlaintext(value); - const { ciphertext, iv, authTag } = encryptSecret(value); - const now = nowIso(); - const existing = await db("secrets") - .where({ owner: ownerName, name: secretName }) - .first(); - - if (existing) { - await db("secrets") - .where({ id: existing.id }) - .update({ - ciphertext, - iv, - auth_tag: authTag, - updated_at: now, - }); - return getSecretById(existing.id); - } - - const id = randomUUID(); - await db("secrets").insert({ - id, - owner: ownerName, - name: secretName, - ciphertext, - iv, - auth_tag: authTag, - created_at: now, - updated_at: now, - }); - return getSecretById(id); -} - -/** - * @param {string} id - * @returns {Promise} - */ -export async function deleteSecret(id) { - const n = await db("secrets").where({ id }).del(); - return n > 0; -} - -/** - * Decrypt a named secret for an owner. Returns null if missing. - * @param {string} owner - * @param {string} name - * @returns {Promise} - */ -export async function getSecretPlaintext(owner, name) { - const ownerName = assertOwner(owner); - const secretName = assertSecretName(name); - const row = await db("secrets") - .where({ owner: ownerName, name: secretName }) - .first(); - if (!row) return null; - try { - const plaintext = decryptSecret({ - ciphertext: row.ciphertext, - iv: row.iv, - authTag: row.auth_tag, - }); - registerPlaintext(plaintext); - return plaintext; - } catch { - throw new Error(`failed to decrypt secret "${secretName}"`); - } -} +export * from "./src/stores/secrets-store.js"; diff --git a/packages/server/secrets.js b/packages/server/secrets.js index 4a8492d..b9239ae 100644 --- a/packages/server/secrets.js +++ b/packages/server/secrets.js @@ -25,7 +25,7 @@ let cachedKey = null; /** * @returns {Buffer} */ -export function getMasterKey() { +function getMasterKey() { if (cachedKey) return cachedKey; const raw = resolveSecretsKeyMaterial(); cachedKey = /^[0-9a-fA-F]{64}$/.test(raw) diff --git a/packages/server/src/api/kv.js b/packages/server/src/api/kv.js index 3d7fe73..7629548 100644 --- a/packages/server/src/api/kv.js +++ b/packages/server/src/api/kv.js @@ -1,4 +1,4 @@ -import { kvNamespaces, kvQuery } from "../../kv-store.js"; +import { kvDelete, kvNamespaces, kvQuery } from "../../kv-store.js"; /** * @param {import("fastify").FastifyInstance} fastify @@ -19,4 +19,17 @@ export default async function kvPlugin(fastify) { offset: Number.isFinite(offset) ? offset : undefined, }); }); + + fastify.delete("/kv", async (req, reply) => { + const q = /** @type {Record} */ (req.query ?? {}); + try { + const deleted = await kvDelete(String(q.namespace ?? ""), String(q.key ?? "")); + if (!deleted) { + return reply.code(404).send({ error: "kv entry not found" }); + } + return { ok: true }; + } catch (err) { + return reply.code(400).send({ error: err.message }); + } + }); } diff --git a/packages/server/src/api/profiles.js b/packages/server/src/api/profiles.js new file mode 100644 index 0000000..2a5c1ce --- /dev/null +++ b/packages/server/src/api/profiles.js @@ -0,0 +1,82 @@ +import * as fsStore from "../../fs-store.js"; +import { + assertProfileName, + deleteProfile, + getProfileById, + getProfilePlain, + listProfileUsages, + listProfiles, + upsertProfile, +} from "../../profiles-store.js"; + +/** + * @param {import("fastify").FastifyInstance} fastify + */ +export default async function profilesPlugin(fastify) { + fastify.get("/profiles", async (req, reply) => { + const q = /** @type {{ owner?: string }} */ (req.query ?? {}); + try { + const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined; + const profiles = await listProfiles({ owner }); + const withUsage = profiles.map((profile) => ({ + ...profile, + usageCount: listProfileUsages(profile.owner, profile.name).length, + })); + return { profiles: withUsage }; + } catch (err) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } + }); + + fastify.get("/profiles/:id/usage", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + const existing = await getProfileById(id); + if (!existing) { + return reply.code(404).send({ error: "profile not found" }); + } + return { usages: listProfileUsages(existing.owner, existing.name) }; + }); + + fastify.put("/profiles", async (req, reply) => { + const body = /** @type {{ + owner?: string, + name?: string, + script?: unknown, + config?: unknown, + description?: unknown, + }} */ (req.body ?? {}); + try { + fsStore.assertOwner(String(body.owner ?? "")); + assertProfileName(String(body.name ?? "")); + const profile = await upsertProfile({ + owner: String(body.owner), + name: String(body.name), + script: body.script, + config: body.config, + description: body.description, + }); + return reply.send({ profile }); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + }); + + fastify.delete("/profiles/:id", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + const q = /** @type {{ force?: string }} */ (req.query ?? {}); + const existing = await getProfileById(id); + if (!existing) { + return reply.code(404).send({ error: "profile not found" }); + } + const usages = listProfileUsages(existing.owner, existing.name); + const force = q.force === "1" || q.force === "true"; + if (usages.length > 0 && !force) { + return reply.code(409).send({ + error: "profile is used by workflows", + usages, + }); + } + await deleteProfile(id); + return { ok: true, forced: force && usages.length > 0, usages }; + }); +} diff --git a/packages/server/src/api/run-query.js b/packages/server/src/api/run-query.js new file mode 100644 index 0000000..6b50237 --- /dev/null +++ b/packages/server/src/api/run-query.js @@ -0,0 +1,28 @@ +import * as store from "../../store.js"; + +/** + * @param {Record} q + */ +export function parseRunQueryParams(q) { + const limit = q.limit != null ? Number(q.limit) : undefined; + const offset = q.offset != null ? Number(q.offset) : undefined; + return { + owner: q.owner || undefined, + workflow: q.workflow || undefined, + status: q.status || undefined, + trigger_type: q.trigger || undefined, + after: q.after || undefined, + before: q.before || undefined, + limit: Number.isFinite(limit) ? limit : undefined, + offset: Number.isFinite(offset) ? offset : undefined, + sort: q.sort || undefined, + order: q.order || undefined, + }; +} + +/** + * @param {Record} q + */ +export async function queryRunsFromRequest(q) { + return store.queryRuns(parseRunQueryParams(q)); +} diff --git a/packages/server/src/api/runs.js b/packages/server/src/api/runs.js index 1aeb8ba..0b59107 100644 --- a/packages/server/src/api/runs.js +++ b/packages/server/src/api/runs.js @@ -1,20 +1,12 @@ import * as store from "../../store.js"; +import { queryRunsFromRequest } from "./run-query.js"; /** * @param {import("fastify").FastifyInstance} fastify */ export default async function runsPlugin(fastify) { fastify.get("/runs", async (req) => { - const q = /** @type {Record} */ (req.query ?? {}); - const limit = q.limit ? Number(q.limit) : undefined; - const runs = await store.listRuns({ - owner: q.owner, - workflow: q.workflow, - status: q.status, - limit: Number.isFinite(limit) ? limit : undefined, - before: q.before, - }); - return { runs }; + return queryRunsFromRequest(/** @type {Record} */ (req.query ?? {})); }); fastify.get("/consecutive-failures", async (req) => { diff --git a/packages/server/src/api/scripts.js b/packages/server/src/api/scripts.js index 8858255..d851f5c 100644 --- a/packages/server/src/api/scripts.js +++ b/packages/server/src/api/scripts.js @@ -8,7 +8,6 @@ import { import * as fsStore from "../../fs-store.js"; import { forkCoreScript, - getInstalledPlugin, listCoreScriptNames, listInstalledPlugins, resolveScriptRef, @@ -22,11 +21,16 @@ import { installPluginFromZipBuffer, } from "../../plugin-install.js"; import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js"; +import { + encodeBinaryForWire, + reviveBinaryFromWire, +} from "../../json-preview.js"; import { normalizeStepResult } from "../../step-result.js"; import { resolveConfigRefs } from "../../config-refs.js"; import { getAppVersion } from "../../app-version.js"; import { EXAMPLE_PLUGINS_DIR } from "../../paths.js"; import { pluginScriptRef } from "../../plugin-manifest.js"; +import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js"; /** * @param {{ referencedScripts: () => Set }} registry @@ -250,14 +254,11 @@ export default function scriptsPluginFactory(registry) { const rawName = decodeURIComponent( /** @type {{ name: string }} */ (req.params).name, ); - const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ ( + const body = /** @type {{ content?: string, expression?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ ( req.body ?? {} ); - if (typeof body.content !== "string") { - return reply.code(400).send({ error: "content is required" }); - } - let owner = "default"; + let owner = "local"; if (body.owner != null && body.owner !== "") { try { owner = fsStore.assertOwner(String(body.owner)); @@ -266,12 +267,92 @@ export default function scriptsPluginFactory(registry) { } } - const incomingContext = + const incomingContext = reviveBinaryFromWire( body.context != null && - typeof body.context === "object" && - !Array.isArray(body.context) + typeof body.context === "object" && + !Array.isArray(body.context) ? body.context - : {}; + : {}, + ); + const incomingData = reviveBinaryFromWire(body.data ?? null); + + const { log, logs } = createDryRunLogger(); + const started = Date.now(); + + // Set steps are inline JSONata (no script file). Match registry runCompiledStep. + if (rawName === SET_STEP_SCRIPT || rawName === `${SET_STEP_SCRIPT}.js`) { + const configObj = + body.config != null && + typeof body.config === "object" && + !Array.isArray(body.config) + ? /** @type {Record} */ (body.config) + : null; + const expression = + typeof body.expression === "string" + ? body.expression + : typeof configObj?.expression === "string" + ? configObj.expression + : null; + if (expression == null || !expression.trim()) { + return reply.code(400).send({ error: "expression is required" }); + } + + try { + const config = await resolveConfigRefs( + { ...(configObj ?? {}), expression }, + { + owner, + workflowKey: "dry-run", + context: incomingContext, + }, + ); + const ctx = { + data: incomingData, + context: incomingContext, + config, + }; + const value = await evaluateJsonata(expression, ctx); + const result = normalizeStepResult( + { + output: value, + context: incomingContext, + skipRemaining: false, + }, + incomingContext, + SET_STEP_SCRIPT, + ); + log.info({ expression }, "set: dry-run evaluated"); + return { + status: "success", + output: safeSerialize(result.output), + context: safeSerialize(result.context), + wireOutput: encodeBinaryForWire(result.output), + wireContext: encodeBinaryForWire(result.context), + skipRemaining: result.skipRemaining, + error: null, + logs, + durationMs: Date.now() - started, + meta: null, + metaError: null, + }; + } catch (err) { + return { + status: "failed", + output: null, + context: null, + skipRemaining: false, + error: err instanceof Error ? err.message : String(err), + logs, + durationMs: Date.now() - started, + meta: null, + metaError: null, + }; + } + } + + if (typeof body.content !== "string") { + return reply.code(400).send({ error: "content is required" }); + } const resolved = resolveScriptRef(rawName); const pluginDir = @@ -279,9 +360,6 @@ export default function scriptsPluginFactory(registry) { ? resolved.pluginDir ?? null : null; - const { log, logs } = createDryRunLogger(); - const started = Date.now(); - try { const config = await resolveConfigRefs(body.config ?? null, { owner, @@ -289,7 +367,7 @@ export default function scriptsPluginFactory(registry) { context: incomingContext, }); const ctx = { - data: body.data ?? null, + data: incomingData, context: incomingContext, config, }; @@ -313,6 +391,8 @@ export default function scriptsPluginFactory(registry) { status: "success", output: safeSerialize(result.output), context: safeSerialize(result.context), + wireOutput: encodeBinaryForWire(result.output), + wireContext: encodeBinaryForWire(result.context), skipRemaining: result.skipRemaining, error: null, logs, @@ -475,7 +555,5 @@ export default function scriptsPluginFactory(registry) { } }, ); - - void getInstalledPlugin; }; } diff --git a/packages/server/src/api/workflows.js b/packages/server/src/api/workflows.js index 6121b63..ff1dadd 100644 --- a/packages/server/src/api/workflows.js +++ b/packages/server/src/api/workflows.js @@ -26,6 +26,8 @@ import { collectWorkflowWarnings, parseWorkflowDocument, } from "../../workflow-validate-warnings.js"; +import { getProfilePlain } from "../../profiles-store.js"; +import { resolveScriptRef } from "../../plugin-store.js"; import { recordRevision, listRevisions, @@ -43,6 +45,11 @@ import { createWorkflowBackupBuffer, restoreWorkflowBackup, } from "../../workflow-backup.js"; +import { + listExampleWorkflows, + readExampleWorkflow, + assertExampleWorkflowId, +} from "../../workflow-examples.js"; /** * Reload this process and notify other HTTP/worker processes via Redis. @@ -81,7 +88,9 @@ function scriptNames(workflow) { for (const raw of workflow.scripts ?? []) { try { const parsed = parseScriptStep(raw); - names.push(parsed.kind === "set" ? "set" : parsed.script); + if (parsed.kind === "set") names.push("set"); + else if (parsed.profile) names.push(`profile:${parsed.profile}`); + else names.push(parsed.script); } catch { names.push(null); } @@ -89,6 +98,59 @@ function scriptNames(workflow) { return names; } +/** + * @param {unknown} parsed + * @param {string} owner + */ +async function collectProfileWarnings(parsed, owner) { + /** @type {Array<{ code: string, message: string, path?: string }>} */ + const warnings = []; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !owner) { + return warnings; + } + for (const [i, raw] of (parsed.scripts ?? []).entries()) { + if (raw == null || typeof raw !== "object" || Array.isArray(raw)) continue; + const profileName = raw.profile; + if (typeof profileName !== "string" || !profileName) continue; + const pathKey = `scripts[${i}]`; + let profile; + try { + profile = await getProfilePlain(owner, profileName); + } catch { + warnings.push({ + code: "unknown_profile", + message: `Profile "${profileName}" is not a valid name`, + path: pathKey, + }); + continue; + } + if (!profile) { + warnings.push({ + code: "unknown_profile", + message: `Profile "${profileName}" not found`, + path: pathKey, + }); + continue; + } + if (typeof raw.script === "string" && raw.script && raw.script !== profile.script) { + warnings.push({ + code: "profile_script_mismatch", + message: `Step script "${raw.script}" does not match profile "${profileName}" (${profile.script})`, + path: pathKey, + }); + } + const resolved = resolveScriptRef(profile.script); + if (resolved.error) { + warnings.push({ + code: "unknown_script", + message: resolved.error, + path: `${pathKey}.profile`, + }); + } + } + return warnings; +} + /** * @param {unknown} parsed */ @@ -110,8 +172,11 @@ async function validateStrictWorkflow(parsed) { * }} opts */ async function saveWorkflowContent(opts) { - const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content); - const saveAnyway = Boolean(opts.saveAnyway); + const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content); + if (parsed) { + warnings.push(...(await collectProfileWarnings(parsed, opts.owner))); + } + const saveAnyway = Boolean(opts.saveAnyway); if (!saveAnyway) { if (parseError) { @@ -187,6 +252,22 @@ export default function workflowsPluginFactory(registry) { return { owners: fsStore.listOwners() }; }); + fastify.get("/workflow-examples", async () => { + return { examples: listExampleWorkflows() }; + }); + + fastify.get("/workflow-examples/:id", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + if (!assertExampleWorkflowId(id)) { + return reply.code(400).send({ error: "invalid example id" }); + } + const example = readExampleWorkflow(id); + if (!example) { + return reply.code(404).send({ error: "example not found" }); + } + return example; + }); + fastify.get("/workflows/trash", async () => { return { items: await listTrash() }; }); @@ -307,6 +388,7 @@ export default function workflowsPluginFactory(registry) { enabled: parsed ? parsed.enabled !== false : false, registered: registered.includes(file), loadError: loadError ?? (parsed ? null : "unreadable"), + lastModifiedAt: fsStore.workflowLastModifiedAt(owner, file), lastInvokedAt: st.lastInvokedAt, lastStatus: st.lastStatus ?? null, invocationCount: st.invocationCount, @@ -315,6 +397,13 @@ export default function workflowsPluginFactory(registry) { }); } } + items.sort((a, b) => { + const byName = String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, { + sensitivity: "base", + }); + if (byName !== 0) return byName; + return String(a.key ?? "").localeCompare(String(b.key ?? "")); + }); return { workflows: items }; }); diff --git a/packages/server/src/stores/http-auths-store.js b/packages/server/src/stores/http-auths-store.js new file mode 100644 index 0000000..11ab057 --- /dev/null +++ b/packages/server/src/stores/http-auths-store.js @@ -0,0 +1,390 @@ +import { randomUUID } from "node:crypto"; +import { db } from "../../db.js"; +import { assertHttpStatus } from "../../http-pages-store.js"; + +const MAX_NAME_LENGTH = 128; +const NAME_RE = /^[A-Za-z0-9._-]+$/; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]); + +function nowIso() { + return new Date().toISOString(); +} + +/** + * @param {unknown} id + * @returns {string} + */ +export function assertAuthId(id) { + if (typeof id !== "string" || !UUID_RE.test(id)) { + const err = new Error("invalid auth id"); + err.statusCode = 400; + throw err; + } + return id.toLowerCase(); +} + +/** + * @param {unknown} name + * @returns {string} + */ +export function assertAuthName(name) { + if (typeof name !== "string" || !NAME_RE.test(name)) { + const err = new Error("invalid auth name"); + err.statusCode = 400; + throw err; + } + if (name.length > MAX_NAME_LENGTH) { + const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`); + err.statusCode = 400; + throw err; + } + return name; +} + +/** + * @param {unknown} type + * @returns {"bearer" | "basic" | "header"} + */ +export function assertAuthType(type) { + const t = String(type ?? ""); + if (!ALLOWED_TYPES.has(t)) { + const err = new Error('auth type must be "bearer", "basic", or "header"'); + err.statusCode = 400; + throw err; + } + return /** @type {"bearer" | "basic" | "header"} */ (t); +} + +/** + * Detect value source without exposing literal values. + * @param {unknown} value + * @returns {"literal" | "kv" | "secret" | "missing"} + */ +export function valueSourceKind(value) { + if (value == null) return "missing"; + if (typeof value === "string") return "literal"; + if (typeof value === "object" && !Array.isArray(value)) { + if ("secret" in value) return "secret"; + if ("kv" in value) return "kv"; + } + return "literal"; +} + +/** + * Redact config for API responses: replace literal strings with source markers. + * @param {Record} config + * @param {string} type + */ +export function publicConfig(config, type) { + /** @type {Record} */ + const out = {}; + if (type === "bearer") { + out.token = redactField(config.token); + } else if (type === "basic") { + out.user = redactField(config.user); + out.password = redactField(config.password); + } else if (type === "header") { + out.header = typeof config.header === "string" ? config.header : null; + out.value = redactField(config.value); + } + return out; +} + +/** + * @param {unknown} value + */ +function redactField(value) { + const kind = valueSourceKind(value); + if (kind === "missing") return { source: "missing" }; + if (kind === "kv") { + const v = /** @type {{ kv: string, namespace?: string }} */ (value); + return { + source: "kv", + kv: v.kv, + ...(v.namespace != null ? { namespace: v.namespace } : {}), + }; + } + if (kind === "secret") { + const v = /** @type {{ secret: string }} */ (value); + return { source: "secret", secret: v.secret }; + } + return { source: "literal", set: true }; +} + +/** + * Validate and normalize auth config for storage. + * @param {string} type + * @param {unknown} config + * @param {{ keepLiteralsFrom?: Record }} [opts] + */ +export function normalizeAuthConfig(type, config, opts = {}) { + const raw = config && typeof config === "object" && !Array.isArray(config) + ? /** @type {Record} */ (config) + : {}; + const keep = opts.keepLiteralsFrom ?? {}; + + if (type === "bearer") { + return { + token: normalizeCredentialField(raw.token, keep.token, "token"), + }; + } + if (type === "basic") { + return { + user: normalizeCredentialField(raw.user, keep.user, "user"), + password: normalizeCredentialField(raw.password, keep.password, "password", { + allowEmpty: true, + }), + }; + } + // header + if (typeof raw.header !== "string" || raw.header.length === 0) { + const err = new Error("header name must be a non-empty string"); + err.statusCode = 400; + throw err; + } + return { + header: raw.header, + value: normalizeCredentialField(raw.value, keep.value, "value"), + }; +} + +/** + * @param {unknown} value + * @param {unknown} previous + * @param {string} label + * @param {{ allowEmpty?: boolean }} [opts] + */ +function normalizeCredentialField(value, previous, label, opts = {}) { + // Explicit "keep previous literal" marker from UI when editing without re-entering + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + /** @type {{ keep?: boolean }} */ (value).keep === true + ) { + if (typeof previous === "string") return previous; + if (previous && typeof previous === "object") return previous; + const err = new Error(`${label} was not previously set`); + err.statusCode = 400; + throw err; + } + + if (value == null || value === "") { + if (opts.allowEmpty && value === "") return ""; + // Allow empty password for basic + if (opts.allowEmpty && (value === "" || value == null)) { + if (typeof previous === "string") return previous; + return ""; + } + const err = new Error(`${label} is required`); + err.statusCode = 400; + throw err; + } + + if (typeof value === "string") return value; + + if (typeof value === "object" && !Array.isArray(value)) { + const v = /** @type {Record} */ (value); + if (typeof v.secret === "string" && v.secret.length > 0) { + return { secret: v.secret }; + } + if (typeof v.kv === "string" && v.kv.length > 0) { + /** @type {{ kv: string, namespace?: string }} */ + const out = { kv: v.kv }; + if (typeof v.namespace === "string" && v.namespace.length > 0) { + out.namespace = v.namespace; + } + return out; + } + } + + const err = new Error( + `${label} must be a string, { kv }, { secret }, or { keep: true }`, + ); + err.statusCode = 400; + throw err; +} + +function parseConfig(raw) { + if (typeof raw !== "string") return raw ?? {}; + try { + return JSON.parse(raw); + } catch { + return {}; + } +} + +function publicAuth(row, { includeConfig = true } = {}) { + const type = row.type; + const config = parseConfig(row.config); + return { + id: row.id, + name: row.name, + type, + ...(includeConfig ? { config: publicConfig(config, type) } : {}), + unauthorized_status: row.unauthorized_status ?? null, + unauthorized_response: row.unauthorized_response ?? null, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** + * Internal: full config including literals (for runtime auth checks). + * @param {string} id + */ +export async function getHttpAuthInternal(id) { + const authId = assertAuthId(id); + const row = await db("http_auths").where({ id: authId }).first(); + if (!row) return null; + return { + id: row.id, + name: row.name, + type: row.type, + config: parseConfig(row.config), + unauthorized_status: row.unauthorized_status ?? null, + unauthorized_response: row.unauthorized_response ?? null, + }; +} + +/** + * Return only plaintext literal credential fields (not KV refs or encrypted secrets). + * @param {string} id + * @returns {Promise<{ id: string, name: string, type: string, literals: Record } | null>} + */ +export async function revealHttpAuthLiterals(id) { + const internal = await getHttpAuthInternal(id); + if (!internal) return null; + /** @type {Record} */ + const literals = {}; + const cfg = internal.config ?? {}; + for (const key of ["token", "user", "password", "value"]) { + const v = cfg[key]; + if (typeof v === "string") literals[key] = v; + } + return { + id: internal.id, + name: internal.name, + type: internal.type, + literals, + }; +} + +export async function listHttpAuths() { + const rows = await db("http_auths").select("*").orderBy("name", "asc"); + return rows.map((r) => publicAuth(r)); +} + +/** + * @param {string} id + */ +export async function getHttpAuthById(id) { + let authId; + try { + authId = assertAuthId(id); + } catch { + return null; + } + const row = await db("http_auths").where({ id: authId }).first(); + return row ? publicAuth(row) : null; +} + +/** + * @param {{ + * id?: string | null, + * name: string, + * type: string, + * config?: unknown, + * unauthorized_status?: number | null, + * unauthorized_response?: string | null, + * }} opts + */ +export async function upsertHttpAuth({ + id, + name, + type, + config, + unauthorized_status, + unauthorized_response, +}) { + const authName = assertAuthName(name); + const authType = assertAuthType(type); + + /** @type {Record | null} */ + let existing = null; + if (id != null && String(id).length > 0) { + const authId = assertAuthId(id); + existing = await db("http_auths").where({ id: authId }).first(); + if (!existing) { + const err = new Error("auth not found"); + err.statusCode = 404; + throw err; + } + } + + const nameClash = await db("http_auths").where({ name: authName }).first(); + if (nameClash && (!existing || nameClash.id !== existing.id)) { + const err = new Error(`auth name "${authName}" already exists`); + err.statusCode = 409; + throw err; + } + + const prevConfig = existing ? parseConfig(existing.config) : {}; + const normalized = normalizeAuthConfig(authType, config, { + keepLiteralsFrom: prevConfig, + }); + + let unauthStatus = null; + if (unauthorized_status != null && unauthorized_status !== "") { + unauthStatus = assertHttpStatus(unauthorized_status, 401); + } + let unauthResponse = null; + if ( + unauthorized_response != null && + String(unauthorized_response).length > 0 + ) { + unauthResponse = String(unauthorized_response); + } + + const now = nowIso(); + const configJson = JSON.stringify(normalized); + + if (existing) { + await db("http_auths") + .where({ id: existing.id }) + .update({ + name: authName, + type: authType, + config: configJson, + unauthorized_status: unauthStatus, + unauthorized_response: unauthResponse, + updated_at: now, + }); + return getHttpAuthById(/** @type {string} */ (existing.id)); + } + + const newId = randomUUID(); + await db("http_auths").insert({ + id: newId, + name: authName, + type: authType, + config: configJson, + unauthorized_status: unauthStatus, + unauthorized_response: unauthResponse, + created_at: now, + updated_at: now, + }); + return getHttpAuthById(newId); +} + +/** + * @param {string} id + * @returns {Promise} + */ +export async function deleteHttpAuth(id) { + const authId = assertAuthId(id); + const n = await db("http_auths").where({ id: authId }).del(); + return n > 0; +} diff --git a/packages/server/src/stores/kv-store.js b/packages/server/src/stores/kv-store.js new file mode 100644 index 0000000..ad54c28 --- /dev/null +++ b/packages/server/src/stores/kv-store.js @@ -0,0 +1,399 @@ +import { db } from "../../db.js"; + +const MAX_KEY_LENGTH = 512; +const MAX_NAMESPACE_LENGTH = 512; +const MAX_VALUE_BYTES = 256 * 1024; +const DEFAULT_LIST_LIMIT = 100; +const MAX_LIST_LIMIT = 500; + +function nowIso() { + return new Date().toISOString(); +} + +/** + * @param {unknown} value + */ +function valuesEqual(a, b) { + if (a === b) return true; + if (a == null || b == null) return a === b; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + +/** + * @param {string} label + * @param {unknown} value + */ +function assertString(label, value) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } +} + +/** + * @param {string} label + * @param {string} value + * @param {number} max + */ +function assertMaxLength(label, value, max) { + if (value.length > max) { + throw new Error(`${label} must be at most ${max} characters`); + } +} + +/** + * @param {string} namespace + */ +function assertNamespace(namespace) { + assertString("namespace", namespace); + assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH); +} + +/** + * @param {string} key + */ +function assertKey(key) { + assertString("key", key); + assertMaxLength("key", key, MAX_KEY_LENGTH); +} + +/** + * @param {unknown} value + * @returns {string} + */ +function serializeKvValue(value) { + let json; + try { + json = JSON.stringify(value); + } catch { + throw new Error("value must be JSON-serializable"); + } + if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) { + throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`); + } + return json; +} + +/** + * @param {string | null | undefined} value + * @returns {unknown} + */ +function deserializeKvValue(value) { + if (value == null) return null; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +/** + * @param {{ expires_at?: string | null }} row + */ +function isExpired(row) { + if (!row.expires_at) return false; + return Date.parse(row.expires_at) <= Date.now(); +} + +/** + * @param {string} namespace + * @param {string} key + * @returns {Promise} + */ +export async function kvGet(namespace, key) { + assertNamespace(namespace); + assertKey(key); + + const row = await db("script_state").where({ namespace, key }).first(); + if (!row) return null; + + if (isExpired(row)) { + await db("script_state").where({ namespace, key }).del(); + return null; + } + + return deserializeKvValue(row.value); +} + +/** + * @param {string} namespace + * @param {string} key + * @param {unknown} value + * @param {{ expiresAt?: string | Date | null }} [opts] + */ +export async function kvSet(namespace, key, value, opts = {}) { + assertNamespace(namespace); + assertKey(key); + + const json = serializeKvValue(value); + const updated_at = nowIso(); + let expires_at = null; + if (opts.expiresAt != null) { + expires_at = + opts.expiresAt instanceof Date + ? opts.expiresAt.toISOString() + : String(opts.expiresAt); + } + + await db("script_state") + .insert({ + namespace, + key, + value: json, + updated_at, + expires_at, + }) + .onConflict(["namespace", "key"]) + .merge({ + value: json, + updated_at, + expires_at, + }); +} + +/** + * @param {string} namespace + * @param {string} key + * @returns {Promise} + */ +export async function kvDelete(namespace, key) { + assertNamespace(namespace); + assertKey(key); + const deleted = await db("script_state").where({ namespace, key }).del(); + return deleted > 0; +} + +/** + * @param {string} namespace + * @param {string} key + * @param {unknown} expected + * @param {unknown} next + * @param {{ expiresAt?: string | Date | null }} [opts] + * @returns {Promise<{ ok: boolean, previous: unknown }>} + */ +export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) { + assertNamespace(namespace); + assertKey(key); + + return db.transaction(async (trx) => { + const row = await trx("script_state").where({ namespace, key }).first(); + + if (row && isExpired(row)) { + await trx("script_state").where({ namespace, key }).del(); + } + + const currentRow = + row && !isExpired(row) + ? row + : await trx("script_state").where({ namespace, key }).first(); + const previous = currentRow ? deserializeKvValue(currentRow.value) : null; + + if (!valuesEqual(previous, expected)) { + return { ok: false, previous }; + } + + const json = serializeKvValue(next); + const updated_at = nowIso(); + let expires_at = null; + if (opts.expiresAt != null) { + expires_at = + opts.expiresAt instanceof Date + ? opts.expiresAt.toISOString() + : String(opts.expiresAt); + } + + if (currentRow) { + await trx("script_state").where({ namespace, key }).update({ + value: json, + updated_at, + expires_at, + }); + } else { + await trx("script_state").insert({ + namespace, + key, + value: json, + updated_at, + expires_at, + }); + } + + return { ok: true, previous }; + }); +} + +/** + * @param {string} namespace + * @param {{ limit?: number }} [opts] + */ +export async function kvList(namespace, opts = {}) { + assertNamespace(namespace); + const limit = Math.min( + Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1), + MAX_LIST_LIMIT, + ); + + const rows = await db("script_state") + .where({ namespace }) + .orderBy("updated_at", "desc") + .limit(limit); + + const items = []; + for (const row of rows) { + if (isExpired(row)) { + await db("script_state").where({ namespace, key: row.key }).del(); + continue; + } + items.push({ + key: row.key, + value: deserializeKvValue(row.value), + updatedAt: row.updated_at, + expiresAt: row.expires_at ?? null, + }); + } + return items; +} + +function escapeLike(value) { + return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); +} + +async function pruneExpiredKv() { + await db("script_state") + .whereNotNull("expires_at") + .andWhere("expires_at", "<=", nowIso()) + .del(); +} + +/** + * @param {{ + * namespace?: string, + * q?: string, + * limit?: number, + * offset?: number, + * }} [opts] + */ +export async function kvQuery(opts = {}) { + await pruneExpiredKv(); + + const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100); + const offset = Math.max(Number(opts.offset) || 0, 0); + + let q = db("script_state"); + if (opts.namespace) { + assertNamespace(opts.namespace); + q = q.where({ namespace: opts.namespace }); + } + if (typeof opts.q === "string" && opts.q.length > 0) { + const like = `%${escapeLike(opts.q)}%`; + q = q.where(function likeSearch() { + this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw( + "value LIKE ? ESCAPE '\\'", + [like], + ); + }); + } + + const countRow = await q.clone().count({ count: "*" }).first(); + const total = Number(countRow?.count ?? 0); + + const rows = await q + .clone() + .orderBy("updated_at", "desc") + .orderBy("namespace", "asc") + .orderBy("key", "asc") + .limit(limit) + .offset(offset); + + return { + items: rows.map((row) => ({ + namespace: row.namespace, + key: row.key, + value: deserializeKvValue(row.value), + updatedAt: row.updated_at, + expiresAt: row.expires_at ?? null, + })), + total, + limit, + offset, + }; +} + +/** + * @returns {Promise} + */ +export async function kvNamespaces() { + await pruneExpiredKv(); + const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc"); + return rows.map((row) => row.namespace); +} + +/** + * @param {string} defaultNamespace + */ +export function createKvApi(defaultNamespace) { + assertNamespace(defaultNamespace); + + /** + * @param {{ namespace?: string }} [opts] + */ + function resolveNamespace(opts = {}) { + const namespace = opts.namespace ?? defaultNamespace; + assertNamespace(namespace); + return namespace; + } + + return { + namespace: defaultNamespace, + + /** + * @param {string} key + * @param {{ namespace?: string }} [opts] + */ + get(key, opts) { + return kvGet(resolveNamespace(opts), key); + }, + + /** + * @param {string} key + * @param {unknown} value + * @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts] + */ + set(key, value, opts) { + const { namespace, expiresAt } = opts ?? {}; + return kvSet(resolveNamespace(opts), key, value, { expiresAt }); + }, + + /** + * @param {string} key + * @param {{ namespace?: string }} [opts] + */ + delete(key, opts) { + return kvDelete(resolveNamespace(opts), key); + }, + + /** + * @param {string} key + * @param {unknown} expected + * @param {unknown} next + * @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts] + */ + compareAndSet(key, expected, next, opts) { + const { expiresAt } = opts ?? {}; + return kvCompareAndSet(resolveNamespace(opts), key, expected, next, { + expiresAt, + }); + }, + + /** + * @param {{ namespace?: string, limit?: number }} [opts] + */ + list(opts) { + const { namespace, limit } = opts ?? {}; + return kvList(resolveNamespace(opts), { limit }); + }, + }; +} diff --git a/packages/server/src/stores/profiles-store.js b/packages/server/src/stores/profiles-store.js new file mode 100644 index 0000000..92b73ca --- /dev/null +++ b/packages/server/src/stores/profiles-store.js @@ -0,0 +1,244 @@ +import { randomUUID } from "node:crypto"; +import yaml from "yaml"; +import { db } from "../../db.js"; +import { assertOwner, listOwnerYamlFiles, readWorkflowYaml } from "../../fs-store.js"; + +const MAX_NAME_LENGTH = 128; +const MAX_DESCRIPTION_LENGTH = 500; +const MAX_CONFIG_BYTES = 64 * 1024; +const PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/; + +function nowIso() { + return new Date().toISOString(); +} + +function httpError(message, statusCode = 400) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +/** + * @param {unknown} name + * @returns {string} + */ +export function assertProfileName(name) { + if (typeof name !== "string" || !PROFILE_NAME_RE.test(name)) { + throw httpError("invalid profile name"); + } + if (name.length > MAX_NAME_LENGTH) { + throw httpError(`profile name must be at most ${MAX_NAME_LENGTH} characters`); + } + return name; +} + +/** + * @param {unknown} script + * @returns {string} + */ +export function assertProfileScript(script) { + if (typeof script !== "string" || script.trim().length === 0) { + throw httpError("script is required"); + } + const trimmed = script.trim(); + if (trimmed.length > 256) { + throw httpError("script name is too long"); + } + return trimmed; +} + +/** + * @param {unknown} description + * @returns {string} + */ +export function assertProfileDescription(description) { + if (description == null) return ""; + if (typeof description !== "string") { + throw httpError("description must be a string"); + } + if (description.length > MAX_DESCRIPTION_LENGTH) { + throw httpError(`description must be at most ${MAX_DESCRIPTION_LENGTH} characters`); + } + return description; +} + +/** + * @param {unknown} config + * @returns {string} + */ +export function encodeProfileConfig(config) { + if (config == null) return "{}"; + if (typeof config !== "object" || Array.isArray(config)) { + throw httpError("config must be an object"); + } + let encoded; + try { + encoded = JSON.stringify(config); + } catch { + throw httpError("config must be JSON-serializable"); + } + if (Buffer.byteLength(encoded, "utf8") > MAX_CONFIG_BYTES) { + throw httpError(`config exceeds ${MAX_CONFIG_BYTES} byte limit`); + } + return encoded; +} + +/** + * @param {string} stored + * @returns {Record} + */ +export function decodeProfileConfig(stored) { + if (stored == null || stored === "") return {}; + try { + const parsed = JSON.parse(stored); + if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; + } + } catch { + throw new Error(`corrupt profile config: ${JSON.stringify(stored).slice(0, 80)}`); + } + throw new Error("corrupt profile config: not an object"); +} + +/** + * @param {Record} row + */ +function publicProfile(row) { + return { + id: row.id, + owner: row.owner, + name: row.name, + script: row.script, + config: decodeProfileConfig(String(row.config ?? "{}")), + description: row.description == null ? "" : String(row.description), + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** + * @param {{ owner?: string }} [filters] + */ +export async function listProfiles(filters = {}) { + let q = db("profiles") + .select("id", "owner", "name", "script", "config", "description", "created_at", "updated_at") + .orderBy("owner", "asc") + .orderBy("name", "asc"); + if (filters.owner) { + q = q.where("owner", assertOwner(filters.owner)); + } + const rows = await q; + return rows.map((row) => publicProfile(row)); +} + +/** + * @param {string} id + */ +export async function getProfileById(id) { + const row = await db("profiles").where({ id }).first(); + return row ? publicProfile(row) : null; +} + +/** + * @param {string} owner + * @param {string} name + */ +export async function getProfilePlain(owner, name) { + const ownerName = assertOwner(owner); + const profileName = assertProfileName(name); + const row = await db("profiles").where({ owner: ownerName, name: profileName }).first(); + return row ? publicProfile(row) : null; +} + +/** + * @param {{ + * owner: string, + * name: string, + * script: unknown, + * config?: unknown, + * description?: unknown, + * }} opts + */ +export async function upsertProfile({ owner, name, script, config, description }) { + const ownerName = assertOwner(owner); + const profileName = assertProfileName(name); + const scriptName = assertProfileScript(script); + const encoded = encodeProfileConfig(config ?? {}); + const desc = assertProfileDescription(description); + const now = nowIso(); + const existing = await db("profiles").where({ owner: ownerName, name: profileName }).first(); + + if (existing) { + await db("profiles") + .where({ id: existing.id }) + .update({ + script: scriptName, + config: encoded, + description: desc, + updated_at: now, + }); + return getProfileById(existing.id); + } + + const id = randomUUID(); + await db("profiles").insert({ + id, + owner: ownerName, + name: profileName, + script: scriptName, + config: encoded, + description: desc, + created_at: now, + updated_at: now, + }); + return getProfileById(id); +} + +/** + * @param {string} id + * @returns {Promise} + */ +export async function deleteProfile(id) { + const n = await db("profiles").where({ id }).del(); + return n > 0; +} + +/** + * Workflows (same owner) whose YAML steps reference this profile name. + * @param {string} owner + * @param {string} name + * @returns {{ file: string, name: string, steps: number }[]} + */ +export function listProfileUsages(owner, name) { + const ownerName = assertOwner(owner); + const profileName = assertProfileName(name); + /** @type {{ file: string, name: string, steps: number }[]} */ + const usages = []; + for (const file of listOwnerYamlFiles(ownerName)) { + const content = readWorkflowYaml(ownerName, file); + if (content == null) continue; + let parsed; + try { + parsed = yaml.parse(content); + } catch { + continue; + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) continue; + const scripts = parsed.scripts; + if (!Array.isArray(scripts)) continue; + let steps = 0; + for (const step of scripts) { + if (step != null && typeof step === "object" && !Array.isArray(step) && step.profile === profileName) { + steps += 1; + } + } + if (steps > 0) { + usages.push({ + file, + name: typeof parsed.name === "string" && parsed.name ? parsed.name : file, + steps, + }); + } + } + return usages; +} diff --git a/packages/server/src/stores/secrets-store.js b/packages/server/src/stores/secrets-store.js new file mode 100644 index 0000000..cd4f491 --- /dev/null +++ b/packages/server/src/stores/secrets-store.js @@ -0,0 +1,144 @@ +import { randomUUID } from "node:crypto"; +import { db } from "../../db.js"; +import { assertOwner } from "../../fs-store.js"; +import { decryptSecret, encryptSecret } from "../../secrets.js"; +import { registerPlaintext } from "../../secret-value.js"; + +const MAX_NAME_LENGTH = 128; +const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/; + +function nowIso() { + return new Date().toISOString(); +} + +/** + * @param {unknown} name + * @returns {string} + */ +export function assertSecretName(name) { + if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) { + const err = new Error("invalid secret name"); + err.statusCode = 400; + throw err; + } + if (name.length > MAX_NAME_LENGTH) { + const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`); + err.statusCode = 400; + throw err; + } + return name; +} + +function publicSecret(row) { + return { + id: row.id, + owner: row.owner, + name: row.name, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** + * @param {{ owner?: string }} [filters] + */ +export async function listSecrets(filters = {}) { + let q = db("secrets") + .select("id", "owner", "name", "created_at", "updated_at") + .orderBy("owner", "asc") + .orderBy("name", "asc"); + if (filters.owner) { + q = q.where("owner", assertOwner(filters.owner)); + } + return q; +} + +/** + * @param {string} id + */ +export async function getSecretById(id) { + const row = await db("secrets") + .select("id", "owner", "name", "created_at", "updated_at") + .where({ id }) + .first(); + return row ?? null; +} + +/** + * @param {{ owner: string, name: string, value: string }} opts + */ +export async function upsertSecret({ owner, name, value }) { + if (typeof value !== "string" || value.length === 0) { + const err = new Error("value is required"); + err.statusCode = 400; + throw err; + } + const ownerName = assertOwner(owner); + const secretName = assertSecretName(name); + registerPlaintext(value); + const { ciphertext, iv, authTag } = encryptSecret(value); + const now = nowIso(); + const existing = await db("secrets") + .where({ owner: ownerName, name: secretName }) + .first(); + + if (existing) { + await db("secrets") + .where({ id: existing.id }) + .update({ + ciphertext, + iv, + auth_tag: authTag, + updated_at: now, + }); + return getSecretById(existing.id); + } + + const id = randomUUID(); + await db("secrets").insert({ + id, + owner: ownerName, + name: secretName, + ciphertext, + iv, + auth_tag: authTag, + created_at: now, + updated_at: now, + }); + return getSecretById(id); +} + +/** + * @param {string} id + * @returns {Promise} + */ +export async function deleteSecret(id) { + const n = await db("secrets").where({ id }).del(); + return n > 0; +} + +/** + * Decrypt a named secret for an owner. Returns null if missing. + * @param {string} owner + * @param {string} name + * @returns {Promise} + */ +export async function getSecretPlaintext(owner, name) { + const ownerName = assertOwner(owner); + const secretName = assertSecretName(name); + const row = await db("secrets") + .where({ owner: ownerName, name: secretName }) + .first(); + if (!row) return null; + try { + const plaintext = decryptSecret({ + ciphertext: row.ciphertext, + iv: row.iv, + authTag: row.auth_tag, + }); + registerPlaintext(plaintext); + return plaintext; + } catch { + throw new Error(`failed to decrypt secret "${secretName}"`); + } +} diff --git a/packages/server/src/stores/variables-store.js b/packages/server/src/stores/variables-store.js new file mode 100644 index 0000000..d6cc37c --- /dev/null +++ b/packages/server/src/stores/variables-store.js @@ -0,0 +1,190 @@ +import { randomUUID } from "node:crypto"; +import { db } from "../../db.js"; +import { assertOwner } from "../../fs-store.js"; + +const MAX_NAME_LENGTH = 128; +const MAX_STRING_BYTES = 64 * 1024; +const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/; +export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]); + +function nowIso() { + return new Date().toISOString(); +} + +function httpError(message, statusCode = 400) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +/** + * @param {unknown} name + * @returns {string} + */ +export function assertVariableName(name) { + if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) { + throw httpError("invalid variable name"); + } + if (name.length > MAX_NAME_LENGTH) { + throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`); + } + return name; +} + +/** + * @param {unknown} type + * @returns {"string" | "number" | "boolean"} + */ +export function assertVariableType(type) { + if (type !== "string" && type !== "number" && type !== "boolean") { + throw httpError("type must be string, number, or boolean"); + } + return type; +} + +/** + * @param {"string" | "number" | "boolean"} type + * @param {unknown} value + * @returns {string} + */ +export function encodeVariableValue(type, value) { + if (type === "string") { + if (typeof value !== "string") { + throw httpError("value must be a string"); + } + if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) { + throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`); + } + return value; + } + if (type === "number") { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw httpError("value must be a finite number"); + } + return String(value); + } + if (typeof value !== "boolean") { + throw httpError("value must be a boolean"); + } + return value ? "true" : "false"; +} + +/** + * @param {"string" | "number" | "boolean"} type + * @param {string} stored + * @returns {string | number | boolean} + */ +export function decodeVariableValue(type, stored) { + if (type === "string") return stored; + if (type === "number") { + const n = Number(stored); + if (!Number.isFinite(n)) { + throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`); + } + return n; + } + if (stored === "true") return true; + if (stored === "false") return false; + throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`); +} + +/** + * @param {Record} row + */ +function publicVariable(row) { + const type = assertVariableType(row.type); + return { + id: row.id, + owner: row.owner, + name: row.name, + type, + value: decodeVariableValue(type, String(row.value ?? "")), + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** + * @param {{ owner?: string }} [filters] + */ +export async function listVariables(filters = {}) { + let q = db("variables") + .select("id", "owner", "name", "type", "value", "created_at", "updated_at") + .orderBy("owner", "asc") + .orderBy("name", "asc"); + if (filters.owner) { + q = q.where("owner", assertOwner(filters.owner)); + } + const rows = await q; + return rows.map((row) => publicVariable(row)); +} + +/** + * @param {string} id + */ +export async function getVariableById(id) { + const row = await db("variables").where({ id }).first(); + return row ? publicVariable(row) : null; +} + +/** + * @param {{ owner: string, name: string, type: unknown, value: unknown }} opts + */ +export async function upsertVariable({ owner, name, type, value }) { + const ownerName = assertOwner(owner); + const variableName = assertVariableName(name); + const variableType = assertVariableType(type); + const encoded = encodeVariableValue(variableType, value); + const now = nowIso(); + const existing = await db("variables") + .where({ owner: ownerName, name: variableName }) + .first(); + + if (existing) { + await db("variables") + .where({ id: existing.id }) + .update({ + type: variableType, + value: encoded, + updated_at: now, + }); + return getVariableById(existing.id); + } + + const id = randomUUID(); + await db("variables").insert({ + id, + owner: ownerName, + name: variableName, + type: variableType, + value: encoded, + created_at: now, + updated_at: now, + }); + return getVariableById(id); +} + +/** + * @param {string} id + * @returns {Promise} + */ +export async function deleteVariable(id) { + const n = await db("variables").where({ id }).del(); + return n > 0; +} + +/** + * Typed primitive for an owner/name. Returns null if missing. + * @param {string} owner + * @param {string} name + * @returns {Promise} + */ +export async function getVariablePlain(owner, name) { + const ownerName = assertOwner(owner); + const variableName = assertVariableName(name); + const row = await db("variables") + .where({ owner: ownerName, name: variableName }) + .first(); + if (!row) return null; + return decodeVariableValue(assertVariableType(row.type), String(row.value ?? "")); +} diff --git a/packages/server/start-app.js b/packages/server/start-app.js index 39f0299..249d2d1 100644 --- a/packages/server/start-app.js +++ b/packages/server/start-app.js @@ -14,10 +14,12 @@ import usersPlugin from "./src/api/users.js"; import scriptsPluginFactory from "./src/api/scripts.js"; import workflowsPluginFactory from "./src/api/workflows.js"; import runsPlugin from "./src/api/runs.js"; +import { queryRunsFromRequest } from "./src/api/run-query.js"; import dashboardPluginFactory from "./src/api/dashboard.js"; import secretsPlugin from "./src/api/secrets.js"; import kvPlugin from "./src/api/kv.js"; import variablesPlugin from "./src/api/variables.js"; +import profilesPlugin from "./src/api/profiles.js"; import httpPagesPlugin from "./src/api/http-pages.js"; import httpAuthsPlugin from "./src/api/http-auths.js"; import { WEB_DIST } from "./paths.js"; @@ -29,6 +31,7 @@ import { getRedisUrlForLog, } from "./workflow-queue.js"; import { purgeExpiredTrash } from "./workflow-trash.js"; +import { migrateLegacyWorkflowsIfNeeded } from "./workflow-migrate.js"; import { getConfigGeneration, startHeartbeatLoop, @@ -126,6 +129,12 @@ export async function startApp(opts = {}) { } }); + try { + migrateLegacyWorkflowsIfNeeded(); + } catch (err) { + log.warn({ err }, "legacy workflow migrate failed"); + } + const registry = createRegistry(server, { queue: workflowQueue, // Cron + HTTP triggers enqueue jobs; only the API process may own them. @@ -168,6 +177,7 @@ export async function startApp(opts = {}) { await api.register(usersPlugin); await api.register(secretsPlugin); await api.register(variablesPlugin); + await api.register(profilesPlugin); await api.register(kvPlugin); await api.register(httpPagesPlugin); await api.register(httpAuthsPlugin); @@ -198,16 +208,8 @@ export async function startApp(opts = {}) { "/admin/runs", { onRequest: [server.authenticate] }, async (req, reply) => { - const q = /** @type {Record} */ (req.query); - const limit = q.limit ? Number(q.limit) : undefined; - const runs = await store.listRuns({ - owner: q.owner, - workflow: q.workflow, - status: q.status, - limit: Number.isFinite(limit) ? limit : undefined, - before: q.before, - }); - return reply.send({ runs }); + const q = /** @type {Record} */ (req.query ?? {}); + return reply.send(await queryRunsFromRequest(q)); }, ); diff --git a/packages/server/step-result.js b/packages/server/step-result.js index af7084f..958f81a 100644 --- a/packages/server/step-result.js +++ b/packages/server/step-result.js @@ -2,12 +2,9 @@ * Step return contract: `{ output, context?, skipRemaining? }`. */ -/** - * @param {unknown} value - */ -export function isPlainObject(value) { - return value != null && typeof value === "object" && !Array.isArray(value); -} +import { isPlainObject } from "@jerapah-flow/shared"; + +export { isPlainObject }; /** * @param {unknown} value diff --git a/packages/server/store.js b/packages/server/store.js index c680529..74adc87 100644 --- a/packages/server/store.js +++ b/packages/server/store.js @@ -222,19 +222,29 @@ export async function insertLogs(rows) { ); } +/** @type {Record} */ +const RUN_SORT_COLUMNS = { + status: "status", + workflow: "workflow_name", + revision: "workflow_revision", + trigger: "trigger_type", + started_at: "started_at", + duration: "duration_ms", +}; + /** + * @param {import("knex").Knex.QueryBuilder} q * @param {{ * owner?: string, * workflow?: string, * status?: string | string[], - * limit?: number, + * trigger_type?: string, + * after?: string, * before?: string, - * }} [filters] + * }} filters */ -export async function listRuns(filters = {}) { - const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); - let q = db("workflow_runs").select("*").orderBy("started_at", "desc"); - if (filters.owner) q = q.where("owner", filters.owner); +function applyRunFilters(q, filters) { + if (filters.owner) q.where("owner", filters.owner); if (filters.workflow) { const key = String(filters.workflow); if (key.includes("*")) { @@ -243,25 +253,102 @@ export async function listRuns(filters = {}) { .replaceAll("%", "\\%") .replaceAll("_", "\\_") .replaceAll("*", "%"); - q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]); + q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]); } else { - q = q.where("workflow", key); + q.where("workflow", key); } } if (filters.status) { if (Array.isArray(filters.status)) { - q = q.whereIn("status", filters.status); + q.whereIn("status", filters.status); } else { - q = q.where("status", filters.status); + q.where("status", filters.status); } } - if (filters.before) q = q.where("started_at", "<", filters.before); - const rows = await q.limit(limit); - return rows.map((row) => ({ + if (filters.trigger_type) q.where("trigger_type", filters.trigger_type); + if (filters.after) q.where("started_at", ">=", filters.after); + if (filters.before) q.where("started_at", "<", filters.before); + return q; +} + +/** + * @param {import("knex").Knex.QueryBuilder} q + * @param {string | undefined} sort + * @param {string | undefined} order + */ +function applyRunSort(q, sort, order) { + const column = RUN_SORT_COLUMNS[sort ?? ""] ?? "started_at"; + const direction = order === "asc" ? "asc" : "desc"; + q.orderBy(column, direction); + if (column !== "started_at") q.orderBy("started_at", "desc"); + return q; +} + +/** + * @param {Record} row + */ +function mapRunRow(row) { + return { ...row, input: deserialize(row.input), output: deserialize(row.output), - })); + }; +} + +/** + * @param {{ + * owner?: string, + * workflow?: string, + * status?: string | string[], + * trigger_type?: string, + * after?: string, + * before?: string, + * limit?: number, + * offset?: number, + * sort?: string, + * order?: string, + * }} [filters] + */ +export async function queryRuns(filters = {}) { + const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); + const offset = Math.max(Number(filters.offset) || 0, 0); + + let q = db("workflow_runs"); + q = applyRunFilters(q, filters); + + const countRow = await q.clone().count({ count: "*" }).first(); + const total = Number(countRow?.count ?? 0); + + let rowsQ = q.clone().select("*"); + rowsQ = applyRunSort(rowsQ, filters.sort, filters.order); + const rows = await rowsQ.limit(limit).offset(offset); + + return { + runs: rows.map(mapRunRow), + total, + limit, + offset, + }; +} + +/** + * @param {{ + * owner?: string, + * workflow?: string, + * status?: string | string[], + * trigger_type?: string, + * after?: string, + * before?: string, + * limit?: number, + * }} [filters] + */ +export async function listRuns(filters = {}) { + const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); + let q = db("workflow_runs").select("*"); + q = applyRunFilters(q, filters); + q = applyRunSort(q, "started_at", "desc"); + const rows = await q.limit(limit); + return rows.map(mapRunRow); } /** @@ -495,12 +582,13 @@ export async function listUsers() { /** * @param {string} id - * @param {{ passwordHash?: string, role?: string }} patch + * @param {{ passwordHash?: string, role?: string, username?: string }} patch */ export async function updateUser(id, patch) { const update = { updated_at: nowIso() }; if (patch.passwordHash) update.password_hash = patch.passwordHash; if (patch.role) update.role = patch.role; + if (patch.username) update.username = patch.username; await db("users").where({ id }).update(update); return getUserById(id); } diff --git a/packages/server/test/detect-url-changes-smoke.js b/packages/server/test/detect-url-changes-smoke.js index 1493458..654d6cc 100644 --- a/packages/server/test/detect-url-changes-smoke.js +++ b/packages/server/test/detect-url-changes-smoke.js @@ -51,21 +51,21 @@ async function freshUrl(pathname) { state.body = "v1"; const first = await run({ url }); - assert(first.data.hasChanges === true, "first run should report hasChanges=true"); + assert(first.output.hasChanges === true, "first run should report hasChanges=true"); assert( - first.data.httpResponse === "v1", + first.output.httpResponse === "v1", "httpResponse should hold the raw body", ); - assert(typeof first.data.fingerprint === "string", "fingerprint hash should be set"); + assert(typeof first.output.fingerprint === "string", "fingerprint hash should be set"); const second = await run({ url }); - assert(second.data.hasChanges === false, "unchanged body should report hasChanges=false"); + assert(second.output.hasChanges === false, "unchanged body should report hasChanges=false"); state.body = "v2 CHANGED"; const third = await run({ url }); - assert(third.data.hasChanges === true, "changed body should report hasChanges=true"); + assert(third.output.hasChanges === true, "changed body should report hasChanges=true"); assert( - third.data.fingerprintPrevious === second.data.fingerprint, + third.output.fingerprintPrevious === second.output.fingerprint, "fingerprintPrevious should equal the prior hash", ); } @@ -77,20 +77,20 @@ async function freshUrl(pathname) { state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" }; const first = await run({ url, fingerprint: "data.httpResponse.version" }); - assert(first.data.hasChanges === true, "json first run should report a change"); + assert(first.output.hasChanges === true, "json first run should report a change"); // Change only an unwatched field -> no change. state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" }; const second = await run({ url, fingerprint: "data.httpResponse.version" }); assert( - second.data.hasChanges === false, + second.output.hasChanges === false, "changing an unwatched field should not report a change", ); // Change the watched field -> change. state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" }; const third = await run({ url, fingerprint: "data.httpResponse.version" }); - assert(third.data.hasChanges === true, "changing the watched field should report a change"); + assert(third.output.hasChanges === true, "changing the watched field should report a change"); state.contentType = "text/html; charset=utf-8"; } @@ -106,13 +106,13 @@ async function freshUrl(pathname) { transform: '"changed=" & $string(data.hasChanges)', }); assert( - withTransform.data.message === "changed=true", - `transform should populate outputVar, got ${JSON.stringify(withTransform.data.message)}`, + withTransform.output.message === "changed=true", + `transform should populate outputVar, got ${JSON.stringify(withTransform.output.message)}`, ); const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" }); assert( - rawOutput.data.payload === rawOutput.data.httpResponse, + rawOutput.output.payload === rawOutput.output.httpResponse, "outputVar without transform should store the raw response", ); @@ -131,11 +131,11 @@ async function freshUrl(pathname) { state.body = "stable"; const first = await run({ url, skipRemaining: true }); - assert(first.data.hasChanges === true, "skip test first run should change"); + assert(first.output.hasChanges === true, "skip test first run should change"); assert(first.skipRemaining !== true, "changed run must not set skipRemaining"); const second = await run({ url, skipRemaining: true }); - assert(second.data.hasChanges === false, "skip test second run should be unchanged"); + assert(second.output.hasChanges === false, "skip test second run should be unchanged"); assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt"); // Default (skipRemaining off) never halts, so downstream can still notify. diff --git a/packages/server/test/json-preview-smoke.js b/packages/server/test/json-preview-smoke.js index bb87cda..ad5c9f8 100644 --- a/packages/server/test/json-preview-smoke.js +++ b/packages/server/test/json-preview-smoke.js @@ -1,4 +1,9 @@ -import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js"; +import { + encodeBinaryForWire, + jsonPreviewReplacer, + reviveBinaryFromWire, + summarizeBinary, +} from "../json-preview.js"; import { serialize, toDisplayValue } from "../store.js"; import { safeSerialize } from "../src/api/dry-run-logger.js"; @@ -53,4 +58,18 @@ if (typed.file.length !== png.length || typed.file.type !== "Buffer") { throw new Error(`Uint8Array: ${JSON.stringify(typed)}`); } +const wired = encodeBinaryForWire({ file: png, n: 1n }); +if (wired.n !== "1" || wired.file.encoding !== "base64" || typeof wired.file.data !== "string") { + throw new Error(`encodeBinaryForWire: ${JSON.stringify(wired)}`); +} +const revived = reviveBinaryFromWire(JSON.parse(JSON.stringify(wired))); +if (!Buffer.isBuffer(revived.file) || !revived.file.equals(png)) { + throw new Error("reviveBinaryFromWire failed to restore bytes"); +} +const previewOnly = { type: "Buffer", length: png.length, preview: "89", truncated: true }; +const left = reviveBinaryFromWire(previewOnly); +if (Buffer.isBuffer(left)) { + throw new Error("preview-only summary should not revive"); +} + console.log("json-preview-smoke: ok"); diff --git a/packages/server/test/profiles-smoke.js b/packages/server/test/profiles-smoke.js new file mode 100644 index 0000000..6462715 --- /dev/null +++ b/packages/server/test/profiles-smoke.js @@ -0,0 +1,101 @@ +import { migrate, db } from "../db.js"; +import { + assertProfileName, + deleteProfile, + encodeProfileConfig, + getProfilePlain, + listProfileUsages, + upsertProfile, +} from "../profiles-store.js"; +import { mergeProfileConfig } from "../profile-config.js"; +import { parseScriptStep } from "../workflow-parse.js"; + +await migrate(); + +function assert(cond, msg) { + if (!cond) throw new Error(msg); +} + +async function assertThrows(fn, match) { + try { + await fn(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (match && !message.includes(match)) { + throw new Error(`threw "${message}", expected to include "${match}"`); + } + return; + } + throw new Error(`expected to throw (${match ?? "any error"})`); +} + +const merged = mergeProfileConfig( + { url: "https://n.example/ops", fingerprint: true }, + { fingerprint: "comic-rss" }, +); +assert(merged.url === "https://n.example/ops", "profile url kept"); +assert(merged.fingerprint === "comic-rss", "overlay wins"); + +const emptyOverlay = mergeProfileConfig({ url: "https://n.example/ops" }, {}); +assert(emptyOverlay.url === "https://n.example/ops", "empty overlay"); + +const emptyWins = mergeProfileConfig({ url: "https://n.example/ops" }, { url: "" }); +assert(emptyWins.url === "", "empty string overlay wins"); + +const profileOnly = parseScriptStep({ + profile: "ops-ntfy", + config: { fingerprint: "x" }, +}); +assert(profileOnly.kind === "script", "profile step kind"); +assert(profileOnly.script === "", "script supplied by profile at runtime"); +assert(profileOnly.profile === "ops-ntfy", "profile name"); + +const both = parseScriptStep({ + script: "ntfy.js", + profile: "ops-ntfy", +}); +assert(both.script === "ntfy.js" && both.profile === "ops-ntfy", "script + profile"); + +await assertThrows( + () => parseScriptStep({ profile: "ops", set: { expression: "1" } }), + "profile and set", +); + +assert(assertProfileName("ops-ntfy") === "ops-ntfy", "valid name"); +await assertThrows(() => assertProfileName("ops ntfy"), "invalid profile name"); +await assertThrows(() => encodeProfileConfig([]), "config must be an object"); + +const owner = "default"; +const name = `profiles_smoke_${Date.now()}`; +const created = await upsertProfile({ + owner, + name, + script: "ntfy.js", + config: { url: "$VAR_ntfy_channel" }, + description: "smoke", +}); +assert(created.name === name, "created"); +assert(created.config.url === "$VAR_ntfy_channel", "config roundtrip"); +assert(created.script === "ntfy.js", "script locked on profile"); + +const fetched = await getProfilePlain(owner, name); +assert(fetched?.id === created.id, "get by owner/name"); + +const updated = await upsertProfile({ + owner, + name, + script: "send-email.js", + config: { service: "Gmail" }, + description: "now mail", +}); +assert(updated.id === created.id, "upsert same row"); +assert(updated.script === "send-email.js", "script may change"); + +const usages = listProfileUsages(owner, name); +assert(Array.isArray(usages) && usages.length === 0, "unused profile"); + +await deleteProfile(created.id); +assert((await getProfilePlain(owner, name)) == null, "deleted"); + +await db.destroy(); +console.log("profiles-smoke: ok"); diff --git a/packages/server/test/set-dry-run-smoke.js b/packages/server/test/set-dry-run-smoke.js new file mode 100644 index 0000000..8e6c963 --- /dev/null +++ b/packages/server/test/set-dry-run-smoke.js @@ -0,0 +1,81 @@ +/** + * Smoke: set dry-run path (evaluateJsonata + envelope), mirrors + * POST /scripts/set/dry-run in src/api/scripts.js. + */ +import assert from "node:assert/strict"; +import { evaluateJsonata, SET_STEP_SCRIPT } from "../workflow-parse.js"; +import { normalizeStepResult } from "../step-result.js"; +import { safeSerialize } from "../src/api/dry-run-logger.js"; + +assert.equal(SET_STEP_SCRIPT, "set"); + +async function dryRunSet({ expression, data, context = {} }) { + if (typeof expression !== "string" || !expression.trim()) { + throw new Error("expression is required"); + } + const incomingContext = + context != null && typeof context === "object" && !Array.isArray(context) + ? context + : {}; + const config = { expression }; + const ctx = { data: data ?? null, context: incomingContext, config }; + const value = await evaluateJsonata(expression, ctx); + const result = normalizeStepResult( + { output: value, context: incomingContext, skipRemaining: false }, + incomingContext, + SET_STEP_SCRIPT, + ); + return { + status: "success", + output: safeSerialize(result.output), + context: safeSerialize(result.context), + skipRemaining: result.skipRemaining, + }; +} + +{ + const res = await dryRunSet({ + expression: '{"title": data.title, "ok": true}', + data: { title: "Hello" }, + context: { runId: "dry" }, + }); + assert.equal(res.status, "success"); + assert.deepEqual(res.output, { title: "Hello", ok: true }); + assert.deepEqual(res.context, { runId: "dry" }); + assert.equal(res.skipRemaining, false); +} + +{ + const res = await dryRunSet({ + expression: "data.count + 1", + data: { count: 41 }, + context: { token: "abc" }, + }); + assert.equal(res.output, 42); + // Sets never mutate context + assert.deepEqual(res.context, { token: "abc" }); +} + +{ + let hit = false; + try { + await dryRunSet({ expression: " ", data: {} }); + } catch (err) { + hit = true; + assert.match(String(err.message), /expression is required/); + } + assert.equal(hit, true); +} + +{ + let hit = false; + try { + await dryRunSet({ expression: "data.{" , data: {} }); + } catch (err) { + hit = true; + assert.ok(err instanceof Error); + } + assert.equal(hit, true); +} + +console.log("set-dry-run-smoke: ok"); diff --git a/packages/server/variables-store.js b/packages/server/variables-store.js index 2ed9f64..35ee535 100644 --- a/packages/server/variables-store.js +++ b/packages/server/variables-store.js @@ -1,190 +1 @@ -import { randomUUID } from "node:crypto"; -import { db } from "./db.js"; -import { assertOwner } from "./fs-store.js"; - -const MAX_NAME_LENGTH = 128; -const MAX_STRING_BYTES = 64 * 1024; -const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/; -export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]); - -function nowIso() { - return new Date().toISOString(); -} - -function httpError(message, statusCode = 400) { - const err = new Error(message); - err.statusCode = statusCode; - return err; -} - -/** - * @param {unknown} name - * @returns {string} - */ -export function assertVariableName(name) { - if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) { - throw httpError("invalid variable name"); - } - if (name.length > MAX_NAME_LENGTH) { - throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`); - } - return name; -} - -/** - * @param {unknown} type - * @returns {"string" | "number" | "boolean"} - */ -export function assertVariableType(type) { - if (type !== "string" && type !== "number" && type !== "boolean") { - throw httpError("type must be string, number, or boolean"); - } - return type; -} - -/** - * @param {"string" | "number" | "boolean"} type - * @param {unknown} value - * @returns {string} - */ -export function encodeVariableValue(type, value) { - if (type === "string") { - if (typeof value !== "string") { - throw httpError("value must be a string"); - } - if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) { - throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`); - } - return value; - } - if (type === "number") { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw httpError("value must be a finite number"); - } - return String(value); - } - if (typeof value !== "boolean") { - throw httpError("value must be a boolean"); - } - return value ? "true" : "false"; -} - -/** - * @param {"string" | "number" | "boolean"} type - * @param {string} stored - * @returns {string | number | boolean} - */ -export function decodeVariableValue(type, stored) { - if (type === "string") return stored; - if (type === "number") { - const n = Number(stored); - if (!Number.isFinite(n)) { - throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`); - } - return n; - } - if (stored === "true") return true; - if (stored === "false") return false; - throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`); -} - -/** - * @param {Record} row - */ -function publicVariable(row) { - const type = assertVariableType(row.type); - return { - id: row.id, - owner: row.owner, - name: row.name, - type, - value: decodeVariableValue(type, String(row.value ?? "")), - created_at: row.created_at, - updated_at: row.updated_at, - }; -} - -/** - * @param {{ owner?: string }} [filters] - */ -export async function listVariables(filters = {}) { - let q = db("variables") - .select("id", "owner", "name", "type", "value", "created_at", "updated_at") - .orderBy("owner", "asc") - .orderBy("name", "asc"); - if (filters.owner) { - q = q.where("owner", assertOwner(filters.owner)); - } - const rows = await q; - return rows.map((row) => publicVariable(row)); -} - -/** - * @param {string} id - */ -export async function getVariableById(id) { - const row = await db("variables").where({ id }).first(); - return row ? publicVariable(row) : null; -} - -/** - * @param {{ owner: string, name: string, type: unknown, value: unknown }} opts - */ -export async function upsertVariable({ owner, name, type, value }) { - const ownerName = assertOwner(owner); - const variableName = assertVariableName(name); - const variableType = assertVariableType(type); - const encoded = encodeVariableValue(variableType, value); - const now = nowIso(); - const existing = await db("variables") - .where({ owner: ownerName, name: variableName }) - .first(); - - if (existing) { - await db("variables") - .where({ id: existing.id }) - .update({ - type: variableType, - value: encoded, - updated_at: now, - }); - return getVariableById(existing.id); - } - - const id = randomUUID(); - await db("variables").insert({ - id, - owner: ownerName, - name: variableName, - type: variableType, - value: encoded, - created_at: now, - updated_at: now, - }); - return getVariableById(id); -} - -/** - * @param {string} id - * @returns {Promise} - */ -export async function deleteVariable(id) { - const n = await db("variables").where({ id }).del(); - return n > 0; -} - -/** - * Typed primitive for an owner/name. Returns null if missing. - * @param {string} owner - * @param {string} name - * @returns {Promise} - */ -export async function getVariablePlain(owner, name) { - const ownerName = assertOwner(owner); - const variableName = assertVariableName(name); - const row = await db("variables") - .where({ owner: ownerName, name: variableName }) - .first(); - if (!row) return null; - return decodeVariableValue(assertVariableType(row.type), String(row.value ?? "")); -} +export * from "./src/stores/variables-store.js"; diff --git a/packages/server/workflow-duplicate.js b/packages/server/workflow-duplicate.js index d540b40..d7ae641 100644 --- a/packages/server/workflow-duplicate.js +++ b/packages/server/workflow-duplicate.js @@ -1,36 +1,14 @@ import yaml from "yaml"; +import { + ensureWorkflowFilename, + suggestCopyFilename, +} from "@jerapah-flow/shared"; import { newWorkflowFilename, workflowFileStem, } from "./workflow-normalize.js"; -export function ensureWorkflowFilename(file) { - const trimmed = String(file ?? "").trim(); - if (!trimmed) return ""; - return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`; -} - -/** - * Legacy human-readable copy name (kept for UI hints). - * @param {string} file - * @param {string[]} existingFiles - */ -export function suggestCopyFilename(file, existingFiles = []) { - const name = ensureWorkflowFilename(file) || "workflow.yaml"; - const match = name.match(/^(.*?)(\.ya?ml)$/i); - const base = match ? match[1] : name; - const ext = match ? match[2] : ".yaml"; - const existing = new Set(existingFiles); - - const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/); - const root = copyMatch ? copyMatch[1] : base; - const candidate = (i) => - i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`; - - let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1; - while (existing.has(candidate(n))) n += 1; - return candidate(n); -} +export { ensureWorkflowFilename, suggestCopyFilename }; /** * UUID-based duplicate filename (default for new duplicates). diff --git a/packages/server/workflow-examples.js b/packages/server/workflow-examples.js new file mode 100644 index 0000000..fda497b --- /dev/null +++ b/packages/server/workflow-examples.js @@ -0,0 +1,79 @@ +import fs from "fs"; +import path from "path"; +import yaml from "yaml"; +import { EXAMPLE_WORKFLOWS_DIR } from "./paths.js"; + +/** + * @param {string} id + * @returns {string | null} safe basename without extension, or null if invalid + */ +export function assertExampleWorkflowId(id) { + if (typeof id !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(id)) { + return null; + } + return id; +} + +/** + * Absolute path to an example YAML, or null if missing/unsafe. + * @param {string} id + */ +export function exampleWorkflowPath(id) { + const safe = assertExampleWorkflowId(id); + if (!safe) return null; + const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, `${safe}.yaml`); + const resolved = path.resolve(filePath); + if ( + resolved !== EXAMPLE_WORKFLOWS_DIR && + !resolved.startsWith(EXAMPLE_WORKFLOWS_DIR + path.sep) + ) { + return null; + } + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { + return null; + } + return resolved; +} + +/** + * @returns {{ id: string, name: string, description: string }[]} + */ +export function listExampleWorkflows() { + if (!fs.existsSync(EXAMPLE_WORKFLOWS_DIR)) return []; + return fs + .readdirSync(EXAMPLE_WORKFLOWS_DIR) + .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) + .sort() + .map((f) => { + const id = f.replace(/\.ya?ml$/i, ""); + const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, f); + let name = id; + let description = ""; + try { + const parsed = yaml.parse(fs.readFileSync(filePath, "utf8")) ?? {}; + if (typeof parsed.name === "string" && parsed.name.trim()) { + name = parsed.name.trim(); + } + if (parsed.description != null) { + description = String(parsed.description).trim(); + } + } catch { + // keep id as name + } + return { id, name, description }; + }); +} + +/** + * @param {string} id + * @returns {{ id: string, content: string } | null} + */ +export function readExampleWorkflow(id) { + const filePath = exampleWorkflowPath(id); + if (!filePath) return null; + const safe = assertExampleWorkflowId(id); + return { + id: /** @type {string} */ (safe), + content: fs.readFileSync(filePath, "utf8"), + }; +} diff --git a/packages/server/workflow-http-routes.js b/packages/server/workflow-http-routes.js new file mode 100644 index 0000000..4537cb0 --- /dev/null +++ b/packages/server/workflow-http-routes.js @@ -0,0 +1,159 @@ +import { HTTP_METHODS } from "@jerapah-flow/shared"; +import { + checkAnyHttpAuth, + resolveAuthMechanisms, + resolveUnauthorizedSpec, + sendHttpPageOrJson, + sendSuccessPage, +} from "./http-trigger-auth.js"; + +/** + * Rebuild METHOD+path → workflow map from loaded workflows. + * + * @param {Map} workflows + * @param {Map} httpRoutes + * @param {{ + * namespacedPath: (owner: string, path: unknown) => string, + * log: { debug: Function, warn: Function }, + * }} deps + */ +export function rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log }) { + httpRoutes.clear(); + + for (const [key, { owner, workflow }] of workflows) { + if (workflow.enabled === false) { + log.debug(`Skipping disabled workflow HTTP triggers (${key})`); + continue; + } + + for (const trigger of workflow.triggers ?? []) { + if (trigger.type !== "HTTP") continue; + + const method = String(trigger.method ?? "POST").toUpperCase(); + const url = namespacedPath(owner, trigger.path); + const routeKey = `${method} ${url}`; + + if (httpRoutes.has(routeKey)) { + log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`); + continue; + } + httpRoutes.set(routeKey, { key, owner, trigger }); + log.debug(`Mapped HTTP trigger ${routeKey} (${key})`); + } + } +} + +/** + * Register the /u/* Fastify wildcard once; subsequent rebuilds only refresh the map. + * + * @param {import("fastify").FastifyInstance} server + * @param {(req: import("fastify").FastifyRequest, reply: import("fastify").FastifyReply) => any} handler + * @param {{ registered: boolean }} state + * @param {{ log: { debug: Function } }} deps + */ +export function ensureHttpWildcardRoute(server, handler, state, { log }) { + if (state.registered) return; + state.registered = true; + server.route({ + method: HTTP_METHODS, + url: "/u/*", + handler, + }); + log.debug("Registered HTTP trigger wildcard dispatcher /u/*"); +} + +/** + * Build the HTTP trigger request handler bound to registry state. + * + * @param {{ + * httpRoutes: Map, + * workflows: Map, + * namespacedPath: (owner: string, path: unknown) => string, + * enqueueWorkflow: (key: string, ctx: any, trigger: any) => Promise, + * }} deps + */ +export function createHttpTriggerHandler({ + httpRoutes, + workflows, + namespacedPath, + enqueueWorkflow, +}) { + /** + * @param {import("fastify").FastifyRequest} req + * @param {import("fastify").FastifyReply} reply + */ + return async function dispatchHttpTrigger(req, reply) { + const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? ""; + const url = `/u/${String(wildcard).replace(/^\/+/, "")}`; + const method = String(req.method ?? "GET").toUpperCase(); + const routeKey = `${method} ${url}`; + const mapped = httpRoutes.get(routeKey); + + if (!mapped) { + return reply.code(404).send({ error: "not found" }); + } + + const entry = workflows.get(mapped.key); + if (!entry || entry.workflow?.enabled === false) { + return reply.code(404).send({ error: "workflow disabled" }); + } + + // Prefer live trigger from current workflow YAML (auth/response edits) + const liveTrigger = + (entry.workflow.triggers ?? []).find((t) => { + if (t?.type !== "HTTP") return false; + const m = String(t.method ?? "POST").toUpperCase(); + const p = namespacedPath(entry.owner, t.path); + return m === method && p === url; + }) ?? mapped.trigger; + + if ( + liveTrigger.auth != null && + liveTrigger.auth !== false && + !(Array.isArray(liveTrigger.auth) && liveTrigger.auth.length === 0) + ) { + const mechanisms = await resolveAuthMechanisms(liveTrigger.auth); + if (mechanisms.length === 0) { + const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null); + return sendHttpPageOrJson(reply, status, pageName, { + error: "unauthorized", + }); + } + const ok = await checkAnyHttpAuth(req, mechanisms, { + owner: entry.owner, + workflowKey: mapped.key, + }); + if (!ok) { + const { status, pageName } = resolveUnauthorizedSpec( + liveTrigger, + mechanisms[0], + ); + return sendHttpPageOrJson(reply, status, pageName, { + error: "unauthorized", + }); + } + } + + const result = await enqueueWorkflow( + mapped.key, + { data: req.body }, + { type: "http", detail: `${method} ${url}` }, + ); + if (result.status === "failed") { + return reply.code(result.runId ? 500 : 404).send({ + runId: result.runId, + status: result.status, + error: result.error, + }); + } + + const defaultBody = { + runId: result.runId, + status: result.status, + }; + if (typeof liveTrigger.response === "string" && liveTrigger.response) { + return sendSuccessPage(reply, liveTrigger.response, defaultBody); + } + return reply.code(202).send(defaultBody); + }; +} diff --git a/packages/server/workflow-migrate.js b/packages/server/workflow-migrate.js new file mode 100644 index 0000000..b159de4 --- /dev/null +++ b/packages/server/workflow-migrate.js @@ -0,0 +1,51 @@ +import fs from "fs"; +import path from "path"; +import { LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR } from "./paths.js"; +import { log } from "./logger.js"; + +/** + * Recursively copy a directory. + * @param {string} src + * @param {string} dest + */ +function copyDir(src, dest) { + 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); + } +} + +/** + * True when WORKFLOWS_DIR has no owner subdirectories. + * @param {string} dir + */ +function isEmptyWorkflowsDir(dir) { + if (!fs.existsSync(dir)) return true; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return !entries.some((e) => e.isDirectory()); +} + +/** + * One-shot: copy packages/server/workflows → data/workflows when the new + * store is empty and the legacy tree still exists. + * Does not copy from examples/workflows. + */ +export function migrateLegacyWorkflowsIfNeeded() { + if (!isEmptyWorkflowsDir(WORKFLOWS_DIR)) return false; + if (!fs.existsSync(LEGACY_WORKFLOWS_DIR)) return false; + const legacyEntries = fs.readdirSync(LEGACY_WORKFLOWS_DIR, { + withFileTypes: true, + }); + if (!legacyEntries.some((e) => e.isDirectory())) return false; + + fs.mkdirSync(WORKFLOWS_DIR, { recursive: true }); + copyDir(LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR); + log.info( + { from: LEGACY_WORKFLOWS_DIR, to: WORKFLOWS_DIR }, + "migrated legacy workflows into instance store", + ); + return true; +} diff --git a/packages/server/workflow-parse.js b/packages/server/workflow-parse.js index 059b707..b3c722b 100644 --- a/packages/server/workflow-parse.js +++ b/packages/server/workflow-parse.js @@ -1,4 +1,5 @@ import jsonata from "jsonata"; +export { namespacedPath } from "@jerapah-flow/shared"; export const SET_STEP_SCRIPT = "set"; @@ -6,19 +7,23 @@ export const SET_STEP_SCRIPT = "set"; * @typedef {{ alias: string, from: string }} NeedEdge * @typedef {{ * kind: "script", - * script: string, + * script: string, + * profile: string | null, * config: unknown | null, * expression?: undefined, + * name: string | null, * id: string | null, - * needsKind: "none" | "list" | "map", - * needs: NeedEdge[], - * when: string | null, - * }} ParsedScriptStep + * needsKind: "none" | "list" | "map", + * needs: NeedEdge[], + * when: string | null, + * }} ParsedScriptStep * @typedef {{ * kind: "set", - * script: typeof SET_STEP_SCRIPT, - * config: { expression: string }, + * script: typeof SET_STEP_SCRIPT, + * profile: null, + * config: { expression: string }, * expression: string, + * name: string | null, * id: string | null, * needsKind: "none" | "list" | "map", * needs: NeedEdge[], @@ -66,16 +71,6 @@ export async function evaluateJsonata(source, ctx) { return await result; } -/** - * Resolve an HTTP path under the owner namespace: /notify -> /u/alice/notify - * @param {string} owner - * @param {string} triggerPath - */ -export function namespacedPath(owner, triggerPath) { - const cleaned = String(triggerPath).replace(/^\/+/, ""); - return `/u/${owner}/${cleaned}`; -} - /** * @param {unknown} step * @returns {ParsedStep} @@ -85,7 +80,9 @@ export function parseScriptStep(step) { return { kind: "script", script: step, + profile: null, config: null, + name: null, id: null, needsKind: "none", needs: [], @@ -97,25 +94,35 @@ export function parseScriptStep(step) { } const hasScript = step.script != null && step.script !== ""; + const hasProfile = step.profile != null && step.profile !== ""; const hasSet = step.set != null; if (hasScript && hasSet) { throw new Error("Step cannot have both script and set"); } + if (hasProfile && hasSet) { + throw new Error("Step cannot have both profile and set"); + } if (hasSet) { return parseSetStep(step); } - if (hasScript) { - if (typeof step.script !== "string") { - throw new Error(`Invalid script step: ${JSON.stringify(step)}`); - } + if (hasProfile && typeof step.profile !== "string") { + throw new Error(`Invalid profile: ${JSON.stringify(step.profile)}`); + } + if (hasScript && typeof step.script !== "string") { + throw new Error(`Invalid script step: ${JSON.stringify(step)}`); + } + + if (hasScript || hasProfile) { const { needsKind, needs } = parseNeeds(step.needs); return { kind: "script", - script: step.script, + script: hasScript ? step.script : "", + profile: hasProfile ? step.profile : null, config: step.config ?? null, + name: parseOptionalName(step.name), id: parseOptionalId(step.id), needsKind, needs, @@ -265,8 +272,10 @@ function parseSetStep(step) { return { kind: "set", script: SET_STEP_SCRIPT, + profile: null, config: { expression }, expression, + name: parseOptionalName(step.name), id: parseOptionalId(step.id), needsKind, needs, @@ -287,6 +296,19 @@ function parseWhen(when) { return when; } +/** + * @param {unknown} name + * @returns {string | null} + */ +function parseOptionalName(name) { + if (name == null || name === "") return null; + if (typeof name !== "string") { + throw new Error(`Invalid step name: ${JSON.stringify(name)}`); + } + const trimmed = name.trim(); + return trimmed || null; +} + /** * @param {unknown} id * @returns {string | null} diff --git a/packages/server/workflow-trash.js b/packages/server/workflow-trash.js index fac75e1..f0cb464 100644 --- a/packages/server/workflow-trash.js +++ b/packages/server/workflow-trash.js @@ -20,7 +20,7 @@ function trashFilePath(owner, file) { /** * @param {string} deletedAtIso */ -export function trashAgeMs(deletedAtIso) { +function trashAgeMs(deletedAtIso) { return Date.now() - Date.parse(deletedAtIso); } diff --git a/packages/server/workflow-validate-warnings.js b/packages/server/workflow-validate-warnings.js index 91aad42..2f81dd4 100644 --- a/packages/server/workflow-validate-warnings.js +++ b/packages/server/workflow-validate-warnings.js @@ -85,6 +85,7 @@ export function collectWorkflowWarnings(content) { try { const step = parseScriptStep(raw); if (step.kind === "set") continue; + if (step.profile && !step.script) continue; const resolved = resolveScriptRef(step.script); if (resolved.error) { warnings.push({ diff --git a/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml b/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml index 8e8690b..a5aebbd 100644 --- a/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml +++ b/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml @@ -18,7 +18,7 @@ scripts: - script: fetch-binary.js - script: ntfy.js config: - url: https://ntfy.sh/jerapah-flow + url: $VAR_ntfy_channel triggers: - type: HTTP method: POST diff --git a/packages/server/workflows/default/dev-joplin-daily.yaml b/packages/server/workflows/default/dev-joplin-daily.yaml deleted file mode 100644 index 4010125..0000000 --- a/packages/server/workflows/default/dev-joplin-daily.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: Joplin nightly daily log -description: | - POST joplin-auto /api/logs/run at 04:00 (sync + yearly/monthly/today), then ntfy. - Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel. -scripts: - - script: plugin/joplin-api - config: - url: http://10.8.0.6:3040/api/logs/run - method: POST - token: $SECRET_joplin_setup_token - timeoutMs: 600000 - - set: - expression: | - { - "title": data.ok ? "Bullet journal" : "Bullet journal failed", - "message": data.ok - ? "The bullet journal for " & data.httpResponse.date & " has been created." - : data.message - } - - script: ntfy.js - config: - url: $VAR_ntfy_channel -triggers: - - type: cron - schedule: "0 4 * * *" - - type: HTTP - method: POST - path: /dev-joplin-daily -enabled: false diff --git a/packages/server/workflows/default/dev-joplin-get-note.yaml b/packages/server/workflows/default/dev-joplin-get-note.yaml deleted file mode 100644 index a3d22f1..0000000 --- a/packages/server/workflows/default/dev-joplin-get-note.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: Joplin get note -description: | - GET a Joplin note by id from joplin-api. Input (data): id (32-char hex). - Secret joplin_api_token (JOPLIN_API_TOKEN / API_KEYS). Returns the full note. -scripts: - - script: plugin/joplin-api - config: - url: http://10.8.0.6:3030/notes - method: GET - token: $SECRET_joplin_api_token - timeoutMs: 60000 -triggers: - - type: workflow - - type: HTTP - method: POST - path: /dev-joplin-get-note -enabled: false diff --git a/packages/server/workflows/default/dev-joplin-sync.yaml b/packages/server/workflows/default/dev-joplin-sync.yaml deleted file mode 100644 index f87b5f0..0000000 --- a/packages/server/workflows/default/dev-joplin-sync.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: Joplin nightly sync -description: | - POST joplin-auto /api/sync at 03:00, then ntfy success or failure. - Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel. -scripts: - - script: plugin/joplin-api - config: - url: http://10.8.0.6:3040/api/sync - method: POST - token: $SECRET_joplin_setup_token - timeoutMs: 600000 - - set: - expression: | - { - "title": data.ok ? "Joplin sync ok" : "Joplin sync failed", - "message": data.message - } - - script: ntfy.js - config: - url: $VAR_ntfy_channel -triggers: - - type: cron - schedule: "0 3 * * *" - - type: HTTP - method: POST - path: /dev-joplin-sync -enabled: false diff --git a/packages/server/workflows/default/dev-zte-sms.yaml b/packages/server/workflows/default/dev-zte-sms.yaml deleted file mode 100644 index 1a85a56..0000000 --- a/packages/server/workflows/default/dev-zte-sms.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: dev-zte-sms -description: | - Send an SMS via a ZTE modem web UI. - Input (data): to, message - Password is the named secret zte_modem_password ($SECRET_). -scripts: - - script: plugin/send-sms - config: - url: http://192.168.5.1/reqproc/proc_post - password: $SECRET_sms_secret -triggers: - - type: workflow - - type: HTTP - method: POST - path: /dev-zte-sms - auth: - - 0f78d6d7-bd44-45d7-a826-f51c027b767f diff --git a/packages/server/workflows/default/fetch-devto.yaml b/packages/server/workflows/default/fetch-devto.yaml deleted file mode 100644 index cc67579..0000000 --- a/packages/server/workflows/default/fetch-devto.yaml +++ /dev/null @@ -1,12 +0,0 @@ -name: fetch-devto -scripts: - - script: fetch-html.js - config: - url: https://dev.to/t/productivity/top/week - selector: "#substories h2" - outputVar: titles - jsonata: "$[].text" -triggers: - - type: HTTP - method: POST - path: /fetch-dev-to diff --git a/packages/server/workflows/default/jadwal-sholat-jakart.yaml b/packages/server/workflows/default/jadwal-sholat-jakart.yaml deleted file mode 100644 index bbc1da7..0000000 --- a/packages/server/workflows/default/jadwal-sholat-jakart.yaml +++ /dev/null @@ -1,50 +0,0 @@ -name: Jadwal Solat Jakarta ntfy -scripts: - - id: fetch - script: fetch-http.js - config: - url: https://kemenag.go.id/api/prayer-times/1301 - method: GET - headers: - Content-Type: application/json - Accept: application/json - - id: transform - script: jsonata.js - config: - expression: |- - { - "title": data.httpResponse.data.date, - "message": "Imsak:" & data.httpResponse.data.imsak & "\n" & - "Subuh:" & data.httpResponse.data.subuh & "\n" & - "Dzuhur:" & data.httpResponse.data.dzuhur & "\n" & - "Ashar:" & data.httpResponse.data.ashar & "\n" & - "Maghrib:" & data.httpResponse.data.maghrib & "\n" & - "Isya:" & data.httpResponse.data.isya - } - needs: - - fetch - - id: ntfy - script: ntfy.js - config: - url: $VAR_ntfy_channel - fingerprint: true - needs: - - transform - - id: slack - script: ntfy.js - config: - url: $VAR_ntfy_channel2 - fingerprint: fingerprint:ntfy2 - needs: - - transform - - script: slack-webhook.js - config: - webhookUrlSecret: slack_deploy_webhook - fingerprint: true - fingerprintMaxAge: 1h - text: $INPUT_message - needs: - - transform -triggers: - - type: cron - schedule: 0 5 * * * diff --git a/packages/server/workflows/default/registers.yaml b/packages/server/workflows/default/registers.yaml index 53d8c98..4fdbdf1 100644 --- a/packages/server/workflows/default/registers.yaml +++ b/packages/server/workflows/default/registers.yaml @@ -1,20 +1,6 @@ scripts: - - time-to-ntfy-example.yaml - - test.yaml - cron-example.yaml - - fetch-devto.yaml - - comic-monkeyuser-to-ntfy.yaml - - time-and-comic-to-ntfy.yaml - - rss-selfhst-to-ntfy.yaml - - send-gmail.yaml - - test-send-gmail.yaml - - track.yaml - - rss-devto-to-ntfy.yaml - - test-minio.yaml - - dev-joplin-sync.yaml - - dev-joplin-daily.yaml - - dev-joplin-get-note.yaml - - web-dave.yaml - - jadwal-sholat-jakart.yaml - detect-example-changes.yaml - - test-sftp.yaml + - time-to-ntfy-example.yaml + - comic-monkeyuser-to-ntfy.yaml + - afb272d4-b217-49ac-8c8b-755a6d8dac4a.yaml diff --git a/packages/server/workflows/default/rss-devto-to-ntfy.yaml b/packages/server/workflows/default/rss-devto-to-ntfy.yaml deleted file mode 100644 index 16b7cb3..0000000 --- a/packages/server/workflows/default/rss-devto-to-ntfy.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: RSS - selfh.st first item to ntfy (copy) -scripts: - - script: fetch-rss-feed.js - config: - url: https://selfh.st/rss/ - outputVar: item - jsonata: items[0] - - script: fingerprint.js - config: - key: selfhst-latest - jsonata: "data.item.guid ? data.item.guid : data.item.link" - - script: jsonata.js - config: - expression: | - { - "title": data.item.title, - "message": data.item.contentSnippet & "\n" & data.item.link, - "attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined) - } - - script: ntfy.js - config: - url: https://n.0dev.web.id/system -triggers: - - type: HTTP - method: POST - path: /selfhst-rss-rss-devto-to-ntfy -enabled: false diff --git a/packages/server/workflows/default/rss-selfhst-to-ntfy.yaml b/packages/server/workflows/default/rss-selfhst-to-ntfy.yaml deleted file mode 100644 index 900bfe2..0000000 --- a/packages/server/workflows/default/rss-selfhst-to-ntfy.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: RSS - selfh.st first item to ntfy -scripts: - - script: fetch-rss-feed.js - config: - url: https://selfh.st/rss/ - outputVar: item - jsonata: items[0] - - script: fingerprint.js - config: - key: selfhst-latest - jsonata: "data.item.guid ? data.item.guid : data.item.link" - - script: jsonata.js - config: - expression: | - { - "title": data.item.title, - "message": data.item.contentSnippet & "\n" & data.item.link, - "attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined) - } - - script: ntfy.js - config: - url: https://n.0dev.web.id/system -triggers: - - type: HTTP - method: POST - path: /selfhst-rss diff --git a/packages/server/workflows/default/send-gmail.yaml b/packages/server/workflows/default/send-gmail.yaml deleted file mode 100644 index e0d5889..0000000 --- a/packages/server/workflows/default/send-gmail.yaml +++ /dev/null @@ -1,40 +0,0 @@ -name: send-gmail -description: | - Send email via Gmail SMTP. Configure user/from and the named secret, - then call from other workflows with trigger-workflow.js (name: send-gmail). - - Input (data): - to optional recipient(s); defaults to your Gmail below - subject required - text plain body (aliases: body, message) - html optional HTML body - from, cc, bcc, replyTo, priority, headers optional -scripts: - - script: jsonata.js - config: - expression: | - { - "to": $exists(data.to) ? data.to : "nasyarobby@gmail.com", - "from": data.from, - "subject": data.subject, - "text": $exists(data.text) ? data.text : ($exists(data.body) ? data.body : data.message), - "html": data.html, - "cc": data.cc, - "bcc": data.bcc, - "replyTo": data.replyTo, - "priority": data.priority, - "headers": data.headers - } - - script: send-email.js - config: - service: Gmail - user: elevent16th@gmail.com - from: elevent16th@gmail.com - passwordSecret: gmail_app_password -triggers: - - type: workflow - - type: HTTP - method: POST - path: /send-gmail - auth: - - 0f78d6d7-bd44-45d7-a826-f51c027b767f diff --git a/packages/server/workflows/default/test-minio.yaml b/packages/server/workflows/default/test-minio.yaml deleted file mode 100644 index fc745f1..0000000 --- a/packages/server/workflows/default/test-minio.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: Test MinIO -scripts: - - script: fetch-binary.js - config: - outputVar: file - url: https://nsrb:error403@dav.0dev.web.id/ntfy/IyM9784UdG4S - filename: test.png - - script: s3.js - config: - action: write - endpoint: http://localhost:9000 - bucket: default - forcePathStyle: true - accessKeyIdSecret: minio_user - secretAccessKeySecret: minio_pass - key: file.png -triggers: - - type: HTTP - method: POST - path: /new diff --git a/packages/server/workflows/default/test-send-gmail.yaml b/packages/server/workflows/default/test-send-gmail.yaml deleted file mode 100644 index 0e67a39..0000000 --- a/packages/server/workflows/default/test-send-gmail.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: test-send-gmail -description: | - Kick send-gmail with sample data. Edit the payload below, then Run - (or POST /u/default/test-send-gmail). -scripts: - - script: trigger-workflow.js - config: - name: send-gmail - expression: | - { - "subject": "JerapahFlow test", - "text": "Hello from test-send-gmail", - "html": "

Hello from test-send-gmail

" - } -triggers: - - type: HTTP - method: POST - path: /test-send-gmail diff --git a/packages/server/workflows/default/test-sftp.yaml b/packages/server/workflows/default/test-sftp.yaml deleted file mode 100644 index 0835216..0000000 --- a/packages/server/workflows/default/test-sftp.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: SFTP Test -scripts: - - script: remote-fs.js - config: - protocol: sftp - action: list - host: localhost - path: /Users/nsrb/ - username: nsrb - port: 22 - password: JKLjkl - - script: jsonata.js - config: - expression: '{"message": $join(data.entries.name, "\n")}' - - script: ntfy.js - config: - url: $VAR_ntfy_channel - fingerprint: "false" -triggers: - - type: HTTP - method: POST - path: /new diff --git a/packages/server/workflows/default/test.yaml b/packages/server/workflows/default/test.yaml deleted file mode 100644 index e8ec686..0000000 --- a/packages/server/workflows/default/test.yaml +++ /dev/null @@ -1,12 +0,0 @@ -name: jsonata -scripts: - - plugin/get-current-time - - script: jsonata.js - config: - expression: '{"message": data.datetime & " " & data.processId}' - - ntfy.js -triggers: - - type: HTTP - method: POST - path: /mt - diff --git a/packages/server/workflows/default/time-and-comic-to-ntfy.yaml b/packages/server/workflows/default/time-and-comic-to-ntfy.yaml deleted file mode 100644 index ab1156d..0000000 --- a/packages/server/workflows/default/time-and-comic-to-ntfy.yaml +++ /dev/null @@ -1,37 +0,0 @@ -name: time and comic to ntfy -description: > - Fan-in from two scripts (current time + monkeyuser comic), then send to ntfy. -scripts: - - id: time - script: plugin/get-current-time - - - id: comic - script: fetch-html.js - config: - url: "https://www.monkeyuser.com/" - outputVar: "httpResponse" - selector: ".comic img" - jsonata: | - {"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]} - - - id: compose - script: jsonata.js - needs: [time, comic] - config: - expression: | - { - "title": data.comic.httpResponse.title, - "message": data.time.datetime & " " & data.comic.httpResponse.title, - "attach": data.comic.httpResponse.url - } - - - id: notify - script: ntfy.js - needs: [compose] - config: - url: https://ntfy.sh/jerapah-flow - -triggers: - - type: HTTP - method: POST - path: /time-and-comic diff --git a/packages/server/workflows/default/time-to-ntfy-example.yaml b/packages/server/workflows/default/time-to-ntfy-example.yaml index 67dcdf3..148ecad 100644 --- a/packages/server/workflows/default/time-to-ntfy-example.yaml +++ b/packages/server/workflows/default/time-to-ntfy-example.yaml @@ -6,9 +6,7 @@ scripts: - script: plugin/get-current-time config: key: "" - - script: ntfy.js - config: - url: https://n.0dev.web.id/system + - profile: ntfy_default triggers: - type: HTTP method: POST diff --git a/packages/server/workflows/default/track.yaml b/packages/server/workflows/default/track.yaml deleted file mode 100644 index 5593b67..0000000 --- a/packages/server/workflows/default/track.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: Track Gojek Trip -scripts: - - script: fetch-http.js - config: - url: https://api.gojekapi.com/live/track/491c3132c2cc7043 - method: GET - headers: - Content-Type: application/json - Accept: application/json - Origin: https://track.gojek.com - - script: fingerprint.js - config: - key: gojek-track-status - jsonata: data.httpResponse.status - - script: jsonata.js - config: - expression: | - { - "title": data.httpResponse.customer_name, - "message": data.httpResponse.customer_name & " : " & data.httpResponse.status - } - - script: ntfy.js - config: - url: https://n.0dev.web.id/system -triggers: - - type: cron - schedule: "* * * * *" - - type: HTTP - method: GET - path: /new -enabled: false diff --git a/packages/server/workflows/default/web-dave.yaml b/packages/server/workflows/default/web-dave.yaml deleted file mode 100644 index 73d560d..0000000 --- a/packages/server/workflows/default/web-dave.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: Dab 0dev -scripts: - - script: list-webdav.js - config: - url: https://dav.0dev.web.id/books - path: / - includeDirectories: true - recursive: false - username: nsrb - passwordSecret: dav_0dev_password -triggers: - - type: HTTP - method: POST - path: /new diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..1f651e3 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,15 @@ +{ + "name": "@jerapah-flow/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3.2.4" + } +} diff --git a/packages/shared/src/index.js b/packages/shared/src/index.js new file mode 100644 index 0000000..a19bec6 --- /dev/null +++ b/packages/shared/src/index.js @@ -0,0 +1,4 @@ +export { isPlainObject } from "./is-plain-object.js"; +export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "./profile-config.js"; +export { ensureWorkflowFilename, suggestCopyFilename } from "./workflow-filename.js"; +export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js"; diff --git a/packages/shared/src/is-plain-object.js b/packages/shared/src/is-plain-object.js new file mode 100644 index 0000000..d8a3835 --- /dev/null +++ b/packages/shared/src/is-plain-object.js @@ -0,0 +1,3 @@ +export function isPlainObject(value) { + return value != null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/shared/src/profile-config.js b/packages/shared/src/profile-config.js new file mode 100644 index 0000000..fe4d57b --- /dev/null +++ b/packages/shared/src/profile-config.js @@ -0,0 +1,70 @@ +/** + * Shallow merge: step overlay keys replace profile defaults (including ""). + * Nested objects/arrays are replaced, not deep-merged. + * + * @param {unknown} profileConfig + * @param {unknown} stepConfig + * @returns {Record} + */ +export function mergeProfileConfig(profileConfig, stepConfig) { + const base = + profileConfig != null && typeof profileConfig === "object" && !Array.isArray(profileConfig) + ? { ...profileConfig } + : {}; + if (stepConfig == null || typeof stepConfig !== "object" || Array.isArray(stepConfig)) { + return base; + } + return { ...base, ...stepConfig }; +} + +/** + * Inverse of merge for Apply-to-card: keep only keys whose values differ from the profile. + * No profile → return the merged object as-is (full step config). + * + * @param {unknown} profileConfig + * @param {unknown} mergedConfig + * @returns {Record} + */ +export function overlayFromMerged(profileConfig, mergedConfig) { + const merged = + mergedConfig != null && typeof mergedConfig === "object" && !Array.isArray(mergedConfig) + ? { ...mergedConfig } + : {}; + const base = + profileConfig != null && typeof profileConfig === "object" && !Array.isArray(profileConfig) + ? profileConfig + : null; + if (!base) return merged; + + const overlay = {}; + for (const [key, value] of Object.entries(merged)) { + if (!Object.prototype.hasOwnProperty.call(base, key) || !sameConfigValue(base[key], value)) { + overlay[key] = value; + } + } + return overlay; +} + +function sameConfigValue(a, b) { + if (Object.is(a, b)) return true; + if (a == null || b == null) return a === b; + if (typeof a !== "object" || typeof b !== "object") return false; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + +/** + * @param {unknown} config + * @returns {boolean} + */ +export function configHasOverlay(config) { + return ( + config != null && + typeof config === "object" && + !Array.isArray(config) && + Object.keys(config).length > 0 + ); +} diff --git a/packages/shared/src/profile-config.test.js b/packages/shared/src/profile-config.test.js new file mode 100644 index 0000000..98c804a --- /dev/null +++ b/packages/shared/src/profile-config.test.js @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + configHasOverlay, + ensureWorkflowFilename, + isPlainObject, + mergeProfileConfig, + namespacedPath, + overlayFromMerged, +} from "./index.js"; + +describe("isPlainObject", () => { + it("accepts plain objects and rejects arrays/null/primitives", () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ a: 1 })).toBe(true); + expect(isPlainObject([])).toBe(false); + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject("x")).toBe(false); + }); +}); + +describe("mergeProfileConfig", () => { + it("shallow-merges step overlay over profile defaults", () => { + expect(mergeProfileConfig({ a: 1, b: 2 }, { b: 3, c: 4 })).toEqual({ + a: 1, + b: 3, + c: 4, + }); + expect(mergeProfileConfig(null, { x: 1 })).toEqual({ x: 1 }); + expect(mergeProfileConfig({ x: 1 }, null)).toEqual({ x: 1 }); + expect(mergeProfileConfig({ nested: { a: 1 } }, { nested: { b: 2 } })).toEqual({ + nested: { b: 2 }, + }); + }); +}); + +describe("overlayFromMerged", () => { + it("returns full merged when there is no profile", () => { + expect(overlayFromMerged(null, { a: 1, b: 2 })).toEqual({ a: 1, b: 2 }); + expect(overlayFromMerged(undefined, { x: 1 })).toEqual({ x: 1 }); + }); + + it("keeps only keys that differ from the profile", () => { + expect(overlayFromMerged({ a: 1, b: 2 }, { a: 1, b: 3, c: 4 })).toEqual({ + b: 3, + c: 4, + }); + expect(overlayFromMerged({ a: 1 }, { a: 1 })).toEqual({}); + expect(overlayFromMerged({ nested: { a: 1 } }, { nested: { a: 1 } })).toEqual({}); + expect(overlayFromMerged({ nested: { a: 1 } }, { nested: { b: 2 } })).toEqual({ + nested: { b: 2 }, + }); + }); + + it("treats empty-string override as a real overlay key", () => { + expect(overlayFromMerged({ url: "http://a" }, { url: "" })).toEqual({ url: "" }); + }); +}); + +describe("configHasOverlay", () => { + it("is true only for non-empty plain objects", () => { + expect(configHasOverlay({ a: 1 })).toBe(true); + expect(configHasOverlay({})).toBe(false); + expect(configHasOverlay(null)).toBe(false); + expect(configHasOverlay([])).toBe(false); + }); +}); + +describe("ensureWorkflowFilename", () => { + it("adds .yaml when missing and preserves yaml/yml", () => { + expect(ensureWorkflowFilename("cron")).toBe("cron.yaml"); + expect(ensureWorkflowFilename("cron.yaml")).toBe("cron.yaml"); + expect(ensureWorkflowFilename("cron.YML")).toBe("cron.YML"); + expect(ensureWorkflowFilename(" ")).toBe(""); + }); +}); + +describe("namespacedPath", () => { + it("builds /u// without leading slash duplication", () => { + expect(namespacedPath("default", "hooks/run")).toBe("/u/default/hooks/run"); + expect(namespacedPath("local", "/hooks/run")).toBe("/u/local/hooks/run"); + }); +}); diff --git a/packages/shared/src/workflow-filename.js b/packages/shared/src/workflow-filename.js new file mode 100644 index 0000000..158ba6d --- /dev/null +++ b/packages/shared/src/workflow-filename.js @@ -0,0 +1,27 @@ +export function ensureWorkflowFilename(file) { + const trimmed = String(file ?? "").trim(); + if (!trimmed) return ""; + return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`; +} + +/** + * Legacy human-readable copy name (kept for UI hints). + * @param {string} file + * @param {string[]} existingFiles + */ +export function suggestCopyFilename(file, existingFiles = []) { + const name = ensureWorkflowFilename(file) || "workflow.yaml"; + const match = name.match(/^(.*?)(\.ya?ml)$/i); + const base = match ? match[1] : name; + const ext = match ? match[2] : ".yaml"; + const existing = new Set(existingFiles); + + const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/); + const root = copyMatch ? copyMatch[1] : base; + const candidate = (i) => + i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`; + + let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1; + while (existing.has(candidate(n))) n += 1; + return candidate(n); +} diff --git a/packages/shared/src/workflow-path.js b/packages/shared/src/workflow-path.js new file mode 100644 index 0000000..9a6a7bd --- /dev/null +++ b/packages/shared/src/workflow-path.js @@ -0,0 +1,13 @@ +export const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; + +export function namespacedPath(owner, triggerPath) { + const cleaned = String(triggerPath ?? "").replace(/^\/+/, "").trim(); + return `/u/${owner}/${cleaned}`; +} + +export function hasWorkflowTrigger(workflow) { + if (!workflow || typeof workflow !== "object") return false; + const triggers = workflow.triggers; + if (!Array.isArray(triggers)) return false; + return triggers.some((t) => String(t?.type ?? "").toLowerCase() === "workflow"); +} diff --git a/packages/web/CONTRIBUTING.md b/packages/web/CONTRIBUTING.md new file mode 100644 index 0000000..1f83885 --- /dev/null +++ b/packages/web/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing (web UI) + +Conventions for editors, dialogs, and routing in `packages/web`. + +## Entity editing patterns + +### Modal + route deep-link (simple entities) + +Use a list page with a route-driven editor modal for: + +- Secrets (`/secrets`, `/secrets/new`, `/secrets/:owner/:name/edit`) +- Variables +- Profiles +- Auth profiles (`/auth`, …) +- Users + +Prefer [`useRouteDrivenModal`](src/hooks/useRouteDrivenModal.js) to open/close the modal from the URL, keep a single `openedRouteKey` guard, and navigate back to the list path on close. + +Navigate to `/…/new` or `/…/:id/edit` from list actions; do not open the editor with local state alone when a deep-link route exists. + +### Full-page editors + +Scripts and workflows use dedicated full-page editors (not list+modal). Keep create/edit as their own routes and leave the list page for browsing only. + +### Known exception: Responses + +The Responses page may keep inline editing (no modal / no full-page editor). Treat that as intentional; do not refactor it to modal+route unless product requirements change. + +## Components + +| Use | For | +|---|---| +| `ConfirmDialog` | Deletes and other yes/no confirmations | +| `FormInput` / `FormSelect` | Form fields in modals and pages | +| `Modal` | Custom dialog chrome when `ConfirmDialog` is not enough | + +## Naming: `*Dialog` vs `*Modal` + +- **`*Dialog`** — confirmations and pickers (e.g. `ConfirmDialog`, `AddTriggerDialog`, `AddScriptDialog`, `DuplicateWorkflowDialog`) +- **`*Modal`** — entity editors (e.g. `SecretEditorModal`, `ProfileEditorModal`, `AuthEditorModal`) + +When adding a new overlay, pick the suffix from the table above. Do not rename existing `*EditorModal` components solely for consistency with older code. diff --git a/packages/web/eslint.config.js b/packages/web/eslint.config.js new file mode 100644 index 0000000..6826439 --- /dev/null +++ b/packages/web/eslint.config.js @@ -0,0 +1,41 @@ +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import jsxA11y from "eslint-plugin-jsx-a11y"; +import globals from "globals"; + +export default [ + { ignores: ["dist/**"] }, + { + files: ["src/**/*.{js,jsx}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + }, + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + plugins: { + "react-hooks": reactHooks, + "jsx-a11y": jsxA11y, + }, + rules: { + ...js.configs.recommended.rules, + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "jsx-a11y/alt-text": "warn", + "jsx-a11y/anchor-has-content": "warn", + "jsx-a11y/aria-role": "warn", + "no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^([A-Z_]|Lu)", + caughtErrorsIgnorePattern: "^_", + }, + ], + }, + }, +]; diff --git a/packages/web/package.json b/packages/web/package.json index 64e7136..59f5b91 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,18 +6,21 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "lint": "eslint src", + "test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js src/lib/workflow-doc.test.js" }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@jerapah-flow/shared": "workspace:*", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.84.0", "@uiw/react-json-view": "2.0.0-alpha.43", + "@xyflow/react": "^12.11.3", "axios": "^1.11.0", "cronstrue": "^3.24.0", - "mermaid": "^11.9.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-icons": "^5.5.0", @@ -25,9 +28,14 @@ "yaml": "^2.8.1" }, "devDependencies": { + "@eslint/js": "^9.33.0", "@tailwindcss/vite": "^4.1.11", "@vitejs/plugin-react": "^5.0.0", "daisyui": "^5.0.50", + "eslint": "^9.33.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react-hooks": "^5.2.0", + "globals": "^16.3.0", "tailwindcss": "^4.1.11", "vite": "^7.1.2" } diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx index 351f59f..b0c3343 100644 --- a/packages/web/src/App.jsx +++ b/packages/web/src/App.jsx @@ -20,6 +20,7 @@ import { ResponsesPage } from "./pages/ResponsesPage.jsx"; import { UsersPage } from "./pages/UsersPage.jsx"; import { SecretsPage } from "./pages/SecretsPage.jsx"; import { VariablesPage } from "./pages/VariablesPage.jsx"; +import { ProfilesPage } from "./pages/ProfilesPage.jsx"; import { OpsPage } from "./pages/OpsPage.jsx"; import { BackupPage } from "./pages/BackupPage.jsx"; @@ -71,6 +72,9 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index cd07e4b..746d179 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -1,723 +1 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, opsApi } from "./client.js"; - -export function useBootstrap() { - return useQuery({ - queryKey: ["bootstrap"], - queryFn: async () => (await api.get("/auth/bootstrap")).data, - }); -} - -export function useMe() { - return useQuery({ - queryKey: ["me"], - queryFn: async () => { - try { - return (await api.get("/auth/me")).data; - } catch (err) { - if (err.response?.status === 401) return { user: null }; - throw err; - } - }, - retry: false, - }); -} - -export function useLogin() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.post("/auth/login", body)).data, - onSuccess: (data) => { - qc.setQueryData(["me"], data); - qc.invalidateQueries({ queryKey: ["bootstrap"] }); - }, - }); -} - -export function useRegister() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.post("/auth/register", body)).data, - onSuccess: (data) => { - qc.setQueryData(["me"], data); - qc.invalidateQueries({ queryKey: ["bootstrap"] }); - }, - }); -} - -export function useLogout() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await api.post("/auth/logout")).data, - onSuccess: () => { - qc.setQueryData(["me"], null); - qc.clear(); - }, - }); -} - -export function useDashboard() { - return useQuery({ - queryKey: ["dashboard"], - queryFn: async () => (await api.get("/dashboard")).data, - refetchInterval: (query) => - query.state.data?.running?.length ? 4000 : 15000, - }); -} - -export function useScripts() { - return useQuery({ - queryKey: ["scripts"], - queryFn: async () => (await api.get("/scripts")).data.scripts, - }); -} - -export function useScript(name, enabled = true) { - return useQuery({ - queryKey: ["scripts", name], - queryFn: async () => (await api.get(`/scripts/${encodeURIComponent(name)}`)).data, - enabled: Boolean(name) && enabled, - }); -} - -export function useSaveScript() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ name, content }) => - (await api.put(`/scripts/${encodeURIComponent(name)}`, { content })).data, - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["scripts", vars.name] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - qc.invalidateQueries({ queryKey: ["ops-status"] }); - }, - }); -} - -export function useCreatePlugin() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ id, content, description }) => - (await api.post("/plugins/create", { id, content, description })).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["ops-status"] }); - }, - }); -} - -export function useForkScript() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ name, id, description }) => - ( - await api.post(`/scripts/${encodeURIComponent(name)}/fork`, { - id, - description, - }) - ).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["ops-status"] }); - }, - }); -} - -export function useInstallPlugin() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.post("/plugins/install", body)).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["ops-status"] }); - }, - }); -} - -export function useDeleteScript() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (name) => - (await api.delete(`/scripts/${encodeURIComponent(name)}`)).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useDryRunScript() { - return useMutation({ - mutationFn: async ({ name, content, data, context, config, owner }) => - ( - await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, { - content, - data, - context, - config, - owner, - }) - ).data, - }); -} - -export function useWorkflows(owner) { - return useQuery({ - queryKey: ["workflows", owner ?? "all"], - queryFn: async () => { - const params = owner ? { owner } : {}; - return (await api.get("/workflows", { params })).data.workflows; - }, - }); -} - -export function useWorkflow(owner, file, enabled = true) { - return useQuery({ - queryKey: ["workflows", owner, file], - queryFn: async () => - (await api.get(`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`)) - .data, - enabled: Boolean(owner && file) && enabled, - }); -} - -export function useOwners() { - return useQuery({ - queryKey: ["owners"], - queryFn: async () => (await api.get("/owners")).data.owners, - }); -} - -export function useSaveWorkflow() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file, content, saveAnyway }) => - ( - await api.put( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, - { content, ...(saveAnyway ? { saveAnyway: true } : {}) }, - ) - ).data, - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - qc.invalidateQueries({ - queryKey: ["workflows", vars.owner, vars.file, "revisions"], - }); - qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); - }, - }); -} - -export function useSetWorkflowEnabled() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file, enabled }) => - ( - await api.patch( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, - { enabled }, - ) - ).data, - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useDeleteWorkflow() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file }) => - ( - await api.delete( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, - ) - ).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useDuplicateWorkflow() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file, destOwner, destFile }) => - ( - await api.post( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`, - { - ...(destOwner ? { owner: destOwner } : {}), - ...(destFile ? { file: destFile } : {}), - }, - ) - ).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useRunWorkflow() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file, data }) => - ( - await api.post( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/run`, - data !== undefined ? { data } : {}, - ) - ).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["runs"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useReregisterWorkflows() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await api.post("/workflows/reregister")).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useRuns(filters = {}) { - const { owner, workflow, status, limit } = filters; - return useQuery({ - queryKey: ["runs", { owner, workflow, status, limit }], - queryFn: async () => { - const params = {}; - if (owner) params.owner = owner; - if (workflow) params.workflow = workflow; - if (status) params.status = status; - if (limit) params.limit = limit; - return (await api.get("/runs", { params })).data.runs; - }, - }); -} - -export function useConsecutiveFailures(limit) { - return useQuery({ - queryKey: ["consecutive-failures", limit ?? "all"], - queryFn: async () => { - const params = {}; - if (limit) params.limit = limit; - return (await api.get("/consecutive-failures", { params })).data; - }, - }); -} - -export function useRun(id) { - return useQuery({ - queryKey: ["runs", id], - queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data, - enabled: Boolean(id), - refetchInterval: (query) => { - const status = query.state.data?.status; - return status === "running" || status === "queued" ? 1500 : false; - }, - }); -} - -export function useUsers() { - return useQuery({ - queryKey: ["users"], - queryFn: async () => (await api.get("/users")).data.users, - }); -} - -export function useCreateUser() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.post("/users", body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), - }); -} - -export function useUpdateUser() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ id, ...body }) => - (await api.patch(`/users/${encodeURIComponent(id)}`, body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), - }); -} - -export function useDeleteUser() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.delete(`/users/${encodeURIComponent(id)}`)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), - }); -} - -export function useSecrets(owner) { - return useQuery({ - queryKey: ["secrets", owner ?? "all"], - queryFn: async () => { - const params = owner ? { owner } : {}; - return (await api.get("/secrets", { params })).data.secrets; - }, - }); -} - -export function useUpsertSecret() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.put("/secrets", body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), - }); -} - -export function useDeleteSecret() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.delete(`/secrets/${encodeURIComponent(id)}`)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), - }); -} - -export function useVariables(owner, options = {}) { - return useQuery({ - queryKey: ["variables", owner ?? "all"], - queryFn: async () => { - const params = owner ? { owner } : {}; - return (await api.get("/variables", { params })).data.variables; - }, - ...options, - }); -} - -export function useUpsertVariable() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.put("/variables", body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }), - }); -} - -export function useDeleteVariable() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.delete(`/variables/${encodeURIComponent(id)}`)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }), - }); -} - -export function useKvNamespaces() { - return useQuery({ - queryKey: ["kv", "namespaces"], - queryFn: async () => (await api.get("/kv/namespaces")).data.namespaces, - }); -} - -export function useKv(filters = {}) { - const { namespace, q, limit, offset } = filters; - return useQuery({ - queryKey: ["kv", { namespace, q, limit, offset }], - queryFn: async () => { - const params = {}; - if (namespace) params.namespace = namespace; - if (q) params.q = q; - if (limit != null) params.limit = limit; - if (offset != null) params.offset = offset; - return (await api.get("/kv", { params })).data; - }, - }); -} - -export function useHttpPages() { - return useQuery({ - queryKey: ["http-pages"], - queryFn: async () => (await api.get("/http-pages")).data.pages, - }); -} - -export function useUpsertHttpPage() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.put("/http-pages", body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }), - }); -} - -export function useDeleteHttpPage() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.delete(`/http-pages/${encodeURIComponent(id)}`)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }), - }); -} - -export function useHttpAuths() { - return useQuery({ - queryKey: ["http-auths"], - queryFn: async () => (await api.get("/http-auths")).data.auths, - }); -} - -export function useUpsertHttpAuth() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body) => (await api.put("/http-auths", body)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }), - }); -} - -export function useDeleteHttpAuth() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.delete(`/http-auths/${encodeURIComponent(id)}`)).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }), - }); -} - -/** Fetch plaintext literals only (not encrypted secrets). */ -export async function fetchHttpAuthLiterals(id) { - return (await api.get(`/http-auths/${encodeURIComponent(id)}/reveal`)).data; -} - -export function useOpsStatus(enabled = true) { - return useQuery({ - queryKey: ["ops-status"], - queryFn: async () => (await opsApi.get("/status")).data, - enabled, - retry: false, - refetchInterval: enabled ? 3000 : false, - }); -} - -export function useOpsPause() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await opsApi.post("/pause")).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsResume() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await opsApi.post("/resume")).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsReload() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await opsApi.post("/reload")).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["ops-status"] }); - qc.invalidateQueries({ queryKey: ["workflows"] }); - }, - }); -} - -export function useOpsRestart() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ force = false } = {}) => - (await opsApi.post("/restart", { force })).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsScale() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ workers, force = false }) => - (await opsApi.post("/scale", { workers, force })).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsHttpStart() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await opsApi.post("/http/start")).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsHttpStop() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async () => (await opsApi.post("/http/stop")).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsProcessRestart() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ pmId }) => - (await opsApi.post("/restart", { pmId: Number(pmId) })).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useOpsBumpGeneration() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (reason) => - (await opsApi.post("/generation/bump", { reason })).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - -export function useWorkflowTrash() { - return useQuery({ - queryKey: ["workflows", "trash"], - queryFn: async () => (await api.get("/workflows/trash")).data.items, - }); -} - -export function useRestoreWorkflowTrash() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.post(`/workflows/trash/${encodeURIComponent(id)}/restore`)).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function usePurgeWorkflowTrash() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (id) => - (await api.delete(`/workflows/trash/${encodeURIComponent(id)}`)).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); - }, - }); -} - -export function useWorkflowRevisions(owner, file, enabled = true) { - return useQuery({ - queryKey: ["workflows", owner, file, "revisions"], - queryFn: async () => - ( - await api.get( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions`, - ) - ).data, - enabled: Boolean(owner && file) && enabled, - }); -} - -export function useWorkflowRevision(owner, file, revision) { - return useQuery({ - queryKey: ["workflows", owner, file, "revisions", revision], - queryFn: async () => - ( - await api.get( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}`, - ) - ).data, - enabled: Boolean(owner && file && revision != null), - }); -} - -export function useRevertWorkflowRevision() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, file, revision, saveAnyway }) => - ( - await api.post( - `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}/revert`, - saveAnyway ? { saveAnyway: true } : {}, - ) - ).data, - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); - qc.invalidateQueries({ - queryKey: ["workflows", vars.owner, vars.file, "revisions"], - }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useCreateWorkflow() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ owner, content, file, saveAnyway }) => - ( - await api.post(`/workflows/${encodeURIComponent(owner)}`, { - content, - ...(file ? { file } : {}), - ...(saveAnyway ? { saveAnyway: true } : {}), - }) - ).data, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - }, - }); -} - -export function useDownloadWorkflowBackup() { - return useMutation({ - mutationFn: async () => { - const res = await api.get("/workflows/backup", { responseType: "blob" }); - const disposition = res.headers["content-disposition"] ?? ""; - const match = disposition.match(/filename="([^"]+)"/); - const filename = match?.[1] ?? "jerapah-flow-backup.zip"; - const url = URL.createObjectURL(res.data); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - return { ok: true }; - }, - }); -} - -export function useRestoreWorkflowBackup() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ file, mode }) => { - const buffer = await file.arrayBuffer(); - const zipBase64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); - return (await api.post("/workflows/backup/restore", { zipBase64, mode })).data; - }, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - qc.invalidateQueries({ queryKey: ["owners"] }); - qc.invalidateQueries({ queryKey: ["scripts"] }); - qc.invalidateQueries({ queryKey: ["dashboard"] }); - qc.invalidateQueries({ queryKey: ["ops-status"] }); - }, - }); -} - +export * from "./hooks/index.js"; diff --git a/packages/web/src/api/hooks/auth.js b/packages/web/src/api/hooks/auth.js new file mode 100644 index 0000000..2152e5d --- /dev/null +++ b/packages/web/src/api/hooks/auth.js @@ -0,0 +1,91 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useBootstrap() { + return useQuery({ + queryKey: ["bootstrap"], + queryFn: async () => (await api.get("/auth/bootstrap")).data, + }); +} + +export function useMe() { + return useQuery({ + queryKey: ["me"], + queryFn: async () => { + try { + return (await api.get("/auth/me")).data; + } catch (err) { + if (err.response?.status === 401) return { user: null }; + throw err; + } + }, + retry: false, + }); +} + +export function useLogin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.post("/auth/login", body)).data, + onSuccess: (data) => { + qc.setQueryData(["me"], data); + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + }, + }); +} + +export function useRegister() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.post("/auth/register", body)).data, + onSuccess: (data) => { + qc.setQueryData(["me"], data); + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + }, + }); +} + +export function useLogout() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await api.post("/auth/logout")).data, + onSuccess: () => { + qc.setQueryData(["me"], null); + qc.clear(); + }, + }); +} + +export function useUsers() { + return useQuery({ + queryKey: ["users"], + queryFn: async () => (await api.get("/users")).data.users, + }); +} + +export function useCreateUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.post("/users", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), + }); +} + +export function useUpdateUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, ...body }) => + (await api.patch(`/users/${encodeURIComponent(id)}`, body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), + }); +} + +export function useDeleteUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/users/${encodeURIComponent(id)}`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), + }); +} + diff --git a/packages/web/src/api/hooks/http.js b/packages/web/src/api/hooks/http.js new file mode 100644 index 0000000..0d7654a --- /dev/null +++ b/packages/web/src/api/hooks/http.js @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useHttpPages() { + return useQuery({ + queryKey: ["http-pages"], + queryFn: async () => (await api.get("/http-pages")).data.pages, + }); +} + +export function useUpsertHttpPage() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/http-pages", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }), + }); +} + +export function useDeleteHttpPage() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/http-pages/${encodeURIComponent(id)}`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }), + }); +} + +export function useHttpAuths() { + return useQuery({ + queryKey: ["http-auths"], + queryFn: async () => (await api.get("/http-auths")).data.auths, + }); +} + +export function useUpsertHttpAuth() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/http-auths", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }), + }); +} + +export function useDeleteHttpAuth() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/http-auths/${encodeURIComponent(id)}`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }), + }); +} + +/** Fetch plaintext literals only (not encrypted secrets). */ + +/** Fetch plaintext literals only (not encrypted secrets). */ +export async function fetchHttpAuthLiterals(id) { + return (await api.get(`/http-auths/${encodeURIComponent(id)}/reveal`)).data; +} + diff --git a/packages/web/src/api/hooks/index.js b/packages/web/src/api/hooks/index.js new file mode 100644 index 0000000..33ed695 --- /dev/null +++ b/packages/web/src/api/hooks/index.js @@ -0,0 +1,10 @@ +export * from "./auth.js"; +export * from "./scripts.js"; +export * from "./workflows.js"; +export * from "./runs.js"; +export * from "./secrets.js"; +export * from "./variables.js"; +export * from "./profiles.js"; +export * from "./kv.js"; +export * from "./http.js"; +export * from "./ops.js"; diff --git a/packages/web/src/api/hooks/kv.js b/packages/web/src/api/hooks/kv.js new file mode 100644 index 0000000..19a6cf8 --- /dev/null +++ b/packages/web/src/api/hooks/kv.js @@ -0,0 +1,34 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useKvNamespaces() { + return useQuery({ + queryKey: ["kv", "namespaces"], + queryFn: async () => (await api.get("/kv/namespaces")).data.namespaces, + }); +} + +export function useKv(filters = {}) { + const { namespace, q, limit, offset } = filters; + return useQuery({ + queryKey: ["kv", { namespace, q, limit, offset }], + queryFn: async () => { + const params = {}; + if (namespace) params.namespace = namespace; + if (q) params.q = q; + if (limit != null) params.limit = limit; + if (offset != null) params.offset = offset; + return (await api.get("/kv", { params })).data; + }, + }); +} + +export function useDeleteKv() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ namespace, key }) => + (await api.delete("/kv", { params: { namespace, key } })).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["kv"] }), + }); +} + diff --git a/packages/web/src/api/hooks/ops.js b/packages/web/src/api/hooks/ops.js new file mode 100644 index 0000000..db9a9ea --- /dev/null +++ b/packages/web/src/api/hooks/ops.js @@ -0,0 +1,83 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { opsApi } from "../client.js"; + +export function useOpsStatus(enabled = true) { + return useQuery({ + queryKey: ["ops-status"], + queryFn: async () => (await opsApi.get("/status")).data, + enabled, + retry: false, + refetchInterval: enabled ? 3000 : false, + }); +} + +export function useOpsPause() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await opsApi.post("/pause")).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsResume() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await opsApi.post("/resume")).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsReload() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await opsApi.post("/reload")).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["ops-status"] }); + qc.invalidateQueries({ queryKey: ["workflows"] }); + }, + }); +} + +export function useOpsRestart() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ force = false } = {}) => + (await opsApi.post("/restart", { force })).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsScale() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ workers, force = false }) => + (await opsApi.post("/scale", { workers, force })).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsHttpStart() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await opsApi.post("/http/start")).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsHttpStop() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await opsApi.post("/http/stop")).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + +export function useOpsProcessRestart() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ pmId }) => + (await opsApi.post("/restart", { pmId: Number(pmId) })).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), + }); +} + diff --git a/packages/web/src/api/hooks/profiles.js b/packages/web/src/api/hooks/profiles.js new file mode 100644 index 0000000..be5874f --- /dev/null +++ b/packages/web/src/api/hooks/profiles.js @@ -0,0 +1,44 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useProfiles(owner, options = {}) { + return useQuery({ + queryKey: ["profiles", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/profiles", { params })).data.profiles; + }, + ...options, + }); +} + +export function useProfileUsage(id, enabled = true) { + return useQuery({ + queryKey: ["profiles", "usage", id], + queryFn: async () => + (await api.get(`/profiles/${encodeURIComponent(id)}/usage`)).data.usages, + enabled: Boolean(id) && enabled, + }); +} + +export function useUpsertProfile() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/profiles", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }), + }); +} + +export function useDeleteProfile() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, force }) => + ( + await api.delete(`/profiles/${encodeURIComponent(id)}`, { + params: force ? { force: "1" } : {}, + }) + ).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }), + }); +} + diff --git a/packages/web/src/api/hooks/runs.js b/packages/web/src/api/hooks/runs.js new file mode 100644 index 0000000..429642c --- /dev/null +++ b/packages/web/src/api/hooks/runs.js @@ -0,0 +1,56 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useDashboard() { + return useQuery({ + queryKey: ["dashboard"], + queryFn: async () => (await api.get("/dashboard")).data, + refetchInterval: (query) => + query.state.data?.running?.length ? 4000 : 15000, + }); +} + +export function useRuns(filters = {}) { + const { owner, workflow, status, trigger, after, before, limit, offset, sort, order } = filters; + return useQuery({ + queryKey: ["runs", { owner, workflow, status, trigger, after, before, limit, offset, sort, order }], + queryFn: async () => { + const params = {}; + if (owner) params.owner = owner; + if (workflow) params.workflow = workflow; + if (status) params.status = status; + if (trigger) params.trigger = trigger; + if (after) params.after = after; + if (before) params.before = before; + if (limit != null) params.limit = limit; + if (offset != null) params.offset = offset; + if (sort) params.sort = sort; + if (order) params.order = order; + return (await api.get("/runs", { params })).data; + }, + }); +} + +export function useConsecutiveFailures(limit) { + return useQuery({ + queryKey: ["consecutive-failures", limit ?? "all"], + queryFn: async () => { + const params = {}; + if (limit) params.limit = limit; + return (await api.get("/consecutive-failures", { params })).data; + }, + }); +} + +export function useRun(id) { + return useQuery({ + queryKey: ["runs", id], + queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data, + enabled: Boolean(id), + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === "running" || status === "queued" ? 1500 : false; + }, + }); +} + diff --git a/packages/web/src/api/hooks/scripts.js b/packages/web/src/api/hooks/scripts.js new file mode 100644 index 0000000..cef656e --- /dev/null +++ b/packages/web/src/api/hooks/scripts.js @@ -0,0 +1,108 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useScripts() { + return useQuery({ + queryKey: ["scripts"], + queryFn: async () => (await api.get("/scripts")).data.scripts, + }); +} + +export function useScript(name, enabled = true) { + return useQuery({ + queryKey: ["scripts", name], + queryFn: async () => (await api.get(`/scripts/${encodeURIComponent(name)}`)).data, + enabled: Boolean(name) && enabled, + }); +} + +export function useSaveScript() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ name, content }) => + (await api.put(`/scripts/${encodeURIComponent(name)}`, { content })).data, + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["scripts", vars.name] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + qc.invalidateQueries({ queryKey: ["ops-status"] }); + }, + }); +} + +export function useCreatePlugin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, content, description }) => + (await api.post("/plugins/create", { id, content, description })).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["ops-status"] }); + }, + }); +} + +export function useForkScript() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ name, id, description }) => + ( + await api.post(`/scripts/${encodeURIComponent(name)}/fork`, { + id, + description, + }) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["ops-status"] }); + }, + }); +} + +export function useInstallPlugin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.post("/plugins/install", body)).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["ops-status"] }); + }, + }); +} + +export function useDeleteScript() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (name) => + (await api.delete(`/scripts/${encodeURIComponent(name)}`)).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useDryRunScript() { + return useMutation({ + mutationFn: async ({ + name, + content, + expression, + data, + context, + config, + owner, + }) => + ( + await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, { + ...(content != null ? { content } : {}), + ...(expression != null ? { expression } : {}), + data, + context, + config, + owner, + }) + ).data, + }); +} + diff --git a/packages/web/src/api/hooks/secrets.js b/packages/web/src/api/hooks/secrets.js new file mode 100644 index 0000000..b2a9aa4 --- /dev/null +++ b/packages/web/src/api/hooks/secrets.js @@ -0,0 +1,30 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useSecrets(owner) { + return useQuery({ + queryKey: ["secrets", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/secrets", { params })).data.secrets; + }, + }); +} + +export function useUpsertSecret() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/secrets", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), + }); +} + +export function useDeleteSecret() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/secrets/${encodeURIComponent(id)}`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), + }); +} + diff --git a/packages/web/src/api/hooks/variables.js b/packages/web/src/api/hooks/variables.js new file mode 100644 index 0000000..1a03c2e --- /dev/null +++ b/packages/web/src/api/hooks/variables.js @@ -0,0 +1,31 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useVariables(owner, options = {}) { + return useQuery({ + queryKey: ["variables", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/variables", { params })).data.variables; + }, + ...options, + }); +} + +export function useUpsertVariable() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/variables", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }), + }); +} + +export function useDeleteVariable() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/variables/${encodeURIComponent(id)}`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }), + }); +} + diff --git a/packages/web/src/api/hooks/workflows.js b/packages/web/src/api/hooks/workflows.js new file mode 100644 index 0000000..4945fe0 --- /dev/null +++ b/packages/web/src/api/hooks/workflows.js @@ -0,0 +1,280 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../client.js"; + +export function useWorkflows(owner) { + return useQuery({ + queryKey: ["workflows", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/workflows", { params })).data.workflows; + }, + }); +} + +export function useWorkflow(owner, file, enabled = true) { + return useQuery({ + queryKey: ["workflows", owner, file], + queryFn: async () => + (await api.get(`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`)) + .data, + enabled: Boolean(owner && file) && enabled, + }); +} + +export function useOwners() { + return useQuery({ + queryKey: ["owners"], + queryFn: async () => (await api.get("/owners")).data.owners, + }); +} + +export function useWorkflowExamples() { + return useQuery({ + queryKey: ["workflow-examples"], + queryFn: async () => (await api.get("/workflow-examples")).data.examples, + }); +} + +export function useSaveWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, content, saveAnyway }) => + ( + await api.put( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, + { content, ...(saveAnyway ? { saveAnyway: true } : {}) }, + ) + ).data, + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + qc.invalidateQueries({ + queryKey: ["workflows", vars.owner, vars.file, "revisions"], + }); + qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); + }, + }); +} + +export function useSetWorkflowEnabled() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, enabled }) => + ( + await api.patch( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, + { enabled }, + ) + ).data, + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useDeleteWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file }) => + ( + await api.delete( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`, + ) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useDuplicateWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, destOwner, destFile }) => + ( + await api.post( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`, + { + ...(destOwner ? { owner: destOwner } : {}), + ...(destFile ? { file: destFile } : {}), + }, + ) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useRunWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, data }) => + ( + await api.post( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/run`, + data !== undefined ? { data } : {}, + ) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["runs"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useReregisterWorkflows() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => (await api.post("/workflows/reregister")).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useWorkflowTrash() { + return useQuery({ + queryKey: ["workflows", "trash"], + queryFn: async () => (await api.get("/workflows/trash")).data.items, + }); +} + +export function useRestoreWorkflowTrash() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.post(`/workflows/trash/${encodeURIComponent(id)}/restore`)).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function usePurgeWorkflowTrash() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/workflows/trash/${encodeURIComponent(id)}`)).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows", "trash"] }); + }, + }); +} + +export function useWorkflowRevisions(owner, file, enabled = true) { + return useQuery({ + queryKey: ["workflows", owner, file, "revisions"], + queryFn: async () => + ( + await api.get( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions`, + ) + ).data, + enabled: Boolean(owner && file) && enabled, + }); +} + +export function useWorkflowRevision(owner, file, revision) { + return useQuery({ + queryKey: ["workflows", owner, file, "revisions", revision], + queryFn: async () => + ( + await api.get( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}`, + ) + ).data, + enabled: Boolean(owner && file && revision != null), + }); +} + +export function useRevertWorkflowRevision() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, revision, saveAnyway }) => + ( + await api.post( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}/revert`, + saveAnyway ? { saveAnyway: true } : {}, + ) + ).data, + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] }); + qc.invalidateQueries({ + queryKey: ["workflows", vars.owner, vars.file, "revisions"], + }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useCreateWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, content, file, saveAnyway }) => + ( + await api.post(`/workflows/${encodeURIComponent(owner)}`, { + content, + ...(file ? { file } : {}), + ...(saveAnyway ? { saveAnyway: true } : {}), + }) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useDownloadWorkflowBackup() { + return useMutation({ + mutationFn: async () => { + const res = await api.get("/workflows/backup", { responseType: "blob" }); + const disposition = res.headers["content-disposition"] ?? ""; + const match = disposition.match(/filename="([^"]+)"/); + const filename = match?.[1] ?? "jerapah-flow-backup.zip"; + const url = URL.createObjectURL(res.data); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + return { ok: true }; + }, + }); +} + +export function useRestoreWorkflowBackup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ file, mode }) => { + const buffer = await file.arrayBuffer(); + const zipBase64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); + return (await api.post("/workflows/backup/restore", { zipBase64, mode })).data; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["scripts"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + qc.invalidateQueries({ queryKey: ["ops-status"] }); + }, + }); +} + diff --git a/packages/web/src/components/AuthEditorModal.jsx b/packages/web/src/components/AuthEditorModal.jsx index 8b01e7d..67977c0 100644 --- a/packages/web/src/components/AuthEditorModal.jsx +++ b/packages/web/src/components/AuthEditorModal.jsx @@ -6,6 +6,7 @@ import { useHttpPages, useUpsertHttpAuth, } from "../api/hooks.js"; +import { FormInput, FormSelect } from "./FormControls.jsx"; function emptyCred(source = "literal") { return { source, value: "", kv: "", namespace: "", secret: "" }; @@ -120,15 +121,15 @@ function CredentialFields({ label, cred, onChange, allowEmpty, masked }) {

{label}

- + {cred.source === "literal" ? (
- onChange({ ...cred, value: e.target.value, keep: false })} placeholder={ @@ -168,16 +169,16 @@ function CredentialFields({ label, cred, onChange, allowEmpty, masked }) { {cred.source === "kv" ? ( <> - onChange({ ...cred, namespace: e.target.value })} /> - onChange({ ...cred, kv: e.target.value })} @@ -191,8 +192,8 @@ function CredentialFields({ label, cred, onChange, allowEmpty, masked }) { label="Secret name" hint="Encrypted secret — value is never shown here. Manage it on the Secrets page." > - onChange({ ...cred, secret: e.target.value })} @@ -297,8 +298,8 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) { ) : (
- setForm({ ...form, name: e.target.value })} required @@ -308,15 +309,15 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) { - + {form.type === "bearer" ? ( @@ -345,8 +346,8 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) { {form.type === "header" ? ( <> - setForm({ ...form, header: e.target.value })} required @@ -362,9 +363,9 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) { ) : null} - setForm({ ...form, unauthorized_status: e.target.value })} min={100} @@ -374,8 +375,8 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) { - + {upsert.isError ? ( diff --git a/packages/web/src/components/CodeEditor.jsx b/packages/web/src/components/CodeEditor.jsx index 9640d6a..ba3f6c7 100644 --- a/packages/web/src/components/CodeEditor.jsx +++ b/packages/web/src/components/CodeEditor.jsx @@ -6,7 +6,7 @@ export function CodeEditor({ language, value, onChange, height = "50vh", readOnl const isMobile = typeof window !== "undefined" && window.innerWidth < 768; return ( -
+
void} props.onCancel + * @param {() => void} props.onConfirm + */ +export function ConfirmDialog({ + open, + title, + message, + confirmLabel = "Delete", + confirmClass = "btn-error", + loading, + pending, + confirmDisabled = false, + error, + onCancel, + onConfirm, +}) { + const busy = Boolean(loading ?? pending); + + return ( + +

{title}

+ {message ?
{message}
: null} + {error ?

{error}

: null} +
+ + +
+
+ ); +} diff --git a/packages/web/src/components/DuplicateWorkflowDialog.jsx b/packages/web/src/components/DuplicateWorkflowDialog.jsx index 168d004..3d0e088 100644 --- a/packages/web/src/components/DuplicateWorkflowDialog.jsx +++ b/packages/web/src/components/DuplicateWorkflowDialog.jsx @@ -1,22 +1,19 @@ -import { useState } from "react"; import { errorMessage } from "../api/client.js"; -import { useDuplicateWorkflow, useOwners } from "../api/hooks.js"; +import { useDuplicateWorkflow } from "../api/hooks.js"; import { useNotifications } from "../notifications.jsx"; export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) { const { notify } = useNotifications(); - const { data: owners = [] } = useOwners(); const duplicate = useDuplicateWorkflow(); - const [destOwner, setDestOwner] = useState(source.owner); function onSubmit(e) { e.preventDefault(); - if (destOwner === source.owner && duplicate.isPending) return; + if (duplicate.isPending) return; duplicate.mutate( { owner: source.owner, file: source.file, - destOwner, + destOwner: source.owner, }, { onSuccess: (data) => { @@ -33,7 +30,7 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic

Duplicate {source.key}?

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. + rewritten so triggers do not collide.

{warnUnsaved ? (

@@ -41,21 +38,6 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic

) : null} - {duplicate.isError ? (

{errorMessage(duplicate.error)}

) : null} diff --git a/packages/web/src/components/FormControls.jsx b/packages/web/src/components/FormControls.jsx new file mode 100644 index 0000000..d971e8e --- /dev/null +++ b/packages/web/src/components/FormControls.jsx @@ -0,0 +1,27 @@ +/** + * Shared DaisyUI form controls — always bordered + sm. + */ + +import { forwardRef } from "react"; + +export const FormInput = forwardRef(function FormInput({ className = "", ...props }, ref) { + return ; +}); + +export const FormSelect = forwardRef(function FormSelect({ className = "", children, ...props }, ref) { + return ( + + ); +}); + +export const FormTextarea = forwardRef(function FormTextarea({ className = "", ...props }, ref) { + return ( +