72 Commits
Author SHA1 Message Date
nsrb dd98421b08 fix(server): refine error handling and PM2 connection logic
Deploy to Raspberry Pi / deploy (push) Canceled after 1m16s
- Reduced the maximum restarts for PM2 processes from 20 to 8 and added a restart delay of 2000ms for better stability.
- Improved error handling during server startup and PM2 connection, ensuring clearer logging and graceful exits on failure.
- Introduced a utility function to filter out PM2 metadata from environment variables, preventing conflicts during process management.
2026-08-23 08:45:26 +07:00
nsrb 69312c9080 feat(pm2): update PM2 configuration and introduce new start script
Deploy to Raspberry Pi / deploy (push) Canceled after 37s
- Updated PM2 version to 6.0.14 in package.json and pnpm-lock.yaml for improved stability.
- Changed interpreter for PM2 processes to use `process.execPath` for consistency.
- Added a new `start:pm2` script in package.json to streamline PM2 process management.
- Updated README to reflect changes in PM2 usage and instructions for starting the application.
2026-08-23 07:25:25 +07:00
nsrb c8697532f9 feat(ecosystem): add exec_mode to PM2 configuration for control and web server
Deploy to Raspberry Pi / deploy (push) Failing after 10s
- Introduced `exec_mode: "fork"` for both the control plane and web server processes to enhance performance and resource management.
2026-08-22 23:11:48 +07:00
nsrb be8122f9e5 feat(ecosystem): update PM2 configuration for control plane and web server
Deploy to Raspberry Pi / deploy (push) Successful in 52s
- Renamed the control process to `jflow-control` and updated the script path to `control.js`.
- Introduced a new `jflow-web` process for the production UI, serving on port 8500.
- Enhanced environment variable handling for both processes, ensuring proper port assignments.
- Updated README to reflect new process architecture and usage instructions for starting the application.
2026-08-22 22:32:04 +07:00
nsrb 0a8463d8f4 Merge branch 'main' into dev
Deploy to Raspberry Pi / deploy (push) Successful in 4m3s
2026-08-22 21:48:51 +07:00
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
nsrb 721f15e900 Merge branch 'dev' of https://git.home.0dev.web.id/nsrb/jerapah-flow into dev
Deploy to Raspberry Pi / deploy (push) Successful in 1m44s
2026-08-21 10:34:34 +07:00
nsrb 877ea9e3f8 Merge branch 'main' into dev 2026-08-21 09:31:01 +07:00
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 93b51dcaaa Update .gitea/workflows/deploy.yaml
Deploy to Raspberry Pi / deploy (push) Successful in 1m43s
2026-08-20 18:56:34 -04:00
nsrb eb7c318c13 Update .gitea/workflows/deploy.yaml
Deploy to Raspberry Pi / deploy (push) Failing after 4s
2026-08-20 18:40:36 -04:00
nsrb 864427b45c feat(workflows): add auto-disable feature for workflows on consecutive failures
Deploy to Raspberry Pi / deploy (push) Canceled after 0s
- Introduced `disableOnConsecutiveFailures` option in workflow triggers to automatically disable workflows after reaching a specified failure threshold.
- Updated related functions to handle the new feature, including persistence of the disabled state and reloading of registries.
- Enhanced UI components to support the new option, allowing users to toggle the auto-disable feature in the workflow configuration.
2026-08-21 05:37:24 +07:00
nsrb 14a08c700c chore(gitignore): add .gitea/workflows/ to ignore list 2026-08-21 05:23:43 +07:00
nsrb 46aa8ca327 add deploy.yml for branch dev pushes
Deploy to Raspberry Pi / deploy (push) Canceled after 0s
2026-08-20 18:09:14 -04:00
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
nsrbandGitHub 16b1bc5668 Merge pull request #13 from nasyarobby/cursor/ftp-sftp-script-3038
Cursor/ftp sftp script 3038
2026-08-19 15:43:21 +07:00
nsrb 453a6f2969 feat(workflows): add SFTP test workflow and update registers
- Introduced a new SFTP test workflow in `test-sftp.yaml` to list remote files using SFTP protocol.
- Updated `registers.yaml` to include the new SFTP test workflow.
2026-08-19 15:42:52 +07:00
nsrbandGitHub a2ee0b633d Merge pull request #11 from nasyarobby/cursor/ftp-sftp-script-3038
feat(scripts): add remote-fs script for SFTP and FTP access
2026-08-19 15:42:15 +07:00
nsrb 3850c529e4 Merge branch 'cursor/trigger-failure-workflow-3038' into cursor/ftp-sftp-script-3038 2026-08-19 15:26:20 +07:00
nsrb 5363dd50c3 Merge branch 'main' of https://github.com/nasyarobby/jerapah-flow into cursor/ftp-sftp-script-3038 2026-08-19 15:20:58 +07:00
nsrbandGitHub c33b6db089 Merge pull request #10 from nasyarobby/cursor/s3-compatible-script-3038
feat(scripts): add s3 script for S3-compatible object storage
2026-08-19 15:16:51 +07:00
nsrbandCursor dc0dfadd44 feat(workflows): add MinIO S3 test workflow
Exercise fetch-binary plus s3 write against a local HTTP MinIO endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:13:25 +07:00
nsrbandCursor f41e9dc23a fix(server): summarize Buffer values in stored and API JSON
Stop dumping every byte as a number in event I/O, workflow test results, and script dry-runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:13:12 +07:00
nsrbandCursor 2845971670 fix(secrets): allow storing values shorter than 8 characters
Keep the 8-character floor only for log redaction so short MinIO keys can be saved.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:13:03 +07:00
nsrbandCursor 14f6331fce chore(server): change default HTTP port from 9000 to 8700
Avoid colliding with MinIO's default S3 API port.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:12:54 +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
Cursor Agentandnsrb 64f605b135 chore: merge origin/main into cursor/ftp-sftp-script-3038
Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-18 23:59:56 +00:00
nsrb 04c0d4b801 feat(failures): implement consecutive failure tracking and dashboard integration
- Added a new function to list consecutive failure streaks for workflows, allowing tracking of workflows that have failed multiple times in a row.
- Integrated the consecutive failure data into the dashboard, displaying streaks and counts for better visibility of workflow health.
- Created a dedicated FailuresPage to present detailed information about consecutive failures, enhancing user experience and monitoring capabilities.
- Updated API endpoints and hooks to support the new failure tracking features, ensuring seamless data retrieval and display.

This enhancement improves the ability to monitor and respond to workflow failures, contributing to overall system reliability.
2026-08-18 11:14:46 +07:00
nsrb c668947c90 refactor(triggers): rename triggerWorkflow to onFailureWorkflow for clarity
- Updated all instances of `triggerWorkflow` to `onFailureWorkflow` across the codebase to improve clarity and consistency in naming.
- Adjusted related logic in workflow configurations and UI components to reflect the new naming convention.
- Enhanced error messages and documentation to align with the updated terminology.

