6 Commits
Author SHA1 Message Date
nsrb 721f15e900 Merge branch 'dev' of https://git.home.0dev.web.id/nsrb/jerapah-flow into dev
Deploy to Raspberry Pi / deploy (push) Successful in 1m44s
2026-08-21 10:34:34 +07:00
nsrb 877ea9e3f8 Merge branch 'main' into dev 2026-08-21 09:31:01 +07:00
nsrb 93b51dcaaa Update .gitea/workflows/deploy.yaml
Deploy to Raspberry Pi / deploy (push) Successful in 1m43s
2026-08-20 18:56:34 -04:00
nsrb eb7c318c13 Update .gitea/workflows/deploy.yaml
Deploy to Raspberry Pi / deploy (push) Failing after 4s
2026-08-20 18:40:36 -04:00
nsrb 864427b45c feat(workflows): add auto-disable feature for workflows on consecutive failures
Deploy to Raspberry Pi / deploy (push) Canceled after 0s
- Introduced `disableOnConsecutiveFailures` option in workflow triggers to automatically disable workflows after reaching a specified failure threshold.
- Updated related functions to handle the new feature, including persistence of the disabled state and reloading of registries.
- Enhanced UI components to support the new option, allowing users to toggle the auto-disable feature in the workflow configuration.
2026-08-21 05:37:24 +07:00
nsrb 46aa8ca327 add deploy.yml for branch dev pushes
Deploy to Raspberry Pi / deploy (push) Canceled after 0s
2026-08-20 18:09:14 -04:00
174 changed files with 5584 additions and 11069 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Deploy to Raspberry Pi
on:
push:
branches: [dev]
jobs:
deploy:
runs-on: home # must match a label on your act_runner
steps:
- name: Deploy
run: |
set -euo pipefail
# act_runner uses bash --noprofile --norc; load nvm/pnpm explicitly
export NVM_DIR="/home/nsrb/.nvm"
export PNPM_HOME="/home/nsrb/.local/share/pnpm"
# shellcheck disable=SC1091
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
export PATH="$PNPM_HOME/bin:$PATH"
APP_DIR=/home/nsrb/apps/jerapah-flow
cd "$APP_DIR"
git fetch origin dev
git checkout dev
git pull --ff-only origin dev
pnpm install --frozen-lockfile
pnpm build
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
-4
View File
@@ -1,5 +1,4 @@
node_modules/
.pnpm-store/
data/
logs/
*.db
@@ -10,9 +9,6 @@ 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/
+2 -14
View File
@@ -2,17 +2,6 @@
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/<owner>/` (gitignored; override with `JFLOW_WORKFLOWS_DIR`).
| Kind | In git? | Path |
|---|---|---|
| Live / personal YAML | No | `packages/server/data/workflows/<owner>/` |
| 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 |
@@ -138,14 +127,13 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger
```yaml
scripts:
- name: Notify to channel
script: plugin/my-plugin
- script: plugin/my-plugin
config:
url: http://10.8.0.6:3030/notes
token: $SECRET_joplin_api_token
```
Optional `name` is the display title in the editor and graph (falls back to the script filename). Canonical ref is `plugin/<id>` (`.js` suffix is optional).
Canonical ref is `plugin/<id>` (`.js` suffix is optional).
## Do not
+6 -25
View File
@@ -20,13 +20,7 @@ pnpm dev
- UI (dev): http://localhost:8500
- API: http://localhost:8700
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'
```
The first account created becomes **admin**. Later accounts are created from Users.
### Process modes
@@ -42,26 +36,13 @@ pnpm --dir packages/server reset-admin -- --username admin --password 'your-pass
| Kind | Name in YAML | Editable | Location |
|---|---|---|---|
| **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` |
| **User plugin** | `plugin/<id>` | Yes | `plugins/<id>/` |
| **Example source** | install → `plugin/<id>` | After install | `examples/plugins/<id>/` |
| **Plugin** | `plugin/<id>` | Yes | `plugins/<id>/` |
- 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`.
- 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/<id>/`.
## Workflows (instance data vs examples)
| Kind | Loaded by runner? | Location |
|---|---|---|
| **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (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`.
- 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`.
```bash
# Smoke
@@ -132,7 +113,6 @@ 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. |
@@ -144,7 +124,8 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev |
| `PORT` | `8700` | HTTP API port |
| `NODE_ENV` | — | Set `production` for secure cookies |
| `NODE_ENV` | — | Set `production` for secure cookies (unless overridden) |
| `COOKIE_SECURE` | (from `NODE_ENV`) | `true`/`false` — force Secure cookie flag. Use `false` for plain HTTP LAN access (`http://192.168.x.x`) |
Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { runId, status: "queued" }` immediately; poll `GET /api/runs/:id` for progress (`queued` → `running` → `success` \| `failed`). Cron remains an in-process producer that enqueues jobs on each tick.
-5
View File
@@ -1,8 +1,3 @@
/**
* 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");
@@ -1,27 +0,0 @@
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
@@ -1,22 +0,0 @@
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
+11 -4
View File
@@ -14,9 +14,16 @@
"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",
"test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test",
"lint": "pnpm --filter @jerapah-flow/web lint"
"migrate": "pnpm --filter @jerapah-flow/server migrate"
},
"packageManager": "pnpm@11.22.0"
"packageManager": "pnpm@10.25.0",
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
},
"dependencies": {
"rss-parser": "^3.13.0"
}
}
+3
View File
@@ -1,5 +1,6 @@
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");
@@ -74,3 +75,5 @@ export function satisfiesRange(version, range) {
}
return true;
}
void fileURLToPath;
+1 -1
View File
@@ -17,7 +17,7 @@ const LOCK_PATH = path.join(DATA_DIR, "ops.lock");
*/
/** @returns {ControlState} */
function defaultControlState() {
export function defaultControlState() {
return {
http: "running",
workers: 1,
+1 -2
View File
@@ -1,8 +1,7 @@
/**
* Local multi-process Ops UI ecosystem (`pnpm dev:pm2`).
* Dev ecosystem for `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");
+32 -27
View File
@@ -49,8 +49,14 @@ 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`, `.jpg`, or `.jpeg`.
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
* @returns {{ filePath: string, contentType: string } | null}
*/
export function resolveScriptIcon(name) {
@@ -59,7 +65,6 @@ 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()) {
@@ -73,6 +78,22 @@ 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
@@ -111,31 +132,6 @@ 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);
@@ -144,6 +140,15 @@ 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 [];
+390 -1
View File
@@ -1 +1,390 @@
export * from "./src/stores/http-auths-store.js";
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<string, unknown>} config
* @param {string} type
*/
export function publicConfig(config, type) {
/** @type {Record<string, unknown>} */
const out = {};
if (type === "bearer") {
out.token = redactField(config.token);
} else if (type === "basic") {
out.user = redactField(config.user);
out.password = redactField(config.password);
} else if (type === "header") {
out.header = typeof config.header === "string" ? config.header : null;
out.value = redactField(config.value);
}
return out;
}
/**
* @param {unknown} value
*/
function redactField(value) {
const kind = valueSourceKind(value);
if (kind === "missing") return { source: "missing" };
if (kind === "kv") {
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
return {
source: "kv",
kv: v.kv,
...(v.namespace != null ? { namespace: v.namespace } : {}),
};
}
if (kind === "secret") {
const v = /** @type {{ secret: string }} */ (value);
return { source: "secret", secret: v.secret };
}
return { source: "literal", set: true };
}
/**
* Validate and normalize auth config for storage.
* @param {string} type
* @param {unknown} config
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
*/
export function normalizeAuthConfig(type, config, opts = {}) {
const raw = config && typeof config === "object" && !Array.isArray(config)
? /** @type {Record<string, unknown>} */ (config)
: {};
const keep = opts.keepLiteralsFrom ?? {};
if (type === "bearer") {
return {
token: normalizeCredentialField(raw.token, keep.token, "token"),
};
}
if (type === "basic") {
return {
user: normalizeCredentialField(raw.user, keep.user, "user"),
password: normalizeCredentialField(raw.password, keep.password, "password", {
allowEmpty: true,
}),
};
}
// header
if (typeof raw.header !== "string" || raw.header.length === 0) {
const err = new Error("header name must be a non-empty string");
err.statusCode = 400;
throw err;
}
return {
header: raw.header,
value: normalizeCredentialField(raw.value, keep.value, "value"),
};
}
/**
* @param {unknown} value
* @param {unknown} previous
* @param {string} label
* @param {{ allowEmpty?: boolean }} [opts]
*/
function normalizeCredentialField(value, previous, label, opts = {}) {
// Explicit "keep previous literal" marker from UI when editing without re-entering
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
/** @type {{ keep?: boolean }} */ (value).keep === true
) {
if (typeof previous === "string") return previous;
if (previous && typeof previous === "object") return previous;
const err = new Error(`${label} was not previously set`);
err.statusCode = 400;
throw err;
}
if (value == null || value === "") {
if (opts.allowEmpty && value === "") return "";
// Allow empty password for basic
if (opts.allowEmpty && (value === "" || value == null)) {
if (typeof previous === "string") return previous;
return "";
}
const err = new Error(`${label} is required`);
err.statusCode = 400;
throw err;
}
if (typeof value === "string") return value;
if (typeof value === "object" && !Array.isArray(value)) {
const v = /** @type {Record<string, unknown>} */ (value);
if (typeof v.secret === "string" && v.secret.length > 0) {
return { secret: v.secret };
}
if (typeof v.kv === "string" && v.kv.length > 0) {
/** @type {{ kv: string, namespace?: string }} */
const out = { kv: v.kv };
if (typeof v.namespace === "string" && v.namespace.length > 0) {
out.namespace = v.namespace;
}
return out;
}
}
const err = new Error(
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
);
err.statusCode = 400;
throw err;
}
function parseConfig(raw) {
if (typeof raw !== "string") return raw ?? {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
function publicAuth(row, { includeConfig = true } = {}) {
const type = row.type;
const config = parseConfig(row.config);
return {
id: row.id,
name: row.name,
type,
...(includeConfig ? { config: publicConfig(config, type) } : {}),
unauthorized_status: row.unauthorized_status ?? null,
unauthorized_response: row.unauthorized_response ?? null,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
/**
* Internal: full config including literals (for runtime auth checks).
* @param {string} 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<string, string> } | null>}
*/
export async function revealHttpAuthLiterals(id) {
const internal = await getHttpAuthInternal(id);
if (!internal) return null;
/** @type {Record<string, string>} */
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<string, unknown> | 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<boolean>}
*/
export async function deleteHttpAuth(id) {
const authId = assertAuthId(id);
const n = await db("http_auths").where({ id: authId }).del();
return n > 0;
}
+5 -88
View File
@@ -11,21 +11,16 @@ 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 = toBuffer(value);
const buf = Buffer.isBuffer(value)
? value
: value instanceof ArrayBuffer
? Buffer.from(value)
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
return {
type: "Buffer",
@@ -35,84 +30,6 @@ 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<string, unknown>} */
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<string, unknown>} */ (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.
+399 -1
View File
@@ -1 +1,399 @@
export * from "./src/stores/kv-store.js";
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<unknown>}
*/
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<boolean>}
*/
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<string[]>}
*/
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 });
},
};
}
@@ -1,25 +0,0 @@
/**
* @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");
}
+1 -6
View File
@@ -13,13 +13,9 @@
"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": "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"
"test:workflow-history": "node test/workflow-history-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",
@@ -42,7 +38,6 @@
"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"
+1 -10
View File
@@ -3,12 +3,8 @@ 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 ??
@@ -18,10 +14,5 @@ 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");
+3
View File
@@ -8,6 +8,7 @@ import {
installPluginFromDirectory,
pluginDir,
} from "./plugin-store.js";
import { readManifestFile } from "./plugin-manifest.js";
const execFileAsync = promisify(execFile);
@@ -213,3 +214,5 @@ export function ensureExampleInstalled(exampleId) {
});
return { ...installed, already: false };
}
void readManifestFile;
-1
View File
@@ -1 +0,0 @@
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared";
-1
View File
@@ -1 +0,0 @@
export * from "./src/stores/profiles-store.js";
+187 -39
View File
@@ -23,20 +23,22 @@ import {
storedEnvelope,
} from "./step-result.js";
import * as fsStore from "./fs-store.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";
checkAnyHttpAuth,
resolveAuthMechanisms,
resolveUnauthorizedSpec,
sendHttpPageOrJson,
sendSuccessPage,
} from "./http-trigger-auth.js";
import { resolveConfigRefs } from "./config-refs.js";
import {
buildFailureAlertData,
resolveFailureTriggerConfig,
} from "./trigger-failure.js";
import { enqueueWorkflowJob } from "./workflow-queue.js";
import { ensureInitialRevision } from "./workflow-history.js";
import { ensureInitialRevision, recordRevision } from "./workflow-history.js";
import { workflowIdFromFile } from "./workflow-normalize.js";
import { publishReload } from "./control-bus.js";
/**
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
@@ -44,6 +46,17 @@ import { ensureInitialRevision } from "./workflow-history.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
@@ -67,14 +80,7 @@ export function createRegistry(server, opts = {}) {
let pruneTask = null;
/** @type {Map<string, HttpRouteEntry>} */
const httpRoutes = new Map();
const httpDispatcherState = { registered: false };
const dispatchHttpTrigger = createHttpTriggerHandler({
httpRoutes,
workflows,
namespacedPath,
enqueueWorkflow: (...args) => enqueueWorkflow(...args),
});
let httpDispatcherRegistered = false;
/**
* Resolve a same-owner workflow that opts in with `type: workflow`.
@@ -180,10 +186,118 @@ export function createRegistry(server, opts = {}) {
* Fastify route once so path/method changes apply on reregister without restart.
*/
function registerHttpTriggers() {
rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log });
ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, {
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})`);
}
}
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);
}
function registerCronTriggers() {
@@ -481,21 +595,8 @@ export function createRegistry(server, opts = {}) {
owner,
depth,
) {
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 script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
const unresolvedConfig = parsed.config;
const incomingContext = normalizeContext(ctx.context);
const step = await store.startStep({
runId,
@@ -553,6 +654,45 @@ export function createRegistry(server, opts = {}) {
}
}
/**
* Persist `enabled: false` for a workflow and reload registries across processes.
* @param {string} owner
* @param {string} file
* @param {string} key
*/
async function disableWorkflowForConsecutiveFailures(owner, file, key) {
const content = fsStore.readWorkflowYaml(owner, file);
if (content == null) {
throw new Error(`workflow file missing for ${key}`);
}
const doc = yaml.parseDocument(content);
if (doc.errors?.length) {
throw new Error(doc.errors[0]?.message ?? "invalid yaml");
}
const parsed = doc.toJSON();
if (parsed?.enabled === false) {
log.debug({ workflow: key }, "workflow already disabled");
return;
}
doc.set("enabled", false);
const nextContent = String(doc);
fsStore.writeWorkflowYaml(owner, file, nextContent);
await recordRevision({
workflowId: workflowIdFromFile(file),
owner,
file,
content: nextContent,
reason: "disable-on-consecutive-failures",
});
reregister();
try {
await publishReload({ type: "workflows" });
} catch {
// Redis may be briefly unavailable; local reload already applied.
}
log.warn({ workflow: key }, "disabled workflow after consecutive failures");
}
/**
* @param {{
* key: string,
@@ -589,6 +729,17 @@ export function createRegistry(server, opts = {}) {
return;
}
if (failureConfig.disableOnConsecutiveFailures) {
const entry = workflows.get(opts.key);
if (entry) {
await disableWorkflowForConsecutiveFailures(entry.owner, entry.file, opts.key);
} else {
log.warn({ workflow: opts.key }, "cannot disable missing workflow entry");
}
}
if (!failureConfig.workflowName) return;
const destKey = resolveWorkflowTriggerKey(opts.owner, failureConfig.workflowName);
const alertData = buildFailureAlertData({
sourceKey: opts.key,
@@ -805,10 +956,7 @@ export function createRegistry(server, opts = {}) {
for (const raw of workflow.scripts ?? []) {
try {
const parsed = parseScriptStep(raw);
if (parsed.kind === "script") {
if (parsed.script) refs.add(parsed.script);
if (parsed.profile) refs.add(`profile:${parsed.profile}`);
}
if (parsed.kind === "script") refs.add(parsed.script);
} catch {
// skip invalid steps
}
-105
View File
@@ -1,105 +0,0 @@
/**
* 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 <name> --password <secret>
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();
}
+1 -1
View File
@@ -486,7 +486,7 @@ const inspectLog = pino({ level: "silent" });
* @param {unknown} fn
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
*/
function extractScriptMeta(fn) {
export function extractScriptMeta(fn) {
if (typeof fn !== "function") {
return { meta: null, metaError: "default export must be a function" };
}
+20 -60
View File
@@ -1,17 +1,9 @@
import jsonata from "jsonata";
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
function ensureDataObject(ctx) {
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
ctx.data = {};
}
return {};
}
function mergeData(data) {
if (data != null && typeof data === "object" && !Array.isArray(data)) {
return { ...data };
}
return {};
}
const ALLOWED_METHODS = new Set([
@@ -43,19 +35,6 @@ function previewValue(value) {
return { preview: `${json.slice(0, 500)}...`, truncated: true };
}
/**
* Eval context for fingerprint/transform JSONata (reads `data.*`).
* @param {unknown} ctx
* @param {Record<string, unknown>} 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) {
@@ -84,7 +63,7 @@ async function detectUrlChanges(ctx) {
);
}
const data = mergeData(ctx.data);
ensureDataObject(ctx);
log.info({ url, method, key }, "detect-url-changes: fetching url");
const response = await $axios.request({
@@ -98,31 +77,28 @@ async function detectUrlChanges(ctx) {
"detect-url-changes: fetch complete",
);
data.httpResponse = response.data;
ctx.data.httpResponse = response.data;
let fingerprintSource = data.httpResponse;
let fingerprintSource = ctx.data.httpResponse;
if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) {
log.info(
{ jsonata: fingerprintExpr },
"detect-url-changes: evaluating fingerprint jsonata",
);
fingerprintSource = await jsonata(fingerprintExpr).evaluate(evalCtx(ctx, data));
fingerprintSource = await jsonata(fingerprintExpr).evaluate(ctx);
}
const result = await $fingerprint.claim(key, fingerprintSource, {
maxAge: ctx.config?.maxAge,
});
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);
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;
log.info(
{
@@ -140,35 +116,28 @@ async function detectUrlChanges(ctx) {
{ outputVar, jsonata: transformExpr },
"detect-url-changes: evaluating transform jsonata",
);
const transformed = await jsonata(transformExpr).evaluate(evalCtx(ctx, data));
data[outputVar] = transformed;
const transformed = await jsonata(transformExpr).evaluate(ctx);
ctx.data[outputVar] = transformed;
log.info(
{ outputVar, value: previewValue(transformed) },
"detect-url-changes: saved transform result",
);
} else {
data[outputVar] = data.httpResponse;
ctx.data[outputVar] = ctx.data.httpResponse;
log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar");
}
}
/** @type {{ output: Record<string, unknown>, context: Record<string, unknown>, skipRemaining?: true }} */
const envelope = {
output: data,
context: { ...passContext(ctx), ...extra },
};
if (skipRemainingWhenUnchanged && !result.changed) {
envelope.skipRemaining = true;
ctx.skipRemaining = true;
}
return envelope;
return ctx;
}
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: {
@@ -234,15 +203,6 @@ 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: {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

+144 -1
View File
@@ -1 +1,144 @@
export * from "./src/stores/secrets-store.js";
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<boolean>}
*/
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<string | null>}
*/
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}"`);
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ let cachedKey = null;
/**
* @returns {Buffer}
*/
function getMasterKey() {
export function getMasterKey() {
if (cachedKey) return cachedKey;
const raw = resolveSecretsKeyMaterial();
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
+1 -14
View File
@@ -1,4 +1,4 @@
import { kvDelete, kvNamespaces, kvQuery } from "../../kv-store.js";
import { kvNamespaces, kvQuery } from "../../kv-store.js";
/**
* @param {import("fastify").FastifyInstance} fastify
@@ -19,17 +19,4 @@ export default async function kvPlugin(fastify) {
offset: Number.isFinite(offset) ? offset : undefined,
});
});
fastify.delete("/kv", async (req, reply) => {
const q = /** @type {Record<string, string | undefined>} */ (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 });
}
});
}
-82
View File
@@ -1,82 +0,0 @@
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 };
});
}
-28
View File
@@ -1,28 +0,0 @@
import * as store from "../../store.js";
/**
* @param {Record<string, string | undefined>} 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<string, string | undefined>} q
*/
export async function queryRunsFromRequest(q) {
return store.queryRuns(parseRunQueryParams(q));
}
+10 -2
View File
@@ -1,12 +1,20 @@
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) => {
return queryRunsFromRequest(/** @type {Record<string, string | undefined>} */ (req.query ?? {}));
const q = /** @type {Record<string, string | undefined>} */ (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 };
});
fastify.get("/consecutive-failures", async (req) => {
+16 -94
View File
@@ -8,6 +8,7 @@ import {
import * as fsStore from "../../fs-store.js";
import {
forkCoreScript,
getInstalledPlugin,
listCoreScriptNames,
listInstalledPlugins,
resolveScriptRef,
@@ -21,16 +22,11 @@ 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<string> }} registry
@@ -254,11 +250,14 @@ export default function scriptsPluginFactory(registry) {
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const body = /** @type {{ content?: string, expression?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
const body = /** @type {{ content?: 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 = "local";
let owner = "default";
if (body.owner != null && body.owner !== "") {
try {
owner = fsStore.assertOwner(String(body.owner));
@@ -267,92 +266,12 @@ export default function scriptsPluginFactory(registry) {
}
}
const incomingContext = reviveBinaryFromWire(
const incomingContext =
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<string, unknown>} */ (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 =
@@ -360,6 +279,9 @@ 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,
@@ -367,7 +289,7 @@ export default function scriptsPluginFactory(registry) {
context: incomingContext,
});
const ctx = {
data: incomingData,
data: body.data ?? null,
context: incomingContext,
config,
};
@@ -391,8 +313,6 @@ 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,
@@ -555,5 +475,7 @@ export default function scriptsPluginFactory(registry) {
}
},
);
void getInstalledPlugin;
};
}
+4 -92
View File
@@ -26,8 +26,6 @@ import {
collectWorkflowWarnings,
parseWorkflowDocument,
} from "../../workflow-validate-warnings.js";
import { getProfilePlain } from "../../profiles-store.js";
import { resolveScriptRef } from "../../plugin-store.js";
import {
recordRevision,
listRevisions,
@@ -45,11 +43,6 @@ 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.
@@ -76,6 +69,7 @@ function triggerSummary(owner, workflow, nameById) {
schedule: t?.schedule ?? null,
onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
onFailureWorkflow: t?.onFailureWorkflow ?? null,
disableOnConsecutiveFailures: t?.disableOnConsecutiveFailures === true,
auth: isHttp ? authLabel(t?.auth, nameById) : null,
};
});
@@ -87,9 +81,7 @@ function scriptNames(workflow) {
for (const raw of workflow.scripts ?? []) {
try {
const parsed = parseScriptStep(raw);
if (parsed.kind === "set") names.push("set");
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
else names.push(parsed.script);
names.push(parsed.kind === "set" ? "set" : parsed.script);
} catch {
names.push(null);
}
@@ -97,59 +89,6 @@ 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
*/
@@ -171,11 +110,8 @@ async function validateStrictWorkflow(parsed) {
* }} opts
*/
async function saveWorkflowContent(opts) {
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
if (parsed) {
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
}
const saveAnyway = Boolean(opts.saveAnyway);
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
const saveAnyway = Boolean(opts.saveAnyway);
if (!saveAnyway) {
if (parseError) {
@@ -251,22 +187,6 @@ 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() };
});
@@ -387,7 +307,6 @@ 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,
@@ -396,13 +315,6 @@ 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 };
});
@@ -1,390 +0,0 @@
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<string, unknown>} config
* @param {string} type
*/
export function publicConfig(config, type) {
/** @type {Record<string, unknown>} */
const out = {};
if (type === "bearer") {
out.token = redactField(config.token);
} else if (type === "basic") {
out.user = redactField(config.user);
out.password = redactField(config.password);
} else if (type === "header") {
out.header = typeof config.header === "string" ? config.header : null;
out.value = redactField(config.value);
}
return out;
}
/**
* @param {unknown} value
*/
function redactField(value) {
const kind = valueSourceKind(value);
if (kind === "missing") return { source: "missing" };
if (kind === "kv") {
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
return {
source: "kv",
kv: v.kv,
...(v.namespace != null ? { namespace: v.namespace } : {}),
};
}
if (kind === "secret") {
const v = /** @type {{ secret: string }} */ (value);
return { source: "secret", secret: v.secret };
}
return { source: "literal", set: true };
}
/**
* Validate and normalize auth config for storage.
* @param {string} type
* @param {unknown} config
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
*/
export function normalizeAuthConfig(type, config, opts = {}) {
const raw = config && typeof config === "object" && !Array.isArray(config)
? /** @type {Record<string, unknown>} */ (config)
: {};
const keep = opts.keepLiteralsFrom ?? {};
if (type === "bearer") {
return {
token: normalizeCredentialField(raw.token, keep.token, "token"),
};
}
if (type === "basic") {
return {
user: normalizeCredentialField(raw.user, keep.user, "user"),
password: normalizeCredentialField(raw.password, keep.password, "password", {
allowEmpty: true,
}),
};
}
// header
if (typeof raw.header !== "string" || raw.header.length === 0) {
const err = new Error("header name must be a non-empty string");
err.statusCode = 400;
throw err;
}
return {
header: raw.header,
value: normalizeCredentialField(raw.value, keep.value, "value"),
};
}
/**
* @param {unknown} value
* @param {unknown} previous
* @param {string} label
* @param {{ allowEmpty?: boolean }} [opts]
*/
function normalizeCredentialField(value, previous, label, opts = {}) {
// Explicit "keep previous literal" marker from UI when editing without re-entering
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
/** @type {{ keep?: boolean }} */ (value).keep === true
) {
if (typeof previous === "string") return previous;
if (previous && typeof previous === "object") return previous;
const err = new Error(`${label} was not previously set`);
err.statusCode = 400;
throw err;
}
if (value == null || value === "") {
if (opts.allowEmpty && value === "") return "";
// Allow empty password for basic
if (opts.allowEmpty && (value === "" || value == null)) {
if (typeof previous === "string") return previous;
return "";
}
const err = new Error(`${label} is required`);
err.statusCode = 400;
throw err;
}
if (typeof value === "string") return value;
if (typeof value === "object" && !Array.isArray(value)) {
const v = /** @type {Record<string, unknown>} */ (value);
if (typeof v.secret === "string" && v.secret.length > 0) {
return { secret: v.secret };
}
if (typeof v.kv === "string" && v.kv.length > 0) {
/** @type {{ kv: string, namespace?: string }} */
const out = { kv: v.kv };
if (typeof v.namespace === "string" && v.namespace.length > 0) {
out.namespace = v.namespace;
}
return out;
}
}
const err = new Error(
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
);
err.statusCode = 400;
throw err;
}
function parseConfig(raw) {
if (typeof raw !== "string") return raw ?? {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
function publicAuth(row, { includeConfig = true } = {}) {
const type = row.type;
const config = parseConfig(row.config);
return {
id: row.id,
name: row.name,
type,
...(includeConfig ? { config: publicConfig(config, type) } : {}),
unauthorized_status: row.unauthorized_status ?? null,
unauthorized_response: row.unauthorized_response ?? null,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
/**
* Internal: full config including literals (for runtime auth checks).
* @param {string} 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<string, string> } | null>}
*/
export async function revealHttpAuthLiterals(id) {
const internal = await getHttpAuthInternal(id);
if (!internal) return null;
/** @type {Record<string, string>} */
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<string, unknown> | 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<boolean>}
*/
export async function deleteHttpAuth(id) {
const authId = assertAuthId(id);
const n = await db("http_auths").where({ id: authId }).del();
return n > 0;
}
-399
View File
@@ -1,399 +0,0 @@
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<unknown>}
*/
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<boolean>}
*/
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<string[]>}
*/
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 });
},
};
}
@@ -1,244 +0,0 @@
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<string, unknown>}
*/
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<string, unknown>} 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<boolean>}
*/
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;
}
-144
View File
@@ -1,144 +0,0 @@
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<boolean>}
*/
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<string | null>}
*/
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}"`);
}
}
@@ -1,190 +0,0 @@
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<string, unknown>} 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<boolean>}
*/
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<string | number | boolean | null>}
*/
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 ?? ""));
}
+10 -12
View File
@@ -14,12 +14,10 @@ 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";
@@ -31,7 +29,6 @@ import {
getRedisUrlForLog,
} from "./workflow-queue.js";
import { purgeExpiredTrash } from "./workflow-trash.js";
import { migrateLegacyWorkflowsIfNeeded } from "./workflow-migrate.js";
import {
getConfigGeneration,
startHeartbeatLoop,
@@ -129,12 +126,6 @@ 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.
@@ -177,7 +168,6 @@ 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);
@@ -208,8 +198,16 @@ export async function startApp(opts = {}) {
"/admin/runs",
{ onRequest: [server.authenticate] },
async (req, reply) => {
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
return reply.send(await queryRunsFromRequest(q));
const q = /** @type {Record<string, string | undefined>} */ (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 });
},
);
+6 -3
View File
@@ -2,9 +2,12 @@
* Step return contract: `{ output, context?, skipRemaining? }`.
*/
import { isPlainObject } from "@jerapah-flow/shared";
export { isPlainObject };
/**
* @param {unknown} value
*/
export function isPlainObject(value) {
return value != null && typeof value === "object" && !Array.isArray(value);
}
/**
* @param {unknown} value
+15 -103
View File
@@ -222,29 +222,19 @@ export async function insertLogs(rows) {
);
}
/** @type {Record<string, string>} */
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[],
* trigger_type?: string,
* after?: string,
* limit?: number,
* before?: string,
* }} filters
* }} [filters]
*/
function applyRunFilters(q, filters) {
if (filters.owner) q.where("owner", filters.owner);
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);
if (filters.workflow) {
const key = String(filters.workflow);
if (key.includes("*")) {
@@ -253,102 +243,25 @@ function applyRunFilters(q, filters) {
.replaceAll("%", "\\%")
.replaceAll("_", "\\_")
.replaceAll("*", "%");
q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
} else {
q.where("workflow", key);
q = q.where("workflow", key);
}
}
if (filters.status) {
if (Array.isArray(filters.status)) {
q.whereIn("status", filters.status);
q = q.whereIn("status", filters.status);
} else {
q.where("status", filters.status);
q = q.where("status", filters.status);
}
}
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<string, unknown>} row
*/
function mapRunRow(row) {
return {
if (filters.before) q = q.where("started_at", "<", filters.before);
const rows = await q.limit(limit);
return rows.map((row) => ({
...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);
}));
}
/**
@@ -582,13 +495,12 @@ export async function listUsers() {
/**
* @param {string} id
* @param {{ passwordHash?: string, role?: string, username?: string }} patch
* @param {{ passwordHash?: string, role?: 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);
}
@@ -51,21 +51,21 @@ async function freshUrl(pathname) {
state.body = "<html><body>v1</body></html>";
const first = await run({ url });
assert(first.output.hasChanges === true, "first run should report hasChanges=true");
assert(first.data.hasChanges === true, "first run should report hasChanges=true");
assert(
first.output.httpResponse === "<html><body>v1</body></html>",
first.data.httpResponse === "<html><body>v1</body></html>",
"httpResponse should hold the raw body",
);
assert(typeof first.output.fingerprint === "string", "fingerprint hash should be set");
assert(typeof first.data.fingerprint === "string", "fingerprint hash should be set");
const second = await run({ url });
assert(second.output.hasChanges === false, "unchanged body should report hasChanges=false");
assert(second.data.hasChanges === false, "unchanged body should report hasChanges=false");
state.body = "<html><body>v2 CHANGED</body></html>";
const third = await run({ url });
assert(third.output.hasChanges === true, "changed body should report hasChanges=true");
assert(third.data.hasChanges === true, "changed body should report hasChanges=true");
assert(
third.output.fingerprintPrevious === second.output.fingerprint,
third.data.fingerprintPrevious === second.data.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.output.hasChanges === true, "json first run should report a change");
assert(first.data.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.output.hasChanges === false,
second.data.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.output.hasChanges === true, "changing the watched field should report a change");
assert(third.data.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.output.message === "changed=true",
`transform should populate outputVar, got ${JSON.stringify(withTransform.output.message)}`,
withTransform.data.message === "changed=true",
`transform should populate outputVar, got ${JSON.stringify(withTransform.data.message)}`,
);
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
assert(
rawOutput.output.payload === rawOutput.output.httpResponse,
rawOutput.data.payload === rawOutput.data.httpResponse,
"outputVar without transform should store the raw response",
);
@@ -131,11 +131,11 @@ async function freshUrl(pathname) {
state.body = "<html><body>stable</body></html>";
const first = await run({ url, skipRemaining: true });
assert(first.output.hasChanges === true, "skip test first run should change");
assert(first.data.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.output.hasChanges === false, "skip test second run should be unchanged");
assert(second.data.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.
+1 -20
View File
@@ -1,9 +1,4 @@
import {
encodeBinaryForWire,
jsonPreviewReplacer,
reviveBinaryFromWire,
summarizeBinary,
} from "../json-preview.js";
import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js";
import { serialize, toDisplayValue } from "../store.js";
import { safeSerialize } from "../src/api/dry-run-logger.js";
@@ -58,18 +53,4 @@ 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");
-101
View File
@@ -1,101 +0,0 @@
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");
-81
View File
@@ -1,81 +0,0 @@
/**
* 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");
+19 -5
View File
@@ -47,12 +47,18 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
const threshold = Number(spec.onConsecutiveFailures);
const workflowName = onFailureWorkflowName(spec);
if (!Number.isFinite(threshold) || threshold < 1 || workflowName.length === 0) {
const disableOnConsecutiveFailures = isDisableOnConsecutiveFailures(spec);
if (
!Number.isFinite(threshold) ||
threshold < 1 ||
(workflowName.length === 0 && !disableOnConsecutiveFailures)
) {
return null;
}
return {
threshold: Math.floor(threshold),
workflowName,
workflowName: workflowName.length > 0 ? workflowName : null,
disableOnConsecutiveFailures,
};
}
return null;
@@ -66,6 +72,13 @@ function onFailureWorkflowName(trigger) {
return typeof value === "string" ? value.trim() : "";
}
/**
* @param {Record<string, unknown>} trigger
*/
export function isDisableOnConsecutiveFailures(trigger) {
return trigger?.disableOnConsecutiveFailures === true;
}
/**
* @param {unknown} workflow
*/
@@ -81,12 +94,13 @@ export async function validateWorkflowFailureTriggers(workflow) {
const hasThreshold =
trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== "";
const hasWorkflow = onFailureWorkflowName(trigger).length > 0;
const hasDisable = isDisableOnConsecutiveFailures(trigger);
if (!hasThreshold && !hasWorkflow) continue;
if (!hasThreshold && !hasWorkflow && !hasDisable) continue;
if (!hasThreshold || !hasWorkflow) {
if (!hasThreshold || (!hasWorkflow && !hasDisable)) {
const err = new Error(
"onConsecutiveFailures and onFailureWorkflow must both be set on a trigger",
"onConsecutiveFailures requires onFailureWorkflow and/or disableOnConsecutiveFailures",
);
err.statusCode = 400;
throw err;
+190 -1
View File
@@ -1 +1,190 @@
export * from "./src/stores/variables-store.js";
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<string, unknown>} 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<boolean>}
*/
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<string | number | boolean | null>}
*/
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 ?? ""));
}
+27 -5
View File
@@ -1,14 +1,36 @@
import yaml from "yaml";
import {
ensureWorkflowFilename,
suggestCopyFilename,
} from "@jerapah-flow/shared";
import {
newWorkflowFilename,
workflowFileStem,
} from "./workflow-normalize.js";
export { ensureWorkflowFilename, suggestCopyFilename };
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);
}
/**
* UUID-based duplicate filename (default for new duplicates).
-79
View File
@@ -1,79 +0,0 @@
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"),
};
}
-159
View File
@@ -1,159 +0,0 @@
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<string, { owner: string, workflow: any }>} workflows
* @param {Map<string, { key: string, owner: string, trigger: any }>} 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<string, { key: string, owner: string, trigger: any }>,
* workflows: Map<string, { owner: string, workflow: any }>,
* namespacedPath: (owner: string, path: unknown) => string,
* enqueueWorkflow: (key: string, ctx: any, trigger: any) => Promise<any>,
* }} 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);
};
}
-51
View File
@@ -1,51 +0,0 @@
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;
}
+22 -44
View File
@@ -1,5 +1,4 @@
import jsonata from "jsonata";
export { namespacedPath } from "@jerapah-flow/shared";
export const SET_STEP_SCRIPT = "set";
@@ -7,23 +6,19 @@ export const SET_STEP_SCRIPT = "set";
* @typedef {{ alias: string, from: string }} NeedEdge
* @typedef {{
* kind: "script",
* script: string,
* profile: string | null,
* script: string,
* 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,
* profile: null,
* config: { expression: string },
* script: typeof SET_STEP_SCRIPT,
* config: { expression: string },
* expression: string,
* name: string | null,
* id: string | null,
* needsKind: "none" | "list" | "map",
* needs: NeedEdge[],
@@ -71,6 +66,16 @@ 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}
@@ -80,9 +85,7 @@ export function parseScriptStep(step) {
return {
kind: "script",
script: step,
profile: null,
config: null,
name: null,
id: null,
needsKind: "none",
needs: [],
@@ -94,35 +97,25 @@ 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 (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) {
if (hasScript) {
if (typeof step.script !== "string") {
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
}
const { needsKind, needs } = parseNeeds(step.needs);
return {
kind: "script",
script: hasScript ? step.script : "",
profile: hasProfile ? step.profile : null,
script: step.script,
config: step.config ?? null,
name: parseOptionalName(step.name),
id: parseOptionalId(step.id),
needsKind,
needs,
@@ -272,10 +265,8 @@ 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,
@@ -296,19 +287,6 @@ 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}
+1 -1
View File
@@ -20,7 +20,7 @@ function trashFilePath(owner, file) {
/**
* @param {string} deletedAtIso
*/
function trashAgeMs(deletedAtIso) {
export function trashAgeMs(deletedAtIso) {
return Date.now() - Date.parse(deletedAtIso);
}
@@ -85,7 +85,6 @@ 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({
@@ -18,7 +18,7 @@ scripts:
- script: fetch-binary.js
- script: ntfy.js
config:
url: $VAR_ntfy_channel
url: https://ntfy.sh/jerapah-flow
triggers:
- type: HTTP
method: POST
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,50 @@
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 * * *
@@ -1,6 +1,20 @@
scripts:
- cron-example.yaml
- detect-example-changes.yaml
- time-to-ntfy-example.yaml
- test.yaml
- cron-example.yaml
- fetch-devto.yaml
- comic-monkeyuser-to-ntfy.yaml
- afb272d4-b217-49ac-8c8b-755a6d8dac4a.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
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,18 @@
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": "<p>Hello from <strong>test-send-gmail</strong></p>"
}
triggers:
- type: HTTP
method: POST
path: /test-send-gmail
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,37 @@
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
@@ -6,7 +6,9 @@ scripts:
- script: plugin/get-current-time
config:
key: ""
- profile: ntfy_default
- script: ntfy.js
config:
url: https://n.0dev.web.id/system
triggers:
- type: HTTP
method: POST
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,14 @@
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
-15
View File
@@ -1,15 +0,0 @@
{
"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"
}
}
-4
View File
@@ -1,4 +0,0 @@
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";
-3
View File
@@ -1,3 +0,0 @@
export function isPlainObject(value) {
return value != null && typeof value === "object" && !Array.isArray(value);
}
-70
View File
@@ -1,70 +0,0 @@
/**
* 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<string, unknown>}
*/
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<string, unknown>}
*/
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
);
}
@@ -1,82 +0,0 @@
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/<owner>/<path> without leading slash duplication", () => {
expect(namespacedPath("default", "hooks/run")).toBe("/u/default/hooks/run");
expect(namespacedPath("local", "/hooks/run")).toBe("/u/local/hooks/run");
});
});
-27
View File
@@ -1,27 +0,0 @@
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);
}
-13
View File
@@ -1,13 +0,0 @@
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");
}
-42
View File
@@ -1,42 +0,0 @@
# 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.
-41
View File
@@ -1,41 +0,0 @@
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: "^_",
},
],
},
},
];
+2 -10
View File
@@ -6,21 +6,18 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"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"
"preview": "vite preview"
},
"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",
@@ -28,14 +25,9 @@
"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"
}
-4
View File
@@ -20,7 +20,6 @@ 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";
@@ -72,9 +71,6 @@ export function App() {
<Route path="/variables" element={<VariablesPage />} />
<Route path="/variables/new" element={<VariablesPage />} />
<Route path="/variables/:owner/:name/edit" element={<VariablesPage />} />
<Route path="/profiles" element={<ProfilesPage />} />
<Route path="/profiles/new" element={<ProfilesPage />} />
<Route path="/profiles/:owner/:name/edit" element={<ProfilesPage />} />
<Route path="/auth" element={<AuthProfilesPage />} />
<Route path="/auth/new" element={<AuthProfilesPage />} />
<Route path="/auth/:name/edit" element={<AuthProfilesPage />} />
+723 -1
View File
@@ -1 +1,723 @@
export * from "./hooks/index.js";
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"] });
},
});
}
-91
View File
@@ -1,91 +0,0 @@
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"] }),
});
}
-58
View File
@@ -1,58 +0,0 @@
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;
}
-10
View File
@@ -1,10 +0,0 @@
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";
-34
View File
@@ -1,34 +0,0 @@
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"] }),
});
}
-83
View File
@@ -1,83 +0,0 @@
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"] }),
});
}
-44
View File
@@ -1,44 +0,0 @@
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"] }),
});
}
-56
View File
@@ -1,56 +0,0 @@
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;
},
});
}
-108
View File
@@ -1,108 +0,0 @@
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,
});
}
-30
View File
@@ -1,30 +0,0 @@
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"] }),
});
}

Some files were not shown because too many files have changed in this diff Show More