Compare commits
47
Commits
16b1bc5668
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41cca86e6e | ||
|
|
71cc705cd2 | ||
|
|
811890443b | ||
|
|
7456dece00 | ||
|
|
beb7e22652 | ||
|
|
72feb39d56 | ||
|
|
1b08979699 | ||
|
|
527f9ac869 | ||
|
|
f9f21052d9 | ||
|
|
db884808f2 | ||
|
|
368a2ca365 | ||
|
|
96c4cc7b47 | ||
|
|
3d25d8a614 | ||
|
|
9985ff3440 | ||
|
|
e84432a492 | ||
|
|
210fdb2244 | ||
|
|
20654b0682 | ||
|
|
14a08c700c | ||
|
|
14c8dc628e | ||
|
|
407607b3ad | ||
|
|
54f6983a74 | ||
|
|
4e866fb958 | ||
|
|
3b284b4fc6 | ||
|
|
ed9555e3fa | ||
|
|
aa3c2a25b2 | ||
|
|
84f29057df | ||
|
|
f68376587e | ||
|
|
69106ab805 | ||
|
|
f74b0defc6 | ||
|
|
eb94a2f669 | ||
|
|
21a31b0b54 | ||
|
|
d953f8113b | ||
|
|
d74923c7e1 | ||
|
|
6a4a653f6b | ||
|
|
4978cbf50f | ||
|
|
7d51ff433f | ||
|
|
82aff86fa0 | ||
|
|
2244834b46 | ||
|
|
bd0c11c20c | ||
|
|
611511ae60 | ||
|
|
f05c149b72 | ||
|
|
f66d0931bd | ||
|
|
8590fce3f8 | ||
|
|
4e6c336ae8 | ||
|
|
756ba8a7ac | ||
|
|
d122bd9a7f | ||
|
|
a95e6d7bc8 |
+10
-2
@@ -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/
|
||||
|
||||
@@ -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`.
|
||||
@@ -13,13 +13,63 @@ Workflow runner with a sandboxed script engine, SQLite run history, and an admin
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
# Redis required for the workflow queue
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- UI (dev): http://localhost:8500
|
||||
- API: http://localhost:8700
|
||||
- UI (dev): http://localhost:5173
|
||||
|
||||
The first account created becomes **admin**. Later accounts are created from Users.
|
||||
The first account created becomes **admin**. JerapahFlow is a **single-machine, single-user** automation app: the Users page is not linked in the nav (still available at `/users` if typed). New workflows, secrets, variables, and profiles default to the internal namespace `local`.
|
||||
|
||||
Reset or create the admin login from the host:
|
||||
|
||||
```bash
|
||||
pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
|
||||
```
|
||||
|
||||
### Process modes
|
||||
|
||||
| Command | Processes | Ports |
|
||||
|---|---|---|
|
||||
| `pnpm dev` | Monolith (`runner.js` = API + worker) + Vite | UI **8500**, API **8700** |
|
||||
| `pnpm dev:pm2` | Control + PM2 HTTP + PM2 workers + Vite | UI **8500**, control **8600**, API **8700** |
|
||||
|
||||
`pnpm dev:pm2` is the mode for Ops (start/stop HTTP, scale workers, drain restart). Control owns SQLite migrations; HTTP/workers do not migrate.
|
||||
|
||||
## Scripts (core vs plugins)
|
||||
|
||||
| Kind | Name in YAML | Editable | Location |
|
||||
|---|---|---|---|
|
||||
| **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` |
|
||||
| **User plugin** | `plugin/<id>` | Yes | `plugins/<id>/` |
|
||||
| **Example source** | install → `plugin/<id>` | After install | `examples/plugins/<id>/` |
|
||||
|
||||
- App version is **`0.1.0`** (root `package.json`). Plugin manifests declare `jerapah: ">=0.1.0 <1.0.0"`.
|
||||
- Install plugins via admin API: zip (base64), HTTPS git URL, example, or fork a core script.
|
||||
- Install/update/uninstall sets **restart-needed** — drain-restart HTTP + workers under `pnpm dev:pm2`.
|
||||
- Shipped example source (install from UI/API): `examples/plugins/get-current-time` → runtime `plugin/get-current-time`.
|
||||
- `plugins/joplin-api` and `plugins/send-sms` are **personal/user plugins** appropriate for a fork — not shipped examples. See **AGENTS.md** for creating user plugins under `plugins/<id>/`.
|
||||
|
||||
## Workflows (instance data vs examples)
|
||||
|
||||
| Kind | Loaded by runner? | Location |
|
||||
|---|---|---|
|
||||
| **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (gitignored) |
|
||||
| **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow |
|
||||
|
||||
- Live YAML is **instance data**, same as SQLite and secrets — not product source. New resources use owner `local` (owner remains in storage/URLs for a possible future multi-tenant mode; the UI hides it).
|
||||
- On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`.
|
||||
- New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save).
|
||||
- Override the live store in tests with `JFLOW_WORKFLOWS_DIR`.
|
||||
|
||||
```bash
|
||||
# Smoke
|
||||
JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
|
||||
JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
|
||||
node packages/server/test/plugins-smoke.js
|
||||
```
|
||||
|
||||
|
||||
## Script contract
|
||||
|
||||
@@ -50,13 +100,31 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pnpm dev` | Server + Vite together |
|
||||
| `pnpm dev:server` | API/runner only |
|
||||
| `pnpm dev:web` | UI only (proxies `/api` to :8700) |
|
||||
| `pnpm dev` | Monolith server + Vite (no PM2) |
|
||||
| `pnpm dev:pm2` | Control + PM2 HTTP/workers + Vite (Ops UI) |
|
||||
| `pnpm dev:server` | Monolith API/runner only |
|
||||
| `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
|
||||
| `pnpm build` | Production UI build |
|
||||
| `pnpm start` | Serve API and built UI from :8700 |
|
||||
| `pnpm start` | Monolith: API + worker + built UI |
|
||||
| `pnpm start:control` | Control plane only (migrates, manages PM2 children) |
|
||||
| `pnpm start:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) |
|
||||
| `pnpm start:worker` | BullMQ worker only |
|
||||
| `pnpm migrate` | Apply SQLite migrations |
|
||||
|
||||
## Ops (control plane)
|
||||
|
||||
Admin UI route **Ops** (`/ops`) talks to the control process.
|
||||
|
||||
| Action | Behavior |
|
||||
|---|---|
|
||||
| Pause / resume | BullMQ `queue.pause()` / `resume()` — cron/HTTP still enqueue |
|
||||
| Reload workflows | Redis pub/sub → all live HTTP/worker processes re-read YAML |
|
||||
| Scale workers | PM2 scale; scale-down drains active jobs unless `force` |
|
||||
| Drain restart | Pause → wait active=0 → stop children → migrate → recreate → resume |
|
||||
| Force restart | Same without waiting (interrupts active runs; orphans marked `worker_lost`) |
|
||||
|
||||
Desired state is stored in `packages/server/data/control-state.json` (generation, worker count, restart-needed). Plugin installs (later) bump generation and set restart-needed; you apply with Drain restart.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Default | Notes |
|
||||
@@ -64,18 +132,32 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|
||||
| `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. |
|
||||
| `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. |
|
||||
| `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. |
|
||||
| `JFLOW_WORKFLOWS_DIR` | `packages/server/data/workflows` | Live workflow YAML (instance data). |
|
||||
| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. |
|
||||
| `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. |
|
||||
| `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
|
||||
| `JFLOW_WORKER_CONCURRENCY` | `5` | Max parallel workflow jobs per worker process. |
|
||||
| `JFLOW_ROLE` | `all` | `all` (HTTP + cron + worker), `api`, or `worker`. Prefer `pnpm start:api` / `start:worker` under control. |
|
||||
| `JFLOW_CONFIG_GENERATION` | `1` | Set by control/PM2 so children report config generation in heartbeats. |
|
||||
| `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. |
|
||||
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
|
||||
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
|
||||
| `PORT` | `8700` | HTTP port |
|
||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev |
|
||||
| `PORT` | `8700` | HTTP API port |
|
||||
| `NODE_ENV` | — | Set `production` for secure cookies |
|
||||
|
||||
Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { runId, status: "queued" }` immediately; poll `GET /api/runs/:id` for progress (`queued` → `running` → `success` \| `failed`). Cron remains an in-process producer that enqueues jobs on each tick.
|
||||
|
||||
## Production
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... NODE_ENV=production pnpm start
|
||||
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
|
||||
# Recommended: run control (migrates + manages PM2 HTTP/workers)
|
||||
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start:control
|
||||
# Or monolith (dev-style):
|
||||
# ... pnpm start
|
||||
```
|
||||
|
||||
The server serves `packages/web/dist` when that folder exists.
|
||||
With control, serve the built UI from Vite preview, a reverse proxy, or set `JFLOW_SERVE_UI=1` on the HTTP process.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Production PM2 ecosystem (monolith runner).
|
||||
* Use for deployed/single-process starts. For local multi-process Ops UI, use
|
||||
* `pnpm dev:pm2` → packages/server/ecosystem.dev.cjs / control.js instead.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function loadEnv(file) {
|
||||
const out = {};
|
||||
if (!fs.existsSync(file)) return out;
|
||||
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith("#")) continue;
|
||||
const i = t.indexOf("=");
|
||||
if (i === -1) continue;
|
||||
const key = t.slice(0, i).trim();
|
||||
let val = t.slice(i + 1).trim();
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const root = __dirname;
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "jerapah-flow",
|
||||
cwd: root,
|
||||
script: "packages/server/runner.js",
|
||||
interpreter: "node",
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
max_restarts: 20,
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
...loadEnv(path.join(root, ".env")),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "get-current-time",
|
||||
"name": "Get current time",
|
||||
"version": "0.1.0",
|
||||
"jerapah": ">=0.1.0 <1.0.0",
|
||||
"main": "script.js",
|
||||
"description": "Example user plugin: return the current time as output.datetime"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "jflow-plugin-get-current-time",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Example JerapahFlow plugin (no npm dependencies)"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Comic - monkeyuser to ntfy
|
||||
description: |
|
||||
Scrape the latest MonkeyUser comic and send it to ntfy (requires $VAR_ntfy_channel).
|
||||
scripts:
|
||||
- script: fetch-html.js
|
||||
config:
|
||||
url: https://www.monkeyuser.com/
|
||||
outputVar: comic
|
||||
selector: .comic img
|
||||
jsonata: |
|
||||
{"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]}
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.comic.title,
|
||||
"message": data.comic.title,
|
||||
"attach": data.comic.url
|
||||
}
|
||||
- script: fetch-binary.js
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: $VAR_ntfy_channel
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /comic-monkeyuser
|
||||
@@ -0,0 +1,22 @@
|
||||
name: detect example.com changes
|
||||
description: |
|
||||
Fetch example.com, fingerprint the response, and report whether it changed
|
||||
since the previous run. Uses detect-url-changes with a transform that builds
|
||||
a human-readable message into ctx.data.message.
|
||||
scripts:
|
||||
- script: detect-url-changes.js
|
||||
config:
|
||||
url: https://example.com/
|
||||
outputVar: message
|
||||
transform: |
|
||||
data.hasChanges
|
||||
? "example.com changed (fingerprint " & data.fingerprint & ")"
|
||||
: "example.com unchanged since " & data.fingerprintAt
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /detect-example
|
||||
- type: cron
|
||||
schedule: "* * * * *"
|
||||
onConsecutiveFailures: 3
|
||||
enabled: false
|
||||
+9
-12
@@ -1,25 +1,22 @@
|
||||
{
|
||||
"name": "jerapah-flow",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Script/workflow runner with admin UI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @jerapah-flow/server --filter @jerapah-flow/web --parallel dev",
|
||||
"dev:pm2": "pnpm --filter @jerapah-flow/server dev:pm2",
|
||||
"dev:server": "pnpm --filter @jerapah-flow/server dev",
|
||||
"dev:web": "pnpm --filter @jerapah-flow/web dev",
|
||||
"build": "pnpm --filter @jerapah-flow/web build",
|
||||
"start": "pnpm --filter @jerapah-flow/server start",
|
||||
"migrate": "pnpm --filter @jerapah-flow/server migrate"
|
||||
"start:api": "pnpm --filter @jerapah-flow/server start:api",
|
||||
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
||||
"start:control": "pnpm --filter @jerapah-flow/server start:control",
|
||||
"migrate": "pnpm --filter @jerapah-flow/server migrate",
|
||||
"test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test",
|
||||
"lint": "pnpm --filter @jerapah-flow/web lint"
|
||||
},
|
||||
"packageManager": "pnpm@10.25.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"rss-parser": "^3.13.0"
|
||||
}
|
||||
"packageManager": "pnpm@11.22.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dump.rdb
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
import jwt from "@fastify/jwt";
|
||||
import { migrate, db } from "./db.js";
|
||||
import { log, enableLogPersistence, flushLogs } from "./logger.js";
|
||||
import authPlugin, { addApiAuthGuard, COOKIE } from "./src/api/auth.js";
|
||||
import {
|
||||
clearRestartNeeded,
|
||||
bumpGeneration,
|
||||
patchControlState,
|
||||
readControlState,
|
||||
refreshOpsLock,
|
||||
releaseOpsLock,
|
||||
tryAcquireOpsLock,
|
||||
} from "./control-state.js";
|
||||
import {
|
||||
getConfigGeneration,
|
||||
publishReload,
|
||||
readHeartbeats,
|
||||
} from "./control-bus.js";
|
||||
import {
|
||||
closeRedis,
|
||||
createWorkflowQueue,
|
||||
getRedisUrlForLog,
|
||||
} from "./workflow-queue.js";
|
||||
import { reconcileOrphanRuns } from "./orphan-runs.js";
|
||||
import {
|
||||
connectPm2,
|
||||
deletePm2App,
|
||||
describeChildren,
|
||||
disconnectPm2,
|
||||
ensureHttp,
|
||||
ensureWorkers,
|
||||
PM2_HTTP_NAME,
|
||||
PM2_WORKER_NAME,
|
||||
recreateChildren,
|
||||
restartPm2Process,
|
||||
stopPm2App,
|
||||
} from "./pm2-bridge.js";
|
||||
|
||||
const DRAIN_DEFAULT_MS = 60_000;
|
||||
const DRAIN_POLL_MS = 500;
|
||||
|
||||
const jwtSecret =
|
||||
process.env.JFLOW_JWT_SECRET ??
|
||||
(process.env.NODE_ENV === "production" ? "" : "jflow-dev-secret");
|
||||
|
||||
if (!jwtSecret) {
|
||||
log.error("JFLOW_JWT_SECRET is required in production");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await migrate();
|
||||
enableLogPersistence();
|
||||
|
||||
log.info({ redis: getRedisUrlForLog() }, "starting jerapah-flow control");
|
||||
|
||||
const workflowQueue = createWorkflowQueue();
|
||||
try {
|
||||
await workflowQueue.waitUntilReady();
|
||||
} catch (err) {
|
||||
log.error({ err, redis: getRedisUrlForLog() }, "failed to connect to Redis");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await connectPm2();
|
||||
} catch (err) {
|
||||
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function applyDesiredState() {
|
||||
const state = readControlState();
|
||||
await ensureHttp({
|
||||
generation: state.generation,
|
||||
running: state.http === "running",
|
||||
});
|
||||
await ensureWorkers({
|
||||
generation: state.generation,
|
||||
count: state.workers,
|
||||
});
|
||||
if (state.queuePaused) {
|
||||
await workflowQueue.pause();
|
||||
} else {
|
||||
const paused = await workflowQueue.isPaused();
|
||||
if (paused) await workflowQueue.resume();
|
||||
}
|
||||
log.info(
|
||||
{
|
||||
http: state.http,
|
||||
workers: state.workers,
|
||||
generation: state.generation,
|
||||
queuePaused: state.queuePaused,
|
||||
},
|
||||
"applied desired state",
|
||||
);
|
||||
}
|
||||
|
||||
await applyDesiredState();
|
||||
|
||||
const server = fastify({ loggerInstance: log });
|
||||
await server.register(cookie);
|
||||
await server.register(jwt, {
|
||||
secret: jwtSecret,
|
||||
cookie: { cookieName: COOKIE, signed: false },
|
||||
});
|
||||
await server.register(cors, {
|
||||
origin: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
server.decorate("authenticate", async function authenticate(req, reply) {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch {
|
||||
return reply.code(401).send({ error: "unauthorized" });
|
||||
}
|
||||
});
|
||||
|
||||
server.decorate("requireAdmin", async function requireAdmin(req, reply) {
|
||||
if (req.user?.role !== "admin") {
|
||||
return reply.code(403).send({ error: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
await server.register(
|
||||
async (api) => {
|
||||
addApiAuthGuard(api, server);
|
||||
await api.register(authPlugin);
|
||||
},
|
||||
{ prefix: "/api" },
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {number} timeoutMs
|
||||
* @param {string} lockToken
|
||||
*/
|
||||
async function waitUntilIdle(timeoutMs, lockToken) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
refreshOpsLock(lockToken);
|
||||
await reconcileOrphanRuns(workflowQueue);
|
||||
const active = await workflowQueue.getActiveCount();
|
||||
if (active === 0) return { ok: true, active: 0 };
|
||||
await new Promise((r) => setTimeout(r, DRAIN_POLL_MS));
|
||||
}
|
||||
const active = await workflowQueue.getActiveCount();
|
||||
return { ok: active === 0, active };
|
||||
}
|
||||
|
||||
/** @param {unknown} raw */
|
||||
function parsePmId(raw) {
|
||||
if (raw == null || raw === "") return null;
|
||||
const id = Math.floor(Number(raw));
|
||||
if (!Number.isFinite(id) || id < 0) return Number.NaN;
|
||||
return id;
|
||||
}
|
||||
|
||||
async function handleProcessRestart(pmId, reply) {
|
||||
const lock = tryAcquireOpsLock(`process-restart:${process.pid}`);
|
||||
if (!lock.ok) {
|
||||
return reply.code(409).send({ error: lock.error, holder: lock.holder });
|
||||
}
|
||||
|
||||
try {
|
||||
const restarted = await restartPm2Process(pmId);
|
||||
return reply.send({ ok: true, ...restarted, status: await buildStatus() });
|
||||
} catch (err) {
|
||||
const code = err && typeof err === "object" ? err.code : undefined;
|
||||
if (code === "BAD_REQUEST") {
|
||||
return reply.code(400).send({ error: "pmId is required" });
|
||||
}
|
||||
if (code === "NOT_FOUND") {
|
||||
return reply.code(404).send({ error: "process not found" });
|
||||
}
|
||||
if (code === "FORBIDDEN") {
|
||||
return reply.code(403).send({ error: "process is not a JerapahFlow child" });
|
||||
}
|
||||
log.error({ err, pmId }, "process restart failed");
|
||||
return reply.code(500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
releaseOpsLock(lock.token);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildStatus() {
|
||||
const state = readControlState();
|
||||
const children = await describeChildren();
|
||||
const heartbeats = await readHeartbeats();
|
||||
const live = heartbeats.filter((h) => !h.stale);
|
||||
const queuePaused = await workflowQueue.isPaused();
|
||||
const counts = await workflowQueue.getJobCounts(
|
||||
"active",
|
||||
"waiting",
|
||||
"delayed",
|
||||
"paused",
|
||||
"failed",
|
||||
"completed",
|
||||
);
|
||||
const orphans = await reconcileOrphanRuns(workflowQueue);
|
||||
|
||||
const expectedGen = state.generation;
|
||||
const mismatched = live.filter((h) => h.generation !== expectedGen);
|
||||
|
||||
return {
|
||||
control: {
|
||||
pid: process.pid,
|
||||
generation: getConfigGeneration(),
|
||||
},
|
||||
desired: state,
|
||||
children,
|
||||
heartbeats: live,
|
||||
generationMismatch: mismatched.length > 0 || state.restartNeeded,
|
||||
mismatched,
|
||||
queue: {
|
||||
paused: queuePaused,
|
||||
counts,
|
||||
},
|
||||
orphans,
|
||||
};
|
||||
}
|
||||
|
||||
server.get(
|
||||
"/ops/status",
|
||||
{ onRequest: [server.authenticate] },
|
||||
async () => buildStatus(),
|
||||
);
|
||||
|
||||
server.get("/ops/health", async () => ({ ok: true, role: "control" }));
|
||||
|
||||
server.post(
|
||||
"/ops/pause",
|
||||
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||
async (_req, reply) => {
|
||||
await workflowQueue.pause();
|
||||
patchControlState({ queuePaused: true });
|
||||
return reply.send({ ok: true, queuePaused: true });
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
"/ops/resume",
|
||||
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||
async (_req, reply) => {
|
||||
await workflowQueue.resume();
|
||||
patchControlState({ queuePaused: false });
|
||||
return reply.send({ ok: true, queuePaused: false });
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
"/ops/reload",
|
||||
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||
async (_req, reply) => {
|
||||
await publishReload({ type: "workflows" });
|
||||
return reply.send({ ok: true, published: true });
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
"/ops/generation/bump",
|
||||
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const body = /** @type {{ reason?: string }} */ (req.body ?? {});
|
||||
const reason = String(body.reason ?? "manual bump").slice(0, 200);
|
||||
const state = bumpGeneration(reason);
|
||||
return reply.send({ ok: true, desired: state });
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
"/ops/http/start",
|
||||
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||
async (_req, reply) => {
|
||||
const state = patchControlState({ http: "running" });
|
||||
await ensureHttp({ generation: state.generation, running: true });
|
||||
return reply.send({ ok: true, desired: state });
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
"/ops/http/stop",
|
||||
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||
async (_req, reply) => {
|
||||
const state = patchControlState({ http: "stopped" });
|
||||
await stopPm2App(PM2_HTTP_NAME);
|
||||
return reply.send({ ok: true, desired: state });
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
"/ops/restart",
|
||||
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const body = /** @type {{ force?: boolean, timeoutMs?: number, pmId?: number }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
|
||||
const pmId = parsePmId(body.pmId);
|
||||
if (pmId != null) {
|
||||
if (Number.isNaN(pmId)) {
|
||||
return reply.code(400).send({ error: "pmId is required" });
|
||||
}
|
||||
return handleProcessRestart(pmId, reply);
|
||||
}
|
||||
|
||||
const force = Boolean(body.force);
|
||||
const timeoutMs = Math.min(
|
||||
Math.max(Number(body.timeoutMs) || DRAIN_DEFAULT_MS, 1_000),
|
||||
10 * 60_000,
|
||||
);
|
||||
|
||||
const lock = tryAcquireOpsLock(`restart:${process.pid}`);
|
||||
if (!lock.ok) {
|
||||
return reply.code(409).send({ error: lock.error, holder: lock.holder });
|
||||
}
|
||||
|
||||
const stateBefore = readControlState();
|
||||
const wantPaused = stateBefore.queuePaused;
|
||||
try {
|
||||
await workflowQueue.pause();
|
||||
|
||||
if (!force) {
|
||||
const drained = await waitUntilIdle(timeoutMs, lock.token);
|
||||
if (!drained.ok) {
|
||||
if (!wantPaused) {
|
||||
await workflowQueue.resume();
|
||||
}
|
||||
return reply.code(409).send({
|
||||
error: "active jobs still running",
|
||||
active: drained.active,
|
||||
hint: "wait or retry with force=true",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const desired = readControlState();
|
||||
await stopPm2App(PM2_HTTP_NAME);
|
||||
await deletePm2App(PM2_HTTP_NAME);
|
||||
await stopPm2App(PM2_WORKER_NAME);
|
||||
await deletePm2App(PM2_WORKER_NAME);
|
||||
|
||||
await reconcileOrphanRuns(workflowQueue);
|
||||
// Drop stale heartbeats from killed PIDs
|
||||
try {
|
||||
const { HEARTBEAT_KEY } = await import("./control-bus.js");
|
||||
const { getSharedConnection } = await import("./workflow-queue.js");
|
||||
await getSharedConnection().del(HEARTBEAT_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await migrate();
|
||||
|
||||
await recreateChildren({
|
||||
generation: desired.generation,
|
||||
http: desired.http === "running",
|
||||
workers: desired.workers,
|
||||
});
|
||||
|
||||
if (wantPaused) {
|
||||
await workflowQueue.pause();
|
||||
patchControlState({ queuePaused: true });
|
||||
} else {
|
||||
await workflowQueue.resume();
|
||||
patchControlState({ queuePaused: false });
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
clearRestartNeeded();
|
||||
|
||||
return reply.send({
|
||||
ok: true,
|
||||
forced: force,
|
||||
desired: readControlState(),
|
||||
status: await buildStatus(),
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err }, "restart failed");
|
||||
return reply.code(500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
releaseOpsLock(lock.token);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
"/ops/process/restart",
|
||||
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const body = /** @type {{ pmId?: number }} */ (req.body ?? {});
|
||||
const pmId = parsePmId(body.pmId);
|
||||
if (pmId == null || Number.isNaN(pmId)) {
|
||||
return reply.code(400).send({ error: "pmId is required" });
|
||||
}
|
||||
return handleProcessRestart(pmId, reply);
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
"/ops/scale",
|
||||
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const body = /** @type {{ workers?: number, force?: boolean, timeoutMs?: number }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
const workers = Math.floor(Number(body.workers));
|
||||
if (!Number.isFinite(workers) || workers < 0 || workers > 32) {
|
||||
return reply.code(400).send({ error: "workers must be 0..32" });
|
||||
}
|
||||
const force = Boolean(body.force);
|
||||
const timeoutMs = Math.min(
|
||||
Math.max(Number(body.timeoutMs) || DRAIN_DEFAULT_MS, 1_000),
|
||||
10 * 60_000,
|
||||
);
|
||||
|
||||
const current = readControlState();
|
||||
const scalingDown = workers < current.workers;
|
||||
|
||||
const lock = tryAcquireOpsLock(`scale:${process.pid}`);
|
||||
if (!lock.ok) {
|
||||
return reply.code(409).send({ error: lock.error, holder: lock.holder });
|
||||
}
|
||||
|
||||
const wasPaused = await workflowQueue.isPaused();
|
||||
try {
|
||||
if (scalingDown) {
|
||||
await workflowQueue.pause();
|
||||
if (!force) {
|
||||
const drained = await waitUntilIdle(timeoutMs, lock.token);
|
||||
if (!drained.ok) {
|
||||
if (!wasPaused) {
|
||||
await workflowQueue.resume();
|
||||
patchControlState({ queuePaused: false });
|
||||
}
|
||||
return reply.code(409).send({
|
||||
error: "active jobs still running",
|
||||
active: drained.active,
|
||||
hint: "wait or retry with force=true",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const state = patchControlState({ workers });
|
||||
await ensureWorkers({ generation: state.generation, count: workers });
|
||||
|
||||
if (scalingDown && !wasPaused && !state.queuePaused) {
|
||||
await workflowQueue.resume();
|
||||
patchControlState({ queuePaused: false });
|
||||
}
|
||||
|
||||
return reply.send({ ok: true, desired: readControlState() });
|
||||
} catch (err) {
|
||||
log.error({ err }, "scale failed");
|
||||
return reply.code(500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
releaseOpsLock(lock.token);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function shutdown() {
|
||||
try {
|
||||
await workflowQueue.close();
|
||||
await closeRedis();
|
||||
await flushLogs();
|
||||
await db.destroy();
|
||||
disconnectPm2();
|
||||
} catch (err) {
|
||||
log.error({ err }, "control shutdown error");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600);
|
||||
server
|
||||
.listen({ host: "0.0.0.0", port })
|
||||
.then(() => {
|
||||
log.info(`Control is running on port ${port}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error({ err }, "failed to start control");
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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 +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";
|
||||
|
||||
@@ -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("|");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,16 +11,21 @@ export function isBinary(value) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||
*/
|
||||
export function toBuffer(value) {
|
||||
if (Buffer.isBuffer(value)) return value;
|
||||
if (value instanceof ArrayBuffer) return Buffer.from(value);
|
||||
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
|
||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||
*/
|
||||
export function summarizeBinary(value) {
|
||||
const buf = Buffer.isBuffer(value)
|
||||
? value
|
||||
: value instanceof ArrayBuffer
|
||||
? Buffer.from(value)
|
||||
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||
const buf = toBuffer(value);
|
||||
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
|
||||
return {
|
||||
type: "Buffer",
|
||||
@@ -30,6 +35,84 @@ export function summarizeBinary(value) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-safe Buffer that can be revived (dry-run / Try chaining).
|
||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||
*/
|
||||
export function encodeBinary(value) {
|
||||
const buf = toBuffer(value);
|
||||
return {
|
||||
type: "Buffer",
|
||||
encoding: "base64",
|
||||
data: buf.toString("base64"),
|
||||
length: buf.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function isWireBuffer(value) {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const obj = /** @type {{ type?: unknown, encoding?: unknown, data?: unknown }} */ (value);
|
||||
if (obj.type !== "Buffer") return false;
|
||||
if (obj.encoding === "base64" && typeof obj.data === "string") return true;
|
||||
return Array.isArray(obj.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace live Buffers with reconstructable JSON (for dry-run responses).
|
||||
* Display still uses summarizeBinary / safeSerialize.
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function encodeBinaryForWire(value) {
|
||||
const seen = new WeakSet();
|
||||
/** @param {unknown} v */
|
||||
function walk(v) {
|
||||
if (isBinary(v)) return encodeBinary(v);
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (v == null || typeof v !== "object") return v;
|
||||
if (seen.has(v)) return "[Circular]";
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) return v.map(walk);
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [k, val] of Object.entries(v)) out[k] = walk(val);
|
||||
return out;
|
||||
}
|
||||
return walk(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revive `{ type: "Buffer", encoding: "base64", data }` or Node `{ type, data: number[] }`.
|
||||
* Preview-only summaries (`preview` / `truncated`, no payload) are left as-is.
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function reviveBinaryFromWire(value) {
|
||||
const seen = new WeakSet();
|
||||
/** @param {unknown} v */
|
||||
function walk(v) {
|
||||
if (v == null || typeof v !== "object") return v;
|
||||
if (isWireBuffer(v)) {
|
||||
const obj = /** @type {{ encoding?: unknown, data: string | number[] }} */ (v);
|
||||
if (obj.encoding === "base64" && typeof obj.data === "string") {
|
||||
return Buffer.from(obj.data, "base64");
|
||||
}
|
||||
return Buffer.from(/** @type {number[]} */ (obj.data));
|
||||
}
|
||||
if (seen.has(v)) return v;
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) {
|
||||
for (let i = 0; i < v.length; i++) v[i] = walk(v[i]);
|
||||
return v;
|
||||
}
|
||||
const obj = /** @type {Record<string, unknown>} */ (v);
|
||||
for (const k of Object.keys(obj)) obj[k] = walk(obj[k]);
|
||||
return obj;
|
||||
}
|
||||
return walk(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON.stringify replacer. Must be a real function so `this` is the holder:
|
||||
* Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer.
|
||||
|
||||
+1
-399
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
{
|
||||
"name": "@jerapah-flow/server",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "runner.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch runner.js",
|
||||
"dev:pm2": "node dev-pm2.mjs",
|
||||
"start": "node runner.js",
|
||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\""
|
||||
"start:api": "node server.js",
|
||||
"start:worker": "node worker.js",
|
||||
"start:control": "node control.js",
|
||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
|
||||
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
|
||||
"test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
|
||||
"reset-admin": "node reset-admin.js",
|
||||
"test:profiles": "node test/profiles-smoke.js",
|
||||
"test:set-dry-run": "node test/set-dry-run-smoke.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jerapah-flow/shared": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.1111.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1111.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@@ -20,7 +30,9 @@
|
||||
"basic-ftp": "^6.2.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"bullmq": "^6.1.2",
|
||||
"fastify": "^5.12.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"jsonata": "^2.2.2",
|
||||
"knex": "^3.3.0",
|
||||
"mustache": "^4.2.0",
|
||||
@@ -29,6 +41,8 @@
|
||||
"nodemailer": "^9.0.5",
|
||||
"pino": "^10.3.1",
|
||||
"pino-roll": "^4.0.0",
|
||||
"pm2": "^6.0.13",
|
||||
"rss-parser": "^3.13.0",
|
||||
"ssh2-sftp-client": "^12.1.1",
|
||||
"webdav": "^5.10.0",
|
||||
"yaml": "^2.9.0"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import path from "path";
|
||||
import pm2 from "pm2";
|
||||
import { SERVER_ROOT } from "./paths.js";
|
||||
|
||||
const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
|
||||
|
||||
export const PM2_HTTP_NAME = "jflow-http";
|
||||
export const PM2_WORKER_NAME = "jflow-worker";
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function connectPm2() {
|
||||
return new Promise((resolve, reject) => {
|
||||
pm2.connect((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function disconnectPm2() {
|
||||
try {
|
||||
pm2.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import("pm2").ProcessDescription[]>}
|
||||
*/
|
||||
export function listPm2() {
|
||||
return new Promise((resolve, reject) => {
|
||||
pm2.list((err, list) => {
|
||||
if (err) reject(err);
|
||||
else resolve(list ?? []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {Promise<import("pm2").ProcessDescription[]>}
|
||||
*/
|
||||
export async function listPm2ByName(name) {
|
||||
const list = await listPm2();
|
||||
return list.filter((p) => p.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} app
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function startPm2App(app) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pm2.start(app, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function stopPm2App(name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pm2.stop(name, (err) => {
|
||||
if (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/not found|doesn't exist|process or namespace/i.test(msg)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function deletePm2App(name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pm2.delete(name, (err) => {
|
||||
if (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/not found|doesn't exist|process or namespace/i.test(msg)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {number} instances
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function scalePm2App(name, instances) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pm2.scale(name, instances, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function restartPm2App(name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pm2.restart(name, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a single PM2 process by id. Only jflow-http / jflow-worker.
|
||||
* @param {number} pmId
|
||||
* @returns {Promise<{ name: string, pmId: number }>}
|
||||
*/
|
||||
export async function restartPm2Process(pmId) {
|
||||
const id = Math.floor(Number(pmId));
|
||||
if (!Number.isFinite(id) || id < 0) {
|
||||
const err = new Error("invalid pmId");
|
||||
err.code = "BAD_REQUEST";
|
||||
throw err;
|
||||
}
|
||||
|
||||
const list = await listPm2();
|
||||
const proc = list.find((p) => Number(p.pm_id) === id);
|
||||
if (!proc) {
|
||||
const err = new Error("process not found");
|
||||
err.code = "NOT_FOUND";
|
||||
throw err;
|
||||
}
|
||||
if (proc.name !== PM2_HTTP_NAME && proc.name !== PM2_WORKER_NAME) {
|
||||
const err = new Error("process is not a JerapahFlow child");
|
||||
err.code = "FORBIDDEN";
|
||||
throw err;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
pm2.restart(id, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
return { name: proc.name, pmId: id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared env for child processes.
|
||||
* @param {{ generation: number }} opts
|
||||
*/
|
||||
export function childEnv(opts) {
|
||||
return {
|
||||
...process.env,
|
||||
JFLOW_CONFIG_GENERATION: String(opts.generation),
|
||||
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
|
||||
PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure HTTP app exists and matches desired running/stopped state.
|
||||
* @param {{ generation: number, running: boolean }} opts
|
||||
*/
|
||||
export async function ensureHttp(opts) {
|
||||
const existing = await listPm2ByName(PM2_HTTP_NAME);
|
||||
if (!opts.running) {
|
||||
if (existing.length) await stopPm2App(PM2_HTTP_NAME);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.length === 0) {
|
||||
await startPm2App({
|
||||
name: PM2_HTTP_NAME,
|
||||
script: path.join(SERVER_ROOT, "server.js"),
|
||||
cwd: REPO_ROOT,
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
max_restarts: 20,
|
||||
env: {
|
||||
...childEnv(opts),
|
||||
JFLOW_ROLE: "api",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const online = existing.some((p) => p.pm2_env?.status === "online");
|
||||
if (!online) {
|
||||
await restartPm2App(PM2_HTTP_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure worker app has `count` online forks (0 = stopped/deleted).
|
||||
* @param {{ generation: number, count: number }} opts
|
||||
*/
|
||||
export async function ensureWorkers(opts) {
|
||||
const count = Math.max(0, Math.min(32, Math.floor(opts.count)));
|
||||
const existing = await listPm2ByName(PM2_WORKER_NAME);
|
||||
|
||||
if (count === 0) {
|
||||
if (existing.length) {
|
||||
await stopPm2App(PM2_WORKER_NAME);
|
||||
await deletePm2App(PM2_WORKER_NAME);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.length === 0) {
|
||||
await startPm2App({
|
||||
name: PM2_WORKER_NAME,
|
||||
script: path.join(SERVER_ROOT, "worker.js"),
|
||||
cwd: REPO_ROOT,
|
||||
instances: count,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
max_restarts: 50,
|
||||
env: {
|
||||
...childEnv(opts),
|
||||
JFLOW_ROLE: "worker",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const current = existing.length;
|
||||
if (current !== count) {
|
||||
await scalePm2App(PM2_WORKER_NAME, count);
|
||||
}
|
||||
|
||||
const stopped = existing.filter((p) => p.pm2_env?.status !== "online");
|
||||
if (stopped.length) {
|
||||
await restartPm2App(PM2_WORKER_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard recycle HTTP + workers with updated generation env.
|
||||
* Children must be stopped first for a clean migrate window.
|
||||
*
|
||||
* @param {{ generation: number, http: boolean, workers: number }} opts
|
||||
*/
|
||||
export async function recreateChildren(opts) {
|
||||
await stopPm2App(PM2_HTTP_NAME);
|
||||
await deletePm2App(PM2_HTTP_NAME);
|
||||
await stopPm2App(PM2_WORKER_NAME);
|
||||
await deletePm2App(PM2_WORKER_NAME);
|
||||
|
||||
if (opts.http) {
|
||||
await ensureHttp({ generation: opts.generation, running: true });
|
||||
}
|
||||
if (opts.workers > 0) {
|
||||
await ensureWorkers({ generation: opts.generation, count: opts.workers });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize PM2 process status for the ops UI.
|
||||
*/
|
||||
export async function describeChildren() {
|
||||
const list = await listPm2();
|
||||
const http = list.filter((p) => p.name === PM2_HTTP_NAME);
|
||||
const workers = list.filter((p) => p.name === PM2_WORKER_NAME);
|
||||
|
||||
const mapOne = (p) => ({
|
||||
name: p.name,
|
||||
pmId: Number(p.pm_id),
|
||||
status: p.pm2_env?.status ?? "unknown",
|
||||
pid: p.pid ?? null,
|
||||
restarts: p.pm2_env?.restart_time ?? 0,
|
||||
uptime: p.pm2_env?.pm_uptime ?? null,
|
||||
generation: Number(p.pm2_env?.JFLOW_CONFIG_GENERATION ?? 0) || null,
|
||||
memory: Number(p.monit?.memory) || 0,
|
||||
cpu: Number(p.monit?.cpu) || 0,
|
||||
});
|
||||
|
||||
const httpMapped = http.map(mapOne);
|
||||
const workersMapped = workers.map(mapOne);
|
||||
const all = [...httpMapped, ...workersMapped];
|
||||
|
||||
return {
|
||||
http: httpMapped,
|
||||
workers: workersMapped,
|
||||
httpOnline: http.some((p) => p.pm2_env?.status === "online"),
|
||||
workerOnlineCount: workers.filter((p) => p.pm2_env?.status === "online").length,
|
||||
totals: {
|
||||
memory: all.reduce((sum, p) => sum + p.memory, 0),
|
||||
cpu: all.reduce((sum, p) => sum + p.cpu, 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getRepoRoot() {
|
||||
return REPO_ROOT;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./src/stores/profiles-store.js";
|
||||
+189
-203
@@ -23,18 +23,20 @@ import {
|
||||
storedEnvelope,
|
||||
} from "./step-result.js";
|
||||
import * as fsStore from "./fs-store.js";
|
||||
import {
|
||||
checkHttpAuth,
|
||||
resolveAuthMechanism,
|
||||
resolveUnauthorizedSpec,
|
||||
sendHttpPageOrJson,
|
||||
sendSuccessPage,
|
||||
} from "./http-trigger-auth.js";
|
||||
import { resolveConfigRefs } from "./config-refs.js";
|
||||
import { hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared";
|
||||
import {
|
||||
createHttpTriggerHandler,
|
||||
ensureHttpWildcardRoute,
|
||||
rebuildHttpRoutes,
|
||||
} from "./workflow-http-routes.js";
|
||||
import { getProfilePlain } from "./profiles-store.js";
|
||||
import {
|
||||
buildFailureAlertData,
|
||||
resolveFailureTriggerConfig,
|
||||
} from "./trigger-failure.js";
|
||||
import { enqueueWorkflowJob } from "./workflow-queue.js";
|
||||
import { ensureInitialRevision } from "./workflow-history.js";
|
||||
|
||||
/**
|
||||
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
|
||||
@@ -42,22 +44,19 @@ import {
|
||||
*/
|
||||
|
||||
const MAX_WORKFLOW_TRIGGER_DEPTH = 8;
|
||||
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
/**
|
||||
* @param {unknown} workflow
|
||||
*/
|
||||
function hasWorkflowTrigger(workflow) {
|
||||
if (!workflow || typeof workflow !== "object") return false;
|
||||
const triggers = /** @type {{ triggers?: Array<{ type?: string }> }} */ (workflow)
|
||||
.triggers;
|
||||
return (triggers ?? []).some((t) => t?.type === "workflow");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} server
|
||||
* @param {{
|
||||
* queue?: import("bullmq").Queue | null,
|
||||
* enableTriggers?: boolean,
|
||||
* }} [opts]
|
||||
* `enableTriggers` must be true only on the API (or all-in-one) process.
|
||||
* Workers reload workflow defs but must not own cron/HTTP trigger registration.
|
||||
*/
|
||||
export function createRegistry(server) {
|
||||
export function createRegistry(server, opts = {}) {
|
||||
const queue = opts.queue ?? null;
|
||||
const enableTriggers = opts.enableTriggers ?? true;
|
||||
/** @type {Map<string, WorkflowEntry>} */
|
||||
const workflows = new Map();
|
||||
/** @type {Map<string, string>} */
|
||||
@@ -68,7 +67,14 @@ export function createRegistry(server) {
|
||||
let pruneTask = null;
|
||||
/** @type {Map<string, HttpRouteEntry>} */
|
||||
const httpRoutes = new Map();
|
||||
let httpDispatcherRegistered = false;
|
||||
const httpDispatcherState = { registered: false };
|
||||
const dispatchHttpTrigger = createHttpTriggerHandler({
|
||||
httpRoutes,
|
||||
workflows,
|
||||
namespacedPath,
|
||||
enqueueWorkflow: (...args) => enqueueWorkflow(...args),
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Resolve a same-owner workflow that opts in with `type: workflow`.
|
||||
@@ -174,113 +180,10 @@ export function createRegistry(server) {
|
||||
* Fastify route once so path/method changes apply on reregister without restart.
|
||||
*/
|
||||
function registerHttpTriggers() {
|
||||
httpRoutes.clear();
|
||||
|
||||
for (const [key, { owner, workflow }] of workflows) {
|
||||
if (workflow.enabled === false) {
|
||||
log.debug(`Skipping disabled workflow HTTP triggers (${key})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const trigger of workflow.triggers ?? []) {
|
||||
if (trigger.type !== "HTTP") continue;
|
||||
|
||||
const method = String(trigger.method ?? "POST").toUpperCase();
|
||||
const url = namespacedPath(owner, trigger.path);
|
||||
const routeKey = `${method} ${url}`;
|
||||
|
||||
if (httpRoutes.has(routeKey)) {
|
||||
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
|
||||
continue;
|
||||
}
|
||||
httpRoutes.set(routeKey, { key, owner, trigger });
|
||||
log.debug(`Mapped HTTP trigger ${routeKey} (${key})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!httpDispatcherRegistered) {
|
||||
httpDispatcherRegistered = true;
|
||||
server.route({
|
||||
method: HTTP_METHODS,
|
||||
url: "/u/*",
|
||||
handler: dispatchHttpTrigger,
|
||||
});
|
||||
log.debug("Registered HTTP trigger wildcard dispatcher /u/*");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyRequest} req
|
||||
* @param {import("fastify").FastifyReply} reply
|
||||
*/
|
||||
async function dispatchHttpTrigger(req, reply) {
|
||||
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
|
||||
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
|
||||
const method = String(req.method ?? "GET").toUpperCase();
|
||||
const routeKey = `${method} ${url}`;
|
||||
const mapped = httpRoutes.get(routeKey);
|
||||
|
||||
if (!mapped) {
|
||||
return reply.code(404).send({ error: "not found" });
|
||||
}
|
||||
|
||||
const entry = workflows.get(mapped.key);
|
||||
if (!entry || entry.workflow?.enabled === false) {
|
||||
return reply.code(404).send({ error: "workflow disabled" });
|
||||
}
|
||||
|
||||
// Prefer live trigger from current workflow YAML (auth/response edits)
|
||||
const liveTrigger =
|
||||
(entry.workflow.triggers ?? []).find((t) => {
|
||||
if (t?.type !== "HTTP") return false;
|
||||
const m = String(t.method ?? "POST").toUpperCase();
|
||||
const p = namespacedPath(entry.owner, t.path);
|
||||
return m === method && p === url;
|
||||
}) ?? mapped.trigger;
|
||||
|
||||
if (liveTrigger.auth != null && liveTrigger.auth !== false) {
|
||||
const mechanism = await resolveAuthMechanism(liveTrigger.auth);
|
||||
if (!mechanism) {
|
||||
const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null);
|
||||
return sendHttpPageOrJson(reply, status, pageName, {
|
||||
error: "unauthorized",
|
||||
});
|
||||
}
|
||||
const ok = await checkHttpAuth(req, mechanism, {
|
||||
owner: entry.owner,
|
||||
workflowKey: mapped.key,
|
||||
});
|
||||
if (!ok) {
|
||||
const { status, pageName } = resolveUnauthorizedSpec(
|
||||
liveTrigger,
|
||||
mechanism,
|
||||
);
|
||||
return sendHttpPageOrJson(reply, status, pageName, {
|
||||
error: "unauthorized",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runWorkflow(
|
||||
mapped.key,
|
||||
{ data: req.body },
|
||||
{ type: "http", detail: `${method} ${url}` },
|
||||
);
|
||||
if (result.status === "failed") {
|
||||
return reply.code(500).send({
|
||||
runId: result.runId,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultBody = {
|
||||
runId: result.runId,
|
||||
result: result.result,
|
||||
};
|
||||
if (typeof liveTrigger.response === "string" && liveTrigger.response) {
|
||||
return sendSuccessPage(reply, liveTrigger.response, defaultBody);
|
||||
}
|
||||
return reply.send(defaultBody);
|
||||
rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log });
|
||||
ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, {
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
function registerCronTriggers() {
|
||||
@@ -308,7 +211,7 @@ export function createRegistry(server) {
|
||||
schedule,
|
||||
() => {
|
||||
log.debug(`cron firing ${key} (${schedule})`);
|
||||
return runWorkflow(
|
||||
return enqueueWorkflow(
|
||||
key,
|
||||
{ data: workflow.data ?? null },
|
||||
{ type: "cron", detail: schedule },
|
||||
@@ -544,14 +447,13 @@ export function createRegistry(server) {
|
||||
);
|
||||
}
|
||||
const destKey = resolveWorkflowTriggerKey(owner, name);
|
||||
return runWorkflow(
|
||||
return enqueueWorkflow(
|
||||
destKey,
|
||||
{ data },
|
||||
{ type: "workflow", detail: parentKey },
|
||||
{
|
||||
parentRunId,
|
||||
depth: depth + 1,
|
||||
detach: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -579,8 +481,21 @@ export function createRegistry(server) {
|
||||
owner,
|
||||
depth,
|
||||
) {
|
||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
const unresolvedConfig = parsed.config;
|
||||
let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
let unresolvedConfig = parsed.config;
|
||||
if (parsed.kind === "script" && parsed.profile) {
|
||||
const profile = await getProfilePlain(owner, parsed.profile);
|
||||
if (!profile) {
|
||||
throw new Error(`profile "${parsed.profile}" not found`);
|
||||
}
|
||||
if (parsed.script && parsed.script !== profile.script) {
|
||||
throw new Error(
|
||||
`step script "${parsed.script}" does not match profile "${parsed.profile}" script "${profile.script}"`,
|
||||
);
|
||||
}
|
||||
script = profile.script;
|
||||
unresolvedConfig = mergeProfileConfig(profile.config, parsed.config);
|
||||
}
|
||||
const incomingContext = normalizeContext(ctx.context);
|
||||
const step = await store.startStep({
|
||||
runId,
|
||||
@@ -696,32 +611,36 @@ export function createRegistry(server) {
|
||||
"triggering failure alert workflow",
|
||||
);
|
||||
|
||||
await runWorkflow(
|
||||
await enqueueWorkflow(
|
||||
destKey,
|
||||
{ data: alertData },
|
||||
{ type: "workflow", detail: `failure:${opts.key}` },
|
||||
{
|
||||
parentRunId: opts.runId,
|
||||
depth: opts.depth + 1,
|
||||
detach: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a queued run and push a BullMQ job. Returns immediately.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {{ data?: unknown, context?: unknown }} context
|
||||
* @param {{ type: string, detail?: string | null }} trigger
|
||||
* @param {{
|
||||
* parentRunId?: string | null,
|
||||
* depth?: number,
|
||||
* detach?: boolean,
|
||||
* }} [opts]
|
||||
*/
|
||||
async function runWorkflow(key, context, trigger, opts = {}) {
|
||||
async function enqueueWorkflow(key, context, trigger, opts = {}) {
|
||||
const parentRunId = opts.parentRunId ?? null;
|
||||
const depth = opts.depth ?? 0;
|
||||
const detach = opts.detach === true;
|
||||
|
||||
if (!queue) {
|
||||
log.error({ workflow: key }, "workflow queue is not configured");
|
||||
return { runId: null, status: "failed", error: "workflow queue is not configured" };
|
||||
}
|
||||
|
||||
const entry = workflows.get(key);
|
||||
if (!entry) {
|
||||
@@ -729,91 +648,153 @@ export function createRegistry(server) {
|
||||
return { runId: null, status: "failed", error: "workflow not found" };
|
||||
}
|
||||
|
||||
const { owner, workflow } = entry;
|
||||
const { owner, file, workflow } = entry;
|
||||
if (workflow?.enabled === false && trigger.type !== "manual") {
|
||||
log.debug({ workflow: key, trigger }, "skipping disabled workflow");
|
||||
return { runId: null, status: "failed", error: "workflow disabled" };
|
||||
}
|
||||
|
||||
const input = context.data ?? workflow.data ?? null;
|
||||
const ensured = await ensureInitialRevision({ owner, file });
|
||||
const run = await store.startRun({
|
||||
owner,
|
||||
workflow: key,
|
||||
workflowName: workflow?.name,
|
||||
trigger,
|
||||
input: context.data,
|
||||
input,
|
||||
parentRunId,
|
||||
status: "queued",
|
||||
workflowRevision: ensured?.revision ?? null,
|
||||
});
|
||||
const runLog = log.child({ runId: run.id, owner, workflow: key });
|
||||
runLog.debug(detach ? "running workflow (detached)" : "running workflow");
|
||||
|
||||
const initialCtx = {
|
||||
data: context.data ?? workflow.data ?? null,
|
||||
context: normalizeContext(context.context),
|
||||
};
|
||||
|
||||
const execute = async () => {
|
||||
let ctx = initialCtx;
|
||||
try {
|
||||
const compiled = compileWorkflowScripts(workflow.scripts);
|
||||
if (compiled.dagMode) {
|
||||
ctx = await runDagSteps(
|
||||
compiled,
|
||||
ctx,
|
||||
run.id,
|
||||
runLog,
|
||||
key,
|
||||
owner,
|
||||
depth,
|
||||
);
|
||||
} else {
|
||||
ctx = await runLinearSteps(
|
||||
compiled,
|
||||
ctx,
|
||||
run.id,
|
||||
runLog,
|
||||
key,
|
||||
owner,
|
||||
depth,
|
||||
);
|
||||
}
|
||||
await store.finishRun(run.id, "success", storedEnvelope(ctx));
|
||||
return { runId: run.id, status: "success", result: storedEnvelope(ctx) };
|
||||
} catch (err) {
|
||||
runLog.error({ err }, "workflow failed");
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
await store.finishRun(run.id, "failed", null, err);
|
||||
try {
|
||||
await maybeTriggerFailureWorkflow({
|
||||
key,
|
||||
owner,
|
||||
workflow,
|
||||
runId: run.id,
|
||||
trigger,
|
||||
error,
|
||||
depth,
|
||||
});
|
||||
} catch (alertErr) {
|
||||
runLog.error({ err: alertErr }, "failed to trigger failure alert workflow");
|
||||
}
|
||||
return {
|
||||
runId: run.id,
|
||||
status: "failed",
|
||||
error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (detach) {
|
||||
execute().catch((err) => {
|
||||
runLog.error({ err }, "detached workflow failed unexpectedly");
|
||||
try {
|
||||
const job = await enqueueWorkflowJob(queue, {
|
||||
runId: run.id,
|
||||
key,
|
||||
depth,
|
||||
});
|
||||
return { runId: run.id, status: "started" };
|
||||
await store.setRunJobId(run.id, String(job.id));
|
||||
runLog.debug({ jobId: job.id }, "workflow queued");
|
||||
return { runId: run.id, status: "queued", jobId: String(job.id) };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
runLog.error({ err }, "failed to enqueue workflow");
|
||||
await store.finishRun(run.id, "failed", null, err);
|
||||
return { runId: run.id, status: "failed", error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a previously queued run (BullMQ worker entrypoint).
|
||||
*
|
||||
* @param {{ runId: string, key: string, depth?: number }} jobData
|
||||
*/
|
||||
async function executeQueuedRun(jobData) {
|
||||
const runId = jobData.runId;
|
||||
const key = jobData.key;
|
||||
const depth = jobData.depth ?? 0;
|
||||
|
||||
const entry = workflows.get(key);
|
||||
if (!entry) {
|
||||
await store.finishRun(runId, "failed", null, new Error("workflow not found"));
|
||||
return { runId, status: "failed", error: "workflow not found" };
|
||||
}
|
||||
|
||||
return execute();
|
||||
const existing = await store.getRun(runId);
|
||||
if (!existing) {
|
||||
return { runId, status: "failed", error: "run not found" };
|
||||
}
|
||||
if (existing.status === "success" || existing.status === "failed") {
|
||||
return { runId, status: existing.status };
|
||||
}
|
||||
|
||||
const marked = await store.markRunRunning(runId);
|
||||
if (!marked.updated && existing.status !== "running") {
|
||||
return { runId, status: existing.status };
|
||||
}
|
||||
|
||||
const { owner, workflow } = entry;
|
||||
const runLog = log.child({ runId, owner, workflow: key });
|
||||
runLog.debug("running queued workflow");
|
||||
|
||||
const trigger = {
|
||||
type: existing.trigger_type,
|
||||
detail: existing.trigger_detail,
|
||||
};
|
||||
// jobId is BullMQ's id; enqueue uses runId as jobId, so they match today.
|
||||
const jobId =
|
||||
existing.job_id != null && String(existing.job_id).length > 0
|
||||
? String(existing.job_id)
|
||||
: runId;
|
||||
const initialCtx = {
|
||||
data: existing.input ?? workflow.data ?? null,
|
||||
context: {
|
||||
runId,
|
||||
jobId,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const compiled = compileWorkflowScripts(workflow.scripts);
|
||||
let ctx;
|
||||
if (compiled.dagMode) {
|
||||
ctx = await runDagSteps(
|
||||
compiled,
|
||||
initialCtx,
|
||||
runId,
|
||||
runLog,
|
||||
key,
|
||||
owner,
|
||||
depth,
|
||||
);
|
||||
} else {
|
||||
ctx = await runLinearSteps(
|
||||
compiled,
|
||||
initialCtx,
|
||||
runId,
|
||||
runLog,
|
||||
key,
|
||||
owner,
|
||||
depth,
|
||||
);
|
||||
}
|
||||
await store.finishRun(runId, "success", storedEnvelope(ctx));
|
||||
return { runId, status: "success", result: storedEnvelope(ctx) };
|
||||
} catch (err) {
|
||||
runLog.error({ err }, "workflow failed");
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
await store.finishRun(runId, "failed", null, err);
|
||||
try {
|
||||
await maybeTriggerFailureWorkflow({
|
||||
key,
|
||||
owner,
|
||||
workflow,
|
||||
runId,
|
||||
trigger,
|
||||
error,
|
||||
depth,
|
||||
});
|
||||
} catch (alertErr) {
|
||||
runLog.error({ err: alertErr }, "failed to trigger failure alert workflow");
|
||||
}
|
||||
return { runId, status: "failed", error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Prefer enqueueWorkflow; kept as alias for callers.
|
||||
*/
|
||||
async function runWorkflow(key, context, trigger, opts = {}) {
|
||||
return enqueueWorkflow(key, context, trigger, opts);
|
||||
}
|
||||
|
||||
function reregister() {
|
||||
registerWorkflows();
|
||||
// Cron/HTTP triggers are API-owned. Workers also subscribe to reload and
|
||||
// must only refresh the in-memory workflow map — otherwise N workers each
|
||||
// schedule the same cron and enqueue N duplicate jobs.
|
||||
if (!enableTriggers) return;
|
||||
registerHttpTriggers();
|
||||
registerCronTriggers();
|
||||
}
|
||||
@@ -824,7 +805,10 @@ export function createRegistry(server) {
|
||||
for (const raw of workflow.scripts ?? []) {
|
||||
try {
|
||||
const parsed = parseScriptStep(raw);
|
||||
if (parsed.kind === "script") refs.add(parsed.script);
|
||||
if (parsed.kind === "script") {
|
||||
if (parsed.script) refs.add(parsed.script);
|
||||
if (parsed.profile) refs.add(`profile:${parsed.profile}`);
|
||||
}
|
||||
} catch {
|
||||
// skip invalid steps
|
||||
}
|
||||
@@ -842,6 +826,8 @@ export function createRegistry(server) {
|
||||
registerPruneJob,
|
||||
reregister,
|
||||
runWorkflow,
|
||||
enqueueWorkflow,
|
||||
executeQueuedRun,
|
||||
referencedScripts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,190 +1,8 @@
|
||||
import fs from "fs";
|
||||
import fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
import jwt from "@fastify/jwt";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { migrate, db } from "./db.js";
|
||||
import { log, enableLogPersistence, flushLogs } from "./logger.js";
|
||||
import * as store from "./store.js";
|
||||
import { createRegistry } from "./registry.js";
|
||||
import { COOKIE, OPEN_API_ROUTES } from "./src/api/auth.js";
|
||||
import authPlugin from "./src/api/auth.js";
|
||||
import usersPlugin from "./src/api/users.js";
|
||||
import scriptsPluginFactory from "./src/api/scripts.js";
|
||||
import workflowsPluginFactory from "./src/api/workflows.js";
|
||||
import runsPlugin from "./src/api/runs.js";
|
||||
import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||
import secretsPlugin from "./src/api/secrets.js";
|
||||
import kvPlugin from "./src/api/kv.js";
|
||||
import variablesPlugin from "./src/api/variables.js";
|
||||
import httpPagesPlugin from "./src/api/http-pages.js";
|
||||
import httpAuthsPlugin from "./src/api/http-auths.js";
|
||||
import { WEB_DIST } from "./paths.js";
|
||||
import { resolveSecretsKeyMaterial } from "./secrets.js";
|
||||
import { startApp } from "./start-app.js";
|
||||
|
||||
await migrate();
|
||||
enableLogPersistence();
|
||||
|
||||
const jwtSecret =
|
||||
process.env.JFLOW_JWT_SECRET ??
|
||||
(process.env.NODE_ENV === "production" ? "" : "jflow-dev-secret");
|
||||
|
||||
if (!jwtSecret) {
|
||||
log.error("JFLOW_JWT_SECRET is required in production");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
resolveSecretsKeyMaterial();
|
||||
} catch (err) {
|
||||
log.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = fastify({ loggerInstance: log });
|
||||
|
||||
await server.register(cookie);
|
||||
await server.register(jwt, {
|
||||
secret: jwtSecret,
|
||||
cookie: {
|
||||
cookieName: COOKIE,
|
||||
signed: false,
|
||||
},
|
||||
// Monolith / local `pnpm dev`: API + worker + migrate in one process.
|
||||
await startApp({
|
||||
role: process.env.JFLOW_ROLE || "all",
|
||||
migrate: true,
|
||||
serveStaticUi: true,
|
||||
});
|
||||
await server.register(cors, {
|
||||
origin: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:5173",
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
server.decorate("authenticate", async function authenticate(req, reply) {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch {
|
||||
return reply.code(401).send({ error: "unauthorized" });
|
||||
}
|
||||
});
|
||||
|
||||
server.decorate("requireAdmin", async function requireAdmin(req, reply) {
|
||||
if (req.user?.role !== "admin") {
|
||||
return reply.code(403).send({ error: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
const registry = createRegistry(server);
|
||||
registry.registerWorkflows();
|
||||
registry.registerHttpTriggers();
|
||||
registry.registerCronTriggers();
|
||||
registry.registerPruneJob();
|
||||
|
||||
await server.register(
|
||||
async (api) => {
|
||||
api.addHook("onRequest", async (req, reply) => {
|
||||
const raw = (req.url || "").split("?")[0];
|
||||
const stripped = raw.replace(/^\/api/, "") || "/";
|
||||
const routeUrl = req.routeOptions?.url || stripped;
|
||||
const open =
|
||||
OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) ||
|
||||
OPEN_API_ROUTES.has(`${req.method} ${stripped}`);
|
||||
if (open) return;
|
||||
await server.authenticate(req, reply);
|
||||
});
|
||||
await api.register(authPlugin);
|
||||
await api.register(usersPlugin);
|
||||
await api.register(secretsPlugin);
|
||||
await api.register(variablesPlugin);
|
||||
await api.register(kvPlugin);
|
||||
await api.register(httpPagesPlugin);
|
||||
await api.register(httpAuthsPlugin);
|
||||
await api.register(scriptsPluginFactory(registry));
|
||||
await api.register(workflowsPluginFactory(registry));
|
||||
await api.register(runsPlugin);
|
||||
await api.register(dashboardPluginFactory(registry));
|
||||
},
|
||||
{ prefix: "/api" },
|
||||
);
|
||||
|
||||
server.post(
|
||||
"/admin/workflows/reregister",
|
||||
{ onRequest: [server.authenticate] },
|
||||
async (_req, reply) => {
|
||||
registry.reregister();
|
||||
return reply.send({ message: "Workflows refreshed" });
|
||||
},
|
||||
);
|
||||
|
||||
server.get(
|
||||
"/admin/runs",
|
||||
{ onRequest: [server.authenticate] },
|
||||
async (req, reply) => {
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query);
|
||||
const limit = q.limit ? Number(q.limit) : undefined;
|
||||
const runs = await store.listRuns({
|
||||
owner: q.owner,
|
||||
workflow: q.workflow,
|
||||
status: q.status,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
before: q.before,
|
||||
});
|
||||
return reply.send({ runs });
|
||||
},
|
||||
);
|
||||
|
||||
server.get(
|
||||
"/admin/runs/:id",
|
||||
{ onRequest: [server.authenticate] },
|
||||
async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const run = await store.getRun(id);
|
||||
if (!run) {
|
||||
return reply.code(404).send({ error: "run not found" });
|
||||
}
|
||||
return reply.send(run);
|
||||
},
|
||||
);
|
||||
|
||||
if (fs.existsSync(WEB_DIST)) {
|
||||
await server.register(fastifyStatic, {
|
||||
root: WEB_DIST,
|
||||
wildcard: false,
|
||||
});
|
||||
server.setNotFoundHandler((req, reply) => {
|
||||
const url = req.raw.url ?? "";
|
||||
if (
|
||||
url.startsWith("/api") ||
|
||||
url.startsWith("/u/") ||
|
||||
url.startsWith("/admin")
|
||||
) {
|
||||
return reply.code(404).send({ error: "not found" });
|
||||
}
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
try {
|
||||
await flushLogs();
|
||||
await db.destroy();
|
||||
} catch (err) {
|
||||
log.error({ err }, "shutdown error");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
const port = Number(process.env.PORT ?? 8700);
|
||||
|
||||
server
|
||||
.listen({
|
||||
host: "0.0.0.0",
|
||||
port,
|
||||
})
|
||||
.then(() => {
|
||||
log.info(`Server is running on port ${port}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error({ err }, "failed to start server");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import axios from "axios";
|
||||
import pino from "pino";
|
||||
import { createKvApi } from "./kv-store.js";
|
||||
import { createFingerprintApi } from "./script-fingerprint.js";
|
||||
import { SCRIPTS_DIR } from "./paths.js";
|
||||
import { resolveScriptRef, createPluginRequire } from "./plugin-store.js";
|
||||
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
|
||||
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
|
||||
import { getSecretPlaintext } from "./secrets-store.js";
|
||||
@@ -296,15 +296,60 @@ function createScreenedAxios(log) {
|
||||
});
|
||||
}
|
||||
|
||||
function createRestrictedRequire(screenedAxios) {
|
||||
const BLOCKED_PLUGIN_MODULES = new Set([
|
||||
"child_process",
|
||||
"node:child_process",
|
||||
"cluster",
|
||||
"node:cluster",
|
||||
"fs",
|
||||
"node:fs",
|
||||
"fs/promises",
|
||||
"node:fs/promises",
|
||||
"module",
|
||||
"node:module",
|
||||
"vm",
|
||||
"node:vm",
|
||||
"worker_threads",
|
||||
"node:worker_threads",
|
||||
"v8",
|
||||
"node:v8",
|
||||
"inspector",
|
||||
"node:inspector",
|
||||
"sqlite",
|
||||
"node:sqlite",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {import("axios").AxiosInstance} screenedAxios
|
||||
* @param {string | null} [pluginDirectory]
|
||||
*/
|
||||
function createRestrictedRequire(screenedAxios, pluginDirectory = null) {
|
||||
const pluginRequire = pluginDirectory
|
||||
? createPluginRequire(pluginDirectory)
|
||||
: null;
|
||||
|
||||
return function restrictedRequire(id) {
|
||||
if (typeof id !== "string" || !ALLOWED_MODULES.has(id)) {
|
||||
if (typeof id !== "string") {
|
||||
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
|
||||
}
|
||||
if (id === "axios") {
|
||||
return screenedAxios;
|
||||
if (BLOCKED_PLUGIN_MODULES.has(id)) {
|
||||
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
|
||||
}
|
||||
return hostRequire(id);
|
||||
if (ALLOWED_MODULES.has(id)) {
|
||||
if (id === "axios") return screenedAxios;
|
||||
return hostRequire(id);
|
||||
}
|
||||
if (pluginRequire) {
|
||||
try {
|
||||
return pluginRequire(id);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`require(${JSON.stringify(id)}) failed in plugin: ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,6 +436,7 @@ const $workflowsStub = {
|
||||
* workflowName: string,
|
||||
* owner?: string,
|
||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||
* pluginDir?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
function createScriptSandbox({
|
||||
@@ -399,6 +445,7 @@ function createScriptSandbox({
|
||||
workflowName,
|
||||
owner = "default",
|
||||
$workflows = $workflowsStub,
|
||||
pluginDir = null,
|
||||
}) {
|
||||
const scriptLog = log.child({ workflow: workflowName, script });
|
||||
const $axios = createScreenedAxios(scriptLog);
|
||||
@@ -418,7 +465,7 @@ function createScriptSandbox({
|
||||
$vars,
|
||||
$responses,
|
||||
$workflows,
|
||||
require: createRestrictedRequire($axios),
|
||||
require: createRestrictedRequire($axios, pluginDir),
|
||||
};
|
||||
|
||||
vm.createContext(sandbox, {
|
||||
@@ -439,7 +486,7 @@ const inspectLog = pino({ level: "silent" });
|
||||
* @param {unknown} fn
|
||||
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
|
||||
*/
|
||||
export function extractScriptMeta(fn) {
|
||||
function extractScriptMeta(fn) {
|
||||
if (typeof fn !== "function") {
|
||||
return { meta: null, metaError: "default export must be a function" };
|
||||
}
|
||||
@@ -470,8 +517,29 @@ export function extractScriptMeta(fn) {
|
||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||
* }} opts
|
||||
*/
|
||||
function instantiateCompiled(compiled, { log, script, workflowName, owner, $workflows }) {
|
||||
const sandbox = createScriptSandbox({ log, script, workflowName, owner, $workflows });
|
||||
/**
|
||||
* @param {import("vm").Script} compiled
|
||||
* @param {{
|
||||
* log: import("pino").Logger,
|
||||
* script: string,
|
||||
* workflowName: string,
|
||||
* owner?: string,
|
||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||
* pluginDir?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
function instantiateCompiled(
|
||||
compiled,
|
||||
{ log, script, workflowName, owner, $workflows, pluginDir = null },
|
||||
) {
|
||||
const sandbox = createScriptSandbox({
|
||||
log,
|
||||
script,
|
||||
workflowName,
|
||||
owner,
|
||||
$workflows,
|
||||
pluginDir,
|
||||
});
|
||||
return compiled.runInContext(sandbox);
|
||||
}
|
||||
|
||||
@@ -486,6 +554,7 @@ function instantiateCompiled(compiled, { log, script, workflowName, owner, $work
|
||||
* workflowName?: string,
|
||||
* owner?: string,
|
||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||
* pluginDir?: string | null,
|
||||
* }} [opts]
|
||||
*/
|
||||
export function instantiateScriptSource(script, source, opts = {}) {
|
||||
@@ -496,6 +565,7 @@ export function instantiateScriptSource(script, source, opts = {}) {
|
||||
workflowName: opts.workflowName ?? "inspect",
|
||||
owner: opts.owner ?? "default",
|
||||
$workflows: opts.$workflows,
|
||||
pluginDir: opts.pluginDir ?? null,
|
||||
});
|
||||
return { fn, ...extractScriptMeta(fn) };
|
||||
}
|
||||
@@ -519,17 +589,28 @@ export function inspectScriptSource(script, source) {
|
||||
}
|
||||
|
||||
function loadCompiledScript(script) {
|
||||
const filePath = path.join(SCRIPTS_DIR, script);
|
||||
const resolved = resolveScriptRef(script);
|
||||
if (resolved.error || !resolved.filePath) {
|
||||
throw new Error(resolved.error || `script not found: ${script}`);
|
||||
}
|
||||
const filePath = resolved.filePath;
|
||||
const { mtimeMs } = fs.statSync(filePath);
|
||||
const cached = scriptCache.get(script);
|
||||
const cacheKey = `${resolved.kind}:${resolved.scriptRef}:${filePath}`;
|
||||
const cached = scriptCache.get(cacheKey);
|
||||
if (cached && cached.mtimeMs === mtimeMs) {
|
||||
return cached.compiled;
|
||||
return cached;
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const compiled = compileScriptSource(source, filePath);
|
||||
scriptCache.set(script, { compiled, mtimeMs });
|
||||
return compiled;
|
||||
const entry = {
|
||||
compiled,
|
||||
mtimeMs,
|
||||
pluginDir: resolved.pluginDir ?? null,
|
||||
scriptRef: resolved.scriptRef,
|
||||
};
|
||||
scriptCache.set(cacheKey, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,13 +626,14 @@ function loadCompiledScript(script) {
|
||||
* }} opts
|
||||
*/
|
||||
export async function runScript(script, ctx, { log, workflowName, owner, $workflows }) {
|
||||
const compiled = loadCompiledScript(script);
|
||||
const fn = instantiateCompiled(compiled, {
|
||||
const loaded = loadCompiledScript(script);
|
||||
const fn = instantiateCompiled(loaded.compiled, {
|
||||
log,
|
||||
script,
|
||||
script: loaded.scriptRef,
|
||||
workflowName,
|
||||
owner,
|
||||
$workflows,
|
||||
pluginDir: loaded.pluginDir,
|
||||
});
|
||||
return await fn(ctx);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import jsonata from "jsonata";
|
||||
|
||||
function ensureDataObject(ctx) {
|
||||
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
|
||||
ctx.data = {};
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeData(data) {
|
||||
if (data != null && typeof data === "object" && !Array.isArray(data)) {
|
||||
return { ...data };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const ALLOWED_METHODS = new Set([
|
||||
@@ -35,6 +43,19 @@ function previewValue(value) {
|
||||
return { preview: `${json.slice(0, 500)}...`, truncated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Eval context for fingerprint/transform JSONata (reads `data.*`).
|
||||
* @param {unknown} ctx
|
||||
* @param {Record<string, unknown>} data
|
||||
*/
|
||||
function evalCtx(ctx, data) {
|
||||
return {
|
||||
data,
|
||||
context: passContext(ctx),
|
||||
config: ctx?.config ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
async function detectUrlChanges(ctx) {
|
||||
const url = ctx.config?.url;
|
||||
if (typeof url !== "string" || url.length === 0) {
|
||||
@@ -63,7 +84,7 @@ async function detectUrlChanges(ctx) {
|
||||
);
|
||||
}
|
||||
|
||||
ensureDataObject(ctx);
|
||||
const data = mergeData(ctx.data);
|
||||
|
||||
log.info({ url, method, key }, "detect-url-changes: fetching url");
|
||||
const response = await $axios.request({
|
||||
@@ -77,28 +98,31 @@ async function detectUrlChanges(ctx) {
|
||||
"detect-url-changes: fetch complete",
|
||||
);
|
||||
|
||||
ctx.data.httpResponse = response.data;
|
||||
data.httpResponse = response.data;
|
||||
|
||||
let fingerprintSource = ctx.data.httpResponse;
|
||||
let fingerprintSource = data.httpResponse;
|
||||
if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) {
|
||||
log.info(
|
||||
{ jsonata: fingerprintExpr },
|
||||
"detect-url-changes: evaluating fingerprint jsonata",
|
||||
);
|
||||
fingerprintSource = await jsonata(fingerprintExpr).evaluate(ctx);
|
||||
fingerprintSource = await jsonata(fingerprintExpr).evaluate(evalCtx(ctx, data));
|
||||
}
|
||||
|
||||
const result = await $fingerprint.claim(key, fingerprintSource, {
|
||||
maxAge: ctx.config?.maxAge,
|
||||
});
|
||||
|
||||
ctx.data.hasChanges = result.changed;
|
||||
ctx.data.fingerprint = result.hash;
|
||||
ctx.data.fingerprintChanged = result.changed;
|
||||
ctx.data.fingerprintPrevious = result.previous;
|
||||
ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt;
|
||||
ctx.data.fingerprintAge = result.ageMs;
|
||||
ctx.data.fingerprintExpired = result.expired;
|
||||
const extra = {
|
||||
hasChanges: result.changed,
|
||||
fingerprint: result.hash,
|
||||
fingerprintChanged: result.changed,
|
||||
fingerprintPrevious: result.previous,
|
||||
fingerprintAt: result.changed ? result.at : result.previousAt,
|
||||
fingerprintAge: result.ageMs,
|
||||
fingerprintExpired: result.expired,
|
||||
};
|
||||
Object.assign(data, extra);
|
||||
|
||||
log.info(
|
||||
{
|
||||
@@ -116,28 +140,35 @@ async function detectUrlChanges(ctx) {
|
||||
{ outputVar, jsonata: transformExpr },
|
||||
"detect-url-changes: evaluating transform jsonata",
|
||||
);
|
||||
const transformed = await jsonata(transformExpr).evaluate(ctx);
|
||||
ctx.data[outputVar] = transformed;
|
||||
const transformed = await jsonata(transformExpr).evaluate(evalCtx(ctx, data));
|
||||
data[outputVar] = transformed;
|
||||
log.info(
|
||||
{ outputVar, value: previewValue(transformed) },
|
||||
"detect-url-changes: saved transform result",
|
||||
);
|
||||
} else {
|
||||
ctx.data[outputVar] = ctx.data.httpResponse;
|
||||
data[outputVar] = data.httpResponse;
|
||||
log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar");
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {{ output: Record<string, unknown>, context: Record<string, unknown>, skipRemaining?: true }} */
|
||||
const envelope = {
|
||||
output: data,
|
||||
context: { ...passContext(ctx), ...extra },
|
||||
};
|
||||
if (skipRemainingWhenUnchanged && !result.changed) {
|
||||
ctx.skipRemaining = true;
|
||||
envelope.skipRemaining = true;
|
||||
}
|
||||
|
||||
return ctx;
|
||||
return envelope;
|
||||
}
|
||||
|
||||
detectUrlChanges.meta = {
|
||||
description:
|
||||
"Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run",
|
||||
previewConfigKey: "url",
|
||||
tags: ["HTTP"],
|
||||
reads: "ctx",
|
||||
config: {
|
||||
url: { type: "string", required: true, description: "URL to fetch" },
|
||||
method: {
|
||||
@@ -203,6 +234,15 @@ detectUrlChanges.meta = {
|
||||
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
|
||||
fingerprintExpired: { type: "boolean" },
|
||||
},
|
||||
context: {
|
||||
hasChanges: { type: "boolean" },
|
||||
fingerprint: { type: "string" },
|
||||
fingerprintChanged: { type: "boolean" },
|
||||
fingerprintPrevious: { type: "string", required: false },
|
||||
fingerprintAt: { type: "string", required: false },
|
||||
fingerprintAge: { type: "number", required: false },
|
||||
fingerprintExpired: { type: "boolean" },
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
config: {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,144 +1 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner } from "./fs-store.js";
|
||||
import { decryptSecret, encryptSecret } from "./secrets.js";
|
||||
import { registerPlaintext } from "./secret-value.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertSecretName(name) {
|
||||
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
|
||||
const err = new Error("invalid secret name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function publicSecret(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listSecrets(filters = {}) {
|
||||
let q = db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getSecretById(id) {
|
||||
const row = await db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.where({ id })
|
||||
.first();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, value: string }} opts
|
||||
*/
|
||||
export async function upsertSecret({ owner, name, value }) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
const err = new Error("value is required");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
registerPlaintext(value);
|
||||
const { ciphertext, iv, authTag } = encryptSecret(value);
|
||||
const now = nowIso();
|
||||
const existing = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("secrets")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("secrets").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: secretName,
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteSecret(id) {
|
||||
const n = await db("secrets").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a named secret for an owner. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function getSecretPlaintext(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
const row = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
try {
|
||||
const plaintext = decryptSecret({
|
||||
ciphertext: row.ciphertext,
|
||||
iv: row.iv,
|
||||
authTag: row.auth_tag,
|
||||
});
|
||||
registerPlaintext(plaintext);
|
||||
return plaintext;
|
||||
} catch {
|
||||
throw new Error(`failed to decrypt secret "${secretName}"`);
|
||||
}
|
||||
}
|
||||
export * from "./src/stores/secrets-store.js";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ export default function dashboardPluginFactory(registry) {
|
||||
}
|
||||
}
|
||||
|
||||
const [running, streaks, failedEvents, recent] = await Promise.all([
|
||||
store.listRuns({ status: "running", limit: 10 }),
|
||||
const [active, streaks, failedEvents, recent] = await Promise.all([
|
||||
store.listRuns({ status: ["queued", "running"], limit: 10 }),
|
||||
store.listConsecutiveFailureStreaks({ minCount: 4, limit: 10 }),
|
||||
store.listRuns({ status: "failed", limit: 5 }),
|
||||
store.listRuns({ limit: 10 }),
|
||||
@@ -75,7 +75,7 @@ export default function dashboardPluginFactory(registry) {
|
||||
scriptCount: fsStore.listScriptFiles().length,
|
||||
enabledCount,
|
||||
brokenCount,
|
||||
running,
|
||||
running: active,
|
||||
needsAttention: {
|
||||
consecutiveFailures: streaks.items,
|
||||
consecutiveFailureCount: streaks.total,
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
import * as store from "../../store.js";
|
||||
import { queryRunsFromRequest } from "./run-query.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function runsPlugin(fastify) {
|
||||
fastify.get("/runs", async (req) => {
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||
const limit = q.limit ? Number(q.limit) : undefined;
|
||||
const runs = await store.listRuns({
|
||||
owner: q.owner,
|
||||
workflow: q.workflow,
|
||||
status: q.status,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
before: q.before,
|
||||
});
|
||||
return { runs };
|
||||
return queryRunsFromRequest(/** @type {Record<string, string | undefined>} */ (req.query ?? {}));
|
||||
});
|
||||
|
||||
fastify.get("/consecutive-failures", async (req) => {
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,14 +10,61 @@ import {
|
||||
authLabel,
|
||||
validateWorkflowHttpTriggers,
|
||||
} from "../../workflow-http-validate.js";
|
||||
import { listHttpAuths } from "../../http-auths-store.js";
|
||||
import { validateWorkflowFailureTriggers } from "../../trigger-failure.js";
|
||||
import {
|
||||
duplicateWorkflowYaml,
|
||||
ensureWorkflowFilename,
|
||||
suggestCopyFilename,
|
||||
suggestDuplicateFilename,
|
||||
} from "../../workflow-duplicate.js";
|
||||
import { publishReload } from "../../control-bus.js";
|
||||
import {
|
||||
workflowIdFromFile,
|
||||
newWorkflowFilename,
|
||||
} from "../../workflow-normalize.js";
|
||||
import {
|
||||
collectWorkflowWarnings,
|
||||
parseWorkflowDocument,
|
||||
} from "../../workflow-validate-warnings.js";
|
||||
import { getProfilePlain } from "../../profiles-store.js";
|
||||
import { resolveScriptRef } from "../../plugin-store.js";
|
||||
import {
|
||||
recordRevision,
|
||||
listRevisions,
|
||||
getRevision,
|
||||
ensureInitialRevision,
|
||||
} from "../../workflow-history.js";
|
||||
import {
|
||||
moveWorkflowToTrash,
|
||||
listTrash,
|
||||
restoreFromTrash,
|
||||
purgeTrashItem,
|
||||
isInTrash,
|
||||
} from "../../workflow-trash.js";
|
||||
import {
|
||||
createWorkflowBackupBuffer,
|
||||
restoreWorkflowBackup,
|
||||
} from "../../workflow-backup.js";
|
||||
import {
|
||||
listExampleWorkflows,
|
||||
readExampleWorkflow,
|
||||
assertExampleWorkflowId,
|
||||
} from "../../workflow-examples.js";
|
||||
|
||||
function triggerSummary(owner, workflow) {
|
||||
/**
|
||||
* Reload this process and notify other HTTP/worker processes via Redis.
|
||||
* @param {{ reregister: () => void }} registry
|
||||
*/
|
||||
async function reregisterAll(registry) {
|
||||
registry.reregister();
|
||||
try {
|
||||
await publishReload({ type: "workflows" });
|
||||
} catch {
|
||||
// Redis may be briefly unavailable; local reload already applied.
|
||||
}
|
||||
}
|
||||
|
||||
function triggerSummary(owner, workflow, nameById) {
|
||||
if (!workflow || typeof workflow !== "object") return [];
|
||||
return (workflow.triggers ?? []).map((t) => {
|
||||
const type = t?.type ?? "unknown";
|
||||
@@ -29,7 +76,7 @@ function triggerSummary(owner, workflow) {
|
||||
schedule: t?.schedule ?? null,
|
||||
onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
|
||||
onFailureWorkflow: t?.onFailureWorkflow ?? null,
|
||||
auth: isHttp ? authLabel(t?.auth) : null,
|
||||
auth: isHttp ? authLabel(t?.auth, nameById) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -40,7 +87,9 @@ function scriptNames(workflow) {
|
||||
for (const raw of workflow.scripts ?? []) {
|
||||
try {
|
||||
const parsed = parseScriptStep(raw);
|
||||
names.push(parsed.kind === "set" ? "set" : parsed.script);
|
||||
if (parsed.kind === "set") names.push("set");
|
||||
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
|
||||
else names.push(parsed.script);
|
||||
} catch {
|
||||
names.push(null);
|
||||
}
|
||||
@@ -48,6 +97,148 @@ function scriptNames(workflow) {
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
* @param {string} owner
|
||||
*/
|
||||
async function collectProfileWarnings(parsed, owner) {
|
||||
/** @type {Array<{ code: string, message: string, path?: string }>} */
|
||||
const warnings = [];
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !owner) {
|
||||
return warnings;
|
||||
}
|
||||
for (const [i, raw] of (parsed.scripts ?? []).entries()) {
|
||||
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) continue;
|
||||
const profileName = raw.profile;
|
||||
if (typeof profileName !== "string" || !profileName) continue;
|
||||
const pathKey = `scripts[${i}]`;
|
||||
let profile;
|
||||
try {
|
||||
profile = await getProfilePlain(owner, profileName);
|
||||
} catch {
|
||||
warnings.push({
|
||||
code: "unknown_profile",
|
||||
message: `Profile "${profileName}" is not a valid name`,
|
||||
path: pathKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!profile) {
|
||||
warnings.push({
|
||||
code: "unknown_profile",
|
||||
message: `Profile "${profileName}" not found`,
|
||||
path: pathKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (typeof raw.script === "string" && raw.script && raw.script !== profile.script) {
|
||||
warnings.push({
|
||||
code: "profile_script_mismatch",
|
||||
message: `Step script "${raw.script}" does not match profile "${profileName}" (${profile.script})`,
|
||||
path: pathKey,
|
||||
});
|
||||
}
|
||||
const resolved = resolveScriptRef(profile.script);
|
||||
if (resolved.error) {
|
||||
warnings.push({
|
||||
code: "unknown_script",
|
||||
message: resolved.error,
|
||||
path: `${pathKey}.profile`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
*/
|
||||
async function validateStrictWorkflow(parsed) {
|
||||
compileWorkflowScripts(parsed?.scripts);
|
||||
await validateWorkflowHttpTriggers(parsed);
|
||||
await validateWorkflowFailureTriggers(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* owner: string,
|
||||
* file: string,
|
||||
* content: string,
|
||||
* saveAnyway?: boolean,
|
||||
* reason?: string | null,
|
||||
* meta?: Record<string, unknown> | null,
|
||||
* forceRevision?: boolean,
|
||||
* }} opts
|
||||
*/
|
||||
async function saveWorkflowContent(opts) {
|
||||
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
||||
if (parsed) {
|
||||
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
|
||||
}
|
||||
const saveAnyway = Boolean(opts.saveAnyway);
|
||||
|
||||
if (!saveAnyway) {
|
||||
if (parseError) {
|
||||
const err = new Error("workflow has validation warnings");
|
||||
err.statusCode = 422;
|
||||
err.warnings = warnings;
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
await validateStrictWorkflow(parsed);
|
||||
} catch (validationErr) {
|
||||
const err = new Error("workflow has validation warnings");
|
||||
err.statusCode = 422;
|
||||
err.warnings = [
|
||||
...warnings,
|
||||
{
|
||||
code: "validation_error",
|
||||
message:
|
||||
validationErr instanceof Error
|
||||
? validationErr.message
|
||||
: String(validationErr),
|
||||
},
|
||||
];
|
||||
throw err;
|
||||
}
|
||||
if (warnings.length) {
|
||||
const err = new Error("workflow has validation warnings");
|
||||
err.statusCode = 422;
|
||||
err.warnings = warnings;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const workflowId = workflowIdFromFile(opts.file);
|
||||
const existed = fsStore.readWorkflowYaml(opts.owner, opts.file) != null;
|
||||
fsStore.writeWorkflowYaml(opts.owner, opts.file, opts.content);
|
||||
|
||||
const registered = fsStore.readRegisters(opts.owner);
|
||||
if (!registered.includes(opts.file)) {
|
||||
registered.push(opts.file);
|
||||
fsStore.writeRegisters(opts.owner, registered);
|
||||
}
|
||||
|
||||
const revision = await recordRevision({
|
||||
workflowId,
|
||||
owner: opts.owner,
|
||||
file: opts.file,
|
||||
content: opts.content,
|
||||
reason: opts.reason ?? "save",
|
||||
meta: opts.meta ?? null,
|
||||
force: opts.forceRevision,
|
||||
});
|
||||
|
||||
return {
|
||||
owner: opts.owner,
|
||||
file: opts.file,
|
||||
workflow_id: workflowId,
|
||||
existed,
|
||||
warnings,
|
||||
revision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ workflows: Map<string, any>, loadErrors: Map<string, string>, reregister: () => void }} registry
|
||||
*/
|
||||
@@ -60,12 +251,99 @@ export default function workflowsPluginFactory(registry) {
|
||||
return { owners: fsStore.listOwners() };
|
||||
});
|
||||
|
||||
fastify.get("/workflow-examples", async () => {
|
||||
return { examples: listExampleWorkflows() };
|
||||
});
|
||||
|
||||
fastify.get("/workflow-examples/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
if (!assertExampleWorkflowId(id)) {
|
||||
return reply.code(400).send({ error: "invalid example id" });
|
||||
}
|
||||
const example = readExampleWorkflow(id);
|
||||
if (!example) {
|
||||
return reply.code(404).send({ error: "example not found" });
|
||||
}
|
||||
return example;
|
||||
});
|
||||
|
||||
fastify.get("/workflows/trash", async () => {
|
||||
return { items: await listTrash() };
|
||||
});
|
||||
|
||||
fastify.post("/workflows/trash/:id/restore", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
try {
|
||||
const restored = await restoreFromTrash(id);
|
||||
await recordRevision({
|
||||
workflowId: restored.workflow_id,
|
||||
owner: restored.owner,
|
||||
file: restored.file,
|
||||
content: restored.content,
|
||||
reason: "restored-from-trash",
|
||||
force: true,
|
||||
});
|
||||
await reregisterAll(registry);
|
||||
return { owner: restored.owner, file: restored.file };
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fastify.delete("/workflows/trash/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
try {
|
||||
return await purgeTrashItem(id);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get("/workflows/backup", async (_req, reply) => {
|
||||
const buffer = await createWorkflowBackupBuffer();
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return reply
|
||||
.header("Content-Type", "application/zip")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="jerapah-flow-backup-${stamp}.zip"`,
|
||||
)
|
||||
.send(buffer);
|
||||
});
|
||||
|
||||
fastify.post("/workflows/backup/restore", async (req, reply) => {
|
||||
const body = /** @type {{ zipBase64?: string, mode?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
if (typeof body.zipBase64 !== "string" || !body.zipBase64.trim()) {
|
||||
return reply.code(400).send({ error: "zipBase64 is required" });
|
||||
}
|
||||
const mode = body.mode === "replace" ? "replace" : "merge";
|
||||
try {
|
||||
const buffer = Buffer.from(body.zipBase64, "base64");
|
||||
const result = await restoreWorkflowBackup(buffer, { mode });
|
||||
await reregisterAll(registry);
|
||||
return result;
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get("/workflows", async (req) => {
|
||||
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
|
||||
const stats = await store.workflowStats();
|
||||
const owners = q.owner
|
||||
? [fsStore.assertOwner(q.owner)]
|
||||
: fsStore.listOwners();
|
||||
const authNameById = Object.fromEntries(
|
||||
(await listHttpAuths()).map((a) => [a.id, a.name]),
|
||||
);
|
||||
|
||||
const items = [];
|
||||
for (const owner of owners) {
|
||||
@@ -79,6 +357,7 @@ export default function workflowsPluginFactory(registry) {
|
||||
const files = [...new Set([...registered, ...onDisk])];
|
||||
|
||||
for (const file of files) {
|
||||
if (await isInTrash(owner, file)) continue;
|
||||
const key = `${owner}/${file}`;
|
||||
const loaded = registry.workflows.get(key);
|
||||
const loadError = registry.loadErrors.get(key) ?? null;
|
||||
@@ -88,11 +367,8 @@ export default function workflowsPluginFactory(registry) {
|
||||
if (raw != null) {
|
||||
try {
|
||||
parsed = yaml.parse(raw);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// keep loadError
|
||||
if (!loadError) {
|
||||
// file on disk but unparseable and not in registers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,25 +380,124 @@ export default function workflowsPluginFactory(registry) {
|
||||
items.push({
|
||||
owner,
|
||||
file,
|
||||
workflow_id: workflowIdFromFile(file),
|
||||
key,
|
||||
name: parsed?.name ?? file,
|
||||
description: parsed?.description ?? null,
|
||||
enabled: parsed ? parsed.enabled !== false : false,
|
||||
registered: registered.includes(file),
|
||||
loadError:
|
||||
loadError ??
|
||||
(parsed ? null : "unreadable"),
|
||||
loadError: loadError ?? (parsed ? null : "unreadable"),
|
||||
lastModifiedAt: fsStore.workflowLastModifiedAt(owner, file),
|
||||
lastInvokedAt: st.lastInvokedAt,
|
||||
lastStatus: st.lastStatus ?? null,
|
||||
invocationCount: st.invocationCount,
|
||||
triggers: triggerSummary(owner, parsed),
|
||||
triggers: triggerSummary(owner, parsed, authNameById),
|
||||
scripts: scriptNames(parsed),
|
||||
});
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => {
|
||||
const byName = String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (byName !== 0) return byName;
|
||||
return String(a.key ?? "").localeCompare(String(b.key ?? ""));
|
||||
});
|
||||
return { workflows: items };
|
||||
});
|
||||
|
||||
fastify.get("/workflows/:owner/:file/revisions", async (req, reply) => {
|
||||
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
||||
req.params
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(owner);
|
||||
fsStore.assertWorkflowFile(file);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
if (fsStore.readWorkflowYaml(owner, file) == null) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
await ensureInitialRevision({ owner, file });
|
||||
const workflowId = workflowIdFromFile(file);
|
||||
return { workflow_id: workflowId, revisions: await listRevisions(workflowId) };
|
||||
});
|
||||
|
||||
fastify.get(
|
||||
"/workflows/:owner/:file/revisions/:revision",
|
||||
async (req, reply) => {
|
||||
const { owner, file, revision } = /** @type {{ owner: string, file: string, revision: string }} */ (
|
||||
req.params
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(owner);
|
||||
fsStore.assertWorkflowFile(file);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const workflowId = workflowIdFromFile(file);
|
||||
const rev = await getRevision(workflowId, Number(revision));
|
||||
if (!rev) {
|
||||
return reply.code(404).send({ error: "revision not found" });
|
||||
}
|
||||
return {
|
||||
workflow_id: workflowId,
|
||||
revision: rev.revision,
|
||||
content: rev.content,
|
||||
reason: rev.reason,
|
||||
meta: rev.meta,
|
||||
created_at: rev.created_at,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/workflows/:owner/:file/revisions/:revision/revert",
|
||||
async (req, reply) => {
|
||||
const { owner, file, revision } = /** @type {{ owner: string, file: string, revision: string }} */ (
|
||||
req.params
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(owner);
|
||||
fsStore.assertWorkflowFile(file);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
if (fsStore.readWorkflowYaml(owner, file) == null) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
const workflowId = workflowIdFromFile(file);
|
||||
const rev = await getRevision(workflowId, Number(revision));
|
||||
if (!rev) {
|
||||
return reply.code(404).send({ error: "revision not found" });
|
||||
}
|
||||
const body = /** @type {{ saveAnyway?: boolean }} */ (req.body ?? {});
|
||||
try {
|
||||
const saved = await saveWorkflowContent({
|
||||
owner,
|
||||
file,
|
||||
content: rev.content,
|
||||
saveAnyway: body.saveAnyway,
|
||||
reason: "revert",
|
||||
meta: { fromRevision: rev.revision },
|
||||
});
|
||||
await reregisterAll(registry);
|
||||
return saved;
|
||||
} catch (err) {
|
||||
if (err.statusCode === 422) {
|
||||
return reply.code(422).send({
|
||||
error: err.message,
|
||||
warnings: err.warnings ?? [],
|
||||
});
|
||||
}
|
||||
return reply.code(err.statusCode ?? 500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get("/workflows/:owner/:file", async (req, reply) => {
|
||||
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
||||
req.params
|
||||
@@ -153,6 +528,7 @@ export default function workflowsPluginFactory(registry) {
|
||||
return {
|
||||
owner,
|
||||
file,
|
||||
workflow_id: workflowIdFromFile(file),
|
||||
key,
|
||||
content,
|
||||
parsed,
|
||||
@@ -174,48 +550,95 @@ export default function workflowsPluginFactory(registry) {
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const body = /** @type {{ content?: string }} */ (req.body ?? {});
|
||||
const body = /** @type {{ content?: string, saveAnyway?: boolean }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
if (typeof body.content !== "string") {
|
||||
return reply.code(400).send({ error: "content is required" });
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = yaml.parse(body.content);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: `invalid yaml: ${err instanceof Error ? err.message : String(err)}`,
|
||||
const saved = await saveWorkflowContent({
|
||||
owner,
|
||||
file,
|
||||
content: body.content,
|
||||
saveAnyway: body.saveAnyway,
|
||||
reason: "save",
|
||||
});
|
||||
await reregisterAll(registry);
|
||||
return reply.code(saved.existed ? 200 : 201).send({
|
||||
owner: saved.owner,
|
||||
file: saved.file,
|
||||
workflow_id: saved.workflow_id,
|
||||
warnings: saved.warnings,
|
||||
revision: saved.revision,
|
||||
});
|
||||
}
|
||||
try {
|
||||
compileWorkflowScripts(parsed?.scripts);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
if (err.statusCode === 422) {
|
||||
return reply.code(422).send({
|
||||
error: err.message,
|
||||
warnings: err.warnings ?? [],
|
||||
});
|
||||
}
|
||||
return reply.code(err.statusCode ?? 500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post("/workflows/:owner", async (req, reply) => {
|
||||
const { owner } = /** @type {{ owner: string }} */ (req.params);
|
||||
try {
|
||||
await validateWorkflowHttpTriggers(parsed);
|
||||
fsStore.assertOwner(owner);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const body = /** @type {{ content?: string, file?: string, saveAnyway?: boolean }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
if (typeof body.content !== "string") {
|
||||
return reply.code(400).send({ error: "content is required" });
|
||||
}
|
||||
let file = body.file?.trim() ? ensureWorkflowFilename(body.file) : "";
|
||||
if (!file) {
|
||||
const existing = fsStore.listOwnerYamlFiles(owner);
|
||||
file = suggestDuplicateFilename(existing);
|
||||
}
|
||||
try {
|
||||
fsStore.assertWorkflowFile(file);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
if (fsStore.readWorkflowYaml(owner, file) != null) {
|
||||
return reply.code(409).send({ error: "workflow already exists" });
|
||||
}
|
||||
try {
|
||||
const saved = await saveWorkflowContent({
|
||||
owner,
|
||||
file,
|
||||
content: body.content,
|
||||
saveAnyway: body.saveAnyway,
|
||||
reason: "create",
|
||||
forceRevision: true,
|
||||
});
|
||||
await reregisterAll(registry);
|
||||
return reply.code(201).send({
|
||||
owner: saved.owner,
|
||||
file: saved.file,
|
||||
workflow_id: saved.workflow_id,
|
||||
warnings: saved.warnings,
|
||||
revision: saved.revision,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 422) {
|
||||
return reply.code(422).send({
|
||||
error: err.message,
|
||||
warnings: err.warnings ?? [],
|
||||
});
|
||||
}
|
||||
return reply.code(err.statusCode ?? 500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
try {
|
||||
await validateWorkflowFailureTriggers(parsed);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
const existed = fsStore.readWorkflowYaml(owner, file) != null;
|
||||
fsStore.writeWorkflowYaml(owner, file, body.content);
|
||||
const registered = fsStore.readRegisters(owner);
|
||||
if (!registered.includes(file)) {
|
||||
registered.push(file);
|
||||
fsStore.writeRegisters(owner, registered);
|
||||
}
|
||||
registry.reregister();
|
||||
return reply.code(existed ? 200 : 201).send({ owner, file });
|
||||
});
|
||||
|
||||
fastify.patch("/workflows/:owner/:file", async (req, reply) => {
|
||||
@@ -236,23 +659,39 @@ export default function workflowsPluginFactory(registry) {
|
||||
if (content == null) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
const doc = yaml.parseDocument(content);
|
||||
if (doc.errors?.length) {
|
||||
const msg = doc.errors[0]?.message ?? "invalid yaml";
|
||||
return reply.code(400).send({ error: msg });
|
||||
}
|
||||
const parsed = doc.toJSON();
|
||||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return reply.code(400).send({ error: "workflow yaml must be an object" });
|
||||
let doc;
|
||||
try {
|
||||
({ doc } = parseWorkflowDocument(content));
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
if (body.enabled) {
|
||||
doc.delete("enabled");
|
||||
} else {
|
||||
doc.set("enabled", false);
|
||||
}
|
||||
fsStore.writeWorkflowYaml(owner, file, String(doc));
|
||||
registry.reregister();
|
||||
return { owner, file, enabled: body.enabled };
|
||||
const nextContent = String(doc);
|
||||
try {
|
||||
const saved = await saveWorkflowContent({
|
||||
owner,
|
||||
file,
|
||||
content: nextContent,
|
||||
reason: body.enabled ? "enable" : "disable",
|
||||
});
|
||||
await reregisterAll(registry);
|
||||
return {
|
||||
owner,
|
||||
file,
|
||||
enabled: body.enabled,
|
||||
revision: saved.revision,
|
||||
};
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fastify.delete("/workflows/:owner/:file", async (req, reply) => {
|
||||
@@ -265,13 +704,31 @@ export default function workflowsPluginFactory(registry) {
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
if (!fsStore.deleteWorkflowYaml(owner, file)) {
|
||||
const raw = fsStore.readWorkflowYaml(owner, file);
|
||||
if (raw == null) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
const registered = fsStore.readRegisters(owner).filter((f) => f !== file);
|
||||
fsStore.writeRegisters(owner, registered);
|
||||
registry.reregister();
|
||||
return { ok: true };
|
||||
let name = null;
|
||||
try {
|
||||
const parsed = yaml.parse(raw);
|
||||
name = parsed?.name ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const item = await moveWorkflowToTrash({
|
||||
workflowId: workflowIdFromFile(file),
|
||||
owner,
|
||||
file,
|
||||
name,
|
||||
});
|
||||
await reregisterAll(registry);
|
||||
return { ok: true, trash: item };
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post("/workflows/:owner/:file/duplicate", async (req, reply) => {
|
||||
@@ -289,7 +746,9 @@ export default function workflowsPluginFactory(registry) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
|
||||
const body = /** @type {{ file?: unknown, owner?: unknown }} */ (req.body ?? {});
|
||||
const body = /** @type {{ file?: unknown, owner?: unknown, saveAnyway?: boolean }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
let destOwner = owner;
|
||||
if (body.owner != null && body.owner !== "") {
|
||||
if (typeof body.owner !== "string") {
|
||||
@@ -305,7 +764,9 @@ export default function workflowsPluginFactory(registry) {
|
||||
let destFile;
|
||||
try {
|
||||
if (body.file == null || body.file === "") {
|
||||
destFile = suggestCopyFilename(file, fsStore.listOwnerYamlFiles(destOwner));
|
||||
destFile = suggestDuplicateFilename(
|
||||
fsStore.listOwnerYamlFiles(destOwner),
|
||||
);
|
||||
} else if (typeof body.file !== "string") {
|
||||
return reply.code(400).send({ error: "file must be a string" });
|
||||
} else {
|
||||
@@ -336,44 +797,34 @@ export default function workflowsPluginFactory(registry) {
|
||||
});
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = yaml.parse(content);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: `invalid yaml: ${err instanceof Error ? err.message : String(err)}`,
|
||||
const saved = await saveWorkflowContent({
|
||||
owner: destOwner,
|
||||
file: destFile,
|
||||
content,
|
||||
saveAnyway: body.saveAnyway,
|
||||
reason: "duplicated",
|
||||
meta: { from: `${owner}/${file}` },
|
||||
forceRevision: true,
|
||||
});
|
||||
await reregisterAll(registry);
|
||||
return reply.code(201).send({
|
||||
owner: destOwner,
|
||||
file: destFile,
|
||||
workflow_id: saved.workflow_id,
|
||||
revision: saved.revision,
|
||||
});
|
||||
}
|
||||
try {
|
||||
compileWorkflowScripts(parsed?.scripts);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
if (err.statusCode === 422) {
|
||||
return reply.code(422).send({
|
||||
error: err.message,
|
||||
warnings: err.warnings ?? [],
|
||||
});
|
||||
}
|
||||
return reply.code(err.statusCode ?? 500).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
try {
|
||||
await validateWorkflowHttpTriggers(parsed);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
try {
|
||||
await validateWorkflowFailureTriggers(parsed);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
fsStore.writeWorkflowYaml(destOwner, destFile, content);
|
||||
const registered = fsStore.readRegisters(destOwner);
|
||||
if (!registered.includes(destFile)) {
|
||||
registered.push(destFile);
|
||||
fsStore.writeRegisters(destOwner, registered);
|
||||
}
|
||||
registry.reregister();
|
||||
return reply.code(201).send({ owner: destOwner, file: destFile });
|
||||
});
|
||||
|
||||
fastify.post("/workflows/:owner/:file/run", async (req, reply) => {
|
||||
@@ -394,9 +845,9 @@ export default function workflowsPluginFactory(registry) {
|
||||
if (!registered.includes(file)) {
|
||||
registered.push(file);
|
||||
fsStore.writeRegisters(owner, registered);
|
||||
registry.reregister();
|
||||
await reregisterAll(registry);
|
||||
} else if (!registry.workflows.has(key) && !registry.loadErrors.has(key)) {
|
||||
registry.reregister();
|
||||
await reregisterAll(registry);
|
||||
}
|
||||
if (!registry.workflows.has(key)) {
|
||||
return reply.code(404).send({
|
||||
@@ -404,7 +855,7 @@ export default function workflowsPluginFactory(registry) {
|
||||
});
|
||||
}
|
||||
const body = /** @type {{ data?: unknown }} */ (req.body ?? {});
|
||||
const result = await registry.runWorkflow(
|
||||
const result = await registry.enqueueWorkflow(
|
||||
key,
|
||||
{ data: body.data ?? null },
|
||||
{ type: "manual", detail: "ui" },
|
||||
@@ -412,19 +863,22 @@ export default function workflowsPluginFactory(registry) {
|
||||
if (result.status === "failed") {
|
||||
return reply.code(result.runId ? 500 : 404).send({
|
||||
runId: result.runId,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
return {
|
||||
return reply.code(202).send({
|
||||
runId: result.runId,
|
||||
status: result.status,
|
||||
result: store.toDisplayValue(result.result),
|
||||
};
|
||||
jobId: result.jobId ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/workflows/reregister", async () => {
|
||||
registry.reregister();
|
||||
await reregisterAll(registry);
|
||||
return { message: "Workflows refreshed" };
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { newWorkflowFilename };
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "../../db.js";
|
||||
import { assertHttpStatus } from "../../http-pages-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertAuthId(id) {
|
||||
if (typeof id !== "string" || !UUID_RE.test(id)) {
|
||||
const err = new Error("invalid auth id");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return id.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertAuthName(name) {
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
const err = new Error("invalid auth name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} type
|
||||
* @returns {"bearer" | "basic" | "header"}
|
||||
*/
|
||||
export function assertAuthType(type) {
|
||||
const t = String(type ?? "");
|
||||
if (!ALLOWED_TYPES.has(t)) {
|
||||
const err = new Error('auth type must be "bearer", "basic", or "header"');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return /** @type {"bearer" | "basic" | "header"} */ (t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect value source without exposing literal values.
|
||||
* @param {unknown} value
|
||||
* @returns {"literal" | "kv" | "secret" | "missing"}
|
||||
*/
|
||||
export function valueSourceKind(value) {
|
||||
if (value == null) return "missing";
|
||||
if (typeof value === "string") return "literal";
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
if ("secret" in value) return "secret";
|
||||
if ("kv" in value) return "kv";
|
||||
}
|
||||
return "literal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact config for API responses: replace literal strings with source markers.
|
||||
* @param {Record<string, unknown>} config
|
||||
* @param {string} type
|
||||
*/
|
||||
export function publicConfig(config, type) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
if (type === "bearer") {
|
||||
out.token = redactField(config.token);
|
||||
} else if (type === "basic") {
|
||||
out.user = redactField(config.user);
|
||||
out.password = redactField(config.password);
|
||||
} else if (type === "header") {
|
||||
out.header = typeof config.header === "string" ? config.header : null;
|
||||
out.value = redactField(config.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function redactField(value) {
|
||||
const kind = valueSourceKind(value);
|
||||
if (kind === "missing") return { source: "missing" };
|
||||
if (kind === "kv") {
|
||||
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
|
||||
return {
|
||||
source: "kv",
|
||||
kv: v.kv,
|
||||
...(v.namespace != null ? { namespace: v.namespace } : {}),
|
||||
};
|
||||
}
|
||||
if (kind === "secret") {
|
||||
const v = /** @type {{ secret: string }} */ (value);
|
||||
return { source: "secret", secret: v.secret };
|
||||
}
|
||||
return { source: "literal", set: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize auth config for storage.
|
||||
* @param {string} type
|
||||
* @param {unknown} config
|
||||
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
|
||||
*/
|
||||
export function normalizeAuthConfig(type, config, opts = {}) {
|
||||
const raw = config && typeof config === "object" && !Array.isArray(config)
|
||||
? /** @type {Record<string, unknown>} */ (config)
|
||||
: {};
|
||||
const keep = opts.keepLiteralsFrom ?? {};
|
||||
|
||||
if (type === "bearer") {
|
||||
return {
|
||||
token: normalizeCredentialField(raw.token, keep.token, "token"),
|
||||
};
|
||||
}
|
||||
if (type === "basic") {
|
||||
return {
|
||||
user: normalizeCredentialField(raw.user, keep.user, "user"),
|
||||
password: normalizeCredentialField(raw.password, keep.password, "password", {
|
||||
allowEmpty: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// header
|
||||
if (typeof raw.header !== "string" || raw.header.length === 0) {
|
||||
const err = new Error("header name must be a non-empty string");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
header: raw.header,
|
||||
value: normalizeCredentialField(raw.value, keep.value, "value"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {unknown} previous
|
||||
* @param {string} label
|
||||
* @param {{ allowEmpty?: boolean }} [opts]
|
||||
*/
|
||||
function normalizeCredentialField(value, previous, label, opts = {}) {
|
||||
// Explicit "keep previous literal" marker from UI when editing without re-entering
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
/** @type {{ keep?: boolean }} */ (value).keep === true
|
||||
) {
|
||||
if (typeof previous === "string") return previous;
|
||||
if (previous && typeof previous === "object") return previous;
|
||||
const err = new Error(`${label} was not previously set`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (value == null || value === "") {
|
||||
if (opts.allowEmpty && value === "") return "";
|
||||
// Allow empty password for basic
|
||||
if (opts.allowEmpty && (value === "" || value == null)) {
|
||||
if (typeof previous === "string") return previous;
|
||||
return "";
|
||||
}
|
||||
const err = new Error(`${label} is required`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (typeof value === "string") return value;
|
||||
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
const v = /** @type {Record<string, unknown>} */ (value);
|
||||
if (typeof v.secret === "string" && v.secret.length > 0) {
|
||||
return { secret: v.secret };
|
||||
}
|
||||
if (typeof v.kv === "string" && v.kv.length > 0) {
|
||||
/** @type {{ kv: string, namespace?: string }} */
|
||||
const out = { kv: v.kv };
|
||||
if (typeof v.namespace === "string" && v.namespace.length > 0) {
|
||||
out.namespace = v.namespace;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
const err = new Error(
|
||||
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
function parseConfig(raw) {
|
||||
if (typeof raw !== "string") return raw ?? {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function publicAuth(row, { includeConfig = true } = {}) {
|
||||
const type = row.type;
|
||||
const config = parseConfig(row.config);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type,
|
||||
...(includeConfig ? { config: publicConfig(config, type) } : {}),
|
||||
unauthorized_status: row.unauthorized_status ?? null,
|
||||
unauthorized_response: row.unauthorized_response ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: full config including literals (for runtime auth checks).
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHttpAuthInternal(id) {
|
||||
const authId = assertAuthId(id);
|
||||
const row = await db("http_auths").where({ id: authId }).first();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config: parseConfig(row.config),
|
||||
unauthorized_status: row.unauthorized_status ?? null,
|
||||
unauthorized_response: row.unauthorized_response ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only plaintext literal credential fields (not KV refs or encrypted secrets).
|
||||
* @param {string} id
|
||||
* @returns {Promise<{ id: string, name: string, type: string, literals: Record<string, string> } | null>}
|
||||
*/
|
||||
export async function revealHttpAuthLiterals(id) {
|
||||
const internal = await getHttpAuthInternal(id);
|
||||
if (!internal) return null;
|
||||
/** @type {Record<string, string>} */
|
||||
const literals = {};
|
||||
const cfg = internal.config ?? {};
|
||||
for (const key of ["token", "user", "password", "value"]) {
|
||||
const v = cfg[key];
|
||||
if (typeof v === "string") literals[key] = v;
|
||||
}
|
||||
return {
|
||||
id: internal.id,
|
||||
name: internal.name,
|
||||
type: internal.type,
|
||||
literals,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listHttpAuths() {
|
||||
const rows = await db("http_auths").select("*").orderBy("name", "asc");
|
||||
return rows.map((r) => publicAuth(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHttpAuthById(id) {
|
||||
let authId;
|
||||
try {
|
||||
authId = assertAuthId(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const row = await db("http_auths").where({ id: authId }).first();
|
||||
return row ? publicAuth(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* id?: string | null,
|
||||
* name: string,
|
||||
* type: string,
|
||||
* config?: unknown,
|
||||
* unauthorized_status?: number | null,
|
||||
* unauthorized_response?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
export async function upsertHttpAuth({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
config,
|
||||
unauthorized_status,
|
||||
unauthorized_response,
|
||||
}) {
|
||||
const authName = assertAuthName(name);
|
||||
const authType = assertAuthType(type);
|
||||
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
let existing = null;
|
||||
if (id != null && String(id).length > 0) {
|
||||
const authId = assertAuthId(id);
|
||||
existing = await db("http_auths").where({ id: authId }).first();
|
||||
if (!existing) {
|
||||
const err = new Error("auth not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const nameClash = await db("http_auths").where({ name: authName }).first();
|
||||
if (nameClash && (!existing || nameClash.id !== existing.id)) {
|
||||
const err = new Error(`auth name "${authName}" already exists`);
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const prevConfig = existing ? parseConfig(existing.config) : {};
|
||||
const normalized = normalizeAuthConfig(authType, config, {
|
||||
keepLiteralsFrom: prevConfig,
|
||||
});
|
||||
|
||||
let unauthStatus = null;
|
||||
if (unauthorized_status != null && unauthorized_status !== "") {
|
||||
unauthStatus = assertHttpStatus(unauthorized_status, 401);
|
||||
}
|
||||
let unauthResponse = null;
|
||||
if (
|
||||
unauthorized_response != null &&
|
||||
String(unauthorized_response).length > 0
|
||||
) {
|
||||
unauthResponse = String(unauthorized_response);
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
const configJson = JSON.stringify(normalized);
|
||||
|
||||
if (existing) {
|
||||
await db("http_auths")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
name: authName,
|
||||
type: authType,
|
||||
config: configJson,
|
||||
unauthorized_status: unauthStatus,
|
||||
unauthorized_response: unauthResponse,
|
||||
updated_at: now,
|
||||
});
|
||||
return getHttpAuthById(/** @type {string} */ (existing.id));
|
||||
}
|
||||
|
||||
const newId = randomUUID();
|
||||
await db("http_auths").insert({
|
||||
id: newId,
|
||||
name: authName,
|
||||
type: authType,
|
||||
config: configJson,
|
||||
unauthorized_status: unauthStatus,
|
||||
unauthorized_response: unauthResponse,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getHttpAuthById(newId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteHttpAuth(id) {
|
||||
const authId = assertAuthId(id);
|
||||
const n = await db("http_auths").where({ id: authId }).del();
|
||||
return n > 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? ""));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,9 @@
|
||||
* Step return contract: `{ output, context?, skipRemaining? }`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
import { isPlainObject } from "@jerapah-flow/shared";
|
||||
|
||||
export { isPlainObject };
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
|
||||
+143
-19
@@ -64,6 +64,8 @@ function nowIso() {
|
||||
* trigger: { type: string, detail?: string | null },
|
||||
* input?: unknown,
|
||||
* parentRunId?: string | null,
|
||||
* status?: "queued" | "running",
|
||||
* workflowRevision?: number | null,
|
||||
* }} opts
|
||||
*/
|
||||
export async function startRun({
|
||||
@@ -73,9 +75,12 @@ export async function startRun({
|
||||
trigger,
|
||||
input = null,
|
||||
parentRunId = null,
|
||||
status = "queued",
|
||||
workflowRevision = null,
|
||||
}) {
|
||||
const id = randomUUID();
|
||||
const started_at = nowIso();
|
||||
const now = nowIso();
|
||||
const isQueued = status === "queued";
|
||||
await db("workflow_runs").insert({
|
||||
id,
|
||||
owner,
|
||||
@@ -83,12 +88,37 @@ export async function startRun({
|
||||
workflow_name: workflowName ?? null,
|
||||
trigger_type: trigger.type,
|
||||
trigger_detail: trigger.detail ?? null,
|
||||
status: "running",
|
||||
started_at,
|
||||
status,
|
||||
started_at: now,
|
||||
queued_at: isQueued ? now : null,
|
||||
input: serialize(input),
|
||||
parent_run_id: parentRunId ?? null,
|
||||
workflow_revision: workflowRevision ?? null,
|
||||
});
|
||||
return { id, started_at };
|
||||
return { id, started_at: now, queued_at: isQueued ? now : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} jobId
|
||||
*/
|
||||
export async function setRunJobId(id, jobId) {
|
||||
await db("workflow_runs").where({ id }).update({ job_id: jobId });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function markRunRunning(id) {
|
||||
const started_at = nowIso();
|
||||
const updated = await db("workflow_runs")
|
||||
.where({ id })
|
||||
.whereIn("status", ["queued", "running"])
|
||||
.update({
|
||||
status: "running",
|
||||
started_at,
|
||||
});
|
||||
return { updated: Number(updated) > 0, started_at };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,19 +222,29 @@ export async function insertLogs(rows) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const RUN_SORT_COLUMNS = {
|
||||
status: "status",
|
||||
workflow: "workflow_name",
|
||||
revision: "workflow_revision",
|
||||
trigger: "trigger_type",
|
||||
started_at: "started_at",
|
||||
duration: "duration_ms",
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex.QueryBuilder} q
|
||||
* @param {{
|
||||
* owner?: string,
|
||||
* workflow?: string,
|
||||
* status?: string,
|
||||
* limit?: number,
|
||||
* status?: string | string[],
|
||||
* trigger_type?: string,
|
||||
* after?: string,
|
||||
* before?: string,
|
||||
* }} [filters]
|
||||
* }} filters
|
||||
*/
|
||||
export async function listRuns(filters = {}) {
|
||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
|
||||
if (filters.owner) q = q.where("owner", filters.owner);
|
||||
function applyRunFilters(q, filters) {
|
||||
if (filters.owner) q.where("owner", filters.owner);
|
||||
if (filters.workflow) {
|
||||
const key = String(filters.workflow);
|
||||
if (key.includes("*")) {
|
||||
@@ -213,19 +253,102 @@ export async function listRuns(filters = {}) {
|
||||
.replaceAll("%", "\\%")
|
||||
.replaceAll("_", "\\_")
|
||||
.replaceAll("*", "%");
|
||||
q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
||||
q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
||||
} else {
|
||||
q = q.where("workflow", key);
|
||||
q.where("workflow", key);
|
||||
}
|
||||
}
|
||||
if (filters.status) q = q.where("status", filters.status);
|
||||
if (filters.before) q = q.where("started_at", "<", filters.before);
|
||||
const rows = await q.limit(limit);
|
||||
return rows.map((row) => ({
|
||||
if (filters.status) {
|
||||
if (Array.isArray(filters.status)) {
|
||||
q.whereIn("status", filters.status);
|
||||
} else {
|
||||
q.where("status", filters.status);
|
||||
}
|
||||
}
|
||||
if (filters.trigger_type) q.where("trigger_type", filters.trigger_type);
|
||||
if (filters.after) q.where("started_at", ">=", filters.after);
|
||||
if (filters.before) q.where("started_at", "<", filters.before);
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex.QueryBuilder} q
|
||||
* @param {string | undefined} sort
|
||||
* @param {string | undefined} order
|
||||
*/
|
||||
function applyRunSort(q, sort, order) {
|
||||
const column = RUN_SORT_COLUMNS[sort ?? ""] ?? "started_at";
|
||||
const direction = order === "asc" ? "asc" : "desc";
|
||||
q.orderBy(column, direction);
|
||||
if (column !== "started_at") q.orderBy("started_at", "desc");
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} row
|
||||
*/
|
||||
function mapRunRow(row) {
|
||||
return {
|
||||
...row,
|
||||
input: deserialize(row.input),
|
||||
output: deserialize(row.output),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* owner?: string,
|
||||
* workflow?: string,
|
||||
* status?: string | string[],
|
||||
* trigger_type?: string,
|
||||
* after?: string,
|
||||
* before?: string,
|
||||
* limit?: number,
|
||||
* offset?: number,
|
||||
* sort?: string,
|
||||
* order?: string,
|
||||
* }} [filters]
|
||||
*/
|
||||
export async function queryRuns(filters = {}) {
|
||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||
const offset = Math.max(Number(filters.offset) || 0, 0);
|
||||
|
||||
let q = db("workflow_runs");
|
||||
q = applyRunFilters(q, filters);
|
||||
|
||||
const countRow = await q.clone().count({ count: "*" }).first();
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
|
||||
let rowsQ = q.clone().select("*");
|
||||
rowsQ = applyRunSort(rowsQ, filters.sort, filters.order);
|
||||
const rows = await rowsQ.limit(limit).offset(offset);
|
||||
|
||||
return {
|
||||
runs: rows.map(mapRunRow),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* owner?: string,
|
||||
* workflow?: string,
|
||||
* status?: string | string[],
|
||||
* trigger_type?: string,
|
||||
* after?: string,
|
||||
* before?: string,
|
||||
* limit?: number,
|
||||
* }} [filters]
|
||||
*/
|
||||
export async function listRuns(filters = {}) {
|
||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||
let q = db("workflow_runs").select("*");
|
||||
q = applyRunFilters(q, filters);
|
||||
q = applyRunSort(q, "started_at", "desc");
|
||||
const rows = await q.limit(limit);
|
||||
return rows.map(mapRunRow);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,12 +582,13 @@ export async function listUsers() {
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{ passwordHash?: string, role?: string }} patch
|
||||
* @param {{ passwordHash?: string, role?: string, username?: string }} patch
|
||||
*/
|
||||
export async function updateUser(id, patch) {
|
||||
const update = { updated_at: nowIso() };
|
||||
if (patch.passwordHash) update.password_hash = patch.passwordHash;
|
||||
if (patch.role) update.role = patch.role;
|
||||
if (patch.username) update.username = patch.username;
|
||||
await db("users").where({ id }).update(update);
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
@@ -51,21 +51,21 @@ async function freshUrl(pathname) {
|
||||
state.body = "<html><body>v1</body></html>";
|
||||
|
||||
const first = await run({ url });
|
||||
assert(first.data.hasChanges === true, "first run should report hasChanges=true");
|
||||
assert(first.output.hasChanges === true, "first run should report hasChanges=true");
|
||||
assert(
|
||||
first.data.httpResponse === "<html><body>v1</body></html>",
|
||||
first.output.httpResponse === "<html><body>v1</body></html>",
|
||||
"httpResponse should hold the raw body",
|
||||
);
|
||||
assert(typeof first.data.fingerprint === "string", "fingerprint hash should be set");
|
||||
assert(typeof first.output.fingerprint === "string", "fingerprint hash should be set");
|
||||
|
||||
const second = await run({ url });
|
||||
assert(second.data.hasChanges === false, "unchanged body should report hasChanges=false");
|
||||
assert(second.output.hasChanges === false, "unchanged body should report hasChanges=false");
|
||||
|
||||
state.body = "<html><body>v2 CHANGED</body></html>";
|
||||
const third = await run({ url });
|
||||
assert(third.data.hasChanges === true, "changed body should report hasChanges=true");
|
||||
assert(third.output.hasChanges === true, "changed body should report hasChanges=true");
|
||||
assert(
|
||||
third.data.fingerprintPrevious === second.data.fingerprint,
|
||||
third.output.fingerprintPrevious === second.output.fingerprint,
|
||||
"fingerprintPrevious should equal the prior hash",
|
||||
);
|
||||
}
|
||||
@@ -77,20 +77,20 @@ async function freshUrl(pathname) {
|
||||
state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" };
|
||||
|
||||
const first = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||
assert(first.data.hasChanges === true, "json first run should report a change");
|
||||
assert(first.output.hasChanges === true, "json first run should report a change");
|
||||
|
||||
// Change only an unwatched field -> no change.
|
||||
state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||
const second = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||
assert(
|
||||
second.data.hasChanges === false,
|
||||
second.output.hasChanges === false,
|
||||
"changing an unwatched field should not report a change",
|
||||
);
|
||||
|
||||
// Change the watched field -> change.
|
||||
state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||
const third = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||
assert(third.data.hasChanges === true, "changing the watched field should report a change");
|
||||
assert(third.output.hasChanges === true, "changing the watched field should report a change");
|
||||
|
||||
state.contentType = "text/html; charset=utf-8";
|
||||
}
|
||||
@@ -106,13 +106,13 @@ async function freshUrl(pathname) {
|
||||
transform: '"changed=" & $string(data.hasChanges)',
|
||||
});
|
||||
assert(
|
||||
withTransform.data.message === "changed=true",
|
||||
`transform should populate outputVar, got ${JSON.stringify(withTransform.data.message)}`,
|
||||
withTransform.output.message === "changed=true",
|
||||
`transform should populate outputVar, got ${JSON.stringify(withTransform.output.message)}`,
|
||||
);
|
||||
|
||||
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
|
||||
assert(
|
||||
rawOutput.data.payload === rawOutput.data.httpResponse,
|
||||
rawOutput.output.payload === rawOutput.output.httpResponse,
|
||||
"outputVar without transform should store the raw response",
|
||||
);
|
||||
|
||||
@@ -131,11 +131,11 @@ async function freshUrl(pathname) {
|
||||
state.body = "<html><body>stable</body></html>";
|
||||
|
||||
const first = await run({ url, skipRemaining: true });
|
||||
assert(first.data.hasChanges === true, "skip test first run should change");
|
||||
assert(first.output.hasChanges === true, "skip test first run should change");
|
||||
assert(first.skipRemaining !== true, "changed run must not set skipRemaining");
|
||||
|
||||
const second = await run({ url, skipRemaining: true });
|
||||
assert(second.data.hasChanges === false, "skip test second run should be unchanged");
|
||||
assert(second.output.hasChanges === false, "skip test second run should be unchanged");
|
||||
assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt");
|
||||
|
||||
// Default (skipRemaining off) never halts, so downstream can still notify.
|
||||
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
getHttpAuthInternal,
|
||||
} from "../http-auths-store.js";
|
||||
import {
|
||||
authLabel,
|
||||
checkAnyHttpAuth,
|
||||
checkHttpAuth,
|
||||
coerceCredentialString,
|
||||
resolveAuthMechanism,
|
||||
resolveAuthMechanisms,
|
||||
resolveCredentialValue,
|
||||
resolveUnauthorizedSpec,
|
||||
sendHttpPageOrJson,
|
||||
@@ -176,11 +179,11 @@ const secret = await upsertSecret({
|
||||
config: { token: { secret: "does_not_exist_xyz" } },
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
);
|
||||
assert(!missingSec, "missing secret fails closed");
|
||||
}
|
||||
|
||||
// --- named profile ---
|
||||
// --- named profile (by id) ---
|
||||
const profile = await upsertHttpAuth({
|
||||
name: "webhook-smoke",
|
||||
type: "bearer",
|
||||
@@ -188,9 +191,10 @@ const profile = await upsertHttpAuth({
|
||||
unauthorized_status: 403,
|
||||
unauthorized_response: "deny-smoke",
|
||||
});
|
||||
assert(typeof profile.id === "string" && profile.id.length > 0, "profile has id");
|
||||
{
|
||||
const mech = await resolveAuthMechanism("webhook-smoke");
|
||||
assert(mech?.label === "webhook-smoke", "named profile");
|
||||
const mech = await resolveAuthMechanism(profile.id);
|
||||
assert(mech?.label === "webhook-smoke", "profile by id");
|
||||
const ok = await checkHttpAuth(
|
||||
mockReq({ authorization: "Bearer named-token" }),
|
||||
mech,
|
||||
@@ -202,9 +206,68 @@ const profile = await upsertHttpAuth({
|
||||
assert(pageName === "deny-smoke", "profile unauth page");
|
||||
}
|
||||
|
||||
// --- rename keeps id ---
|
||||
{
|
||||
const renamed = await upsertHttpAuth({
|
||||
id: profile.id,
|
||||
name: "webhook-renamed",
|
||||
type: "bearer",
|
||||
config: { token: { keep: true } },
|
||||
unauthorized_status: 403,
|
||||
unauthorized_response: "deny-smoke",
|
||||
});
|
||||
assert(renamed.id === profile.id, "rename keeps id");
|
||||
assert(renamed.name === "webhook-renamed", "rename updates name");
|
||||
const mech = await resolveAuthMechanism(profile.id);
|
||||
assert(mech?.label === "webhook-renamed", "resolve uses new name label");
|
||||
const ok = await checkHttpAuth(
|
||||
mockReq({ authorization: "Bearer named-token" }),
|
||||
mech,
|
||||
ctx,
|
||||
);
|
||||
assert(ok, "credentials survive rename");
|
||||
}
|
||||
|
||||
// --- multi-auth OR ---
|
||||
const basicProfile = await upsertHttpAuth({
|
||||
name: "basic-smoke",
|
||||
type: "basic",
|
||||
config: { user: "bob", password: "p@ss" },
|
||||
});
|
||||
{
|
||||
const mechs = await resolveAuthMechanisms([profile.id, basicProfile.id]);
|
||||
assert(mechs.length === 2, "resolve two mechanisms");
|
||||
assert(
|
||||
authLabel([profile.id, basicProfile.id], {
|
||||
[profile.id]: "webhook-renamed",
|
||||
[basicProfile.id]: "basic-smoke",
|
||||
}) === "webhook-renamed|basic-smoke",
|
||||
"authLabel",
|
||||
);
|
||||
const viaBearer = await checkAnyHttpAuth(
|
||||
mockReq({ authorization: "Bearer named-token" }),
|
||||
mechs,
|
||||
ctx,
|
||||
);
|
||||
assert(viaBearer, "OR accepts bearer");
|
||||
const encoded = Buffer.from("bob:p@ss").toString("base64");
|
||||
const viaBasic = await checkAnyHttpAuth(
|
||||
mockReq({ authorization: `Basic ${encoded}` }),
|
||||
mechs,
|
||||
ctx,
|
||||
);
|
||||
assert(viaBasic, "OR accepts basic");
|
||||
const neither = await checkAnyHttpAuth(
|
||||
mockReq({ authorization: "Bearer wrong" }),
|
||||
mechs,
|
||||
ctx,
|
||||
);
|
||||
assert(!neither, "OR rejects when none match");
|
||||
}
|
||||
|
||||
// trigger-level override
|
||||
{
|
||||
const mech = await getHttpAuthInternal("webhook-smoke");
|
||||
const mech = await getHttpAuthInternal(profile.id);
|
||||
const { status, pageName } = resolveUnauthorizedSpec(
|
||||
{ unauthorized: { status: 401, response: "deny-smoke" } },
|
||||
mech,
|
||||
@@ -226,7 +289,7 @@ await validateWorkflowHttpTriggers({
|
||||
type: "HTTP",
|
||||
method: "POST",
|
||||
path: "/x",
|
||||
auth: "webhook-smoke",
|
||||
auth: [profile.id, basicProfile.id],
|
||||
response: "deny-smoke",
|
||||
},
|
||||
],
|
||||
@@ -235,12 +298,38 @@ await validateWorkflowHttpTriggers({
|
||||
let threw = false;
|
||||
try {
|
||||
await validateWorkflowHttpTriggers({
|
||||
triggers: [{ type: "HTTP", path: "/x", auth: "no-such-profile" }],
|
||||
triggers: [{ type: "HTTP", path: "/x", auth: profile.id }],
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assert(threw, "unknown auth fails validation");
|
||||
assert(threw, "non-array auth fails validation");
|
||||
|
||||
threw = false;
|
||||
try {
|
||||
await validateWorkflowHttpTriggers({
|
||||
triggers: [{ type: "HTTP", path: "/x", auth: ["webhook-renamed"] }],
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assert(threw, "name string fails validation");
|
||||
|
||||
threw = false;
|
||||
try {
|
||||
await validateWorkflowHttpTriggers({
|
||||
triggers: [
|
||||
{
|
||||
type: "HTTP",
|
||||
path: "/x",
|
||||
auth: ["00000000-0000-4000-8000-000000000000"],
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assert(threw, "unknown auth id fails validation");
|
||||
|
||||
threw = false;
|
||||
try {
|
||||
@@ -280,6 +369,7 @@ assert(threw, "unknown page fails validation");
|
||||
|
||||
// cleanup
|
||||
await deleteHttpAuth(profile.id);
|
||||
await deleteHttpAuth(basicProfile.id);
|
||||
await deleteHttpPage(page.id);
|
||||
await deleteSecret(secret.id);
|
||||
const leftover = (await listSecrets({ owner })).find(
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js";
|
||||
import {
|
||||
encodeBinaryForWire,
|
||||
jsonPreviewReplacer,
|
||||
reviveBinaryFromWire,
|
||||
summarizeBinary,
|
||||
} from "../json-preview.js";
|
||||
import { serialize, toDisplayValue } from "../store.js";
|
||||
import { safeSerialize } from "../src/api/dry-run-logger.js";
|
||||
|
||||
@@ -53,4 +58,18 @@ if (typed.file.length !== png.length || typed.file.type !== "Buffer") {
|
||||
throw new Error(`Uint8Array: ${JSON.stringify(typed)}`);
|
||||
}
|
||||
|
||||
const wired = encodeBinaryForWire({ file: png, n: 1n });
|
||||
if (wired.n !== "1" || wired.file.encoding !== "base64" || typeof wired.file.data !== "string") {
|
||||
throw new Error(`encodeBinaryForWire: ${JSON.stringify(wired)}`);
|
||||
}
|
||||
const revived = reviveBinaryFromWire(JSON.parse(JSON.stringify(wired)));
|
||||
if (!Buffer.isBuffer(revived.file) || !revived.file.equals(png)) {
|
||||
throw new Error("reviveBinaryFromWire failed to restore bytes");
|
||||
}
|
||||
const previewOnly = { type: "Buffer", length: png.length, preview: "89", truncated: true };
|
||||
const left = reviveBinaryFromWire(previewOnly);
|
||||
if (Buffer.isBuffer(left)) {
|
||||
throw new Error("preview-only summary should not revive");
|
||||
}
|
||||
|
||||
console.log("json-preview-smoke: ok");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Smoke: set dry-run path (evaluateJsonata + envelope), mirrors
|
||||
* POST /scripts/set/dry-run in src/api/scripts.js.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { evaluateJsonata, SET_STEP_SCRIPT } from "../workflow-parse.js";
|
||||
import { normalizeStepResult } from "../step-result.js";
|
||||
import { safeSerialize } from "../src/api/dry-run-logger.js";
|
||||
|
||||
assert.equal(SET_STEP_SCRIPT, "set");
|
||||
|
||||
async function dryRunSet({ expression, data, context = {} }) {
|
||||
if (typeof expression !== "string" || !expression.trim()) {
|
||||
throw new Error("expression is required");
|
||||
}
|
||||
const incomingContext =
|
||||
context != null && typeof context === "object" && !Array.isArray(context)
|
||||
? context
|
||||
: {};
|
||||
const config = { expression };
|
||||
const ctx = { data: data ?? null, context: incomingContext, config };
|
||||
const value = await evaluateJsonata(expression, ctx);
|
||||
const result = normalizeStepResult(
|
||||
{ output: value, context: incomingContext, skipRemaining: false },
|
||||
incomingContext,
|
||||
SET_STEP_SCRIPT,
|
||||
);
|
||||
return {
|
||||
status: "success",
|
||||
output: safeSerialize(result.output),
|
||||
context: safeSerialize(result.context),
|
||||
skipRemaining: result.skipRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const res = await dryRunSet({
|
||||
expression: '{"title": data.title, "ok": true}',
|
||||
data: { title: "Hello" },
|
||||
context: { runId: "dry" },
|
||||
});
|
||||
assert.equal(res.status, "success");
|
||||
assert.deepEqual(res.output, { title: "Hello", ok: true });
|
||||
assert.deepEqual(res.context, { runId: "dry" });
|
||||
assert.equal(res.skipRemaining, false);
|
||||
}
|
||||
|
||||
{
|
||||
const res = await dryRunSet({
|
||||
expression: "data.count + 1",
|
||||
data: { count: 41 },
|
||||
context: { token: "abc" },
|
||||
});
|
||||
assert.equal(res.output, 42);
|
||||
// Sets never mutate context
|
||||
assert.deepEqual(res.context, { token: "abc" });
|
||||
}
|
||||
|
||||
{
|
||||
let hit = false;
|
||||
try {
|
||||
await dryRunSet({ expression: " ", data: {} });
|
||||
} catch (err) {
|
||||
hit = true;
|
||||
assert.match(String(err.message), /expression is required/);
|
||||
}
|
||||
assert.equal(hit, true);
|
||||
}
|
||||
|
||||
{
|
||||
let hit = false;
|
||||
try {
|
||||
await dryRunSet({ expression: "data.{" , data: {} });
|
||||
} catch (err) {
|
||||
hit = true;
|
||||
assert.ok(err instanceof Error);
|
||||
}
|
||||
assert.equal(hit, true);
|
||||
}
|
||||
|
||||
console.log("set-dry-run-smoke: ok");
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Smoke: workflow revisions, SHA dedup, trash, restore, purge.
|
||||
*
|
||||
* Run: pnpm --dir packages/server test:workflow-history
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { db, migrate } from "../db.js";
|
||||
import { WORKFLOWS_DIR } from "../paths.js";
|
||||
import * as fsStore from "../fs-store.js";
|
||||
import {
|
||||
workflowContentSha,
|
||||
workflowIdFromFile,
|
||||
newWorkflowFilename,
|
||||
} from "../workflow-normalize.js";
|
||||
import {
|
||||
recordRevision,
|
||||
listRevisions,
|
||||
getLatestRevision,
|
||||
deleteRevisionHistory,
|
||||
ensureInitialRevision,
|
||||
} from "../workflow-history.js";
|
||||
import {
|
||||
moveWorkflowToTrash,
|
||||
listTrash,
|
||||
restoreFromTrash,
|
||||
purgeTrashItem,
|
||||
TRASH_WORKFLOWS_DIR,
|
||||
} from "../workflow-trash.js";
|
||||
import { collectWorkflowWarnings } from "../workflow-validate-warnings.js";
|
||||
|
||||
const owner = "__workflow_history_smoke__";
|
||||
const file = newWorkflowFilename();
|
||||
const workflowId = workflowIdFromFile(file);
|
||||
const ownerDir = path.join(WORKFLOWS_DIR, owner);
|
||||
const trashPath = path.join(TRASH_WORKFLOWS_DIR, owner, file);
|
||||
|
||||
function cleanup() {
|
||||
if (fs.existsSync(trashPath)) fs.unlinkSync(trashPath);
|
||||
if (fs.existsSync(ownerDir)) fs.rmSync(ownerDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
cleanup();
|
||||
await migrate();
|
||||
|
||||
const yamlV1 = `name: smoke test
|
||||
scripts:
|
||||
- plugin/get-current-time
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /smoke
|
||||
`;
|
||||
|
||||
fsStore.writeWorkflowYaml(owner, file, yamlV1);
|
||||
fsStore.writeRegisters(owner, [file]);
|
||||
|
||||
assert.equal(workflowContentSha(yamlV1), workflowContentSha(`${yamlV1}\n\n`));
|
||||
|
||||
const rev1 = await recordRevision({
|
||||
workflowId,
|
||||
owner,
|
||||
file,
|
||||
content: yamlV1,
|
||||
reason: "create",
|
||||
force: true,
|
||||
});
|
||||
assert.equal(rev1.skipped, false);
|
||||
assert.equal(rev1.revision, 1);
|
||||
|
||||
const revDup = await recordRevision({
|
||||
workflowId,
|
||||
owner,
|
||||
file,
|
||||
content: `${yamlV1}\n\n`,
|
||||
reason: "save",
|
||||
});
|
||||
assert.equal(revDup.skipped, true, "normalized SHA should dedupe blank lines");
|
||||
|
||||
const yamlV2 = yamlV1.replace("smoke test", "smoke test v2");
|
||||
const rev2 = await recordRevision({
|
||||
workflowId,
|
||||
owner,
|
||||
file,
|
||||
content: yamlV2,
|
||||
reason: "save",
|
||||
});
|
||||
assert.equal(rev2.revision, 2);
|
||||
assert.equal((await listRevisions(workflowId)).length, 2);
|
||||
|
||||
const yamlDisabled = `${yamlV2}\nenabled: false\n`;
|
||||
assert.equal(
|
||||
workflowContentSha(yamlV2),
|
||||
workflowContentSha(yamlDisabled),
|
||||
"enabled-only change should not change content SHA",
|
||||
);
|
||||
const revDisable = await recordRevision({
|
||||
workflowId,
|
||||
owner,
|
||||
file,
|
||||
content: yamlDisabled,
|
||||
reason: "disable",
|
||||
});
|
||||
assert.equal(revDisable.skipped, true, "enable/disable should not create a revision");
|
||||
assert.equal((await listRevisions(workflowId)).length, 2);
|
||||
|
||||
const { startRun, getRun } = await import("../store.js");
|
||||
const latest = await getLatestRevision(workflowId);
|
||||
assert.equal(latest?.revision, 2);
|
||||
const run = await startRun({
|
||||
owner,
|
||||
workflow: `${owner}/${file}`,
|
||||
workflowName: "smoke test v2",
|
||||
trigger: { type: "manual", detail: "smoke" },
|
||||
input: null,
|
||||
workflowRevision: latest?.revision ?? null,
|
||||
});
|
||||
const loaded = await getRun(run.id);
|
||||
assert.equal(loaded.workflow_revision, 2);
|
||||
await db("workflow_runs").where({ id: run.id }).del();
|
||||
|
||||
await deleteRevisionHistory(workflowId);
|
||||
assert.equal(await getLatestRevision(workflowId), null);
|
||||
const seeded = await ensureInitialRevision({ owner, file });
|
||||
assert.ok(seeded);
|
||||
assert.equal(seeded.revision, 1);
|
||||
assert.equal(seeded.seeded, true);
|
||||
const again = await ensureInitialRevision({ owner, file });
|
||||
assert.equal(again?.revision, 1);
|
||||
assert.equal(again?.seeded, false);
|
||||
|
||||
const warnings = collectWorkflowWarnings(
|
||||
`name: bad\nscripts:\n - unknown-script-xyz\n`,
|
||||
);
|
||||
assert.ok(warnings.warnings.some((w) => w.code === "unknown_script"));
|
||||
|
||||
const trashed = await moveWorkflowToTrash({
|
||||
workflowId,
|
||||
owner,
|
||||
file,
|
||||
name: "smoke test v2",
|
||||
});
|
||||
assert.ok(trashed.id);
|
||||
assert.equal(fsStore.readWorkflowYaml(owner, file), null);
|
||||
assert.ok(fs.existsSync(trashPath));
|
||||
|
||||
const restored = await restoreFromTrash(trashed.id);
|
||||
assert.equal(restored.file, file);
|
||||
assert.ok(fsStore.readWorkflowYaml(owner, file));
|
||||
|
||||
await moveWorkflowToTrash({ workflowId, owner, file, name: "smoke test v2" });
|
||||
const trashAgain = (await listTrash()).find((t) => t.file === file);
|
||||
assert.ok(trashAgain);
|
||||
await purgeTrashItem(trashAgain.id);
|
||||
assert.ok(!(await listTrash()).some((t) => t.file === file));
|
||||
|
||||
await deleteRevisionHistory(workflowId);
|
||||
cleanup();
|
||||
|
||||
console.log("workflow-history-smoke: ok");
|
||||
await db.destroy();
|
||||
@@ -1,190 +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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { deleteRevisionHistory } from "./workflow-history.js";
|
||||
import { DATA_DIR, WORKFLOWS_DIR } from "./paths.js";
|
||||
import * as fsStore from "./fs-store.js";
|
||||
|
||||
export const TRASH_WORKFLOWS_DIR = path.join(DATA_DIR, "trash", "workflows");
|
||||
export const TRASH_RETENTION_DAYS = 7;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function trashFilePath(owner, file) {
|
||||
return path.join(TRASH_WORKFLOWS_DIR, owner, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} deletedAtIso
|
||||
*/
|
||||
function trashAgeMs(deletedAtIso) {
|
||||
return Date.now() - Date.parse(deletedAtIso);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} deletedAtIso
|
||||
*/
|
||||
export function trashDaysRemaining(deletedAtIso) {
|
||||
const purgeAt =
|
||||
Date.parse(deletedAtIso) + TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
return Math.max(0, Math.ceil((purgeAt - Date.now()) / (24 * 60 * 60 * 1000)));
|
||||
}
|
||||
|
||||
function rowToItem(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
workflow_id: row.workflow_id,
|
||||
owner: row.owner,
|
||||
file: row.file,
|
||||
name: row.name ?? null,
|
||||
deleted_at: row.deleted_at,
|
||||
trash_path: row.trash_path,
|
||||
age_ms: trashAgeMs(row.deleted_at),
|
||||
days_until_purge: trashDaysRemaining(row.deleted_at),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTrash() {
|
||||
const rows = await db("workflow_trash").orderBy("deleted_at", "desc");
|
||||
return rows.map(rowToItem);
|
||||
}
|
||||
|
||||
export async function getTrashItem(id) {
|
||||
const row = await db("workflow_trash").where({ id }).first();
|
||||
return row ? rowToItem(row) : null;
|
||||
}
|
||||
|
||||
export async function isInTrash(owner, file) {
|
||||
const row = await db("workflow_trash").where({ owner, file }).first();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete: move YAML to trash dir, unregister, keep revision history.
|
||||
* @param {{
|
||||
* workflowId: string,
|
||||
* owner: string,
|
||||
* file: string,
|
||||
* name?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
export async function moveWorkflowToTrash(opts) {
|
||||
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
const err = new Error("workflow not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const trashPath = trashFilePath(opts.owner, opts.file);
|
||||
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
|
||||
fs.renameSync(sourcePath, trashPath);
|
||||
|
||||
const registered = fsStore.readRegisters(opts.owner).filter((f) => f !== opts.file);
|
||||
fsStore.writeRegisters(opts.owner, registered);
|
||||
|
||||
const id = randomUUID();
|
||||
const deleted_at = nowIso();
|
||||
await db("workflow_trash").insert({
|
||||
id,
|
||||
workflow_id: opts.workflowId,
|
||||
owner: opts.owner,
|
||||
file: opts.file,
|
||||
name: opts.name ?? null,
|
||||
deleted_at,
|
||||
trash_path: trashPath,
|
||||
});
|
||||
|
||||
return rowToItem(await db("workflow_trash").where({ id }).first());
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore workflow from trash.
|
||||
* @param {string} trashId
|
||||
*/
|
||||
export async function restoreFromTrash(trashId) {
|
||||
const row = await db("workflow_trash").where({ id: trashId }).first();
|
||||
if (!row) {
|
||||
const err = new Error("trash item not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const destPath = path.join(WORKFLOWS_DIR, row.owner, row.file);
|
||||
if (fs.existsSync(destPath)) {
|
||||
const err = new Error("workflow file already exists");
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
if (!fs.existsSync(row.trash_path)) {
|
||||
const err = new Error("trash file missing on disk");
|
||||
err.statusCode = 410;
|
||||
throw err;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.renameSync(row.trash_path, destPath);
|
||||
|
||||
const registered = fsStore.readRegisters(row.owner);
|
||||
if (!registered.includes(row.file)) {
|
||||
registered.push(row.file);
|
||||
fsStore.writeRegisters(row.owner, registered);
|
||||
}
|
||||
|
||||
await db("workflow_trash").where({ id: trashId }).del();
|
||||
|
||||
return {
|
||||
owner: row.owner,
|
||||
file: row.file,
|
||||
workflow_id: row.workflow_id,
|
||||
content: fs.readFileSync(destPath, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete a trash item and its revision history.
|
||||
* @param {string} trashId
|
||||
*/
|
||||
export async function purgeTrashItem(trashId) {
|
||||
const row = await db("workflow_trash").where({ id: trashId }).first();
|
||||
if (!row) {
|
||||
const err = new Error("trash item not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (fs.existsSync(row.trash_path)) {
|
||||
fs.unlinkSync(row.trash_path);
|
||||
}
|
||||
|
||||
await deleteRevisionHistory(row.workflow_id);
|
||||
await db("workflow_trash").where({ id: trashId }).del();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-purge trash older than retention window.
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function purgeExpiredTrash() {
|
||||
const cutoff = new Date(
|
||||
Date.now() - TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
const rows = await db("workflow_trash")
|
||||
.where("deleted_at", "<", cutoff)
|
||||
.select("id");
|
||||
for (const row of rows) {
|
||||
await purgeTrashItem(row.id);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import yaml from "yaml";
|
||||
import { parseScriptStep } from "./workflow-parse.js";
|
||||
import { resolveScriptRef } from "./plugin-store.js";
|
||||
import { parseWorkflowObject } from "./workflow-normalize.js";
|
||||
|
||||
const SECRET_KEY_RE =
|
||||
/(?:password|passwd|secret|token|api[_-]?key|auth(?:orization)?|credential|private[_-]?key)/i;
|
||||
|
||||
const BEARER_RE = /Bearer\s+[A-Za-z0-9._~+/=-]{8,}/;
|
||||
|
||||
/**
|
||||
* Walk parsed YAML for suspicious secret-like string values.
|
||||
* @param {unknown} value
|
||||
* @param {string} pathKey
|
||||
* @param {Array<{ code: string, message: string, path?: string }>} warnings
|
||||
*/
|
||||
function scanSecrets(value, pathKey, warnings) {
|
||||
if (value == null) return;
|
||||
if (typeof value === "string") {
|
||||
if (BEARER_RE.test(value)) {
|
||||
warnings.push({
|
||||
code: "plaintext_secret",
|
||||
message: "Possible Bearer token in workflow YAML",
|
||||
path: pathKey,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, i) => scanSecrets(item, `${pathKey}[${i}]`, warnings));
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
const childPath = pathKey ? `${pathKey}.${k}` : k;
|
||||
if (typeof v === "string" && v.trim() && SECRET_KEY_RE.test(k)) {
|
||||
warnings.push({
|
||||
code: "plaintext_secret",
|
||||
message: `Possible secret in field "${k}"`,
|
||||
path: childPath,
|
||||
});
|
||||
}
|
||||
scanSecrets(v, childPath, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect non-blocking save warnings for workflow YAML.
|
||||
* @param {string} content
|
||||
* @returns {{ warnings: Array<{ code: string, message: string, path?: string }>, parsed: unknown | null, parseError: string | null }}
|
||||
*/
|
||||
export function collectWorkflowWarnings(content) {
|
||||
/** @type {Array<{ code: string, message: string, path?: string }>} */
|
||||
const warnings = [];
|
||||
|
||||
let parsed = null;
|
||||
let parseError = null;
|
||||
try {
|
||||
parsed = parseWorkflowObject(content);
|
||||
if (parsed == null) {
|
||||
warnings.push({
|
||||
code: "invalid_yaml",
|
||||
message: "Workflow YAML is empty or not an object",
|
||||
});
|
||||
} else if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
warnings.push({
|
||||
code: "invalid_yaml",
|
||||
message: "Workflow YAML must be a mapping/object",
|
||||
});
|
||||
parsed = null;
|
||||
}
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : String(err);
|
||||
warnings.push({
|
||||
code: "invalid_yaml",
|
||||
message: `Invalid YAML: ${parseError}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
scanSecrets(parsed, "", warnings);
|
||||
|
||||
for (const [i, raw] of (parsed.scripts ?? []).entries()) {
|
||||
try {
|
||||
const step = parseScriptStep(raw);
|
||||
if (step.kind === "set") continue;
|
||||
if (step.profile && !step.script) continue;
|
||||
const resolved = resolveScriptRef(step.script);
|
||||
if (resolved.error) {
|
||||
warnings.push({
|
||||
code: "unknown_script",
|
||||
message: resolved.error,
|
||||
path: `scripts[${i}]`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push({
|
||||
code: "invalid_script_step",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
path: `scripts[${i}]`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { warnings, parsed, parseError };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict validation used when saveAnyway is false.
|
||||
* @param {unknown} parsed
|
||||
*/
|
||||
export function assertStrictWorkflow(parsed) {
|
||||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
const err = new Error("workflow yaml must be an object");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse for PATCH/enable toggles (must be valid YAML document).
|
||||
* @param {string} content
|
||||
*/
|
||||
export function parseWorkflowDocument(content) {
|
||||
const doc = yaml.parseDocument(content);
|
||||
if (doc.errors?.length) {
|
||||
const err = new Error(doc.errors[0]?.message ?? "invalid yaml");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const parsed = doc.toJSON();
|
||||
assertStrictWorkflow(parsed);
|
||||
return { doc, parsed };
|
||||
}
|
||||
@@ -18,7 +18,7 @@ scripts:
|
||||
- script: fetch-binary.js
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://ntfy.sh/jerapah-flow
|
||||
url: $VAR_ntfy_channel
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
|
||||
@@ -2,7 +2,7 @@ name: cron example
|
||||
description: |
|
||||
this workflow triggered by cron
|
||||
scripts:
|
||||
- script: get-current-time.js
|
||||
- script: plugin/get-current-time
|
||||
- set:
|
||||
expression: '{"message": context.datetime}'
|
||||
- script: ntfy.js
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
name: fetch-devto
|
||||
scripts:
|
||||
- script: fetch-html.js
|
||||
config:
|
||||
url: https://dev.to/t/productivity/top/week
|
||||
selector: "#substories h2"
|
||||
outputVar: titles
|
||||
jsonata: "$[].text"
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /fetch-dev-to
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Jadwal Solat Jakarta ntfy
|
||||
scripts:
|
||||
- id: fetch
|
||||
script: fetch-http.js
|
||||
config:
|
||||
url: https://kemenag.go.id/api/prayer-times/1301
|
||||
method: GET
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
- id: transform
|
||||
script: jsonata.js
|
||||
config:
|
||||
expression: |-
|
||||
{
|
||||
"title": data.httpResponse.data.date,
|
||||
"message": "Imsak:" & data.httpResponse.data.imsak & "\n" &
|
||||
"Subuh:" & data.httpResponse.data.subuh & "\n" &
|
||||
"Dzuhur:" & data.httpResponse.data.dzuhur & "\n" &
|
||||
"Ashar:" & data.httpResponse.data.ashar & "\n" &
|
||||
"Maghrib:" & data.httpResponse.data.maghrib & "\n" &
|
||||
"Isya:" & data.httpResponse.data.isya
|
||||
}
|
||||
needs:
|
||||
- fetch
|
||||
- id: ntfy
|
||||
script: ntfy.js
|
||||
config:
|
||||
url: $VAR_ntfy_channel
|
||||
fingerprint: true
|
||||
needs:
|
||||
- transform
|
||||
- id: slack
|
||||
script: ntfy.js
|
||||
config:
|
||||
url: $VAR_ntfy_channel2
|
||||
fingerprint: fingerprint:ntfy2
|
||||
needs:
|
||||
- transform
|
||||
- script: slack-webhook.js
|
||||
config:
|
||||
webhookUrlSecret: slack_deploy_webhook
|
||||
fingerprint: true
|
||||
fingerprintMaxAge: 1h
|
||||
text: $INPUT_message
|
||||
needs:
|
||||
- transform
|
||||
triggers:
|
||||
- type: cron
|
||||
schedule: 0 5 * * *
|
||||
@@ -1,20 +1,6 @@
|
||||
scripts:
|
||||
- time-to-ntfy-example.yaml
|
||||
- test.yaml
|
||||
- cron-example.yaml
|
||||
- fetch-devto.yaml
|
||||
- comic-monkeyuser-to-ntfy.yaml
|
||||
- time-and-comic-to-ntfy.yaml
|
||||
- rss-selfhst-to-ntfy.yaml
|
||||
- send-gmail.yaml
|
||||
- test-send-gmail.yaml
|
||||
- track.yaml
|
||||
- rss-devto-to-ntfy.yaml
|
||||
- test-minio.yaml
|
||||
- dev-joplin-sync.yaml
|
||||
- dev-joplin-daily.yaml
|
||||
- dev-joplin-get-note.yaml
|
||||
- web-dave.yaml
|
||||
- jadwal-sholat-jakart.yaml
|
||||
- detect-example-changes.yaml
|
||||
- test-sftp.yaml
|
||||
- time-to-ntfy-example.yaml
|
||||
- comic-monkeyuser-to-ntfy.yaml
|
||||
- afb272d4-b217-49ac-8c8b-755a6d8dac4a.yaml
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: RSS - selfh.st first item to ntfy (copy)
|
||||
scripts:
|
||||
- script: fetch-rss-feed.js
|
||||
config:
|
||||
url: https://selfh.st/rss/
|
||||
outputVar: item
|
||||
jsonata: items[0]
|
||||
- script: fingerprint.js
|
||||
config:
|
||||
key: selfhst-latest
|
||||
jsonata: "data.item.guid ? data.item.guid : data.item.link"
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.item.title,
|
||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /selfhst-rss-rss-devto-to-ntfy
|
||||
enabled: false
|
||||
@@ -1,26 +0,0 @@
|
||||
name: RSS - selfh.st first item to ntfy
|
||||
scripts:
|
||||
- script: fetch-rss-feed.js
|
||||
config:
|
||||
url: https://selfh.st/rss/
|
||||
outputVar: item
|
||||
jsonata: items[0]
|
||||
- script: fingerprint.js
|
||||
config:
|
||||
key: selfhst-latest
|
||||
jsonata: "data.item.guid ? data.item.guid : data.item.link"
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.item.title,
|
||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /selfhst-rss
|
||||
@@ -1,39 +0,0 @@
|
||||
name: send-gmail
|
||||
description: |
|
||||
Send email via Gmail SMTP. Configure user/from and the named secret,
|
||||
then call from other workflows with trigger-workflow.js (name: send-gmail).
|
||||
|
||||
Input (data):
|
||||
to optional recipient(s); defaults to your Gmail below
|
||||
subject required
|
||||
text plain body (aliases: body, message)
|
||||
html optional HTML body
|
||||
from, cc, bcc, replyTo, priority, headers optional
|
||||
scripts:
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"to": $exists(data.to) ? data.to : "nasyarobby@gmail.com",
|
||||
"from": data.from,
|
||||
"subject": data.subject,
|
||||
"text": $exists(data.text) ? data.text : ($exists(data.body) ? data.body : data.message),
|
||||
"html": data.html,
|
||||
"cc": data.cc,
|
||||
"bcc": data.bcc,
|
||||
"replyTo": data.replyTo,
|
||||
"priority": data.priority,
|
||||
"headers": data.headers
|
||||
}
|
||||
- script: send-email.js
|
||||
config:
|
||||
service: Gmail
|
||||
user: elevent16th@gmail.com
|
||||
from: elevent16th@gmail.com
|
||||
passwordSecret: gmail_app_password
|
||||
triggers:
|
||||
- type: workflow
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /send-gmail
|
||||
auth: basic-auth
|
||||
@@ -1,20 +0,0 @@
|
||||
name: Test MinIO
|
||||
scripts:
|
||||
- script: fetch-binary.js
|
||||
config:
|
||||
outputVar: file
|
||||
url: https://nsrb:error403@dav.0dev.web.id/ntfy/IyM9784UdG4S
|
||||
filename: test.png
|
||||
- script: s3.js
|
||||
config:
|
||||
action: write
|
||||
endpoint: http://localhost:9000
|
||||
bucket: default
|
||||
forcePathStyle: true
|
||||
accessKeyIdSecret: minio_user
|
||||
secretAccessKeySecret: minio_pass
|
||||
key: file.png
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /new
|
||||
@@ -1,18 +0,0 @@
|
||||
name: test-send-gmail
|
||||
description: |
|
||||
Kick send-gmail with sample data. Edit the payload below, then Run
|
||||
(or POST /u/default/test-send-gmail).
|
||||
scripts:
|
||||
- script: trigger-workflow.js
|
||||
config:
|
||||
name: send-gmail
|
||||
expression: |
|
||||
{
|
||||
"subject": "JerapahFlow test",
|
||||
"text": "Hello from test-send-gmail",
|
||||
"html": "<p>Hello from <strong>test-send-gmail</strong></p>"
|
||||
}
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /test-send-gmail
|
||||
@@ -1,22 +0,0 @@
|
||||
name: SFTP Test
|
||||
scripts:
|
||||
- script: remote-fs.js
|
||||
config:
|
||||
protocol: sftp
|
||||
action: list
|
||||
host: localhost
|
||||
path: /Users/nsrb/
|
||||
username: nsrb
|
||||
port: 22
|
||||
password: JKLjkl
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: '{"message": $join(data.entries.name, "\n")}'
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: $VAR_ntfy_channel
|
||||
fingerprint: "false"
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /new
|
||||
@@ -1,12 +0,0 @@
|
||||
name: jsonata
|
||||
scripts:
|
||||
- get-current-time.js
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: '{"message": data.datetime & " " & data.processId}'
|
||||
- ntfy.js
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /mt
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
name: time and comic to ntfy
|
||||
description: >
|
||||
Fan-in from two scripts (current time + monkeyuser comic), then send to ntfy.
|
||||
scripts:
|
||||
- id: time
|
||||
script: get-current-time.js
|
||||
|
||||
- id: comic
|
||||
script: fetch-html.js
|
||||
config:
|
||||
url: "https://www.monkeyuser.com/"
|
||||
outputVar: "httpResponse"
|
||||
selector: ".comic img"
|
||||
jsonata: |
|
||||
{"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]}
|
||||
|
||||
- id: compose
|
||||
script: jsonata.js
|
||||
needs: [time, comic]
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.comic.httpResponse.title,
|
||||
"message": data.time.datetime & " " & data.comic.httpResponse.title,
|
||||
"attach": data.comic.httpResponse.url
|
||||
}
|
||||
|
||||
- id: notify
|
||||
script: ntfy.js
|
||||
needs: [compose]
|
||||
config:
|
||||
url: https://ntfy.sh/jerapah-flow
|
||||
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /time-and-comic
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user