This change aims to provide a clearer understanding of the workflow's failure handling mechanism.
2026-08-18 11:05:39 +07:00
Cursor Agentandnsrb 7474bb5c6d feat(scripts): add remote-fs script for SFTP and FTP access
Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-17 00:22:30 +00:00
220 changed files with 22340 additions and 5526 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Deploy to Raspberry Pi
on:
push:
branches: [dev]
jobs:
deploy:
runs-on: home # must match a label on your act_runner
steps:
- name: Deploy
run: |
set -euo pipefail
# act_runner uses bash --noprofile --norc; load nvm/pnpm explicitly
export NVM_DIR="/home/nsrb/.nvm"
export PNPM_HOME="/home/nsrb/.local/share/pnpm"
# shellcheck disable=SC1091
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
export PATH="$PNPM_HOME/bin:$PATH"
APP_DIR=/home/nsrb/apps/jerapah-flow
cd "$APP_DIR"
git fetch origin dev
git checkout dev
git pull --ff-only origin dev
pnpm install --frozen-lockfile
pnpm build
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
+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`.
+116 -12
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
```
- API: http://localhost:9000
- UI (dev): http://localhost:5173
- UI (dev): http://localhost:8500
- API: http://localhost:8700
The first account created becomes **admin**. JerapahFlow is a **single-machine, single-user** automation app: the Users page is not linked in the nav (still available at `/users` if typed). New workflows, secrets, variables, and profiles default to the internal namespace `local`.
Reset or create the admin login from the host:
```bash
pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
```
### 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
```
The first account created becomes **admin**. Later accounts are created from Users.
## Script contract
@@ -50,13 +100,32 @@ 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 :9000) |
| `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 :9000 |
| `pnpm start` | Monolith: API + worker + built UI (serves `dist` on :8700) |
| `pnpm start:control` | Control plane only (migrates, manages PM2 children) |
| `pnpm start:web` | Production UI on :8500 (`dist` + proxies to control/HTTP) |
| `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 +133,53 @@ 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_UI_PORT` | `8500` | Production UI server (`web-server.js`) port. |
| `JFLOW_HTTP_PORT` | `8700` | HTTP API port (PM2 children / UI proxy target). |
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
| `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
| `PORT` | `9000` | HTTP port |
| `NODE_ENV` | — | Set `production` for secure cookies |
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Browser origin (Vite in dev, UI server in prod) |
| `PORT` | `8700` | HTTP API port (alias; prefer `JFLOW_HTTP_PORT` under control) |
| `NODE_ENV` | — | Set `production` for secure cookies (unless overridden) |
| `COOKIE_SECURE` | (from `NODE_ENV`) | `true`/`false` — force Secure cookie flag. Use `false` for plain HTTP LAN access (`http://192.168.x.x`) |
Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { runId, status: "queued" }` immediately; poll `GET /api/runs/:id` for progress (`queued` → `running` → `success` \| `failed`). Cron remains an in-process producer that enqueues jobs on each tick.
## Production
Control-plane topology (same ports as `pnpm dev:pm2`):
| Process | Port | Role |
|---|---|---|
| `jflow-web` | **8500** | Built UI + proxies `/api` → :8700, `/ops` + `/api/auth` → :8600 |
| `jflow-control` | **8600** | Migrations, Ops API, starts/stops PM2 HTTP + workers |
| `jflow-http` | **8700** | API + cron enqueue (managed by control) |
| `jflow-worker` | — | BullMQ workers (managed by control) |
```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)
# Put secrets in .env (JFLOW_JWT_SECRET, JFLOW_SECRETS_KEY, REDIS_URL, …)
# Use in-tree PM2 6.x (same module control.js requires). A global `pm2` 7.x
# against a 6.x daemon pegs CPU even when ls shows only 2 fork instances.
pnpm start:pm2
# UI: http://localhost:8500
# If you already mixed versions: pnpm pm2 -- kill && pnpm start:pm2
```
The server serves `packages/web/dist` when that folder exists.
Or without the ecosystem file:
```bash
NODE_ENV=production pnpm start:control # :8600 + PM2 children
NODE_ENV=production pnpm start:web # :8500
```
Monolith (no Ops stop/scale): `pnpm build && pnpm start` serves the UI from the API process on :8700. Optional `JFLOW_SERVE_UI=1` on `start:api` does the same when you run HTTP alone — do **not** use that under control-plane mode (stopping HTTP would take down the UI).
+75
View File
@@ -0,0 +1,75 @@
/**
* Production PM2 ecosystem (control plane + UI).
* Starts always-on processes only; HTTP (:8700) and workers are owned by
* control.js via PM2 (same as `pnpm dev:pm2`).
*
* Use `pnpm start:pm2` (in-tree PM2 6.x). Do not use a global `pm2` 7.x —
* a CLI/daemon version mismatch pegs CPU even with instances: 1.
*
* Prerequisites: `pnpm build` (packages/web/dist), Redis, .env secrets.
*/
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;
const env = {
NODE_ENV: "production",
...loadEnv(path.join(root, ".env")),
};
module.exports = {
apps: [
{
name: "jflow-control",
cwd: root,
script: "packages/server/control.js",
interpreter: process.execPath,
instances: 1,
exec_mode: "fork",
autorestart: true,
max_restarts: 8,
restart_delay: 2000,
env: {
...env,
JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600",
},
},
{
name: "jflow-web",
cwd: root,
script: "packages/server/web-server.js",
interpreter: process.execPath,
instances: 1,
exec_mode: "fork",
autorestart: true,
max_restarts: 20,
env: {
...env,
JFLOW_UI_PORT: env.JFLOW_UI_PORT ?? "8500",
JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600",
JFLOW_HTTP_PORT: env.JFLOW_HTTP_PORT ?? "8700",
},
},
],
};
@@ -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
+12 -12
View File
@@ -1,25 +1,25 @@
{
"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",
"start:web": "pnpm --filter @jerapah-flow/server start:web",
"pm2": "node scripts/pm2.mjs",
"start:pm2": "node scripts/pm2.mjs start ecosystem.config.cjs",
"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;
}
}
+498
View File
@@ -0,0 +1,498 @@
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);
}
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",
);
}
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);
try {
await server.listen({ host: "0.0.0.0", port });
log.info(`Control is running on port ${port}`);
} catch (err) {
log.error({ err }, "failed to start control");
process.exit(1);
}
try {
await connectPm2();
} catch (err) {
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
process.exit(1);
}
try {
await applyDesiredState();
} catch (err) {
log.error({ err }, "failed to apply desired state");
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",
},
},
);
+15 -22
View File
@@ -1,41 +1,34 @@
### Manual trigger (default owner)
POST http://localhost:9000/u/default/mt
POST http://localhost:8700/u/default/mt
Content-Type: application/json
0
###
POST http://localhost:9000/u/default/time-to-ntfy
POST http://localhost:8700/u/default/time-to-ntfy
Content-Type: application/json
{}
### Auth bootstrap
GET http://localhost:9000/api/auth/bootstrap
### Login
POST http://localhost:9000/api/auth/login
###
GET http://localhost:8700/api/auth/bootstrap
###
POST http://localhost:8700/api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "changeme1"
}
### Dashboard
GET http://localhost:9000/api/dashboard
### Runs
GET http://localhost:9000/api/runs?owner=default&limit=20
### Reregister
POST http://localhost:9000/api/workflows/reregister
###
GET http://localhost:8700/api/dashboard
###
GET http://localhost:8700/api/runs?owner=default&limit=20
###
POST http://localhost:8700/api/workflows/reregister
Content-Type: application/json
{}
### Run workflow manually
POST http://localhost:9000/api/workflows/default/manual-trigger.yaml/run
###
POST http://localhost:8700/api/workflows/default/manual-trigger.yaml/run
Content-Type: application/json
{}
{}
+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("|");
}
/**
+126
View File
@@ -0,0 +1,126 @@
const BUFFER_PREVIEW_BYTES = 16;
/**
* @param {unknown} value
*/
export function isBinary(value) {
return (
Buffer.isBuffer(value) ||
ArrayBuffer.isView(value) ||
value instanceof ArrayBuffer
);
}
/**
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
*/
export function toBuffer(value) {
if (Buffer.isBuffer(value)) return value;
if (value instanceof ArrayBuffer) return Buffer.from(value);
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
}
/**
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
*/
export function summarizeBinary(value) {
const buf = toBuffer(value);
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
return {
type: "Buffer",
length: buf.length,
preview: buf.subarray(0, take).toString("hex"),
truncated: buf.length > take,
};
}
/**
* 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.
* @param {string} key
* @param {unknown} value
*/
export function jsonPreviewReplacer(key, value) {
const raw = this[key];
if (isBinary(raw)) return summarizeBinary(raw);
return value;
}
+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");
}
+97
View File
@@ -0,0 +1,97 @@
const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"host",
"content-length",
]);
const REPLY_SKIP = new Set([
"connection",
"keep-alive",
"transfer-encoding",
"content-encoding",
"content-length",
]);
/**
* Control origin used when the UI or HTTP process proxies to control.
* @returns {string}
*/
export function controlOrigin() {
const explicit = process.env.JFLOW_CONTROL_URL?.trim();
if (explicit) return explicit.replace(/\/$/, "");
const port = Number(process.env.JFLOW_CONTROL_PORT ?? 8600);
return `http://127.0.0.1:${port}`;
}
/**
* HTTP API origin (workflow triggers + REST).
* @returns {string}
*/
export function httpOrigin() {
const explicit = process.env.JFLOW_HTTP_URL?.trim();
if (explicit) return explicit.replace(/\/$/, "");
const port = Number(process.env.JFLOW_HTTP_PORT ?? process.env.PORT ?? 8700);
return `http://127.0.0.1:${port}`;
}
/**
* Forward the incoming request to `origin`, preserving path + query.
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
* @param {string} origin
* @param {{ unreachableMessage?: string }} [opts]
*/
export async function proxyToOrigin(req, reply, origin, opts = {}) {
const target = `${origin.replace(/\/$/, "")}${req.raw.url ?? "/"}`;
const headers = {};
for (const [key, value] of Object.entries(req.headers)) {
if (value == null || HOP_BY_HOP.has(key.toLowerCase())) continue;
headers[key] = Array.isArray(value) ? value.join(", ") : String(value);
}
const method = req.method.toUpperCase();
const hasBody = method !== "GET" && method !== "HEAD";
let body;
if (hasBody) {
if (Buffer.isBuffer(req.body)) body = req.body;
else if (typeof req.body === "string") body = req.body;
else if (req.body != null) {
body = JSON.stringify(req.body);
if (!headers["content-type"]) headers["content-type"] = "application/json";
}
}
let res;
try {
res = await fetch(target, { method, headers, body });
} catch (err) {
const message = opts.unreachableMessage ?? "upstream unreachable";
req.log.warn({ err, target }, `proxy: ${message}`);
return reply.code(502).send({ error: message });
}
reply.code(res.status);
res.headers.forEach((value, key) => {
if (REPLY_SKIP.has(key.toLowerCase())) return;
reply.header(key, value);
});
return reply.send(Buffer.from(await res.arrayBuffer()));
}
/**
* Forward `/ops/*` to the control plane (same-origin UI in production).
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
*/
export async function proxyOpsToControl(req, reply) {
return proxyToOrigin(req, reply, controlOrigin(), {
unreachableMessage: "control plane unreachable",
});
}
+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 };
}
+19 -2
View File
@@ -1,15 +1,26 @@
{
"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",
"start:web": "node web-server.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",
@@ -17,9 +28,12 @@
"@fastify/jwt": "^9.1.0",
"@fastify/static": "^8.2.0",
"axios": "^1.19.0",
"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",
@@ -28,6 +42,9 @@
"nodemailer": "^9.0.5",
"pino": "^10.3.1",
"pino-roll": "^4.0.0",
"pm2": "6.0.14",
"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 });
}
}
+360
View File
@@ -0,0 +1,360 @@
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 };
}
/**
* PM2 injects these into process.env of a managed app. Spreading them into
* `pm2.start({ env })` overwrites `name` / `pm_exec_path` so God restarts
* jflow-control instead of launching http/worker (EADDRINUSE :8600 loop).
*/
const PM2_META_KEYS = new Set([
"name",
"namespace",
"exec_mode",
"exec_interpreter",
"instances",
"instance_var",
"node_app_instance",
"unique_id",
"status",
"username",
"windowsHide",
"merge_logs",
"vizion",
"vizion_running",
"autostart",
"autorestart",
"automation",
"km_link",
]);
/**
* @param {NodeJS.ProcessEnv} env
* @returns {NodeJS.ProcessEnv}
*/
export function withoutPm2Meta(env) {
/** @type {NodeJS.ProcessEnv} */
const out = {};
for (const [key, val] of Object.entries(env)) {
if (val == null) continue;
if (PM2_META_KEYS.has(key)) continue;
if (key.startsWith("pm_") || key.startsWith("axm_") || key.startsWith("PM2_")) {
continue;
}
out[key] = val;
}
return out;
}
/**
* Shared env for child processes.
* @param {{ generation: number }} opts
*/
export function childEnv(opts) {
return {
...withoutPm2Meta(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";
+242 -204
View File
@@ -23,18 +23,22 @@ 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, recordRevision } from "./workflow-history.js";
import { workflowIdFromFile } from "./workflow-normalize.js";
import { publishReload } from "./control-bus.js";
/**
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
@@ -42,22 +46,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 +69,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 +182,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 +213,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 +449,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 +483,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,
@@ -638,6 +555,45 @@ export function createRegistry(server) {
}
}
/**
* Persist `enabled: false` for a workflow and reload registries across processes.
* @param {string} owner
* @param {string} file
* @param {string} key
*/
async function disableWorkflowForConsecutiveFailures(owner, file, key) {
const content = fsStore.readWorkflowYaml(owner, file);
if (content == null) {
throw new Error(`workflow file missing for ${key}`);
}
const doc = yaml.parseDocument(content);
if (doc.errors?.length) {
throw new Error(doc.errors[0]?.message ?? "invalid yaml");
}
const parsed = doc.toJSON();
if (parsed?.enabled === false) {
log.debug({ workflow: key }, "workflow already disabled");
return;
}
doc.set("enabled", false);
const nextContent = String(doc);
fsStore.writeWorkflowYaml(owner, file, nextContent);
await recordRevision({
workflowId: workflowIdFromFile(file),
owner,
file,
content: nextContent,
reason: "disable-on-consecutive-failures",
});
reregister();
try {
await publishReload({ type: "workflows" });
} catch {
// Redis may be briefly unavailable; local reload already applied.
}
log.warn({ workflow: key }, "disabled workflow after consecutive failures");
}
/**
* @param {{
* key: string,
@@ -674,6 +630,17 @@ export function createRegistry(server) {
return;
}
if (failureConfig.disableOnConsecutiveFailures) {
const entry = workflows.get(opts.key);
if (entry) {
await disableWorkflowForConsecutiveFailures(entry.owner, entry.file, opts.key);
} else {
log.warn({ workflow: opts.key }, "cannot disable missing workflow entry");
}
}
if (!failureConfig.workflowName) return;
const destKey = resolveWorkflowTriggerKey(opts.owner, failureConfig.workflowName);
const alertData = buildFailureAlertData({
sourceKey: opts.key,
@@ -690,38 +657,42 @@ export function createRegistry(server) {
{
workflow: opts.key,
consecutiveFailures,
triggerWorkflow: failureConfig.workflowName,
onFailureWorkflow: failureConfig.workflowName,
destination: destKey,
},
"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 +700,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 +857,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 +878,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 ?? 9000);
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);
});
+103 -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";
@@ -18,11 +18,14 @@ const ALLOWED_MODULES = new Set([
"@aws-sdk/client-s3",
"@aws-sdk/s3-request-presigner",
"axios",
"basic-ftp",
"jsonata",
"mustache",
"node-html-parser",
"node:stream",
"nodemailer",
"rss-parser",
"ssh2-sftp-client",
"webdav",
]);
@@ -293,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`);
};
}
@@ -388,6 +436,7 @@ const $workflowsStub = {
* workflowName: string,
* owner?: string,
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
* pluginDir?: string | null,
* }} opts
*/
function createScriptSandbox({
@@ -396,6 +445,7 @@ function createScriptSandbox({
workflowName,
owner = "default",
$workflows = $workflowsStub,
pluginDir = null,
}) {
const scriptLog = log.child({ workflow: workflowName, script });
const $axios = createScreenedAxios(scriptLog);
@@ -415,7 +465,7 @@ function createScriptSandbox({
$vars,
$responses,
$workflows,
require: createRestrictedRequire($axios),
require: createRestrictedRequire($axios, pluginDir),
};
vm.createContext(sandbox, {
@@ -436,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" };
}
@@ -467,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);
}
@@ -483,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 = {}) {
@@ -493,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) };
}
@@ -516,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;
}
/**
@@ -542,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

+521
View File
@@ -0,0 +1,521 @@
import SftpClient from "ssh2-sftp-client";
import { Client } from "basic-ftp";
import { Readable, Writable } from "node:stream";
const PROTOCOLS = new Set(["ftp", "sftp"]);
const ACTIONS = new Set(["list", "read", "write", "delete", "stat", "mkdir", "rename"]);
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 {};
}
function resolveProtocol(ctx) {
const raw = ctx.config?.protocol ?? ctx.data?.protocol ?? "sftp";
if (typeof raw !== "string" || raw.length === 0) {
throw new Error("remote-fs: protocol must be a non-empty string");
}
const protocol = raw.toLowerCase();
if (!PROTOCOLS.has(protocol)) {
throw new Error(`remote-fs: unsupported protocol "${raw}" (use ftp or sftp)`);
}
return protocol;
}
function resolveAction(ctx) {
const raw = ctx.config?.action ?? ctx.data?.action ?? "list";
if (typeof raw !== "string" || raw.length === 0) {
throw new Error("remote-fs: action must be a non-empty string");
}
const action = raw.toLowerCase();
if (!ACTIONS.has(action)) {
throw new Error(`remote-fs: unsupported action "${raw}"`);
}
return action;
}
function resolvePath(ctx, { required = false, label = "path" } = {}) {
const path = ctx.config?.path ?? ctx.data?.path;
if (path == null || path === "") {
if (required) throw new Error(`remote-fs: ${label} is required`);
return ".";
}
if (typeof path !== "string") {
throw new Error(`remote-fs: ${label} must be a string`);
}
return path;
}
function resolveHost(ctx) {
const host = ctx.config?.host ?? ctx.data?.host;
if (typeof host !== "string" || host.length === 0) {
throw new Error("remote-fs: host is required (ctx.config.host or ctx.data.host)");
}
return host;
}
function resolvePort(ctx, protocol) {
const raw = ctx.config?.port ?? ctx.data?.port;
if (raw == null || raw === "") {
return protocol === "sftp" ? 22 : 21;
}
const port = Number(raw);
if (!Number.isFinite(port) || port <= 0) {
throw new Error("remote-fs: port must be a positive number");
}
return port;
}
async function resolveSecretValue(secretName, label) {
if (typeof secretName !== "string" || secretName.length === 0) {
throw new Error(`remote-fs: ${label} is required`);
}
return $secrets.reveal(await $secrets.get(secretName));
}
async function resolveAuth(ctx) {
const username =
typeof ctx.config?.usernameSecret === "string" && ctx.config.usernameSecret.length > 0
? await resolveSecretValue(ctx.config.usernameSecret, "usernameSecret")
: (ctx.config?.username ?? ctx.data?.username);
if (typeof username !== "string" || username.length === 0) {
throw new Error("remote-fs: username is required");
}
let password;
if (typeof ctx.config?.passwordSecret === "string" && ctx.config.passwordSecret.length > 0) {
password = await resolveSecretValue(ctx.config.passwordSecret, "passwordSecret");
} else if (typeof ctx.config?.password === "string") {
password = ctx.config.password;
} else if (typeof ctx.data?.password === "string") {
password = ctx.data.password;
}
let privateKey;
if (typeof ctx.config?.privateKeySecret === "string" && ctx.config.privateKeySecret.length > 0) {
privateKey = await resolveSecretValue(ctx.config.privateKeySecret, "privateKeySecret");
} else if (typeof ctx.config?.privateKey === "string") {
privateKey = ctx.config.privateKey;
}
let passphrase;
if (typeof ctx.config?.passphraseSecret === "string" && ctx.config.passphraseSecret.length > 0) {
passphrase = await resolveSecretValue(ctx.config.passphraseSecret, "passphraseSecret");
} else if (typeof ctx.config?.passphrase === "string") {
passphrase = ctx.config.passphrase;
}
if (!password && !privateKey) {
throw new Error(
"remote-fs: password or private key is required (passwordSecret/privateKeySecret or inline values)",
);
}
return { username, password, privateKey, passphrase };
}
function toIsoDate(value) {
if (value == null) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
function joinRemotePath(parentPath, name) {
if (!parentPath || parentPath === ".") return name;
if (parentPath.endsWith("/")) return `${parentPath}${name}`;
return `${parentPath}/${name}`;
}
function normalizeSftpEntry(entry, parentPath) {
const name = entry.name;
const type = entry.type === "d" ? "directory" : "file";
return {
name,
path: joinRemotePath(parentPath, name),
type,
size: type === "directory" ? null : typeof entry.size === "number" ? entry.size : null,
modified: toIsoDate(entry.modifyTime),
};
}
function normalizeFtpEntry(entry, parentPath) {
const type = entry.type === 2 ? "directory" : "file";
return {
name: entry.name,
path: joinRemotePath(parentPath, entry.name),
type,
size: type === "directory" ? null : typeof entry.size === "number" ? entry.size : null,
modified: toIsoDate(entry.modifiedAt ?? entry.rawModifiedAt),
};
}
function bufferFromWritable(writeFn) {
return new Promise((resolve, reject) => {
/** @type {Buffer[]} */
const chunks = [];
const writable = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
callback();
},
});
writable.on("finish", () => resolve(Buffer.concat(chunks)));
writable.on("error", reject);
Promise.resolve(writeFn(writable)).catch(reject);
});
}
function resolveWriteBody(ctx) {
if (ctx.config != null && typeof ctx.config === "object" && "body" in ctx.config) {
const body = ctx.config.body;
if (Buffer.isBuffer(body) || body instanceof Uint8Array) return Buffer.from(body);
if (typeof body === "string") return Buffer.from(body, "utf8");
return Buffer.from(JSON.stringify(body), "utf8");
}
if (ctx.data?.file != null) {
const file = ctx.data.file;
if (Buffer.isBuffer(file) || file instanceof Uint8Array) return Buffer.from(file);
}
if (ctx.data?.body != null) {
const body = ctx.data.body;
if (Buffer.isBuffer(body) || body instanceof Uint8Array) return Buffer.from(body);
if (typeof body === "string") return Buffer.from(body, "utf8");
return Buffer.from(JSON.stringify(body), "utf8");
}
throw new Error("remote-fs: write requires ctx.config.body, ctx.data.body, or ctx.data.file");
}
async function withSftp(ctx, protocol, fn) {
const auth = await resolveAuth(ctx);
const client = new SftpClient();
/** @type {Record<string, unknown>} */
const connectOptions = {
host: resolveHost(ctx),
port: resolvePort(ctx, protocol),
username: auth.username,
};
if (auth.privateKey) {
connectOptions.privateKey = auth.privateKey;
if (auth.passphrase) connectOptions.passphrase = auth.passphrase;
} else {
connectOptions.password = auth.password;
}
if (ctx.config?.readyTimeout != null) {
connectOptions.readyTimeout = Number(ctx.config.readyTimeout);
}
await client.connect(connectOptions);
try {
return await fn(client);
} finally {
await client.end();
}
}
async function withFtp(ctx, protocol, fn) {
const auth = await resolveAuth(ctx);
const client = new Client(
typeof ctx.config?.timeout === "number" ? ctx.config.timeout : 30000,
);
const secure = ctx.config?.secure === true || ctx.config?.secure === "implicit";
await client.access({
host: resolveHost(ctx),
port: resolvePort(ctx, protocol),
user: auth.username,
password: auth.password ?? "",
secure,
});
if (ctx.config?.passive === false) {
client.ftp.passive = false;
}
try {
return await fn(client);
} finally {
client.close();
}
}
async function runAction(protocol, ctx, action) {
const path = resolvePath(ctx, { required: action !== "list", label: "path" });
if (protocol === "sftp") {
return withSftp(ctx, protocol, async (client) => {
switch (action) {
case "list": {
const listPath = resolvePath(ctx);
const items = await client.list(listPath);
const entries = items.map((item) => normalizeSftpEntry(item, listPath));
return { path: listPath, entries, count: entries.length };
}
case "read": {
const outputVar =
typeof ctx.config?.outputVar === "string" && ctx.config.outputVar.length > 0
? ctx.config.outputVar
: "file";
const file = await client.get(path);
const buffer = Buffer.isBuffer(file) ? file : Buffer.from(file);
const encoding = ctx.config?.encoding ?? ctx.data?.encoding;
/** @type {Record<string, unknown>} */
const out = {
path,
[outputVar]: buffer,
contentLength: buffer.length,
};
if (encoding === "utf8" || encoding === "text") out.text = buffer.toString("utf8");
if (encoding === "base64") out.base64 = buffer.toString("base64");
return out;
}
case "write": {
const body = resolveWriteBody(ctx);
await client.put(body, path);
return { path, written: true, contentLength: body.length };
}
case "delete": {
await client.delete(path);
return { path, deleted: true };
}
case "stat": {
const stat = await client.stat(path);
return {
path,
type: stat.isDirectory ? "directory" : "file",
size: typeof stat.size === "number" ? stat.size : null,
modified: toIsoDate(stat.modifyTime),
accessed: toIsoDate(stat.accessTime),
};
}
case "mkdir": {
const recursive = ctx.config?.recursive !== false;
await client.mkdir(path, recursive);
return { path, created: true, recursive };
}
case "rename": {
const destination = ctx.config?.destination ?? ctx.data?.destination;
if (typeof destination !== "string" || destination.length === 0) {
throw new Error("remote-fs: rename requires destination");
}
await client.rename(path, destination);
return { path, destination, renamed: true };
}
default:
throw new Error(`remote-fs: unsupported action "${action}"`);
}
});
}
return withFtp(ctx, protocol, async (client) => {
switch (action) {
case "list": {
const listPath = resolvePath(ctx);
const items = await client.list(listPath === "." ? undefined : listPath);
const entries = items.map((item) => normalizeFtpEntry(item, listPath));
return { path: listPath, entries, count: entries.length };
}
case "read": {
const outputVar =
typeof ctx.config?.outputVar === "string" && ctx.config.outputVar.length > 0
? ctx.config.outputVar
: "file";
const buffer = await bufferFromWritable((writable) => client.downloadTo(writable, path));
const encoding = ctx.config?.encoding ?? ctx.data?.encoding;
/** @type {Record<string, unknown>} */
const out = {
path,
[outputVar]: buffer,
contentLength: buffer.length,
};
if (encoding === "utf8" || encoding === "text") out.text = buffer.toString("utf8");
if (encoding === "base64") out.base64 = buffer.toString("base64");
return out;
}
case "write": {
const body = resolveWriteBody(ctx);
const stream = Readable.from(body);
await client.uploadFrom(stream, path);
return { path, written: true, contentLength: body.length };
}
case "delete": {
await client.remove(path);
return { path, deleted: true };
}
case "stat": {
const size = await client.size(path);
const modified = await client.lastMod(path);
return {
path,
type: "file",
size: typeof size === "number" ? size : null,
modified: toIsoDate(modified),
};
}
case "mkdir": {
await client.ensureDir(path);
return { path, created: true, recursive: true };
}
case "rename": {
const destination = ctx.config?.destination ?? ctx.data?.destination;
if (typeof destination !== "string" || destination.length === 0) {
throw new Error("remote-fs: rename requires destination");
}
await client.rename(path, destination);
return { path, destination, renamed: true };
}
default:
throw new Error(`remote-fs: unsupported action "${action}"`);
}
});
}
async function remoteFs(ctx) {
const protocol = resolveProtocol(ctx);
const action = resolveAction(ctx);
const host = resolveHost(ctx);
const port = resolvePort(ctx, protocol);
log.info({ protocol, action, host, port }, "remote-fs: starting");
const result = await runAction(protocol, ctx, action);
const output = {
protocol,
action,
host,
...result,
};
log.info(
{ protocol, action, path: output.path ?? null, count: output.count ?? null },
"remote-fs: complete",
);
return {
output: { ...mergeData(ctx.data), ...output },
context: { ...passContext(ctx), ...output },
};
}
remoteFs.meta = {
description: "Access remote files over SFTP or FTP/FTPS",
previewConfigKey: "protocol",
tags: ["SFTP", "FTP", "storage"],
config: {
protocol: {
type: "string",
default: "sftp",
enum: ["sftp", "ftp"],
description: "Transfer protocol",
},
action: {
type: "string",
default: "list",
enum: ["list", "read", "write", "delete", "stat", "mkdir", "rename"],
description: "Operation to perform",
},
host: { type: "string", required: true, description: "Server hostname" },
port: { type: "number", required: false, description: "Port (default 22 for SFTP, 21 for FTP)" },
path: {
type: "string",
required: false,
description: "Remote directory for list, or file path for other actions",
},
username: { type: "string", required: false, description: "Login username" },
usernameSecret: { type: "string", required: false, description: "Named secret for username" },
password: { type: "string", required: false, description: "Login password" },
passwordSecret: { type: "string", required: false, description: "Named secret for password" },
privateKeySecret: {
type: "string",
required: false,
description: "Named secret holding an SFTP private key (PEM)",
},
privateKey: { type: "string", required: false, description: "Inline SFTP private key (PEM)" },
passphraseSecret: {
type: "string",
required: false,
description: "Named secret for encrypted private key passphrase",
},
passphrase: { type: "string", required: false, description: "Private key passphrase" },
secure: {
type: "boolean",
default: false,
description: "Use FTPS for FTP protocol",
},
passive: {
type: "boolean",
default: true,
description: "Use passive FTP mode",
},
recursive: {
type: "boolean",
default: true,
description: "Create parent directories for mkdir",
},
destination: {
type: "string",
required: false,
description: "Destination path for rename",
},
body: { type: "any", required: false, description: "Write payload" },
outputVar: {
type: "string",
default: "file",
description: "Output key for read action bytes",
},
encoding: {
type: "string",
required: false,
enum: ["utf8", "text", "base64"],
description: "Optional read decoding helper",
},
timeout: { type: "number", required: false, description: "FTP client timeout in ms" },
readyTimeout: { type: "number", required: false, description: "SFTP ready timeout in ms" },
},
input: {
protocol: { type: "string", required: false },
action: { type: "string", required: false },
host: { type: "string", required: false },
path: { type: "string", required: false },
username: { type: "string", required: false },
password: { type: "string", required: false },
body: { type: "any", required: false },
file: { type: "buffer", required: false },
destination: { type: "string", required: false },
},
output: {
protocol: { type: "string" },
action: { type: "string" },
host: { type: "string" },
entries: { type: "array", required: false, description: "list results" },
file: { type: "buffer", required: false, description: "read bytes (or outputVar)" },
written: { type: "boolean", required: false },
deleted: { type: "boolean", required: false },
renamed: { type: "boolean", required: false },
created: { type: "boolean", required: false },
},
context: {
protocol: { type: "string" },
action: { type: "string" },
host: { type: "string" },
},
example: {
data: {},
config: {
protocol: "sftp",
action: "list",
host: "sftp.example.com",
path: "/incoming",
username: "deploy",
passwordSecret: "sftp_password",
},
},
};
export default remoteFs;
+1
View File
@@ -1,6 +1,7 @@
import { inspect } from "node:util";
export const REDACTED = "[secret]";
/** Short values are stored, but skipped in log redaction to avoid false positives. */
export const MIN_SECRET_LENGTH = 8;
/** @type {Set<string>} */
+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 { MIN_SECRET_LENGTH, 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 < MIN_SECRET_LENGTH) {
const err = new Error(`value must be at least ${MIN_SECRET_LENGTH} characters`);
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 };
});
+8 -5
View File
@@ -63,9 +63,10 @@ export default function dashboardPluginFactory(registry) {
}
}
const [running, failed, recent] = await Promise.all([
store.listRuns({ status: "running", limit: 10 }),
store.listRuns({ status: "failed", limit: 20 }),
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 }),
]);
@@ -74,11 +75,13 @@ export default function dashboardPluginFactory(registry) {
scriptCount: fsStore.listScriptFiles().length,
enabledCount,
brokenCount,
running,
running: active,
needsAttention: {
failed,
consecutiveFailures: streaks.items,
consecutiveFailureCount: streaks.total,
brokenWorkflows,
},
failedEvents,
recent,
};
});
+4 -1
View File
@@ -1,4 +1,5 @@
import pino from "pino";
import { isBinary, summarizeBinary } from "../../json-preview.js";
import { redactString } from "../../secret-value.js";
const LEVEL_TO_NUM = {
@@ -64,7 +65,9 @@ export function safeSerialize(value) {
try {
return JSON.parse(
redactString(
JSON.stringify(value, (_key, v) => {
JSON.stringify(value, function (key, v) {
const raw = this[key];
if (isBinary(raw)) return summarizeBinary(raw);
if (typeof v === "bigint") return v.toString();
if (typeof v === "object" && v !== null) {
if (seen.has(v)) return "[Circular]";
+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));
}
+8 -7
View File
@@ -1,20 +1,21 @@
import * as store from "../../store.js";
import { queryRunsFromRequest } from "./run-query.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function runsPlugin(fastify) {
fastify.get("/runs", async (req) => {
return queryRunsFromRequest(/** @type {Record<string, string | undefined>} */ (req.query ?? {}));
});
fastify.get("/consecutive-failures", 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 store.listConsecutiveFailureStreaks({
minCount: 4,
limit: Number.isFinite(limit) ? limit : 200,
});
return { runs };
});
fastify.get("/runs/:id", async (req, reply) => {
+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) });
}
},
);
};
}
+2 -5
View File
@@ -6,7 +6,6 @@ import {
listSecrets,
upsertSecret,
} from "../../secrets-store.js";
import { MIN_SECRET_LENGTH } from "../../secret-value.js";
/**
* @param {import("fastify").FastifyInstance} fastify
@@ -37,10 +36,8 @@ export default async function secretsPlugin(fastify) {
}
const value = String(body.value ?? "");
if (value.length < MIN_SECRET_LENGTH) {
return reply
.code(400)
.send({ error: `value must be at least ${MIN_SECRET_LENGTH} characters` });
if (value.length === 0) {
return reply.code(400).send({ error: "value is required" });
}
try {
+553 -98
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";
@@ -28,8 +75,9 @@ function triggerSummary(owner, workflow) {
path: isHttp && t?.path != null ? namespacedPath(owner, t.path) : t?.path ?? null,
schedule: t?.schedule ?? null,
onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
triggerWorkflow: t?.triggerWorkflow ?? null,
auth: isHttp ? authLabel(t?.auth) : null,
onFailureWorkflow: t?.onFailureWorkflow ?? null,
disableOnConsecutiveFailures: t?.disableOnConsecutiveFailures === true,
auth: isHttp ? authLabel(t?.auth, nameById) : null,
};
});
}
@@ -40,7 +88,9 @@ function scriptNames(workflow) {
for (const raw of workflow.scripts ?? []) {
try {
const parsed = parseScriptStep(raw);
names.push(parsed.kind === "set" ? "set" : parsed.script);
if (parsed.kind === "set") names.push("set");
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
else names.push(parsed.script);
} catch {
names.push(null);
}
@@ -48,6 +98,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 +252,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 +358,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 +368,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 +381,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 +529,7 @@ export default function workflowsPluginFactory(registry) {
return {
owner,
file,
workflow_id: workflowIdFromFile(file),
key,
content,
parsed,
@@ -174,48 +551,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 +660,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 +705,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 +747,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 +765,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 +798,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 +846,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 +856,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 +864,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: 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
+251 -20
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { jsonPreviewReplacer } from "./json-preview.js";
import { redactString } from "./secret-value.js";
const MAX_JSON_BYTES = 64 * 1024;
@@ -12,7 +13,7 @@ export function serialize(value) {
if (value === undefined || value === null) return null;
let json;
try {
json = JSON.stringify(value);
json = JSON.stringify(value, jsonPreviewReplacer);
} catch {
json = JSON.stringify({ truncated: true, reason: "unserializable" });
}
@@ -24,6 +25,20 @@ export function serialize(value) {
});
}
/**
* Parsed JSON-safe copy for API / UI (buffers summarized, size-capped).
* @param {unknown} value
*/
export function toDisplayValue(value) {
const json = serialize(value);
if (json == null) return null;
try {
return JSON.parse(json);
} catch {
return null;
}
}
/**
* @param {string | null} value
* @returns {unknown}
@@ -49,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({
@@ -58,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,
@@ -68,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 };
}
/**
@@ -177,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("*")) {
@@ -198,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);
}
/**
@@ -278,6 +416,98 @@ export async function countConsecutiveFailures(workflow, triggerType, triggerDet
return count;
}
const CONSECUTIVE_FAILURE_WINDOW = 5000;
const STREAK_LAST_RUN_FIELDS = [
"id",
"owner",
"workflow",
"workflow_name",
"trigger_type",
"trigger_detail",
"status",
"started_at",
"finished_at",
"duration_ms",
"error",
];
/**
* Workflow+trigger groups currently in a trailing failure streak.
*
* @param {{
* minCount?: number,
* limit?: number,
* }} [opts]
* @returns {Promise<{
* items: Array<{
* consecutiveFailures: number,
* workflow: string,
* workflow_name: string | null,
* owner: string,
* trigger_type: string,
* trigger_detail: string | null,
* lastRun: {
* id: string,
* owner: string,
* workflow: string,
* workflow_name: string | null,
* trigger_type: string,
* trigger_detail: string | null,
* status: string,
* started_at: string,
* finished_at: string | null,
* duration_ms: number | null,
* error: string | null,
* },
* }>,
* total: number,
* }>}
*/
export async function listConsecutiveFailureStreaks(opts = {}) {
const minCount = Math.max(opts.minCount ?? 4, 1);
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 200);
const rows = await db("workflow_runs")
.select(STREAK_LAST_RUN_FIELDS)
.whereIn("status", ["success", "failed"])
.orderBy("started_at", "desc")
.limit(CONSECUTIVE_FAILURE_WINDOW);
/** @type {Map<string, { count: number, done: boolean, lastRun: (typeof rows)[number] }>} */
const groups = new Map();
for (const row of rows) {
const key = `${row.workflow}\0${row.trigger_type}\0${row.trigger_detail ?? ""}`;
let group = groups.get(key);
if (!group) {
group = { count: 0, done: false, lastRun: row };
groups.set(key, group);
}
if (group.done) continue;
if (row.status === "failed") group.count += 1;
else group.done = true;
}
const streaks = [...groups.values()]
.filter((g) => g.lastRun.status === "failed" && g.count >= minCount)
.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return String(b.lastRun.started_at).localeCompare(String(a.lastRun.started_at));
});
return {
total: streaks.length,
items: streaks.slice(0, limit).map((g) => ({
consecutiveFailures: g.count,
workflow: g.lastRun.workflow,
workflow_name: g.lastRun.workflow_name,
owner: g.lastRun.owner,
trigger_type: g.lastRun.trigger_type,
trigger_detail: g.lastRun.trigger_detail,
lastRun: g.lastRun,
})),
};
}
/**
* @returns {Promise<Record<string, { invocationCount: number, lastInvokedAt: string | null, lastStatus: string | null }>>}
*/
@@ -352,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(
@@ -0,0 +1,75 @@
import {
encodeBinaryForWire,
jsonPreviewReplacer,
reviveBinaryFromWire,
summarizeBinary,
} from "../json-preview.js";
import { serialize, toDisplayValue } from "../store.js";
import { safeSerialize } from "../src/api/dry-run-logger.js";
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
const summary = summarizeBinary(png);
if (summary.length !== png.length || summary.preview !== "89504e470d0a1a0a010203" || summary.truncated !== false) {
throw new Error(`summarizeBinary: ${JSON.stringify(summary)}`);
}
const long = Buffer.alloc(32, 0xff);
const longSummary = summarizeBinary(long);
if (longSummary.length !== 32 || longSummary.preview.length !== 32 || longSummary.truncated !== true) {
throw new Error(`long summarizeBinary: ${JSON.stringify(longSummary)}`);
}
const dumped = JSON.stringify({ file: png });
if (!dumped.includes('"data":[')) {
throw new Error("expected default Buffer JSON to include data array");
}
const previewed = JSON.stringify({ file: png }, jsonPreviewReplacer);
if (previewed.includes('"data":[')) {
throw new Error(`replacer still dumped bytes: ${previewed}`);
}
if (!previewed.includes('"preview":"89504e470d0a1a0a010203"')) {
throw new Error(`replacer missing hex preview: ${previewed}`);
}
const stored = serialize({ output: { file: png, filename: "test.png" } });
if (stored.includes('"data":[')) {
throw new Error(`serialize dumped bytes: ${stored.slice(0, 200)}`);
}
const display = toDisplayValue({
output: { file: png },
context: { file: png },
});
if (display.output.file.length !== png.length || display.context.file.truncated !== false) {
throw new Error(`toDisplayValue: ${JSON.stringify(display)}`);
}
if (Array.isArray(display.output.file.data)) {
throw new Error("toDisplayValue should not keep Buffer.data");
}
const dry = safeSerialize({ file: png, n: 1n });
if (dry.n !== "1" || Array.isArray(dry.file.data)) {
throw new Error(`safeSerialize: ${JSON.stringify(dry)}`);
}
const typed = safeSerialize({ file: new Uint8Array(png) });
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();
+29 -9
View File
@@ -46,19 +46,39 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
if (!spec) continue;
const threshold = Number(spec.onConsecutiveFailures);
const workflowName =
typeof spec.triggerWorkflow === "string" ? spec.triggerWorkflow.trim() : "";
if (!Number.isFinite(threshold) || threshold < 1 || workflowName.length === 0) {
const workflowName = onFailureWorkflowName(spec);
const disableOnConsecutiveFailures = isDisableOnConsecutiveFailures(spec);
if (
!Number.isFinite(threshold) ||
threshold < 1 ||
(workflowName.length === 0 && !disableOnConsecutiveFailures)
) {
return null;
}
return {
threshold: Math.floor(threshold),
workflowName,
workflowName: workflowName.length > 0 ? workflowName : null,
disableOnConsecutiveFailures,
};
}
return null;
}
/**
* @param {Record<string, unknown>} trigger
*/
function onFailureWorkflowName(trigger) {
const value = trigger?.onFailureWorkflow;
return typeof value === "string" ? value.trim() : "";
}
/**
* @param {Record<string, unknown>} trigger
*/
export function isDisableOnConsecutiveFailures(trigger) {
return trigger?.disableOnConsecutiveFailures === true;
}
/**
* @param {unknown} workflow
*/
@@ -73,14 +93,14 @@ export async function validateWorkflowFailureTriggers(workflow) {
const hasThreshold =
trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== "";
const hasWorkflow =
typeof trigger.triggerWorkflow === "string" && trigger.triggerWorkflow.trim().length > 0;
const hasWorkflow = onFailureWorkflowName(trigger).length > 0;
const hasDisable = isDisableOnConsecutiveFailures(trigger);
if (!hasThreshold && !hasWorkflow) continue;
if (!hasThreshold && !hasWorkflow && !hasDisable) continue;
if (!hasThreshold || !hasWorkflow) {
if (!hasThreshold || (!hasWorkflow && !hasDisable)) {
const err = new Error(
"onConsecutiveFailures and triggerWorkflow must both be set on a trigger",
"onConsecutiveFailures requires onFailureWorkflow and/or disableOnConsecutiveFailures",
);
err.statusCode = 400;
throw err;
+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";
+116
View File
@@ -0,0 +1,116 @@
/**
* Production UI server (:8500).
* Serves packages/web/dist and proxies /api, /ops, /admin, /u like Vite in dev.
* Always-on — survives Ops stop of jflow-http.
*/
import fs from "fs";
import fastify from "fastify";
import fastifyStatic from "@fastify/static";
import { log } from "./logger.js";
import { WEB_DIST } from "./paths.js";
import {
controlOrigin,
httpOrigin,
proxyToOrigin,
} from "./ops-proxy.js";
if (!fs.existsSync(WEB_DIST)) {
log.error(
{ WEB_DIST },
"web dist missing — run `pnpm build` before starting the UI server",
);
process.exit(1);
}
const port = Number(process.env.JFLOW_UI_PORT ?? 8500);
const control = controlOrigin();
const http = httpOrigin();
const server = fastify({ loggerInstance: log });
/**
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
*/
async function proxyApi(req, reply) {
const url = req.raw.url ?? "";
// Match Vite: /api/auth → control (login works when HTTP is stopped).
if (url === "/api/auth" || url.startsWith("/api/auth/") || url.startsWith("/api/auth?")) {
return proxyToOrigin(req, reply, control, {
unreachableMessage: "control plane unreachable",
});
}
return proxyToOrigin(req, reply, http, {
unreachableMessage: "HTTP API unreachable",
});
}
/**
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
*/
async function proxyOps(req, reply) {
return proxyToOrigin(req, reply, control, {
unreachableMessage: "control plane unreachable",
});
}
/**
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
*/
async function proxyHttp(req, reply) {
return proxyToOrigin(req, reply, http, {
unreachableMessage: "HTTP API unreachable",
});
}
server.all("/api", proxyApi);
server.all("/api/*", proxyApi);
server.all("/ops", proxyOps);
server.all("/ops/*", proxyOps);
server.all("/admin", proxyHttp);
server.all("/admin/*", proxyHttp);
server.all("/u", proxyHttp);
server.all("/u/*", proxyHttp);
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");
});
async function shutdown() {
try {
await server.close();
} catch (err) {
log.error({ err }, "web-server shutdown error");
}
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
try {
await server.listen({ host: "0.0.0.0", port });
log.info(
{ port, WEB_DIST, control, http },
"UI server listening (static + proxy)",
);
} catch (err) {
log.error({ err }, "failed to start UI server");
process.exit(1);
}
+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
@@ -8,7 +8,7 @@ scripts:
config:
url: https://example.com/
outputVar: message
transform: >
transform: |
data.hasChanges
? "example.com changed (fingerprint " & data.fingerprint & ")"
: "example.com unchanged since " & data.fingerprintAt
@@ -16,3 +16,8 @@ triggers:
- type: HTTP
method: POST
path: /detect-example
- type: cron
schedule: "* * * * *"
onConsecutiveFailures: 3
onFailureWorkflow: dev-zte-sms
enabled: false
@@ -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

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