47 Commits
Author SHA1 Message Date
nsrb 41cca86e6e feat(workflows): enhance script handling and introduce custom step names
- Updated AGENTS.md to include a `name` field for scripts, allowing custom display titles in the editor and graph.
- Enhanced script icon resolution to support `.jpeg` file extensions.
- Modified workflow parsing to accommodate the new `name` property for steps, improving step identification.
- Added new utility functions for handling custom step names and display names.
- Introduced tests to validate the functionality of custom step names in workflow YAML parsing and step labeling.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 21:48:18 +07:00
nsrb 71cc705cd2 feat(server): introduce admin reset functionality and enhance user management
- Added a new `reset-admin` script to reset or create the admin username and password.
- Updated the README to include instructions for resetting the admin login.
- Enhanced user management by allowing username updates during admin reset.
- Defaulted new resources to the internal namespace `local` for better organization.
- Removed unused owner selection from various components to streamline the UI.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 19:08:52 +07:00
nsrb 811890443b refactor(server): improve URL change detection and context handling
- Renamed `ensureDataObject` to `passContext` and added `mergeData` for better context management.
- Updated `detectUrlChanges` to utilize the new context handling functions, improving data merging and response handling.
- Enhanced evaluation of JSONata expressions with a new `evalCtx` function to streamline context passing.
- Adjusted test assertions to reflect changes in output structure from `data` to `output`.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 18:17:36 +07:00
nsrb 7456dece00 feat(workflows): migrate legacy workflows and enhance example handling
- Migrated legacy workflow files from `packages/server/workflows/` to `packages/server/data/workflows/` when the new store is empty.
- Introduced new API endpoints to list and retrieve example workflows.
- Added a dialog component for selecting example workflows when creating new workflows.
- Updated workflow paths and configurations to reflect the new structure.
- Enhanced the README and AGENTS.md documentation to clarify workflow management and usage.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 18:09:22 +07:00
nsrb beb7e22652 feat(server): enhance workflow and script handling with new features
- Added `workflowLastModifiedAt` function to retrieve the last modified timestamp of workflow YAML files.
- Introduced `encodeBinaryForWire` and `reviveBinaryFromWire` functions for better handling of binary data in JSON.
- Implemented `useDeleteKv` hook for deleting key-value pairs in the web API.
- Enhanced `scriptsPluginFactory` to support dry-run evaluations with JSONata expressions.
- Added `set-dry-run-smoke.js` test to validate dry-run functionality.
- Updated profile configuration to include `overlayFromMerged` for better profile management.
- Introduced try-session management in the web components to handle step execution states.
- Improved various components to support new try-session features and maintain UI consistency.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 17:52:17 +07:00
Cursor Agentandnsrb 72feb39d56 fix(web): enlarge step handles and mark them nodrag for connections
Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 06:20:53 +00:00
Cursor Agentandnsrb 1b08979699 fix(web): allow dragging connections between step nodes
Treat in-progress connections (no target yet) as valid so React Flow can complete handle-to-handle links, and enlarge step handles.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 06:10:26 +00:00
Cursor Agentandnsrb 527f9ac869 feat(web): add workflow graph editor and try-mode JSON tree
Add a Graph tab (React Flow) as the default workflow editor view, drop the Mermaid YAML preview, auto-assign step-N ids on add, and show test results as a JSON tree.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 05:43:58 +00:00
nsrbandCursor f9f21052d9 chore: trim distribution clutter and add lint/test tooling
Keep only example workflows in default/, move site-specific YAMLs to gitignored local/, remove the duplicate get-current-time plugin install, document personal plugins, clarify PM2 ecosystem usage, add shared Vitest and web ESLint, and extract OpsStatusCard for ops cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 06:30:37 +07:00
nsrbandCursor db884808f2 refactor: split oversized modules and start server src/stores layout
Consolidate format helpers under lib/format, split React Query hooks by domain, extract AuthPicker/ConfigRefHint and HTTP trigger routing, demote file-private exports, and move KV/secrets/variables/profiles/http-auths stores under src/stores with root re-exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 06:16:38 +07:00
nsrbandCursor 368a2ca365 feat(shared): extract client/server pure helpers into @jerapah-flow/shared
Share profile merge, workflow filename/path helpers, HTTP_METHODS (with OPTIONS), and isPlainObject so web and server stop drifting. Core script sandboxes keep local isPlainObject copies.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 06:01:42 +07:00
nsrbandCursor 96c4cc7b47 refactor(web): route-driven modal hook and CRUD conventions
Extract useRouteDrivenModal for the five list editors, move AddTriggerDialog into its own file, and document modal vs full-page CRUD patterns in CONTRIBUTING.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 00:48:44 +07:00
nsrbandCursor 3d25d8a614 refactor(web): shared ConfirmDialog, FormControls, and Modal a11y
Extract reusable confirm/modal shells and bordered form controls, adopt them across list pages and editors, and improve icon-button labels plus KvPage keyboard rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 22:08:23 +07:00
nsrb 9985ff3440 chore(deps): update pnpm version and clean up package.json; remove unused dependencies and scripts
- Updated package manager to pnpm@11.22.0 in package.json.
- Removed unused dependencies and scripts from package.json and pnpm-lock.yaml.
- Cleaned up fs-store.js and other files by removing commented-out code and unused imports.
- Updated workflow configurations to use environment variables for URLs.
2026-08-21 21:48:24 +07:00
Cursor Agentandnsrb e84432a492 feat(profiles): add typed live step config profiles
Profiles store script + default config per owner. Workflow steps reference
them with profile:, overlay keys win, and the UI marks overrides. Profile
name is immutable after create so YAML refs stay stable.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-21 06:12:49 +00:00
nsrbandGitHub 210fdb2244 Merge pull request #17 from nasyarobby/cursor/events-pagination-32d6
feat(events): add pagination, filters, sort, and page size
2026-08-21 11:05:31 +07:00
Cursor Agentandnsrb 20654b0682 feat(events): add pagination, filters, sort, and page size
Extend GET /api/runs with offset/total, date and trigger filters,
and configurable column sort. Rebuild Events page with URL-driven state
matching the KV page pagination pattern.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-20 23:28:35 +00:00
nsrb 14a08c700c chore(gitignore): add .gitea/workflows/ to ignore list 2026-08-21 05:23:43 +07:00
nsrb 14c8dc628e feat(config): enable builds for better-sqlite3 and esbuild; update cookie options for secure handling in auth 2026-08-20 23:05:55 +07:00
nsrb 407607b3ad Merge branch 'cursor/control-pm2-split-3038' 2026-08-20 22:35:09 +07:00
nsrbandCursor 54f6983a74 style(web): use success toggle for workflow enable switch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:31:53 +07:00
nsrbandCursor 4e866fb958 feat(server): wire multi-auth, API-only triggers, and revision stamping
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:31:53 +07:00
nsrbandCursor 3b284b4fc6 feat(web): peek variable values in workflow config field refs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:31:53 +07:00
nsrbandCursor ed9555e3fa refactor(web): extract admin editor modals with deep-link routes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:31:53 +07:00
nsrbandCursor aa3c2a25b2 feat(ops): restart individual PM2 children and enrich manage UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:31:34 +07:00
nsrbandCursor 84f29057df feat(workflows): stamp runs with revision and improve history preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:31:18 +07:00
nsrbandCursor f68376587e feat(http-auth): resolve profiles by UUID and allow multi-auth triggers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:31:02 +07:00
nsrbandCursor 69106ab805 feat(plugins): add get-current-time example plugin
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:30:47 +07:00
nsrbandCursor f74b0defc6 feat(web): add backup page and reorganize admin navigation
Move workflow backup/restore to a dedicated page, rename Ops to Manage,
and group sidebar links into Automate, Platform, and Admin sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 11:00:59 +07:00
nsrbandCursor eb94a2f669 fix(control): allow login when HTTP server is stopped
Mount auth routes on the control plane and proxy /api/auth to :8600 in
dev:pm2 so the UI can authenticate while the HTTP API process is down.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 11:00:47 +07:00
nsrbandCursor 21a31b0b54 fix(start-app): add missing control-bus imports
Import getConfigGeneration, startHeartbeatLoop, and subscribeReload so
the monolith runner starts without ReferenceError crashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 11:00:23 +07:00
nsrbandCursor d953f8113b fix(scripts): import installPluginFromDirectory from plugin-store
The helper is exported from plugin-store, not plugin-install.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 11:00:23 +07:00
nsrbandCursor d74923c7e1 fix(workflows): export workflowFileStem from workflow-normalize
Restore the missing export so workflow duplication resolves YAML stems
without a broken import from workflow-duplicate.js.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 11:00:09 +07:00
nsrb 6a4a653f6b Merge branch 'cursor/workflow-history-trash-3038' of https://github.com/nasyarobby/jerapah-flow into cursor/control-pm2-split-3038 2026-08-20 09:50:04 +07:00
nsrbandGitHub 4978cbf50f Merge pull request #14 from nasyarobby/cursor/control-pm2-split-3038
feat(control): PM2 control plane with split HTTP and worker processes
2026-08-20 09:43:46 +07:00
Cursor Agentandnsrb 7d51ff433f feat(workflows): add revision history, trash, UUID naming, and backup
- Store up to 50 revisions per workflow in SQLite with normalized SHA dedup
- Soft-delete workflows to trash (7-day retention) with restore and permanent purge
- Assign UUID filenames for new and duplicated workflows; show name + file in UI
- Warn on invalid YAML, unknown scripts, and plaintext secrets with save-anyway option
- Add workflow backup zip (workflows + plugins) and merge/replace restore
- Trash page, history panel on editor, and smoke test

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-20 02:14:50 +00:00
nsrb 82aff86fa0 fix(dev-pm2): correct control script path in PM2 launcher 2026-08-20 06:37:37 +07:00
nsrbandCursor 2244834b46 docs: add AGENTS.md for creating user plugins
Give later agents a checklist for plugin layout, manifests, sandbox globals, and workflow refs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 06:34:58 +07:00
nsrbandCursor bd0c11c20c feat(plugins): add joplin-api and send-sms under repo-root plugins/
Load user plugins from /plugins instead of data/plugins so site-specific scripts stay in git and workflows can reference plugin/joplin-api and plugin/send-sms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 06:34:33 +07:00
Cursor Agentandnsrb 611511ae60 feat(plugins): core read-only scripts and user plugin system
Introduce plugin/<id> installs (zip, HTTPS git, example, fork),
jerapah-plugin.json manifests with jerapah semver ranges, isolated
plugin dirs under data/plugins, and app version 0.1.0. Core scripts
are non-editable; get-current-time moves to examples/plugins.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-19 16:04:00 +00:00
nsrbandGitHub f05c149b72 Merge pull request #12 from nasyarobby/cursor/bullmq-queue-phase-a-3038
feat(runner): queue workflow runs with BullMQ (phase A)
2026-08-19 22:19:10 +07:00
Cursor Agentandnsrb f66d0931bd fix(control): resolve monorepo root from packages/server/dev-pm2.mjs
Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-19 10:56:27 +00:00
Cursor Agentandnsrb 8590fce3f8 fix(control): ship dev-pm2 launcher outside ignored scripts path
Move launcher to packages/server/dev-pm2.mjs (scripts/dev-* is gitignored)
and fix repo root resolution. Improve Ops pause/resume button disabled styles.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-19 10:55:56 +00:00
Cursor Agentandnsrb 4e6c336ae8 feat(web): add Ops page and proxy control on :8600
Vite listens on 8500 and proxies /ops to the control plane. Admin Ops UI
covers pause/resume, scale, drain/force restart, and restart-needed banner.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-19 10:55:02 +00:00
Cursor Agentandnsrb 756ba8a7ac feat(queue): support REDIS_PASS for Redis AUTH
Wire optional REDIS_PASS into the BullMQ ioredis connection, redact
credentials in startup logs, and document REDIS_PASS plus JFLOW_ROLE.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-19 09:05:29 +00:00
nsrb d122bd9a7f Merge branch 'main' into cursor/bullmq-queue-phase-a-3038 2026-08-19 15:53:07 +07:00
Cursor Agentandnsrb a95e6d7bc8 feat(runner): queue workflow runs with BullMQ (phase A)
Enqueue HTTP, cron, and manual runs via Redis/BullMQ; workers execute
asynchronously with configurable concurrency. Triggers return 202 and
clients poll GET /api/runs/:id for progress (queued → running → done).

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-19 04:25:21 +00:00
213 changed files with 20800 additions and 5574 deletions
+10 -2
View File
@@ -1,4 +1,5 @@
node_modules/
.pnpm-store/
data/
logs/
*.db
@@ -7,6 +8,13 @@ logs/
packages/web/dist
# Personal/local scripts and workflows (not for the repo)
packages/server/scripts/dev-*
packages/server/workflows/**/dev-*
debug-*.js
# Legacy live workflow tree (migrated to packages/server/data/workflows/)
packages/server/workflows/
# Plugin install staging and per-plugin deps
plugins/.staging-*
plugins/*/node_modules/
.gitea/workflows/
+156
View File
@@ -0,0 +1,156 @@
# JerapahFlow plugins
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 |
|---|---|---|---|
| Core | `fetch-http.js` | No | `packages/server/scripts/` |
| User plugin | `plugin/<id>` | Yes | `plugins/<id>/` |
| Example source | install → `plugin/<id>` | After install | `examples/plugins/<id>/` |
Runtime load path is repo-root `plugins/` (`PLUGINS_DIR` in `packages/server/paths.js`). Override with `JFLOW_PLUGINS_DIR` only in tests.
Plugins are **outside** the pnpm workspace (`packages/*`). Do not add `plugins/*` to `pnpm-workspace.yaml`.
## When to create a plugin
Create a user plugin when the script is:
- Site-specific (LAN IPs, personal modems, private APIs)
- A fork of a core script the user wants to edit
- Anything that should stay in git but not ship as core
Use native `fetch` (not `$axios`) when the URL is RFC1918 / WG (`10.x`, `192.168.x`, …). `$axios` is screened and **blocks** those hosts.
## Create a new plugin
1. Pick an id: lowercase letters, numbers, hyphens; max 64 chars; `^[a-z0-9]+(?:-[a-z0-9]+)*$`.
2. Folder name **must equal** manifest `id`. Example: `plugins/joplin-api`.
3. Id **must not** collide with a core script basename (`fetch-http`, `ntfy`, …).
4. Create three files (see below). Prefer `main: "script.js"`.
5. Point workflows at `plugin/<id>`.
6. Restart the runner (`pnpm dev` / drain-restart under `pnpm dev:pm2`) so the plugin is picked up.
Copy the layout from `plugins/joplin-api`, `plugins/send-sms`, or `examples/plugins/get-current-time`.
### `plugins/<id>/jerapah-plugin.json`
```json
{
"id": "my-plugin",
"name": "My plugin",
"version": "0.1.0",
"jerapah": ">=0.1.0 <1.0.0",
"main": "script.js",
"description": "One-line description"
}
```
- `version` must be semver (`0.1.0`).
- `jerapah` must match the app (`0.1.0` in root `package.json`). Use `">=0.1.0 <1.0.0"` unless you know otherwise.
- `main` is a relative path; no `..`, not absolute.
### `plugins/<id>/package.json`
```json
{
"name": "jflow-plugin-my-plugin",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "JerapahFlow plugin: …"
}
```
Add `dependencies` only if the script `require()`s extra npm packages. Then:
```bash
pnpm install --dir plugins/<id> --ignore-scripts --prefer-offline
```
`plugins/*/node_modules/` is gitignored. Host-allowlisted modules (`axios`, `jsonata`, …) come from the server; extra deps resolve from the plugin directory.
### `plugins/<id>/script.js`
Must `export default` a function. The sandbox rewrites ESM `import`/`export default` to CJS.
```js
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
async function myPlugin(ctx) {
const output = { ok: true };
return { output, context: { ...passContext(ctx), ...output } };
}
myPlugin.meta = {
description: "What this step does",
previewConfigKey: "url",
tags: ["HTTP"],
config: {},
input: {},
output: { ok: { type: "boolean" } },
context: { ok: { type: "boolean" } },
example: { data: {}, config: {} },
};
export default myPlugin;
```
Return `{ output, context?, skipRemaining? }`. Do not return `ctx`. Mutations of `ctx.data` / `ctx.context` are discarded unless returned.
| Field | Meaning |
|---|---|
| `ctx.data` | Step input (trigger payload, previous `output`, or DAG `needs`) |
| `ctx.context` | Run clipboard (plain object) |
| `ctx.config` | YAML `config` (secrets already unwrapped from `$SECRET_name`) |
| `output` | Next step’s `data` |
| `context` | Next clipboard. Omit to keep incoming |
`fn.meta` must be JSON-serializable (UI + dry-run). Include `config` / `input` / `output` field schemas and an `example`.
## Sandbox globals (do not import these)
Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$fingerprint`, `$secrets`, `$vars`, `$responses`, `$workflows`.
- Use `log.info({ … }, "my-plugin: …")` — `log` is not an import.
- `require("axios")` is the screened `$axios` (RFC1918 blocked). Prefer `fetch` for LAN.
- Plugin `require("some-npm-dep")` uses the plugin’s `node_modules`.
## Workflow YAML
```yaml
scripts:
- name: Notify to channel
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).
## Do not
- Add user plugins under `packages/server/scripts/` or `examples/plugins/`.
- Use an id that matches a core script file (`ntfy`, `jsonata`, …).
- Mismatch folder name and `jerapah-plugin.json` `id` (plugin is disabled).
- Commit `plugins/.staging-*` or `plugins/*/node_modules/`.
- Put secrets in `script.js`; use YAML `$SECRET_name` / `$VAR_name`.
+92 -10
View File
@@ -13,13 +13,63 @@ Workflow runner with a sandboxed script engine, SQLite run history, and an admin
```bash
pnpm install
# Redis required for the workflow queue
pnpm dev
```
- UI (dev): http://localhost:8500
- API: http://localhost:8700
- UI (dev): http://localhost:5173
The first account created becomes **admin**. Later accounts are created from Users.
The first account created becomes **admin**. JerapahFlow is a **single-machine, single-user** automation app: the Users page is not linked in the nav (still available at `/users` if typed). New workflows, secrets, variables, and profiles default to the internal namespace `local`.
Reset or create the admin login from the host:
```bash
pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
```
### Process modes
| Command | Processes | Ports |
|---|---|---|
| `pnpm dev` | Monolith (`runner.js` = API + worker) + Vite | UI **8500**, API **8700** |
| `pnpm dev:pm2` | Control + PM2 HTTP + PM2 workers + Vite | UI **8500**, control **8600**, API **8700** |
`pnpm dev:pm2` is the mode for Ops (start/stop HTTP, scale workers, drain restart). Control owns SQLite migrations; HTTP/workers do not migrate.
## Scripts (core vs plugins)
| 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>/` |
- 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`.
```bash
# Smoke
JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
node packages/server/test/plugins-smoke.js
```
## Script contract
@@ -50,13 +100,31 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
| Command | Description |
|---|---|
| `pnpm dev` | Server + Vite together |
| `pnpm dev:server` | API/runner only |
| `pnpm dev:web` | UI only (proxies `/api` to :8700) |
| `pnpm dev` | Monolith server + Vite (no PM2) |
| `pnpm dev:pm2` | Control + PM2 HTTP/workers + Vite (Ops UI) |
| `pnpm dev:server` | Monolith API/runner only |
| `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
| `pnpm build` | Production UI build |
| `pnpm start` | Serve API and built UI from :8700 |
| `pnpm start` | Monolith: API + worker + built UI |
| `pnpm start:control` | Control plane only (migrates, manages PM2 children) |
| `pnpm start:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) |
| `pnpm start:worker` | BullMQ worker only |
| `pnpm migrate` | Apply SQLite migrations |
## Ops (control plane)
Admin UI route **Ops** (`/ops`) talks to the control process.
| Action | Behavior |
|---|---|
| Pause / resume | BullMQ `queue.pause()` / `resume()` — cron/HTTP still enqueue |
| Reload workflows | Redis pub/sub → all live HTTP/worker processes re-read YAML |
| Scale workers | PM2 scale; scale-down drains active jobs unless `force` |
| Drain restart | Pause → wait active=0 → stop children → migrate → recreate → resume |
| Force restart | Same without waiting (interrupts active runs; orphans marked `worker_lost`) |
Desired state is stored in `packages/server/data/control-state.json` (generation, worker count, restart-needed). Plugin installs (later) bump generation and set restart-needed; you apply with Drain restart.
## Environment
| Variable | Default | Notes |
@@ -64,18 +132,32 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
| `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. |
| `JFLOW_WORKER_CONCURRENCY` | `5` | Max parallel workflow jobs per worker process. |
| `JFLOW_ROLE` | `all` | `all` (HTTP + cron + worker), `api`, or `worker`. Prefer `pnpm start:api` / `start:worker` under control. |
| `JFLOW_CONFIG_GENERATION` | `1` | Set by control/PM2 so children report config generation in heartbeats. |
| `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. |
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
| `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
| `PORT` | `8700` | HTTP port |
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev |
| `PORT` | `8700` | HTTP API port |
| `NODE_ENV` | — | Set `production` for secure cookies |
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.
## Production
```bash
pnpm install
pnpm build
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... NODE_ENV=production pnpm start
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
# Recommended: run control (migrates + manages PM2 HTTP/workers)
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start:control
# Or monolith (dev-style):
# ... pnpm start
```
The server serves `packages/web/dist` when that folder exists.
With control, serve the built UI from Vite preview, a reverse proxy, or set `JFLOW_SERVE_UI=1` on the HTTP process.
+48
View File
@@ -0,0 +1,48 @@
/**
* 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");
function loadEnv(file) {
const out = {};
if (!fs.existsSync(file)) return out;
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const i = t.indexOf("=");
if (i === -1) continue;
const key = t.slice(0, i).trim();
let val = t.slice(i + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
out[key] = val;
}
return out;
}
const root = __dirname;
module.exports = {
apps: [
{
name: "jerapah-flow",
cwd: root,
script: "packages/server/runner.js",
interpreter: "node",
instances: 1,
autorestart: true,
max_restarts: 20,
env: {
NODE_ENV: "production",
...loadEnv(path.join(root, ".env")),
},
},
],
};
@@ -0,0 +1,8 @@
{
"id": "get-current-time",
"name": "Get current time",
"version": "0.1.0",
"jerapah": ">=0.1.0 <1.0.0",
"main": "script.js",
"description": "Example user plugin: return the current time as output.datetime"
}
@@ -0,0 +1,7 @@
{
"name": "jflow-plugin-get-current-time",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Example JerapahFlow plugin (no npm dependencies)"
}
@@ -0,0 +1,27 @@
name: Comic - monkeyuser to ntfy
description: |
Scrape the latest MonkeyUser comic and send it to ntfy (requires $VAR_ntfy_channel).
scripts:
- script: fetch-html.js
config:
url: https://www.monkeyuser.com/
outputVar: comic
selector: .comic img
jsonata: |
{"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]}
- script: jsonata.js
config:
expression: |
{
"title": data.comic.title,
"message": data.comic.title,
"attach": data.comic.url
}
- script: fetch-binary.js
- script: ntfy.js
config:
url: $VAR_ntfy_channel
triggers:
- type: HTTP
method: POST
path: /comic-monkeyuser
@@ -0,0 +1,22 @@
name: detect example.com changes
description: |
Fetch example.com, fingerprint the response, and report whether it changed
since the previous run. Uses detect-url-changes with a transform that builds
a human-readable message into ctx.data.message.
scripts:
- script: detect-url-changes.js
config:
url: https://example.com/
outputVar: message
transform: |
data.hasChanges
? "example.com changed (fingerprint " & data.fingerprint & ")"
: "example.com unchanged since " & data.fingerprintAt
triggers:
- type: HTTP
method: POST
path: /detect-example
- type: cron
schedule: "* * * * *"
onConsecutiveFailures: 3
enabled: false
+9 -12
View File
@@ -1,25 +1,22 @@
{
"name": "jerapah-flow",
"version": "1.0.0",
"version": "0.1.0",
"private": true,
"description": "Script/workflow runner with admin UI",
"type": "module",
"scripts": {
"dev": "pnpm --filter @jerapah-flow/server --filter @jerapah-flow/web --parallel dev",
"dev:pm2": "pnpm --filter @jerapah-flow/server dev:pm2",
"dev:server": "pnpm --filter @jerapah-flow/server dev",
"dev:web": "pnpm --filter @jerapah-flow/web dev",
"build": "pnpm --filter @jerapah-flow/web build",
"start": "pnpm --filter @jerapah-flow/server start",
"migrate": "pnpm --filter @jerapah-flow/server migrate"
"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"
},
"packageManager": "pnpm@10.25.0",
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
},
"dependencies": {
"rss-parser": "^3.13.0"
}
"packageManager": "pnpm@11.22.0"
}
+1
View File
@@ -0,0 +1 @@
dump.rdb
+76
View File
@@ -0,0 +1,76 @@
import fs from "fs";
import path from "path";
import { SERVER_ROOT } from "./paths.js";
const ROOT_PKG = path.resolve(SERVER_ROOT, "../../package.json");
/**
* JerapahFlow app version from the monorepo root package.json.
* @returns {string}
*/
export function getAppVersion() {
try {
const raw = JSON.parse(fs.readFileSync(ROOT_PKG, "utf8"));
if (typeof raw.version === "string" && raw.version.trim()) {
return raw.version.trim();
}
} catch {
// fall through
}
return "0.1.0";
}
/**
* @param {string} version
* @returns {[number, number, number]}
*/
function parseSemver(version) {
const cleaned = String(version).trim().replace(/^v/i, "");
const core = cleaned.split("-")[0].split("+")[0];
const parts = core.split(".").map((p) => Number(p));
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
}
/**
* @param {[number, number, number]} a
* @param {[number, number, number]} b
*/
function cmp(a, b) {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}
/**
* Minimal semver range check for patterns used in manifests:
* `1.2.3`, `>=0.1.0`, `<1.0.0`, `>=0.1.0 <1.0.0`
*
* @param {string} version
* @param {string} range
* @returns {boolean}
*/
export function satisfiesRange(version, range) {
if (typeof range !== "string" || !range.trim()) return false;
const ver = parseSemver(version);
const tokens = range.trim().split(/\s+/);
for (const token of tokens) {
if (/^\d+\.\d+\.\d+/.test(token) && !token.startsWith(">") && !token.startsWith("<")) {
if (cmp(ver, parseSemver(token)) !== 0) return false;
continue;
}
const m = /^(>=|<=|>|<|=)?\s*v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/.exec(
token,
);
if (!m) return false;
const op = m[1] || "=";
const bound = parseSemver(m[2]);
const c = cmp(ver, bound);
if (op === ">=" && c < 0) return false;
if (op === "<=" && c > 0) return false;
if (op === ">" && c <= 0) return false;
if (op === "<" && c >= 0) return false;
if (op === "=" && c !== 0) return false;
}
return true;
}
+142
View File
@@ -0,0 +1,142 @@
import IORedis from "ioredis";
import { log } from "./logger.js";
import {
getRedisPassword,
getRedisUrl,
getSharedConnection,
} from "./workflow-queue.js";
export const CHANNEL_RELOAD = "jflow:reload";
export const HEARTBEAT_KEY = "jflow:heartbeats";
export const HEARTBEAT_TTL_SEC = 20;
export const HEARTBEAT_INTERVAL_MS = 5_000;
/**
* @returns {number}
*/
export function getConfigGeneration() {
const raw = Number(process.env.JFLOW_CONFIG_GENERATION ?? 1);
if (!Number.isFinite(raw) || raw < 1) return 1;
return Math.floor(raw);
}
/**
* Dedicated Redis connection for pub/sub (ioredis cannot mix pub/sub with other commands).
* @returns {IORedis}
*/
export function createPubSubConnection() {
/** @type {import("ioredis").RedisOptions} */
const options = { maxRetriesPerRequest: null, enableReadyCheck: true };
const password = getRedisPassword();
if (password) options.password = password;
const conn = new IORedis(getRedisUrl(), options);
conn.on("error", (err) => {
log.error({ err }, "redis pub/sub connection error");
});
return conn;
}
/**
* @param {string} role
* @param {{ pid?: number, hostname?: string }} [extra]
*/
export async function writeHeartbeat(role, extra = {}) {
const conn = getSharedConnection();
const payload = JSON.stringify({
role,
pid: extra.pid ?? process.pid,
hostname: extra.hostname ?? process.env.HOSTNAME ?? "local",
generation: getConfigGeneration(),
ts: Date.now(),
});
const field = `${role}:${process.pid}`;
await conn.hset(HEARTBEAT_KEY, field, payload);
await conn.expire(HEARTBEAT_KEY, HEARTBEAT_TTL_SEC * 3);
}
/**
* @returns {Promise<Array<{
* field: string,
* role: string,
* pid: number,
* hostname: string,
* generation: number,
* ts: number,
* stale: boolean,
* }>>}
*/
export async function readHeartbeats() {
const conn = getSharedConnection();
const all = await conn.hgetall(HEARTBEAT_KEY);
const now = Date.now();
/** @type {Array<any>} */
const out = [];
for (const [field, raw] of Object.entries(all)) {
try {
const parsed = JSON.parse(raw);
const ts = Number(parsed.ts) || 0;
out.push({
field,
role: String(parsed.role ?? "unknown"),
pid: Number(parsed.pid) || 0,
hostname: String(parsed.hostname ?? ""),
generation: Number(parsed.generation) || 0,
ts,
stale: now - ts > HEARTBEAT_TTL_SEC * 1000,
});
} catch {
// skip bad rows
}
}
return out;
}
/**
* @param {{ type?: string }} [payload]
*/
export async function publishReload(payload = { type: "workflows" }) {
const conn = getSharedConnection();
await conn.publish(CHANNEL_RELOAD, JSON.stringify(payload));
}
/**
* @param {(msg: { type?: string }) => void | Promise<void>} handler
* @returns {Promise<{ stop: () => Promise<void> }>}
*/
export async function subscribeReload(handler) {
const sub = createPubSubConnection();
await sub.subscribe(CHANNEL_RELOAD);
sub.on("message", (_channel, message) => {
let parsed = { type: "workflows" };
try {
parsed = JSON.parse(message);
} catch {
// use default
}
void Promise.resolve(handler(parsed)).catch((err) => {
log.error({ err }, "reload handler failed");
});
});
return {
async stop() {
await sub.unsubscribe(CHANNEL_RELOAD).catch(() => {});
await sub.quit().catch(() => sub.disconnect());
},
};
}
/**
* Start periodic heartbeats. Returns a stop function.
* @param {string} role
*/
export function startHeartbeatLoop(role) {
const tick = () => {
void writeHeartbeat(role).catch((err) => {
log.error({ err, role }, "heartbeat failed");
});
};
tick();
const timer = setInterval(tick, HEARTBEAT_INTERVAL_MS);
timer.unref?.();
return () => clearInterval(timer);
}
+164
View File
@@ -0,0 +1,164 @@
import fs from "fs";
import path from "path";
import { DATA_DIR } from "./paths.js";
const STATE_PATH = path.join(DATA_DIR, "control-state.json");
const LOCK_PATH = path.join(DATA_DIR, "ops.lock");
/**
* @typedef {{
* http: "running" | "stopped",
* workers: number,
* queuePaused: boolean,
* generation: number,
* restartNeeded: boolean,
* restartReason: string | null,
* }} ControlState
*/
/** @returns {ControlState} */
function defaultControlState() {
return {
http: "running",
workers: 1,
queuePaused: false,
generation: 1,
restartNeeded: false,
restartReason: null,
};
}
/**
* @returns {ControlState}
*/
export function readControlState() {
fs.mkdirSync(DATA_DIR, { recursive: true });
if (!fs.existsSync(STATE_PATH)) {
const initial = defaultControlState();
writeControlState(initial);
return initial;
}
try {
const raw = JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
const base = defaultControlState();
return {
http: raw.http === "stopped" ? "stopped" : "running",
workers: Math.max(0, Math.min(32, Number(raw.workers) || 1)),
queuePaused: Boolean(raw.queuePaused),
generation: Math.max(1, Math.floor(Number(raw.generation) || 1)),
restartNeeded: Boolean(raw.restartNeeded),
restartReason:
typeof raw.restartReason === "string" ? raw.restartReason : null,
};
} catch {
return defaultControlState();
}
}
/**
* @param {ControlState} state
*/
export function writeControlState(state) {
fs.mkdirSync(DATA_DIR, { recursive: true });
const tmp = `${STATE_PATH}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
fs.renameSync(tmp, STATE_PATH);
}
/**
* @param {Partial<ControlState>} patch
* @returns {ControlState}
*/
export function patchControlState(patch) {
const next = { ...readControlState(), ...patch };
writeControlState(next);
return next;
}
/**
* Bump config generation and mark restart needed.
* @param {string} reason
* @returns {ControlState}
*/
export function bumpGeneration(reason) {
const cur = readControlState();
return patchControlState({
generation: cur.generation + 1,
restartNeeded: true,
restartReason: reason,
});
}
/**
* Clear restart-needed after processes match generation.
* @returns {ControlState}
*/
export function clearRestartNeeded() {
return patchControlState({
restartNeeded: false,
restartReason: null,
});
}
/**
* @param {string} owner
* @param {number} [ttlMs]
* @returns {{ ok: true, token: string } | { ok: false, error: string, holder?: string }}
*/
export function tryAcquireOpsLock(owner, ttlMs = 120_000) {
fs.mkdirSync(DATA_DIR, { recursive: true });
const now = Date.now();
if (fs.existsSync(LOCK_PATH)) {
try {
const existing = JSON.parse(fs.readFileSync(LOCK_PATH, "utf8"));
if (existing.expiresAt > now) {
return {
ok: false,
error: "ops lock held",
holder: String(existing.owner ?? "unknown"),
};
}
} catch {
// stale/corrupt lock — overwrite
}
}
const token = `${owner}:${now}:${Math.random().toString(36).slice(2)}`;
const payload = {
owner,
token,
expiresAt: now + ttlMs,
};
fs.writeFileSync(LOCK_PATH, `${JSON.stringify(payload)}\n`, "utf8");
return { ok: true, token };
}
/**
* @param {string} token
*/
export function releaseOpsLock(token) {
if (!fs.existsSync(LOCK_PATH)) return;
try {
const existing = JSON.parse(fs.readFileSync(LOCK_PATH, "utf8"));
if (existing.token !== token) return;
} catch {
// ignore
}
fs.unlinkSync(LOCK_PATH);
}
/**
* @param {string} token
* @param {number} [ttlMs]
*/
export function refreshOpsLock(token, ttlMs = 120_000) {
if (!fs.existsSync(LOCK_PATH)) return false;
try {
const existing = JSON.parse(fs.readFileSync(LOCK_PATH, "utf8"));
if (existing.token !== token) return false;
existing.expiresAt = Date.now() + ttlMs;
fs.writeFileSync(LOCK_PATH, `${JSON.stringify(existing)}\n`, "utf8");
return true;
} catch {
return false;
}
}
+495
View File
@@ -0,0 +1,495 @@
import fastify from "fastify";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import jwt from "@fastify/jwt";
import { migrate, db } from "./db.js";
import { log, enableLogPersistence, flushLogs } from "./logger.js";
import authPlugin, { addApiAuthGuard, COOKIE } from "./src/api/auth.js";
import {
clearRestartNeeded,
bumpGeneration,
patchControlState,
readControlState,
refreshOpsLock,
releaseOpsLock,
tryAcquireOpsLock,
} from "./control-state.js";
import {
getConfigGeneration,
publishReload,
readHeartbeats,
} from "./control-bus.js";
import {
closeRedis,
createWorkflowQueue,
getRedisUrlForLog,
} from "./workflow-queue.js";
import { reconcileOrphanRuns } from "./orphan-runs.js";
import {
connectPm2,
deletePm2App,
describeChildren,
disconnectPm2,
ensureHttp,
ensureWorkers,
PM2_HTTP_NAME,
PM2_WORKER_NAME,
recreateChildren,
restartPm2Process,
stopPm2App,
} from "./pm2-bridge.js";
const DRAIN_DEFAULT_MS = 60_000;
const DRAIN_POLL_MS = 500;
const jwtSecret =
process.env.JFLOW_JWT_SECRET ??
(process.env.NODE_ENV === "production" ? "" : "jflow-dev-secret");
if (!jwtSecret) {
log.error("JFLOW_JWT_SECRET is required in production");
process.exit(1);
}
await migrate();
enableLogPersistence();
log.info({ redis: getRedisUrlForLog() }, "starting jerapah-flow control");
const workflowQueue = createWorkflowQueue();
try {
await workflowQueue.waitUntilReady();
} catch (err) {
log.error({ err, redis: getRedisUrlForLog() }, "failed to connect to Redis");
process.exit(1);
}
try {
await connectPm2();
} catch (err) {
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
process.exit(1);
}
async function applyDesiredState() {
const state = readControlState();
await ensureHttp({
generation: state.generation,
running: state.http === "running",
});
await ensureWorkers({
generation: state.generation,
count: state.workers,
});
if (state.queuePaused) {
await workflowQueue.pause();
} else {
const paused = await workflowQueue.isPaused();
if (paused) await workflowQueue.resume();
}
log.info(
{
http: state.http,
workers: state.workers,
generation: state.generation,
queuePaused: state.queuePaused,
},
"applied desired state",
);
}
await applyDesiredState();
const server = fastify({ loggerInstance: log });
await server.register(cookie);
await server.register(jwt, {
secret: jwtSecret,
cookie: { cookieName: COOKIE, signed: false },
});
await server.register(cors, {
origin: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
credentials: true,
});
server.decorate("authenticate", async function authenticate(req, reply) {
try {
await req.jwtVerify();
} catch {
return reply.code(401).send({ error: "unauthorized" });
}
});
server.decorate("requireAdmin", async function requireAdmin(req, reply) {
if (req.user?.role !== "admin") {
return reply.code(403).send({ error: "forbidden" });
}
});
await server.register(
async (api) => {
addApiAuthGuard(api, server);
await api.register(authPlugin);
},
{ prefix: "/api" },
);
/**
* @param {number} timeoutMs
* @param {string} lockToken
*/
async function waitUntilIdle(timeoutMs, lockToken) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
refreshOpsLock(lockToken);
await reconcileOrphanRuns(workflowQueue);
const active = await workflowQueue.getActiveCount();
if (active === 0) return { ok: true, active: 0 };
await new Promise((r) => setTimeout(r, DRAIN_POLL_MS));
}
const active = await workflowQueue.getActiveCount();
return { ok: active === 0, active };
}
/** @param {unknown} raw */
function parsePmId(raw) {
if (raw == null || raw === "") return null;
const id = Math.floor(Number(raw));
if (!Number.isFinite(id) || id < 0) return Number.NaN;
return id;
}
async function handleProcessRestart(pmId, reply) {
const lock = tryAcquireOpsLock(`process-restart:${process.pid}`);
if (!lock.ok) {
return reply.code(409).send({ error: lock.error, holder: lock.holder });
}
try {
const restarted = await restartPm2Process(pmId);
return reply.send({ ok: true, ...restarted, status: await buildStatus() });
} catch (err) {
const code = err && typeof err === "object" ? err.code : undefined;
if (code === "BAD_REQUEST") {
return reply.code(400).send({ error: "pmId is required" });
}
if (code === "NOT_FOUND") {
return reply.code(404).send({ error: "process not found" });
}
if (code === "FORBIDDEN") {
return reply.code(403).send({ error: "process is not a JerapahFlow child" });
}
log.error({ err, pmId }, "process restart failed");
return reply.code(500).send({
error: err instanceof Error ? err.message : String(err),
});
} finally {
releaseOpsLock(lock.token);
}
}
async function buildStatus() {
const state = readControlState();
const children = await describeChildren();
const heartbeats = await readHeartbeats();
const live = heartbeats.filter((h) => !h.stale);
const queuePaused = await workflowQueue.isPaused();
const counts = await workflowQueue.getJobCounts(
"active",
"waiting",
"delayed",
"paused",
"failed",
"completed",
);
const orphans = await reconcileOrphanRuns(workflowQueue);
const expectedGen = state.generation;
const mismatched = live.filter((h) => h.generation !== expectedGen);
return {
control: {
pid: process.pid,
generation: getConfigGeneration(),
},
desired: state,
children,
heartbeats: live,
generationMismatch: mismatched.length > 0 || state.restartNeeded,
mismatched,
queue: {
paused: queuePaused,
counts,
},
orphans,
};
}
server.get(
"/ops/status",
{ onRequest: [server.authenticate] },
async () => buildStatus(),
);
server.get("/ops/health", async () => ({ ok: true, role: "control" }));
server.post(
"/ops/pause",
{ onRequest: [server.authenticate, server.requireAdmin] },
async (_req, reply) => {
await workflowQueue.pause();
patchControlState({ queuePaused: true });
return reply.send({ ok: true, queuePaused: true });
},
);
server.post(
"/ops/resume",
{ onRequest: [server.authenticate, server.requireAdmin] },
async (_req, reply) => {
await workflowQueue.resume();
patchControlState({ queuePaused: false });
return reply.send({ ok: true, queuePaused: false });
},
);
server.post(
"/ops/reload",
{ onRequest: [server.authenticate, server.requireAdmin] },
async (_req, reply) => {
await publishReload({ type: "workflows" });
return reply.send({ ok: true, published: true });
},
);
server.post(
"/ops/generation/bump",
{ onRequest: [server.authenticate, server.requireAdmin] },
async (req, reply) => {
const body = /** @type {{ reason?: string }} */ (req.body ?? {});
const reason = String(body.reason ?? "manual bump").slice(0, 200);
const state = bumpGeneration(reason);
return reply.send({ ok: true, desired: state });
},
);
server.post(
"/ops/http/start",
{ onRequest: [server.authenticate, server.requireAdmin] },
async (_req, reply) => {
const state = patchControlState({ http: "running" });
await ensureHttp({ generation: state.generation, running: true });
return reply.send({ ok: true, desired: state });
},
);
server.post(
"/ops/http/stop",
{ onRequest: [server.authenticate, server.requireAdmin] },
async (_req, reply) => {
const state = patchControlState({ http: "stopped" });
await stopPm2App(PM2_HTTP_NAME);
return reply.send({ ok: true, desired: state });
},
);
server.post(
"/ops/restart",
{ onRequest: [server.authenticate, server.requireAdmin] },
async (req, reply) => {
const body = /** @type {{ force?: boolean, timeoutMs?: number, pmId?: number }} */ (
req.body ?? {}
);
const pmId = parsePmId(body.pmId);
if (pmId != null) {
if (Number.isNaN(pmId)) {
return reply.code(400).send({ error: "pmId is required" });
}
return handleProcessRestart(pmId, reply);
}
const force = Boolean(body.force);
const timeoutMs = Math.min(
Math.max(Number(body.timeoutMs) || DRAIN_DEFAULT_MS, 1_000),
10 * 60_000,
);
const lock = tryAcquireOpsLock(`restart:${process.pid}`);
if (!lock.ok) {
return reply.code(409).send({ error: lock.error, holder: lock.holder });
}
const stateBefore = readControlState();
const wantPaused = stateBefore.queuePaused;
try {
await workflowQueue.pause();
if (!force) {
const drained = await waitUntilIdle(timeoutMs, lock.token);
if (!drained.ok) {
if (!wantPaused) {
await workflowQueue.resume();
}
return reply.code(409).send({
error: "active jobs still running",
active: drained.active,
hint: "wait or retry with force=true",
});
}
}
const desired = readControlState();
await stopPm2App(PM2_HTTP_NAME);
await deletePm2App(PM2_HTTP_NAME);
await stopPm2App(PM2_WORKER_NAME);
await deletePm2App(PM2_WORKER_NAME);
await reconcileOrphanRuns(workflowQueue);
// Drop stale heartbeats from killed PIDs
try {
const { HEARTBEAT_KEY } = await import("./control-bus.js");
const { getSharedConnection } = await import("./workflow-queue.js");
await getSharedConnection().del(HEARTBEAT_KEY);
} catch {
// ignore
}
await migrate();
await recreateChildren({
generation: desired.generation,
http: desired.http === "running",
workers: desired.workers,
});
if (wantPaused) {
await workflowQueue.pause();
patchControlState({ queuePaused: true });
} else {
await workflowQueue.resume();
patchControlState({ queuePaused: false });
}
await new Promise((r) => setTimeout(r, 1500));
clearRestartNeeded();
return reply.send({
ok: true,
forced: force,
desired: readControlState(),
status: await buildStatus(),
});
} catch (err) {
log.error({ err }, "restart failed");
return reply.code(500).send({
error: err instanceof Error ? err.message : String(err),
});
} finally {
releaseOpsLock(lock.token);
}
},
);
server.post(
"/ops/process/restart",
{ onRequest: [server.authenticate, server.requireAdmin] },
async (req, reply) => {
const body = /** @type {{ pmId?: number }} */ (req.body ?? {});
const pmId = parsePmId(body.pmId);
if (pmId == null || Number.isNaN(pmId)) {
return reply.code(400).send({ error: "pmId is required" });
}
return handleProcessRestart(pmId, reply);
},
);
server.post(
"/ops/scale",
{ onRequest: [server.authenticate, server.requireAdmin] },
async (req, reply) => {
const body = /** @type {{ workers?: number, force?: boolean, timeoutMs?: number }} */ (
req.body ?? {}
);
const workers = Math.floor(Number(body.workers));
if (!Number.isFinite(workers) || workers < 0 || workers > 32) {
return reply.code(400).send({ error: "workers must be 0..32" });
}
const force = Boolean(body.force);
const timeoutMs = Math.min(
Math.max(Number(body.timeoutMs) || DRAIN_DEFAULT_MS, 1_000),
10 * 60_000,
);
const current = readControlState();
const scalingDown = workers < current.workers;
const lock = tryAcquireOpsLock(`scale:${process.pid}`);
if (!lock.ok) {
return reply.code(409).send({ error: lock.error, holder: lock.holder });
}
const wasPaused = await workflowQueue.isPaused();
try {
if (scalingDown) {
await workflowQueue.pause();
if (!force) {
const drained = await waitUntilIdle(timeoutMs, lock.token);
if (!drained.ok) {
if (!wasPaused) {
await workflowQueue.resume();
patchControlState({ queuePaused: false });
}
return reply.code(409).send({
error: "active jobs still running",
active: drained.active,
hint: "wait or retry with force=true",
});
}
}
}
const state = patchControlState({ workers });
await ensureWorkers({ generation: state.generation, count: workers });
if (scalingDown && !wasPaused && !state.queuePaused) {
await workflowQueue.resume();
patchControlState({ queuePaused: false });
}
return reply.send({ ok: true, desired: readControlState() });
} catch (err) {
log.error({ err }, "scale failed");
return reply.code(500).send({
error: err instanceof Error ? err.message : String(err),
});
} finally {
releaseOpsLock(lock.token);
}
},
);
async function shutdown() {
try {
await workflowQueue.close();
await closeRedis();
await flushLogs();
await db.destroy();
disconnectPm2();
} catch (err) {
log.error({ err }, "control shutdown error");
}
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600);
server
.listen({ host: "0.0.0.0", port })
.then(() => {
log.info(`Control is running on port ${port}`);
})
.catch((err) => {
log.error({ err }, "failed to start control");
process.exit(1);
});
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Start Vite (:8500) + control (:8600). Control connects to PM2 and starts HTTP/workers.
*
* Usage: pnpm dev:pm2
*/
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import net from "node:net";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const children = [];
function run(command, args, opts = {}) {
const child = spawn(command, args, {
cwd: root,
stdio: "inherit",
env: {
...process.env,
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
JFLOW_CONTROL_PORT: process.env.JFLOW_CONTROL_PORT ?? "8600",
PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
...opts.env,
},
shell: false,
});
children.push(child);
child.on("exit", (code, signal) => {
if (shuttingDown) return;
console.error(`[dev:pm2] ${command} ${args.join(" ")} exited (${code ?? signal})`);
shutdown(code ?? 1);
});
return child;
}
let shuttingDown = false;
async function redisReachable() {
const url = process.env.REDIS_URL || "redis://127.0.0.1:6379";
let host = "127.0.0.1";
let port = 6379;
try {
const u = new URL(url);
host = u.hostname || host;
port = Number(u.port || 6379);
} catch {
// keep defaults
}
return new Promise((resolve) => {
const socket = net.connect({ host, port });
socket.setTimeout(1500);
socket.on("connect", () => {
socket.destroy();
resolve(true);
});
socket.on("timeout", () => {
socket.destroy();
resolve(false);
});
socket.on("error", () => resolve(false));
});
}
async function shutdown(code = 0) {
if (shuttingDown) return;
shuttingDown = true;
for (const child of children) {
if (!child.killed) child.kill("SIGTERM");
}
// Best-effort: stop jflow apps so the next run is clean
try {
const stop = spawn(
process.platform === "win32" ? "npx.cmd" : "npx",
["pm2", "delete", "jflow-http", "jflow-worker"],
{ cwd: root, stdio: "ignore", shell: false },
);
await new Promise((r) => stop.on("exit", r));
} catch {
// ignore
}
process.exit(code);
}
process.on("SIGINT", () => void shutdown(0));
process.on("SIGTERM", () => void shutdown(0));
if (!(await redisReachable())) {
console.error(
"[dev:pm2] Redis is not reachable. Start Redis (default redis://127.0.0.1:6379) and retry.",
);
process.exit(1);
}
console.log("[dev:pm2] starting control on :8600 (PM2 will start HTTP :8700 + workers)");
run(process.execPath, [path.join(root, "server/control.js")], {
env: {
JFLOW_CONTROL_PORT: "8600",
},
});
// Give control a moment to migrate + spawn before Vite opens
await new Promise((r) => setTimeout(r, 1500));
console.log("[dev:pm2] starting Vite on :8500");
run(
process.platform === "win32" ? "pnpm.cmd" : "pnpm",
["--filter", "@jerapah-flow/web", "dev"],
{
env: {
JFLOW_AUTH_PROXY: "http://127.0.0.1:8600",
},
},
);
+39
View File
@@ -0,0 +1,39 @@
/**
* Local multi-process Ops UI ecosystem (`pnpm dev:pm2`).
* Prefer starting children via control.js (desired state) rather than this file.
* Kept as a reference / fallback: `pm2 start packages/server/ecosystem.dev.cjs`
* For production monolith, use root ecosystem.config.cjs instead.
*/
const path = require("path");
const root = path.resolve(__dirname, "../..");
module.exports = {
apps: [
{
name: "jflow-http",
script: path.join(__dirname, "server.js"),
cwd: root,
instances: 1,
exec_mode: "fork",
env: {
JFLOW_ROLE: "api",
PORT: "8700",
JFLOW_CORS_ORIGIN: "http://localhost:8500",
JFLOW_CONFIG_GENERATION: "1",
},
},
{
name: "jflow-worker",
script: path.join(__dirname, "worker.js"),
cwd: root,
instances: 1,
exec_mode: "fork",
env: {
JFLOW_ROLE: "worker",
JFLOW_CORS_ORIGIN: "http://localhost:8500",
JFLOW_CONFIG_GENERATION: "1",
},
},
],
};
+27 -32
View File
@@ -49,14 +49,8 @@ export function readScript(name) {
return fs.readFileSync(filePath, "utf8");
}
export function writeScript(name, content) {
assertScriptName(name);
fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8");
}
/**
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
* Icon next to the script: `fetch-html.js` → `fetch-html.png`, `.jpg`, or `.jpeg`.
* @returns {{ filePath: string, contentType: string } | null}
*/
export function resolveScriptIcon(name) {
@@ -65,6 +59,7 @@ export function resolveScriptIcon(name) {
for (const { ext, contentType } of [
{ ext: "png", contentType: "image/png" },
{ ext: "jpg", contentType: "image/jpeg" },
{ ext: "jpeg", contentType: "image/jpeg" },
]) {
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
@@ -78,22 +73,6 @@ export function scriptHasIcon(name) {
return resolveScriptIcon(name) != null;
}
export function deleteScript(name) {
assertScriptName(name);
const filePath = path.join(SCRIPTS_DIR, name);
if (!fs.existsSync(filePath)) return false;
const icon = resolveScriptIcon(name);
fs.unlinkSync(filePath);
if (icon) {
try {
fs.unlinkSync(icon.filePath);
} catch {
// ignore missing icon
}
}
return true;
}
export function listOwners() {
if (!fs.existsSync(WORKFLOWS_DIR)) return [];
return fs
@@ -132,6 +111,31 @@ export function readWorkflowYaml(owner, file) {
return fs.readFileSync(filePath, "utf8");
}
/**
* Last content change time for a workflow YAML file.
* Uses birthtime (creation) when the file has not been modified since it was created.
* @returns {string | null} ISO timestamp
*/
export function workflowLastModifiedAt(owner, file) {
try {
assertOwner(owner);
assertWorkflowFile(file);
} catch {
return null;
}
const filePath = path.join(WORKFLOWS_DIR, owner, file);
try {
const st = fs.statSync(filePath);
const birthMs = Number.isFinite(st.birthtimeMs) && st.birthtimeMs > 0 ? st.birthtimeMs : null;
const mtimeMs = Number.isFinite(st.mtimeMs) && st.mtimeMs > 0 ? st.mtimeMs : null;
const unmodified = birthMs != null && (mtimeMs == null || mtimeMs <= birthMs + 1000);
const ms = unmodified ? birthMs : (mtimeMs ?? birthMs);
return ms != null ? new Date(ms).toISOString() : null;
} catch {
return null;
}
}
export function writeWorkflowYaml(owner, file, content) {
assertOwner(owner);
assertWorkflowFile(file);
@@ -140,15 +144,6 @@ export function writeWorkflowYaml(owner, file, content) {
fs.writeFileSync(path.join(ownerDir, file), content, "utf8");
}
export function deleteWorkflowYaml(owner, file) {
assertOwner(owner);
assertWorkflowFile(file);
const filePath = path.join(WORKFLOWS_DIR, owner, file);
if (!fs.existsSync(filePath)) return false;
fs.unlinkSync(filePath);
return true;
}
export function listOwnerYamlFiles(owner) {
const ownerDir = path.join(WORKFLOWS_DIR, owner);
if (!fs.existsSync(ownerDir)) return [];
+1 -348
View File
@@ -1,348 +1 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { assertHttpStatus } from "./http-pages-store.js";
const MAX_NAME_LENGTH = 128;
const NAME_RE = /^[A-Za-z0-9._-]+$/;
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
function nowIso() {
return new Date().toISOString();
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertAuthName(name) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
const err = new Error("invalid auth name");
err.statusCode = 400;
throw err;
}
if (name.length > MAX_NAME_LENGTH) {
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
err.statusCode = 400;
throw err;
}
return name;
}
/**
* @param {unknown} type
* @returns {"bearer" | "basic" | "header"}
*/
export function assertAuthType(type) {
const t = String(type ?? "");
if (!ALLOWED_TYPES.has(t)) {
const err = new Error('auth type must be "bearer", "basic", or "header"');
err.statusCode = 400;
throw err;
}
return /** @type {"bearer" | "basic" | "header"} */ (t);
}
/**
* Detect value source without exposing literal values.
* @param {unknown} value
* @returns {"literal" | "kv" | "secret" | "missing"}
*/
export function valueSourceKind(value) {
if (value == null) return "missing";
if (typeof value === "string") return "literal";
if (typeof value === "object" && !Array.isArray(value)) {
if ("secret" in value) return "secret";
if ("kv" in value) return "kv";
}
return "literal";
}
/**
* Redact config for API responses: replace literal strings with source markers.
* @param {Record<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} name
*/
export async function getHttpAuthInternal(name) {
const row = await db("http_auths").where({ name: assertAuthName(name) }).first();
if (!row) return null;
return {
id: row.id,
name: row.name,
type: row.type,
config: parseConfig(row.config),
unauthorized_status: row.unauthorized_status ?? null,
unauthorized_response: row.unauthorized_response ?? null,
};
}
/**
* Return only plaintext literal credential fields (not KV refs or encrypted secrets).
* @param {string} name
* @returns {Promise<{ name: string, type: string, literals: Record<string, string> } | null>}
*/
export async function revealHttpAuthLiterals(name) {
const internal = await getHttpAuthInternal(name);
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 { 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} name
*/
export async function getHttpAuthByName(name) {
const row = await db("http_auths").where({ name: assertAuthName(name) }).first();
return row ? publicAuth(row) : null;
}
/**
* @param {string} id
*/
export async function getHttpAuthById(id) {
const row = await db("http_auths").where({ id }).first();
return row ? publicAuth(row) : null;
}
/**
* @param {{
* name: string,
* type: string,
* config?: unknown,
* unauthorized_status?: number | null,
* unauthorized_response?: string | null,
* }} opts
*/
export async function upsertHttpAuth({
name,
type,
config,
unauthorized_status,
unauthorized_response,
}) {
const authName = assertAuthName(name);
const authType = assertAuthType(type);
const existing = await db("http_auths").where({ name: authName }).first();
const prevConfig = existing ? parseConfig(existing.config) : {};
const normalized = normalizeAuthConfig(authType, config, {
keepLiteralsFrom: prevConfig,
});
let unauthStatus = null;
if (unauthorized_status != null && unauthorized_status !== "") {
unauthStatus = assertHttpStatus(unauthorized_status, 401);
}
let unauthResponse = null;
if (
unauthorized_response != null &&
String(unauthorized_response).length > 0
) {
unauthResponse = String(unauthorized_response);
}
const now = nowIso();
const configJson = JSON.stringify(normalized);
if (existing) {
await db("http_auths")
.where({ id: existing.id })
.update({
type: authType,
config: configJson,
unauthorized_status: unauthStatus,
unauthorized_response: unauthResponse,
updated_at: now,
});
return getHttpAuthById(existing.id);
}
const id = randomUUID();
await db("http_auths").insert({
id,
name: authName,
type: authType,
config: configJson,
unauthorized_status: unauthStatus,
unauthorized_response: unauthResponse,
created_at: now,
updated_at: now,
});
return getHttpAuthById(id);
}
/**
* @param {string} id
* @returns {Promise<boolean>}
*/
export async function deleteHttpAuth(id) {
const n = await db("http_auths").where({ id }).del();
return n > 0;
}
export * from "./src/stores/http-auths-store.js";
+86 -30
View File
@@ -77,38 +77,47 @@ export async function resolveCredentialValue(field, ctx) {
}
/**
* Normalize trigger.auth into an inline auth mechanism object.
* @param {unknown} authField
* @returns {Promise<{
* @typedef {{
* type: string,
* config: Record<string, unknown>,
* unauthorized_status?: number | null,
* unauthorized_response?: string | null,
* label: string,
* } | null>}
* }} AuthMechanism
*/
export async function resolveAuthMechanism(authField) {
if (authField == null || authField === false) return null;
if (typeof authField === "string") {
const named = await getHttpAuthInternal(authField);
if (!named) {
log.warn({ name: authField }, "http auth: named profile not found");
/**
* Resolve one auth entry (auth profile id UUID, or inline object).
* @param {unknown} entry
* @returns {Promise<AuthMechanism | null>}
*/
export async function resolveAuthMechanism(entry) {
if (entry == null || entry === false) return null;
if (typeof entry === "string") {
try {
const named = await getHttpAuthInternal(entry);
if (!named) {
log.warn({ id: entry }, "http auth: profile id not found");
return null;
}
return {
type: named.type,
config: named.config,
unauthorized_status: named.unauthorized_status,
unauthorized_response: named.unauthorized_response,
label: named.name,
};
} catch (err) {
log.warn({ err, id: entry }, "http auth: invalid profile id");
return null;
}
return {
type: named.type,
config: named.config,
unauthorized_status: named.unauthorized_status,
unauthorized_response: named.unauthorized_response,
label: authField,
};
}
if (typeof authField === "object" && !Array.isArray(authField)) {
const obj = /** @type {Record<string, unknown>} */ (authField);
if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) {
return resolveAuthMechanism(obj.name);
if (typeof entry === "object" && !Array.isArray(entry)) {
const obj = /** @type {Record<string, unknown>} */ (entry);
if (typeof obj.id === "string" && obj.id.length > 0 && !obj.type) {
return resolveAuthMechanism(obj.id);
}
try {
const type = assertAuthType(obj.type);
@@ -116,6 +125,7 @@ export async function resolveAuthMechanism(authField) {
const config = { ...obj };
delete config.type;
delete config.name;
delete config.id;
return {
type,
config,
@@ -133,18 +143,64 @@ export async function resolveAuthMechanism(authField) {
}
/**
* Label for mermaid / summary (sync, no DB).
* Normalize trigger.auth (array of auth ids / inline objects) into mechanisms.
* Empty / null / false → no auth. Any entry that fails to resolve is skipped;
* if the field was non-empty but nothing resolves, returns [] (caller treats as unauthorized).
* @param {unknown} authField
* @returns {Promise<AuthMechanism[]>}
*/
export function authLabel(authField) {
if (authField == null) return null;
if (typeof authField === "string") return authField;
if (typeof authField === "object" && !Array.isArray(authField)) {
const o = /** @type {Record<string, unknown>} */ (authField);
if (typeof o.name === "string" && o.name) return o.name;
if (typeof o.type === "string" && o.type) return o.type;
export async function resolveAuthMechanisms(authField) {
if (authField == null || authField === false) return [];
if (!Array.isArray(authField) || authField.length === 0) return [];
/** @type {AuthMechanism[]} */
const out = [];
for (const entry of authField) {
const mech = await resolveAuthMechanism(entry);
if (mech) out.push(mech);
}
return "auth";
return out;
}
/**
* True if any mechanism accepts the request (OR).
* @param {import("fastify").FastifyRequest} req
* @param {AuthMechanism[]} mechanisms
* @param {{ owner: string, workflowKey: string }} ctx
*/
export async function checkAnyHttpAuth(req, mechanisms, ctx) {
for (const mechanism of mechanisms) {
if (await checkHttpAuth(req, mechanism, ctx)) return true;
}
return false;
}
/**
* Label for mermaid / summary (sync). Prefer resolved display names when provided.
* @param {unknown} authField
* @param {Map<string, string> | Record<string, string>} [nameById]
*/
export function authLabel(authField, nameById) {
if (authField == null || authField === false) return null;
if (!Array.isArray(authField) || authField.length === 0) return null;
const lookup =
nameById instanceof Map
? (id) => nameById.get(id)
: nameById
? (id) => nameById[id]
: () => undefined;
const parts = authField.map((entry) => {
if (typeof entry === "string") return lookup(entry) ?? entry;
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
const o = /** @type {Record<string, unknown>} */ (entry);
if (typeof o.id === "string" && o.id && !o.type) {
return lookup(o.id) ?? o.id;
}
if (typeof o.type === "string" && o.type) return o.type;
}
return "auth";
});
return parts.join("|");
}
/**
+88 -5
View File
@@ -11,16 +11,21 @@ export function isBinary(value) {
);
}
/**
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
*/
export function toBuffer(value) {
if (Buffer.isBuffer(value)) return value;
if (value instanceof ArrayBuffer) return Buffer.from(value);
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
}
/**
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
*/
export function summarizeBinary(value) {
const buf = Buffer.isBuffer(value)
? value
: value instanceof ArrayBuffer
? Buffer.from(value)
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
const buf = toBuffer(value);
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
return {
type: "Buffer",
@@ -30,6 +35,84 @@ export function summarizeBinary(value) {
};
}
/**
* JSON-safe Buffer that can be revived (dry-run / Try chaining).
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
*/
export function encodeBinary(value) {
const buf = toBuffer(value);
return {
type: "Buffer",
encoding: "base64",
data: buf.toString("base64"),
length: buf.length,
};
}
/**
* @param {unknown} value
*/
export function isWireBuffer(value) {
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
const obj = /** @type {{ type?: unknown, encoding?: unknown, data?: unknown }} */ (value);
if (obj.type !== "Buffer") return false;
if (obj.encoding === "base64" && typeof obj.data === "string") return true;
return Array.isArray(obj.data);
}
/**
* Replace live Buffers with reconstructable JSON (for dry-run responses).
* Display still uses summarizeBinary / safeSerialize.
* @param {unknown} value
*/
export function encodeBinaryForWire(value) {
const seen = new WeakSet();
/** @param {unknown} v */
function walk(v) {
if (isBinary(v)) return encodeBinary(v);
if (typeof v === "bigint") return v.toString();
if (v == null || typeof v !== "object") return v;
if (seen.has(v)) return "[Circular]";
seen.add(v);
if (Array.isArray(v)) return v.map(walk);
/** @type {Record<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.
+1 -399
View File
@@ -1,399 +1 @@
import { db } from "./db.js";
const MAX_KEY_LENGTH = 512;
const MAX_NAMESPACE_LENGTH = 512;
const MAX_VALUE_BYTES = 256 * 1024;
const DEFAULT_LIST_LIMIT = 100;
const MAX_LIST_LIMIT = 500;
function nowIso() {
return new Date().toISOString();
}
/**
* @param {unknown} value
*/
function valuesEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return a === b;
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
/**
* @param {string} label
* @param {unknown} value
*/
function assertString(label, value) {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${label} must be a non-empty string`);
}
}
/**
* @param {string} label
* @param {string} value
* @param {number} max
*/
function assertMaxLength(label, value, max) {
if (value.length > max) {
throw new Error(`${label} must be at most ${max} characters`);
}
}
/**
* @param {string} namespace
*/
function assertNamespace(namespace) {
assertString("namespace", namespace);
assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH);
}
/**
* @param {string} key
*/
function assertKey(key) {
assertString("key", key);
assertMaxLength("key", key, MAX_KEY_LENGTH);
}
/**
* @param {unknown} value
* @returns {string}
*/
export function serializeKvValue(value) {
let json;
try {
json = JSON.stringify(value);
} catch {
throw new Error("value must be JSON-serializable");
}
if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) {
throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`);
}
return json;
}
/**
* @param {string | null | undefined} value
* @returns {unknown}
*/
function deserializeKvValue(value) {
if (value == null) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
}
/**
* @param {{ expires_at?: string | null }} row
*/
function isExpired(row) {
if (!row.expires_at) return false;
return Date.parse(row.expires_at) <= Date.now();
}
/**
* @param {string} namespace
* @param {string} key
* @returns {Promise<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 });
},
};
}
export * from "./src/stores/kv-store.js";
@@ -0,0 +1,24 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.alterTable("workflow_runs", (t) => {
t.text("job_id");
t.text("queued_at");
});
await knex.schema.raw(
"CREATE INDEX workflow_runs_job_id_idx ON workflow_runs (job_id)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.raw("DROP INDEX IF EXISTS workflow_runs_job_id_idx");
await knex.schema.alterTable("workflow_runs", (t) => {
t.dropColumn("job_id");
t.dropColumn("queued_at");
});
}
@@ -0,0 +1,49 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.createTable("workflow_revisions", (t) => {
t.text("id").primary();
t.text("workflow_id").notNullable();
t.text("owner").notNullable();
t.text("file").notNullable();
t.integer("revision").notNullable();
t.text("content_sha").notNullable();
t.text("content").notNullable();
t.text("reason");
t.text("meta");
t.text("created_at").notNullable();
});
await knex.schema.raw(
"CREATE UNIQUE INDEX workflow_revisions_workflow_id_revision_idx ON workflow_revisions (workflow_id, revision)",
);
await knex.schema.raw(
"CREATE INDEX workflow_revisions_workflow_id_created_at_idx ON workflow_revisions (workflow_id, created_at DESC)",
);
await knex.schema.createTable("workflow_trash", (t) => {
t.text("id").primary();
t.text("workflow_id").notNullable();
t.text("owner").notNullable();
t.text("file").notNullable();
t.text("name");
t.text("deleted_at").notNullable();
t.text("trash_path").notNullable();
});
await knex.schema.raw(
"CREATE INDEX workflow_trash_deleted_at_idx ON workflow_trash (deleted_at ASC)",
);
await knex.schema.raw(
"CREATE UNIQUE INDEX workflow_trash_owner_file_idx ON workflow_trash (owner, file)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.dropTableIfExists("workflow_trash");
await knex.schema.dropTableIfExists("workflow_revisions");
}
@@ -0,0 +1,21 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.alterTable("workflow_runs", (t) => {
t.integer("workflow_revision");
});
await knex.schema.raw(
"CREATE INDEX workflow_runs_workflow_revision_idx ON workflow_runs (workflow, workflow_revision)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.raw("DROP INDEX IF EXISTS workflow_runs_workflow_revision_idx");
await knex.schema.alterTable("workflow_runs", (t) => {
t.dropColumn("workflow_revision");
});
}
@@ -0,0 +1,25 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.createTable("profiles", (t) => {
t.text("id").primary();
t.text("owner").notNullable();
t.text("name").notNullable();
t.text("script").notNullable();
t.text("config").notNullable();
t.text("description").notNullable().defaultTo("");
t.text("created_at").notNullable();
t.text("updated_at").notNullable();
t.unique(["owner", "name"]);
});
await knex.schema.raw("CREATE INDEX profiles_owner_name_idx ON profiles (owner, name)");
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.dropTableIfExists("profiles");
}
+58
View File
@@ -0,0 +1,58 @@
import * as store from "./store.js";
import { log } from "./logger.js";
/**
* Mark SQLite runs stuck in `running` when BullMQ no longer has them active.
*
* @param {import("bullmq").Queue} queue
* @returns {Promise<{ repaired: number, ids: string[] }>}
*/
export async function reconcileOrphanRuns(queue) {
const runs = await store.listRuns({ status: "running", limit: 200 });
if (!runs.length) return { repaired: 0, ids: [] };
/** @type {Set<string>} */
const activeIds = new Set();
try {
const active = await queue.getJobs(["active"]);
for (const job of active) {
if (job?.id != null) activeIds.add(String(job.id));
}
} catch (err) {
log.error({ err }, "failed to list active jobs for orphan reconcile");
return { repaired: 0, ids: [] };
}
/** @type {string[]} */
const ids = [];
for (const run of runs) {
const jobId = run.job_id ? String(run.job_id) : run.id;
if (activeIds.has(jobId)) continue;
let stillAlive = false;
try {
const job = await queue.getJob(jobId);
if (job) {
const state = await job.getState();
// waiting/delayed means not started as running worker — still orphan for SQLite running
stillAlive = state === "active";
}
} catch {
stillAlive = false;
}
if (stillAlive) continue;
await store.finishRun(
run.id,
"failed",
null,
new Error("worker_lost: run interrupted by process stop or crash"),
);
ids.push(run.id);
}
if (ids.length) {
log.warn({ count: ids.length, ids }, "reconciled orphan running runs");
}
return { repaired: ids.length, ids };
}
+16 -2
View File
@@ -1,15 +1,25 @@
{
"name": "@jerapah-flow/server",
"version": "1.0.0",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "runner.js",
"scripts": {
"dev": "node --watch runner.js",
"dev:pm2": "node dev-pm2.mjs",
"start": "node runner.js",
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\""
"start:api": "node server.js",
"start:worker": "node worker.js",
"start:control": "node control.js",
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
"test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
"reset-admin": "node reset-admin.js",
"test:profiles": "node test/profiles-smoke.js",
"test:set-dry-run": "node test/set-dry-run-smoke.js"
},
"dependencies": {
"@jerapah-flow/shared": "workspace:*",
"@aws-sdk/client-s3": "^3.1111.0",
"@aws-sdk/s3-request-presigner": "^3.1111.0",
"@fastify/cookie": "^11.0.2",
@@ -20,7 +30,9 @@
"basic-ftp": "^6.2.0",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^13.0.3",
"bullmq": "^6.1.2",
"fastify": "^5.12.0",
"ioredis": "^6.0.0",
"jsonata": "^2.2.2",
"knex": "^3.3.0",
"mustache": "^4.2.0",
@@ -29,6 +41,8 @@
"nodemailer": "^9.0.5",
"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"
+19 -1
View File
@@ -3,7 +3,25 @@ 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 ??
path.resolve(SERVER_ROOT, "../../plugins");
/** Example plugin sources shipped with the repo. */
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");
+215
View File
@@ -0,0 +1,215 @@
import fs from "fs";
import os from "os";
import path from "path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { EXAMPLE_PLUGINS_DIR, PLUGINS_DIR } from "./paths.js";
import {
installPluginFromDirectory,
pluginDir,
} from "./plugin-store.js";
const execFileAsync = promisify(execFile);
/**
* @param {string} url
*/
export function assertHttpsGitUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
const err = new Error("invalid git URL");
err.statusCode = 400;
throw err;
}
if (parsed.protocol !== "https:") {
const err = new Error("git URL must use https://");
err.statusCode = 400;
throw err;
}
return parsed.toString();
}
/**
* Run pnpm install in a plugin directory (ignore lifecycle scripts).
* @param {string} dir
*/
export async function pnpmInstallPlugin(dir) {
const pkg = path.join(dir, "package.json");
if (!fs.existsSync(pkg)) return { skipped: true };
let hasDeps = false;
try {
const raw = JSON.parse(fs.readFileSync(pkg, "utf8"));
hasDeps = Boolean(
(raw.dependencies && Object.keys(raw.dependencies).length) ||
(raw.optionalDependencies &&
Object.keys(raw.optionalDependencies).length),
);
} catch {
hasDeps = true;
}
if (!hasDeps) return { skipped: true };
await execFileAsync(
"pnpm",
["install", "--dir", dir, "--ignore-scripts", "--prefer-offline"],
{
cwd: dir,
env: { ...process.env, CI: "1" },
timeout: 5 * 60_000,
maxBuffer: 10 * 1024 * 1024,
},
);
return { skipped: false };
}
/**
* @param {string} url
* @param {{ ref?: string, overwrite?: boolean }} [opts]
*/
export async function installPluginFromGit(url, opts = {}) {
const httpsUrl = assertHttpsGitUrl(url);
const staging = path.join(
PLUGINS_DIR,
`.staging-git-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
fs.mkdirSync(staging, { recursive: true });
try {
const args = ["clone", "--depth", "1"];
if (opts.ref) {
args.push("--branch", String(opts.ref));
}
args.push(httpsUrl, staging);
await execFileAsync("git", args, {
timeout: 5 * 60_000,
maxBuffer: 5 * 1024 * 1024,
});
// Remove .git to keep plugins lean
fs.rmSync(path.join(staging, ".git"), { recursive: true, force: true });
const installed = installPluginFromDirectory(staging, {
overwrite: Boolean(opts.overwrite),
markRestart: false,
});
await pnpmInstallPlugin(installed.dir);
const { bumpGeneration } = await import("./control-state.js");
bumpGeneration(`plugin:${installed.id} installed from git`);
return installed;
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}
/**
* @param {string} zipPath
* @param {{ overwrite?: boolean }} [opts]
*/
export async function installPluginFromZipFile(zipPath, opts = {}) {
const abs = path.resolve(zipPath);
if (!fs.existsSync(abs)) {
const err = new Error("zip file not found");
err.statusCode = 400;
throw err;
}
const staging = path.join(
PLUGINS_DIR,
`.staging-zip-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const extractDir = path.join(staging, "extract");
fs.mkdirSync(extractDir, { recursive: true });
try {
await execFileAsync("unzip", ["-q", abs, "-d", extractDir], {
timeout: 120_000,
});
const root = findPluginRoot(extractDir);
const installed = installPluginFromDirectory(root, {
overwrite: Boolean(opts.overwrite),
markRestart: false,
});
await pnpmInstallPlugin(installed.dir);
const { bumpGeneration } = await import("./control-state.js");
bumpGeneration(`plugin:${installed.id} installed from zip`);
return installed;
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}
/**
* @param {Buffer} buffer
* @param {{ overwrite?: boolean }} [opts]
*/
export async function installPluginFromZipBuffer(buffer, opts = {}) {
const tmp = path.join(
os.tmpdir(),
`jflow-plugin-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`,
);
fs.writeFileSync(tmp, buffer);
try {
return await installPluginFromZipFile(tmp, opts);
} finally {
fs.unlinkSync(tmp);
}
}
/**
* Find directory containing jerapah-plugin.json (zip may have a single top folder).
* @param {string} extractDir
*/
function findPluginRoot(extractDir) {
const direct = path.join(extractDir, "jerapah-plugin.json");
if (fs.existsSync(direct)) return extractDir;
const entries = fs.readdirSync(extractDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith("."));
if (dirs.length === 1) {
const nested = path.join(extractDir, dirs[0].name);
if (fs.existsSync(path.join(nested, "jerapah-plugin.json"))) return nested;
}
for (const e of dirs) {
const nested = path.join(extractDir, e.name);
if (fs.existsSync(path.join(nested, "jerapah-plugin.json"))) return nested;
}
const err = new Error("zip missing jerapah-plugin.json");
err.statusCode = 400;
throw err;
}
/**
* Install a shipped example plugin by id (from examples/plugins/<id>).
* @param {string} exampleId
* @param {{ overwrite?: boolean }} [opts]
*/
export async function installExamplePlugin(exampleId, opts = {}) {
const src = path.join(EXAMPLE_PLUGINS_DIR, exampleId);
if (!fs.existsSync(src)) {
const err = new Error(`example plugin not found: ${exampleId}`);
err.statusCode = 404;
throw err;
}
const installed = installPluginFromDirectory(src, {
overwrite: Boolean(opts.overwrite),
markRestart: false,
});
await pnpmInstallPlugin(installed.dir);
const { bumpGeneration } = await import("./control-state.js");
bumpGeneration(`plugin:${installed.id} installed from example`);
return installed;
}
/**
* Copy example into plugins if missing (used by smoke / first-run helpers).
* @param {string} exampleId
*/
export function ensureExampleInstalled(exampleId) {
const dest = pluginDir(exampleId);
if (fs.existsSync(dest)) {
return { id: exampleId, already: true, dir: dest };
}
const src = path.join(EXAMPLE_PLUGINS_DIR, exampleId);
const installed = installPluginFromDirectory(src, {
overwrite: false,
markRestart: false,
});
return { ...installed, already: false };
}
+159
View File
@@ -0,0 +1,159 @@
import fs from "fs";
import path from "path";
import { getAppVersion, satisfiesRange } from "./app-version.js";
export const PLUGIN_MANIFEST = "jerapah-plugin.json";
export const PLUGIN_PREFIX = "plugin/";
/**
* @param {string} id
* @returns {string}
*/
export function assertPluginId(id) {
if (typeof id !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) {
const err = new Error(
"invalid plugin id (use lowercase letters, numbers, hyphens)",
);
err.statusCode = 400;
throw err;
}
if (id.length > 64) {
const err = new Error("plugin id too long");
err.statusCode = 400;
throw err;
}
return id;
}
/**
* @param {string} scriptRef e.g. plugin/foo or plugin/foo.js
* @returns {{ id: string, scriptRef: string } | null}
*/
export function parsePluginScriptRef(scriptRef) {
if (typeof scriptRef !== "string") return null;
let rest = scriptRef;
if (rest.startsWith(PLUGIN_PREFIX)) {
rest = rest.slice(PLUGIN_PREFIX.length);
} else {
return null;
}
if (rest.endsWith(".js")) rest = rest.slice(0, -3);
if (!rest || rest.includes("/") || rest.includes("\\")) return null;
try {
const id = assertPluginId(rest);
return { id, scriptRef: `${PLUGIN_PREFIX}${id}` };
} catch {
return null;
}
}
/**
* @param {string} id
* @returns {string}
*/
export function pluginScriptRef(id) {
return `${PLUGIN_PREFIX}${assertPluginId(id)}`;
}
/**
* @param {unknown} raw
* @returns {{
* id: string,
* name: string,
* version: string,
* jerapah: string,
* main: string,
* description: string | null,
* }}
*/
export function validateManifest(raw) {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
const err = new Error("manifest must be an object");
err.statusCode = 400;
throw err;
}
const id = assertPluginId(String(/** @type {any} */ (raw).id ?? ""));
const version = String(/** @type {any} */ (raw).version ?? "").trim();
if (!/^\d+\.\d+\.\d+/.test(version)) {
const err = new Error("manifest.version must be semver (e.g. 0.1.0)");
err.statusCode = 400;
throw err;
}
const jerapah = String(/** @type {any} */ (raw).jerapah ?? "").trim();
if (!jerapah) {
const err = new Error("manifest.jerapah range is required");
err.statusCode = 400;
throw err;
}
const main = String(/** @type {any} */ (raw).main ?? "script.js").trim();
if (!main || main.includes("..") || path.isAbsolute(main)) {
const err = new Error("manifest.main must be a relative file path");
err.statusCode = 400;
throw err;
}
const name =
String(/** @type {any} */ (raw).name ?? id).trim() || id;
const descriptionRaw = /** @type {any} */ (raw).description;
const description =
typeof descriptionRaw === "string" && descriptionRaw.trim()
? descriptionRaw.trim()
: null;
return { id, name, version, jerapah, main, description };
}
/**
* @param {string} pluginDir
*/
export function readManifestFile(pluginDir) {
const filePath = path.join(pluginDir, PLUGIN_MANIFEST);
if (!fs.existsSync(filePath)) {
const err = new Error(`missing ${PLUGIN_MANIFEST}`);
err.statusCode = 400;
throw err;
}
let raw;
try {
raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
const err = new Error(`invalid ${PLUGIN_MANIFEST} JSON`);
err.statusCode = 400;
throw err;
}
return validateManifest(raw);
}
/**
* @param {ReturnType<typeof validateManifest>} manifest
* @returns {{ ok: true } | { ok: false, error: string }}
*/
export function checkJerapahCompat(manifest) {
const appVersion = getAppVersion();
if (!satisfiesRange(appVersion, manifest.jerapah)) {
return {
ok: false,
error: `plugin requires JerapahFlow ${manifest.jerapah}; app is ${appVersion}`,
};
}
return { ok: true };
}
/**
* @param {{
* id: string,
* name?: string,
* version?: string,
* jerapah?: string,
* main?: string,
* description?: string | null,
* }} opts
*/
export function buildManifest(opts) {
return validateManifest({
id: opts.id,
name: opts.name ?? opts.id,
version: opts.version ?? "0.1.0",
jerapah: opts.jerapah ?? ">=0.1.0 <1.0.0",
main: opts.main ?? "script.js",
description: opts.description ?? null,
});
}
+456
View File
@@ -0,0 +1,456 @@
import fs from "fs";
import path from "path";
import { createRequire } from "node:module";
import { PLUGINS_DIR, SCRIPTS_DIR } from "./paths.js";
import {
PLUGIN_MANIFEST,
assertPluginId,
buildManifest,
checkJerapahCompat,
parsePluginScriptRef,
pluginScriptRef,
readManifestFile,
validateManifest,
} from "./plugin-manifest.js";
import { listScriptFiles } from "./fs-store.js";
import { bumpGeneration } from "./control-state.js";
/**
* @param {string} id
*/
export function pluginDir(id) {
return path.join(PLUGINS_DIR, assertPluginId(id));
}
export function ensurePluginsDir() {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
/**
* Core script file names (*.js) currently shipped under SCRIPTS_DIR.
* @returns {string[]}
*/
export function listCoreScriptNames() {
return listScriptFiles();
}
/**
* Bare core names without .js (for collision checks).
* @returns {Set<string>}
*/
export function coreBareNames() {
return new Set(
listCoreScriptNames().map((n) => (n.endsWith(".js") ? n.slice(0, -3) : n)),
);
}
/**
* @returns {Array<{
* id: string,
* scriptRef: string,
* dir: string,
* manifest: ReturnType<typeof readManifestFile>,
* compatible: boolean,
* compatError: string | null,
* disabled: boolean,
* }>}
*/
export function listInstalledPlugins() {
ensurePluginsDir();
if (!fs.existsSync(PLUGINS_DIR)) return [];
/** @type {Array<any>} */
const out = [];
for (const entry of fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
let id;
try {
id = assertPluginId(entry.name);
} catch {
continue;
}
const dir = pluginDir(id);
try {
const manifest = readManifestFile(dir);
if (manifest.id !== id) {
out.push({
id,
scriptRef: pluginScriptRef(id),
dir,
manifest,
compatible: false,
compatError: `manifest id "${manifest.id}" does not match folder "${id}"`,
disabled: true,
});
continue;
}
const compat = checkJerapahCompat(manifest);
const disabledFlag = fs.existsSync(path.join(dir, ".disabled"));
out.push({
id,
scriptRef: pluginScriptRef(id),
dir,
manifest,
compatible: compat.ok,
compatError: compat.ok ? null : compat.error,
disabled: disabledFlag || !compat.ok,
});
} catch (err) {
out.push({
id,
scriptRef: pluginScriptRef(id),
dir,
manifest: null,
compatible: false,
compatError: err instanceof Error ? err.message : String(err),
disabled: true,
});
}
}
return out.sort((a, b) => a.id.localeCompare(b.id));
}
/**
* @param {string} id
*/
export function getInstalledPlugin(id) {
const needle = assertPluginId(id);
return listInstalledPlugins().find((p) => p.id === needle) ?? null;
}
/**
* Resolve a workflow script ref to a filesystem path + kind.
*
* @param {string} scriptRef
* @returns {{
* kind: "core" | "plugin",
* scriptRef: string,
* filePath: string,
* pluginId?: string,
* pluginDir?: string,
* disabled?: boolean,
* error?: string,
* }}
*/
export function resolveScriptRef(scriptRef) {
if (typeof scriptRef !== "string" || !scriptRef.trim()) {
return {
kind: "core",
scriptRef: String(scriptRef),
filePath: "",
error: "invalid script ref",
};
}
const plugin = parsePluginScriptRef(scriptRef);
if (plugin) {
const installed = getInstalledPlugin(plugin.id);
if (!installed) {
return {
kind: "plugin",
scriptRef: plugin.scriptRef,
pluginId: plugin.id,
filePath: "",
error: `plugin not installed: ${plugin.scriptRef}`,
};
}
if (installed.disabled) {
return {
kind: "plugin",
scriptRef: plugin.scriptRef,
pluginId: plugin.id,
pluginDir: installed.dir,
filePath: "",
disabled: true,
error:
installed.compatError ||
`plugin disabled: ${plugin.scriptRef}`,
};
}
const mainPath = path.join(installed.dir, installed.manifest.main);
if (!fs.existsSync(mainPath)) {
return {
kind: "plugin",
scriptRef: plugin.scriptRef,
pluginId: plugin.id,
pluginDir: installed.dir,
filePath: "",
error: `plugin main missing: ${installed.manifest.main}`,
};
}
return {
kind: "plugin",
scriptRef: plugin.scriptRef,
pluginId: plugin.id,
pluginDir: installed.dir,
filePath: mainPath,
};
}
// Core: must be a plain *.js filename
if (
scriptRef.includes("/") ||
scriptRef.includes("\\") ||
scriptRef.includes("..")
) {
return {
kind: "core",
scriptRef,
filePath: "",
error: "invalid core script name",
};
}
const name = scriptRef.endsWith(".js") ? scriptRef : `${scriptRef}.js`;
const filePath = path.join(SCRIPTS_DIR, name);
if (!fs.existsSync(filePath)) {
return {
kind: "core",
scriptRef: name,
filePath: "",
error: `core script not found: ${name}`,
};
}
return { kind: "core", scriptRef: name, filePath };
}
/**
* Copy a prepared plugin directory into PLUGINS_DIR.
*
* @param {string} sourceDir directory containing jerapah-plugin.json
* @param {{ overwrite?: boolean, markRestart?: boolean, reason?: string }} [opts]
*/
export function installPluginFromDirectory(sourceDir, opts = {}) {
const abs = path.resolve(sourceDir);
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
const err = new Error("plugin source directory not found");
err.statusCode = 400;
throw err;
}
const manifest = readManifestFile(abs);
const compat = checkJerapahCompat(manifest);
if (!compat.ok) {
const err = new Error(compat.error);
err.statusCode = 409;
throw err;
}
if (coreBareNames().has(manifest.id)) {
const err = new Error(
`plugin id "${manifest.id}" collides with a core script name`,
);
err.statusCode = 409;
throw err;
}
const mainPath = path.join(abs, manifest.main);
if (!fs.existsSync(mainPath)) {
const err = new Error(`manifest.main not found: ${manifest.main}`);
err.statusCode = 400;
throw err;
}
ensurePluginsDir();
const dest = pluginDir(manifest.id);
if (fs.existsSync(dest)) {
if (!opts.overwrite) {
const err = new Error(`plugin already installed: ${manifest.id}`);
err.statusCode = 409;
throw err;
}
fs.rmSync(dest, { recursive: true, force: true });
}
fs.cpSync(abs, dest, { recursive: true });
// Ensure package.json exists (fork / thin plugins).
const pkgPath = path.join(dest, "package.json");
if (!fs.existsSync(pkgPath)) {
fs.writeFileSync(
pkgPath,
`${JSON.stringify(
{
name: `jflow-plugin-${manifest.id}`,
version: manifest.version,
private: true,
type: "module",
},
null,
2,
)}\n`,
"utf8",
);
}
if (opts.markRestart !== false) {
bumpGeneration(opts.reason ?? `plugin:${manifest.id} installed`);
}
return {
id: manifest.id,
scriptRef: pluginScriptRef(manifest.id),
dir: dest,
manifest,
};
}
/**
* @param {string} id
* @param {{ markRestart?: boolean }} [opts]
*/
export function uninstallPlugin(id, opts = {}) {
const pluginId = assertPluginId(id);
const dir = pluginDir(pluginId);
if (!fs.existsSync(dir)) {
const err = new Error("plugin not found");
err.statusCode = 404;
throw err;
}
fs.rmSync(dir, { recursive: true, force: true });
if (opts.markRestart !== false) {
bumpGeneration(`plugin:${pluginId} uninstalled`);
}
return { ok: true, id: pluginId };
}
/**
* Fork a core script into a new plugin.
*
* @param {string} coreName e.g. fetch-http.js
* @param {string} newId
* @param {{ description?: string }} [opts]
*/
export function forkCoreScript(coreName, newId, opts = {}) {
const id = assertPluginId(newId);
if (coreBareNames().has(id)) {
const err = new Error(`plugin id collides with core script: ${id}`);
err.statusCode = 409;
throw err;
}
if (fs.existsSync(pluginDir(id))) {
const err = new Error(`plugin already exists: ${id}`);
err.statusCode = 409;
throw err;
}
const coreFile = coreName.endsWith(".js") ? coreName : `${coreName}.js`;
const src = path.join(SCRIPTS_DIR, coreFile);
if (!fs.existsSync(src)) {
const err = new Error(`core script not found: ${coreFile}`);
err.statusCode = 404;
throw err;
}
const staging = path.join(PLUGINS_DIR, `.staging-fork-${id}-${Date.now()}`);
fs.mkdirSync(staging, { recursive: true });
try {
const main = "script.js";
fs.copyFileSync(src, path.join(staging, main));
const manifest = buildManifest({
id,
name: id,
version: "0.1.0",
jerapah: ">=0.1.0 <1.0.0",
main,
description:
opts.description ?? `Fork of core script ${coreFile}`,
});
fs.writeFileSync(
path.join(staging, PLUGIN_MANIFEST),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(
path.join(staging, "package.json"),
`${JSON.stringify(
{
name: `jflow-plugin-${id}`,
version: "0.1.0",
private: true,
type: "module",
},
null,
2,
)}\n`,
"utf8",
);
return installPluginFromDirectory(staging, {
overwrite: false,
reason: `plugin:${id} forked from ${coreFile}`,
});
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}
/**
* @param {string} pluginDirectory
* @returns {((id: string) => unknown) | null}
*/
export function createPluginRequire(pluginDirectory) {
const pkg = path.resolve(pluginDirectory, "package.json");
if (!fs.existsSync(pkg)) return null;
return createRequire(pkg);
}
/**
* Create an empty plugin from the new-script template.
* @param {string} newId
* @param {string} source
* @param {{ description?: string }} [opts]
*/
export function createBlankPlugin(newId, source, opts = {}) {
const id = assertPluginId(newId);
if (coreBareNames().has(id)) {
const err = new Error(`plugin id collides with core script: ${id}`);
err.statusCode = 409;
throw err;
}
if (fs.existsSync(pluginDir(id))) {
const err = new Error(`plugin already exists: ${id}`);
err.statusCode = 409;
throw err;
}
if (typeof source !== "string") {
const err = new Error("source content is required");
err.statusCode = 400;
throw err;
}
const staging = path.join(PLUGINS_DIR, `.staging-new-${id}-${Date.now()}`);
fs.mkdirSync(staging, { recursive: true });
try {
const main = "script.js";
fs.writeFileSync(path.join(staging, main), source, "utf8");
const manifest = buildManifest({
id,
name: id,
version: "0.1.0",
jerapah: ">=0.1.0 <1.0.0",
main,
description: opts.description ?? null,
});
fs.writeFileSync(
path.join(staging, PLUGIN_MANIFEST),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(
path.join(staging, "package.json"),
`${JSON.stringify(
{
name: `jflow-plugin-${id}`,
version: "0.1.0",
private: true,
type: "module",
},
null,
2,
)}\n`,
"utf8",
);
return installPluginFromDirectory(staging, {
overwrite: false,
reason: `plugin:${id} created`,
});
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}
+316
View File
@@ -0,0 +1,316 @@
import path from "path";
import pm2 from "pm2";
import { SERVER_ROOT } from "./paths.js";
const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
export const PM2_HTTP_NAME = "jflow-http";
export const PM2_WORKER_NAME = "jflow-worker";
/**
* @returns {Promise<void>}
*/
export function connectPm2() {
return new Promise((resolve, reject) => {
pm2.connect((err) => {
if (err) reject(err);
else resolve();
});
});
}
export function disconnectPm2() {
try {
pm2.disconnect();
} catch {
// ignore
}
}
/**
* @returns {Promise<import("pm2").ProcessDescription[]>}
*/
export function listPm2() {
return new Promise((resolve, reject) => {
pm2.list((err, list) => {
if (err) reject(err);
else resolve(list ?? []);
});
});
}
/**
* @param {string} name
* @returns {Promise<import("pm2").ProcessDescription[]>}
*/
export async function listPm2ByName(name) {
const list = await listPm2();
return list.filter((p) => p.name === name);
}
/**
* @param {object} app
* @returns {Promise<void>}
*/
function startPm2App(app) {
return new Promise((resolve, reject) => {
pm2.start(app, (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* @param {string} name
* @returns {Promise<void>}
*/
export function stopPm2App(name) {
return new Promise((resolve, reject) => {
pm2.stop(name, (err) => {
if (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/not found|doesn't exist|process or namespace/i.test(msg)) {
resolve();
return;
}
reject(err);
return;
}
resolve();
});
});
}
/**
* @param {string} name
* @returns {Promise<void>}
*/
export function deletePm2App(name) {
return new Promise((resolve, reject) => {
pm2.delete(name, (err) => {
if (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/not found|doesn't exist|process or namespace/i.test(msg)) {
resolve();
return;
}
reject(err);
return;
}
resolve();
});
});
}
/**
* @param {string} name
* @param {number} instances
* @returns {Promise<void>}
*/
export function scalePm2App(name, instances) {
return new Promise((resolve, reject) => {
pm2.scale(name, instances, (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* @param {string} name
* @returns {Promise<void>}
*/
export function restartPm2App(name) {
return new Promise((resolve, reject) => {
pm2.restart(name, (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* Restart a single PM2 process by id. Only jflow-http / jflow-worker.
* @param {number} pmId
* @returns {Promise<{ name: string, pmId: number }>}
*/
export async function restartPm2Process(pmId) {
const id = Math.floor(Number(pmId));
if (!Number.isFinite(id) || id < 0) {
const err = new Error("invalid pmId");
err.code = "BAD_REQUEST";
throw err;
}
const list = await listPm2();
const proc = list.find((p) => Number(p.pm_id) === id);
if (!proc) {
const err = new Error("process not found");
err.code = "NOT_FOUND";
throw err;
}
if (proc.name !== PM2_HTTP_NAME && proc.name !== PM2_WORKER_NAME) {
const err = new Error("process is not a JerapahFlow child");
err.code = "FORBIDDEN";
throw err;
}
await new Promise((resolve, reject) => {
pm2.restart(id, (err) => {
if (err) reject(err);
else resolve();
});
});
return { name: proc.name, pmId: id };
}
/**
* Shared env for child processes.
* @param {{ generation: number }} opts
*/
export function childEnv(opts) {
return {
...process.env,
JFLOW_CONFIG_GENERATION: String(opts.generation),
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
};
}
/**
* Ensure HTTP app exists and matches desired running/stopped state.
* @param {{ generation: number, running: boolean }} opts
*/
export async function ensureHttp(opts) {
const existing = await listPm2ByName(PM2_HTTP_NAME);
if (!opts.running) {
if (existing.length) await stopPm2App(PM2_HTTP_NAME);
return;
}
if (existing.length === 0) {
await startPm2App({
name: PM2_HTTP_NAME,
script: path.join(SERVER_ROOT, "server.js"),
cwd: REPO_ROOT,
instances: 1,
exec_mode: "fork",
autorestart: true,
max_restarts: 20,
env: {
...childEnv(opts),
JFLOW_ROLE: "api",
},
});
return;
}
const online = existing.some((p) => p.pm2_env?.status === "online");
if (!online) {
await restartPm2App(PM2_HTTP_NAME);
}
}
/**
* Ensure worker app has `count` online forks (0 = stopped/deleted).
* @param {{ generation: number, count: number }} opts
*/
export async function ensureWorkers(opts) {
const count = Math.max(0, Math.min(32, Math.floor(opts.count)));
const existing = await listPm2ByName(PM2_WORKER_NAME);
if (count === 0) {
if (existing.length) {
await stopPm2App(PM2_WORKER_NAME);
await deletePm2App(PM2_WORKER_NAME);
}
return;
}
if (existing.length === 0) {
await startPm2App({
name: PM2_WORKER_NAME,
script: path.join(SERVER_ROOT, "worker.js"),
cwd: REPO_ROOT,
instances: count,
exec_mode: "fork",
autorestart: true,
max_restarts: 50,
env: {
...childEnv(opts),
JFLOW_ROLE: "worker",
},
});
return;
}
const current = existing.length;
if (current !== count) {
await scalePm2App(PM2_WORKER_NAME, count);
}
const stopped = existing.filter((p) => p.pm2_env?.status !== "online");
if (stopped.length) {
await restartPm2App(PM2_WORKER_NAME);
}
}
/**
* Hard recycle HTTP + workers with updated generation env.
* Children must be stopped first for a clean migrate window.
*
* @param {{ generation: number, http: boolean, workers: number }} opts
*/
export async function recreateChildren(opts) {
await stopPm2App(PM2_HTTP_NAME);
await deletePm2App(PM2_HTTP_NAME);
await stopPm2App(PM2_WORKER_NAME);
await deletePm2App(PM2_WORKER_NAME);
if (opts.http) {
await ensureHttp({ generation: opts.generation, running: true });
}
if (opts.workers > 0) {
await ensureWorkers({ generation: opts.generation, count: opts.workers });
}
}
/**
* Summarize PM2 process status for the ops UI.
*/
export async function describeChildren() {
const list = await listPm2();
const http = list.filter((p) => p.name === PM2_HTTP_NAME);
const workers = list.filter((p) => p.name === PM2_WORKER_NAME);
const mapOne = (p) => ({
name: p.name,
pmId: Number(p.pm_id),
status: p.pm2_env?.status ?? "unknown",
pid: p.pid ?? null,
restarts: p.pm2_env?.restart_time ?? 0,
uptime: p.pm2_env?.pm_uptime ?? null,
generation: Number(p.pm2_env?.JFLOW_CONFIG_GENERATION ?? 0) || null,
memory: Number(p.monit?.memory) || 0,
cpu: Number(p.monit?.cpu) || 0,
});
const httpMapped = http.map(mapOne);
const workersMapped = workers.map(mapOne);
const all = [...httpMapped, ...workersMapped];
return {
http: httpMapped,
workers: workersMapped,
httpOnline: http.some((p) => p.pm2_env?.status === "online"),
workerOnlineCount: workers.filter((p) => p.pm2_env?.status === "online").length,
totals: {
memory: all.reduce((sum, p) => sum + p.memory, 0),
cpu: all.reduce((sum, p) => sum + p.cpu, 0),
},
};
}
export function getRepoRoot() {
return REPO_ROOT;
}
+1
View File
@@ -0,0 +1 @@
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared";
+1
View File
@@ -0,0 +1 @@
export * from "./src/stores/profiles-store.js";
+189 -203
View File
@@ -23,18 +23,20 @@ import {
storedEnvelope,
} from "./step-result.js";
import * as fsStore from "./fs-store.js";
import {
checkHttpAuth,
resolveAuthMechanism,
resolveUnauthorizedSpec,
sendHttpPageOrJson,
sendSuccessPage,
} from "./http-trigger-auth.js";
import { resolveConfigRefs } from "./config-refs.js";
import { hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared";
import {
createHttpTriggerHandler,
ensureHttpWildcardRoute,
rebuildHttpRoutes,
} from "./workflow-http-routes.js";
import { getProfilePlain } from "./profiles-store.js";
import {
buildFailureAlertData,
resolveFailureTriggerConfig,
} from "./trigger-failure.js";
import { enqueueWorkflowJob } from "./workflow-queue.js";
import { ensureInitialRevision } from "./workflow-history.js";
/**
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
@@ -42,22 +44,19 @@ import {
*/
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
* @param {{
* queue?: import("bullmq").Queue | null,
* enableTriggers?: boolean,
* }} [opts]
* `enableTriggers` must be true only on the API (or all-in-one) process.
* Workers reload workflow defs but must not own cron/HTTP trigger registration.
*/
export function createRegistry(server) {
export function createRegistry(server, opts = {}) {
const queue = opts.queue ?? null;
const enableTriggers = opts.enableTriggers ?? true;
/** @type {Map<string, WorkflowEntry>} */
const workflows = new Map();
/** @type {Map<string, string>} */
@@ -68,7 +67,14 @@ export function createRegistry(server) {
let pruneTask = null;
/** @type {Map<string, HttpRouteEntry>} */
const httpRoutes = new Map();
let httpDispatcherRegistered = false;
const httpDispatcherState = { registered: false };
const dispatchHttpTrigger = createHttpTriggerHandler({
httpRoutes,
workflows,
namespacedPath,
enqueueWorkflow: (...args) => enqueueWorkflow(...args),
});
/**
* Resolve a same-owner workflow that opts in with `type: workflow`.
@@ -174,113 +180,10 @@ export function createRegistry(server) {
* Fastify route once so path/method changes apply on reregister without restart.
*/
function registerHttpTriggers() {
httpRoutes.clear();
for (const [key, { owner, workflow }] of workflows) {
if (workflow.enabled === false) {
log.debug(`Skipping disabled workflow HTTP triggers (${key})`);
continue;
}
for (const trigger of workflow.triggers ?? []) {
if (trigger.type !== "HTTP") continue;
const method = String(trigger.method ?? "POST").toUpperCase();
const url = namespacedPath(owner, trigger.path);
const routeKey = `${method} ${url}`;
if (httpRoutes.has(routeKey)) {
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
continue;
}
httpRoutes.set(routeKey, { key, owner, trigger });
log.debug(`Mapped HTTP trigger ${routeKey} (${key})`);
}
}
if (!httpDispatcherRegistered) {
httpDispatcherRegistered = true;
server.route({
method: HTTP_METHODS,
url: "/u/*",
handler: dispatchHttpTrigger,
});
log.debug("Registered HTTP trigger wildcard dispatcher /u/*");
}
}
/**
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
*/
async function dispatchHttpTrigger(req, reply) {
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
const method = String(req.method ?? "GET").toUpperCase();
const routeKey = `${method} ${url}`;
const mapped = httpRoutes.get(routeKey);
if (!mapped) {
return reply.code(404).send({ error: "not found" });
}
const entry = workflows.get(mapped.key);
if (!entry || entry.workflow?.enabled === false) {
return reply.code(404).send({ error: "workflow disabled" });
}
// Prefer live trigger from current workflow YAML (auth/response edits)
const liveTrigger =
(entry.workflow.triggers ?? []).find((t) => {
if (t?.type !== "HTTP") return false;
const m = String(t.method ?? "POST").toUpperCase();
const p = namespacedPath(entry.owner, t.path);
return m === method && p === url;
}) ?? mapped.trigger;
if (liveTrigger.auth != null && liveTrigger.auth !== false) {
const mechanism = await resolveAuthMechanism(liveTrigger.auth);
if (!mechanism) {
const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null);
return sendHttpPageOrJson(reply, status, pageName, {
error: "unauthorized",
});
}
const ok = await checkHttpAuth(req, mechanism, {
owner: entry.owner,
workflowKey: mapped.key,
});
if (!ok) {
const { status, pageName } = resolveUnauthorizedSpec(
liveTrigger,
mechanism,
);
return sendHttpPageOrJson(reply, status, pageName, {
error: "unauthorized",
});
}
}
const result = await runWorkflow(
mapped.key,
{ data: req.body },
{ type: "http", detail: `${method} ${url}` },
);
if (result.status === "failed") {
return reply.code(500).send({
runId: result.runId,
error: result.error,
});
}
const defaultBody = {
runId: result.runId,
result: result.result,
};
if (typeof liveTrigger.response === "string" && liveTrigger.response) {
return sendSuccessPage(reply, liveTrigger.response, defaultBody);
}
return reply.send(defaultBody);
rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log });
ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, {
log,
});
}
function registerCronTriggers() {
@@ -308,7 +211,7 @@ export function createRegistry(server) {
schedule,
() => {
log.debug(`cron firing ${key} (${schedule})`);
return runWorkflow(
return enqueueWorkflow(
key,
{ data: workflow.data ?? null },
{ type: "cron", detail: schedule },
@@ -544,14 +447,13 @@ export function createRegistry(server) {
);
}
const destKey = resolveWorkflowTriggerKey(owner, name);
return runWorkflow(
return enqueueWorkflow(
destKey,
{ data },
{ type: "workflow", detail: parentKey },
{
parentRunId,
depth: depth + 1,
detach: true,
},
);
},
@@ -579,8 +481,21 @@ export function createRegistry(server) {
owner,
depth,
) {
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
const unresolvedConfig = parsed.config;
let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
let unresolvedConfig = parsed.config;
if (parsed.kind === "script" && parsed.profile) {
const profile = await getProfilePlain(owner, parsed.profile);
if (!profile) {
throw new Error(`profile "${parsed.profile}" not found`);
}
if (parsed.script && parsed.script !== profile.script) {
throw new Error(
`step script "${parsed.script}" does not match profile "${parsed.profile}" script "${profile.script}"`,
);
}
script = profile.script;
unresolvedConfig = mergeProfileConfig(profile.config, parsed.config);
}
const incomingContext = normalizeContext(ctx.context);
const step = await store.startStep({
runId,
@@ -696,32 +611,36 @@ export function createRegistry(server) {
"triggering failure alert workflow",
);
await runWorkflow(
await enqueueWorkflow(
destKey,
{ data: alertData },
{ type: "workflow", detail: `failure:${opts.key}` },
{
parentRunId: opts.runId,
depth: opts.depth + 1,
detach: true,
},
);
}
/**
* Create a queued run and push a BullMQ job. Returns immediately.
*
* @param {string} key
* @param {{ data?: unknown, context?: unknown }} context
* @param {{ type: string, detail?: string | null }} trigger
* @param {{
* parentRunId?: string | null,
* depth?: number,
* detach?: boolean,
* }} [opts]
*/
async function runWorkflow(key, context, trigger, opts = {}) {
async function enqueueWorkflow(key, context, trigger, opts = {}) {
const parentRunId = opts.parentRunId ?? null;
const depth = opts.depth ?? 0;
const detach = opts.detach === true;
if (!queue) {
log.error({ workflow: key }, "workflow queue is not configured");
return { runId: null, status: "failed", error: "workflow queue is not configured" };
}
const entry = workflows.get(key);
if (!entry) {
@@ -729,91 +648,153 @@ export function createRegistry(server) {
return { runId: null, status: "failed", error: "workflow not found" };
}
const { owner, workflow } = entry;
const { owner, file, workflow } = entry;
if (workflow?.enabled === false && trigger.type !== "manual") {
log.debug({ workflow: key, trigger }, "skipping disabled workflow");
return { runId: null, status: "failed", error: "workflow disabled" };
}
const input = context.data ?? workflow.data ?? null;
const ensured = await ensureInitialRevision({ owner, file });
const run = await store.startRun({
owner,
workflow: key,
workflowName: workflow?.name,
trigger,
input: context.data,
input,
parentRunId,
status: "queued",
workflowRevision: ensured?.revision ?? null,
});
const runLog = log.child({ runId: run.id, owner, workflow: key });
runLog.debug(detach ? "running workflow (detached)" : "running workflow");
const initialCtx = {
data: context.data ?? workflow.data ?? null,
context: normalizeContext(context.context),
};
const execute = async () => {
let ctx = initialCtx;
try {
const compiled = compileWorkflowScripts(workflow.scripts);
if (compiled.dagMode) {
ctx = await runDagSteps(
compiled,
ctx,
run.id,
runLog,
key,
owner,
depth,
);
} else {
ctx = await runLinearSteps(
compiled,
ctx,
run.id,
runLog,
key,
owner,
depth,
);
}
await store.finishRun(run.id, "success", storedEnvelope(ctx));
return { runId: run.id, status: "success", result: storedEnvelope(ctx) };
} catch (err) {
runLog.error({ err }, "workflow failed");
const error = err instanceof Error ? err.message : String(err);
await store.finishRun(run.id, "failed", null, err);
try {
await maybeTriggerFailureWorkflow({
key,
owner,
workflow,
runId: run.id,
trigger,
error,
depth,
});
} catch (alertErr) {
runLog.error({ err: alertErr }, "failed to trigger failure alert workflow");
}
return {
runId: run.id,
status: "failed",
error,
};
}
};
if (detach) {
execute().catch((err) => {
runLog.error({ err }, "detached workflow failed unexpectedly");
try {
const job = await enqueueWorkflowJob(queue, {
runId: run.id,
key,
depth,
});
return { runId: run.id, status: "started" };
await store.setRunJobId(run.id, String(job.id));
runLog.debug({ jobId: job.id }, "workflow queued");
return { runId: run.id, status: "queued", jobId: String(job.id) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
runLog.error({ err }, "failed to enqueue workflow");
await store.finishRun(run.id, "failed", null, err);
return { runId: run.id, status: "failed", error: message };
}
}
/**
* Execute a previously queued run (BullMQ worker entrypoint).
*
* @param {{ runId: string, key: string, depth?: number }} jobData
*/
async function executeQueuedRun(jobData) {
const runId = jobData.runId;
const key = jobData.key;
const depth = jobData.depth ?? 0;
const entry = workflows.get(key);
if (!entry) {
await store.finishRun(runId, "failed", null, new Error("workflow not found"));
return { runId, status: "failed", error: "workflow not found" };
}
return execute();
const existing = await store.getRun(runId);
if (!existing) {
return { runId, status: "failed", error: "run not found" };
}
if (existing.status === "success" || existing.status === "failed") {
return { runId, status: existing.status };
}
const marked = await store.markRunRunning(runId);
if (!marked.updated && existing.status !== "running") {
return { runId, status: existing.status };
}
const { owner, workflow } = entry;
const runLog = log.child({ runId, owner, workflow: key });
runLog.debug("running queued workflow");
const trigger = {
type: existing.trigger_type,
detail: existing.trigger_detail,
};
// jobId is BullMQ's id; enqueue uses runId as jobId, so they match today.
const jobId =
existing.job_id != null && String(existing.job_id).length > 0
? String(existing.job_id)
: runId;
const initialCtx = {
data: existing.input ?? workflow.data ?? null,
context: {
runId,
jobId,
},
};
try {
const compiled = compileWorkflowScripts(workflow.scripts);
let ctx;
if (compiled.dagMode) {
ctx = await runDagSteps(
compiled,
initialCtx,
runId,
runLog,
key,
owner,
depth,
);
} else {
ctx = await runLinearSteps(
compiled,
initialCtx,
runId,
runLog,
key,
owner,
depth,
);
}
await store.finishRun(runId, "success", storedEnvelope(ctx));
return { runId, status: "success", result: storedEnvelope(ctx) };
} catch (err) {
runLog.error({ err }, "workflow failed");
const error = err instanceof Error ? err.message : String(err);
await store.finishRun(runId, "failed", null, err);
try {
await maybeTriggerFailureWorkflow({
key,
owner,
workflow,
runId,
trigger,
error,
depth,
});
} catch (alertErr) {
runLog.error({ err: alertErr }, "failed to trigger failure alert workflow");
}
return { runId, status: "failed", error };
}
}
/**
* @deprecated Prefer enqueueWorkflow; kept as alias for callers.
*/
async function runWorkflow(key, context, trigger, opts = {}) {
return enqueueWorkflow(key, context, trigger, opts);
}
function reregister() {
registerWorkflows();
// Cron/HTTP triggers are API-owned. Workers also subscribe to reload and
// must only refresh the in-memory workflow map — otherwise N workers each
// schedule the same cron and enqueue N duplicate jobs.
if (!enableTriggers) return;
registerHttpTriggers();
registerCronTriggers();
}
@@ -824,7 +805,10 @@ export function createRegistry(server) {
for (const raw of workflow.scripts ?? []) {
try {
const parsed = parseScriptStep(raw);
if (parsed.kind === "script") refs.add(parsed.script);
if (parsed.kind === "script") {
if (parsed.script) refs.add(parsed.script);
if (parsed.profile) refs.add(`profile:${parsed.profile}`);
}
} catch {
// skip invalid steps
}
@@ -842,6 +826,8 @@ export function createRegistry(server) {
registerPruneJob,
reregister,
runWorkflow,
enqueueWorkflow,
executeQueuedRun,
referencedScripts,
};
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Reset (or create) the admin username and password.
*
* Usage:
* pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
*
* Uses JFLOW_DB_PATH like the app. Never prints the password.
*/
import bcrypt from "bcryptjs";
import { db, migrate } from "./db.js";
import * as store from "./store.js";
import { validateCredentials } from "./src/api/auth.js";
function parseArgs(argv) {
/** @type {{ username?: string, password?: string }} */
const out = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--username" || arg === "-u") {
out.username = argv[++i];
continue;
}
if (arg === "--password" || arg === "-p") {
out.password = argv[++i];
continue;
}
if (arg === "--help" || arg === "-h") {
out.help = true;
}
}
return out;
}
function usage() {
console.log(`Usage:
pnpm --dir packages/server reset-admin -- --username <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();
}
+6 -188
View File
@@ -1,190 +1,8 @@
import fs from "fs";
import fastify from "fastify";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import jwt from "@fastify/jwt";
import fastifyStatic from "@fastify/static";
import { migrate, db } from "./db.js";
import { log, enableLogPersistence, flushLogs } from "./logger.js";
import * as store from "./store.js";
import { createRegistry } from "./registry.js";
import { COOKIE, OPEN_API_ROUTES } from "./src/api/auth.js";
import authPlugin from "./src/api/auth.js";
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 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 httpPagesPlugin from "./src/api/http-pages.js";
import httpAuthsPlugin from "./src/api/http-auths.js";
import { WEB_DIST } from "./paths.js";
import { resolveSecretsKeyMaterial } from "./secrets.js";
import { startApp } from "./start-app.js";
await migrate();
enableLogPersistence();
const jwtSecret =
process.env.JFLOW_JWT_SECRET ??
(process.env.NODE_ENV === "production" ? "" : "jflow-dev-secret");
if (!jwtSecret) {
log.error("JFLOW_JWT_SECRET is required in production");
process.exit(1);
}
try {
resolveSecretsKeyMaterial();
} catch (err) {
log.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
const server = fastify({ loggerInstance: log });
await server.register(cookie);
await server.register(jwt, {
secret: jwtSecret,
cookie: {
cookieName: COOKIE,
signed: false,
},
// Monolith / local `pnpm dev`: API + worker + migrate in one process.
await startApp({
role: process.env.JFLOW_ROLE || "all",
migrate: true,
serveStaticUi: true,
});
await server.register(cors, {
origin: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:5173",
credentials: true,
});
server.decorate("authenticate", async function authenticate(req, reply) {
try {
await req.jwtVerify();
} catch {
return reply.code(401).send({ error: "unauthorized" });
}
});
server.decorate("requireAdmin", async function requireAdmin(req, reply) {
if (req.user?.role !== "admin") {
return reply.code(403).send({ error: "forbidden" });
}
});
const registry = createRegistry(server);
registry.registerWorkflows();
registry.registerHttpTriggers();
registry.registerCronTriggers();
registry.registerPruneJob();
await server.register(
async (api) => {
api.addHook("onRequest", async (req, reply) => {
const raw = (req.url || "").split("?")[0];
const stripped = raw.replace(/^\/api/, "") || "/";
const routeUrl = req.routeOptions?.url || stripped;
const open =
OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) ||
OPEN_API_ROUTES.has(`${req.method} ${stripped}`);
if (open) return;
await server.authenticate(req, reply);
});
await api.register(authPlugin);
await api.register(usersPlugin);
await api.register(secretsPlugin);
await api.register(variablesPlugin);
await api.register(kvPlugin);
await api.register(httpPagesPlugin);
await api.register(httpAuthsPlugin);
await api.register(scriptsPluginFactory(registry));
await api.register(workflowsPluginFactory(registry));
await api.register(runsPlugin);
await api.register(dashboardPluginFactory(registry));
},
{ prefix: "/api" },
);
server.post(
"/admin/workflows/reregister",
{ onRequest: [server.authenticate] },
async (_req, reply) => {
registry.reregister();
return reply.send({ message: "Workflows refreshed" });
},
);
server.get(
"/admin/runs",
{ onRequest: [server.authenticate] },
async (req, reply) => {
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 });
},
);
server.get(
"/admin/runs/:id",
{ onRequest: [server.authenticate] },
async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const run = await store.getRun(id);
if (!run) {
return reply.code(404).send({ error: "run not found" });
}
return reply.send(run);
},
);
if (fs.existsSync(WEB_DIST)) {
await server.register(fastifyStatic, {
root: WEB_DIST,
wildcard: false,
});
server.setNotFoundHandler((req, reply) => {
const url = req.raw.url ?? "";
if (
url.startsWith("/api") ||
url.startsWith("/u/") ||
url.startsWith("/admin")
) {
return reply.code(404).send({ error: "not found" });
}
return reply.sendFile("index.html");
});
}
async function shutdown() {
try {
await flushLogs();
await db.destroy();
} catch (err) {
log.error({ err }, "shutdown error");
}
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
const port = Number(process.env.PORT ?? 8700);
server
.listen({
host: "0.0.0.0",
port,
})
.then(() => {
log.info(`Server is running on port ${port}`);
})
.catch((err) => {
log.error({ err }, "failed to start server");
process.exit(1);
});
+100 -18
View File
@@ -6,7 +6,7 @@ import axios from "axios";
import pino from "pino";
import { createKvApi } from "./kv-store.js";
import { createFingerprintApi } from "./script-fingerprint.js";
import { SCRIPTS_DIR } from "./paths.js";
import { resolveScriptRef, createPluginRequire } from "./plugin-store.js";
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
import { getSecretPlaintext } from "./secrets-store.js";
@@ -296,15 +296,60 @@ function createScreenedAxios(log) {
});
}
function createRestrictedRequire(screenedAxios) {
const BLOCKED_PLUGIN_MODULES = new Set([
"child_process",
"node:child_process",
"cluster",
"node:cluster",
"fs",
"node:fs",
"fs/promises",
"node:fs/promises",
"module",
"node:module",
"vm",
"node:vm",
"worker_threads",
"node:worker_threads",
"v8",
"node:v8",
"inspector",
"node:inspector",
"sqlite",
"node:sqlite",
]);
/**
* @param {import("axios").AxiosInstance} screenedAxios
* @param {string | null} [pluginDirectory]
*/
function createRestrictedRequire(screenedAxios, pluginDirectory = null) {
const pluginRequire = pluginDirectory
? createPluginRequire(pluginDirectory)
: null;
return function restrictedRequire(id) {
if (typeof id !== "string" || !ALLOWED_MODULES.has(id)) {
if (typeof id !== "string") {
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
}
if (id === "axios") {
return screenedAxios;
if (BLOCKED_PLUGIN_MODULES.has(id)) {
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
}
return hostRequire(id);
if (ALLOWED_MODULES.has(id)) {
if (id === "axios") return screenedAxios;
return hostRequire(id);
}
if (pluginRequire) {
try {
return pluginRequire(id);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
`require(${JSON.stringify(id)}) failed in plugin: ${msg}`,
);
}
}
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
};
}
@@ -391,6 +436,7 @@ const $workflowsStub = {
* workflowName: string,
* owner?: string,
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
* pluginDir?: string | null,
* }} opts
*/
function createScriptSandbox({
@@ -399,6 +445,7 @@ function createScriptSandbox({
workflowName,
owner = "default",
$workflows = $workflowsStub,
pluginDir = null,
}) {
const scriptLog = log.child({ workflow: workflowName, script });
const $axios = createScreenedAxios(scriptLog);
@@ -418,7 +465,7 @@ function createScriptSandbox({
$vars,
$responses,
$workflows,
require: createRestrictedRequire($axios),
require: createRestrictedRequire($axios, pluginDir),
};
vm.createContext(sandbox, {
@@ -439,7 +486,7 @@ const inspectLog = pino({ level: "silent" });
* @param {unknown} fn
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
*/
export function extractScriptMeta(fn) {
function extractScriptMeta(fn) {
if (typeof fn !== "function") {
return { meta: null, metaError: "default export must be a function" };
}
@@ -470,8 +517,29 @@ export function extractScriptMeta(fn) {
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
* }} opts
*/
function instantiateCompiled(compiled, { log, script, workflowName, owner, $workflows }) {
const sandbox = createScriptSandbox({ log, script, workflowName, owner, $workflows });
/**
* @param {import("vm").Script} compiled
* @param {{
* log: import("pino").Logger,
* script: string,
* workflowName: string,
* owner?: string,
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
* pluginDir?: string | null,
* }} opts
*/
function instantiateCompiled(
compiled,
{ log, script, workflowName, owner, $workflows, pluginDir = null },
) {
const sandbox = createScriptSandbox({
log,
script,
workflowName,
owner,
$workflows,
pluginDir,
});
return compiled.runInContext(sandbox);
}
@@ -486,6 +554,7 @@ function instantiateCompiled(compiled, { log, script, workflowName, owner, $work
* workflowName?: string,
* owner?: string,
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
* pluginDir?: string | null,
* }} [opts]
*/
export function instantiateScriptSource(script, source, opts = {}) {
@@ -496,6 +565,7 @@ export function instantiateScriptSource(script, source, opts = {}) {
workflowName: opts.workflowName ?? "inspect",
owner: opts.owner ?? "default",
$workflows: opts.$workflows,
pluginDir: opts.pluginDir ?? null,
});
return { fn, ...extractScriptMeta(fn) };
}
@@ -519,17 +589,28 @@ export function inspectScriptSource(script, source) {
}
function loadCompiledScript(script) {
const filePath = path.join(SCRIPTS_DIR, script);
const resolved = resolveScriptRef(script);
if (resolved.error || !resolved.filePath) {
throw new Error(resolved.error || `script not found: ${script}`);
}
const filePath = resolved.filePath;
const { mtimeMs } = fs.statSync(filePath);
const cached = scriptCache.get(script);
const cacheKey = `${resolved.kind}:${resolved.scriptRef}:${filePath}`;
const cached = scriptCache.get(cacheKey);
if (cached && cached.mtimeMs === mtimeMs) {
return cached.compiled;
return cached;
}
const source = fs.readFileSync(filePath, "utf8");
const compiled = compileScriptSource(source, filePath);
scriptCache.set(script, { compiled, mtimeMs });
return compiled;
const entry = {
compiled,
mtimeMs,
pluginDir: resolved.pluginDir ?? null,
scriptRef: resolved.scriptRef,
};
scriptCache.set(cacheKey, entry);
return entry;
}
/**
@@ -545,13 +626,14 @@ function loadCompiledScript(script) {
* }} opts
*/
export async function runScript(script, ctx, { log, workflowName, owner, $workflows }) {
const compiled = loadCompiledScript(script);
const fn = instantiateCompiled(compiled, {
const loaded = loadCompiledScript(script);
const fn = instantiateCompiled(loaded.compiled, {
log,
script,
script: loaded.scriptRef,
workflowName,
owner,
$workflows,
pluginDir: loaded.pluginDir,
});
return await fn(ctx);
}
+60 -20
View File
@@ -1,9 +1,17 @@
import jsonata from "jsonata";
function ensureDataObject(ctx) {
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
ctx.data = {};
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
function mergeData(data) {
if (data != null && typeof data === "object" && !Array.isArray(data)) {
return { ...data };
}
return {};
}
const ALLOWED_METHODS = new Set([
@@ -35,6 +43,19 @@ function previewValue(value) {
return { preview: `${json.slice(0, 500)}...`, truncated: true };
}
/**
* Eval context for fingerprint/transform JSONata (reads `data.*`).
* @param {unknown} ctx
* @param {Record<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) {
@@ -63,7 +84,7 @@ async function detectUrlChanges(ctx) {
);
}
ensureDataObject(ctx);
const data = mergeData(ctx.data);
log.info({ url, method, key }, "detect-url-changes: fetching url");
const response = await $axios.request({
@@ -77,28 +98,31 @@ async function detectUrlChanges(ctx) {
"detect-url-changes: fetch complete",
);
ctx.data.httpResponse = response.data;
data.httpResponse = response.data;
let fingerprintSource = ctx.data.httpResponse;
let fingerprintSource = data.httpResponse;
if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) {
log.info(
{ jsonata: fingerprintExpr },
"detect-url-changes: evaluating fingerprint jsonata",
);
fingerprintSource = await jsonata(fingerprintExpr).evaluate(ctx);
fingerprintSource = await jsonata(fingerprintExpr).evaluate(evalCtx(ctx, data));
}
const result = await $fingerprint.claim(key, fingerprintSource, {
maxAge: ctx.config?.maxAge,
});
ctx.data.hasChanges = result.changed;
ctx.data.fingerprint = result.hash;
ctx.data.fingerprintChanged = result.changed;
ctx.data.fingerprintPrevious = result.previous;
ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt;
ctx.data.fingerprintAge = result.ageMs;
ctx.data.fingerprintExpired = result.expired;
const extra = {
hasChanges: result.changed,
fingerprint: result.hash,
fingerprintChanged: result.changed,
fingerprintPrevious: result.previous,
fingerprintAt: result.changed ? result.at : result.previousAt,
fingerprintAge: result.ageMs,
fingerprintExpired: result.expired,
};
Object.assign(data, extra);
log.info(
{
@@ -116,28 +140,35 @@ async function detectUrlChanges(ctx) {
{ outputVar, jsonata: transformExpr },
"detect-url-changes: evaluating transform jsonata",
);
const transformed = await jsonata(transformExpr).evaluate(ctx);
ctx.data[outputVar] = transformed;
const transformed = await jsonata(transformExpr).evaluate(evalCtx(ctx, data));
data[outputVar] = transformed;
log.info(
{ outputVar, value: previewValue(transformed) },
"detect-url-changes: saved transform result",
);
} else {
ctx.data[outputVar] = ctx.data.httpResponse;
data[outputVar] = data.httpResponse;
log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar");
}
}
/** @type {{ output: Record<string, unknown>, context: Record<string, unknown>, skipRemaining?: true }} */
const envelope = {
output: data,
context: { ...passContext(ctx), ...extra },
};
if (skipRemainingWhenUnchanged && !result.changed) {
ctx.skipRemaining = true;
envelope.skipRemaining = true;
}
return ctx;
return envelope;
}
detectUrlChanges.meta = {
description:
"Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run",
previewConfigKey: "url",
tags: ["HTTP"],
reads: "ctx",
config: {
url: { type: "string", required: true, description: "URL to fetch" },
method: {
@@ -203,6 +234,15 @@ detectUrlChanges.meta = {
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
fingerprintExpired: { type: "boolean" },
},
context: {
hasChanges: { type: "boolean" },
fingerprint: { type: "string" },
fingerprintChanged: { type: "boolean" },
fingerprintPrevious: { type: "string", required: false },
fingerprintAt: { type: "string", required: false },
fingerprintAge: { type: "number", required: false },
fingerprintExpired: { type: "boolean" },
},
example: {
data: {},
config: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+1 -144
View File
@@ -1,144 +1 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { assertOwner } from "./fs-store.js";
import { decryptSecret, encryptSecret } from "./secrets.js";
import { registerPlaintext } from "./secret-value.js";
const MAX_NAME_LENGTH = 128;
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
function nowIso() {
return new Date().toISOString();
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertSecretName(name) {
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
const err = new Error("invalid secret name");
err.statusCode = 400;
throw err;
}
if (name.length > MAX_NAME_LENGTH) {
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
err.statusCode = 400;
throw err;
}
return name;
}
function publicSecret(row) {
return {
id: row.id,
owner: row.owner,
name: row.name,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
/**
* @param {{ owner?: string }} [filters]
*/
export async function listSecrets(filters = {}) {
let q = db("secrets")
.select("id", "owner", "name", "created_at", "updated_at")
.orderBy("owner", "asc")
.orderBy("name", "asc");
if (filters.owner) {
q = q.where("owner", assertOwner(filters.owner));
}
return q;
}
/**
* @param {string} id
*/
export async function getSecretById(id) {
const row = await db("secrets")
.select("id", "owner", "name", "created_at", "updated_at")
.where({ id })
.first();
return row ?? null;
}
/**
* @param {{ owner: string, name: string, value: string }} opts
*/
export async function upsertSecret({ owner, name, value }) {
if (typeof value !== "string" || value.length === 0) {
const err = new Error("value is required");
err.statusCode = 400;
throw err;
}
const ownerName = assertOwner(owner);
const secretName = assertSecretName(name);
registerPlaintext(value);
const { ciphertext, iv, authTag } = encryptSecret(value);
const now = nowIso();
const existing = await db("secrets")
.where({ owner: ownerName, name: secretName })
.first();
if (existing) {
await db("secrets")
.where({ id: existing.id })
.update({
ciphertext,
iv,
auth_tag: authTag,
updated_at: now,
});
return getSecretById(existing.id);
}
const id = randomUUID();
await db("secrets").insert({
id,
owner: ownerName,
name: secretName,
ciphertext,
iv,
auth_tag: authTag,
created_at: now,
updated_at: now,
});
return getSecretById(id);
}
/**
* @param {string} id
* @returns {Promise<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}"`);
}
}
export * from "./src/stores/secrets-store.js";
+1 -1
View File
@@ -25,7 +25,7 @@ let cachedKey = null;
/**
* @returns {Buffer}
*/
export function getMasterKey() {
function getMasterKey() {
if (cachedKey) return cachedKey;
const raw = resolveSecretsKeyMaterial();
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
+11
View File
@@ -0,0 +1,11 @@
process.env.JFLOW_ROLE = "api";
import { startApp } from "./start-app.js";
// HTTP API + cron enqueue only. Schema migrations are owned by control.
await startApp({
role: "api",
migrate: false,
// In PM2/split mode the Vite/control process serves the SPA.
serveStaticUi: process.env.JFLOW_SERVE_UI === "1",
});
+33 -2
View File
@@ -8,16 +8,47 @@ export const OPEN_API_ROUTES = new Set([
"POST /auth/login",
]);
/**
* Cookie flags for auth JWT.
* Secure defaults on in production (HTTPS). Override with COOKIE_SECURE=false
* when serving over plain HTTP (e.g. LAN IP http://192.168.x.x) — browsers
* refuse to store Secure cookies on non-HTTPS origins.
*/
export function cookieOpts() {
const flag = process.env.COOKIE_SECURE;
const secure =
flag === "true" || flag === "1"
? true
: flag === "false" || flag === "0"
? false
: process.env.NODE_ENV === "production";
return {
httpOnly: true,
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
secure,
maxAge: 7 * 24 * 60 * 60,
};
}
/**
* Require JWT for /api routes except bootstrap, login, and register.
* @param {import("fastify").FastifyInstance} api
* @param {import("fastify").FastifyInstance} root
*/
export function addApiAuthGuard(api, root) {
api.addHook("onRequest", async (req, reply) => {
const raw = (req.url || "").split("?")[0];
const stripped = raw.replace(/^\/api/, "") || "/";
const routeUrl = req.routeOptions?.url || stripped;
const open =
OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) ||
OPEN_API_ROUTES.has(`${req.method} ${stripped}`);
if (open) return;
await root.authenticate(req, reply);
});
}
export function validateCredentials(username, password) {
if (!/^[A-Za-z0-9_]{3,32}$/.test(username)) {
return "username must be 3-32 letters, numbers, or underscore";
@@ -89,7 +120,7 @@ export default async function authPlugin(fastify) {
});
fastify.post("/auth/logout", async (_req, reply) => {
reply.clearCookie(COOKIE, { path: "/" });
reply.clearCookie(COOKIE, cookieOpts());
return { ok: true };
});
+3 -3
View File
@@ -63,8 +63,8 @@ export default function dashboardPluginFactory(registry) {
}
}
const [running, streaks, failedEvents, recent] = await Promise.all([
store.listRuns({ status: "running", limit: 10 }),
const [active, streaks, failedEvents, recent] = await Promise.all([
store.listRuns({ status: ["queued", "running"], limit: 10 }),
store.listConsecutiveFailureStreaks({ minCount: 4, limit: 10 }),
store.listRuns({ status: "failed", limit: 5 }),
store.listRuns({ limit: 10 }),
@@ -75,7 +75,7 @@ export default function dashboardPluginFactory(registry) {
scriptCount: fsStore.listScriptFiles().length,
enabledCount,
brokenCount,
running,
running: active,
needsAttention: {
consecutiveFailures: streaks.items,
consecutiveFailureCount: streaks.total,
+19 -9
View File
@@ -1,9 +1,9 @@
import {
assertAuthId,
assertAuthName,
assertAuthType,
listHttpAuths,
getHttpAuthById,
getHttpAuthByName,
upsertHttpAuth,
deleteHttpAuth,
revealHttpAuthLiterals,
@@ -18,28 +18,28 @@ export default async function httpAuthsPlugin(fastify) {
return { auths: await listHttpAuths() };
});
fastify.get("/http-auths/:name/reveal", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
fastify.get("/http-auths/:id/reveal", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
try {
assertAuthName(name);
assertAuthId(id);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const revealed = await revealHttpAuthLiterals(name);
const revealed = await revealHttpAuthLiterals(id);
if (!revealed) {
return reply.code(404).send({ error: "auth not found" });
}
return revealed;
});
fastify.get("/http-auths/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
fastify.get("/http-auths/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
try {
assertAuthName(name);
assertAuthId(id);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const auth = await getHttpAuthByName(name);
const auth = await getHttpAuthById(id);
if (!auth) {
return reply.code(404).send({ error: "auth not found" });
}
@@ -48,6 +48,7 @@ export default async function httpAuthsPlugin(fastify) {
fastify.put("/http-auths", async (req, reply) => {
const body = /** @type {{
id?: string | null,
name?: string,
type?: string,
config?: unknown,
@@ -57,6 +58,9 @@ export default async function httpAuthsPlugin(fastify) {
try {
assertAuthName(String(body.name ?? ""));
assertAuthType(body.type);
if (body.id != null && String(body.id).length > 0) {
assertAuthId(String(body.id));
}
if (
body.unauthorized_response != null &&
String(body.unauthorized_response).length > 0
@@ -69,6 +73,7 @@ export default async function httpAuthsPlugin(fastify) {
);
}
const auth = await upsertHttpAuth({
id: body.id != null && String(body.id).length > 0 ? String(body.id) : null,
name: String(body.name),
type: String(body.type),
config: body.config,
@@ -83,6 +88,11 @@ export default async function httpAuthsPlugin(fastify) {
fastify.delete("/http-auths/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
try {
assertAuthId(id);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const existing = await getHttpAuthById(id);
if (!existing) {
return reply.code(404).send({ error: "auth not found" });
+14 -1
View File
@@ -1,4 +1,4 @@
import { kvNamespaces, kvQuery } from "../../kv-store.js";
import { kvDelete, kvNamespaces, kvQuery } from "../../kv-store.js";
/**
* @param {import("fastify").FastifyInstance} fastify
@@ -19,4 +19,17 @@ export default async function kvPlugin(fastify) {
offset: Number.isFinite(offset) ? offset : undefined,
});
});
fastify.delete("/kv", async (req, reply) => {
const q = /** @type {Record<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
@@ -0,0 +1,82 @@
import * as fsStore from "../../fs-store.js";
import {
assertProfileName,
deleteProfile,
getProfileById,
getProfilePlain,
listProfileUsages,
listProfiles,
upsertProfile,
} from "../../profiles-store.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function profilesPlugin(fastify) {
fastify.get("/profiles", async (req, reply) => {
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
try {
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
const profiles = await listProfiles({ owner });
const withUsage = profiles.map((profile) => ({
...profile,
usageCount: listProfileUsages(profile.owner, profile.name).length,
}));
return { profiles: withUsage };
} catch (err) {
return reply.code(err.statusCode ?? 500).send({ error: err.message });
}
});
fastify.get("/profiles/:id/usage", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const existing = await getProfileById(id);
if (!existing) {
return reply.code(404).send({ error: "profile not found" });
}
return { usages: listProfileUsages(existing.owner, existing.name) };
});
fastify.put("/profiles", async (req, reply) => {
const body = /** @type {{
owner?: string,
name?: string,
script?: unknown,
config?: unknown,
description?: unknown,
}} */ (req.body ?? {});
try {
fsStore.assertOwner(String(body.owner ?? ""));
assertProfileName(String(body.name ?? ""));
const profile = await upsertProfile({
owner: String(body.owner),
name: String(body.name),
script: body.script,
config: body.config,
description: body.description,
});
return reply.send({ profile });
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
});
fastify.delete("/profiles/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const q = /** @type {{ force?: string }} */ (req.query ?? {});
const existing = await getProfileById(id);
if (!existing) {
return reply.code(404).send({ error: "profile not found" });
}
const usages = listProfileUsages(existing.owner, existing.name);
const force = q.force === "1" || q.force === "true";
if (usages.length > 0 && !force) {
return reply.code(409).send({
error: "profile is used by workflows",
usages,
});
}
await deleteProfile(id);
return { ok: true, forced: force && usages.length > 0, usages };
});
}
+28
View File
@@ -0,0 +1,28 @@
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));
}
+2 -10
View File
@@ -1,20 +1,12 @@
import * as store from "../../store.js";
import { queryRunsFromRequest } from "./run-query.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function runsPlugin(fastify) {
fastify.get("/runs", async (req) => {
const q = /** @type {Record<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 };
return queryRunsFromRequest(/** @type {Record<string, string | undefined>} */ (req.query ?? {}));
});
fastify.get("/consecutive-failures", async (req) => {
+439 -55
View File
@@ -1,13 +1,36 @@
import fs from "fs";
import path from "path";
import {
clearScriptCache,
inspectScriptSource,
instantiateScriptSource,
} from "../../script-sandbox.js";
import * as fsStore from "../../fs-store.js";
import {
forkCoreScript,
listCoreScriptNames,
listInstalledPlugins,
resolveScriptRef,
uninstallPlugin,
createBlankPlugin,
installPluginFromDirectory,
} from "../../plugin-store.js";
import {
installExamplePlugin,
installPluginFromGit,
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
@@ -18,19 +41,58 @@ export default function scriptsPluginFactory(registry) {
*/
return async function scriptsPlugin(fastify) {
fastify.get("/scripts", async () => {
const scripts = fsStore.listScriptFiles().map((name) => {
const core = listCoreScriptNames().map((name) => {
const content = fsStore.readScript(name);
const inspected =
content == null
? { meta: null, metaError: "script not found" }
: inspectScriptSource(name, content);
return { name, hasIcon: fsStore.scriptHasIcon(name), ...inspected };
return {
name,
kind: "core",
editable: false,
hasIcon: fsStore.scriptHasIcon(name),
...inspected,
};
});
return { scripts };
const plugins = listInstalledPlugins().map((p) => {
let inspected = { meta: null, metaError: null };
if (!p.disabled && p.manifest) {
try {
const mainPath = path.join(p.dir, p.manifest.main);
const content = fs.readFileSync(mainPath, "utf8");
inspected = inspectScriptSource(p.scriptRef, content);
} catch (err) {
inspected = {
meta: null,
metaError: err instanceof Error ? err.message : String(err),
};
}
} else if (p.compatError) {
inspected = { meta: null, metaError: p.compatError };
}
return {
name: p.scriptRef,
kind: "plugin",
editable: true,
pluginId: p.id,
disabled: p.disabled,
version: p.manifest?.version ?? null,
hasIcon: false,
...inspected,
};
});
return {
scripts: [...core, ...plugins],
appVersion: getAppVersion(),
};
});
fastify.get("/scripts/:name/icon", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
// Icons only for core scripts today
try {
fsStore.assertScriptName(name);
} catch (err) {
@@ -46,72 +108,157 @@ export default function scriptsPluginFactory(registry) {
});
fastify.get("/scripts/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
fsStore.assertScriptName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const resolved = resolveScriptRef(rawName);
if (resolved.error || !resolved.filePath) {
return reply
.code(404)
.send({ error: resolved.error || "script not found" });
}
const content = fsStore.readScript(name);
if (content == null) return reply.code(404).send({ error: "script not found" });
return { name, content, hasIcon: fsStore.scriptHasIcon(name), ...inspectScriptSource(name, content) };
const content = fs.readFileSync(resolved.filePath, "utf8");
const inspected = inspectScriptSource(resolved.scriptRef, content);
return {
name: resolved.scriptRef,
kind: resolved.kind,
editable: resolved.kind === "plugin",
pluginId: resolved.pluginId ?? null,
content,
hasIcon:
resolved.kind === "core"
? fsStore.scriptHasIcon(resolved.scriptRef)
: false,
...inspected,
};
});
// Core scripts are read-only. Creating/editing bare *.js writes is disabled.
// New user scripts must be plugins (fork / zip / git).
fastify.put("/scripts/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
fsStore.assertScriptName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const pluginRef = resolveScriptRef(rawName);
if (pluginRef.kind === "core" || !rawName.startsWith("plugin/")) {
// Attempt to treat as core name
try {
fsStore.assertScriptName(
rawName.endsWith(".js") ? rawName : `${rawName}.js`,
);
} catch {
// continue
}
if (!rawName.startsWith("plugin/")) {
return reply.code(403).send({
error:
"core scripts are read-only; fork to a plugin or install a plugin",
});
}
}
const body = /** @type {{ content?: string }} */ (req.body ?? {});
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
const existed = fsStore.readScript(name) != null;
fsStore.writeScript(name, body.content);
const resolved = resolveScriptRef(rawName);
if (resolved.kind !== "plugin" || !resolved.filePath || !resolved.pluginDir) {
return reply.code(404).send({
error: resolved.error || "plugin not found (install or fork first)",
});
}
if (resolved.disabled) {
return reply.code(409).send({ error: resolved.error || "plugin disabled" });
}
fs.writeFileSync(resolved.filePath, body.content, "utf8");
clearScriptCache();
return reply.code(existed ? 200 : 201).send({
name,
...inspectScriptSource(name, body.content),
return reply.send({
name: resolved.scriptRef,
kind: "plugin",
editable: true,
...inspectScriptSource(resolved.scriptRef, body.content),
});
});
fastify.delete("/scripts/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
fsStore.assertScriptName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
if (!rawName.startsWith("plugin/")) {
return reply.code(403).send({
error: "core scripts cannot be deleted",
});
}
if (registry.referencedScripts().has(name)) {
const resolved = resolveScriptRef(rawName);
const id = resolved.pluginId;
if (!id) {
// may be installed but disabled — still allow uninstall via plugin id parse
const installed = listInstalledPlugins().find(
(p) => p.scriptRef === rawName || `plugin/${p.id}` === rawName,
);
if (!installed) {
return reply.code(404).send({ error: "plugin not found" });
}
if (registry.referencedScripts().has(installed.scriptRef)) {
return reply
.code(409)
.send({ error: "plugin is referenced by a workflow" });
}
uninstallPlugin(installed.id);
clearScriptCache();
return { ok: true, restartNeeded: true };
}
if (registry.referencedScripts().has(resolved.scriptRef)) {
return reply
.code(409)
.send({ error: "script is referenced by a workflow" });
}
if (!fsStore.deleteScript(name)) {
return reply.code(404).send({ error: "script not found" });
.send({ error: "plugin is referenced by a workflow" });
}
uninstallPlugin(id);
clearScriptCache();
return { ok: true };
return { ok: true, restartNeeded: true };
});
fastify.post("/scripts/:name/fork", async (req, reply) => {
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const body = /** @type {{ id?: string, description?: string }} */ (
req.body ?? {}
);
if (typeof body.id !== "string" || !body.id.trim()) {
return reply.code(400).send({ error: "id is required" });
}
try {
const coreName = rawName.endsWith(".js") ? rawName : `${rawName}.js`;
fsStore.assertScriptName(coreName);
const installed = forkCoreScript(coreName, body.id.trim(), {
description: body.description,
});
clearScriptCache();
return reply.code(201).send({
...installed,
restartNeeded: true,
warning:
"Plugins run as the JerapahFlow process user. Review code before install.",
});
} catch (err) {
return reply
.code(/** @type {any} */ (err).statusCode ?? 500)
.send({ error: err instanceof Error ? err.message : String(err) });
}
});
fastify.post("/scripts/:name/dry-run", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
fsStore.assertScriptName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const body = /** @type {{ content?: string, expression?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
req.body ?? {}
);
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
let owner = "default";
let owner = "local";
if (body.owner != null && body.owner !== "") {
try {
owner = fsStore.assertOwner(String(body.owner));
@@ -120,14 +267,99 @@ export default function scriptsPluginFactory(registry) {
}
}
const incomingContext =
body.context != null && typeof body.context === "object" && !Array.isArray(body.context)
const incomingContext = reviveBinaryFromWire(
body.context != null &&
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 =
resolved.kind === "plugin" && !resolved.error
? resolved.pluginDir ?? null
: null;
try {
const config = await resolveConfigRefs(body.config ?? null, {
owner,
@@ -135,21 +367,32 @@ export default function scriptsPluginFactory(registry) {
context: incomingContext,
});
const ctx = {
data: body.data ?? null,
data: incomingData,
context: incomingContext,
config,
};
const { fn, meta, metaError } = instantiateScriptSource(name, body.content, {
log,
workflowName: "dry-run",
owner,
});
const { fn, meta, metaError } = instantiateScriptSource(
resolved.scriptRef || rawName,
body.content,
{
log,
workflowName: "dry-run",
owner,
pluginDir,
},
);
const raw = await fn(ctx);
const result = normalizeStepResult(raw, incomingContext, name);
const result = normalizeStepResult(
raw,
incomingContext,
resolved.scriptRef || rawName,
);
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,
@@ -158,7 +401,7 @@ export default function scriptsPluginFactory(registry) {
metaError,
};
} catch (err) {
const inspected = inspectScriptSource(name, body.content);
const inspected = inspectScriptSource(rawName, body.content);
return {
status: "failed",
output: null,
@@ -171,5 +414,146 @@ export default function scriptsPluginFactory(registry) {
};
}
});
// --- Plugins ---
fastify.get("/plugins", async () => {
return {
appVersion: getAppVersion(),
plugins: listInstalledPlugins(),
warning:
"Installing plugins runs third-party code as the JerapahFlow OS user.",
};
});
fastify.post(
"/plugins/create",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => {
const body = /** @type {{ id?: string, content?: string, description?: string }} */ (
req.body ?? {}
);
if (typeof body.id !== "string" || !body.id.trim()) {
return reply.code(400).send({ error: "id is required" });
}
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
try {
const installed = createBlankPlugin(body.id.trim(), body.content, {
description: body.description,
});
clearScriptCache();
return reply.code(201).send({
...installed,
restartNeeded: true,
warning:
"Plugins run as the JerapahFlow process user. Review code before install.",
});
} catch (err) {
return reply
.code(/** @type {any} */ (err).statusCode ?? 500)
.send({ error: err instanceof Error ? err.message : String(err) });
}
},
);
fastify.post(
"/plugins/install",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => {
const body = /** @type {{
source?: string,
url?: string,
ref?: string,
path?: string,
exampleId?: string,
zipBase64?: string,
overwrite?: boolean,
}} */ (req.body ?? {});
try {
let installed;
if (body.source === "git") {
if (!body.url) {
return reply.code(400).send({ error: "url is required" });
}
installed = await installPluginFromGit(body.url, {
ref: body.ref,
overwrite: Boolean(body.overwrite),
});
} else if (body.source === "example") {
const id = body.exampleId || "get-current-time";
installed = await installExamplePlugin(id, {
overwrite: Boolean(body.overwrite),
});
} else if (body.source === "dir") {
if (!body.path) {
return reply.code(400).send({ error: "path is required" });
}
// Only allow examples/ or existing staging under plugins for safety
const abs = path.resolve(body.path);
const allowed =
abs.startsWith(EXAMPLE_PLUGINS_DIR + path.sep) ||
abs.startsWith(EXAMPLE_PLUGINS_DIR);
if (!allowed) {
return reply.code(403).send({
error: "dir install only allowed under examples/plugins",
});
}
installed = installPluginFromDirectory(abs, {
overwrite: Boolean(body.overwrite),
});
} else if (body.source === "zip") {
if (!body.zipBase64) {
return reply.code(400).send({ error: "zipBase64 is required" });
}
const buf = Buffer.from(body.zipBase64, "base64");
installed = await installPluginFromZipBuffer(buf, {
overwrite: Boolean(body.overwrite),
});
} else {
return reply.code(400).send({
error: "source must be git | zip | example | dir",
});
}
clearScriptCache();
return reply.code(201).send({
...installed,
scriptRef: pluginScriptRef(installed.id),
restartNeeded: true,
warning:
"Plugins run as the JerapahFlow process user. Review code before install. Drain-restart workers to load new dependencies.",
});
} catch (err) {
return reply
.code(/** @type {any} */ (err).statusCode ?? 500)
.send({ error: err instanceof Error ? err.message : String(err) });
}
},
);
fastify.delete(
"/plugins/:id",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
try {
const scriptRef = pluginScriptRef(id);
if (registry.referencedScripts().has(scriptRef)) {
return reply
.code(409)
.send({ error: "plugin is referenced by a workflow" });
}
uninstallPlugin(id);
clearScriptCache();
return { ok: true, restartNeeded: true };
} catch (err) {
return reply
.code(/** @type {any} */ (err).statusCode ?? 500)
.send({ error: err instanceof Error ? err.message : String(err) });
}
},
);
};
}
+551 -97
View File
@@ -10,14 +10,61 @@ import {
authLabel,
validateWorkflowHttpTriggers,
} from "../../workflow-http-validate.js";
import { listHttpAuths } from "../../http-auths-store.js";
import { validateWorkflowFailureTriggers } from "../../trigger-failure.js";
import {
duplicateWorkflowYaml,
ensureWorkflowFilename,
suggestCopyFilename,
suggestDuplicateFilename,
} from "../../workflow-duplicate.js";
import { publishReload } from "../../control-bus.js";
import {
workflowIdFromFile,
newWorkflowFilename,
} from "../../workflow-normalize.js";
import {
collectWorkflowWarnings,
parseWorkflowDocument,
} from "../../workflow-validate-warnings.js";
import { getProfilePlain } from "../../profiles-store.js";
import { resolveScriptRef } from "../../plugin-store.js";
import {
recordRevision,
listRevisions,
getRevision,
ensureInitialRevision,
} from "../../workflow-history.js";
import {
moveWorkflowToTrash,
listTrash,
restoreFromTrash,
purgeTrashItem,
isInTrash,
} from "../../workflow-trash.js";
import {
createWorkflowBackupBuffer,
restoreWorkflowBackup,
} from "../../workflow-backup.js";
import {
listExampleWorkflows,
readExampleWorkflow,
assertExampleWorkflowId,
} from "../../workflow-examples.js";
function triggerSummary(owner, workflow) {
/**
* Reload this process and notify other HTTP/worker processes via Redis.
* @param {{ reregister: () => void }} registry
*/
async function reregisterAll(registry) {
registry.reregister();
try {
await publishReload({ type: "workflows" });
} catch {
// Redis may be briefly unavailable; local reload already applied.
}
}
function triggerSummary(owner, workflow, nameById) {
if (!workflow || typeof workflow !== "object") return [];
return (workflow.triggers ?? []).map((t) => {
const type = t?.type ?? "unknown";
@@ -29,7 +76,7 @@ function triggerSummary(owner, workflow) {
schedule: t?.schedule ?? null,
onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
onFailureWorkflow: t?.onFailureWorkflow ?? null,
auth: isHttp ? authLabel(t?.auth) : null,
auth: isHttp ? authLabel(t?.auth, nameById) : null,
};
});
}
@@ -40,7 +87,9 @@ function scriptNames(workflow) {
for (const raw of workflow.scripts ?? []) {
try {
const parsed = parseScriptStep(raw);
names.push(parsed.kind === "set" ? "set" : parsed.script);
if (parsed.kind === "set") names.push("set");
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
else names.push(parsed.script);
} catch {
names.push(null);
}
@@ -48,6 +97,148 @@ 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
*/
async function validateStrictWorkflow(parsed) {
compileWorkflowScripts(parsed?.scripts);
await validateWorkflowHttpTriggers(parsed);
await validateWorkflowFailureTriggers(parsed);
}
/**
* @param {{
* owner: string,
* file: string,
* content: string,
* saveAnyway?: boolean,
* reason?: string | null,
* meta?: Record<string, unknown> | null,
* forceRevision?: boolean,
* }} 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);
if (!saveAnyway) {
if (parseError) {
const err = new Error("workflow has validation warnings");
err.statusCode = 422;
err.warnings = warnings;
throw err;
}
try {
await validateStrictWorkflow(parsed);
} catch (validationErr) {
const err = new Error("workflow has validation warnings");
err.statusCode = 422;
err.warnings = [
...warnings,
{
code: "validation_error",
message:
validationErr instanceof Error
? validationErr.message
: String(validationErr),
},
];
throw err;
}
if (warnings.length) {
const err = new Error("workflow has validation warnings");
err.statusCode = 422;
err.warnings = warnings;
throw err;
}
}
const workflowId = workflowIdFromFile(opts.file);
const existed = fsStore.readWorkflowYaml(opts.owner, opts.file) != null;
fsStore.writeWorkflowYaml(opts.owner, opts.file, opts.content);
const registered = fsStore.readRegisters(opts.owner);
if (!registered.includes(opts.file)) {
registered.push(opts.file);
fsStore.writeRegisters(opts.owner, registered);
}
const revision = await recordRevision({
workflowId,
owner: opts.owner,
file: opts.file,
content: opts.content,
reason: opts.reason ?? "save",
meta: opts.meta ?? null,
force: opts.forceRevision,
});
return {
owner: opts.owner,
file: opts.file,
workflow_id: workflowId,
existed,
warnings,
revision,
};
}
/**
* @param {{ workflows: Map<string, any>, loadErrors: Map<string, string>, reregister: () => void }} registry
*/
@@ -60,12 +251,99 @@ 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() };
});
fastify.post("/workflows/trash/:id/restore", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
try {
const restored = await restoreFromTrash(id);
await recordRevision({
workflowId: restored.workflow_id,
owner: restored.owner,
file: restored.file,
content: restored.content,
reason: "restored-from-trash",
force: true,
});
await reregisterAll(registry);
return { owner: restored.owner, file: restored.file };
} catch (err) {
return reply.code(err.statusCode ?? 500).send({
error: err instanceof Error ? err.message : String(err),
});
}
});
fastify.delete("/workflows/trash/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
try {
return await purgeTrashItem(id);
} catch (err) {
return reply.code(err.statusCode ?? 500).send({
error: err instanceof Error ? err.message : String(err),
});
}
});
fastify.get("/workflows/backup", async (_req, reply) => {
const buffer = await createWorkflowBackupBuffer();
const stamp = new Date().toISOString().slice(0, 10);
return reply
.header("Content-Type", "application/zip")
.header(
"Content-Disposition",
`attachment; filename="jerapah-flow-backup-${stamp}.zip"`,
)
.send(buffer);
});
fastify.post("/workflows/backup/restore", async (req, reply) => {
const body = /** @type {{ zipBase64?: string, mode?: string }} */ (
req.body ?? {}
);
if (typeof body.zipBase64 !== "string" || !body.zipBase64.trim()) {
return reply.code(400).send({ error: "zipBase64 is required" });
}
const mode = body.mode === "replace" ? "replace" : "merge";
try {
const buffer = Buffer.from(body.zipBase64, "base64");
const result = await restoreWorkflowBackup(buffer, { mode });
await reregisterAll(registry);
return result;
} catch (err) {
return reply.code(400).send({
error: err instanceof Error ? err.message : String(err),
});
}
});
fastify.get("/workflows", async (req) => {
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
const stats = await store.workflowStats();
const owners = q.owner
? [fsStore.assertOwner(q.owner)]
: fsStore.listOwners();
const authNameById = Object.fromEntries(
(await listHttpAuths()).map((a) => [a.id, a.name]),
);
const items = [];
for (const owner of owners) {
@@ -79,6 +357,7 @@ export default function workflowsPluginFactory(registry) {
const files = [...new Set([...registered, ...onDisk])];
for (const file of files) {
if (await isInTrash(owner, file)) continue;
const key = `${owner}/${file}`;
const loaded = registry.workflows.get(key);
const loadError = registry.loadErrors.get(key) ?? null;
@@ -88,11 +367,8 @@ export default function workflowsPluginFactory(registry) {
if (raw != null) {
try {
parsed = yaml.parse(raw);
} catch (err) {
} catch {
// keep loadError
if (!loadError) {
// file on disk but unparseable and not in registers
}
}
}
}
@@ -104,25 +380,124 @@ export default function workflowsPluginFactory(registry) {
items.push({
owner,
file,
workflow_id: workflowIdFromFile(file),
key,
name: parsed?.name ?? file,
description: parsed?.description ?? null,
enabled: parsed ? parsed.enabled !== false : false,
registered: registered.includes(file),
loadError:
loadError ??
(parsed ? null : "unreadable"),
loadError: loadError ?? (parsed ? null : "unreadable"),
lastModifiedAt: fsStore.workflowLastModifiedAt(owner, file),
lastInvokedAt: st.lastInvokedAt,
lastStatus: st.lastStatus ?? null,
invocationCount: st.invocationCount,
triggers: triggerSummary(owner, parsed),
triggers: triggerSummary(owner, parsed, authNameById),
scripts: scriptNames(parsed),
});
}
}
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 };
});
fastify.get("/workflows/:owner/:file/revisions", async (req, reply) => {
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
req.params
);
try {
fsStore.assertOwner(owner);
fsStore.assertWorkflowFile(file);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
if (fsStore.readWorkflowYaml(owner, file) == null) {
return reply.code(404).send({ error: "workflow not found" });
}
await ensureInitialRevision({ owner, file });
const workflowId = workflowIdFromFile(file);
return { workflow_id: workflowId, revisions: await listRevisions(workflowId) };
});
fastify.get(
"/workflows/:owner/:file/revisions/:revision",
async (req, reply) => {
const { owner, file, revision } = /** @type {{ owner: string, file: string, revision: string }} */ (
req.params
);
try {
fsStore.assertOwner(owner);
fsStore.assertWorkflowFile(file);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const workflowId = workflowIdFromFile(file);
const rev = await getRevision(workflowId, Number(revision));
if (!rev) {
return reply.code(404).send({ error: "revision not found" });
}
return {
workflow_id: workflowId,
revision: rev.revision,
content: rev.content,
reason: rev.reason,
meta: rev.meta,
created_at: rev.created_at,
};
},
);
fastify.post(
"/workflows/:owner/:file/revisions/:revision/revert",
async (req, reply) => {
const { owner, file, revision } = /** @type {{ owner: string, file: string, revision: string }} */ (
req.params
);
try {
fsStore.assertOwner(owner);
fsStore.assertWorkflowFile(file);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
if (fsStore.readWorkflowYaml(owner, file) == null) {
return reply.code(404).send({ error: "workflow not found" });
}
const workflowId = workflowIdFromFile(file);
const rev = await getRevision(workflowId, Number(revision));
if (!rev) {
return reply.code(404).send({ error: "revision not found" });
}
const body = /** @type {{ saveAnyway?: boolean }} */ (req.body ?? {});
try {
const saved = await saveWorkflowContent({
owner,
file,
content: rev.content,
saveAnyway: body.saveAnyway,
reason: "revert",
meta: { fromRevision: rev.revision },
});
await reregisterAll(registry);
return saved;
} catch (err) {
if (err.statusCode === 422) {
return reply.code(422).send({
error: err.message,
warnings: err.warnings ?? [],
});
}
return reply.code(err.statusCode ?? 500).send({
error: err instanceof Error ? err.message : String(err),
});
}
},
);
fastify.get("/workflows/:owner/:file", async (req, reply) => {
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
req.params
@@ -153,6 +528,7 @@ export default function workflowsPluginFactory(registry) {
return {
owner,
file,
workflow_id: workflowIdFromFile(file),
key,
content,
parsed,
@@ -174,48 +550,95 @@ export default function workflowsPluginFactory(registry) {
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const body = /** @type {{ content?: string }} */ (req.body ?? {});
const body = /** @type {{ content?: string, saveAnyway?: boolean }} */ (
req.body ?? {}
);
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
let parsed;
try {
parsed = yaml.parse(body.content);
} catch (err) {
return reply.code(400).send({
error: `invalid yaml: ${err instanceof Error ? err.message : String(err)}`,
const saved = await saveWorkflowContent({
owner,
file,
content: body.content,
saveAnyway: body.saveAnyway,
reason: "save",
});
await reregisterAll(registry);
return reply.code(saved.existed ? 200 : 201).send({
owner: saved.owner,
file: saved.file,
workflow_id: saved.workflow_id,
warnings: saved.warnings,
revision: saved.revision,
});
}
try {
compileWorkflowScripts(parsed?.scripts);
} catch (err) {
return reply.code(400).send({
if (err.statusCode === 422) {
return reply.code(422).send({
error: err.message,
warnings: err.warnings ?? [],
});
}
return reply.code(err.statusCode ?? 500).send({
error: err instanceof Error ? err.message : String(err),
});
}
});
fastify.post("/workflows/:owner", async (req, reply) => {
const { owner } = /** @type {{ owner: string }} */ (req.params);
try {
await validateWorkflowHttpTriggers(parsed);
fsStore.assertOwner(owner);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const body = /** @type {{ content?: string, file?: string, saveAnyway?: boolean }} */ (
req.body ?? {}
);
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
let file = body.file?.trim() ? ensureWorkflowFilename(body.file) : "";
if (!file) {
const existing = fsStore.listOwnerYamlFiles(owner);
file = suggestDuplicateFilename(existing);
}
try {
fsStore.assertWorkflowFile(file);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
if (fsStore.readWorkflowYaml(owner, file) != null) {
return reply.code(409).send({ error: "workflow already exists" });
}
try {
const saved = await saveWorkflowContent({
owner,
file,
content: body.content,
saveAnyway: body.saveAnyway,
reason: "create",
forceRevision: true,
});
await reregisterAll(registry);
return reply.code(201).send({
owner: saved.owner,
file: saved.file,
workflow_id: saved.workflow_id,
warnings: saved.warnings,
revision: saved.revision,
});
} catch (err) {
if (err.statusCode === 422) {
return reply.code(422).send({
error: err.message,
warnings: err.warnings ?? [],
});
}
return reply.code(err.statusCode ?? 500).send({
error: err instanceof Error ? err.message : String(err),
});
}
try {
await validateWorkflowFailureTriggers(parsed);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({
error: err instanceof Error ? err.message : String(err),
});
}
const existed = fsStore.readWorkflowYaml(owner, file) != null;
fsStore.writeWorkflowYaml(owner, file, body.content);
const registered = fsStore.readRegisters(owner);
if (!registered.includes(file)) {
registered.push(file);
fsStore.writeRegisters(owner, registered);
}
registry.reregister();
return reply.code(existed ? 200 : 201).send({ owner, file });
});
fastify.patch("/workflows/:owner/:file", async (req, reply) => {
@@ -236,23 +659,39 @@ export default function workflowsPluginFactory(registry) {
if (content == null) {
return reply.code(404).send({ error: "workflow not found" });
}
const doc = yaml.parseDocument(content);
if (doc.errors?.length) {
const msg = doc.errors[0]?.message ?? "invalid yaml";
return reply.code(400).send({ error: msg });
}
const parsed = doc.toJSON();
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
return reply.code(400).send({ error: "workflow yaml must be an object" });
let doc;
try {
({ doc } = parseWorkflowDocument(content));
} catch (err) {
return reply.code(err.statusCode ?? 400).send({
error: err instanceof Error ? err.message : String(err),
});
}
if (body.enabled) {
doc.delete("enabled");
} else {
doc.set("enabled", false);
}
fsStore.writeWorkflowYaml(owner, file, String(doc));
registry.reregister();
return { owner, file, enabled: body.enabled };
const nextContent = String(doc);
try {
const saved = await saveWorkflowContent({
owner,
file,
content: nextContent,
reason: body.enabled ? "enable" : "disable",
});
await reregisterAll(registry);
return {
owner,
file,
enabled: body.enabled,
revision: saved.revision,
};
} catch (err) {
return reply.code(err.statusCode ?? 500).send({
error: err instanceof Error ? err.message : String(err),
});
}
});
fastify.delete("/workflows/:owner/:file", async (req, reply) => {
@@ -265,13 +704,31 @@ export default function workflowsPluginFactory(registry) {
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
if (!fsStore.deleteWorkflowYaml(owner, file)) {
const raw = fsStore.readWorkflowYaml(owner, file);
if (raw == null) {
return reply.code(404).send({ error: "workflow not found" });
}
const registered = fsStore.readRegisters(owner).filter((f) => f !== file);
fsStore.writeRegisters(owner, registered);
registry.reregister();
return { ok: true };
let name = null;
try {
const parsed = yaml.parse(raw);
name = parsed?.name ?? null;
} catch {
// ignore
}
try {
const item = await moveWorkflowToTrash({
workflowId: workflowIdFromFile(file),
owner,
file,
name,
});
await reregisterAll(registry);
return { ok: true, trash: item };
} catch (err) {
return reply.code(err.statusCode ?? 500).send({
error: err instanceof Error ? err.message : String(err),
});
}
});
fastify.post("/workflows/:owner/:file/duplicate", async (req, reply) => {
@@ -289,7 +746,9 @@ export default function workflowsPluginFactory(registry) {
return reply.code(404).send({ error: "workflow not found" });
}
const body = /** @type {{ file?: unknown, owner?: unknown }} */ (req.body ?? {});
const body = /** @type {{ file?: unknown, owner?: unknown, saveAnyway?: boolean }} */ (
req.body ?? {}
);
let destOwner = owner;
if (body.owner != null && body.owner !== "") {
if (typeof body.owner !== "string") {
@@ -305,7 +764,9 @@ export default function workflowsPluginFactory(registry) {
let destFile;
try {
if (body.file == null || body.file === "") {
destFile = suggestCopyFilename(file, fsStore.listOwnerYamlFiles(destOwner));
destFile = suggestDuplicateFilename(
fsStore.listOwnerYamlFiles(destOwner),
);
} else if (typeof body.file !== "string") {
return reply.code(400).send({ error: "file must be a string" });
} else {
@@ -336,44 +797,34 @@ export default function workflowsPluginFactory(registry) {
});
}
let parsed;
try {
parsed = yaml.parse(content);
} catch (err) {
return reply.code(400).send({
error: `invalid yaml: ${err instanceof Error ? err.message : String(err)}`,
const saved = await saveWorkflowContent({
owner: destOwner,
file: destFile,
content,
saveAnyway: body.saveAnyway,
reason: "duplicated",
meta: { from: `${owner}/${file}` },
forceRevision: true,
});
await reregisterAll(registry);
return reply.code(201).send({
owner: destOwner,
file: destFile,
workflow_id: saved.workflow_id,
revision: saved.revision,
});
}
try {
compileWorkflowScripts(parsed?.scripts);
} catch (err) {
return reply.code(400).send({
if (err.statusCode === 422) {
return reply.code(422).send({
error: err.message,
warnings: err.warnings ?? [],
});
}
return reply.code(err.statusCode ?? 500).send({
error: err instanceof Error ? err.message : String(err),
});
}
try {
await validateWorkflowHttpTriggers(parsed);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({
error: err instanceof Error ? err.message : String(err),
});
}
try {
await validateWorkflowFailureTriggers(parsed);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({
error: err instanceof Error ? err.message : String(err),
});
}
fsStore.writeWorkflowYaml(destOwner, destFile, content);
const registered = fsStore.readRegisters(destOwner);
if (!registered.includes(destFile)) {
registered.push(destFile);
fsStore.writeRegisters(destOwner, registered);
}
registry.reregister();
return reply.code(201).send({ owner: destOwner, file: destFile });
});
fastify.post("/workflows/:owner/:file/run", async (req, reply) => {
@@ -394,9 +845,9 @@ export default function workflowsPluginFactory(registry) {
if (!registered.includes(file)) {
registered.push(file);
fsStore.writeRegisters(owner, registered);
registry.reregister();
await reregisterAll(registry);
} else if (!registry.workflows.has(key) && !registry.loadErrors.has(key)) {
registry.reregister();
await reregisterAll(registry);
}
if (!registry.workflows.has(key)) {
return reply.code(404).send({
@@ -404,7 +855,7 @@ export default function workflowsPluginFactory(registry) {
});
}
const body = /** @type {{ data?: unknown }} */ (req.body ?? {});
const result = await registry.runWorkflow(
const result = await registry.enqueueWorkflow(
key,
{ data: body.data ?? null },
{ type: "manual", detail: "ui" },
@@ -412,19 +863,22 @@ export default function workflowsPluginFactory(registry) {
if (result.status === "failed") {
return reply.code(result.runId ? 500 : 404).send({
runId: result.runId,
status: result.status,
error: result.error,
});
}
return {
return reply.code(202).send({
runId: result.runId,
status: result.status,
result: store.toDisplayValue(result.result),
};
jobId: result.jobId ?? null,
});
});
fastify.post("/workflows/reregister", async () => {
registry.reregister();
await reregisterAll(registry);
return { message: "Workflows refreshed" };
});
};
}
export { newWorkflowFilename };
@@ -0,0 +1,390 @@
import { randomUUID } from "node:crypto";
import { db } from "../../db.js";
import { assertHttpStatus } from "../../http-pages-store.js";
const MAX_NAME_LENGTH = 128;
const NAME_RE = /^[A-Za-z0-9._-]+$/;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
function nowIso() {
return new Date().toISOString();
}
/**
* @param {unknown} id
* @returns {string}
*/
export function assertAuthId(id) {
if (typeof id !== "string" || !UUID_RE.test(id)) {
const err = new Error("invalid auth id");
err.statusCode = 400;
throw err;
}
return id.toLowerCase();
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertAuthName(name) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
const err = new Error("invalid auth name");
err.statusCode = 400;
throw err;
}
if (name.length > MAX_NAME_LENGTH) {
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
err.statusCode = 400;
throw err;
}
return name;
}
/**
* @param {unknown} type
* @returns {"bearer" | "basic" | "header"}
*/
export function assertAuthType(type) {
const t = String(type ?? "");
if (!ALLOWED_TYPES.has(t)) {
const err = new Error('auth type must be "bearer", "basic", or "header"');
err.statusCode = 400;
throw err;
}
return /** @type {"bearer" | "basic" | "header"} */ (t);
}
/**
* Detect value source without exposing literal values.
* @param {unknown} value
* @returns {"literal" | "kv" | "secret" | "missing"}
*/
export function valueSourceKind(value) {
if (value == null) return "missing";
if (typeof value === "string") return "literal";
if (typeof value === "object" && !Array.isArray(value)) {
if ("secret" in value) return "secret";
if ("kv" in value) return "kv";
}
return "literal";
}
/**
* Redact config for API responses: replace literal strings with source markers.
* @param {Record<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
@@ -0,0 +1,399 @@
import { db } from "../../db.js";
const MAX_KEY_LENGTH = 512;
const MAX_NAMESPACE_LENGTH = 512;
const MAX_VALUE_BYTES = 256 * 1024;
const DEFAULT_LIST_LIMIT = 100;
const MAX_LIST_LIMIT = 500;
function nowIso() {
return new Date().toISOString();
}
/**
* @param {unknown} value
*/
function valuesEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return a === b;
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
/**
* @param {string} label
* @param {unknown} value
*/
function assertString(label, value) {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${label} must be a non-empty string`);
}
}
/**
* @param {string} label
* @param {string} value
* @param {number} max
*/
function assertMaxLength(label, value, max) {
if (value.length > max) {
throw new Error(`${label} must be at most ${max} characters`);
}
}
/**
* @param {string} namespace
*/
function assertNamespace(namespace) {
assertString("namespace", namespace);
assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH);
}
/**
* @param {string} key
*/
function assertKey(key) {
assertString("key", key);
assertMaxLength("key", key, MAX_KEY_LENGTH);
}
/**
* @param {unknown} value
* @returns {string}
*/
function serializeKvValue(value) {
let json;
try {
json = JSON.stringify(value);
} catch {
throw new Error("value must be JSON-serializable");
}
if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) {
throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`);
}
return json;
}
/**
* @param {string | null | undefined} value
* @returns {unknown}
*/
function deserializeKvValue(value) {
if (value == null) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
}
/**
* @param {{ expires_at?: string | null }} row
*/
function isExpired(row) {
if (!row.expires_at) return false;
return Date.parse(row.expires_at) <= Date.now();
}
/**
* @param {string} namespace
* @param {string} key
* @returns {Promise<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 });
},
};
}
@@ -0,0 +1,244 @@
import { randomUUID } from "node:crypto";
import yaml from "yaml";
import { db } from "../../db.js";
import { assertOwner, listOwnerYamlFiles, readWorkflowYaml } from "../../fs-store.js";
const MAX_NAME_LENGTH = 128;
const MAX_DESCRIPTION_LENGTH = 500;
const MAX_CONFIG_BYTES = 64 * 1024;
const PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/;
function nowIso() {
return new Date().toISOString();
}
function httpError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertProfileName(name) {
if (typeof name !== "string" || !PROFILE_NAME_RE.test(name)) {
throw httpError("invalid profile name");
}
if (name.length > MAX_NAME_LENGTH) {
throw httpError(`profile name must be at most ${MAX_NAME_LENGTH} characters`);
}
return name;
}
/**
* @param {unknown} script
* @returns {string}
*/
export function assertProfileScript(script) {
if (typeof script !== "string" || script.trim().length === 0) {
throw httpError("script is required");
}
const trimmed = script.trim();
if (trimmed.length > 256) {
throw httpError("script name is too long");
}
return trimmed;
}
/**
* @param {unknown} description
* @returns {string}
*/
export function assertProfileDescription(description) {
if (description == null) return "";
if (typeof description !== "string") {
throw httpError("description must be a string");
}
if (description.length > MAX_DESCRIPTION_LENGTH) {
throw httpError(`description must be at most ${MAX_DESCRIPTION_LENGTH} characters`);
}
return description;
}
/**
* @param {unknown} config
* @returns {string}
*/
export function encodeProfileConfig(config) {
if (config == null) return "{}";
if (typeof config !== "object" || Array.isArray(config)) {
throw httpError("config must be an object");
}
let encoded;
try {
encoded = JSON.stringify(config);
} catch {
throw httpError("config must be JSON-serializable");
}
if (Buffer.byteLength(encoded, "utf8") > MAX_CONFIG_BYTES) {
throw httpError(`config exceeds ${MAX_CONFIG_BYTES} byte limit`);
}
return encoded;
}
/**
* @param {string} stored
* @returns {Record<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
@@ -0,0 +1,144 @@
import { randomUUID } from "node:crypto";
import { db } from "../../db.js";
import { assertOwner } from "../../fs-store.js";
import { decryptSecret, encryptSecret } from "../../secrets.js";
import { registerPlaintext } from "../../secret-value.js";
const MAX_NAME_LENGTH = 128;
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
function nowIso() {
return new Date().toISOString();
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertSecretName(name) {
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
const err = new Error("invalid secret name");
err.statusCode = 400;
throw err;
}
if (name.length > MAX_NAME_LENGTH) {
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
err.statusCode = 400;
throw err;
}
return name;
}
function publicSecret(row) {
return {
id: row.id,
owner: row.owner,
name: row.name,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
/**
* @param {{ owner?: string }} [filters]
*/
export async function listSecrets(filters = {}) {
let q = db("secrets")
.select("id", "owner", "name", "created_at", "updated_at")
.orderBy("owner", "asc")
.orderBy("name", "asc");
if (filters.owner) {
q = q.where("owner", assertOwner(filters.owner));
}
return q;
}
/**
* @param {string} id
*/
export async function getSecretById(id) {
const row = await db("secrets")
.select("id", "owner", "name", "created_at", "updated_at")
.where({ id })
.first();
return row ?? null;
}
/**
* @param {{ owner: string, name: string, value: string }} opts
*/
export async function upsertSecret({ owner, name, value }) {
if (typeof value !== "string" || value.length === 0) {
const err = new Error("value is required");
err.statusCode = 400;
throw err;
}
const ownerName = assertOwner(owner);
const secretName = assertSecretName(name);
registerPlaintext(value);
const { ciphertext, iv, authTag } = encryptSecret(value);
const now = nowIso();
const existing = await db("secrets")
.where({ owner: ownerName, name: secretName })
.first();
if (existing) {
await db("secrets")
.where({ id: existing.id })
.update({
ciphertext,
iv,
auth_tag: authTag,
updated_at: now,
});
return getSecretById(existing.id);
}
const id = randomUUID();
await db("secrets").insert({
id,
owner: ownerName,
name: secretName,
ciphertext,
iv,
auth_tag: authTag,
created_at: now,
updated_at: now,
});
return getSecretById(id);
}
/**
* @param {string} id
* @returns {Promise<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}"`);
}
}
@@ -0,0 +1,190 @@
import { randomUUID } from "node:crypto";
import { db } from "../../db.js";
import { assertOwner } from "../../fs-store.js";
const MAX_NAME_LENGTH = 128;
const MAX_STRING_BYTES = 64 * 1024;
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
function nowIso() {
return new Date().toISOString();
}
function httpError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertVariableName(name) {
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
throw httpError("invalid variable name");
}
if (name.length > MAX_NAME_LENGTH) {
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
}
return name;
}
/**
* @param {unknown} type
* @returns {"string" | "number" | "boolean"}
*/
export function assertVariableType(type) {
if (type !== "string" && type !== "number" && type !== "boolean") {
throw httpError("type must be string, number, or boolean");
}
return type;
}
/**
* @param {"string" | "number" | "boolean"} type
* @param {unknown} value
* @returns {string}
*/
export function encodeVariableValue(type, value) {
if (type === "string") {
if (typeof value !== "string") {
throw httpError("value must be a string");
}
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
}
return value;
}
if (type === "number") {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw httpError("value must be a finite number");
}
return String(value);
}
if (typeof value !== "boolean") {
throw httpError("value must be a boolean");
}
return value ? "true" : "false";
}
/**
* @param {"string" | "number" | "boolean"} type
* @param {string} stored
* @returns {string | number | boolean}
*/
export function decodeVariableValue(type, stored) {
if (type === "string") return stored;
if (type === "number") {
const n = Number(stored);
if (!Number.isFinite(n)) {
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
}
return n;
}
if (stored === "true") return true;
if (stored === "false") return false;
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
}
/**
* @param {Record<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 ?? ""));
}
+298
View File
@@ -0,0 +1,298 @@
import fs from "fs";
import fastify from "fastify";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import jwt from "@fastify/jwt";
import fastifyStatic from "@fastify/static";
import { migrate, db } from "./db.js";
import { log, enableLogPersistence, flushLogs } from "./logger.js";
import * as store from "./store.js";
import { createRegistry } from "./registry.js";
import { addApiAuthGuard, COOKIE } from "./src/api/auth.js";
import authPlugin from "./src/api/auth.js";
import usersPlugin from "./src/api/users.js";
import scriptsPluginFactory from "./src/api/scripts.js";
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";
import { resolveSecretsKeyMaterial } from "./secrets.js";
import {
closeRedis,
createWorkflowQueue,
createWorkflowWorker,
getRedisUrlForLog,
} from "./workflow-queue.js";
import { purgeExpiredTrash } from "./workflow-trash.js";
import { migrateLegacyWorkflowsIfNeeded } from "./workflow-migrate.js";
import {
getConfigGeneration,
startHeartbeatLoop,
subscribeReload,
} from "./control-bus.js";
/**
* @param {{
* role?: string,
* migrate?: boolean,
* serveStaticUi?: boolean,
* }} [opts]
*/
export async function startApp(opts = {}) {
const role = (opts.role || process.env.JFLOW_ROLE || "all").toLowerCase();
const shouldMigrate = opts.migrate ?? role === "all";
const serveStaticUi = opts.serveStaticUi ?? (role === "all" || role === "api");
if (shouldMigrate) {
await migrate();
try {
const purged = await purgeExpiredTrash();
if (purged > 0) {
log.info({ purged }, "purged expired workflow trash");
}
} catch (err) {
log.warn({ err }, "workflow trash purge failed");
}
}
enableLogPersistence();
const jwtSecret =
process.env.JFLOW_JWT_SECRET ??
(process.env.NODE_ENV === "production" ? "" : "jflow-dev-secret");
if (!jwtSecret) {
log.error("JFLOW_JWT_SECRET is required in production");
process.exit(1);
}
try {
resolveSecretsKeyMaterial();
} catch (err) {
log.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
const runApi = role === "all" || role === "api";
const runWorker = role === "all" || role === "worker";
log.info(
{
redis: getRedisUrlForLog(),
role,
generation: getConfigGeneration(),
migrate: shouldMigrate,
},
"starting jerapah-flow",
);
const workflowQueue = createWorkflowQueue();
try {
await workflowQueue.waitUntilReady();
} catch (err) {
log.error({ err, redis: getRedisUrlForLog() }, "failed to connect to Redis");
process.exit(1);
}
const server = fastify({ loggerInstance: log });
await server.register(cookie);
await server.register(jwt, {
secret: jwtSecret,
cookie: {
cookieName: COOKIE,
signed: false,
},
});
await server.register(cors, {
origin: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
credentials: true,
});
server.decorate("authenticate", async function authenticate(req, reply) {
try {
await req.jwtVerify();
} catch {
return reply.code(401).send({ error: "unauthorized" });
}
});
server.decorate("requireAdmin", async function requireAdmin(req, reply) {
if (req.user?.role !== "admin") {
return reply.code(403).send({ error: "forbidden" });
}
});
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.
enableTriggers: runApi,
});
registry.registerWorkflows();
if (runApi) {
registry.registerHttpTriggers();
registry.registerCronTriggers();
registry.registerPruneJob();
}
/** @type {import("bullmq").Worker | null} */
let workflowWorker = null;
if (runWorker) {
workflowWorker = createWorkflowWorker(async (job) => {
const data = /** @type {{ runId?: string, key?: string, depth?: number }} */ (
job.data ?? {}
);
if (typeof data.runId !== "string" || typeof data.key !== "string") {
throw new Error("invalid workflow job payload");
}
const result = await registry.executeQueuedRun({
runId: data.runId,
key: data.key,
depth: data.depth ?? 0,
});
if (result.status === "failed") {
throw new Error(result.error || "workflow failed");
}
return result;
});
}
if (runApi) {
await server.register(
async (api) => {
addApiAuthGuard(api, server);
await api.register(authPlugin);
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);
await api.register(scriptsPluginFactory(registry));
await api.register(workflowsPluginFactory(registry));
await api.register(runsPlugin);
await api.register(dashboardPluginFactory(registry));
},
{ prefix: "/api" },
);
server.post(
"/admin/workflows/reregister",
{ onRequest: [server.authenticate] },
async (_req, reply) => {
registry.reregister();
try {
const { publishReload } = await import("./control-bus.js");
await publishReload({ type: "workflows" });
} catch {
// ignore
}
return reply.send({ message: "Workflows refreshed" });
},
);
server.get(
"/admin/runs",
{ onRequest: [server.authenticate] },
async (req, reply) => {
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
return reply.send(await queryRunsFromRequest(q));
},
);
server.get(
"/admin/runs/:id",
{ onRequest: [server.authenticate] },
async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const run = await store.getRun(id);
if (!run) {
return reply.code(404).send({ error: "run not found" });
}
return reply.send(run);
},
);
if (serveStaticUi && fs.existsSync(WEB_DIST)) {
await server.register(fastifyStatic, {
root: WEB_DIST,
wildcard: false,
});
server.setNotFoundHandler((req, reply) => {
const url = req.raw.url ?? "";
if (
url.startsWith("/api") ||
url.startsWith("/u/") ||
url.startsWith("/admin") ||
url.startsWith("/ops")
) {
return reply.code(404).send({ error: "not found" });
}
return reply.sendFile("index.html");
});
}
}
const stopHeartbeat = startHeartbeatLoop(runApi && runWorker ? "all" : runApi ? "api" : "worker");
const reloadSub = await subscribeReload(async () => {
log.info("received reload signal");
registry.reregister();
});
async function shutdown() {
try {
stopHeartbeat();
await reloadSub.stop();
if (workflowWorker) {
await workflowWorker.close();
}
await workflowQueue.close();
await closeRedis();
await flushLogs();
await db.destroy();
} catch (err) {
log.error({ err }, "shutdown error");
}
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
const port = Number(process.env.PORT ?? 8700);
if (runApi) {
try {
await server.listen({ host: "0.0.0.0", port });
log.info(`Server is running on port ${port}`);
} catch (err) {
log.error({ err }, "failed to start server");
process.exit(1);
}
} else {
log.info("worker-only mode; HTTP server not started");
}
return {
role,
server,
workflowQueue,
workflowWorker,
registry,
shutdown,
};
}
+3 -6
View File
@@ -2,12 +2,9 @@
* Step return contract: `{ output, context?, skipRemaining? }`.
*/
/**
* @param {unknown} value
*/
export function isPlainObject(value) {
return value != null && typeof value === "object" && !Array.isArray(value);
}
import { isPlainObject } from "@jerapah-flow/shared";
export { isPlainObject };
/**
* @param {unknown} value
+143 -19
View File
@@ -64,6 +64,8 @@ function nowIso() {
* trigger: { type: string, detail?: string | null },
* input?: unknown,
* parentRunId?: string | null,
* status?: "queued" | "running",
* workflowRevision?: number | null,
* }} opts
*/
export async function startRun({
@@ -73,9 +75,12 @@ export async function startRun({
trigger,
input = null,
parentRunId = null,
status = "queued",
workflowRevision = null,
}) {
const id = randomUUID();
const started_at = nowIso();
const now = nowIso();
const isQueued = status === "queued";
await db("workflow_runs").insert({
id,
owner,
@@ -83,12 +88,37 @@ export async function startRun({
workflow_name: workflowName ?? null,
trigger_type: trigger.type,
trigger_detail: trigger.detail ?? null,
status: "running",
started_at,
status,
started_at: now,
queued_at: isQueued ? now : null,
input: serialize(input),
parent_run_id: parentRunId ?? null,
workflow_revision: workflowRevision ?? null,
});
return { id, started_at };
return { id, started_at: now, queued_at: isQueued ? now : null };
}
/**
* @param {string} id
* @param {string} jobId
*/
export async function setRunJobId(id, jobId) {
await db("workflow_runs").where({ id }).update({ job_id: jobId });
}
/**
* @param {string} id
*/
export async function markRunRunning(id) {
const started_at = nowIso();
const updated = await db("workflow_runs")
.where({ id })
.whereIn("status", ["queued", "running"])
.update({
status: "running",
started_at,
});
return { updated: Number(updated) > 0, started_at };
}
/**
@@ -192,19 +222,29 @@ 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,
* limit?: number,
* status?: string | string[],
* trigger_type?: string,
* after?: string,
* before?: string,
* }} [filters]
* }} filters
*/
export async function listRuns(filters = {}) {
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
if (filters.owner) q = q.where("owner", filters.owner);
function applyRunFilters(q, filters) {
if (filters.owner) q.where("owner", filters.owner);
if (filters.workflow) {
const key = String(filters.workflow);
if (key.includes("*")) {
@@ -213,19 +253,102 @@ export async function listRuns(filters = {}) {
.replaceAll("%", "\\%")
.replaceAll("_", "\\_")
.replaceAll("*", "%");
q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
} else {
q = q.where("workflow", key);
q.where("workflow", key);
}
}
if (filters.status) q = q.where("status", filters.status);
if (filters.before) q = q.where("started_at", "<", filters.before);
const rows = await q.limit(limit);
return rows.map((row) => ({
if (filters.status) {
if (Array.isArray(filters.status)) {
q.whereIn("status", filters.status);
} else {
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 {
...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);
}
/**
@@ -459,12 +582,13 @@ export async function listUsers() {
/**
* @param {string} id
* @param {{ passwordHash?: string, role?: string }} patch
* @param {{ passwordHash?: string, role?: string, username?: string }} patch
*/
export async function updateUser(id, patch) {
const update = { updated_at: nowIso() };
if (patch.passwordHash) update.password_hash = patch.passwordHash;
if (patch.role) update.role = patch.role;
if (patch.username) update.username = patch.username;
await db("users").where({ id }).update(update);
return getUserById(id);
}
@@ -51,21 +51,21 @@ async function freshUrl(pathname) {
state.body = "<html><body>v1</body></html>";
const first = await run({ url });
assert(first.data.hasChanges === true, "first run should report hasChanges=true");
assert(first.output.hasChanges === true, "first run should report hasChanges=true");
assert(
first.data.httpResponse === "<html><body>v1</body></html>",
first.output.httpResponse === "<html><body>v1</body></html>",
"httpResponse should hold the raw body",
);
assert(typeof first.data.fingerprint === "string", "fingerprint hash should be set");
assert(typeof first.output.fingerprint === "string", "fingerprint hash should be set");
const second = await run({ url });
assert(second.data.hasChanges === false, "unchanged body should report hasChanges=false");
assert(second.output.hasChanges === false, "unchanged body should report hasChanges=false");
state.body = "<html><body>v2 CHANGED</body></html>";
const third = await run({ url });
assert(third.data.hasChanges === true, "changed body should report hasChanges=true");
assert(third.output.hasChanges === true, "changed body should report hasChanges=true");
assert(
third.data.fingerprintPrevious === second.data.fingerprint,
third.output.fingerprintPrevious === second.output.fingerprint,
"fingerprintPrevious should equal the prior hash",
);
}
@@ -77,20 +77,20 @@ async function freshUrl(pathname) {
state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" };
const first = await run({ url, fingerprint: "data.httpResponse.version" });
assert(first.data.hasChanges === true, "json first run should report a change");
assert(first.output.hasChanges === true, "json first run should report a change");
// Change only an unwatched field -> no change.
state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" };
const second = await run({ url, fingerprint: "data.httpResponse.version" });
assert(
second.data.hasChanges === false,
second.output.hasChanges === false,
"changing an unwatched field should not report a change",
);
// Change the watched field -> change.
state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" };
const third = await run({ url, fingerprint: "data.httpResponse.version" });
assert(third.data.hasChanges === true, "changing the watched field should report a change");
assert(third.output.hasChanges === true, "changing the watched field should report a change");
state.contentType = "text/html; charset=utf-8";
}
@@ -106,13 +106,13 @@ async function freshUrl(pathname) {
transform: '"changed=" & $string(data.hasChanges)',
});
assert(
withTransform.data.message === "changed=true",
`transform should populate outputVar, got ${JSON.stringify(withTransform.data.message)}`,
withTransform.output.message === "changed=true",
`transform should populate outputVar, got ${JSON.stringify(withTransform.output.message)}`,
);
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
assert(
rawOutput.data.payload === rawOutput.data.httpResponse,
rawOutput.output.payload === rawOutput.output.httpResponse,
"outputVar without transform should store the raw response",
);
@@ -131,11 +131,11 @@ async function freshUrl(pathname) {
state.body = "<html><body>stable</body></html>";
const first = await run({ url, skipRemaining: true });
assert(first.data.hasChanges === true, "skip test first run should change");
assert(first.output.hasChanges === true, "skip test first run should change");
assert(first.skipRemaining !== true, "changed run must not set skipRemaining");
const second = await run({ url, skipRemaining: true });
assert(second.data.hasChanges === false, "skip test second run should be unchanged");
assert(second.output.hasChanges === false, "skip test second run should be unchanged");
assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt");
// Default (skipRemaining off) never halts, so downstream can still notify.
@@ -12,9 +12,12 @@ import {
getHttpAuthInternal,
} from "../http-auths-store.js";
import {
authLabel,
checkAnyHttpAuth,
checkHttpAuth,
coerceCredentialString,
resolveAuthMechanism,
resolveAuthMechanisms,
resolveCredentialValue,
resolveUnauthorizedSpec,
sendHttpPageOrJson,
@@ -176,11 +179,11 @@ const secret = await upsertSecret({
config: { token: { secret: "does_not_exist_xyz" } },
},
ctx,
);
);
assert(!missingSec, "missing secret fails closed");
}
// --- named profile ---
// --- named profile (by id) ---
const profile = await upsertHttpAuth({
name: "webhook-smoke",
type: "bearer",
@@ -188,9 +191,10 @@ const profile = await upsertHttpAuth({
unauthorized_status: 403,
unauthorized_response: "deny-smoke",
});
assert(typeof profile.id === "string" && profile.id.length > 0, "profile has id");
{
const mech = await resolveAuthMechanism("webhook-smoke");
assert(mech?.label === "webhook-smoke", "named profile");
const mech = await resolveAuthMechanism(profile.id);
assert(mech?.label === "webhook-smoke", "profile by id");
const ok = await checkHttpAuth(
mockReq({ authorization: "Bearer named-token" }),
mech,
@@ -202,9 +206,68 @@ const profile = await upsertHttpAuth({
assert(pageName === "deny-smoke", "profile unauth page");
}
// --- rename keeps id ---
{
const renamed = await upsertHttpAuth({
id: profile.id,
name: "webhook-renamed",
type: "bearer",
config: { token: { keep: true } },
unauthorized_status: 403,
unauthorized_response: "deny-smoke",
});
assert(renamed.id === profile.id, "rename keeps id");
assert(renamed.name === "webhook-renamed", "rename updates name");
const mech = await resolveAuthMechanism(profile.id);
assert(mech?.label === "webhook-renamed", "resolve uses new name label");
const ok = await checkHttpAuth(
mockReq({ authorization: "Bearer named-token" }),
mech,
ctx,
);
assert(ok, "credentials survive rename");
}
// --- multi-auth OR ---
const basicProfile = await upsertHttpAuth({
name: "basic-smoke",
type: "basic",
config: { user: "bob", password: "p@ss" },
});
{
const mechs = await resolveAuthMechanisms([profile.id, basicProfile.id]);
assert(mechs.length === 2, "resolve two mechanisms");
assert(
authLabel([profile.id, basicProfile.id], {
[profile.id]: "webhook-renamed",
[basicProfile.id]: "basic-smoke",
}) === "webhook-renamed|basic-smoke",
"authLabel",
);
const viaBearer = await checkAnyHttpAuth(
mockReq({ authorization: "Bearer named-token" }),
mechs,
ctx,
);
assert(viaBearer, "OR accepts bearer");
const encoded = Buffer.from("bob:p@ss").toString("base64");
const viaBasic = await checkAnyHttpAuth(
mockReq({ authorization: `Basic ${encoded}` }),
mechs,
ctx,
);
assert(viaBasic, "OR accepts basic");
const neither = await checkAnyHttpAuth(
mockReq({ authorization: "Bearer wrong" }),
mechs,
ctx,
);
assert(!neither, "OR rejects when none match");
}
// trigger-level override
{
const mech = await getHttpAuthInternal("webhook-smoke");
const mech = await getHttpAuthInternal(profile.id);
const { status, pageName } = resolveUnauthorizedSpec(
{ unauthorized: { status: 401, response: "deny-smoke" } },
mech,
@@ -226,7 +289,7 @@ await validateWorkflowHttpTriggers({
type: "HTTP",
method: "POST",
path: "/x",
auth: "webhook-smoke",
auth: [profile.id, basicProfile.id],
response: "deny-smoke",
},
],
@@ -235,12 +298,38 @@ await validateWorkflowHttpTriggers({
let threw = false;
try {
await validateWorkflowHttpTriggers({
triggers: [{ type: "HTTP", path: "/x", auth: "no-such-profile" }],
triggers: [{ type: "HTTP", path: "/x", auth: profile.id }],
});
} catch {
threw = true;
}
assert(threw, "unknown auth fails validation");
assert(threw, "non-array auth fails validation");
threw = false;
try {
await validateWorkflowHttpTriggers({
triggers: [{ type: "HTTP", path: "/x", auth: ["webhook-renamed"] }],
});
} catch {
threw = true;
}
assert(threw, "name string fails validation");
threw = false;
try {
await validateWorkflowHttpTriggers({
triggers: [
{
type: "HTTP",
path: "/x",
auth: ["00000000-0000-4000-8000-000000000000"],
},
],
});
} catch {
threw = true;
}
assert(threw, "unknown auth id fails validation");
threw = false;
try {
@@ -280,6 +369,7 @@ assert(threw, "unknown page fails validation");
// cleanup
await deleteHttpAuth(profile.id);
await deleteHttpAuth(basicProfile.id);
await deleteHttpPage(page.id);
await deleteSecret(secret.id);
const leftover = (await listSecrets({ owner })).find(
+20 -1
View File
@@ -1,4 +1,9 @@
import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js";
import {
encodeBinaryForWire,
jsonPreviewReplacer,
reviveBinaryFromWire,
summarizeBinary,
} from "../json-preview.js";
import { serialize, toDisplayValue } from "../store.js";
import { safeSerialize } from "../src/api/dry-run-logger.js";
@@ -53,4 +58,18 @@ if (typed.file.length !== png.length || typed.file.type !== "Buffer") {
throw new Error(`Uint8Array: ${JSON.stringify(typed)}`);
}
const wired = encodeBinaryForWire({ file: png, n: 1n });
if (wired.n !== "1" || wired.file.encoding !== "base64" || typeof wired.file.data !== "string") {
throw new Error(`encodeBinaryForWire: ${JSON.stringify(wired)}`);
}
const revived = reviveBinaryFromWire(JSON.parse(JSON.stringify(wired)));
if (!Buffer.isBuffer(revived.file) || !revived.file.equals(png)) {
throw new Error("reviveBinaryFromWire failed to restore bytes");
}
const previewOnly = { type: "Buffer", length: png.length, preview: "89", truncated: true };
const left = reviveBinaryFromWire(previewOnly);
if (Buffer.isBuffer(left)) {
throw new Error("preview-only summary should not revive");
}
console.log("json-preview-smoke: ok");
+118
View File
@@ -0,0 +1,118 @@
/**
* Smoke: core vs plugin scripts, fork, example install, resolve, run.
*
* Run:
* JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
* JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
* node packages/server/test/plugins-smoke.js
*/
import assert from "node:assert/strict";
import fs from "fs";
import { migrate, db } from "../db.js";
import { getAppVersion, satisfiesRange } from "../app-version.js";
import {
forkCoreScript,
resolveScriptRef,
uninstallPlugin,
listInstalledPlugins,
createBlankPlugin,
} from "../plugin-store.js";
import { installExamplePlugin } from "../plugin-install.js";
import {
runScript,
clearScriptCache,
inspectScriptSource,
} from "../script-sandbox.js";
import { PLUGINS_DIR } from "../paths.js";
import pino from "pino";
const silent = pino({ level: "silent" });
async function main() {
assert.equal(getAppVersion(), "0.1.0");
assert.equal(satisfiesRange("0.1.0", ">=0.1.0 <1.0.0"), true);
assert.equal(satisfiesRange("1.0.0", ">=0.1.0 <1.0.0"), false);
assert.equal(satisfiesRange("0.2.0", ">=0.1.0 <1.0.0"), true);
await migrate();
if (fs.existsSync(PLUGINS_DIR)) {
fs.rmSync(PLUGINS_DIR, { recursive: true, force: true });
}
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
const core = resolveScriptRef("fetch-http.js");
assert.equal(core.kind, "core");
assert.ok(core.filePath && fs.existsSync(core.filePath));
assert.ok(resolveScriptRef("nope.js").error?.includes("not found"));
const example = await installExamplePlugin("get-current-time", {
overwrite: true,
});
assert.equal(example.id, "get-current-time");
assert.equal(example.scriptRef, "plugin/get-current-time");
clearScriptCache();
const pluginResolved = resolveScriptRef("plugin/get-current-time");
assert.equal(pluginResolved.kind, "plugin");
assert.ok(pluginResolved.filePath);
const result = await runScript(
"plugin/get-current-time",
{ data: null, context: {}, config: null },
{ log: silent, workflowName: "smoke", owner: "default" },
);
assert.ok(result?.output?.datetime);
const forked = forkCoreScript("jsonata.js", "jsonata-smoke-fork");
assert.equal(forked.scriptRef, "plugin/jsonata-smoke-fork");
clearScriptCache();
assert.equal(resolveScriptRef("plugin/jsonata-smoke-fork").kind, "plugin");
const blank = createBlankPlugin(
"blank-smoke",
`export default async function main(ctx) { return { output: { ok: true }, context: ctx.context ?? {} }; }`,
);
assert.equal(blank.scriptRef, "plugin/blank-smoke");
clearScriptCache();
const blankRun = await runScript(
"plugin/blank-smoke",
{ data: 1, context: {}, config: null },
{ log: silent, workflowName: "smoke", owner: "default" },
);
assert.equal(blankRun.output.ok, true);
let hit = false;
try {
forkCoreScript("ntfy.js", "ntfy");
} catch (err) {
hit = true;
assert.match(String(err.message), /collides/);
}
assert.equal(hit, true);
const meta = inspectScriptSource(
"fetch-http.js",
fs.readFileSync(core.filePath, "utf8"),
);
assert.ok(meta);
assert.ok(listInstalledPlugins().some((p) => p.id === "get-current-time"));
uninstallPlugin("jsonata-smoke-fork");
uninstallPlugin("blank-smoke");
uninstallPlugin("get-current-time");
console.log("plugins-smoke: ok");
await db.destroy();
}
main().catch(async (err) => {
console.error(err);
try {
await db.destroy();
} catch {
// ignore
}
process.exit(1);
});
+101
View File
@@ -0,0 +1,101 @@
import { migrate, db } from "../db.js";
import {
assertProfileName,
deleteProfile,
encodeProfileConfig,
getProfilePlain,
listProfileUsages,
upsertProfile,
} from "../profiles-store.js";
import { mergeProfileConfig } from "../profile-config.js";
import { parseScriptStep } from "../workflow-parse.js";
await migrate();
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
async function assertThrows(fn, match) {
try {
await fn();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (match && !message.includes(match)) {
throw new Error(`threw "${message}", expected to include "${match}"`);
}
return;
}
throw new Error(`expected to throw (${match ?? "any error"})`);
}
const merged = mergeProfileConfig(
{ url: "https://n.example/ops", fingerprint: true },
{ fingerprint: "comic-rss" },
);
assert(merged.url === "https://n.example/ops", "profile url kept");
assert(merged.fingerprint === "comic-rss", "overlay wins");
const emptyOverlay = mergeProfileConfig({ url: "https://n.example/ops" }, {});
assert(emptyOverlay.url === "https://n.example/ops", "empty overlay");
const emptyWins = mergeProfileConfig({ url: "https://n.example/ops" }, { url: "" });
assert(emptyWins.url === "", "empty string overlay wins");
const profileOnly = parseScriptStep({
profile: "ops-ntfy",
config: { fingerprint: "x" },
});
assert(profileOnly.kind === "script", "profile step kind");
assert(profileOnly.script === "", "script supplied by profile at runtime");
assert(profileOnly.profile === "ops-ntfy", "profile name");
const both = parseScriptStep({
script: "ntfy.js",
profile: "ops-ntfy",
});
assert(both.script === "ntfy.js" && both.profile === "ops-ntfy", "script + profile");
await assertThrows(
() => parseScriptStep({ profile: "ops", set: { expression: "1" } }),
"profile and set",
);
assert(assertProfileName("ops-ntfy") === "ops-ntfy", "valid name");
await assertThrows(() => assertProfileName("ops ntfy"), "invalid profile name");
await assertThrows(() => encodeProfileConfig([]), "config must be an object");
const owner = "default";
const name = `profiles_smoke_${Date.now()}`;
const created = await upsertProfile({
owner,
name,
script: "ntfy.js",
config: { url: "$VAR_ntfy_channel" },
description: "smoke",
});
assert(created.name === name, "created");
assert(created.config.url === "$VAR_ntfy_channel", "config roundtrip");
assert(created.script === "ntfy.js", "script locked on profile");
const fetched = await getProfilePlain(owner, name);
assert(fetched?.id === created.id, "get by owner/name");
const updated = await upsertProfile({
owner,
name,
script: "send-email.js",
config: { service: "Gmail" },
description: "now mail",
});
assert(updated.id === created.id, "upsert same row");
assert(updated.script === "send-email.js", "script may change");
const usages = listProfileUsages(owner, name);
assert(Array.isArray(usages) && usages.length === 0, "unused profile");
await deleteProfile(created.id);
assert((await getProfilePlain(owner, name)) == null, "deleted");
await db.destroy();
console.log("profiles-smoke: ok");
+81
View File
@@ -0,0 +1,81 @@
/**
* Smoke: set dry-run path (evaluateJsonata + envelope), mirrors
* POST /scripts/set/dry-run in src/api/scripts.js.
*/
import assert from "node:assert/strict";
import { evaluateJsonata, SET_STEP_SCRIPT } from "../workflow-parse.js";
import { normalizeStepResult } from "../step-result.js";
import { safeSerialize } from "../src/api/dry-run-logger.js";
assert.equal(SET_STEP_SCRIPT, "set");
async function dryRunSet({ expression, data, context = {} }) {
if (typeof expression !== "string" || !expression.trim()) {
throw new Error("expression is required");
}
const incomingContext =
context != null && typeof context === "object" && !Array.isArray(context)
? context
: {};
const config = { expression };
const ctx = { data: data ?? null, context: incomingContext, config };
const value = await evaluateJsonata(expression, ctx);
const result = normalizeStepResult(
{ output: value, context: incomingContext, skipRemaining: false },
incomingContext,
SET_STEP_SCRIPT,
);
return {
status: "success",
output: safeSerialize(result.output),
context: safeSerialize(result.context),
skipRemaining: result.skipRemaining,
};
}
{
const res = await dryRunSet({
expression: '{"title": data.title, "ok": true}',
data: { title: "Hello" },
context: { runId: "dry" },
});
assert.equal(res.status, "success");
assert.deepEqual(res.output, { title: "Hello", ok: true });
assert.deepEqual(res.context, { runId: "dry" });
assert.equal(res.skipRemaining, false);
}
{
const res = await dryRunSet({
expression: "data.count + 1",
data: { count: 41 },
context: { token: "abc" },
});
assert.equal(res.output, 42);
// Sets never mutate context
assert.deepEqual(res.context, { token: "abc" });
}
{
let hit = false;
try {
await dryRunSet({ expression: " ", data: {} });
} catch (err) {
hit = true;
assert.match(String(err.message), /expression is required/);
}
assert.equal(hit, true);
}
{
let hit = false;
try {
await dryRunSet({ expression: "data.{" , data: {} });
} catch (err) {
hit = true;
assert.ok(err instanceof Error);
}
assert.equal(hit, true);
}
console.log("set-dry-run-smoke: ok");
@@ -0,0 +1,163 @@
/**
* Smoke: workflow revisions, SHA dedup, trash, restore, purge.
*
* Run: pnpm --dir packages/server test:workflow-history
*/
import assert from "node:assert/strict";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { db, migrate } from "../db.js";
import { WORKFLOWS_DIR } from "../paths.js";
import * as fsStore from "../fs-store.js";
import {
workflowContentSha,
workflowIdFromFile,
newWorkflowFilename,
} from "../workflow-normalize.js";
import {
recordRevision,
listRevisions,
getLatestRevision,
deleteRevisionHistory,
ensureInitialRevision,
} from "../workflow-history.js";
import {
moveWorkflowToTrash,
listTrash,
restoreFromTrash,
purgeTrashItem,
TRASH_WORKFLOWS_DIR,
} from "../workflow-trash.js";
import { collectWorkflowWarnings } from "../workflow-validate-warnings.js";
const owner = "__workflow_history_smoke__";
const file = newWorkflowFilename();
const workflowId = workflowIdFromFile(file);
const ownerDir = path.join(WORKFLOWS_DIR, owner);
const trashPath = path.join(TRASH_WORKFLOWS_DIR, owner, file);
function cleanup() {
if (fs.existsSync(trashPath)) fs.unlinkSync(trashPath);
if (fs.existsSync(ownerDir)) fs.rmSync(ownerDir, { recursive: true, force: true });
}
cleanup();
await migrate();
const yamlV1 = `name: smoke test
scripts:
- plugin/get-current-time
triggers:
- type: HTTP
method: POST
path: /smoke
`;
fsStore.writeWorkflowYaml(owner, file, yamlV1);
fsStore.writeRegisters(owner, [file]);
assert.equal(workflowContentSha(yamlV1), workflowContentSha(`${yamlV1}\n\n`));
const rev1 = await recordRevision({
workflowId,
owner,
file,
content: yamlV1,
reason: "create",
force: true,
});
assert.equal(rev1.skipped, false);
assert.equal(rev1.revision, 1);
const revDup = await recordRevision({
workflowId,
owner,
file,
content: `${yamlV1}\n\n`,
reason: "save",
});
assert.equal(revDup.skipped, true, "normalized SHA should dedupe blank lines");
const yamlV2 = yamlV1.replace("smoke test", "smoke test v2");
const rev2 = await recordRevision({
workflowId,
owner,
file,
content: yamlV2,
reason: "save",
});
assert.equal(rev2.revision, 2);
assert.equal((await listRevisions(workflowId)).length, 2);
const yamlDisabled = `${yamlV2}\nenabled: false\n`;
assert.equal(
workflowContentSha(yamlV2),
workflowContentSha(yamlDisabled),
"enabled-only change should not change content SHA",
);
const revDisable = await recordRevision({
workflowId,
owner,
file,
content: yamlDisabled,
reason: "disable",
});
assert.equal(revDisable.skipped, true, "enable/disable should not create a revision");
assert.equal((await listRevisions(workflowId)).length, 2);
const { startRun, getRun } = await import("../store.js");
const latest = await getLatestRevision(workflowId);
assert.equal(latest?.revision, 2);
const run = await startRun({
owner,
workflow: `${owner}/${file}`,
workflowName: "smoke test v2",
trigger: { type: "manual", detail: "smoke" },
input: null,
workflowRevision: latest?.revision ?? null,
});
const loaded = await getRun(run.id);
assert.equal(loaded.workflow_revision, 2);
await db("workflow_runs").where({ id: run.id }).del();
await deleteRevisionHistory(workflowId);
assert.equal(await getLatestRevision(workflowId), null);
const seeded = await ensureInitialRevision({ owner, file });
assert.ok(seeded);
assert.equal(seeded.revision, 1);
assert.equal(seeded.seeded, true);
const again = await ensureInitialRevision({ owner, file });
assert.equal(again?.revision, 1);
assert.equal(again?.seeded, false);
const warnings = collectWorkflowWarnings(
`name: bad\nscripts:\n - unknown-script-xyz\n`,
);
assert.ok(warnings.warnings.some((w) => w.code === "unknown_script"));
const trashed = await moveWorkflowToTrash({
workflowId,
owner,
file,
name: "smoke test v2",
});
assert.ok(trashed.id);
assert.equal(fsStore.readWorkflowYaml(owner, file), null);
assert.ok(fs.existsSync(trashPath));
const restored = await restoreFromTrash(trashed.id);
assert.equal(restored.file, file);
assert.ok(fsStore.readWorkflowYaml(owner, file));
await moveWorkflowToTrash({ workflowId, owner, file, name: "smoke test v2" });
const trashAgain = (await listTrash()).find((t) => t.file === file);
assert.ok(trashAgain);
await purgeTrashItem(trashAgain.id);
assert.ok(!(await listTrash()).some((t) => t.file === file));
await deleteRevisionHistory(workflowId);
cleanup();
console.log("workflow-history-smoke: ok");
await db.destroy();
+1 -190
View File
@@ -1,190 +1 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { assertOwner } from "./fs-store.js";
const MAX_NAME_LENGTH = 128;
const MAX_STRING_BYTES = 64 * 1024;
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
function nowIso() {
return new Date().toISOString();
}
function httpError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertVariableName(name) {
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
throw httpError("invalid variable name");
}
if (name.length > MAX_NAME_LENGTH) {
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
}
return name;
}
/**
* @param {unknown} type
* @returns {"string" | "number" | "boolean"}
*/
export function assertVariableType(type) {
if (type !== "string" && type !== "number" && type !== "boolean") {
throw httpError("type must be string, number, or boolean");
}
return type;
}
/**
* @param {"string" | "number" | "boolean"} type
* @param {unknown} value
* @returns {string}
*/
export function encodeVariableValue(type, value) {
if (type === "string") {
if (typeof value !== "string") {
throw httpError("value must be a string");
}
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
}
return value;
}
if (type === "number") {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw httpError("value must be a finite number");
}
return String(value);
}
if (typeof value !== "boolean") {
throw httpError("value must be a boolean");
}
return value ? "true" : "false";
}
/**
* @param {"string" | "number" | "boolean"} type
* @param {string} stored
* @returns {string | number | boolean}
*/
export function decodeVariableValue(type, stored) {
if (type === "string") return stored;
if (type === "number") {
const n = Number(stored);
if (!Number.isFinite(n)) {
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
}
return n;
}
if (stored === "true") return true;
if (stored === "false") return false;
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
}
/**
* @param {Record<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 ?? ""));
}
export * from "./src/stores/variables-store.js";
+10
View File
@@ -0,0 +1,10 @@
process.env.JFLOW_ROLE = "worker";
import { startApp } from "./start-app.js";
// BullMQ worker only. Schema migrations are owned by control.
await startApp({
role: "worker",
migrate: false,
serveStaticUi: false,
});
+134
View File
@@ -0,0 +1,134 @@
import fs from "fs";
import os from "os";
import path from "path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import { DATA_DIR, PLUGINS_DIR, WORKFLOWS_DIR } from "./paths.js";
import { getAppVersion } from "./app-version.js";
import * as fsStore from "./fs-store.js";
import { listInstalledPlugins } from "./plugin-store.js";
const execFileAsync = promisify(execFile);
/**
* @param {string} dir
* @param {string} zipPath
*/
async function zipDirectory(dir, zipPath) {
await execFileAsync("zip", ["-r", zipPath, "."], { cwd: dir });
}
/**
* @param {string} zipPath
* @param {string} destDir
*/
async function unzipArchive(zipPath, destDir) {
fs.mkdirSync(destDir, { recursive: true });
await execFileAsync("unzip", ["-o", zipPath, "-d", destDir]);
}
/**
* Copy directory recursively.
* @param {string} src
* @param {string} dest
*/
function copyDir(src, dest) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
if (entry.isDirectory()) copyDir(from, to);
else fs.copyFileSync(from, to);
}
}
/**
* Build a backup zip buffer (workflows + installed plugins + manifest).
*/
export async function createWorkflowBackupBuffer() {
const staging = path.join(DATA_DIR, `.backup-staging-${randomUUID()}`);
fs.mkdirSync(staging, { recursive: true });
const zipPath = path.join(DATA_DIR, `.backup-${randomUUID()}.zip`);
try {
const wfDest = path.join(staging, "workflows");
copyDir(WORKFLOWS_DIR, wfDest);
const pluginsDest = path.join(staging, "plugins");
copyDir(PLUGINS_DIR, pluginsDest);
const manifest = {
version: getAppVersion(),
created_at: new Date().toISOString(),
plugins: listInstalledPlugins().map((p) => p.id),
owners: fsStore.listOwners(),
};
fs.writeFileSync(
path.join(staging, "manifest.json"),
JSON.stringify(manifest, null, 2),
"utf8",
);
await zipDirectory(staging, zipPath);
return fs.readFileSync(zipPath);
} finally {
fs.rmSync(staging, { recursive: true, force: true });
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
}
}
/**
* @param {Buffer} zipBuffer
* @param {{ mode?: "merge" | "replace" }} [opts]
*/
export async function restoreWorkflowBackup(zipBuffer, opts = {}) {
const mode = opts.mode === "replace" ? "replace" : "merge";
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), "jflow-restore-"));
const zipPath = path.join(extractDir, "backup.zip");
fs.writeFileSync(zipPath, zipBuffer);
/** @type {string[]} */
const warnings = [];
try {
const contentDir = path.join(extractDir, "content");
await unzipArchive(zipPath, contentDir);
const manifestPath = path.join(contentDir, "manifest.json");
if (fs.existsSync(manifestPath)) {
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
for (const pluginId of manifest.plugins ?? []) {
const dir = path.join(PLUGINS_DIR, pluginId);
if (!fs.existsSync(dir)) {
warnings.push(`Plugin "${pluginId}" from backup is not installed`);
}
}
} catch {
warnings.push("Could not read backup manifest.json");
}
}
const wfSrc = path.join(contentDir, "workflows");
if (fs.existsSync(wfSrc)) {
if (mode === "replace" && fs.existsSync(WORKFLOWS_DIR)) {
fs.rmSync(WORKFLOWS_DIR, { recursive: true, force: true });
}
copyDir(wfSrc, WORKFLOWS_DIR);
}
const pluginsSrc = path.join(contentDir, "plugins");
if (fs.existsSync(pluginsSrc)) {
if (mode === "replace" && fs.existsSync(PLUGINS_DIR)) {
fs.rmSync(PLUGINS_DIR, { recursive: true, force: true });
}
copyDir(pluginsSrc, PLUGINS_DIR);
}
return { ok: true, mode, warnings };
} finally {
fs.rmSync(extractDir, { recursive: true, force: true });
}
}
+20 -27
View File
@@ -1,36 +1,26 @@
import yaml from "yaml";
import {
ensureWorkflowFilename,
suggestCopyFilename,
} from "@jerapah-flow/shared";
import {
newWorkflowFilename,
workflowFileStem,
} from "./workflow-normalize.js";
export function ensureWorkflowFilename(file) {
const trimmed = String(file ?? "").trim();
if (!trimmed) return "";
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
}
export function workflowFileStem(file) {
return String(file).replace(/\.ya?ml$/i, "");
}
export { ensureWorkflowFilename, suggestCopyFilename };
/**
* Next unused copy filename: `track.yaml` → `track-copy.yaml`,
* `track-copy.yaml` → `track-copy-2.yaml`.
* @param {string} file
* UUID-based duplicate filename (default for new duplicates).
* @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";
export function suggestDuplicateFilename(existingFiles = []) {
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);
let file = newWorkflowFilename();
while (existing.has(file)) {
file = newWorkflowFilename();
}
return file;
}
export function nextCopyName(name) {
@@ -55,7 +45,10 @@ export function httpPathCopySuffix(sourceFile, destFile) {
function suffixHttpPath(path, suffix) {
const trimmed = String(path).replace(/\/+$/, "");
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
const safe = String(suffix).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "copy";
const safe =
String(suffix)
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "") || "copy";
return `${withSlash}-${safe}`;
}
+79
View File
@@ -0,0 +1,79 @@
import fs from "fs";
import path from "path";
import yaml from "yaml";
import { EXAMPLE_WORKFLOWS_DIR } from "./paths.js";
/**
* @param {string} id
* @returns {string | null} safe basename without extension, or null if invalid
*/
export function assertExampleWorkflowId(id) {
if (typeof id !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(id)) {
return null;
}
return id;
}
/**
* Absolute path to an example YAML, or null if missing/unsafe.
* @param {string} id
*/
export function exampleWorkflowPath(id) {
const safe = assertExampleWorkflowId(id);
if (!safe) return null;
const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, `${safe}.yaml`);
const resolved = path.resolve(filePath);
if (
resolved !== EXAMPLE_WORKFLOWS_DIR &&
!resolved.startsWith(EXAMPLE_WORKFLOWS_DIR + path.sep)
) {
return null;
}
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
return null;
}
return resolved;
}
/**
* @returns {{ id: string, name: string, description: string }[]}
*/
export function listExampleWorkflows() {
if (!fs.existsSync(EXAMPLE_WORKFLOWS_DIR)) return [];
return fs
.readdirSync(EXAMPLE_WORKFLOWS_DIR)
.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
.sort()
.map((f) => {
const id = f.replace(/\.ya?ml$/i, "");
const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, f);
let name = id;
let description = "";
try {
const parsed = yaml.parse(fs.readFileSync(filePath, "utf8")) ?? {};
if (typeof parsed.name === "string" && parsed.name.trim()) {
name = parsed.name.trim();
}
if (parsed.description != null) {
description = String(parsed.description).trim();
}
} catch {
// keep id as name
}
return { id, name, description };
});
}
/**
* @param {string} id
* @returns {{ id: string, content: string } | null}
*/
export function readExampleWorkflow(id) {
const filePath = exampleWorkflowPath(id);
if (!filePath) return null;
const safe = assertExampleWorkflowId(id);
return {
id: /** @type {string} */ (safe),
content: fs.readFileSync(filePath, "utf8"),
};
}
+173
View File
@@ -0,0 +1,173 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import * as fsStore from "./fs-store.js";
import { workflowContentSha, workflowIdFromFile } from "./workflow-normalize.js";
const MAX_REVISIONS = 50;
function nowIso() {
return new Date().toISOString();
}
/**
* @param {string | null} meta
*/
function parseMeta(meta) {
if (!meta) return null;
try {
return JSON.parse(meta);
} catch {
return null;
}
}
/**
* @param {string} workflowId
*/
export async function getLatestRevision(workflowId) {
const row = await db("workflow_revisions")
.where({ workflow_id: workflowId })
.orderBy("revision", "desc")
.first();
if (!row) return null;
return {
...row,
meta: parseMeta(row.meta),
};
}
/**
* @param {string} workflowId
*/
export async function listRevisions(workflowId) {
const rows = await db("workflow_revisions")
.where({ workflow_id: workflowId })
.orderBy("revision", "desc");
return rows.map((row) => ({
id: row.id,
workflow_id: row.workflow_id,
owner: row.owner,
file: row.file,
revision: row.revision,
content_sha: row.content_sha,
reason: row.reason ?? null,
meta: parseMeta(row.meta),
created_at: row.created_at,
}));
}
/**
* @param {string} workflowId
* @param {number} revision
*/
export async function getRevision(workflowId, revision) {
const row = await db("workflow_revisions")
.where({ workflow_id: workflowId, revision })
.first();
if (!row) return null;
return {
...row,
meta: parseMeta(row.meta),
};
}
/**
* Ensure a workflow has at least revision #1 (seed from disk when history is empty).
* @param {{ owner: string, file: string }} opts
* @returns {Promise<{ revision: number, id: string, content_sha: string, created_at: string, seeded: boolean } | null>}
*/
export async function ensureInitialRevision(opts) {
const workflowId = workflowIdFromFile(opts.file);
const latest = await getLatestRevision(workflowId);
if (latest) {
return {
revision: latest.revision,
id: latest.id,
content_sha: latest.content_sha,
created_at: latest.created_at,
seeded: false,
};
}
const content = fsStore.readWorkflowYaml(opts.owner, opts.file);
if (content == null) return null;
const recorded = await recordRevision({
workflowId,
owner: opts.owner,
file: opts.file,
content,
reason: "seed",
force: true,
});
if (recorded.revision == null || recorded.id == null) return null;
const row = await getLatestRevision(workflowId);
if (!row) return null;
return {
revision: row.revision,
id: row.id,
content_sha: row.content_sha,
created_at: row.created_at,
seeded: true,
};
}
/**
* Insert a revision when content changed (SHA dedup skips identical saves).
* @param {{
* workflowId: string,
* owner: string,
* file: string,
* content: string,
* reason?: string | null,
* meta?: Record<string, unknown> | null,
* force?: boolean,
* }} opts
* @returns {Promise<{ skipped: boolean, revision: number | null, id: string | null }>}
*/
export async function recordRevision(opts) {
const sha = workflowContentSha(opts.content);
const latest = await getLatestRevision(opts.workflowId);
if (!opts.force && latest && latest.content_sha === sha) {
return { skipped: true, revision: latest.revision, id: latest.id };
}
const nextRevision = latest ? latest.revision + 1 : 1;
const id = randomUUID();
const created_at = nowIso();
await db("workflow_revisions").insert({
id,
workflow_id: opts.workflowId,
owner: opts.owner,
file: opts.file,
revision: nextRevision,
content_sha: sha,
content: opts.content,
reason: opts.reason ?? null,
meta: opts.meta ? JSON.stringify(opts.meta) : null,
created_at,
});
const overflow = await db("workflow_revisions")
.where({ workflow_id: opts.workflowId })
.orderBy("revision", "desc")
.offset(MAX_REVISIONS)
.pluck("id");
if (overflow.length) {
await db("workflow_revisions").whereIn("id", overflow).del();
}
return { skipped: false, revision: nextRevision, id };
}
/**
* @param {string} workflowId
*/
export async function deleteRevisionHistory(workflowId) {
return db("workflow_revisions").where({ workflow_id: workflowId }).del();
}
+159
View File
@@ -0,0 +1,159 @@
import { HTTP_METHODS } from "@jerapah-flow/shared";
import {
checkAnyHttpAuth,
resolveAuthMechanisms,
resolveUnauthorizedSpec,
sendHttpPageOrJson,
sendSuccessPage,
} from "./http-trigger-auth.js";
/**
* Rebuild METHOD+path → workflow map from loaded workflows.
*
* @param {Map<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 -18
View File
@@ -1,7 +1,11 @@
/**
* Validate HTTP trigger auth / response fields on workflow save.
*/
import { assertAuthType, getHttpAuthByName } from "./http-auths-store.js";
import {
assertAuthId,
assertAuthType,
getHttpAuthById,
} from "./http-auths-store.js";
import { getHttpPageByName, assertHttpResponsePage } from "./http-pages-store.js";
import { authLabel } from "./http-trigger-auth.js";
@@ -24,51 +28,80 @@ function assertCredentialFieldShape(field, label) {
}
/**
* @param {unknown} auth
* @param {unknown} entry
* @param {string} path
*/
async function validateAuthField(auth) {
if (auth == null || auth === false) return;
if (typeof auth === "string") {
const named = await getHttpAuthByName(auth);
async function validateAuthEntry(entry, path) {
if (typeof entry === "string") {
try {
assertAuthId(entry);
} catch {
const err = new Error(`${path} must be an auth profile UUID`);
err.statusCode = 400;
throw err;
}
const named = await getHttpAuthById(entry);
if (!named) {
const err = new Error(`unknown auth profile "${auth}"`);
const err = new Error(`unknown auth profile id "${entry}"`);
err.statusCode = 400;
throw err;
}
return;
}
if (typeof auth === "object" && !Array.isArray(auth)) {
const obj = /** @type {Record<string, unknown>} */ (auth);
if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) {
await validateAuthField(obj.name);
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
const obj = /** @type {Record<string, unknown>} */ (entry);
if (typeof obj.id === "string" && obj.id.length > 0 && !obj.type) {
await validateAuthEntry(obj.id, path);
return;
}
const type = assertAuthType(obj.type);
if (type === "bearer") {
assertCredentialFieldShape(obj.token, "auth.token");
assertCredentialFieldShape(obj.token, `${path}.token`);
} else if (type === "basic") {
assertCredentialFieldShape(obj.user, "auth.user");
assertCredentialFieldShape(obj.user, `${path}.user`);
if (obj.password != null && obj.password !== "") {
assertCredentialFieldShape(obj.password, "auth.password");
assertCredentialFieldShape(obj.password, `${path}.password`);
}
} else if (type === "header") {
if (typeof obj.header !== "string" || obj.header.length === 0) {
const err = new Error("auth.header must be a non-empty string");
const err = new Error(`${path}.header must be a non-empty string`);
err.statusCode = 400;
throw err;
}
assertCredentialFieldShape(obj.value, "auth.value");
assertCredentialFieldShape(obj.value, `${path}.value`);
}
return;
}
const err = new Error("auth must be a profile name or an auth object");
const err = new Error(
`${path} must be an auth profile UUID or an inline auth object`,
);
err.statusCode = 400;
throw err;
}
/**
* auth is an array of auth profile UUIDs and/or inline auth objects (OR).
* null / false / [] = no auth.
* @param {unknown} auth
*/
async function validateAuthField(auth) {
if (auth == null || auth === false) return;
if (!Array.isArray(auth)) {
const err = new Error(
"auth must be an array of auth profile UUIDs and/or inline auth objects",
);
err.statusCode = 400;
throw err;
}
for (let i = 0; i < auth.length; i++) {
await validateAuthEntry(auth[i], `auth[${i}]`);
}
}
/**
* @param {unknown} pageName
* @param {string} label
+51
View File
@@ -0,0 +1,51 @@
import fs from "fs";
import path from "path";
import { LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR } from "./paths.js";
import { log } from "./logger.js";
/**
* Recursively copy a directory.
* @param {string} src
* @param {string} dest
*/
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
if (entry.isDirectory()) copyDir(from, to);
else fs.copyFileSync(from, to);
}
}
/**
* True when WORKFLOWS_DIR has no owner subdirectories.
* @param {string} dir
*/
function isEmptyWorkflowsDir(dir) {
if (!fs.existsSync(dir)) return true;
const entries = fs.readdirSync(dir, { withFileTypes: true });
return !entries.some((e) => e.isDirectory());
}
/**
* One-shot: copy packages/server/workflows → data/workflows when the new
* store is empty and the legacy tree still exists.
* Does not copy from examples/workflows.
*/
export function migrateLegacyWorkflowsIfNeeded() {
if (!isEmptyWorkflowsDir(WORKFLOWS_DIR)) return false;
if (!fs.existsSync(LEGACY_WORKFLOWS_DIR)) return false;
const legacyEntries = fs.readdirSync(LEGACY_WORKFLOWS_DIR, {
withFileTypes: true,
});
if (!legacyEntries.some((e) => e.isDirectory())) return false;
fs.mkdirSync(WORKFLOWS_DIR, { recursive: true });
copyDir(LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR);
log.info(
{ from: LEGACY_WORKFLOWS_DIR, to: WORKFLOWS_DIR },
"migrated legacy workflows into instance store",
);
return true;
}
+77
View File
@@ -0,0 +1,77 @@
import { createHash, randomUUID } from "node:crypto";
import yaml from "yaml";
/**
* Stable key order for canonical JSON (dedup ignores YAML formatting).
* @param {unknown} value
*/
export function canonicalize(value) {
if (value == null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(canonicalize);
const out = {};
for (const key of Object.keys(value).sort()) {
out[key] = canonicalize(value[key]);
}
return out;
}
/**
* Parse YAML to a JS object (null when empty/invalid for callers that handle errors).
* @param {string} content
*/
export function parseWorkflowObject(content) {
if (typeof content !== "string" || !content.trim()) return null;
return yaml.parse(content) ?? null;
}
/**
* Drop `enabled` before hashing so enable/disable does not create revision points.
* @param {unknown} parsed
*/
function stripEnabledForHash(parsed) {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
const { enabled, ...rest } = parsed;
return rest;
}
/**
* SHA256 of normalized workflow content (YAML → object → canonical JSON).
* @param {string} content
*/
export function workflowContentSha(content) {
const parsed = stripEnabledForHash(parseWorkflowObject(content));
const canonical = canonicalize(parsed);
const json = JSON.stringify(canonical);
return createHash("sha256").update(json, "utf8").digest("hex");
}
/**
* @param {string} file
*/
export function workflowIdFromFile(file) {
return String(file).replace(/\.ya?ml$/i, "");
}
/** @param {string} file */
export function workflowFileStem(file) {
return workflowIdFromFile(file);
}
/**
* New on-disk workflow filename: `{uuid}.yaml`.
* @param {string} [uuid]
*/
export function newWorkflowFilename(uuid) {
const id = uuid ?? randomUUID();
return `${id}.yaml`;
}
const UUID_FILE_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.ya?ml$/i;
/**
* @param {string} file
*/
export function isUuidWorkflowFile(file) {
return UUID_FILE_RE.test(String(file));
}
+44 -22
View File
@@ -1,4 +1,5 @@
import jsonata from "jsonata";
export { namespacedPath } from "@jerapah-flow/shared";
export const SET_STEP_SCRIPT = "set";
@@ -6,19 +7,23 @@ export const SET_STEP_SCRIPT = "set";
* @typedef {{ alias: string, from: string }} NeedEdge
* @typedef {{
* kind: "script",
* script: string,
* script: string,
* profile: string | null,
* config: unknown | null,
* expression?: undefined,
* name: string | null,
* id: string | null,
* needsKind: "none" | "list" | "map",
* needs: NeedEdge[],
* when: string | null,
* }} ParsedScriptStep
* needsKind: "none" | "list" | "map",
* needs: NeedEdge[],
* when: string | null,
* }} ParsedScriptStep
* @typedef {{
* kind: "set",
* script: typeof SET_STEP_SCRIPT,
* config: { expression: string },
* script: typeof SET_STEP_SCRIPT,
* profile: null,
* config: { expression: string },
* expression: string,
* name: string | null,
* id: string | null,
* needsKind: "none" | "list" | "map",
* needs: NeedEdge[],
@@ -66,16 +71,6 @@ export async function evaluateJsonata(source, ctx) {
return await result;
}
/**
* Resolve an HTTP path under the owner namespace: /notify -> /u/alice/notify
* @param {string} owner
* @param {string} triggerPath
*/
export function namespacedPath(owner, triggerPath) {
const cleaned = String(triggerPath).replace(/^\/+/, "");
return `/u/${owner}/${cleaned}`;
}
/**
* @param {unknown} step
* @returns {ParsedStep}
@@ -85,7 +80,9 @@ export function parseScriptStep(step) {
return {
kind: "script",
script: step,
profile: null,
config: null,
name: null,
id: null,
needsKind: "none",
needs: [],
@@ -97,25 +94,35 @@ export function parseScriptStep(step) {
}
const hasScript = step.script != null && step.script !== "";
const hasProfile = step.profile != null && step.profile !== "";
const hasSet = step.set != null;
if (hasScript && hasSet) {
throw new Error("Step cannot have both script and set");
}
if (hasProfile && hasSet) {
throw new Error("Step cannot have both profile and set");
}
if (hasSet) {
return parseSetStep(step);
}
if (hasScript) {
if (typeof step.script !== "string") {
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
}
if (hasProfile && typeof step.profile !== "string") {
throw new Error(`Invalid profile: ${JSON.stringify(step.profile)}`);
}
if (hasScript && typeof step.script !== "string") {
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
}
if (hasScript || hasProfile) {
const { needsKind, needs } = parseNeeds(step.needs);
return {
kind: "script",
script: step.script,
script: hasScript ? step.script : "",
profile: hasProfile ? step.profile : null,
config: step.config ?? null,
name: parseOptionalName(step.name),
id: parseOptionalId(step.id),
needsKind,
needs,
@@ -265,8 +272,10 @@ function parseSetStep(step) {
return {
kind: "set",
script: SET_STEP_SCRIPT,
profile: null,
config: { expression },
expression,
name: parseOptionalName(step.name),
id: parseOptionalId(step.id),
needsKind,
needs,
@@ -287,6 +296,19 @@ function parseWhen(when) {
return when;
}
/**
* @param {unknown} name
* @returns {string | null}
*/
function parseOptionalName(name) {
if (name == null || name === "") return null;
if (typeof name !== "string") {
throw new Error(`Invalid step name: ${JSON.stringify(name)}`);
}
const trimmed = name.trim();
return trimmed || null;
}
/**
* @param {unknown} id
* @returns {string | null}
+130
View File
@@ -0,0 +1,130 @@
import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";
import { log } from "./logger.js";
const DEFAULT_REDIS_URL = "redis://127.0.0.1:6379";
const DEFAULT_QUEUE_NAME = "jerapah-workflows";
const DEFAULT_CONCURRENCY = 5;
/** @type {IORedis | null} */
let sharedConnection = null;
export function getRedisUrl() {
return process.env.REDIS_URL || DEFAULT_REDIS_URL;
}
/**
* Optional Redis AUTH password. Applied even when REDIS_URL has no embedded credentials.
* @returns {string | undefined}
*/
export function getRedisPassword() {
const pass = process.env.REDIS_PASS;
if (typeof pass !== "string" || pass.length === 0) return undefined;
return pass;
}
/** Redact credentials for logs. */
export function getRedisUrlForLog() {
try {
const url = new URL(getRedisUrl());
if (url.password || getRedisPassword()) url.password = "***";
if (url.username) url.username = url.username ? "***" : "";
return url.toString();
} catch {
return getRedisUrl();
}
}
export function getQueueName() {
return process.env.JFLOW_QUEUE_NAME || DEFAULT_QUEUE_NAME;
}
export function getWorkerConcurrency() {
const raw = Number(process.env.JFLOW_WORKER_CONCURRENCY ?? DEFAULT_CONCURRENCY);
if (!Number.isFinite(raw) || raw < 1) return DEFAULT_CONCURRENCY;
return Math.floor(raw);
}
/**
* BullMQ requires maxRetriesPerRequest: null for blocking commands.
* @returns {IORedis}
*/
export function getSharedConnection() {
if (sharedConnection) return sharedConnection;
/** @type {import("ioredis").RedisOptions} */
const options = {
maxRetriesPerRequest: null,
enableReadyCheck: true,
};
const password = getRedisPassword();
if (password) options.password = password;
sharedConnection = new IORedis(getRedisUrl(), options);
sharedConnection.on("error", (err) => {
log.error({ err }, "redis connection error");
});
return sharedConnection;
}
/**
* @returns {Queue}
*/
export function createWorkflowQueue() {
return new Queue(getQueueName(), {
connection: getSharedConnection(),
defaultJobOptions: {
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
attempts: 1,
},
});
}
/**
* @param {(job: import("bullmq").Job) => Promise<unknown>} processor
* @returns {Worker}
*/
export function createWorkflowWorker(processor) {
const concurrency = getWorkerConcurrency();
const worker = new Worker(getQueueName(), processor, {
connection: getSharedConnection(),
concurrency,
});
worker.on("error", (err) => {
log.error({ err }, "workflow worker error");
});
log.info({ concurrency, queue: getQueueName() }, "workflow worker started");
return worker;
}
/**
* @param {Queue} queue
* @param {{
* runId: string,
* key: string,
* depth?: number,
* }} data
*/
export async function enqueueWorkflowJob(queue, data) {
const job = await queue.add(
"run",
{
runId: data.runId,
key: data.key,
depth: data.depth ?? 0,
},
{
jobId: data.runId,
},
);
return job;
}
/**
* @param {IORedis | null} [connection]
*/
export async function closeRedis(connection = sharedConnection) {
if (!connection) return;
if (connection === sharedConnection) sharedConnection = null;
await connection.quit().catch(() => connection.disconnect());
}
+183
View File
@@ -0,0 +1,183 @@
import fs from "fs";
import path from "path";
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { deleteRevisionHistory } from "./workflow-history.js";
import { DATA_DIR, WORKFLOWS_DIR } from "./paths.js";
import * as fsStore from "./fs-store.js";
export const TRASH_WORKFLOWS_DIR = path.join(DATA_DIR, "trash", "workflows");
export const TRASH_RETENTION_DAYS = 7;
function nowIso() {
return new Date().toISOString();
}
function trashFilePath(owner, file) {
return path.join(TRASH_WORKFLOWS_DIR, owner, file);
}
/**
* @param {string} deletedAtIso
*/
function trashAgeMs(deletedAtIso) {
return Date.now() - Date.parse(deletedAtIso);
}
/**
* @param {string} deletedAtIso
*/
export function trashDaysRemaining(deletedAtIso) {
const purgeAt =
Date.parse(deletedAtIso) + TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000;
return Math.max(0, Math.ceil((purgeAt - Date.now()) / (24 * 60 * 60 * 1000)));
}
function rowToItem(row) {
return {
id: row.id,
workflow_id: row.workflow_id,
owner: row.owner,
file: row.file,
name: row.name ?? null,
deleted_at: row.deleted_at,
trash_path: row.trash_path,
age_ms: trashAgeMs(row.deleted_at),
days_until_purge: trashDaysRemaining(row.deleted_at),
};
}
export async function listTrash() {
const rows = await db("workflow_trash").orderBy("deleted_at", "desc");
return rows.map(rowToItem);
}
export async function getTrashItem(id) {
const row = await db("workflow_trash").where({ id }).first();
return row ? rowToItem(row) : null;
}
export async function isInTrash(owner, file) {
const row = await db("workflow_trash").where({ owner, file }).first();
return Boolean(row);
}
/**
* Soft-delete: move YAML to trash dir, unregister, keep revision history.
* @param {{
* workflowId: string,
* owner: string,
* file: string,
* name?: string | null,
* }} opts
*/
export async function moveWorkflowToTrash(opts) {
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
if (!fs.existsSync(sourcePath)) {
const err = new Error("workflow not found");
err.statusCode = 404;
throw err;
}
const trashPath = trashFilePath(opts.owner, opts.file);
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
fs.renameSync(sourcePath, trashPath);
const registered = fsStore.readRegisters(opts.owner).filter((f) => f !== opts.file);
fsStore.writeRegisters(opts.owner, registered);
const id = randomUUID();
const deleted_at = nowIso();
await db("workflow_trash").insert({
id,
workflow_id: opts.workflowId,
owner: opts.owner,
file: opts.file,
name: opts.name ?? null,
deleted_at,
trash_path: trashPath,
});
return rowToItem(await db("workflow_trash").where({ id }).first());
}
/**
* Restore workflow from trash.
* @param {string} trashId
*/
export async function restoreFromTrash(trashId) {
const row = await db("workflow_trash").where({ id: trashId }).first();
if (!row) {
const err = new Error("trash item not found");
err.statusCode = 404;
throw err;
}
const destPath = path.join(WORKFLOWS_DIR, row.owner, row.file);
if (fs.existsSync(destPath)) {
const err = new Error("workflow file already exists");
err.statusCode = 409;
throw err;
}
if (!fs.existsSync(row.trash_path)) {
const err = new Error("trash file missing on disk");
err.statusCode = 410;
throw err;
}
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.renameSync(row.trash_path, destPath);
const registered = fsStore.readRegisters(row.owner);
if (!registered.includes(row.file)) {
registered.push(row.file);
fsStore.writeRegisters(row.owner, registered);
}
await db("workflow_trash").where({ id: trashId }).del();
return {
owner: row.owner,
file: row.file,
workflow_id: row.workflow_id,
content: fs.readFileSync(destPath, "utf8"),
};
}
/**
* Permanently delete a trash item and its revision history.
* @param {string} trashId
*/
export async function purgeTrashItem(trashId) {
const row = await db("workflow_trash").where({ id: trashId }).first();
if (!row) {
const err = new Error("trash item not found");
err.statusCode = 404;
throw err;
}
if (fs.existsSync(row.trash_path)) {
fs.unlinkSync(row.trash_path);
}
await deleteRevisionHistory(row.workflow_id);
await db("workflow_trash").where({ id: trashId }).del();
return { ok: true };
}
/**
* Auto-purge trash older than retention window.
* @returns {Promise<number>}
*/
export async function purgeExpiredTrash() {
const cutoff = new Date(
Date.now() - TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000,
).toISOString();
const rows = await db("workflow_trash")
.where("deleted_at", "<", cutoff)
.select("id");
for (const row of rows) {
await purgeTrashItem(row.id);
}
return rows.length;
}
@@ -0,0 +1,137 @@
import yaml from "yaml";
import { parseScriptStep } from "./workflow-parse.js";
import { resolveScriptRef } from "./plugin-store.js";
import { parseWorkflowObject } from "./workflow-normalize.js";
const SECRET_KEY_RE =
/(?:password|passwd|secret|token|api[_-]?key|auth(?:orization)?|credential|private[_-]?key)/i;
const BEARER_RE = /Bearer\s+[A-Za-z0-9._~+/=-]{8,}/;
/**
* Walk parsed YAML for suspicious secret-like string values.
* @param {unknown} value
* @param {string} pathKey
* @param {Array<{ code: string, message: string, path?: string }>} warnings
*/
function scanSecrets(value, pathKey, warnings) {
if (value == null) return;
if (typeof value === "string") {
if (BEARER_RE.test(value)) {
warnings.push({
code: "plaintext_secret",
message: "Possible Bearer token in workflow YAML",
path: pathKey,
});
}
return;
}
if (Array.isArray(value)) {
value.forEach((item, i) => scanSecrets(item, `${pathKey}[${i}]`, warnings));
return;
}
if (typeof value === "object") {
for (const [k, v] of Object.entries(value)) {
const childPath = pathKey ? `${pathKey}.${k}` : k;
if (typeof v === "string" && v.trim() && SECRET_KEY_RE.test(k)) {
warnings.push({
code: "plaintext_secret",
message: `Possible secret in field "${k}"`,
path: childPath,
});
}
scanSecrets(v, childPath, warnings);
}
}
}
/**
* Collect non-blocking save warnings for workflow YAML.
* @param {string} content
* @returns {{ warnings: Array<{ code: string, message: string, path?: string }>, parsed: unknown | null, parseError: string | null }}
*/
export function collectWorkflowWarnings(content) {
/** @type {Array<{ code: string, message: string, path?: string }>} */
const warnings = [];
let parsed = null;
let parseError = null;
try {
parsed = parseWorkflowObject(content);
if (parsed == null) {
warnings.push({
code: "invalid_yaml",
message: "Workflow YAML is empty or not an object",
});
} else if (typeof parsed !== "object" || Array.isArray(parsed)) {
warnings.push({
code: "invalid_yaml",
message: "Workflow YAML must be a mapping/object",
});
parsed = null;
}
} catch (err) {
parseError = err instanceof Error ? err.message : String(err);
warnings.push({
code: "invalid_yaml",
message: `Invalid YAML: ${parseError}`,
});
}
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
scanSecrets(parsed, "", warnings);
for (const [i, raw] of (parsed.scripts ?? []).entries()) {
try {
const step = parseScriptStep(raw);
if (step.kind === "set") continue;
if (step.profile && !step.script) continue;
const resolved = resolveScriptRef(step.script);
if (resolved.error) {
warnings.push({
code: "unknown_script",
message: resolved.error,
path: `scripts[${i}]`,
});
}
} catch (err) {
warnings.push({
code: "invalid_script_step",
message: err instanceof Error ? err.message : String(err),
path: `scripts[${i}]`,
});
}
}
}
return { warnings, parsed, parseError };
}
/**
* Strict validation used when saveAnyway is false.
* @param {unknown} parsed
*/
export function assertStrictWorkflow(parsed) {
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
const err = new Error("workflow yaml must be an object");
err.statusCode = 400;
throw err;
}
return parsed;
}
/**
* Parse for PATCH/enable toggles (must be valid YAML document).
* @param {string} content
*/
export function parseWorkflowDocument(content) {
const doc = yaml.parseDocument(content);
if (doc.errors?.length) {
const err = new Error(doc.errors[0]?.message ?? "invalid yaml");
err.statusCode = 400;
throw err;
}
const parsed = doc.toJSON();
assertStrictWorkflow(parsed);
return { doc, parsed };
}
@@ -18,7 +18,7 @@ scripts:
- script: fetch-binary.js
- script: ntfy.js
config:
url: https://ntfy.sh/jerapah-flow
url: $VAR_ntfy_channel
triggers:
- type: HTTP
method: POST
@@ -2,7 +2,7 @@ name: cron example
description: |
this workflow triggered by cron
scripts:
- script: get-current-time.js
- script: plugin/get-current-time
- set:
expression: '{"message": context.datetime}'
- script: ntfy.js
@@ -1,12 +0,0 @@
name: fetch-devto
scripts:
- script: fetch-html.js
config:
url: https://dev.to/t/productivity/top/week
selector: "#substories h2"
outputVar: titles
jsonata: "$[].text"
triggers:
- type: HTTP
method: POST
path: /fetch-dev-to
@@ -1,50 +0,0 @@
name: Jadwal Solat Jakarta ntfy
scripts:
- id: fetch
script: fetch-http.js
config:
url: https://kemenag.go.id/api/prayer-times/1301
method: GET
headers:
Content-Type: application/json
Accept: application/json
- id: transform
script: jsonata.js
config:
expression: |-
{
"title": data.httpResponse.data.date,
"message": "Imsak:" & data.httpResponse.data.imsak & "\n" &
"Subuh:" & data.httpResponse.data.subuh & "\n" &
"Dzuhur:" & data.httpResponse.data.dzuhur & "\n" &
"Ashar:" & data.httpResponse.data.ashar & "\n" &
"Maghrib:" & data.httpResponse.data.maghrib & "\n" &
"Isya:" & data.httpResponse.data.isya
}
needs:
- fetch
- id: ntfy
script: ntfy.js
config:
url: $VAR_ntfy_channel
fingerprint: true
needs:
- transform
- id: slack
script: ntfy.js
config:
url: $VAR_ntfy_channel2
fingerprint: fingerprint:ntfy2
needs:
- transform
- script: slack-webhook.js
config:
webhookUrlSecret: slack_deploy_webhook
fingerprint: true
fingerprintMaxAge: 1h
text: $INPUT_message
needs:
- transform
triggers:
- type: cron
schedule: 0 5 * * *
@@ -1,20 +1,6 @@
scripts:
- time-to-ntfy-example.yaml
- test.yaml
- cron-example.yaml
- fetch-devto.yaml
- comic-monkeyuser-to-ntfy.yaml
- time-and-comic-to-ntfy.yaml
- rss-selfhst-to-ntfy.yaml
- send-gmail.yaml
- test-send-gmail.yaml
- track.yaml
- rss-devto-to-ntfy.yaml
- test-minio.yaml
- dev-joplin-sync.yaml
- dev-joplin-daily.yaml
- dev-joplin-get-note.yaml
- web-dave.yaml
- jadwal-sholat-jakart.yaml
- detect-example-changes.yaml
- test-sftp.yaml
- time-to-ntfy-example.yaml
- comic-monkeyuser-to-ntfy.yaml
- afb272d4-b217-49ac-8c8b-755a6d8dac4a.yaml
@@ -1,27 +0,0 @@
name: RSS - selfh.st first item to ntfy (copy)
scripts:
- script: fetch-rss-feed.js
config:
url: https://selfh.st/rss/
outputVar: item
jsonata: items[0]
- script: fingerprint.js
config:
key: selfhst-latest
jsonata: "data.item.guid ? data.item.guid : data.item.link"
- script: jsonata.js
config:
expression: |
{
"title": data.item.title,
"message": data.item.contentSnippet & "\n" & data.item.link,
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
}
- script: ntfy.js
config:
url: https://n.0dev.web.id/system
triggers:
- type: HTTP
method: POST
path: /selfhst-rss-rss-devto-to-ntfy
enabled: false
@@ -1,26 +0,0 @@
name: RSS - selfh.st first item to ntfy
scripts:
- script: fetch-rss-feed.js
config:
url: https://selfh.st/rss/
outputVar: item
jsonata: items[0]
- script: fingerprint.js
config:
key: selfhst-latest
jsonata: "data.item.guid ? data.item.guid : data.item.link"
- script: jsonata.js
config:
expression: |
{
"title": data.item.title,
"message": data.item.contentSnippet & "\n" & data.item.link,
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
}
- script: ntfy.js
config:
url: https://n.0dev.web.id/system
triggers:
- type: HTTP
method: POST
path: /selfhst-rss
@@ -1,39 +0,0 @@
name: send-gmail
description: |
Send email via Gmail SMTP. Configure user/from and the named secret,
then call from other workflows with trigger-workflow.js (name: send-gmail).
Input (data):
to optional recipient(s); defaults to your Gmail below
subject required
text plain body (aliases: body, message)
html optional HTML body
from, cc, bcc, replyTo, priority, headers optional
scripts:
- script: jsonata.js
config:
expression: |
{
"to": $exists(data.to) ? data.to : "nasyarobby@gmail.com",
"from": data.from,
"subject": data.subject,
"text": $exists(data.text) ? data.text : ($exists(data.body) ? data.body : data.message),
"html": data.html,
"cc": data.cc,
"bcc": data.bcc,
"replyTo": data.replyTo,
"priority": data.priority,
"headers": data.headers
}
- script: send-email.js
config:
service: Gmail
user: elevent16th@gmail.com
from: elevent16th@gmail.com
passwordSecret: gmail_app_password
triggers:
- type: workflow
- type: HTTP
method: POST
path: /send-gmail
auth: basic-auth
@@ -1,20 +0,0 @@
name: Test MinIO
scripts:
- script: fetch-binary.js
config:
outputVar: file
url: https://nsrb:error403@dav.0dev.web.id/ntfy/IyM9784UdG4S
filename: test.png
- script: s3.js
config:
action: write
endpoint: http://localhost:9000
bucket: default
forcePathStyle: true
accessKeyIdSecret: minio_user
secretAccessKeySecret: minio_pass
key: file.png
triggers:
- type: HTTP
method: POST
path: /new
@@ -1,18 +0,0 @@
name: test-send-gmail
description: |
Kick send-gmail with sample data. Edit the payload below, then Run
(or POST /u/default/test-send-gmail).
scripts:
- script: trigger-workflow.js
config:
name: send-gmail
expression: |
{
"subject": "JerapahFlow test",
"text": "Hello from test-send-gmail",
"html": "<p>Hello from <strong>test-send-gmail</strong></p>"
}
triggers:
- type: HTTP
method: POST
path: /test-send-gmail
@@ -1,22 +0,0 @@
name: SFTP Test
scripts:
- script: remote-fs.js
config:
protocol: sftp
action: list
host: localhost
path: /Users/nsrb/
username: nsrb
port: 22
password: JKLjkl
- script: jsonata.js
config:
expression: '{"message": $join(data.entries.name, "\n")}'
- script: ntfy.js
config:
url: $VAR_ntfy_channel
fingerprint: "false"
triggers:
- type: HTTP
method: POST
path: /new
@@ -1,12 +0,0 @@
name: jsonata
scripts:
- get-current-time.js
- script: jsonata.js
config:
expression: '{"message": data.datetime & " " & data.processId}'
- ntfy.js
triggers:
- type: HTTP
method: POST
path: /mt
@@ -1,37 +0,0 @@
name: time and comic to ntfy
description: >
Fan-in from two scripts (current time + monkeyuser comic), then send to ntfy.
scripts:
- id: time
script: get-current-time.js
- 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

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